From 1ab9ea295d1d6101699451835a12bba63e9fcb71 Mon Sep 17 00:00:00 2001 From: suyao Date: Mon, 12 May 2025 18:14:19 +0800 Subject: [PATCH 01/16] fix: move start_time_millsec initialization to onChunk for accurate timing --- src/renderer/src/providers/AiProvider/AnthropicProvider.ts | 2 +- src/renderer/src/providers/AiProvider/GeminiProvider.ts | 2 +- .../src/providers/AiProvider/OpenAICompatibleProvider.ts | 2 +- src/renderer/src/providers/AiProvider/OpenAIProvider.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/providers/AiProvider/AnthropicProvider.ts b/src/renderer/src/providers/AiProvider/AnthropicProvider.ts index 3f2929bdd0..2bfd4b6fb5 100644 --- a/src/renderer/src/providers/AiProvider/AnthropicProvider.ts +++ b/src/renderer/src/providers/AiProvider/AnthropicProvider.ts @@ -290,7 +290,6 @@ export default class AnthropicProvider extends BaseProvider { const processStream = async (body: MessageCreateParamsNonStreaming, idx: number) => { let time_first_token_millsec = 0 - const start_time_millsec = new Date().getTime() if (!streamOutput) { const message = await this.sdk.messages.create({ ...body, stream: false }) @@ -484,6 +483,7 @@ export default class AnthropicProvider extends BaseProvider { }) } onChunk({ type: ChunkType.LLM_RESPONSE_CREATED }) + const start_time_millsec = new Date().getTime() await processStream(body, 0).finally(cleanup) } diff --git a/src/renderer/src/providers/AiProvider/GeminiProvider.ts b/src/renderer/src/providers/AiProvider/GeminiProvider.ts index 0a49ebe573..32c964fda8 100644 --- a/src/renderer/src/providers/AiProvider/GeminiProvider.ts +++ b/src/renderer/src/providers/AiProvider/GeminiProvider.ts @@ -500,7 +500,6 @@ export default class GeminiProvider extends BaseProvider { let functionCalls: FunctionCall[] = [] let time_first_token_millsec = 0 - const start_time_millsec = new Date().getTime() if (stream instanceof GenerateContentResponse) { let content = '' @@ -647,6 +646,7 @@ export default class GeminiProvider extends BaseProvider { } onChunk({ type: ChunkType.LLM_RESPONSE_CREATED }) + const start_time_millsec = new Date().getTime() const userMessagesStream = await chat.sendMessageStream({ message: messageContents as PartUnion, config: { diff --git a/src/renderer/src/providers/AiProvider/OpenAICompatibleProvider.ts b/src/renderer/src/providers/AiProvider/OpenAICompatibleProvider.ts index 54df45ed98..c793f5ae2f 100644 --- a/src/renderer/src/providers/AiProvider/OpenAICompatibleProvider.ts +++ b/src/renderer/src/providers/AiProvider/OpenAICompatibleProvider.ts @@ -518,7 +518,6 @@ export default class OpenAICompatibleProvider extends BaseOpenAiProvider { const processStream = async (stream: any, idx: number) => { const toolCalls: ChatCompletionMessageToolCall[] = [] let time_first_token_millsec = 0 - const start_time_millsec = new Date().getTime() // Handle non-streaming case (already returns early, no change needed here) if (!isSupportStreamOutput()) { @@ -831,6 +830,7 @@ export default class OpenAICompatibleProvider extends BaseOpenAiProvider { reqMessages = processReqMessages(model, reqMessages) // 等待接口返回流 onChunk({ type: ChunkType.LLM_RESPONSE_CREATED }) + const start_time_millsec = new Date().getTime() const stream = await this.sdk.chat.completions // @ts-ignore key is not typed .create( diff --git a/src/renderer/src/providers/AiProvider/OpenAIProvider.ts b/src/renderer/src/providers/AiProvider/OpenAIProvider.ts index 154b1a7357..01b06d5d35 100644 --- a/src/renderer/src/providers/AiProvider/OpenAIProvider.ts +++ b/src/renderer/src/providers/AiProvider/OpenAIProvider.ts @@ -567,7 +567,6 @@ export abstract class BaseOpenAiProvider extends BaseProvider { ) => { const toolCalls: OpenAI.Responses.ResponseFunctionToolCall[] = [] let time_first_token_millsec = 0 - const start_time_millsec = new Date().getTime() if (!streamOutput) { const nonStream = stream as OpenAI.Responses.Response @@ -785,6 +784,7 @@ export abstract class BaseOpenAiProvider extends BaseProvider { } onChunk({ type: ChunkType.LLM_RESPONSE_CREATED }) + const start_time_millsec = new Date().getTime() const stream = await this.sdk.responses.create( { model: model.id, From 657c3148cb0fd386329328491987c89962950f89 Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Mon, 12 May 2025 20:28:51 +0800 Subject: [PATCH 02/16] fix: regenerate message use assistant model --- src/renderer/src/pages/home/Messages/Blocks/ErrorBlock.tsx | 5 ++++- .../src/pages/home/Messages/Blocks/ThinkingBlock.tsx | 1 - src/renderer/src/store/thunk/messageThunk.ts | 5 ++++- src/renderer/src/utils/messageUtils/create.ts | 2 +- 4 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/pages/home/Messages/Blocks/ErrorBlock.tsx b/src/renderer/src/pages/home/Messages/Blocks/ErrorBlock.tsx index f55a3ab6b1..5a1f597ede 100644 --- a/src/renderer/src/pages/home/Messages/Blocks/ErrorBlock.tsx +++ b/src/renderer/src/pages/home/Messages/Blocks/ErrorBlock.tsx @@ -11,22 +11,25 @@ interface Props { const ErrorBlock: React.FC = ({ block }) => { return } + const MessageErrorInfo: React.FC<{ block: ErrorMessageBlock }> = ({ block }) => { const { t, i18n } = useTranslation() const HTTP_ERROR_CODES = [400, 401, 403, 404, 429, 500, 502, 503, 504] + if (block.error && HTTP_ERROR_CODES.includes(block.error?.status)) { return } + if (block?.error?.message) { const errorKey = `error.${block.error.message}` const pauseErrorLanguagePlaceholder = i18n.exists(errorKey) ? t(errorKey) : block.error.message - return } return } + const Alert = styled(AntdAlert)` margin: 15px 0 8px; padding: 10px; diff --git a/src/renderer/src/pages/home/Messages/Blocks/ThinkingBlock.tsx b/src/renderer/src/pages/home/Messages/Blocks/ThinkingBlock.tsx index caf7d3f764..7e27b8fcee 100644 --- a/src/renderer/src/pages/home/Messages/Blocks/ThinkingBlock.tsx +++ b/src/renderer/src/pages/home/Messages/Blocks/ThinkingBlock.tsx @@ -155,7 +155,6 @@ const ThinkingBlock: React.FC = ({ block }) => { const CollapseContainer = styled(Collapse)` margin-bottom: 15px; - max-width: 960px; ` const MessageTitleLabel = styled.div` diff --git a/src/renderer/src/store/thunk/messageThunk.ts b/src/renderer/src/store/thunk/messageThunk.ts index aafe8bc652..77dc38a09c 100644 --- a/src/renderer/src/store/thunk/messageThunk.ts +++ b/src/renderer/src/store/thunk/messageThunk.ts @@ -985,8 +985,11 @@ export const regenerateAssistantResponseThunk = // 5. Reset the message entity in Redux const resetAssistantMsg = resetAssistantMessage(messageToResetEntity, { status: AssistantMessageStatus.PENDING, - updatedAt: new Date().toISOString() + updatedAt: new Date().toISOString(), + model: assistant.model, + modelId: assistant?.model?.id }) + dispatch( newMessagesActions.updateMessage({ topicId, diff --git a/src/renderer/src/utils/messageUtils/create.ts b/src/renderer/src/utils/messageUtils/create.ts index c9b3bbbfd4..1a7450aad7 100644 --- a/src/renderer/src/utils/messageUtils/create.ts +++ b/src/renderer/src/utils/messageUtils/create.ts @@ -388,7 +388,7 @@ export function resetMessage( */ export const resetAssistantMessage = ( originalMessage: Message, - updates?: Partial> // Primarily allow updating status + updates?: Partial> // Primarily allow updating status ): Message => { // Ensure we are only resetting assistant messages if (originalMessage.role !== 'assistant') { From 5200dcfef68982aff63ecf2d2fc5cb2949cf1e18 Mon Sep 17 00:00:00 2001 From: "saica.go" Date: Mon, 12 May 2025 21:48:53 +0800 Subject: [PATCH 03/16] feat: add undo functionality to agent prompt generation (#5821) * feat: add undo functionality to agent prompt generation * feat: add a standalone undo button --- .../pages/agents/components/AddAgentPopup.tsx | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/pages/agents/components/AddAgentPopup.tsx b/src/renderer/src/pages/agents/components/AddAgentPopup.tsx index 05f9dc872e..eeca7a39c8 100644 --- a/src/renderer/src/pages/agents/components/AddAgentPopup.tsx +++ b/src/renderer/src/pages/agents/components/AddAgentPopup.tsx @@ -1,6 +1,6 @@ import 'emoji-picker-element' -import { CheckOutlined, LoadingOutlined, ThunderboltOutlined } from '@ant-design/icons' +import { CheckOutlined, LoadingOutlined, ThunderboltOutlined, RollbackOutlined } from '@ant-design/icons' import EmojiPicker from '@renderer/components/EmojiPicker' import { TopView } from '@renderer/components/TopView' import { AGENT_PROMPT } from '@renderer/config/prompts' @@ -38,6 +38,8 @@ const PopupContainer: React.FC = ({ resolve }) => { const formRef = useRef(null) const [emoji, setEmoji] = useState('') const [loading, setLoading] = useState(false) + const [showUndoButton, setShowUndoButton] = useState(false) + const [originalPrompt, setOriginalPrompt] = useState('') const [tokenCount, setTokenCount] = useState(0) const knowledgeState = useAppSelector((state) => state.knowledge) const showKnowledgeIcon = useSidebarIconShow('knowledge') @@ -98,7 +100,7 @@ const PopupContainer: React.FC = ({ resolve }) => { resolve(null) } - const handleButtonClick = async () => { + const handleGenerateButtonClick = async () => { const name = formRef.current?.getFieldValue('name') const content = formRef.current?.getFieldValue('prompt') const promptText = content || name @@ -112,6 +114,7 @@ const PopupContainer: React.FC = ({ resolve }) => { } setLoading(true) + setShowUndoButton(false) try { const generatedText = await fetchGenerate({ @@ -119,6 +122,8 @@ const PopupContainer: React.FC = ({ resolve }) => { content: promptText }) form.setFieldsValue({ prompt: generatedText }) + setShowUndoButton(true) + setOriginalPrompt(content) } catch (error) { console.error('Error fetching data:', error) } @@ -126,6 +131,11 @@ const PopupContainer: React.FC = ({ resolve }) => { setLoading(false) } + const handleUndoButtonClick = async () => { + form.setFieldsValue({ prompt: originalPrompt }) + setShowUndoButton(false) + } + // Compute label width based on the longest label const labelWidth = [t('agents.add.name'), t('agents.add.prompt'), t('agents.add.knowledge_base')] .map((labelText) => stringWidth(labelText) * 8) @@ -155,6 +165,7 @@ const PopupContainer: React.FC = ({ resolve }) => { if (changedValues.prompt) { const count = await estimateTextTokens(changedValues.prompt) setTokenCount(count) + setShowUndoButton(false) } }}> @@ -176,10 +187,15 @@ const PopupContainer: React.FC = ({ resolve }) => { Tokens: {tokenCount} + ) + }, [isExpanded, t, toggleExpand]) + + return ( + + {text} + {button} + + ) +} + +const Container = styled.div<{ $expanded?: boolean }>` + display: flex; + flex-direction: ${(props) => (props.$expanded ? 'column' : 'row')}; +` + +const TextContainer = styled.div<{ $expanded?: boolean }>` + overflow: hidden; + text-overflow: ${(props) => (props.$expanded ? 'unset' : 'ellipsis')}; + white-space: ${(props) => (props.$expanded ? 'normal' : 'nowrap')}; + line-height: ${(props) => (props.$expanded ? 'unset' : '30px')}; +` + +export default memo(ExpandableText) diff --git a/src/renderer/src/components/ModelIdWithTags.tsx b/src/renderer/src/components/ModelIdWithTags.tsx new file mode 100644 index 0000000000..cfd109d0aa --- /dev/null +++ b/src/renderer/src/components/ModelIdWithTags.tsx @@ -0,0 +1,64 @@ +import { Model } from '@renderer/types' +import { Tooltip, Typography } from 'antd' +import { memo } from 'react' +import styled from 'styled-components' + +import ModelTagsWithLabel from './ModelTagsWithLabel' + +interface ModelIdWithTagsProps { + model: Model + fontSize?: number + style?: React.CSSProperties +} + +const ModelIdWithTags = ({ + ref, + model, + fontSize = 14, + style +}: ModelIdWithTagsProps & { ref?: React.RefObject | null }) => { + return ( + + + {model.id} + + } + mouseEnterDelay={0.5} + placement="top"> + {model.name} + + + + ) +} + +const ListItemName = styled.div<{ $fontSize?: number }>` + display: flex; + align-items: center; + flex-direction: row; + gap: 10px; + color: var(--color-text); + line-height: 1; + font-weight: 600; + font-size: ${(props) => props.$fontSize}px; +` + +const NameSpan = styled.span` + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + cursor: help; + font-family: 'Ubuntu'; + line-height: 30px; +` + +export default memo(ModelIdWithTags) diff --git a/src/renderer/src/components/ModelTags.tsx b/src/renderer/src/components/ModelTags.tsx deleted file mode 100644 index 4c683bff58..0000000000 --- a/src/renderer/src/components/ModelTags.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { - isEmbeddingModel, - isFunctionCallingModel, - isReasoningModel, - isRerankModel, - isVisionModel, - isWebSearchModel -} from '@renderer/config/models' -import { Model } from '@renderer/types' -import { isFreeModel } from '@renderer/utils' -import { Tag } from 'antd' -import { FC } from 'react' -import { useTranslation } from 'react-i18next' -import styled from 'styled-components' - -import ReasoningIcon from './Icons/ReasoningIcon' -import ToolsCallingIcon from './Icons/ToolsCallingIcon' -import VisionIcon from './Icons/VisionIcon' -import WebSearchIcon from './Icons/WebSearchIcon' - -interface ModelTagsProps { - model: Model - showFree?: boolean - showReasoning?: boolean - showToolsCalling?: boolean -} - -const ModelTags: FC = ({ model, showFree = true, showReasoning = true, showToolsCalling = true }) => { - const { t } = useTranslation() - return ( - - {isVisionModel(model) && } - {isWebSearchModel(model) && } - {showReasoning && isReasoningModel(model) && } - {showToolsCalling && isFunctionCallingModel(model) && } - {isEmbeddingModel(model) && {t('models.type.embedding')}} - {showFree && isFreeModel(model) && {t('models.type.free')}} - {isRerankModel(model) && {t('models.type.rerank')}} - - ) -} - -const Container = styled.div` - display: flex; - flex-direction: row; - align-items: center; - justify-content: space-between; - gap: 2px; -` - -export default ModelTags diff --git a/src/renderer/src/pages/home/Messages/Blocks/ThinkingBlock.tsx b/src/renderer/src/pages/home/Messages/Blocks/ThinkingBlock.tsx index f920000c2f..212ee53ee9 100644 --- a/src/renderer/src/pages/home/Messages/Blocks/ThinkingBlock.tsx +++ b/src/renderer/src/pages/home/Messages/Blocks/ThinkingBlock.tsx @@ -1,6 +1,7 @@ import { CheckOutlined } from '@ant-design/icons' import { useSettings } from '@renderer/hooks/useSettings' import { MessageBlockStatus, type ThinkingMessageBlock } from '@renderer/types/newMessage' +import { lightbulbVariants } from '@renderer/utils/motionVariants' import { Collapse, message as antdMessage, Tooltip } from 'antd' import { Lightbulb } from 'lucide-react' import { motion } from 'motion/react' @@ -10,27 +11,6 @@ import styled from 'styled-components' import Markdown from '../../Markdown/Markdown' -// Define variants outside the component if they don't depend on component's props/state directly -// or inside if they do (though for this case, outside is fine). -const lightbulbVariants = { - thinking: { - opacity: [1, 0.2, 1], - transition: { - duration: 1.2, - ease: 'easeInOut', - times: [0, 0.5, 1], - repeat: Infinity - } - }, - idle: { - opacity: 1, - transition: { - duration: 0.3, // Smooth transition to idle state - ease: 'easeInOut' - } - } -} - interface Props { block: ThinkingMessageBlock } @@ -116,7 +96,7 @@ const ThinkingBlock: React.FC = ({ block }) => { diff --git a/src/renderer/src/pages/settings/ProviderSettings/EditModelsPopup.tsx b/src/renderer/src/pages/settings/ProviderSettings/EditModelsPopup.tsx index 1c5d189bd3..4ff54fa55a 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/EditModelsPopup.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/EditModelsPopup.tsx @@ -1,7 +1,8 @@ -import { LoadingOutlined, MinusOutlined, PlusOutlined } from '@ant-design/icons' +import { MinusOutlined, PlusOutlined } from '@ant-design/icons' import CustomCollapse from '@renderer/components/CustomCollapse' import CustomTag from '@renderer/components/CustomTag' -import ModelTagsWithLabel from '@renderer/components/ModelTagsWithLabel' +import ExpandableText from '@renderer/components/ExpandableText' +import ModelIdWithTags from '@renderer/components/ModelIdWithTags' import { getModelLogo, groupQwenModels, @@ -18,11 +19,12 @@ import FileItem from '@renderer/pages/files/FileItem' import { fetchModels } from '@renderer/services/ApiService' import { Model, Provider } from '@renderer/types' import { getDefaultGroupName, isFreeModel, runAsyncFunction } from '@renderer/utils' -import { Avatar, Button, Empty, Flex, Modal, Tabs, Tooltip, Typography } from 'antd' +import { Avatar, Button, Empty, Flex, Modal, Spin, Tabs, Tooltip } from 'antd' import Input from 'antd/es/input/Input' import { groupBy, isEmpty, uniqBy } from 'lodash' +import { debounce } from 'lodash' import { Search } from 'lucide-react' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { memo, useCallback, useEffect, useMemo, useOptimistic, useRef, useState, useTransition } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -47,7 +49,28 @@ const PopupContainer: React.FC = ({ provider: _provider, resolve }) => { const [listModels, setListModels] = useState([]) const [loading, setLoading] = useState(false) const [searchText, setSearchText] = useState('') - const [filterType, setFilterType] = useState('all') + const [filterSearchText, setFilterSearchText] = useState('') + const debouncedSetFilterText = useMemo( + () => + debounce((value: string) => { + startSearchTransition(() => { + setFilterSearchText(value) + }) + }, 300), + [] + ) + useEffect(() => { + return () => { + debouncedSetFilterText.cancel() + } + }, [debouncedSetFilterText]) + const [actualFilterType, setActualFilterType] = useState('all') + const [optimisticFilterType, setOptimisticFilterTypeFn] = useOptimistic( + actualFilterType, + (_currentFilterType, newFilterType: string) => newFilterType + ) + const [isSearchPending, startSearchTransition] = useTransition() + const [isFilterTypePending, startFilterTypeTransition] = useTransition() const { t, i18n } = useTranslation() const searchInputRef = useRef(null) @@ -56,14 +79,14 @@ const PopupContainer: React.FC = ({ provider: _provider, resolve }) => { const list = allModels.filter((model) => { if ( - searchText && - !model.id.toLocaleLowerCase().includes(searchText.toLocaleLowerCase()) && - !model.name?.toLocaleLowerCase().includes(searchText.toLocaleLowerCase()) + filterSearchText && + !model.id.toLocaleLowerCase().includes(filterSearchText.toLocaleLowerCase()) && + !model.name?.toLocaleLowerCase().includes(filterSearchText.toLocaleLowerCase()) ) { return false } - switch (filterType) { + switch (actualFilterType) { case 'reasoning': return isReasoningModel(model) case 'vision': @@ -133,9 +156,10 @@ const PopupContainer: React.FC = ({ provider: _provider, resolve }) => { })) .filter((model) => !isEmpty(model.name)) ) - setLoading(false) } catch (error) { - setLoading(false) + console.error('Failed to fetch models', error) + } finally { + setTimeout(() => setLoading(false), 300) } }) // eslint-disable-next-line react-hooks/exhaustive-deps @@ -145,7 +169,7 @@ const PopupContainer: React.FC = ({ provider: _provider, resolve }) => { if (open && searchInputRef.current) { setTimeout(() => { searchInputRef.current?.focus() - }, 100) + }, 350) } }, [open]) @@ -157,7 +181,6 @@ const PopupContainer: React.FC = ({ provider: _provider, resolve }) => { {i18n.language.startsWith('zh') ? '' : ' '} {t('common.models')} - {loading && } ) } @@ -170,6 +193,7 @@ const PopupContainer: React.FC = ({ provider: _provider, resolve }) => { title={ isAllFilteredInProvider ? t('settings.models.manage.remove_listed') : t('settings.models.manage.add_listed') } + mouseEnterDelay={0.5} placement="top">