From 058ea072656bab51d161cd6e9ce551876a81f605 Mon Sep 17 00:00:00 2001 From: fullex <0xfullex@gmail.com> Date: Mon, 12 May 2025 12:42:55 +0800 Subject: [PATCH 01/50] chore: remove bufferutil dependency from package.json and yarn.lock --- package.json | 1 - yarn.lock | 22 ---------------------- 2 files changed, 23 deletions(-) diff --git a/package.json b/package.json index 8c6cab3f29..306312cda3 100644 --- a/package.json +++ b/package.json @@ -76,7 +76,6 @@ "adm-zip": "^0.5.16", "archiver": "^7.0.1", "async-mutex": "^0.5.0", - "bufferutil": "^4.0.9", "color": "^5.0.0", "diff": "^7.0.0", "docx": "^9.0.2", diff --git a/yarn.lock b/yarn.lock index ab86ce3037..05068932fd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4400,7 +4400,6 @@ __metadata: axios: "npm:^1.7.3" babel-plugin-styled-components: "npm:^2.1.4" browser-image-compression: "npm:^2.0.2" - bufferutil: "npm:^4.0.9" color: "npm:^5.0.0" dayjs: "npm:^1.11.11" dexie: "npm:^4.0.8" @@ -5323,16 +5322,6 @@ __metadata: languageName: node linkType: hard -"bufferutil@npm:^4.0.9": - version: 4.0.9 - resolution: "bufferutil@npm:4.0.9" - dependencies: - node-gyp: "npm:latest" - node-gyp-build: "npm:^4.3.0" - checksum: 10c0/f8a93279fc9bdcf32b42eba97edc672b39ca0fe5c55a8596099886cffc76ea9dd78e0f6f51ecee3b5ee06d2d564aa587036b5d4ea39b8b5ac797262a363cdf7d - languageName: node - linkType: hard - "builder-util-runtime@npm:9.3.2": version: 9.3.2 resolution: "builder-util-runtime@npm:9.3.2" @@ -12621,17 +12610,6 @@ __metadata: languageName: node linkType: hard -"node-gyp-build@npm:^4.3.0": - version: 4.8.4 - resolution: "node-gyp-build@npm:4.8.4" - bin: - node-gyp-build: bin.js - node-gyp-build-optional: optional.js - node-gyp-build-test: build-test.js - checksum: 10c0/444e189907ece2081fe60e75368784f7782cfddb554b60123743dfb89509df89f1f29c03bbfa16b3a3e0be3f48799a4783f487da6203245fa5bed239ba7407e1 - languageName: node - linkType: hard - "node-gyp@npm:^9.1.0": version: 9.4.1 resolution: "node-gyp@npm:9.4.1" From 932cd84d3b51a1572cc6049e1ff3bd51ad2ea291 Mon Sep 17 00:00:00 2001 From: jwcrystal <121911854+jwcrystal@users.noreply.github.com> Date: Mon, 12 May 2025 14:55:23 +0800 Subject: [PATCH 02/50] fix: fix the formating error on qwen3 (#5899) fix(ModelMessageService): fix the formating error on qwen3 --- src/renderer/src/services/ModelMessageService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/src/services/ModelMessageService.ts b/src/renderer/src/services/ModelMessageService.ts index 4e9c1d5729..b48543ea19 100644 --- a/src/renderer/src/services/ModelMessageService.ts +++ b/src/renderer/src/services/ModelMessageService.ts @@ -57,7 +57,7 @@ export function processPostsuffixQwen3Model( } else { // 思考模式未启用,添加 postsuffix if (!content.endsWith(postsuffix)) { - return content + postsuffix + return content + ' ' + postsuffix } } } else if (Array.isArray(content)) { From 2782744a859002513ec4327362e77845ec9c32bf Mon Sep 17 00:00:00 2001 From: Wang Jiyuan <59059173+EurFelux@users.noreply.github.com> Date: Mon, 12 May 2025 18:19:03 +0800 Subject: [PATCH 03/50] feat: minimize token usage when testing model (#5905) --- src/renderer/src/providers/AiProvider/AnthropicProvider.ts | 2 +- src/renderer/src/providers/AiProvider/GeminiProvider.ts | 4 ++-- .../src/providers/AiProvider/OpenAICompatibleProvider.ts | 3 +++ src/renderer/src/providers/AiProvider/OpenAIProvider.ts | 4 +++- 4 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/providers/AiProvider/AnthropicProvider.ts b/src/renderer/src/providers/AiProvider/AnthropicProvider.ts index 3f2929bdd0..c7cc3b37ed 100644 --- a/src/renderer/src/providers/AiProvider/AnthropicProvider.ts +++ b/src/renderer/src/providers/AiProvider/AnthropicProvider.ts @@ -678,7 +678,7 @@ export default class AnthropicProvider extends BaseProvider { const body = { model: model.id, messages: [{ role: 'user' as const, content: 'hi' }], - max_tokens: 100, + max_tokens: 2, // api文档写的 x>1 stream } diff --git a/src/renderer/src/providers/AiProvider/GeminiProvider.ts b/src/renderer/src/providers/AiProvider/GeminiProvider.ts index 0a49ebe573..e45dc134bd 100644 --- a/src/renderer/src/providers/AiProvider/GeminiProvider.ts +++ b/src/renderer/src/providers/AiProvider/GeminiProvider.ts @@ -916,7 +916,7 @@ export default class GeminiProvider extends BaseProvider { model: model.id, contents: [{ role: 'user', parts: [{ text: 'hi' }] }], config: { - maxOutputTokens: 100 + maxOutputTokens: 1 } }) if (isEmpty(result.text)) { @@ -927,7 +927,7 @@ export default class GeminiProvider extends BaseProvider { model: model.id, contents: [{ role: 'user', parts: [{ text: 'hi' }] }], config: { - maxOutputTokens: 100 + maxOutputTokens: 1 } }) // 等待整个流式响应结束 diff --git a/src/renderer/src/providers/AiProvider/OpenAICompatibleProvider.ts b/src/renderer/src/providers/AiProvider/OpenAICompatibleProvider.ts index 54df45ed98..33675a0885 100644 --- a/src/renderer/src/providers/AiProvider/OpenAICompatibleProvider.ts +++ b/src/renderer/src/providers/AiProvider/OpenAICompatibleProvider.ts @@ -1112,6 +1112,9 @@ export default class OpenAICompatibleProvider extends BaseOpenAiProvider { const body = { model: model.id, messages: [{ role: 'user', content: 'hi' }], + max_completion_tokens: 1, // openAI + max_tokens: 1, // openAI deprecated 但大部分OpenAI兼容的提供商继续用这个头 + enable_thinking: false, // qwen3 stream } diff --git a/src/renderer/src/providers/AiProvider/OpenAIProvider.ts b/src/renderer/src/providers/AiProvider/OpenAIProvider.ts index 154b1a7357..122f87888d 100644 --- a/src/renderer/src/providers/AiProvider/OpenAIProvider.ts +++ b/src/renderer/src/providers/AiProvider/OpenAIProvider.ts @@ -1026,6 +1026,7 @@ export abstract class BaseOpenAiProvider extends BaseProvider { const response = await this.sdk.responses.create({ model: model.id, input: [{ role: 'user', content: 'hi' }], + max_output_tokens: 1, stream: true }) let hasContent = false @@ -1042,7 +1043,8 @@ export abstract class BaseOpenAiProvider extends BaseProvider { const response = await this.sdk.responses.create({ model: model.id, input: [{ role: 'user', content: 'hi' }], - stream: false + stream: false, + max_output_tokens: 1 }) if (!response.output_text) { throw new Error('Empty response') From 5f3ef42826ac45d412e682ef332e3bd5dce1acda Mon Sep 17 00:00:00 2001 From: suyao Date: Mon, 12 May 2025 18:14:19 +0800 Subject: [PATCH 04/50] 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 c7cc3b37ed..79e7207e72 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 e45dc134bd..be7707c4d2 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 33675a0885..057a1e3e06 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 122f87888d..51f61136b3 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 dd5229d5bae56e29a0c8a9fdf1c9c798335e75c2 Mon Sep 17 00:00:00 2001 From: Chen Tao <70054568+eeee0717@users.noreply.github.com> Date: Mon, 12 May 2025 20:32:28 +0800 Subject: [PATCH 05/50] feat(knowledge): adjust default top-n to 10 (#5919) --- src/main/reranker/BaseReranker.ts | 2 +- .../src/pages/knowledge/components/KnowledgeSettingsPopup.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/reranker/BaseReranker.ts b/src/main/reranker/BaseReranker.ts index 5a8bd6ee2a..a88d0883ae 100644 --- a/src/main/reranker/BaseReranker.ts +++ b/src/main/reranker/BaseReranker.ts @@ -38,7 +38,7 @@ export default abstract class BaseReranker { protected getRerankRequestBody(query: string, searchResults: ExtractChunkData[]) { const provider = this.base.rerankModelProvider const documents = searchResults.map((doc) => doc.pageContent) - const topN = this.base.topN || 5 + const topN = this.base.topN || 10 if (provider === 'voyageai') { return { diff --git a/src/renderer/src/pages/knowledge/components/KnowledgeSettingsPopup.tsx b/src/renderer/src/pages/knowledge/components/KnowledgeSettingsPopup.tsx index bf318fb265..f409990094 100644 --- a/src/renderer/src/pages/knowledge/components/KnowledgeSettingsPopup.tsx +++ b/src/renderer/src/pages/knowledge/components/KnowledgeSettingsPopup.tsx @@ -291,7 +291,7 @@ const PopupContainer: React.FC = ({ base: _base, resolve }) => { rules={[ { validator(_, value) { - if (value && (value < 0 || value > 10)) { + if (value && (value < 0 || value > 30)) { return Promise.reject(new Error(t('knowledge.topN_too_large_or_small'))) } return Promise.resolve() From 0a2d0ec4a8261d9f8555f19af760a28398ab60a5 Mon Sep 17 00:00:00 2001 From: one Date: Mon, 12 May 2025 20:43:45 +0800 Subject: [PATCH 06/50] refactor: SelectModelPopup pinning (#5855) * refactor: focus the hovered item when toggling a pinned model * refactor: focus the selected item after loading pinned models * refactor: update sticky group after loading pinned models * fix: rapidly update sticky group * refactor: defer lastscrolloffset * refactor: rename updateOnListChange to focusOnListChange for clarity * refactor: increaset overscan count * refactor: use startTransition instead of deferred value * refactor: add guard, clean up code * refactor: simplify cleanup logic * refactor: remove unnecessary dep on pinnedModels * fix: flicker on searching * refactor: simplify tag tooltips, prevent tooltips in SelectModelPopup --- src/renderer/src/components/CustomTag.tsx | 15 +- .../src/components/ModelTagsWithLabel.tsx | 22 ++- .../Popups/SelectModelPopup/hook.ts | 5 +- .../Popups/SelectModelPopup/popup.tsx | 138 ++++++++++-------- .../Popups/SelectModelPopup/reducer.ts | 9 +- .../Popups/SelectModelPopup/types.ts | 3 +- 6 files changed, 103 insertions(+), 89 deletions(-) diff --git a/src/renderer/src/components/CustomTag.tsx b/src/renderer/src/components/CustomTag.tsx index 76334ae6cb..c875ba01a4 100644 --- a/src/renderer/src/components/CustomTag.tsx +++ b/src/renderer/src/components/CustomTag.tsx @@ -1,6 +1,6 @@ import { CloseOutlined } from '@ant-design/icons' import { Tooltip } from 'antd' -import { FC, memo } from 'react' +import { FC, memo, useMemo } from 'react' import styled from 'styled-components' interface CustomTagProps { @@ -14,13 +14,22 @@ interface CustomTagProps { } const CustomTag: FC = ({ children, icon, color, size = 12, tooltip, closable = false, onClose }) => { - return ( - + const tagContent = useMemo( + () => ( {icon && icon} {children} {closable && } + ), + [children, closable, color, icon, onClose, size] + ) + + return tooltip ? ( + + {tagContent} + ) : ( + tagContent ) } diff --git a/src/renderer/src/components/ModelTagsWithLabel.tsx b/src/renderer/src/components/ModelTagsWithLabel.tsx index 9e5feb45b0..86a04dd454 100644 --- a/src/renderer/src/components/ModelTagsWithLabel.tsx +++ b/src/renderer/src/components/ModelTagsWithLabel.tsx @@ -23,6 +23,7 @@ interface ModelTagsProps { showToolsCalling?: boolean size?: number showLabel?: boolean + showTooltip?: boolean style?: React.CSSProperties } @@ -33,6 +34,7 @@ const ModelTagsWithLabel: FC = ({ showToolsCalling = true, size = 12, showLabel = true, + showTooltip = true, style }) => { const { t } = useTranslation() @@ -73,7 +75,7 @@ const ModelTagsWithLabel: FC = ({ size={size} color="#00b96b" icon={} - tooltip={t('models.type.vision')}> + tooltip={showTooltip ? t('models.type.vision') : undefined}> {shouldShowLabel ? t('models.type.vision') : ''} )} @@ -82,7 +84,7 @@ const ModelTagsWithLabel: FC = ({ size={size} color="#1677ff" icon={} - tooltip={t('models.type.websearch')}> + tooltip={showTooltip ? t('models.type.websearch') : undefined}> {shouldShowLabel ? t('models.type.websearch') : ''} )} @@ -91,7 +93,7 @@ const ModelTagsWithLabel: FC = ({ size={size} color="#6372bd" icon={} - tooltip={t('models.type.reasoning')}> + tooltip={showTooltip ? t('models.type.reasoning') : undefined}> {shouldShowLabel ? t('models.type.reasoning') : ''} )} @@ -100,19 +102,13 @@ const ModelTagsWithLabel: FC = ({ size={size} color="#f18737" icon={} - tooltip={t('models.type.function_calling')}> + tooltip={showTooltip ? t('models.type.function_calling') : undefined}> {shouldShowLabel ? t('models.type.function_calling') : ''} )} - {isEmbeddingModel(model) && ( - - )} - {showFree && isFreeModel(model) && ( - - )} - {isRerankModel(model) && ( - - )} + {isEmbeddingModel(model) && } + {showFree && isFreeModel(model) && } + {isRerankModel(model) && } ) } diff --git a/src/renderer/src/components/Popups/SelectModelPopup/hook.ts b/src/renderer/src/components/Popups/SelectModelPopup/hook.ts index 93441acb21..4a8206df69 100644 --- a/src/renderer/src/components/Popups/SelectModelPopup/hook.ts +++ b/src/renderer/src/components/Popups/SelectModelPopup/hook.ts @@ -21,9 +21,8 @@ export function useScrollState() { focusPage: (modelItems: FlatListItem[], currentIndex: number, step: number) => dispatch({ type: 'FOCUS_PAGE', payload: { modelItems, currentIndex, step } }), searchChanged: (searchText: string) => dispatch({ type: 'SEARCH_CHANGED', payload: { searchText } }), - updateOnListChange: (modelItems: FlatListItem[]) => - dispatch({ type: 'UPDATE_ON_LIST_CHANGE', payload: { modelItems } }), - initScroll: () => dispatch({ type: 'INIT_SCROLL' }) + focusOnListChange: (modelItems: FlatListItem[]) => + dispatch({ type: 'FOCUS_ON_LIST_CHANGE', payload: { modelItems } }) }), [] ) diff --git a/src/renderer/src/components/Popups/SelectModelPopup/popup.tsx b/src/renderer/src/components/Popups/SelectModelPopup/popup.tsx index febf768189..558b254ae0 100644 --- a/src/renderer/src/components/Popups/SelectModelPopup/popup.tsx +++ b/src/renderer/src/components/Popups/SelectModelPopup/popup.tsx @@ -11,7 +11,16 @@ import { classNames } from '@renderer/utils/style' import { Avatar, Divider, Empty, Input, InputRef, Modal } from 'antd' import { first, sortBy } from 'lodash' import { Search } from 'lucide-react' -import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react' +import { + startTransition, + useCallback, + useDeferredValue, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState +} from 'react' import React from 'react' import { useTranslation } from 'react-i18next' import { FixedSizeList } from 'react-window' @@ -34,7 +43,7 @@ interface Props extends PopupParams { const PopupContainer: React.FC = ({ model, resolve }) => { const { t } = useTranslation() const { providers } = useProviders() - const { pinnedModels, togglePinnedModel, loading: loadingPinnedModels } = usePinnedModels() + const { pinnedModels, togglePinnedModel, loading } = usePinnedModels() const [open, setOpen] = useState(true) const inputRef = useRef(null) const listRef = useRef(null) @@ -49,29 +58,40 @@ const PopupContainer: React.FC = ({ model, resolve }) => { focusedItemKey, scrollTrigger, lastScrollOffset, - stickyGroup: _stickyGroup, + stickyGroup, isMouseOver, - setFocusedItemKey, + setFocusedItemKey: _setFocusedItemKey, setScrollTrigger, - setLastScrollOffset, - setStickyGroup, + setLastScrollOffset: _setLastScrollOffset, + setStickyGroup: _setStickyGroup, setIsMouseOver, focusNextItem, focusPage, searchChanged, - updateOnListChange, - initScroll + focusOnListChange } = useScrollState() - const stickyGroup = useDeferredValue(_stickyGroup) const firstGroupRef = useRef(null) - const togglePin = useCallback( - async (modelId: string) => { - await togglePinnedModel(modelId) - setScrollTrigger('none') // pin操作不触发滚动 + const setFocusedItemKey = useCallback( + (key: string) => { + startTransition(() => _setFocusedItemKey(key)) }, - [togglePinnedModel, setScrollTrigger] + [_setFocusedItemKey] + ) + + const setLastScrollOffset = useCallback( + (offset: number) => { + startTransition(() => _setLastScrollOffset(offset)) + }, + [_setLastScrollOffset] + ) + + const setStickyGroup = useCallback( + (group: FlatListItem | null) => { + startTransition(() => _setStickyGroup(group)) + }, + [_setStickyGroup] ) // 根据输入的文本筛选模型 @@ -89,14 +109,11 @@ const PopupContainer: React.FC = ({ model, resolve }) => { const lowerFullName = fullName.toLowerCase() return keywords.every((keyword) => lowerFullName.includes(keyword)) }) - } else { - // 如果不是搜索状态,过滤掉已固定的模型 - models = models.filter((m) => !pinnedModels.includes(getModelUniqId(m))) } return sortBy(models, ['group', 'name']) }, - [searchText, t, pinnedModels] + [searchText, t] ) // 创建模型列表项 @@ -116,7 +133,7 @@ const PopupContainer: React.FC = ({ model, resolve }) => { ), tags: ( - + ), icon: ( @@ -137,7 +154,7 @@ const PopupContainer: React.FC = ({ model, resolve }) => { const items: FlatListItem[] = [] // 添加置顶模型分组(仅在无搜索文本时) - if (pinnedModels.length > 0 && searchText.length === 0) { + if (searchText.length === 0 && pinnedModels.length > 0) { const pinnedItems = providers.flatMap((p) => p.models.filter((m) => pinnedModels.includes(getModelUniqId(m))).map((m) => createModelItem(m, p, true)) ) @@ -158,7 +175,7 @@ const PopupContainer: React.FC = ({ model, resolve }) => { // 添加常规模型分组 providers.forEach((p) => { const filteredModels = getFilteredModels(p).filter( - (m) => !pinnedModels.includes(getModelUniqId(m)) || searchText.length > 0 + (m) => searchText.length > 0 || !pinnedModels.includes(getModelUniqId(m)) ) if (filteredModels.length === 0) return @@ -198,48 +215,53 @@ const PopupContainer: React.FC = ({ model, resolve }) => { const updateStickyGroup = useCallback( (scrollOffset?: number) => { if (listItems.length === 0) { - setStickyGroup(null) + stickyGroup && setStickyGroup(null) return } + let newStickyGroup: FlatListItem | null = null + // 基于滚动位置计算当前可见的第一个项的索引 const estimatedIndex = Math.floor((scrollOffset ?? lastScrollOffset) / ITEM_HEIGHT) // 从该索引向前查找最近的分组标题 for (let i = estimatedIndex - 1; i >= 0; i--) { if (i < listItems.length && listItems[i]?.type === 'group') { - setStickyGroup(listItems[i]) - return + newStickyGroup = listItems[i] + break } } // 找不到则使用第一个分组标题 - setStickyGroup(firstGroupRef.current) - }, - [listItems, lastScrollOffset, setStickyGroup] - ) + if (!newStickyGroup) newStickyGroup = firstGroupRef.current - // 在listItems变化时更新sticky group - useEffect(() => { - updateStickyGroup() - }, [listItems, updateStickyGroup]) + if (stickyGroup?.key !== newStickyGroup?.key) { + setStickyGroup(newStickyGroup) + } + }, + [listItems, lastScrollOffset, setStickyGroup, stickyGroup] + ) // 处理列表滚动事件,更新lastScrollOffset并更新sticky分组 const handleScroll = useCallback( ({ scrollOffset }) => { setLastScrollOffset(scrollOffset) - updateStickyGroup(scrollOffset) }, - [updateStickyGroup, setLastScrollOffset] + [setLastScrollOffset] ) - // 在列表项更新时,更新焦点项 + // 列表项更新时,更新焦点 useEffect(() => { - updateOnListChange(modelItems) - }, [modelItems, updateOnListChange]) + if (!loading) focusOnListChange(modelItems) + }, [modelItems, focusOnListChange, loading]) + + // 列表项更新时,更新sticky分组 + useEffect(() => { + if (!loading) updateStickyGroup() + }, [modelItems, updateStickyGroup, loading]) // 滚动到聚焦项 - useEffect(() => { + useLayoutEffect(() => { if (scrollTrigger === 'none' || !focusedItemKey) return const index = listItems.findIndex((item) => item.key === focusedItemKey) @@ -302,23 +324,12 @@ const PopupContainer: React.FC = ({ model, resolve }) => { break case 'Escape': e.preventDefault() - setScrollTrigger('none') setOpen(false) resolve(undefined) break } }, - [ - focusedItemKey, - modelItems, - handleItemClick, - open, - resolve, - setIsMouseOver, - focusNextItem, - focusPage, - setScrollTrigger - ] + [focusedItemKey, modelItems, handleItemClick, open, resolve, setIsMouseOver, focusNextItem, focusPage] ) useEffect(() => { @@ -327,11 +338,10 @@ const PopupContainer: React.FC = ({ model, resolve }) => { }, [handleKeyDown]) const onCancel = useCallback(() => { - setScrollTrigger('initial') setOpen(false) - }, [setScrollTrigger]) + }, []) - const onClose = useCallback(async () => { + const onAfterClose = useCallback(async () => { setScrollTrigger('initial') resolve(undefined) SelectModelPopup.hide() @@ -339,10 +349,16 @@ const PopupContainer: React.FC = ({ model, resolve }) => { // 初始化焦点和滚动位置 useEffect(() => { - if (!open || loadingPinnedModels) return + if (!open) return setTimeout(() => inputRef.current?.focus(), 0) - initScroll() - }, [open, initScroll, loadingPinnedModels]) + }, [open]) + + const togglePin = useCallback( + async (modelId: string) => { + await togglePinnedModel(modelId) + }, + [togglePinnedModel] + ) const RowData = useMemo( (): VirtualizedRowData => ({ @@ -365,7 +381,7 @@ const PopupContainer: React.FC = ({ model, resolve }) => { centered open={open} onCancel={onCancel} - afterClose={onClose} + afterClose={onAfterClose} width={600} transitionName="animation-move-down" styles={{ @@ -408,7 +424,7 @@ const PopupContainer: React.FC = ({ model, resolve }) => { {listItems.length > 0 ? ( - !isMouseOver && setIsMouseOver(true)}> + !isMouseOver && startTransition(() => setIsMouseOver(true))}> {/* Sticky Group Banner,它会替换第一个分组名称 */} {stickyGroup?.name} } + const isFocused = item.key === focusedItemKey + return (
{item.type === 'group' ? ( @@ -463,11 +481,11 @@ const VirtualizedRow = React.memo( ) : ( handleItemClick(item)} - onMouseEnter={() => setFocusedItemKey(item.key)}> + onMouseOver={() => !isFocused && setFocusedItemKey(item.key)}> {item.icon} {item.name} diff --git a/src/renderer/src/components/Popups/SelectModelPopup/reducer.ts b/src/renderer/src/components/Popups/SelectModelPopup/reducer.ts index 45e3390ea8..974fc5b509 100644 --- a/src/renderer/src/components/Popups/SelectModelPopup/reducer.ts +++ b/src/renderer/src/components/Popups/SelectModelPopup/reducer.ts @@ -72,7 +72,7 @@ export const scrollReducer = (state: ScrollState, action: ScrollAction): ScrollS scrollTrigger: action.payload.searchText ? 'search' : 'initial' } - case 'UPDATE_ON_LIST_CHANGE': { + case 'FOCUS_ON_LIST_CHANGE': { const { modelItems } = action.payload // 在列表变化时尝试聚焦一个模型: @@ -96,13 +96,6 @@ export const scrollReducer = (state: ScrollState, action: ScrollAction): ScrollS } } - case 'INIT_SCROLL': - return { - ...state, - scrollTrigger: 'initial', - lastScrollOffset: 0 - } - default: return state } diff --git a/src/renderer/src/components/Popups/SelectModelPopup/types.ts b/src/renderer/src/components/Popups/SelectModelPopup/types.ts index 41ec04c583..745e9688bb 100644 --- a/src/renderer/src/components/Popups/SelectModelPopup/types.ts +++ b/src/renderer/src/components/Popups/SelectModelPopup/types.ts @@ -38,5 +38,4 @@ export type ScrollAction = | { type: 'FOCUS_NEXT_ITEM'; payload: { modelItems: FlatListItem[]; step: number } } | { type: 'FOCUS_PAGE'; payload: { modelItems: FlatListItem[]; currentIndex: number; step: number } } | { type: 'SEARCH_CHANGED'; payload: { searchText: string } } - | { type: 'UPDATE_ON_LIST_CHANGE'; payload: { modelItems: FlatListItem[] } } - | { type: 'INIT_SCROLL'; payload?: void } + | { type: 'FOCUS_ON_LIST_CHANGE'; payload: { modelItems: FlatListItem[] } } From 0b45e1fd11de373a480dd250928336aa741f2c2c Mon Sep 17 00:00:00 2001 From: Chen Tao <70054568+eeee0717@users.noreply.github.com> Date: Mon, 12 May 2025 20:49:31 +0800 Subject: [PATCH 07/50] fix: add i18n (#5921) * fix(i18n) * Update en-us.json --- src/renderer/src/i18n/locales/en-us.json | 2 +- src/renderer/src/i18n/locales/ja-jp.json | 2 +- src/renderer/src/i18n/locales/ru-ru.json | 2 +- src/renderer/src/i18n/locales/zh-cn.json | 2 +- src/renderer/src/i18n/locales/zh-tw.json | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 4db5907ac4..e5cc69df61 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -489,7 +489,7 @@ "threshold_tooltip": "Used to evaluate the relevance between the user's question and the content in the knowledge base (0-1)", "title": "Knowledge Base", "topN": "Number of results returned", - "topN__too_large_or_small": "The number of results returned cannot be greater than 100 or less than 1.", + "topN_too_large_or_small": "The number of results returned cannot be greater than 30 or less than 1.", "topN_placeholder": "Not set", "topN_tooltip": "The number of matching results returned; the larger the value, the more matching results, but also the more tokens consumed.", "url_added": "URL added", diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index 6e9ab12361..a403b3292c 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -489,7 +489,7 @@ "threshold_tooltip": "ユーザーの質問と知識ベースの内容の関連性を評価するためのしきい値(0-1)", "title": "ナレッジベース", "topN": "返却される結果の数", - "topN__too_large_or_small": "結果の数は100より大きくてはならず、1より小さくてはなりません。", + "topN_too_large_or_small": "結果の数は30より大きくてはならず、1より小さくてはなりません。", "topN_placeholder": "未設定", "topN_tooltip": "返されるマッチ結果の数は、数値が大きいほどマッチ結果が多くなりますが、消費されるトークンも増えます。", "url_added": "URLが追加されました", diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index 82f03caebb..d0920fd2cf 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -489,7 +489,7 @@ "threshold_tooltip": "Используется для оценки соответствия между пользовательским вопросом и содержимым в базе знаний (0-1)", "title": "База знаний", "topN": "Количество возвращаемых результатов", - "topN__too_large_or_small": "Количество возвращаемых результатов не может быть больше 100 или меньше 1.", + "topN_too_large_or_small": "Количество возвращаемых результатов не может быть больше 30 или меньше 1.", "topN_placeholder": "Не установлено", "topN_tooltip": "Количество возвращаемых совпадений; чем больше значение, тем больше совпадений, но и потребление токенов тоже возрастает.", "url_added": "URL добавлен", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index f3545271d1..85c9e4c485 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -493,7 +493,7 @@ "threshold_tooltip": "用于衡量用户问题与知识库内容之间的相关性(0-1)", "title": "知识库", "topN": "返回结果数量", - "topN__too_large_or_small": "返回结果数量不能大于100或小于1", + "topN_too_large_or_small": "返回结果数量不能大于30或小于1", "topN_placeholder": "未设置", "topN_tooltip": "返回的匹配结果数量,数值越大,匹配结果越多,但消耗的 Token 也越多", "url_added": "网址已添加", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index a342046550..212316a4ac 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -489,7 +489,7 @@ "threshold_tooltip": "用於衡量使用者問題與知識庫內容之間的相關性(0-1)", "title": "知識庫", "topN": "返回結果數量", - "topN__too_large_or_small": "返回結果數量不能大於100或小於1", + "topN_too_large_or_small": "返回結果數量不能大於30或小於1", "topN_placeholder": "未設定", "topN_tooltip": "返回的匹配結果數量,數值越大,匹配結果越多,但消耗的 Token 也越多", "url_added": "網址已新增", From f9611c78e4638c886f1e713a16bea1df469eb4c3 Mon Sep 17 00:00:00 2001 From: George Zhao <38124587+CreatorZZY@users.noreply.github.com> Date: Mon, 12 May 2025 20:58:35 +0800 Subject: [PATCH 08/50] fix: ensure correct handling of custom mini app updates and removals (#5922) * fix: ensure correct handling of custom mini app updates and removals * fix: update title for custom mini app to be more concise in localization files --------- Co-authored-by: George Zhao --- src/renderer/src/i18n/locales/en-us.json | 2 +- src/renderer/src/i18n/locales/zh-cn.json | 2 +- src/renderer/src/i18n/locales/zh-tw.json | 2 +- src/renderer/src/pages/apps/App.tsx | 8 +++++--- 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index e5cc69df61..631f92bc11 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -1148,7 +1148,7 @@ "miniapps": { "title": "Mini Apps Settings", "custom": { - "title": "Custom Mini App", + "title": "Custom", "edit_title": "Edit Custom Mini App", "save_success": "Custom mini app saved successfully.", "save_error": "Failed to save custom mini app.", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 85c9e4c485..6ffb9620c1 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -1154,7 +1154,7 @@ "title": "在浏览器中打开新窗口链接" }, "custom": { - "title": "自定义小程序", + "title": "自定义", "edit_title": "编辑自定义小程序", "save_success": "自定义小程序保存成功。", "save_error": "自定义小程序保存失败。", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 212316a4ac..17667120e0 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -1156,7 +1156,7 @@ "custom": { "duplicate_ids": "發現重複的ID: {{ids}}", "conflicting_ids": "與預設應用ID衝突: {{ids}}", - "title": "自定義小程序", + "title": "自定義", "edit_title": "編輯自定義小程序", "save_success": "自定義小程序保存成功。", "save_error": "自定義小程序保存失敗。", diff --git a/src/renderer/src/pages/apps/App.tsx b/src/renderer/src/pages/apps/App.tsx index 295c77f6cc..506aaded1e 100644 --- a/src/renderer/src/pages/apps/App.tsx +++ b/src/renderer/src/pages/apps/App.tsx @@ -53,7 +53,7 @@ const App: FC = ({ app, onClick, size = 60, isLast }) => { return } - const newApp = { + const newApp: MinAppType = { id: values.id, name: values.name, url: values.url, @@ -70,7 +70,7 @@ const App: FC = ({ app, onClick, size = 60, isLast }) => { // 重新加载应用列表 const reloadedApps = [...ORIGIN_DEFAULT_MIN_APPS, ...(await loadCustomMiniApp())] updateDefaultMinApps(reloadedApps) - updateMinapps(reloadedApps) + updateMinapps([...minapps, newApp]) } catch (error) { message.error(t('settings.miniapps.custom.save_error')) console.error('Failed to save custom mini app:', error) @@ -143,7 +143,9 @@ const App: FC = ({ app, onClick, size = 60, isLast }) => { message.success(t('settings.miniapps.custom.remove_success')) const reloadedApps = [...ORIGIN_DEFAULT_MIN_APPS, ...(await loadCustomMiniApp())] updateDefaultMinApps(reloadedApps) - updateMinapps(reloadedApps) + updateMinapps(minapps.filter((item) => item.id !== app.id)) + updatePinnedMinapps(pinned.filter((item) => item.id !== app.id)) + updateDisabledMinapps(disabled.filter((item) => item.id !== app.id)) } catch (error) { message.error(t('settings.miniapps.custom.remove_error')) console.error('Failed to remove custom mini app:', error) From 10ce47239cc48c00728bfaf26fb033d4f9f5d322 Mon Sep 17 00:00:00 2001 From: beyondkmp Date: Mon, 12 May 2025 21:57:50 +0800 Subject: [PATCH 09/50] fix(ipc): enhance theme handling with title bar overlay updates and broadcast notifications (#5915) feat(ipc): enhance theme handling with title bar overlay updates and broadcast notifications --- src/main/ipc.ts | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/src/main/ipc.ts b/src/main/ipc.ts index d628dd45a5..ecb74a57b4 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -121,11 +121,21 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { // theme ipcMain.handle(IpcChannel.App_SetTheme, (_, theme: ThemeMode) => { + const updateTitleBarOverlay = () => { + if (!mainWindow?.setTitleBarOverlay) return + const isDark = nativeTheme.shouldUseDarkColors + mainWindow.setTitleBarOverlay(isDark ? titleBarOverlayDark : titleBarOverlayLight) + } + + const broadcastThemeChange = () => { + const isDark = nativeTheme.shouldUseDarkColors + const effectiveTheme = isDark ? ThemeMode.dark : ThemeMode.light + BrowserWindow.getAllWindows().forEach((win) => win.webContents.send(IpcChannel.ThemeChange, effectiveTheme)) + } + const notifyThemeChange = () => { - const windows = BrowserWindow.getAllWindows() - windows.forEach((win) => - win.webContents.send(IpcChannel.ThemeChange, nativeTheme.shouldUseDarkColors ? ThemeMode.dark : ThemeMode.light) - ) + updateTitleBarOverlay() + broadcastThemeChange() } if (theme === ThemeMode.auto) { @@ -133,11 +143,10 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { nativeTheme.on('updated', notifyThemeChange) } else { nativeTheme.themeSource = theme - nativeTheme.removeAllListeners('updated') + nativeTheme.off('updated', notifyThemeChange) } - mainWindow?.setTitleBarOverlay && - mainWindow.setTitleBarOverlay(nativeTheme.shouldUseDarkColors ? titleBarOverlayDark : titleBarOverlayLight) + updateTitleBarOverlay() configManager.setTheme(theme) notifyThemeChange() }) From 01439c56d9023c53ca7ce72ee33eae95c9d686d1 Mon Sep 17 00:00:00 2001 From: SuYao Date: Tue, 13 May 2025 08:47:14 +0800 Subject: [PATCH 10/50] fix: timer stop (#5914) --- src/renderer/src/pages/home/Messages/Blocks/ThinkingBlock.tsx | 3 ++- src/renderer/src/store/thunk/messageThunk.ts | 4 ++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/pages/home/Messages/Blocks/ThinkingBlock.tsx b/src/renderer/src/pages/home/Messages/Blocks/ThinkingBlock.tsx index caf7d3f764..252f3d3f7e 100644 --- a/src/renderer/src/pages/home/Messages/Blocks/ThinkingBlock.tsx +++ b/src/renderer/src/pages/home/Messages/Blocks/ThinkingBlock.tsx @@ -89,7 +89,8 @@ const ThinkingBlock: React.FC = ({ block }) => { return () => { if (intervalId.current) { - window.clearInterval(intervalId.current) + clearInterval(intervalId.current) + intervalId.current = null } } }, [isThinking]) diff --git a/src/renderer/src/store/thunk/messageThunk.ts b/src/renderer/src/store/thunk/messageThunk.ts index aafe8bc652..a98b69f39d 100644 --- a/src/renderer/src/store/thunk/messageThunk.ts +++ b/src/renderer/src/store/thunk/messageThunk.ts @@ -348,6 +348,7 @@ const fetchAndProcessAssistantResponseImpl = async ( } }, onTextComplete: async (finalText) => { + cancelThrottledBlockUpdate() if (lastBlockType === MessageBlockType.MAIN_TEXT && lastBlockId) { const changes = { content: finalText, @@ -405,6 +406,8 @@ const fetchAndProcessAssistantResponseImpl = async ( } }, onThinkingComplete: (finalText, final_thinking_millsec) => { + cancelThrottledBlockUpdate() + if (lastBlockType === MessageBlockType.THINKING && lastBlockId) { const changes = { type: MessageBlockType.THINKING, @@ -446,6 +449,7 @@ const fetchAndProcessAssistantResponseImpl = async ( } }, onToolCallComplete: (toolResponse: MCPToolResponse) => { + cancelThrottledBlockUpdate() const existingBlockId = toolCallIdToBlockIdMap.get(toolResponse.id) if (toolResponse.status === 'done' || toolResponse.status === 'error') { if (!existingBlockId) { From a90142f4b106579a50b6cf817c9dfd5a691c73a2 Mon Sep 17 00:00:00 2001 From: jwcrystal <121911854+jwcrystal@users.noreply.github.com> Date: Tue, 13 May 2025 12:20:01 +0800 Subject: [PATCH 11/50] docs: Add Photo instructions to the branch strategy document (#5944) --- docs/branching-strategy.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/branching-strategy.md b/docs/branching-strategy.md index 3196d09fe7..897763af16 100644 --- a/docs/branching-strategy.md +++ b/docs/branching-strategy.md @@ -49,3 +49,4 @@ When contributing to Cherry Studio, please follow these guidelines: - Include relevant issue numbers in your PR description - Make sure all tests pass and code meets our quality standards - Critical hotfixes may be submitted against `main` but must also be merged into `develop` +- Add a photo to show what is different if you add a new feature or modify a component in the UI. From 2ae1069fc289d745f6dec8f9059281d62b7a16f5 Mon Sep 17 00:00:00 2001 From: dlzmoe Date: Tue, 13 May 2025 12:21:12 +0800 Subject: [PATCH 12/50] feat: Optimize the display method for the three modes (#5938) chore: Optimize the display method for the three modes --- src/renderer/src/pages/home/Messages/MessageTokens.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/pages/home/Messages/MessageTokens.tsx b/src/renderer/src/pages/home/Messages/MessageTokens.tsx index 98b4e82732..390b3530df 100644 --- a/src/renderer/src/pages/home/Messages/MessageTokens.tsx +++ b/src/renderer/src/pages/home/Messages/MessageTokens.tsx @@ -44,7 +44,10 @@ const MessgeTokens: React.FC = ({ message }) => { {metrixs} - Tokens: {message?.usage?.total_tokens} ↑{message?.usage?.prompt_tokens} ↓{message?.usage?.completion_tokens} + Tokens: + {message?.usage?.total_tokens} + ↑{message?.usage?.prompt_tokens} + ↓{message?.usage?.completion_tokens} ) @@ -67,6 +70,10 @@ const MessageMetadata = styled.div` .tokens { display: block; + + span { + padding:0 2px; + } } &.has-metrics:hover { From 909acf1da374633534511a84494746aed010a864 Mon Sep 17 00:00:00 2001 From: one Date: Tue, 13 May 2025 13:22:24 +0800 Subject: [PATCH 13/50] fix: animation on resolving SelectModelPopup (#5947) --- .../components/Popups/SelectModelPopup/popup.tsx | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/src/renderer/src/components/Popups/SelectModelPopup/popup.tsx b/src/renderer/src/components/Popups/SelectModelPopup/popup.tsx index 558b254ae0..a02c63bcb2 100644 --- a/src/renderer/src/components/Popups/SelectModelPopup/popup.tsx +++ b/src/renderer/src/components/Popups/SelectModelPopup/popup.tsx @@ -278,12 +278,11 @@ const PopupContainer: React.FC = ({ model, resolve }) => { const handleItemClick = useCallback( (item: FlatListItem) => { if (item.type === 'model') { - setScrollTrigger('initial') resolve(item.model) setOpen(false) } }, - [resolve, setScrollTrigger] + [resolve] ) // 处理键盘导航 @@ -651,16 +650,7 @@ export class SelectModelPopup { static show(params: PopupParams) { return new Promise((resolve) => { - TopView.show( - { - resolve(v) - TopView.hide(TopViewKey) - }} - />, - TopViewKey - ) + TopView.show( resolve(v)} />, TopViewKey) }) } } From ff3d418622e7b715c0f96900c2a4bd9d2bb02957 Mon Sep 17 00:00:00 2001 From: beyondkmp Date: Tue, 13 May 2025 13:41:06 +0800 Subject: [PATCH 14/50] chore: use node-stream-zip to improve perfermanc and remove unused dependencies (#5946) * chore: remove unused dependencies from package.json and yarn.lock * fix: update backup extraction progress logging in BackupManager --------- Co-authored-by: beyondkmp --- package.json | 2 -- src/main/services/BackupManager.ts | 15 +++++---------- yarn.lock | 4 +--- 3 files changed, 6 insertions(+), 15 deletions(-) diff --git a/package.json b/package.json index 306312cda3..2b3483a952 100644 --- a/package.json +++ b/package.json @@ -73,7 +73,6 @@ "@strongtz/win32-arm64-msvc": "^0.4.7", "@tanstack/react-query": "^5.27.0", "@types/react-infinite-scroll-component": "^5.0.0", - "adm-zip": "^0.5.16", "archiver": "^7.0.1", "async-mutex": "^0.5.0", "color": "^5.0.0", @@ -84,7 +83,6 @@ "electron-updater": "6.6.4", "electron-window-state": "^5.0.3", "epub": "patch:epub@npm%3A1.3.0#~/.yarn/patches/epub-npm-1.3.0-8325494ffe.patch", - "extract-zip": "^2.0.1", "fast-xml-parser": "^5.2.0", "fetch-socks": "^1.3.2", "fs-extra": "^11.2.0", diff --git a/src/main/services/BackupManager.ts b/src/main/services/BackupManager.ts index ef96529903..6be19d035b 100644 --- a/src/main/services/BackupManager.ts +++ b/src/main/services/BackupManager.ts @@ -4,7 +4,7 @@ import archiver from 'archiver' import { exec } from 'child_process' import { app } from 'electron' import Logger from 'electron-log' -import extract from 'extract-zip' +import StreamZip from 'node-stream-zip' import * as fs from 'fs-extra' import * as path from 'path' import { createClient, CreateDirectoryOptions, FileStat } from 'webdav' @@ -231,15 +231,10 @@ class BackupManager { Logger.log('[backup] step 1: unzip backup file', this.tempDir) - // 使用 extract-zip 解压 - await extract(backupPath, { - dir: this.tempDir, - onEntry: () => { - // 这里可以处理进度,但 extract-zip 不提供总条目数信息 - onProgress({ stage: 'extracting', progress: 15, total: 100 }) - } - }) - onProgress({ stage: 'extracting', progress: 25, total: 100 }) + const zip = new StreamZip.async({ file: backupPath }) + onProgress({ stage: 'extracting', progress: 15, total: 100 }) + await zip.extract(null, this.tempDir) + onProgress({ stage: 'extracted', progress: 25, total: 100 }) Logger.log('[backup] step 2: read data.json') // 读取 data.json diff --git a/yarn.lock b/yarn.lock index 05068932fd..4d7e8ea9f7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4392,7 +4392,6 @@ __metadata: "@vitest/coverage-v8": "npm:^3.1.1" "@vitest/ui": "npm:^3.1.1" "@xyflow/react": "npm:^12.4.4" - adm-zip: "npm:^0.5.16" antd: "npm:^5.22.5" applescript: "npm:^1.0.0" archiver: "npm:^7.0.1" @@ -4423,7 +4422,6 @@ __metadata: eslint-plugin-react-hooks: "npm:^5.2.0" eslint-plugin-simple-import-sort: "npm:^12.1.1" eslint-plugin-unused-imports: "npm:^4.1.4" - extract-zip: "npm:^2.0.1" fast-xml-parser: "npm:^5.2.0" fetch-socks: "npm:^1.3.2" fs-extra: "npm:^11.2.0" @@ -4540,7 +4538,7 @@ __metadata: languageName: node linkType: hard -"adm-zip@npm:^0.5.16, adm-zip@npm:^0.5.9": +"adm-zip@npm:^0.5.9": version: 0.5.16 resolution: "adm-zip@npm:0.5.16" checksum: 10c0/6f10119d4570c7ba76dcf428abb8d3f69e63f92e51f700a542b43d4c0130373dd2ddfc8f85059f12d4a843703a90c3970cfd17876844b4f3f48bf042bfa6b49f From f8603d0c248d07f4d6ac4cf657310b6ea970063d Mon Sep 17 00:00:00 2001 From: one Date: Tue, 13 May 2025 14:48:59 +0800 Subject: [PATCH 15/50] refactor: improve model management UI, add animations to some buttons (#5932) * feat: add motion to ModelListSearchBar * feat: add motion to health checking button * refactor(EditModelsPopup): show spin while fetching models * refactor: remove redundant filtering, use transient props * chore: remove useless component ModelTags * refactor: extract and reuse ModelIdWithTags * refactor(EditModelsPopup): use ExpandableText instead of expandable Typography.Paragraph * refactor(EditModelsPopup): implement optimistic updates for filter type and loading state * refactor: startTransition for search * refactor(EditModelsPopup): enhance search and filter handling with optimistic updates * refactor(EditModelsPopup): implement debounced search filter updates --------- Co-authored-by: suyao --- .../src/components/ExpandableText.tsx | 51 ++++ .../src/components/ModelIdWithTags.tsx | 64 +++++ src/renderer/src/components/ModelTags.tsx | 51 ---- .../home/Messages/Blocks/ThinkingBlock.tsx | 24 +- .../ProviderSettings/EditModelsPopup.tsx | 224 ++++++++++-------- .../settings/ProviderSettings/ModelList.tsx | 87 ++----- .../ProviderSettings/ModelListSearchBar.tsx | 104 +++++--- .../ProviderSettings/ProviderSetting.tsx | 12 +- src/renderer/src/utils/motionVariants.ts | 18 ++ 9 files changed, 357 insertions(+), 278 deletions(-) create mode 100644 src/renderer/src/components/ExpandableText.tsx create mode 100644 src/renderer/src/components/ModelIdWithTags.tsx delete mode 100644 src/renderer/src/components/ModelTags.tsx create mode 100644 src/renderer/src/utils/motionVariants.ts diff --git a/src/renderer/src/components/ExpandableText.tsx b/src/renderer/src/components/ExpandableText.tsx new file mode 100644 index 0000000000..5df32bb9c6 --- /dev/null +++ b/src/renderer/src/components/ExpandableText.tsx @@ -0,0 +1,51 @@ +import { Button } from 'antd' +import { memo, useCallback, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import styled from 'styled-components' + +interface ExpandableTextProps { + text: string + style?: React.CSSProperties +} + +const ExpandableText = ({ + ref, + text, + style +}: ExpandableTextProps & { ref?: React.RefObject | null }) => { + const { t } = useTranslation() + const [isExpanded, setIsExpanded] = useState(false) + + const toggleExpand = useCallback(() => { + setIsExpanded((prev) => !prev) + }, []) + + const button = useMemo(() => { + return ( + + ) + }, [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 252f3d3f7e..6911250e9b 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">
- ) - }} - /> - ))} - - - ) - })} - {isEmpty(list) && } + {loading || isFilterTypePending || isSearchPending ? ( + + + + ) : ( + Object.keys(modelGroups).map((group, i) => { + return ( + + {group} + + {modelGroups[group].length} + + + } + extra={renderGroupTools(group)}> + + {modelGroups[group].map((model) => ( + + ))} + + + ) + }) + )} + {!(loading || isFilterTypePending || isSearchPending) && isEmpty(list) && ( + + )}
) } +interface ModelListItemProps { + model: Model + provider: Provider + onAddModel: (model: Model) => void + onRemoveModel: (model: Model) => void +} + +const ModelListItem: React.FC = memo(({ model, provider, onAddModel, onRemoveModel }) => { + const isAdded = useMemo(() => isModelInProvider(provider, model.id), [provider, model.id]) + + return ( + {model?.name?.[0]?.toUpperCase()}, + name: , + extra: model.description && , + ext: '.model', + actions: isAdded ? ( + + + + + + {t('settings.about.debug.title')} + + + ) From c0c0faabc8378d18faf2574edc2239947e524d72 Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Tue, 13 May 2025 20:24:19 +0800 Subject: [PATCH 19/50] fix: history topic message block is empty * Added useEffect to dispatch loadTopicMessagesThunk when the topic is available * Integrated useAppDispatch for state management --- .../src/pages/history/components/TopicMessages.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/pages/history/components/TopicMessages.tsx b/src/renderer/src/pages/history/components/TopicMessages.tsx index 6e8095037b..86805dd1af 100644 --- a/src/renderer/src/pages/history/components/TopicMessages.tsx +++ b/src/renderer/src/pages/history/components/TopicMessages.tsx @@ -7,10 +7,12 @@ import { getAssistantById } from '@renderer/services/AssistantService' import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' import { isGenerating, locateToMessage } from '@renderer/services/MessagesService' import NavigationService from '@renderer/services/NavigationService' +import { useAppDispatch } from '@renderer/store' +import { loadTopicMessagesThunk } from '@renderer/store/thunk/messageThunk' import { Topic } from '@renderer/types' import { Button, Divider, Empty } from 'antd' import { t } from 'i18next' -import { FC } from 'react' +import { FC, useEffect } from 'react' import styled from 'styled-components' import { default as MessageItem } from '../../home/Messages/Message' @@ -23,6 +25,11 @@ const TopicMessages: FC = ({ topic, ...props }) => { const navigate = NavigationService.navigate! const { handleScroll, containerRef } = useScrollPosition('TopicMessages') const { messageStyle } = useSettings() + const dispatch = useAppDispatch() + + useEffect(() => { + topic && dispatch(loadTopicMessagesThunk(topic.id)) + }, [dispatch, topic]) const isEmpty = (topic?.messages || []).length === 0 From 483ea46440f968fb402634803e488eb3d69b428c Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Mon, 12 May 2025 20:28:51 +0800 Subject: [PATCH 20/50] 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 6911250e9b..212ee53ee9 100644 --- a/src/renderer/src/pages/home/Messages/Blocks/ThinkingBlock.tsx +++ b/src/renderer/src/pages/home/Messages/Blocks/ThinkingBlock.tsx @@ -136,7 +136,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 a98b69f39d..4e631bba65 100644 --- a/src/renderer/src/store/thunk/messageThunk.ts +++ b/src/renderer/src/store/thunk/messageThunk.ts @@ -989,8 +989,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 396b400004fbf5a3fce8125f264bbae64d5cad16 Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Mon, 12 May 2025 21:56:05 +0800 Subject: [PATCH 21/50] revert: openai compatible type --- src/renderer/src/config/models.ts | 4 +- src/renderer/src/databases/upgrades.ts | 2 +- .../home/Messages/Blocks/MainTextBlock.tsx | 4 +- .../ProviderSettings/AddProviderPopup.tsx | 8 +- .../ProviderSettings/ProviderSetting.tsx | 2 +- .../providers/AiProvider/AihubmixProvider.ts | 6 +- .../AiProvider/OpenAICompatibleProvider.ts | 1220 ------------- .../providers/AiProvider/OpenAIProvider.ts | 1605 ++++++++--------- .../AiProvider/OpenAIResponseProvider.ts | 1265 +++++++++++++ .../providers/AiProvider/ProviderFactory.ts | 15 +- src/renderer/src/store/index.ts | 2 +- src/renderer/src/store/llm.ts | 86 +- src/renderer/src/store/messageBlock.ts | 4 +- src/renderer/src/store/migrate.ts | 17 + src/renderer/src/types/index.ts | 4 +- 15 files changed, 2131 insertions(+), 2113 deletions(-) delete mode 100644 src/renderer/src/providers/AiProvider/OpenAICompatibleProvider.ts create mode 100644 src/renderer/src/providers/AiProvider/OpenAIResponseProvider.ts diff --git a/src/renderer/src/config/models.ts b/src/renderer/src/config/models.ts index 5565584254..f00f2d1a88 100644 --- a/src/renderer/src/config/models.ts +++ b/src/renderer/src/config/models.ts @@ -2410,7 +2410,7 @@ export function isWebSearchModel(model: Model): boolean { return CLAUDE_SUPPORTED_WEBSEARCH_REGEX.test(model.id) } - if (provider.type === 'openai') { + if (provider.type === 'openai-response') { if ( isOpenAILLMModel(model) && !isTextToImageModel(model) && @@ -2441,7 +2441,7 @@ export function isWebSearchModel(model: Model): boolean { return models.includes(model?.id) } - if (provider?.type === 'openai-compatible') { + if (provider?.type === 'openai') { if (GEMINI_SEARCH_MODELS.includes(model?.id) || isOpenAIWebSearch(model)) { return true } diff --git a/src/renderer/src/databases/upgrades.ts b/src/renderer/src/databases/upgrades.ts index 11db1ed090..fae8a74719 100644 --- a/src/renderer/src/databases/upgrades.ts +++ b/src/renderer/src/databases/upgrades.ts @@ -213,7 +213,7 @@ export async function upgradeToV7(tx: Transaction): Promise { hasCitationData = true citationDataToCreate.response = { results: oldMessage.metadata.annotations, - source: WebSearchSource.OPENAI + source: WebSearchSource.OPENAI_RESPONSE } } if (oldMessage.metadata?.citations?.length) { diff --git a/src/renderer/src/pages/home/Messages/Blocks/MainTextBlock.tsx b/src/renderer/src/pages/home/Messages/Blocks/MainTextBlock.tsx index 0d9156a885..f21cc47131 100644 --- a/src/renderer/src/pages/home/Messages/Blocks/MainTextBlock.tsx +++ b/src/renderer/src/pages/home/Messages/Blocks/MainTextBlock.tsx @@ -49,8 +49,8 @@ const MainTextBlock: React.FC = ({ block, citationBlockId, role, mentions } switch (block.citationReferences[0].citationBlockSource) { - case WebSearchSource.OPENAI_COMPATIBLE: - case WebSearchSource.OPENAI: { + case WebSearchSource.OPENAI: + case WebSearchSource.OPENAI_RESPONSE: { formattedCitations.forEach((citation) => { const citationNum = citation.number const supData = { diff --git a/src/renderer/src/pages/settings/ProviderSettings/AddProviderPopup.tsx b/src/renderer/src/pages/settings/ProviderSettings/AddProviderPopup.tsx index 309ebbe0a8..e683f8c432 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/AddProviderPopup.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/AddProviderPopup.tsx @@ -16,7 +16,7 @@ interface Props { const PopupContainer: React.FC = ({ provider, resolve }) => { const [open, setOpen] = useState(true) const [name, setName] = useState(provider?.name || '') - const [type, setType] = useState(provider?.type || 'openai-compatible') + const [type, setType] = useState(provider?.type || 'openai') const [logo, setLogo] = useState(null) const [dropdownOpen, setDropdownOpen] = useState(false) const { t } = useTranslation() @@ -52,7 +52,7 @@ const PopupContainer: React.FC = ({ provider, resolve }) => { const onCancel = () => { setOpen(false) - resolve({ name: '', type: 'openai-compatible' }) + resolve({ name: '', type: 'openai' }) } const onClose = () => { @@ -189,8 +189,8 @@ const PopupContainer: React.FC = ({ provider, resolve }) => { value={type} onChange={setType} options={[ - { label: 'OpenAI-Compatible', value: 'openai-compatible' }, - { label: 'OpenAI-Response', value: 'openai' }, + { label: 'OpenAI', value: 'openai' }, + { label: 'OpenAI-Response', value: 'openai-response' }, { label: 'Gemini', value: 'gemini' }, { label: 'Anthropic', value: 'anthropic' }, { label: 'Azure OpenAI', value: 'azure-openai' } diff --git a/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx b/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx index 793e2bd282..2f58a924dd 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx @@ -262,7 +262,7 @@ const ProviderSetting: FC = ({ provider: _provider }) => { if (apiHost.endsWith('#')) { return apiHost.replace('#', '') } - if (provider.type === 'openai-compatible') { + if (provider.type === 'openai') { return formatApiHost(apiHost) + 'chat/completions' } return formatApiHost(apiHost) + 'responses' diff --git a/src/renderer/src/providers/AiProvider/AihubmixProvider.ts b/src/renderer/src/providers/AiProvider/AihubmixProvider.ts index 83b5377b5c..6fe0a1c235 100644 --- a/src/renderer/src/providers/AiProvider/AihubmixProvider.ts +++ b/src/renderer/src/providers/AiProvider/AihubmixProvider.ts @@ -8,8 +8,8 @@ import { CompletionsParams } from '.' import AnthropicProvider from './AnthropicProvider' import BaseProvider from './BaseProvider' import GeminiProvider from './GeminiProvider' -import OpenAICompatibleProvider from './OpenAICompatibleProvider' import OpenAIProvider from './OpenAIProvider' +import OpenAIResponseProvider from './OpenAIResponseProvider' /** * AihubmixProvider - 根据模型类型自动选择合适的提供商 @@ -26,8 +26,8 @@ export default class AihubmixProvider extends BaseProvider { // 初始化各个提供商 this.providers.set('claude', new AnthropicProvider(provider)) this.providers.set('gemini', new GeminiProvider({ ...provider, apiHost: 'https://aihubmix.com/gemini' })) - this.providers.set('openai', new OpenAIProvider(provider)) - this.providers.set('default', new OpenAICompatibleProvider(provider)) + this.providers.set('openai', new OpenAIResponseProvider(provider)) + this.providers.set('default', new OpenAIProvider(provider)) // 设置默认提供商 this.defaultProvider = this.providers.get('default')! diff --git a/src/renderer/src/providers/AiProvider/OpenAICompatibleProvider.ts b/src/renderer/src/providers/AiProvider/OpenAICompatibleProvider.ts deleted file mode 100644 index 057a1e3e06..0000000000 --- a/src/renderer/src/providers/AiProvider/OpenAICompatibleProvider.ts +++ /dev/null @@ -1,1220 +0,0 @@ -import { - findTokenLimit, - getOpenAIWebSearchParams, - isHunyuanSearchModel, - isOpenAIReasoningModel, - isOpenAIWebSearch, - isReasoningModel, - isSupportedModel, - isSupportedReasoningEffortGrokModel, - isSupportedReasoningEffortModel, - isSupportedReasoningEffortOpenAIModel, - isSupportedThinkingTokenClaudeModel, - isSupportedThinkingTokenModel, - isSupportedThinkingTokenQwenModel, - isVisionModel, - isZhipuModel -} from '@renderer/config/models' -import { getStoreSetting } from '@renderer/hooks/useSettings' -import i18n from '@renderer/i18n' -import { extractReasoningMiddleware } from '@renderer/middlewares/extractReasoningMiddleware' -import { getAssistantSettings, getDefaultModel, getTopNamingModel } from '@renderer/services/AssistantService' -import { EVENT_NAMES } from '@renderer/services/EventService' -import { - filterContextMessages, - filterEmptyMessages, - filterUserRoleStartMessages -} from '@renderer/services/MessagesService' -import { processPostsuffixQwen3Model, processReqMessages } from '@renderer/services/ModelMessageService' -import store from '@renderer/store' -import { - Assistant, - EFFORT_RATIO, - FileTypes, - MCPCallToolResponse, - MCPTool, - MCPToolResponse, - Metrics, - Model, - Provider, - Suggestion, - ToolCallResponse, - Usage, - WebSearchSource -} from '@renderer/types' -import { ChunkType, LLMWebSearchCompleteChunk } from '@renderer/types/chunk' -import { Message } from '@renderer/types/newMessage' -import { removeSpecialCharactersForTopicName } from '@renderer/utils' -import { addImageFileToContents } from '@renderer/utils/formats' -import { - convertLinks, - convertLinksToHunyuan, - convertLinksToOpenRouter, - convertLinksToZhipu -} from '@renderer/utils/linkConverter' -import { - mcpToolCallResponseToOpenAICompatibleMessage, - mcpToolsToOpenAIChatTools, - openAIToolsToMcpTool, - parseAndCallTools -} from '@renderer/utils/mcp-tools' -import { findFileBlocks, findImageBlocks, getMainTextContent } from '@renderer/utils/messageUtils/find' -import { buildSystemPrompt } from '@renderer/utils/prompt' -import { asyncGeneratorToReadableStream, readableStreamAsyncIterable } from '@renderer/utils/stream' -import { isEmpty, takeRight } from 'lodash' -import OpenAI, { AzureOpenAI } from 'openai' -import { - ChatCompletionContentPart, - ChatCompletionCreateParamsNonStreaming, - ChatCompletionMessageParam, - ChatCompletionMessageToolCall, - ChatCompletionTool, - ChatCompletionToolMessageParam -} from 'openai/resources' - -import { CompletionsParams } from '.' -import { BaseOpenAiProvider } from './OpenAIProvider' - -// 1. 定义联合类型 -export type OpenAIStreamChunk = - | { type: 'reasoning' | 'text-delta'; textDelta: string } - | { type: 'tool-calls'; delta: any } - | { type: 'finish'; finishReason: any; usage: any; delta: any; chunk: any } - -export default class OpenAICompatibleProvider extends BaseOpenAiProvider { - constructor(provider: Provider) { - super(provider) - - if (provider.id === 'azure-openai' || provider.type === 'azure-openai') { - this.sdk = new AzureOpenAI({ - dangerouslyAllowBrowser: true, - apiKey: this.apiKey, - apiVersion: provider.apiVersion, - endpoint: provider.apiHost - }) - return - } - - this.sdk = new OpenAI({ - dangerouslyAllowBrowser: true, - apiKey: this.apiKey, - baseURL: this.getBaseURL(), - defaultHeaders: { - ...this.defaultHeaders(), - ...(this.provider.id === 'copilot' ? { 'editor-version': 'vscode/1.97.2' } : {}), - ...(this.provider.id === 'copilot' ? { 'copilot-vision-request': 'true' } : {}) - } - }) - } - - /** - * Check if the provider does not support files - * @returns True if the provider does not support files, false otherwise - */ - private get isNotSupportFiles() { - if (this.provider?.isNotSupportArrayContent) { - return true - } - - const providers = ['deepseek', 'baichuan', 'minimax', 'xirang'] - - return providers.includes(this.provider.id) - } - - /** - * Get the message parameter - * @param message - The message - * @param model - The model - * @returns The message parameter - */ - override async getMessageParam( - message: Message, - model: Model - ): Promise { - const isVision = isVisionModel(model) - const content = await this.getMessageContent(message) - const fileBlocks = findFileBlocks(message) - const imageBlocks = findImageBlocks(message) - - if (fileBlocks.length === 0 && imageBlocks.length === 0) { - return { - role: message.role === 'system' ? 'user' : message.role, - content - } - } - - // If the model does not support files, extract the file content - if (this.isNotSupportFiles) { - const fileContent = await this.extractFileContent(message) - - return { - role: message.role === 'system' ? 'user' : message.role, - content: content + '\n\n---\n\n' + fileContent - } - } - - // If the model supports files, add the file content to the message - const parts: ChatCompletionContentPart[] = [] - - if (content) { - parts.push({ type: 'text', text: content }) - } - - for (const imageBlock of imageBlocks) { - if (isVision) { - if (imageBlock.file) { - const image = await window.api.file.base64Image(imageBlock.file.id + imageBlock.file.ext) - parts.push({ type: 'image_url', image_url: { url: image.data } }) - } else if (imageBlock.url && imageBlock.url.startsWith('data:')) { - parts.push({ type: 'image_url', image_url: { url: imageBlock.url } }) - } - } - } - - for (const fileBlock of fileBlocks) { - const file = fileBlock.file - if (!file) { - continue - } - - if ([FileTypes.TEXT, FileTypes.DOCUMENT].includes(file.type)) { - const fileContent = await (await window.api.file.read(file.id + file.ext)).trim() - parts.push({ - type: 'text', - text: file.origin_name + '\n' + fileContent - }) - } - } - - return { - role: message.role === 'system' ? 'user' : message.role, - content: parts - } as ChatCompletionMessageParam - } - - /** - * Get the temperature for the assistant - * @param assistant - The assistant - * @param model - The model - * @returns The temperature - */ - override getTemperature(assistant: Assistant, model: Model) { - return isReasoningModel(model) || isOpenAIWebSearch(model) ? undefined : assistant?.settings?.temperature - } - - /** - * Get the provider specific parameters for the assistant - * @param assistant - The assistant - * @param model - The model - * @returns The provider specific parameters - */ - private getProviderSpecificParameters(assistant: Assistant, model: Model) { - const { maxTokens } = getAssistantSettings(assistant) - - if (this.provider.id === 'openrouter') { - if (model.id.includes('deepseek-r1')) { - return { - include_reasoning: true - } - } - } - - if (isOpenAIReasoningModel(model)) { - return { - max_tokens: undefined, - max_completion_tokens: maxTokens - } - } - - return {} - } - - /** - * Get the top P for the assistant - * @param assistant - The assistant - * @param model - The model - * @returns The top P - */ - override getTopP(assistant: Assistant, model: Model) { - if (isReasoningModel(model) || isOpenAIWebSearch(model)) { - return undefined - } - - return assistant?.settings?.topP - } - - /** - * Get the reasoning effort for the assistant - * @param assistant - The assistant - * @param model - The model - * @returns The reasoning effort - */ - private getReasoningEffort(assistant: Assistant, model: Model) { - if (this.provider.id === 'groq') { - return {} - } - - if (!isReasoningModel(model)) { - return {} - } - const reasoningEffort = assistant?.settings?.reasoning_effort - if (!reasoningEffort) { - if (isSupportedThinkingTokenQwenModel(model)) { - return { enable_thinking: false } - } - - if (isSupportedThinkingTokenClaudeModel(model)) { - return { thinking: { type: 'disabled' } } - } - - return {} - } - const effortRatio = EFFORT_RATIO[reasoningEffort] - const budgetTokens = Math.floor((findTokenLimit(model.id)?.max || 0) * effortRatio) - // OpenRouter models - if (model.provider === 'openrouter') { - if (isSupportedReasoningEffortModel(model)) { - return { - reasoning: { - effort: assistant?.settings?.reasoning_effort - } - } - } - - if (isSupportedThinkingTokenModel(model)) { - return { - reasoning: { - max_tokens: budgetTokens - } - } - } - } - - // Qwen models - if (isSupportedThinkingTokenQwenModel(model)) { - return { - enable_thinking: true, - thinking_budget: budgetTokens - } - } - - // Grok models - if (isSupportedReasoningEffortGrokModel(model)) { - return { - reasoning_effort: assistant?.settings?.reasoning_effort - } - } - - // OpenAI models - if (isSupportedReasoningEffortOpenAIModel(model)) { - return { - reasoning_effort: assistant?.settings?.reasoning_effort - } - } - - // Claude models - if (isSupportedThinkingTokenClaudeModel(model)) { - return { - thinking: { - type: 'enabled', - budget_tokens: budgetTokens - } - } - } - - // Default case: no special thinking settings - return {} - } - - public convertMcpTools(mcpTools: MCPTool[]): T[] { - return mcpToolsToOpenAIChatTools(mcpTools) as T[] - } - - public mcpToolCallResponseToMessage = (mcpToolResponse: MCPToolResponse, resp: MCPCallToolResponse, model: Model) => { - if ('toolUseId' in mcpToolResponse && mcpToolResponse.toolUseId) { - return mcpToolCallResponseToOpenAICompatibleMessage(mcpToolResponse, resp, isVisionModel(model)) - } else if ('toolCallId' in mcpToolResponse && mcpToolResponse.toolCallId) { - const toolCallOut: ChatCompletionToolMessageParam = { - role: 'tool', - tool_call_id: mcpToolResponse.toolCallId, - content: JSON.stringify(resp.content) - } - return toolCallOut - } - return - } - - /** - * Generate completions for the assistant - * @param messages - The messages - * @param assistant - The assistant - * @param mcpTools - The MCP tools - * @param onChunk - The onChunk callback - * @param onFilterMessages - The onFilterMessages callback - * @returns The completions - */ - async completions({ messages, assistant, mcpTools, onChunk, onFilterMessages }: CompletionsParams): Promise { - if (assistant.enableGenerateImage) { - await this.generateImageByChat({ messages, assistant, onChunk } as CompletionsParams) - return - } - const defaultModel = getDefaultModel() - const model = assistant.model || defaultModel - - const { contextCount, maxTokens, streamOutput, enableToolUse } = getAssistantSettings(assistant) - const isEnabledBultinWebSearch = assistant.enableWebSearch - messages = addImageFileToContents(messages) - const enableReasoning = - ((isSupportedThinkingTokenModel(model) || isSupportedReasoningEffortModel(model)) && - assistant.settings?.reasoning_effort !== undefined) || - (isReasoningModel(model) && (!isSupportedThinkingTokenModel(model) || !isSupportedReasoningEffortModel(model))) - let systemMessage = { role: 'system', content: assistant.prompt || '' } - if (isSupportedReasoningEffortOpenAIModel(model)) { - systemMessage = { - role: 'developer', - content: `Formatting re-enabled${systemMessage ? '\n' + systemMessage.content : ''}` - } - } - const { tools } = this.setupToolsConfig({ mcpTools, model, enableToolUse }) - - if (this.useSystemPromptForTools) { - systemMessage.content = buildSystemPrompt(systemMessage.content || '', mcpTools) - } - - const userMessages: ChatCompletionMessageParam[] = [] - const _messages = filterUserRoleStartMessages( - filterEmptyMessages(filterContextMessages(takeRight(messages, contextCount + 1))) - ) - - onFilterMessages(_messages) - - for (const message of _messages) { - userMessages.push(await this.getMessageParam(message, model)) - } - - const isSupportStreamOutput = () => { - return streamOutput - } - - const lastUserMessage = _messages.findLast((m) => m.role === 'user') - const { abortController, cleanup, signalPromise } = this.createAbortController(lastUserMessage?.id, true) - const { signal } = abortController - await this.checkIsCopilot() - - const lastUserMsg = userMessages.findLast((m) => m.role === 'user') - if (lastUserMsg && isSupportedThinkingTokenQwenModel(model)) { - const postsuffix = '/no_think' - // qwenThinkMode === true 表示思考模式啓用,此時不應添加 /no_think,如果存在則移除 - const qwenThinkModeEnabled = assistant.settings?.qwenThinkMode === true - const currentContent = lastUserMsg.content // content 類型:string | ChatCompletionContentPart[] | null - - lastUserMsg.content = processPostsuffixQwen3Model( - currentContent, - postsuffix, - qwenThinkModeEnabled - ) as ChatCompletionContentPart[] - } - - //当 systemMessage 内容为空时不发送 systemMessage - let reqMessages: ChatCompletionMessageParam[] - if (!systemMessage.content) { - reqMessages = [...userMessages] - } else { - reqMessages = [systemMessage, ...userMessages].filter(Boolean) as ChatCompletionMessageParam[] - } - - let finalUsage: Usage = { - completion_tokens: 0, - prompt_tokens: 0, - total_tokens: 0 - } - - const finalMetrics: Metrics = { - completion_tokens: 0, - time_completion_millsec: 0, - time_first_token_millsec: 0 - } - - const toolResponses: MCPToolResponse[] = [] - - const processToolResults = async (toolResults: Awaited>, idx: number) => { - if (toolResults.length === 0) return - - toolResults.forEach((ts) => reqMessages.push(ts as ChatCompletionMessageParam)) - - console.debug('[tool] reqMessages before processing', model.id, reqMessages) - reqMessages = processReqMessages(model, reqMessages) - console.debug('[tool] reqMessages', model.id, reqMessages) - - onChunk({ type: ChunkType.LLM_RESPONSE_CREATED }) - const newStream = await this.sdk.chat.completions - // @ts-ignore key is not typed - .create( - { - model: model.id, - messages: reqMessages, - temperature: this.getTemperature(assistant, model), - top_p: this.getTopP(assistant, model), - max_tokens: maxTokens, - keep_alive: this.keepAliveTime, - stream: isSupportStreamOutput(), - tools: !isEmpty(tools) ? tools : undefined, - ...getOpenAIWebSearchParams(assistant, model), - ...this.getReasoningEffort(assistant, model), - ...this.getProviderSpecificParameters(assistant, model), - ...this.getCustomParameters(assistant) - }, - { - signal - } - ) - await processStream(newStream, idx + 1) - } - - const processToolCalls = async (mcpTools, toolCalls: ChatCompletionMessageToolCall[]) => { - const mcpToolResponses = toolCalls - .map((toolCall) => { - const mcpTool = openAIToolsToMcpTool(mcpTools, toolCall as ChatCompletionMessageToolCall) - if (!mcpTool) return undefined - - const parsedArgs = (() => { - try { - return JSON.parse(toolCall.function.arguments) - } catch { - return toolCall.function.arguments - } - })() - - return { - id: toolCall.id, - toolCallId: toolCall.id, - tool: mcpTool, - arguments: parsedArgs, - status: 'pending' - } as ToolCallResponse - }) - .filter((t): t is ToolCallResponse => typeof t !== 'undefined') - return await parseAndCallTools( - mcpToolResponses, - toolResponses, - onChunk, - this.mcpToolCallResponseToMessage, - model, - mcpTools - ) - } - - const processToolUses = async (content: string) => { - return await parseAndCallTools( - content, - toolResponses, - onChunk, - this.mcpToolCallResponseToMessage, - model, - mcpTools - ) - } - - const processStream = async (stream: any, idx: number) => { - const toolCalls: ChatCompletionMessageToolCall[] = [] - let time_first_token_millsec = 0 - - // Handle non-streaming case (already returns early, no change needed here) - if (!isSupportStreamOutput()) { - // Calculate final metrics once - finalMetrics.completion_tokens = stream.usage?.completion_tokens - finalMetrics.time_completion_millsec = new Date().getTime() - start_time_millsec - - // Create a synthetic usage object if stream.usage is undefined - finalUsage = { ...stream.usage } - // Separate onChunk calls for text and usage/metrics - let content = '' - stream.choices.forEach((choice) => { - // reasoning - if (choice.message.reasoning) { - onChunk({ type: ChunkType.THINKING_DELTA, text: choice.message.reasoning }) - onChunk({ - type: ChunkType.THINKING_COMPLETE, - text: choice.message.reasoning, - thinking_millsec: new Date().getTime() - start_time_millsec - }) - } - // text - if (choice.message.content) { - content += choice.message.content - onChunk({ type: ChunkType.TEXT_DELTA, text: choice.message.content }) - } - // tool call - if (choice.message.tool_calls && choice.message.tool_calls.length) { - choice.message.tool_calls.forEach((t) => toolCalls.push(t)) - } - - reqMessages.push({ - role: choice.message.role, - content: choice.message.content, - tool_calls: toolCalls.length - ? toolCalls.map((toolCall) => ({ - id: toolCall.id, - function: { - ...toolCall.function, - arguments: - typeof toolCall.function.arguments === 'string' - ? toolCall.function.arguments - : JSON.stringify(toolCall.function.arguments) - }, - type: 'function' - })) - : undefined - }) - }) - - if (content.length) { - onChunk({ type: ChunkType.TEXT_COMPLETE, text: content }) - } - - const toolResults: Awaited> = [] - if (toolCalls.length) { - toolResults.push(...(await processToolCalls(mcpTools, toolCalls))) - } - if (stream.choices[0].message?.content) { - toolResults.push(...(await processToolUses(stream.choices[0].message?.content))) - } - await processToolResults(toolResults, idx) - - // Always send usage and metrics data - onChunk({ type: ChunkType.BLOCK_COMPLETE, response: { usage: finalUsage, metrics: finalMetrics } }) - return - } - - let content = '' - let thinkingContent = '' - let isFirstChunk = true - - // 1. 初始化中间件 - const reasoningTags = [ - { openingTag: '', closingTag: '', separator: '\n' }, - { openingTag: '###Thinking', closingTag: '###Response', separator: '\n' } - ] - const getAppropriateTag = (model: Model) => { - if (model.id.includes('qwen3')) return reasoningTags[0] - return reasoningTags[0] - } - const reasoningTag = getAppropriateTag(model) - async function* openAIChunkToTextDelta(stream: any): AsyncGenerator { - for await (const chunk of stream) { - if (window.keyv.get(EVENT_NAMES.CHAT_COMPLETION_PAUSED)) { - break - } - - const delta = chunk.choices[0]?.delta - if (delta?.reasoning_content || delta?.reasoning) { - yield { type: 'reasoning', textDelta: delta.reasoning_content || delta.reasoning } - } - if (delta?.content) { - yield { type: 'text-delta', textDelta: delta.content } - } - if (delta?.tool_calls) { - yield { type: 'tool-calls', delta: delta } - } - - const finishReason = chunk.choices[0]?.finish_reason - if (!isEmpty(finishReason)) { - yield { type: 'finish', finishReason, usage: chunk.usage, delta, chunk } - break - } - } - } - - // 2. 使用中间件 - const { stream: processedStream } = await extractReasoningMiddleware({ - openingTag: reasoningTag?.openingTag, - closingTag: reasoningTag?.closingTag, - separator: reasoningTag?.separator, - enableReasoning - }).wrapStream({ - doStream: async () => ({ - stream: asyncGeneratorToReadableStream(openAIChunkToTextDelta(stream)) - }) - }) - - // 3. 消费 processedStream,分发 onChunk - for await (const chunk of readableStreamAsyncIterable(processedStream)) { - const delta = chunk.type === 'finish' ? chunk.delta : chunk - const rawChunk = chunk.type === 'finish' ? chunk.chunk : chunk - - switch (chunk.type) { - case 'reasoning': { - if (time_first_token_millsec === 0) { - time_first_token_millsec = new Date().getTime() - } - thinkingContent += chunk.textDelta - onChunk({ - type: ChunkType.THINKING_DELTA, - text: chunk.textDelta, - thinking_millsec: new Date().getTime() - time_first_token_millsec - }) - break - } - case 'text-delta': { - let textDelta = chunk.textDelta - if (assistant.enableWebSearch && delta) { - const originalDelta = rawChunk?.choices?.[0]?.delta - - if (originalDelta?.annotations) { - textDelta = convertLinks(textDelta, isFirstChunk) - } else if (assistant.model?.provider === 'openrouter') { - textDelta = convertLinksToOpenRouter(textDelta, isFirstChunk) - } else if (isZhipuModel(assistant.model)) { - textDelta = convertLinksToZhipu(textDelta, isFirstChunk) - } else if (isHunyuanSearchModel(assistant.model)) { - const searchResults = rawChunk?.search_info?.search_results || [] - textDelta = convertLinksToHunyuan(textDelta, searchResults, isFirstChunk) - } - } - if (isFirstChunk) { - isFirstChunk = false - if (time_first_token_millsec === 0) { - time_first_token_millsec = new Date().getTime() - } else { - onChunk({ - type: ChunkType.THINKING_COMPLETE, - text: thinkingContent, - thinking_millsec: new Date().getTime() - time_first_token_millsec - }) - } - } - content += textDelta - onChunk({ type: ChunkType.TEXT_DELTA, text: textDelta }) - break - } - case 'tool-calls': { - if (isFirstChunk) { - isFirstChunk = false - if (time_first_token_millsec === 0) { - time_first_token_millsec = new Date().getTime() - } else { - onChunk({ - type: ChunkType.THINKING_COMPLETE, - text: thinkingContent, - thinking_millsec: new Date().getTime() - time_first_token_millsec - }) - } - } - chunk.delta.tool_calls.forEach((toolCall) => { - const { id, index, type, function: fun } = toolCall - if (id && type === 'function' && fun) { - const { name, arguments: args } = fun - toolCalls.push({ - id, - function: { - name: name || '', - arguments: args || '' - }, - type: 'function' - }) - } else if (fun?.arguments) { - toolCalls[index].function.arguments += fun.arguments - } - }) - break - } - case 'finish': { - const finishReason = chunk.finishReason - const usage = chunk.usage - const originalFinishDelta = chunk.delta - const originalFinishRawChunk = chunk.chunk - - if (!isEmpty(finishReason)) { - onChunk({ type: ChunkType.TEXT_COMPLETE, text: content }) - if (usage) { - finalUsage.completion_tokens += usage.completion_tokens || 0 - finalUsage.prompt_tokens += usage.prompt_tokens || 0 - finalUsage.total_tokens += usage.total_tokens || 0 - finalMetrics.completion_tokens += usage.completion_tokens || 0 - } - finalMetrics.time_completion_millsec += new Date().getTime() - start_time_millsec - finalMetrics.time_first_token_millsec = time_first_token_millsec - start_time_millsec - if (originalFinishDelta?.annotations) { - if (assistant.model?.provider === 'copilot') return - - onChunk({ - type: ChunkType.LLM_WEB_SEARCH_COMPLETE, - llm_web_search: { - results: originalFinishDelta.annotations, - source: WebSearchSource.OPENAI - } - } as LLMWebSearchCompleteChunk) - } - if (assistant.model?.provider === 'perplexity') { - const citations = originalFinishRawChunk.citations - if (citations) { - onChunk({ - type: ChunkType.LLM_WEB_SEARCH_COMPLETE, - llm_web_search: { - results: citations, - source: WebSearchSource.PERPLEXITY - } - } as LLMWebSearchCompleteChunk) - } - } - if ( - isEnabledBultinWebSearch && - isZhipuModel(model) && - finishReason === 'stop' && - originalFinishRawChunk?.web_search - ) { - onChunk({ - type: ChunkType.LLM_WEB_SEARCH_COMPLETE, - llm_web_search: { - results: originalFinishRawChunk.web_search, - source: WebSearchSource.ZHIPU - } - } as LLMWebSearchCompleteChunk) - } - if ( - isEnabledBultinWebSearch && - isHunyuanSearchModel(model) && - originalFinishRawChunk?.search_info?.search_results - ) { - onChunk({ - type: ChunkType.LLM_WEB_SEARCH_COMPLETE, - llm_web_search: { - results: originalFinishRawChunk.search_info.search_results, - source: WebSearchSource.HUNYUAN - } - } as LLMWebSearchCompleteChunk) - } - } - break - } - } - } - - reqMessages.push({ - role: 'assistant', - content: content, - tool_calls: toolCalls.length - ? toolCalls.map((toolCall) => ({ - id: toolCall.id, - function: { - ...toolCall.function, - arguments: - typeof toolCall.function.arguments === 'string' - ? toolCall.function.arguments - : JSON.stringify(toolCall.function.arguments) - }, - type: 'function' - })) - : undefined - }) - let toolResults: Awaited> = [] - if (toolCalls.length) { - toolResults = await processToolCalls(mcpTools, toolCalls) - } - if (content.length) { - toolResults = toolResults.concat(await processToolUses(content)) - } - if (toolResults.length) { - await processToolResults(toolResults, idx) - } - - onChunk({ - type: ChunkType.BLOCK_COMPLETE, - response: { - usage: finalUsage, - metrics: finalMetrics - } - }) - } - - 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( - { - model: model.id, - messages: reqMessages, - temperature: this.getTemperature(assistant, model), - top_p: this.getTopP(assistant, model), - max_tokens: maxTokens, - keep_alive: this.keepAliveTime, - stream: isSupportStreamOutput(), - tools: !isEmpty(tools) ? tools : undefined, - service_tier: this.getServiceTier(model), - ...getOpenAIWebSearchParams(assistant, model), - ...this.getReasoningEffort(assistant, model), - ...this.getProviderSpecificParameters(assistant, model), - ...this.getCustomParameters(assistant) - }, - { - signal, - timeout: this.getTimeout(model) - } - ) - - await processStream(stream, 0).finally(cleanup) - - // 捕获signal的错误 - await signalPromise?.promise?.catch((error) => { - throw error - }) - } - - /** - * Translate a message - * @param content - * @param assistant - The assistant - * @param onResponse - The onResponse callback - * @returns The translated message - */ - async translate(content: string, assistant: Assistant, onResponse?: (text: string, isComplete: boolean) => void) { - const defaultModel = getDefaultModel() - const model = assistant.model || defaultModel - - const messagesForApi = content - ? [ - { role: 'system', content: assistant.prompt }, - { role: 'user', content } - ] - : [{ role: 'user', content: assistant.prompt }] - - const isSupportedStreamOutput = () => { - if (!onResponse) { - return false - } - return true - } - - const stream = isSupportedStreamOutput() - - await this.checkIsCopilot() - - // console.debug('[translate] reqMessages', model.id, message) - // @ts-ignore key is not typed - const response = await this.sdk.chat.completions.create({ - model: model.id, - messages: messagesForApi as ChatCompletionMessageParam[], - stream, - keep_alive: this.keepAliveTime, - temperature: this.getTemperature(assistant, model), - top_p: this.getTopP(assistant, model), - ...this.getReasoningEffort(assistant, model) - }) - - if (!stream) { - return response.choices[0].message?.content || '' - } - - let text = '' - let isThinking = false - const isReasoning = isReasoningModel(model) - - for await (const chunk of response) { - const deltaContent = chunk.choices[0]?.delta?.content || '' - - if (isReasoning) { - if (deltaContent.includes('')) { - isThinking = true - } - - if (!isThinking) { - text += deltaContent - onResponse?.(text, false) - } - - if (deltaContent.includes('')) { - isThinking = false - } - } else { - text += deltaContent - onResponse?.(text, false) - } - } - - onResponse?.(text, true) - - return text - } - - /** - * Summarize a message - * @param messages - The messages - * @param assistant - The assistant - * @returns The summary - */ - public async summaries(messages: Message[], assistant: Assistant): Promise { - const model = getTopNamingModel() || assistant.model || getDefaultModel() - - const userMessages = takeRight(messages, 5) - .filter((message) => !message.isPreset) - .map((message) => ({ - role: message.role, - content: getMainTextContent(message) - })) - - const userMessageContent = userMessages.reduce((prev, curr) => { - const content = curr.role === 'user' ? `User: ${curr.content}` : `Assistant: ${curr.content}` - return prev + (prev ? '\n' : '') + content - }, '') - - const systemMessage = { - role: 'system', - content: getStoreSetting('topicNamingPrompt') || i18n.t('prompts.title') - } - - const userMessage = { - role: 'user', - content: userMessageContent - } - - await this.checkIsCopilot() - - // @ts-ignore key is not typed - const response = await this.sdk.chat.completions.create({ - model: model.id, - messages: [systemMessage, userMessage] as ChatCompletionMessageParam[], - stream: false, - keep_alive: this.keepAliveTime, - max_tokens: 1000 - }) - - // 针对思考类模型的返回,总结仅截取之后的内容 - let content = response.choices[0].message?.content || '' - content = content.replace(/^(.*?)<\/think>/s, '') - - return removeSpecialCharactersForTopicName(content.substring(0, 50)) - } - - /** - * Summarize a message for search - * @param messages - The messages - * @param assistant - The assistant - * @returns The summary - */ - public async summaryForSearch(messages: Message[], assistant: Assistant): Promise { - const model = assistant.model || getDefaultModel() - - const systemMessage = { - role: 'system', - content: assistant.prompt - } - - const messageContents = messages.map((m) => getMainTextContent(m)) - const userMessageContent = messageContents.join('\n') - - const userMessage = { - role: 'user', - content: userMessageContent - } - - const lastUserMessage = messages[messages.length - 1] - const { abortController, cleanup } = this.createAbortController(lastUserMessage?.id) - const { signal } = abortController - - const response = await this.sdk.chat.completions - // @ts-ignore key is not typed - .create( - { - model: model.id, - messages: [systemMessage, userMessage] as ChatCompletionMessageParam[], - stream: false, - keep_alive: this.keepAliveTime, - max_tokens: 1000 - }, - { - timeout: 20 * 1000, - signal: signal - } - ) - .finally(cleanup) - - // 针对思考类模型的返回,总结仅截取之后的内容 - let content = response.choices[0].message?.content || '' - content = content.replace(/^(.*?)<\/think>/s, '') - - return content - } - - /** - * Generate text - * @param prompt - The prompt - * @param content - The content - * @returns The generated text - */ - public async generateText({ prompt, content }: { prompt: string; content: string }): Promise { - const model = getDefaultModel() - - await this.checkIsCopilot() - - const response = await this.sdk.chat.completions.create({ - model: model.id, - stream: false, - messages: [ - { role: 'system', content: prompt }, - { role: 'user', content } - ] - }) - - return response.choices[0].message?.content || '' - } - - /** - * Generate suggestions - * @param messages - The messages - * @param assistant - The assistant - * @returns The suggestions - */ - async suggestions(messages: Message[], assistant: Assistant): Promise { - const { model } = assistant - - if (!model) { - return [] - } - - await this.checkIsCopilot() - - const userMessagesForApi = messages - .filter((m) => m.role === 'user') - .map((m) => ({ - role: m.role, - content: getMainTextContent(m) - })) - - const response: any = await this.sdk.request({ - method: 'post', - path: '/advice_questions', - body: { - messages: userMessagesForApi, - model: model.id, - max_tokens: 0, - temperature: 0, - n: 0 - } - }) - - return response?.questions?.filter(Boolean)?.map((q: any) => ({ content: q })) || [] - } - - /** - * Check if the model is valid - * @param model - The model - * @param stream - Whether to use streaming interface - * @returns The validity of the model - */ - public async check(model: Model, stream: boolean = false): Promise<{ valid: boolean; error: Error | null }> { - if (!model) { - return { valid: false, error: new Error('No model found') } - } - - const body = { - model: model.id, - messages: [{ role: 'user', content: 'hi' }], - max_completion_tokens: 1, // openAI - max_tokens: 1, // openAI deprecated 但大部分OpenAI兼容的提供商继续用这个头 - enable_thinking: false, // qwen3 - stream - } - - try { - await this.checkIsCopilot() - if (!stream) { - const response = await this.sdk.chat.completions.create(body as ChatCompletionCreateParamsNonStreaming) - if (!response?.choices[0].message) { - throw new Error('Empty response') - } - return { valid: true, error: null } - } else { - const response: any = await this.sdk.chat.completions.create(body as any) - // 等待整个流式响应结束 - let hasContent = false - for await (const chunk of response) { - if (chunk.choices?.[0]?.delta?.content) { - hasContent = true - } - } - if (hasContent) { - return { valid: true, error: null } - } - throw new Error('Empty streaming response') - } - } catch (error: any) { - return { - valid: false, - error - } - } - } - - /** - * Get the models - * @returns The models - */ - public async models(): Promise { - try { - await this.checkIsCopilot() - - const response = await this.sdk.models.list() - - if (this.provider.id === 'github') { - // @ts-ignore key is not typed - return response.body - .map((model) => ({ - id: model.name, - description: model.summary, - object: 'model', - owned_by: model.publisher - })) - .filter(isSupportedModel) - } - - if (this.provider.id === 'together') { - // @ts-ignore key is not typed - return response?.body - .map((model: any) => ({ - id: model.id, - description: model.display_name, - object: 'model', - owned_by: model.organization - })) - .filter(isSupportedModel) - } - - const models = response.data || [] - models.forEach((model) => { - model.id = model.id.trim() - }) - - return models.filter(isSupportedModel) - } catch (error) { - return [] - } - } - - /** - * Get the embedding dimensions - * @param model - The model - * @returns The embedding dimensions - */ - public async getEmbeddingDimensions(model: Model): Promise { - await this.checkIsCopilot() - - const data = await this.sdk.embeddings.create({ - model: model.id, - input: model?.provider === 'baidu-cloud' ? ['hi'] : 'hi' - }) - return data.data[0].embedding.length - } - - public async checkIsCopilot() { - if (this.provider.id !== 'copilot') { - return - } - const defaultHeaders = store.getState().copilot.defaultHeaders - // copilot每次请求前需要重新获取token,因为token中附带时间戳 - const { token } = await window.api.copilot.getToken(defaultHeaders) - this.sdk.apiKey = token - } -} diff --git a/src/renderer/src/providers/AiProvider/OpenAIProvider.ts b/src/renderer/src/providers/AiProvider/OpenAIProvider.ts index 51f61136b3..650960fc65 100644 --- a/src/renderer/src/providers/AiProvider/OpenAIProvider.ts +++ b/src/renderer/src/providers/AiProvider/OpenAIProvider.ts @@ -1,26 +1,36 @@ import { + findTokenLimit, getOpenAIWebSearchParams, - isOpenAILLMModel, + isHunyuanSearchModel, isOpenAIReasoningModel, isOpenAIWebSearch, + isReasoningModel, isSupportedModel, + isSupportedReasoningEffortGrokModel, + isSupportedReasoningEffortModel, isSupportedReasoningEffortOpenAIModel, - isVisionModel + isSupportedThinkingTokenClaudeModel, + isSupportedThinkingTokenModel, + isSupportedThinkingTokenQwenModel, + isVisionModel, + isZhipuModel } from '@renderer/config/models' import { getStoreSetting } from '@renderer/hooks/useSettings' import i18n from '@renderer/i18n' +import { extractReasoningMiddleware } from '@renderer/middlewares/extractReasoningMiddleware' import { getAssistantSettings, getDefaultModel, getTopNamingModel } from '@renderer/services/AssistantService' import { EVENT_NAMES } from '@renderer/services/EventService' -import FileManager from '@renderer/services/FileManager' import { filterContextMessages, filterEmptyMessages, filterUserRoleStartMessages } from '@renderer/services/MessagesService' +import { processPostsuffixQwen3Model, processReqMessages } from '@renderer/services/ModelMessageService' +import store from '@renderer/store' import { Assistant, + EFFORT_RATIO, FileTypes, - GenerateImageParams, MCPCallToolResponse, MCPTool, MCPToolResponse, @@ -32,205 +42,83 @@ import { Usage, WebSearchSource } from '@renderer/types' -import { ChunkType } from '@renderer/types/chunk' +import { ChunkType, LLMWebSearchCompleteChunk } from '@renderer/types/chunk' import { Message } from '@renderer/types/newMessage' import { removeSpecialCharactersForTopicName } from '@renderer/utils' import { addImageFileToContents } from '@renderer/utils/formats' -import { convertLinks } from '@renderer/utils/linkConverter' import { - mcpToolCallResponseToOpenAIMessage, - mcpToolsToOpenAIResponseTools, + convertLinks, + convertLinksToHunyuan, + convertLinksToOpenRouter, + convertLinksToZhipu +} from '@renderer/utils/linkConverter' +import { + mcpToolCallResponseToOpenAICompatibleMessage, + mcpToolsToOpenAIChatTools, openAIToolsToMcpTool, parseAndCallTools } from '@renderer/utils/mcp-tools' import { findFileBlocks, findImageBlocks, getMainTextContent } from '@renderer/utils/messageUtils/find' import { buildSystemPrompt } from '@renderer/utils/prompt' +import { asyncGeneratorToReadableStream, readableStreamAsyncIterable } from '@renderer/utils/stream' import { isEmpty, takeRight } from 'lodash' -import OpenAI from 'openai' -import { ChatCompletionContentPart, ChatCompletionMessageParam } from 'openai/resources/chat/completions' -import { Stream } from 'openai/streaming' -import { FileLike, toFile } from 'openai/uploads' +import OpenAI, { AzureOpenAI } from 'openai' +import { + ChatCompletionContentPart, + ChatCompletionCreateParamsNonStreaming, + ChatCompletionMessageParam, + ChatCompletionMessageToolCall, + ChatCompletionTool, + ChatCompletionToolMessageParam +} from 'openai/resources' import { CompletionsParams } from '.' -import BaseProvider from './BaseProvider' +import { BaseOpenAiProvider } from './OpenAIResponseProvider' -export abstract class BaseOpenAiProvider extends BaseProvider { - protected sdk: OpenAI +// 1. 定义联合类型 +export type OpenAIStreamChunk = + | { type: 'reasoning' | 'text-delta'; textDelta: string } + | { type: 'tool-calls'; delta: any } + | { type: 'finish'; finishReason: any; usage: any; delta: any; chunk: any } +export default class OpenAIProvider extends BaseOpenAiProvider { constructor(provider: Provider) { super(provider) + if (provider.id === 'azure-openai' || provider.type === 'azure-openai') { + this.sdk = new AzureOpenAI({ + dangerouslyAllowBrowser: true, + apiKey: this.apiKey, + apiVersion: provider.apiVersion, + endpoint: provider.apiHost + }) + return + } + this.sdk = new OpenAI({ dangerouslyAllowBrowser: true, apiKey: this.apiKey, baseURL: this.getBaseURL(), defaultHeaders: { - ...this.defaultHeaders() + ...this.defaultHeaders(), + ...(this.provider.id === 'copilot' ? { 'editor-version': 'vscode/1.97.2' } : {}), + ...(this.provider.id === 'copilot' ? { 'copilot-vision-request': 'true' } : {}) } }) } - abstract convertMcpTools(mcpTools: MCPTool[]): T[] - - abstract mcpToolCallResponseToMessage: ( - mcpToolResponse: MCPToolResponse, - resp: MCPCallToolResponse, - model: Model - ) => OpenAI.Responses.ResponseInputItem | ChatCompletionMessageParam | undefined - /** - * Extract the file content from the message - * @param message - The message - * @returns The file content + * Check if the provider does not support files + * @returns True if the provider does not support files, false otherwise */ - protected async extractFileContent(message: Message) { - const fileBlocks = findFileBlocks(message) - if (fileBlocks.length > 0) { - const textFileBlocks = fileBlocks.filter( - (fb) => fb.file && [FileTypes.TEXT, FileTypes.DOCUMENT].includes(fb.file.type) - ) - - if (textFileBlocks.length > 0) { - let text = '' - const divider = '\n\n---\n\n' - - for (const fileBlock of textFileBlocks) { - const file = fileBlock.file - const fileContent = (await window.api.file.read(file.id + file.ext)).trim() - const fileNameRow = 'file: ' + file.origin_name + '\n\n' - text = text + fileNameRow + fileContent + divider - } - - return text - } + private get isNotSupportFiles() { + if (this.provider?.isNotSupportArrayContent) { + return true } - return '' - } + const providers = ['deepseek', 'baichuan', 'minimax', 'xirang'] - private async getReponseMessageParam(message: Message, model: Model): Promise { - const isVision = isVisionModel(model) - const content = await this.getMessageContent(message) - const fileBlocks = findFileBlocks(message) - const imageBlocks = findImageBlocks(message) - - if (fileBlocks.length === 0 && imageBlocks.length === 0) { - if (message.role === 'assistant') { - return { - role: 'assistant', - content: content - } - } else { - return { - role: message.role === 'system' ? 'user' : message.role, - content: content ? [{ type: 'input_text', text: content }] : [] - } as OpenAI.Responses.EasyInputMessage - } - } - - const parts: OpenAI.Responses.ResponseInputContent[] = [] - if (content) { - parts.push({ - type: 'input_text', - text: content - }) - } - - for (const imageBlock of imageBlocks) { - if (isVision) { - if (imageBlock.file) { - const image = await window.api.file.base64Image(imageBlock.file.id + imageBlock.file.ext) - parts.push({ - detail: 'auto', - type: 'input_image', - image_url: image.data as string - }) - } else if (imageBlock.url && imageBlock.url.startsWith('data:')) { - parts.push({ - detail: 'auto', - type: 'input_image', - image_url: imageBlock.url - }) - } - } - } - - for (const fileBlock of fileBlocks) { - const file = fileBlock.file - if (!file) continue - - if ([FileTypes.TEXT, FileTypes.DOCUMENT].includes(file.type)) { - const fileContent = (await window.api.file.read(file.id + file.ext)).trim() - parts.push({ - type: 'input_text', - text: file.origin_name + '\n' + fileContent - }) - } - } - - return { - role: message.role === 'system' ? 'user' : message.role, - content: parts - } - } - - protected getServiceTier(model: Model) { - if ((model.id.includes('o3') && !model.id.includes('o3-mini')) || model.id.includes('o4-mini')) { - return 'flex' - } - if (isOpenAILLMModel(model)) { - return 'auto' - } - return undefined - } - - protected getTimeout(model: Model) { - if ((model.id.includes('o3') && !model.id.includes('o3-mini')) || model.id.includes('o4-mini')) { - return 15 * 1000 * 60 - } - return 5 * 1000 * 60 - } - - /** - * Get the temperature for the assistant - * @param assistant - The assistant - * @param model - The model - * @returns The temperature - */ - protected getTemperature(assistant: Assistant, model: Model) { - return isOpenAIReasoningModel(model) || isOpenAILLMModel(model) ? undefined : assistant?.settings?.temperature - } - - /** - * Get the top P for the assistant - * @param assistant - The assistant - * @param model - The model - * @returns The top P - */ - protected getTopP(assistant: Assistant, model: Model) { - return isOpenAIReasoningModel(model) || isOpenAILLMModel(model) ? undefined : assistant?.settings?.topP - } - - private getResponseReasoningEffort(assistant: Assistant, model: Model) { - if (!isSupportedReasoningEffortOpenAIModel(model)) { - return {} - } - - const reasoningEffort = assistant?.settings?.reasoning_effort - if (!reasoningEffort) { - return {} - } - - if (isSupportedReasoningEffortOpenAIModel(model)) { - return { - reasoning: { - effort: reasoningEffort as OpenAI.ReasoningEffort, - summary: 'detailed' - } as OpenAI.Reasoning - } - } - - return {} + return providers.includes(this.provider.id) } /** @@ -239,7 +127,7 @@ export abstract class BaseOpenAiProvider extends BaseProvider { * @param model - The model * @returns The message parameter */ - protected async getMessageParam( + override async getMessageParam( message: Message, model: Model ): Promise { @@ -255,6 +143,17 @@ export abstract class BaseOpenAiProvider extends BaseProvider { } } + // If the model does not support files, extract the file content + if (this.isNotSupportFiles) { + const fileContent = await this.extractFileContent(message) + + return { + role: message.role === 'system' ? 'user' : message.role, + content: content + '\n\n---\n\n' + fileContent + } + } + + // If the model supports files, add the file content to the message const parts: ChatCompletionContentPart[] = [] if (content) { @@ -273,7 +172,7 @@ export abstract class BaseOpenAiProvider extends BaseProvider { } for (const fileBlock of fileBlocks) { - const { file } = fileBlock + const file = fileBlock.file if (!file) { continue } @@ -294,10 +193,162 @@ export abstract class BaseOpenAiProvider extends BaseProvider { } /** - * Generate completions for the assistant use Response API + * Get the temperature for the assistant + * @param assistant - The assistant + * @param model - The model + * @returns The temperature + */ + override getTemperature(assistant: Assistant, model: Model) { + return isReasoningModel(model) || isOpenAIWebSearch(model) ? undefined : assistant?.settings?.temperature + } + + /** + * Get the provider specific parameters for the assistant + * @param assistant - The assistant + * @param model - The model + * @returns The provider specific parameters + */ + private getProviderSpecificParameters(assistant: Assistant, model: Model) { + const { maxTokens } = getAssistantSettings(assistant) + + if (this.provider.id === 'openrouter') { + if (model.id.includes('deepseek-r1')) { + return { + include_reasoning: true + } + } + } + + if (isOpenAIReasoningModel(model)) { + return { + max_tokens: undefined, + max_completion_tokens: maxTokens + } + } + + return {} + } + + /** + * Get the top P for the assistant + * @param assistant - The assistant + * @param model - The model + * @returns The top P + */ + override getTopP(assistant: Assistant, model: Model) { + if (isReasoningModel(model) || isOpenAIWebSearch(model)) { + return undefined + } + + return assistant?.settings?.topP + } + + /** + * Get the reasoning effort for the assistant + * @param assistant - The assistant + * @param model - The model + * @returns The reasoning effort + */ + private getReasoningEffort(assistant: Assistant, model: Model) { + if (this.provider.id === 'groq') { + return {} + } + + if (!isReasoningModel(model)) { + return {} + } + const reasoningEffort = assistant?.settings?.reasoning_effort + if (!reasoningEffort) { + if (isSupportedThinkingTokenQwenModel(model)) { + return { enable_thinking: false } + } + + if (isSupportedThinkingTokenClaudeModel(model)) { + return { thinking: { type: 'disabled' } } + } + + return {} + } + const effortRatio = EFFORT_RATIO[reasoningEffort] + const budgetTokens = Math.floor((findTokenLimit(model.id)?.max || 0) * effortRatio) + // OpenRouter models + if (model.provider === 'openrouter') { + if (isSupportedReasoningEffortModel(model)) { + return { + reasoning: { + effort: assistant?.settings?.reasoning_effort + } + } + } + + if (isSupportedThinkingTokenModel(model)) { + return { + reasoning: { + max_tokens: budgetTokens + } + } + } + } + + // Qwen models + if (isSupportedThinkingTokenQwenModel(model)) { + return { + enable_thinking: true, + thinking_budget: budgetTokens + } + } + + // Grok models + if (isSupportedReasoningEffortGrokModel(model)) { + return { + reasoning_effort: assistant?.settings?.reasoning_effort + } + } + + // OpenAI models + if (isSupportedReasoningEffortOpenAIModel(model)) { + return { + reasoning_effort: assistant?.settings?.reasoning_effort + } + } + + // Claude models + if (isSupportedThinkingTokenClaudeModel(model)) { + return { + thinking: { + type: 'enabled', + budget_tokens: budgetTokens + } + } + } + + // Default case: no special thinking settings + return {} + } + + public convertMcpTools(mcpTools: MCPTool[]): T[] { + return mcpToolsToOpenAIChatTools(mcpTools) as T[] + } + + public mcpToolCallResponseToMessage = (mcpToolResponse: MCPToolResponse, resp: MCPCallToolResponse, model: Model) => { + if ('toolUseId' in mcpToolResponse && mcpToolResponse.toolUseId) { + return mcpToolCallResponseToOpenAICompatibleMessage(mcpToolResponse, resp, isVisionModel(model)) + } else if ('toolCallId' in mcpToolResponse && mcpToolResponse.toolCallId) { + const toolCallOut: ChatCompletionToolMessageParam = { + role: 'tool', + tool_call_id: mcpToolResponse.toolCallId, + content: JSON.stringify(resp.content) + } + return toolCallOut + } + return + } + + /** + * Generate completions for the assistant * @param messages - The messages * @param assistant - The assistant - * @param mcpTools + * @param mcpTools - The MCP tools * @param onChunk - The onChunk callback * @param onFilterMessages - The onFilterMessages callback * @returns The completions @@ -309,173 +360,70 @@ export abstract class BaseOpenAiProvider extends BaseProvider { } const defaultModel = getDefaultModel() const model = assistant.model || defaultModel + const { contextCount, maxTokens, streamOutput, enableToolUse } = getAssistantSettings(assistant) - const isEnabledBuiltinWebSearch = assistant.enableWebSearch - // 退回到 OpenAI 兼容模式 - if (isOpenAIWebSearch(model)) { - const systemMessage = { role: 'system', content: assistant.prompt || '' } - const userMessages: ChatCompletionMessageParam[] = [] - const _messages = filterUserRoleStartMessages( - filterEmptyMessages(filterContextMessages(takeRight(messages, contextCount + 1))) - ) - onFilterMessages(_messages) - - for (const message of _messages) { - userMessages.push(await this.getMessageParam(message, model)) - } - //当 systemMessage 内容为空时不发送 systemMessage - let reqMessages: ChatCompletionMessageParam[] - if (!systemMessage.content) { - reqMessages = [...userMessages] - } else { - reqMessages = [systemMessage, ...userMessages].filter(Boolean) as ChatCompletionMessageParam[] - } - const lastUserMessage = _messages.findLast((m) => m.role === 'user') - const { abortController, cleanup, signalPromise } = this.createAbortController(lastUserMessage?.id, true) - const { signal } = abortController - const start_time_millsec = new Date().getTime() - const response = await this.sdk.chat.completions - // @ts-ignore key is not typed - .create( - { - model: model.id, - messages: reqMessages, - stream: true, - temperature: this.getTemperature(assistant, model), - top_p: this.getTopP(assistant, model), - max_tokens: maxTokens, - ...getOpenAIWebSearchParams(assistant, model), - ...this.getCustomParameters(assistant) - }, - { - signal - } - ) - const processStream = async (stream: any) => { - let content = '' - let isFirstChunk = true - const finalUsage: Usage = { - completion_tokens: 0, - prompt_tokens: 0, - total_tokens: 0 - } - - const finalMetrics: Metrics = { - completion_tokens: 0, - time_completion_millsec: 0, - time_first_token_millsec: 0 - } - for await (const chunk of stream as any) { - if (window.keyv.get(EVENT_NAMES.CHAT_COMPLETION_PAUSED)) { - break - } - const delta = chunk.choices[0]?.delta - const finishReason = chunk.choices[0]?.finish_reason - if (delta?.content) { - if (isOpenAIWebSearch(model)) { - delta.content = convertLinks(delta.content || '', isFirstChunk) - } - if (isFirstChunk) { - isFirstChunk = false - finalMetrics.time_first_token_millsec = new Date().getTime() - start_time_millsec - } - content += delta.content - onChunk({ type: ChunkType.TEXT_DELTA, text: delta.content }) - } - if (!isEmpty(finishReason) || chunk?.annotations) { - onChunk({ type: ChunkType.TEXT_COMPLETE, text: content }) - finalMetrics.time_completion_millsec = new Date().getTime() - start_time_millsec - if (chunk.usage) { - const usage = chunk.usage as OpenAI.Completions.CompletionUsage - finalUsage.completion_tokens = usage.completion_tokens - finalUsage.prompt_tokens = usage.prompt_tokens - finalUsage.total_tokens = usage.total_tokens - } - finalMetrics.completion_tokens = finalUsage.completion_tokens - } - if (delta?.annotations) { - onChunk({ - type: ChunkType.LLM_WEB_SEARCH_COMPLETE, - llm_web_search: { - results: delta.annotations, - source: WebSearchSource.OPENAI_COMPATIBLE - } - }) - } - } - onChunk({ - type: ChunkType.BLOCK_COMPLETE, - response: { - usage: finalUsage, - metrics: finalMetrics - } - }) - } - await processStream(response).finally(cleanup) - await signalPromise?.promise?.catch((error) => { - throw error - }) - return - } - let tools: OpenAI.Responses.Tool[] = [] - const toolChoices: OpenAI.Responses.ToolChoiceTypes = { - type: 'web_search_preview' - } - if (isEnabledBuiltinWebSearch) { - tools.push({ - type: 'web_search_preview' - }) - } + const isEnabledBultinWebSearch = assistant.enableWebSearch messages = addImageFileToContents(messages) - const systemMessage: OpenAI.Responses.EasyInputMessage = { - role: 'system', - content: [] - } - const systemMessageContent: OpenAI.Responses.ResponseInputMessageContentList = [] - const systemMessageInput: OpenAI.Responses.ResponseInputText = { - text: assistant.prompt || '', - type: 'input_text' - } + const enableReasoning = + ((isSupportedThinkingTokenModel(model) || isSupportedReasoningEffortModel(model)) && + assistant.settings?.reasoning_effort !== undefined) || + (isReasoningModel(model) && (!isSupportedThinkingTokenModel(model) || !isSupportedReasoningEffortModel(model))) + let systemMessage = { role: 'system', content: assistant.prompt || '' } if (isSupportedReasoningEffortOpenAIModel(model)) { - systemMessage.role = 'developer' + systemMessage = { + role: 'developer', + content: `Formatting re-enabled${systemMessage ? '\n' + systemMessage.content : ''}` + } } - - const { tools: extraTools } = this.setupToolsConfig({ - mcpTools, - model, - enableToolUse - }) - - tools = tools.concat(extraTools) + const { tools } = this.setupToolsConfig({ mcpTools, model, enableToolUse }) if (this.useSystemPromptForTools) { - systemMessageInput.text = buildSystemPrompt(systemMessageInput.text || '', mcpTools) + systemMessage.content = buildSystemPrompt(systemMessage.content || '', mcpTools) } - systemMessageContent.push(systemMessageInput) - systemMessage.content = systemMessageContent + + const userMessages: ChatCompletionMessageParam[] = [] const _messages = filterUserRoleStartMessages( filterEmptyMessages(filterContextMessages(takeRight(messages, contextCount + 1))) ) onFilterMessages(_messages) - const userMessage: OpenAI.Responses.ResponseInputItem[] = [] + for (const message of _messages) { - userMessage.push(await this.getReponseMessageParam(message, model)) + userMessages.push(await this.getMessageParam(message, model)) + } + + const isSupportStreamOutput = () => { + return streamOutput } const lastUserMessage = _messages.findLast((m) => m.role === 'user') const { abortController, cleanup, signalPromise } = this.createAbortController(lastUserMessage?.id, true) const { signal } = abortController + await this.checkIsCopilot() - // 当 systemMessage 内容为空时不发送 systemMessage - let reqMessages: OpenAI.Responses.ResponseInput - if (!systemMessage.content) { - reqMessages = [...userMessage] - } else { - reqMessages = [systemMessage, ...userMessage].filter(Boolean) as OpenAI.Responses.EasyInputMessage[] + const lastUserMsg = userMessages.findLast((m) => m.role === 'user') + if (lastUserMsg && isSupportedThinkingTokenQwenModel(model)) { + const postsuffix = '/no_think' + // qwenThinkMode === true 表示思考模式啓用,此時不應添加 /no_think,如果存在則移除 + const qwenThinkModeEnabled = assistant.settings?.qwenThinkMode === true + const currentContent = lastUserMsg.content // content 類型:string | ChatCompletionContentPart[] | null + + lastUserMsg.content = processPostsuffixQwen3Model( + currentContent, + postsuffix, + qwenThinkModeEnabled + ) as ChatCompletionContentPart[] } - const finalUsage: Usage = { + //当 systemMessage 内容为空时不发送 systemMessage + let reqMessages: ChatCompletionMessageParam[] + if (!systemMessage.content) { + reqMessages = [...userMessages] + } else { + reqMessages = [systemMessage, ...userMessages].filter(Boolean) as ChatCompletionMessageParam[] + } + + let finalUsage: Usage = { completion_tokens: 0, prompt_tokens: 0, total_tokens: 0 @@ -492,55 +440,61 @@ export abstract class BaseOpenAiProvider extends BaseProvider { const processToolResults = async (toolResults: Awaited>, idx: number) => { if (toolResults.length === 0) return - toolResults.forEach((ts) => reqMessages.push(ts as OpenAI.Responses.EasyInputMessage)) + toolResults.forEach((ts) => reqMessages.push(ts as ChatCompletionMessageParam)) + + console.debug('[tool] reqMessages before processing', model.id, reqMessages) + reqMessages = processReqMessages(model, reqMessages) + console.debug('[tool] reqMessages', model.id, reqMessages) onChunk({ type: ChunkType.LLM_RESPONSE_CREATED }) - const stream = await this.sdk.responses.create( - { - model: model.id, - input: reqMessages, - temperature: this.getTemperature(assistant, model), - top_p: this.getTopP(assistant, model), - max_output_tokens: maxTokens, - stream: streamOutput, - tools: !isEmpty(tools) ? tools : undefined, - service_tier: this.getServiceTier(model), - ...this.getResponseReasoningEffort(assistant, model), - ...this.getCustomParameters(assistant) - }, - { - signal, - timeout: this.getTimeout(model) - } - ) - await processStream(stream, idx + 1) + const newStream = await this.sdk.chat.completions + // @ts-ignore key is not typed + .create( + { + model: model.id, + messages: reqMessages, + temperature: this.getTemperature(assistant, model), + top_p: this.getTopP(assistant, model), + max_tokens: maxTokens, + keep_alive: this.keepAliveTime, + stream: isSupportStreamOutput(), + tools: !isEmpty(tools) ? tools : undefined, + ...getOpenAIWebSearchParams(assistant, model), + ...this.getReasoningEffort(assistant, model), + ...this.getProviderSpecificParameters(assistant, model), + ...this.getCustomParameters(assistant) + }, + { + signal + } + ) + await processStream(newStream, idx + 1) } - const processToolCalls = async (mcpTools, toolCalls: OpenAI.Responses.ResponseFunctionToolCall[]) => { + const processToolCalls = async (mcpTools, toolCalls: ChatCompletionMessageToolCall[]) => { const mcpToolResponses = toolCalls .map((toolCall) => { - const mcpTool = openAIToolsToMcpTool(mcpTools, toolCall as OpenAI.Responses.ResponseFunctionToolCall) + const mcpTool = openAIToolsToMcpTool(mcpTools, toolCall as ChatCompletionMessageToolCall) if (!mcpTool) return undefined const parsedArgs = (() => { try { - return JSON.parse(toolCall.arguments) + return JSON.parse(toolCall.function.arguments) } catch { - return toolCall.arguments + return toolCall.function.arguments } })() return { - id: toolCall.call_id, - toolCallId: toolCall.call_id, + id: toolCall.id, + toolCallId: toolCall.id, tool: mcpTool, arguments: parsedArgs, status: 'pending' } as ToolCallResponse }) .filter((t): t is ToolCallResponse => typeof t !== 'undefined') - - return await parseAndCallTools( + return await parseAndCallTools( mcpToolResponses, toolResponses, onChunk, @@ -561,218 +515,308 @@ export abstract class BaseOpenAiProvider extends BaseProvider { ) } - const processStream = async ( - stream: Stream | OpenAI.Responses.Response, - idx: number - ) => { - const toolCalls: OpenAI.Responses.ResponseFunctionToolCall[] = [] + const processStream = async (stream: any, idx: number) => { + const toolCalls: ChatCompletionMessageToolCall[] = [] let time_first_token_millsec = 0 - if (!streamOutput) { - const nonStream = stream as OpenAI.Responses.Response - const time_completion_millsec = new Date().getTime() - start_time_millsec - const completion_tokens = - (nonStream.usage?.output_tokens || 0) + (nonStream.usage?.output_tokens_details.reasoning_tokens ?? 0) - const total_tokens = - (nonStream.usage?.total_tokens || 0) + (nonStream.usage?.output_tokens_details.reasoning_tokens ?? 0) - const finalMetrics = { - completion_tokens, - time_completion_millsec, - time_first_token_millsec: 0 - } - const finalUsage = { - completion_tokens, - prompt_tokens: nonStream.usage?.input_tokens || 0, - total_tokens - } + // Handle non-streaming case (already returns early, no change needed here) + if (!isSupportStreamOutput()) { + // Calculate final metrics once + finalMetrics.completion_tokens = stream.usage?.completion_tokens + finalMetrics.time_completion_millsec = new Date().getTime() - start_time_millsec + + // Create a synthetic usage object if stream.usage is undefined + finalUsage = { ...stream.usage } + // Separate onChunk calls for text and usage/metrics let content = '' - - for (const output of nonStream.output) { - switch (output.type) { - case 'message': - if (output.content[0].type === 'output_text') { - onChunk({ type: ChunkType.TEXT_DELTA, text: output.content[0].text }) - onChunk({ type: ChunkType.TEXT_COMPLETE, text: output.content[0].text }) - content += output.content[0].text - if (output.content[0].annotations && output.content[0].annotations.length > 0) { - onChunk({ - type: ChunkType.LLM_WEB_SEARCH_COMPLETE, - llm_web_search: { - source: WebSearchSource.OPENAI, - results: output.content[0].annotations - } - }) - } - } - break - case 'reasoning': - onChunk({ - type: ChunkType.THINKING_COMPLETE, - text: output.summary.map((s) => s.text).join('\n'), - thinking_millsec: new Date().getTime() - start_time_millsec - }) - break - case 'function_call': - toolCalls.push(output) + stream.choices.forEach((choice) => { + // reasoning + if (choice.message.reasoning) { + onChunk({ type: ChunkType.THINKING_DELTA, text: choice.message.reasoning }) + onChunk({ + type: ChunkType.THINKING_COMPLETE, + text: choice.message.reasoning, + thinking_millsec: new Date().getTime() - start_time_millsec + }) + } + // text + if (choice.message.content) { + content += choice.message.content + onChunk({ type: ChunkType.TEXT_DELTA, text: choice.message.content }) + } + // tool call + if (choice.message.tool_calls && choice.message.tool_calls.length) { + choice.message.tool_calls.forEach((t) => toolCalls.push(t)) } - } - if (content) { reqMessages.push({ - role: 'assistant', - content: content - }) - } - if (toolCalls.length) { - toolCalls.forEach((toolCall) => { - reqMessages.push(toolCall) + role: choice.message.role, + content: choice.message.content, + tool_calls: toolCalls.length + ? toolCalls.map((toolCall) => ({ + id: toolCall.id, + function: { + ...toolCall.function, + arguments: + typeof toolCall.function.arguments === 'string' + ? toolCall.function.arguments + : JSON.stringify(toolCall.function.arguments) + }, + type: 'function' + })) + : undefined }) + }) + + if (content.length) { + onChunk({ type: ChunkType.TEXT_COMPLETE, text: content }) } const toolResults: Awaited> = [] if (toolCalls.length) { toolResults.push(...(await processToolCalls(mcpTools, toolCalls))) } - if (content.length) { - toolResults.push(...(await processToolUses(content))) + if (stream.choices[0].message?.content) { + toolResults.push(...(await processToolUses(stream.choices[0].message?.content))) } await processToolResults(toolResults, idx) - onChunk({ - type: ChunkType.BLOCK_COMPLETE, - response: { - usage: finalUsage, - metrics: finalMetrics - } - }) + // Always send usage and metrics data + onChunk({ type: ChunkType.BLOCK_COMPLETE, response: { usage: finalUsage, metrics: finalMetrics } }) return } + let content = '' + let thinkingContent = '' + let isFirstChunk = true - const outputItems: OpenAI.Responses.ResponseOutputItem[] = [] + // 1. 初始化中间件 + const reasoningTags = [ + { openingTag: '', closingTag: '', separator: '\n' }, + { openingTag: '###Thinking', closingTag: '###Response', separator: '\n' } + ] + const getAppropriateTag = (model: Model) => { + if (model.id.includes('qwen3')) return reasoningTags[0] + return reasoningTags[0] + } + const reasoningTag = getAppropriateTag(model) + async function* openAIChunkToTextDelta(stream: any): AsyncGenerator { + for await (const chunk of stream) { + if (window.keyv.get(EVENT_NAMES.CHAT_COMPLETION_PAUSED)) { + break + } - for await (const chunk of stream as Stream) { - if (window.keyv.get(EVENT_NAMES.CHAT_COMPLETION_PAUSED)) { - break + const delta = chunk.choices[0]?.delta + if (delta?.reasoning_content || delta?.reasoning) { + yield { type: 'reasoning', textDelta: delta.reasoning_content || delta.reasoning } + } + if (delta?.content) { + yield { type: 'text-delta', textDelta: delta.content } + } + if (delta?.tool_calls) { + yield { type: 'tool-calls', delta: delta } + } + + const finishReason = chunk.choices[0]?.finish_reason + if (!isEmpty(finishReason)) { + yield { type: 'finish', finishReason, usage: chunk.usage, delta, chunk } + break + } } + } + + // 2. 使用中间件 + const { stream: processedStream } = await extractReasoningMiddleware({ + openingTag: reasoningTag?.openingTag, + closingTag: reasoningTag?.closingTag, + separator: reasoningTag?.separator, + enableReasoning + }).wrapStream({ + doStream: async () => ({ + stream: asyncGeneratorToReadableStream(openAIChunkToTextDelta(stream)) + }) + }) + + // 3. 消费 processedStream,分发 onChunk + for await (const chunk of readableStreamAsyncIterable(processedStream)) { + const delta = chunk.type === 'finish' ? chunk.delta : chunk + const rawChunk = chunk.type === 'finish' ? chunk.chunk : chunk + switch (chunk.type) { - case 'response.output_item.added': + case 'reasoning': { if (time_first_token_millsec === 0) { time_first_token_millsec = new Date().getTime() } - if (chunk.item.type === 'function_call') { - outputItems.push(chunk.item) - } - break - - case 'response.reasoning_summary_text.delta': + thinkingContent += chunk.textDelta onChunk({ type: ChunkType.THINKING_DELTA, - text: chunk.delta, + text: chunk.textDelta, thinking_millsec: new Date().getTime() - time_first_token_millsec }) break - case 'response.reasoning_summary_text.done': - onChunk({ - type: ChunkType.THINKING_COMPLETE, - text: chunk.text, - thinking_millsec: new Date().getTime() - time_first_token_millsec - }) - break - case 'response.output_text.delta': { - let delta = chunk.delta - if (isEnabledBuiltinWebSearch) { - delta = convertLinks(delta) - } - onChunk({ - type: ChunkType.TEXT_DELTA, - text: delta - }) - content += delta - break } - case 'response.output_text.done': - onChunk({ - type: ChunkType.TEXT_COMPLETE, - text: content - }) - break - case 'response.function_call_arguments.done': { - const outputItem: OpenAI.Responses.ResponseOutputItem | undefined = outputItems.find( - (item) => item.id === chunk.item_id - ) - if (outputItem) { - if (outputItem.type === 'function_call') { - toolCalls.push({ - ...outputItem, - arguments: chunk.arguments + case 'text-delta': { + let textDelta = chunk.textDelta + if (assistant.enableWebSearch && delta) { + const originalDelta = rawChunk?.choices?.[0]?.delta + + if (originalDelta?.annotations) { + textDelta = convertLinks(textDelta, isFirstChunk) + } else if (assistant.model?.provider === 'openrouter') { + textDelta = convertLinksToOpenRouter(textDelta, isFirstChunk) + } else if (isZhipuModel(assistant.model)) { + textDelta = convertLinksToZhipu(textDelta, isFirstChunk) + } else if (isHunyuanSearchModel(assistant.model)) { + const searchResults = rawChunk?.search_info?.search_results || [] + textDelta = convertLinksToHunyuan(textDelta, searchResults, isFirstChunk) + } + } + if (isFirstChunk) { + isFirstChunk = false + if (time_first_token_millsec === 0) { + time_first_token_millsec = new Date().getTime() + } else { + onChunk({ + type: ChunkType.THINKING_COMPLETE, + text: thinkingContent, + thinking_millsec: new Date().getTime() - time_first_token_millsec }) } } - + content += textDelta + onChunk({ type: ChunkType.TEXT_DELTA, text: textDelta }) break } - case 'response.content_part.done': - if (chunk.part.type === 'output_text' && chunk.part.annotations && chunk.part.annotations.length > 0) { - onChunk({ - type: ChunkType.LLM_WEB_SEARCH_COMPLETE, - llm_web_search: { - source: WebSearchSource.OPENAI, - results: chunk.part.annotations - } - }) + case 'tool-calls': { + if (isFirstChunk) { + isFirstChunk = false + if (time_first_token_millsec === 0) { + time_first_token_millsec = new Date().getTime() + } else { + onChunk({ + type: ChunkType.THINKING_COMPLETE, + text: thinkingContent, + thinking_millsec: new Date().getTime() - time_first_token_millsec + }) + } } - break - case 'response.completed': { - const completion_tokens = - (chunk.response.usage?.output_tokens || 0) + - (chunk.response.usage?.output_tokens_details.reasoning_tokens ?? 0) - const total_tokens = - (chunk.response.usage?.total_tokens || 0) + - (chunk.response.usage?.output_tokens_details.reasoning_tokens ?? 0) - finalUsage.completion_tokens += completion_tokens - finalUsage.prompt_tokens += chunk.response.usage?.input_tokens || 0 - finalUsage.total_tokens += total_tokens - finalMetrics.completion_tokens += completion_tokens - finalMetrics.time_completion_millsec += new Date().getTime() - start_time_millsec - finalMetrics.time_first_token_millsec = time_first_token_millsec - start_time_millsec - break - } - case 'error': - onChunk({ - type: ChunkType.ERROR, - error: { - message: chunk.message, - code: chunk.code + chunk.delta.tool_calls.forEach((toolCall) => { + const { id, index, type, function: fun } = toolCall + if (id && type === 'function' && fun) { + const { name, arguments: args } = fun + toolCalls.push({ + id, + function: { + name: name || '', + arguments: args || '' + }, + type: 'function' + }) + } else if (fun?.arguments) { + toolCalls[index].function.arguments += fun.arguments } }) break + } + case 'finish': { + const finishReason = chunk.finishReason + const usage = chunk.usage + const originalFinishDelta = chunk.delta + const originalFinishRawChunk = chunk.chunk + + if (!isEmpty(finishReason)) { + onChunk({ type: ChunkType.TEXT_COMPLETE, text: content }) + if (usage) { + finalUsage.completion_tokens += usage.completion_tokens || 0 + finalUsage.prompt_tokens += usage.prompt_tokens || 0 + finalUsage.total_tokens += usage.total_tokens || 0 + finalMetrics.completion_tokens += usage.completion_tokens || 0 + } + finalMetrics.time_completion_millsec += new Date().getTime() - start_time_millsec + finalMetrics.time_first_token_millsec = time_first_token_millsec - start_time_millsec + if (originalFinishDelta?.annotations) { + if (assistant.model?.provider === 'copilot') return + + onChunk({ + type: ChunkType.LLM_WEB_SEARCH_COMPLETE, + llm_web_search: { + results: originalFinishDelta.annotations, + source: WebSearchSource.OPENAI_RESPONSE + } + } as LLMWebSearchCompleteChunk) + } + if (assistant.model?.provider === 'perplexity') { + const citations = originalFinishRawChunk.citations + if (citations) { + onChunk({ + type: ChunkType.LLM_WEB_SEARCH_COMPLETE, + llm_web_search: { + results: citations, + source: WebSearchSource.PERPLEXITY + } + } as LLMWebSearchCompleteChunk) + } + } + if ( + isEnabledBultinWebSearch && + isZhipuModel(model) && + finishReason === 'stop' && + originalFinishRawChunk?.web_search + ) { + onChunk({ + type: ChunkType.LLM_WEB_SEARCH_COMPLETE, + llm_web_search: { + results: originalFinishRawChunk.web_search, + source: WebSearchSource.ZHIPU + } + } as LLMWebSearchCompleteChunk) + } + if ( + isEnabledBultinWebSearch && + isHunyuanSearchModel(model) && + originalFinishRawChunk?.search_info?.search_results + ) { + onChunk({ + type: ChunkType.LLM_WEB_SEARCH_COMPLETE, + llm_web_search: { + results: originalFinishRawChunk.search_info.search_results, + source: WebSearchSource.HUNYUAN + } + } as LLMWebSearchCompleteChunk) + } + } + break + } } - - // --- End of Incremental onChunk calls --- - } // End of for await loop - if (content) { - reqMessages.push({ - role: 'assistant', - content: content - }) - } - if (toolCalls.length) { - toolCalls.forEach((toolCall) => { - reqMessages.push(toolCall) - }) } - // Call processToolUses AFTER the loop finishes processing the main stream content - // Note: parseAndCallTools inside processToolUses should handle its own onChunk for tool responses - const toolResults: Awaited> = [] + reqMessages.push({ + role: 'assistant', + content: content, + tool_calls: toolCalls.length + ? toolCalls.map((toolCall) => ({ + id: toolCall.id, + function: { + ...toolCall.function, + arguments: + typeof toolCall.function.arguments === 'string' + ? toolCall.function.arguments + : JSON.stringify(toolCall.function.arguments) + }, + type: 'function' + })) + : undefined + }) + let toolResults: Awaited> = [] if (toolCalls.length) { - toolResults.push(...(await processToolCalls(mcpTools, toolCalls))) + toolResults = await processToolCalls(mcpTools, toolCalls) } - if (content) { - toolResults.push(...(await processToolUses(content))) + if (content.length) { + toolResults = toolResults.concat(await processToolUses(content)) + } + if (toolResults.length) { + await processToolResults(toolResults, idx) } - await processToolResults(toolResults, idx) onChunk({ type: ChunkType.BLOCK_COMPLETE, @@ -783,27 +827,33 @@ export abstract class BaseOpenAiProvider extends BaseProvider { }) } + reqMessages = processReqMessages(model, reqMessages) + // 等待接口返回流 onChunk({ type: ChunkType.LLM_RESPONSE_CREATED }) const start_time_millsec = new Date().getTime() - const stream = await this.sdk.responses.create( - { - model: model.id, - input: reqMessages, - temperature: this.getTemperature(assistant, model), - top_p: this.getTopP(assistant, model), - max_output_tokens: maxTokens, - stream: streamOutput, - tools: tools.length > 0 ? tools : undefined, - tool_choice: isEnabledBuiltinWebSearch ? toolChoices : undefined, - service_tier: this.getServiceTier(model), - ...this.getResponseReasoningEffort(assistant, model), - ...this.getCustomParameters(assistant) - }, - { - signal, - timeout: this.getTimeout(model) - } - ) + const stream = await this.sdk.chat.completions + // @ts-ignore key is not typed + .create( + { + model: model.id, + messages: reqMessages, + temperature: this.getTemperature(assistant, model), + top_p: this.getTopP(assistant, model), + max_tokens: maxTokens, + keep_alive: this.keepAliveTime, + stream: isSupportStreamOutput(), + tools: !isEmpty(tools) ? tools : undefined, + service_tier: this.getServiceTier(model), + ...getOpenAIWebSearchParams(assistant, model), + ...this.getReasoningEffort(assistant, model), + ...this.getProviderSpecificParameters(assistant, model), + ...this.getCustomParameters(assistant) + }, + { + signal, + timeout: this.getTimeout(model) + } + ) await processStream(stream, 0).finally(cleanup) @@ -814,163 +864,218 @@ export abstract class BaseOpenAiProvider extends BaseProvider { } /** - * Translate the content - * @param content - The content + * Translate a message + * @param content * @param assistant - The assistant * @param onResponse - The onResponse callback - * @returns The translated content + * @returns The translated message */ - async translate( - content: string, - assistant: Assistant, - onResponse?: (text: string, isComplete: boolean) => void - ): Promise { + async translate(content: string, assistant: Assistant, onResponse?: (text: string, isComplete: boolean) => void) { const defaultModel = getDefaultModel() const model = assistant.model || defaultModel - const messageForApi: OpenAI.Responses.EasyInputMessage[] = content + + const messagesForApi = content ? [ - { - role: 'system', - content: assistant.prompt - }, - { - role: 'user', - content - } + { role: 'system', content: assistant.prompt }, + { role: 'user', content } ] : [{ role: 'user', content: assistant.prompt }] - const isOpenAIReasoning = isOpenAIReasoningModel(model) const isSupportedStreamOutput = () => { if (!onResponse) { return false } - return !isOpenAIReasoning + return true } const stream = isSupportedStreamOutput() - let text = '' - if (stream) { - const response = await this.sdk.responses.create({ - model: model.id, - input: messageForApi, - stream: true, - temperature: this.getTemperature(assistant, model), - top_p: this.getTopP(assistant, model), - ...this.getResponseReasoningEffort(assistant, model) - }) - for await (const chunk of response) { - switch (chunk.type) { - case 'response.output_text.delta': - text += chunk.delta - onResponse?.(text, false) - break - case 'response.output_text.done': - onResponse?.(chunk.text, true) - break - } - } - } else { - const response = await this.sdk.responses.create({ - model: model.id, - input: messageForApi, - stream: false, - temperature: this.getTemperature(assistant, model), - top_p: this.getTopP(assistant, model), - ...this.getResponseReasoningEffort(assistant, model) - }) - return response.output_text + await this.checkIsCopilot() + + // console.debug('[translate] reqMessages', model.id, message) + // @ts-ignore key is not typed + const response = await this.sdk.chat.completions.create({ + model: model.id, + messages: messagesForApi as ChatCompletionMessageParam[], + stream, + keep_alive: this.keepAliveTime, + temperature: this.getTemperature(assistant, model), + top_p: this.getTopP(assistant, model), + ...this.getReasoningEffort(assistant, model) + }) + + if (!stream) { + return response.choices[0].message?.content || '' } + let text = '' + let isThinking = false + const isReasoning = isReasoningModel(model) + + for await (const chunk of response) { + const deltaContent = chunk.choices[0]?.delta?.content || '' + + if (isReasoning) { + if (deltaContent.includes('')) { + isThinking = true + } + + if (!isThinking) { + text += deltaContent + onResponse?.(text, false) + } + + if (deltaContent.includes('')) { + isThinking = false + } + } else { + text += deltaContent + onResponse?.(text, false) + } + } + + onResponse?.(text, true) + return text } /** - * Summarize the messages + * Summarize a message * @param messages - The messages * @param assistant - The assistant * @returns The summary */ public async summaries(messages: Message[], assistant: Assistant): Promise { const model = getTopNamingModel() || assistant.model || getDefaultModel() + const userMessages = takeRight(messages, 5) .filter((message) => !message.isPreset) .map((message) => ({ role: message.role, content: getMainTextContent(message) })) + const userMessageContent = userMessages.reduce((prev, curr) => { const content = curr.role === 'user' ? `User: ${curr.content}` : `Assistant: ${curr.content}` return prev + (prev ? '\n' : '') + content }, '') - const systemMessage: OpenAI.Responses.EasyInputMessage = { + const systemMessage = { role: 'system', - content: (getStoreSetting('topicNamingPrompt') as string) || i18n.t('prompts.title') + content: getStoreSetting('topicNamingPrompt') || i18n.t('prompts.title') } - const userMessage: OpenAI.Responses.EasyInputMessage = { + const userMessage = { role: 'user', content: userMessageContent } - const response = await this.sdk.responses.create({ + await this.checkIsCopilot() + + // @ts-ignore key is not typed + const response = await this.sdk.chat.completions.create({ model: model.id, - input: [systemMessage, userMessage], + messages: [systemMessage, userMessage] as ChatCompletionMessageParam[], stream: false, - max_output_tokens: 1000 + keep_alive: this.keepAliveTime, + max_tokens: 1000 }) - return removeSpecialCharactersForTopicName(response.output_text.substring(0, 50)) + + // 针对思考类模型的返回,总结仅截取之后的内容 + let content = response.choices[0].message?.content || '' + content = content.replace(/^(.*?)<\/think>/s, '') + + return removeSpecialCharactersForTopicName(content.substring(0, 50)) } + /** + * Summarize a message for search + * @param messages - The messages + * @param assistant - The assistant + * @returns The summary + */ public async summaryForSearch(messages: Message[], assistant: Assistant): Promise { - const model = getTopNamingModel() || assistant.model || getDefaultModel() - const systemMessage: OpenAI.Responses.EasyInputMessage = { + const model = assistant.model || getDefaultModel() + + const systemMessage = { role: 'system', content: assistant.prompt } + const messageContents = messages.map((m) => getMainTextContent(m)) const userMessageContent = messageContents.join('\n') - const userMessage: OpenAI.Responses.EasyInputMessage = { + + const userMessage = { role: 'user', content: userMessageContent } + const lastUserMessage = messages[messages.length - 1] const { abortController, cleanup } = this.createAbortController(lastUserMessage?.id) const { signal } = abortController - const response = await this.sdk.responses + const response = await this.sdk.chat.completions + // @ts-ignore key is not typed .create( { model: model.id, - input: [systemMessage, userMessage], + messages: [systemMessage, userMessage] as ChatCompletionMessageParam[], stream: false, - max_output_tokens: 1000 + keep_alive: this.keepAliveTime, + max_tokens: 1000 }, { - signal, - timeout: 20 * 1000 + timeout: 20 * 1000, + signal: signal } ) .finally(cleanup) - return response.output_text + // 针对思考类模型的返回,总结仅截取之后的内容 + let content = response.choices[0].message?.content || '' + content = content.replace(/^(.*?)<\/think>/s, '') + + return content } /** - * Generate suggestions + * Generate text + * @param prompt - The prompt + * @param content - The content + * @returns The generated text + */ + public async generateText({ prompt, content }: { prompt: string; content: string }): Promise { + const model = getDefaultModel() + + await this.checkIsCopilot() + + const response = await this.sdk.chat.completions.create({ + model: model.id, + stream: false, + messages: [ + { role: 'system', content: prompt }, + { role: 'user', content } + ] + }) + + return response.choices[0].message?.content || '' + } + + /** + * Generate suggestions * @param messages - The messages * @param assistant - The assistant * @returns The suggestions */ async suggestions(messages: Message[], assistant: Assistant): Promise { - const model = assistant.model + const { model } = assistant if (!model) { return [] } + await this.checkIsCopilot() + const userMessagesForApi = messages .filter((m) => m.role === 'user') .map((m) => ({ @@ -993,63 +1098,53 @@ export abstract class BaseOpenAiProvider extends BaseProvider { return response?.questions?.filter(Boolean)?.map((q: any) => ({ content: q })) || [] } - /** - * Generate text - * @param prompt - The prompt - * @param content - The content - * @returns The generated text - */ - public async generateText({ prompt, content }: { prompt: string; content: string }): Promise { - const model = getDefaultModel() - const response = await this.sdk.responses.create({ - model: model.id, - stream: false, - input: [ - { role: 'system', content: prompt }, - { role: 'user', content } - ] - }) - return response.output_text - } - /** * Check if the model is valid * @param model - The model * @param stream - Whether to use streaming interface * @returns The validity of the model */ - public async check(model: Model, stream: boolean): Promise<{ valid: boolean; error: Error | null }> { + public async check(model: Model, stream: boolean = false): Promise<{ valid: boolean; error: Error | null }> { if (!model) { return { valid: false, error: new Error('No model found') } } - if (stream) { - const response = await this.sdk.responses.create({ - model: model.id, - input: [{ role: 'user', content: 'hi' }], - max_output_tokens: 1, - stream: true - }) - let hasContent = false - for await (const chunk of response) { - if (chunk.type === 'response.output_text.delta') { - hasContent = true + + const body = { + model: model.id, + messages: [{ role: 'user', content: 'hi' }], + max_completion_tokens: 1, // openAI + max_tokens: 1, // openAI deprecated 但大部分OpenAI兼容的提供商继续用这个头 + enable_thinking: false, // qwen3 + stream + } + + try { + await this.checkIsCopilot() + if (!stream) { + const response = await this.sdk.chat.completions.create(body as ChatCompletionCreateParamsNonStreaming) + if (!response?.choices[0].message) { + throw new Error('Empty response') } - } - if (hasContent) { return { valid: true, error: null } + } else { + const response: any = await this.sdk.chat.completions.create(body as any) + // 等待整个流式响应结束 + let hasContent = false + for await (const chunk of response) { + if (chunk.choices?.[0]?.delta?.content) { + hasContent = true + } + } + if (hasContent) { + return { valid: true, error: null } + } + throw new Error('Empty streaming response') } - throw new Error('Empty streaming response') - } else { - const response = await this.sdk.responses.create({ - model: model.id, - input: [{ role: 'user', content: 'hi' }], - stream: false, - max_output_tokens: 1 - }) - if (!response.output_text) { - throw new Error('Empty response') + } catch (error: any) { + return { + valid: false, + error } - return { valid: true, error: null } } } @@ -1059,207 +1154,67 @@ export abstract class BaseOpenAiProvider extends BaseProvider { */ public async models(): Promise { try { + await this.checkIsCopilot() + const response = await this.sdk.models.list() + + if (this.provider.id === 'github') { + // @ts-ignore key is not typed + return response.body + .map((model) => ({ + id: model.name, + description: model.summary, + object: 'model', + owned_by: model.publisher + })) + .filter(isSupportedModel) + } + + if (this.provider.id === 'together') { + // @ts-ignore key is not typed + return response?.body + .map((model: any) => ({ + id: model.id, + description: model.display_name, + object: 'model', + owned_by: model.organization + })) + .filter(isSupportedModel) + } + const models = response.data || [] models.forEach((model) => { model.id = model.id.trim() }) + return models.filter(isSupportedModel) } catch (error) { return [] } } - /** - * Generate an image - * @param params - The parameters - * @returns The generated image - */ - public async generateImage({ - model, - prompt, - negativePrompt, - imageSize, - batchSize, - seed, - numInferenceSteps, - guidanceScale, - signal, - promptEnhancement - }: GenerateImageParams): Promise { - const response = (await this.sdk.request({ - method: 'post', - path: '/images/generations', - signal, - body: { - model, - prompt, - negative_prompt: negativePrompt, - image_size: imageSize, - batch_size: batchSize, - seed: seed ? parseInt(seed) : undefined, - num_inference_steps: numInferenceSteps, - guidance_scale: guidanceScale, - prompt_enhancement: promptEnhancement - } - })) as { data: Array<{ url: string }> } - - return response.data.map((item) => item.url) - } - - public async generateImageByChat({ messages, assistant, onChunk }: CompletionsParams): Promise { - const defaultModel = getDefaultModel() - const model = assistant.model || defaultModel - // save image data from the last assistant message - messages = addImageFileToContents(messages) - const lastUserMessage = messages.findLast((m) => m.role === 'user') - const lastAssistantMessage = messages.findLast((m) => m.role === 'assistant') - if (!lastUserMessage) { - return - } - - const { abortController } = this.createAbortController(lastUserMessage?.id, true) - const { signal } = abortController - const content = getMainTextContent(lastUserMessage!) - let response: OpenAI.Images.ImagesResponse | null = null - let images: FileLike[] = [] - - try { - if (lastUserMessage) { - const UserFiles = findImageBlocks(lastUserMessage) - const validUserFiles = UserFiles.filter((f) => f.file) // Filter out files that are undefined first - const userImages = await Promise.all( - validUserFiles.map(async (f) => { - // f.file is guaranteed to exist here due to the filter above - const fileInfo = f.file! - const binaryData = await FileManager.readBinaryImage(fileInfo) - return await toFile(binaryData, fileInfo.origin_name || 'image.png', { - type: 'image/png' - }) - }) - ) - images = images.concat(userImages) - } - - if (lastAssistantMessage) { - const assistantFiles = findImageBlocks(lastAssistantMessage) - const assistantImages = await Promise.all( - assistantFiles.filter(Boolean).map(async (f) => { - const base64Data = f?.url?.replace(/^data:image\/\w+;base64,/, '') - if (!base64Data) return null - const binary = atob(base64Data) - const bytes = new Uint8Array(binary.length) - for (let i = 0; i < binary.length; i++) { - bytes[i] = binary.charCodeAt(i) - } - return await toFile(bytes, 'assistant_image.png', { - type: 'image/png' - }) - }) - ) - images = images.concat(assistantImages.filter(Boolean) as FileLike[]) - } - onChunk({ - type: ChunkType.IMAGE_CREATED - }) - - const start_time_millsec = new Date().getTime() - - if (images.length > 0) { - response = await this.sdk.images.edit( - { - model: model.id, - image: images, - prompt: content || '' - }, - { - signal, - timeout: 300_000 - } - ) - } else { - response = await this.sdk.images.generate( - { - model: model.id, - prompt: content || '', - response_format: model.id.includes('gpt-image-1') ? undefined : 'b64_json' - }, - { - signal, - timeout: 300_000 - } - ) - } - - onChunk({ - type: ChunkType.IMAGE_COMPLETE, - image: { - type: 'base64', - images: response?.data?.map((item) => `data:image/png;base64,${item.b64_json}`) || [] - } - }) - - onChunk({ - type: ChunkType.BLOCK_COMPLETE, - response: { - usage: { - completion_tokens: response.usage?.output_tokens || 0, - prompt_tokens: response.usage?.input_tokens || 0, - total_tokens: response.usage?.total_tokens || 0 - }, - metrics: { - completion_tokens: response.usage?.output_tokens || 0, - time_first_token_millsec: 0, // Non-streaming, first token time is not relevant - time_completion_millsec: new Date().getTime() - start_time_millsec - } - } - }) - } catch (error: any) { - console.error('[generateImageByChat] error', error) - onChunk({ - type: ChunkType.ERROR, - error - }) - } - } - /** * Get the embedding dimensions * @param model - The model * @returns The embedding dimensions */ public async getEmbeddingDimensions(model: Model): Promise { + await this.checkIsCopilot() + const data = await this.sdk.embeddings.create({ model: model.id, - input: 'hi' + input: model?.provider === 'baidu-cloud' ? ['hi'] : 'hi' }) return data.data[0].embedding.length } -} -export default class OpenAIProvider extends BaseOpenAiProvider { - constructor(provider: Provider) { - super(provider) - } - - public convertMcpTools(mcpTools: MCPTool[]) { - return mcpToolsToOpenAIResponseTools(mcpTools) as T[] - } - - public mcpToolCallResponseToMessage = ( - mcpToolResponse: MCPToolResponse, - resp: MCPCallToolResponse, - model: Model - ): OpenAI.Responses.ResponseInputItem | undefined => { - if ('toolUseId' in mcpToolResponse && mcpToolResponse.toolUseId) { - return mcpToolCallResponseToOpenAIMessage(mcpToolResponse, resp, isVisionModel(model)) - } else if ('toolCallId' in mcpToolResponse && mcpToolResponse.toolCallId) { - return { - type: 'function_call_output', - call_id: mcpToolResponse.toolCallId, - output: JSON.stringify(resp.content) - } + public async checkIsCopilot() { + if (this.provider.id !== 'copilot') { + return } - return + const defaultHeaders = store.getState().copilot.defaultHeaders + // copilot每次请求前需要重新获取token,因为token中附带时间戳 + const { token } = await window.api.copilot.getToken(defaultHeaders) + this.sdk.apiKey = token } } diff --git a/src/renderer/src/providers/AiProvider/OpenAIResponseProvider.ts b/src/renderer/src/providers/AiProvider/OpenAIResponseProvider.ts new file mode 100644 index 0000000000..8ebcc475a1 --- /dev/null +++ b/src/renderer/src/providers/AiProvider/OpenAIResponseProvider.ts @@ -0,0 +1,1265 @@ +import { + getOpenAIWebSearchParams, + isOpenAILLMModel, + isOpenAIReasoningModel, + isOpenAIWebSearch, + isSupportedModel, + isSupportedReasoningEffortOpenAIModel, + isVisionModel +} from '@renderer/config/models' +import { getStoreSetting } from '@renderer/hooks/useSettings' +import i18n from '@renderer/i18n' +import { getAssistantSettings, getDefaultModel, getTopNamingModel } from '@renderer/services/AssistantService' +import { EVENT_NAMES } from '@renderer/services/EventService' +import FileManager from '@renderer/services/FileManager' +import { + filterContextMessages, + filterEmptyMessages, + filterUserRoleStartMessages +} from '@renderer/services/MessagesService' +import { + Assistant, + FileTypes, + GenerateImageParams, + MCPCallToolResponse, + MCPTool, + MCPToolResponse, + Metrics, + Model, + Provider, + Suggestion, + ToolCallResponse, + Usage, + WebSearchSource +} from '@renderer/types' +import { ChunkType } from '@renderer/types/chunk' +import { Message } from '@renderer/types/newMessage' +import { removeSpecialCharactersForTopicName } from '@renderer/utils' +import { addImageFileToContents } from '@renderer/utils/formats' +import { convertLinks } from '@renderer/utils/linkConverter' +import { + mcpToolCallResponseToOpenAIMessage, + mcpToolsToOpenAIResponseTools, + openAIToolsToMcpTool, + parseAndCallTools +} from '@renderer/utils/mcp-tools' +import { findFileBlocks, findImageBlocks, getMainTextContent } from '@renderer/utils/messageUtils/find' +import { buildSystemPrompt } from '@renderer/utils/prompt' +import { isEmpty, takeRight } from 'lodash' +import OpenAI from 'openai' +import { ChatCompletionContentPart, ChatCompletionMessageParam } from 'openai/resources/chat/completions' +import { Stream } from 'openai/streaming' +import { FileLike, toFile } from 'openai/uploads' + +import { CompletionsParams } from '.' +import BaseProvider from './BaseProvider' + +export abstract class BaseOpenAiProvider extends BaseProvider { + protected sdk: OpenAI + + constructor(provider: Provider) { + super(provider) + + this.sdk = new OpenAI({ + dangerouslyAllowBrowser: true, + apiKey: this.apiKey, + baseURL: this.getBaseURL(), + defaultHeaders: { + ...this.defaultHeaders() + } + }) + } + + abstract convertMcpTools(mcpTools: MCPTool[]): T[] + + abstract mcpToolCallResponseToMessage: ( + mcpToolResponse: MCPToolResponse, + resp: MCPCallToolResponse, + model: Model + ) => OpenAI.Responses.ResponseInputItem | ChatCompletionMessageParam | undefined + + /** + * Extract the file content from the message + * @param message - The message + * @returns The file content + */ + protected async extractFileContent(message: Message) { + const fileBlocks = findFileBlocks(message) + if (fileBlocks.length > 0) { + const textFileBlocks = fileBlocks.filter( + (fb) => fb.file && [FileTypes.TEXT, FileTypes.DOCUMENT].includes(fb.file.type) + ) + + if (textFileBlocks.length > 0) { + let text = '' + const divider = '\n\n---\n\n' + + for (const fileBlock of textFileBlocks) { + const file = fileBlock.file + const fileContent = (await window.api.file.read(file.id + file.ext)).trim() + const fileNameRow = 'file: ' + file.origin_name + '\n\n' + text = text + fileNameRow + fileContent + divider + } + + return text + } + } + + return '' + } + + private async getReponseMessageParam(message: Message, model: Model): Promise { + const isVision = isVisionModel(model) + const content = await this.getMessageContent(message) + const fileBlocks = findFileBlocks(message) + const imageBlocks = findImageBlocks(message) + + if (fileBlocks.length === 0 && imageBlocks.length === 0) { + if (message.role === 'assistant') { + return { + role: 'assistant', + content: content + } + } else { + return { + role: message.role === 'system' ? 'user' : message.role, + content: content ? [{ type: 'input_text', text: content }] : [] + } as OpenAI.Responses.EasyInputMessage + } + } + + const parts: OpenAI.Responses.ResponseInputContent[] = [] + if (content) { + parts.push({ + type: 'input_text', + text: content + }) + } + + for (const imageBlock of imageBlocks) { + if (isVision) { + if (imageBlock.file) { + const image = await window.api.file.base64Image(imageBlock.file.id + imageBlock.file.ext) + parts.push({ + detail: 'auto', + type: 'input_image', + image_url: image.data as string + }) + } else if (imageBlock.url && imageBlock.url.startsWith('data:')) { + parts.push({ + detail: 'auto', + type: 'input_image', + image_url: imageBlock.url + }) + } + } + } + + for (const fileBlock of fileBlocks) { + const file = fileBlock.file + if (!file) continue + + if ([FileTypes.TEXT, FileTypes.DOCUMENT].includes(file.type)) { + const fileContent = (await window.api.file.read(file.id + file.ext)).trim() + parts.push({ + type: 'input_text', + text: file.origin_name + '\n' + fileContent + }) + } + } + + return { + role: message.role === 'system' ? 'user' : message.role, + content: parts + } + } + + protected getServiceTier(model: Model) { + if ((model.id.includes('o3') && !model.id.includes('o3-mini')) || model.id.includes('o4-mini')) { + return 'flex' + } + if (isOpenAILLMModel(model)) { + return 'auto' + } + return undefined + } + + protected getTimeout(model: Model) { + if ((model.id.includes('o3') && !model.id.includes('o3-mini')) || model.id.includes('o4-mini')) { + return 15 * 1000 * 60 + } + return 5 * 1000 * 60 + } + + /** + * Get the temperature for the assistant + * @param assistant - The assistant + * @param model - The model + * @returns The temperature + */ + protected getTemperature(assistant: Assistant, model: Model) { + return isOpenAIReasoningModel(model) || isOpenAILLMModel(model) ? undefined : assistant?.settings?.temperature + } + + /** + * Get the top P for the assistant + * @param assistant - The assistant + * @param model - The model + * @returns The top P + */ + protected getTopP(assistant: Assistant, model: Model) { + return isOpenAIReasoningModel(model) || isOpenAILLMModel(model) ? undefined : assistant?.settings?.topP + } + + private getResponseReasoningEffort(assistant: Assistant, model: Model) { + if (!isSupportedReasoningEffortOpenAIModel(model)) { + return {} + } + + const reasoningEffort = assistant?.settings?.reasoning_effort + if (!reasoningEffort) { + return {} + } + + if (isSupportedReasoningEffortOpenAIModel(model)) { + return { + reasoning: { + effort: reasoningEffort as OpenAI.ReasoningEffort, + summary: 'detailed' + } as OpenAI.Reasoning + } + } + + return {} + } + + /** + * Get the message parameter + * @param message - The message + * @param model - The model + * @returns The message parameter + */ + protected async getMessageParam( + message: Message, + model: Model + ): Promise { + const isVision = isVisionModel(model) + const content = await this.getMessageContent(message) + const fileBlocks = findFileBlocks(message) + const imageBlocks = findImageBlocks(message) + + if (fileBlocks.length === 0 && imageBlocks.length === 0) { + return { + role: message.role === 'system' ? 'user' : message.role, + content + } + } + + const parts: ChatCompletionContentPart[] = [] + + if (content) { + parts.push({ type: 'text', text: content }) + } + + for (const imageBlock of imageBlocks) { + if (isVision) { + if (imageBlock.file) { + const image = await window.api.file.base64Image(imageBlock.file.id + imageBlock.file.ext) + parts.push({ type: 'image_url', image_url: { url: image.data } }) + } else if (imageBlock.url && imageBlock.url.startsWith('data:')) { + parts.push({ type: 'image_url', image_url: { url: imageBlock.url } }) + } + } + } + + for (const fileBlock of fileBlocks) { + const { file } = fileBlock + if (!file) { + continue + } + + if ([FileTypes.TEXT, FileTypes.DOCUMENT].includes(file.type)) { + const fileContent = await (await window.api.file.read(file.id + file.ext)).trim() + parts.push({ + type: 'text', + text: file.origin_name + '\n' + fileContent + }) + } + } + + return { + role: message.role === 'system' ? 'user' : message.role, + content: parts + } as ChatCompletionMessageParam + } + + /** + * Generate completions for the assistant use Response API + * @param messages - The messages + * @param assistant - The assistant + * @param mcpTools + * @param onChunk - The onChunk callback + * @param onFilterMessages - The onFilterMessages callback + * @returns The completions + */ + async completions({ messages, assistant, mcpTools, onChunk, onFilterMessages }: CompletionsParams): Promise { + if (assistant.enableGenerateImage) { + await this.generateImageByChat({ messages, assistant, onChunk } as CompletionsParams) + return + } + const defaultModel = getDefaultModel() + const model = assistant.model || defaultModel + const { contextCount, maxTokens, streamOutput, enableToolUse } = getAssistantSettings(assistant) + const isEnabledBuiltinWebSearch = assistant.enableWebSearch + // 退回到 OpenAI 兼容模式 + if (isOpenAIWebSearch(model)) { + const systemMessage = { role: 'system', content: assistant.prompt || '' } + const userMessages: ChatCompletionMessageParam[] = [] + const _messages = filterUserRoleStartMessages( + filterEmptyMessages(filterContextMessages(takeRight(messages, contextCount + 1))) + ) + onFilterMessages(_messages) + + for (const message of _messages) { + userMessages.push(await this.getMessageParam(message, model)) + } + //当 systemMessage 内容为空时不发送 systemMessage + let reqMessages: ChatCompletionMessageParam[] + if (!systemMessage.content) { + reqMessages = [...userMessages] + } else { + reqMessages = [systemMessage, ...userMessages].filter(Boolean) as ChatCompletionMessageParam[] + } + const lastUserMessage = _messages.findLast((m) => m.role === 'user') + const { abortController, cleanup, signalPromise } = this.createAbortController(lastUserMessage?.id, true) + const { signal } = abortController + const start_time_millsec = new Date().getTime() + const response = await this.sdk.chat.completions + // @ts-ignore key is not typed + .create( + { + model: model.id, + messages: reqMessages, + stream: true, + temperature: this.getTemperature(assistant, model), + top_p: this.getTopP(assistant, model), + max_tokens: maxTokens, + ...getOpenAIWebSearchParams(assistant, model), + ...this.getCustomParameters(assistant) + }, + { + signal + } + ) + const processStream = async (stream: any) => { + let content = '' + let isFirstChunk = true + const finalUsage: Usage = { + completion_tokens: 0, + prompt_tokens: 0, + total_tokens: 0 + } + + const finalMetrics: Metrics = { + completion_tokens: 0, + time_completion_millsec: 0, + time_first_token_millsec: 0 + } + for await (const chunk of stream as any) { + if (window.keyv.get(EVENT_NAMES.CHAT_COMPLETION_PAUSED)) { + break + } + const delta = chunk.choices[0]?.delta + const finishReason = chunk.choices[0]?.finish_reason + if (delta?.content) { + if (isOpenAIWebSearch(model)) { + delta.content = convertLinks(delta.content || '', isFirstChunk) + } + if (isFirstChunk) { + isFirstChunk = false + finalMetrics.time_first_token_millsec = new Date().getTime() - start_time_millsec + } + content += delta.content + onChunk({ type: ChunkType.TEXT_DELTA, text: delta.content }) + } + if (!isEmpty(finishReason) || chunk?.annotations) { + onChunk({ type: ChunkType.TEXT_COMPLETE, text: content }) + finalMetrics.time_completion_millsec = new Date().getTime() - start_time_millsec + if (chunk.usage) { + const usage = chunk.usage as OpenAI.Completions.CompletionUsage + finalUsage.completion_tokens = usage.completion_tokens + finalUsage.prompt_tokens = usage.prompt_tokens + finalUsage.total_tokens = usage.total_tokens + } + finalMetrics.completion_tokens = finalUsage.completion_tokens + } + if (delta?.annotations) { + onChunk({ + type: ChunkType.LLM_WEB_SEARCH_COMPLETE, + llm_web_search: { + results: delta.annotations, + source: WebSearchSource.OPENAI + } + }) + } + } + onChunk({ + type: ChunkType.BLOCK_COMPLETE, + response: { + usage: finalUsage, + metrics: finalMetrics + } + }) + } + await processStream(response).finally(cleanup) + await signalPromise?.promise?.catch((error) => { + throw error + }) + return + } + let tools: OpenAI.Responses.Tool[] = [] + const toolChoices: OpenAI.Responses.ToolChoiceTypes = { + type: 'web_search_preview' + } + if (isEnabledBuiltinWebSearch) { + tools.push({ + type: 'web_search_preview' + }) + } + messages = addImageFileToContents(messages) + const systemMessage: OpenAI.Responses.EasyInputMessage = { + role: 'system', + content: [] + } + const systemMessageContent: OpenAI.Responses.ResponseInputMessageContentList = [] + const systemMessageInput: OpenAI.Responses.ResponseInputText = { + text: assistant.prompt || '', + type: 'input_text' + } + if (isSupportedReasoningEffortOpenAIModel(model)) { + systemMessage.role = 'developer' + } + + const { tools: extraTools } = this.setupToolsConfig({ + mcpTools, + model, + enableToolUse + }) + + tools = tools.concat(extraTools) + + if (this.useSystemPromptForTools) { + systemMessageInput.text = buildSystemPrompt(systemMessageInput.text || '', mcpTools) + } + systemMessageContent.push(systemMessageInput) + systemMessage.content = systemMessageContent + const _messages = filterUserRoleStartMessages( + filterEmptyMessages(filterContextMessages(takeRight(messages, contextCount + 1))) + ) + + onFilterMessages(_messages) + const userMessage: OpenAI.Responses.ResponseInputItem[] = [] + for (const message of _messages) { + userMessage.push(await this.getReponseMessageParam(message, model)) + } + + const lastUserMessage = _messages.findLast((m) => m.role === 'user') + const { abortController, cleanup, signalPromise } = this.createAbortController(lastUserMessage?.id, true) + const { signal } = abortController + + // 当 systemMessage 内容为空时不发送 systemMessage + let reqMessages: OpenAI.Responses.ResponseInput + if (!systemMessage.content) { + reqMessages = [...userMessage] + } else { + reqMessages = [systemMessage, ...userMessage].filter(Boolean) as OpenAI.Responses.EasyInputMessage[] + } + + const finalUsage: Usage = { + completion_tokens: 0, + prompt_tokens: 0, + total_tokens: 0 + } + + const finalMetrics: Metrics = { + completion_tokens: 0, + time_completion_millsec: 0, + time_first_token_millsec: 0 + } + + const toolResponses: MCPToolResponse[] = [] + + const processToolResults = async (toolResults: Awaited>, idx: number) => { + if (toolResults.length === 0) return + + toolResults.forEach((ts) => reqMessages.push(ts as OpenAI.Responses.EasyInputMessage)) + + onChunk({ type: ChunkType.LLM_RESPONSE_CREATED }) + const stream = await this.sdk.responses.create( + { + model: model.id, + input: reqMessages, + temperature: this.getTemperature(assistant, model), + top_p: this.getTopP(assistant, model), + max_output_tokens: maxTokens, + stream: streamOutput, + tools: !isEmpty(tools) ? tools : undefined, + service_tier: this.getServiceTier(model), + ...this.getResponseReasoningEffort(assistant, model), + ...this.getCustomParameters(assistant) + }, + { + signal, + timeout: this.getTimeout(model) + } + ) + await processStream(stream, idx + 1) + } + + const processToolCalls = async (mcpTools, toolCalls: OpenAI.Responses.ResponseFunctionToolCall[]) => { + const mcpToolResponses = toolCalls + .map((toolCall) => { + const mcpTool = openAIToolsToMcpTool(mcpTools, toolCall as OpenAI.Responses.ResponseFunctionToolCall) + if (!mcpTool) return undefined + + const parsedArgs = (() => { + try { + return JSON.parse(toolCall.arguments) + } catch { + return toolCall.arguments + } + })() + + return { + id: toolCall.call_id, + toolCallId: toolCall.call_id, + tool: mcpTool, + arguments: parsedArgs, + status: 'pending' + } as ToolCallResponse + }) + .filter((t): t is ToolCallResponse => typeof t !== 'undefined') + + return await parseAndCallTools( + mcpToolResponses, + toolResponses, + onChunk, + this.mcpToolCallResponseToMessage, + model, + mcpTools + ) + } + + const processToolUses = async (content: string) => { + return await parseAndCallTools( + content, + toolResponses, + onChunk, + this.mcpToolCallResponseToMessage, + model, + mcpTools + ) + } + + const processStream = async ( + stream: Stream | OpenAI.Responses.Response, + idx: number + ) => { + const toolCalls: OpenAI.Responses.ResponseFunctionToolCall[] = [] + let time_first_token_millsec = 0 + + if (!streamOutput) { + const nonStream = stream as OpenAI.Responses.Response + const time_completion_millsec = new Date().getTime() - start_time_millsec + const completion_tokens = + (nonStream.usage?.output_tokens || 0) + (nonStream.usage?.output_tokens_details.reasoning_tokens ?? 0) + const total_tokens = + (nonStream.usage?.total_tokens || 0) + (nonStream.usage?.output_tokens_details.reasoning_tokens ?? 0) + const finalMetrics = { + completion_tokens, + time_completion_millsec, + time_first_token_millsec: 0 + } + const finalUsage = { + completion_tokens, + prompt_tokens: nonStream.usage?.input_tokens || 0, + total_tokens + } + let content = '' + + for (const output of nonStream.output) { + switch (output.type) { + case 'message': + if (output.content[0].type === 'output_text') { + onChunk({ type: ChunkType.TEXT_DELTA, text: output.content[0].text }) + onChunk({ type: ChunkType.TEXT_COMPLETE, text: output.content[0].text }) + content += output.content[0].text + if (output.content[0].annotations && output.content[0].annotations.length > 0) { + onChunk({ + type: ChunkType.LLM_WEB_SEARCH_COMPLETE, + llm_web_search: { + source: WebSearchSource.OPENAI_RESPONSE, + results: output.content[0].annotations + } + }) + } + } + break + case 'reasoning': + onChunk({ + type: ChunkType.THINKING_COMPLETE, + text: output.summary.map((s) => s.text).join('\n'), + thinking_millsec: new Date().getTime() - start_time_millsec + }) + break + case 'function_call': + toolCalls.push(output) + } + } + + if (content) { + reqMessages.push({ + role: 'assistant', + content: content + }) + } + if (toolCalls.length) { + toolCalls.forEach((toolCall) => { + reqMessages.push(toolCall) + }) + } + + const toolResults: Awaited> = [] + if (toolCalls.length) { + toolResults.push(...(await processToolCalls(mcpTools, toolCalls))) + } + if (content.length) { + toolResults.push(...(await processToolUses(content))) + } + await processToolResults(toolResults, idx) + + onChunk({ + type: ChunkType.BLOCK_COMPLETE, + response: { + usage: finalUsage, + metrics: finalMetrics + } + }) + return + } + let content = '' + + const outputItems: OpenAI.Responses.ResponseOutputItem[] = [] + + for await (const chunk of stream as Stream) { + if (window.keyv.get(EVENT_NAMES.CHAT_COMPLETION_PAUSED)) { + break + } + switch (chunk.type) { + case 'response.output_item.added': + if (time_first_token_millsec === 0) { + time_first_token_millsec = new Date().getTime() + } + if (chunk.item.type === 'function_call') { + outputItems.push(chunk.item) + } + break + + case 'response.reasoning_summary_text.delta': + onChunk({ + type: ChunkType.THINKING_DELTA, + text: chunk.delta, + thinking_millsec: new Date().getTime() - time_first_token_millsec + }) + break + case 'response.reasoning_summary_text.done': + onChunk({ + type: ChunkType.THINKING_COMPLETE, + text: chunk.text, + thinking_millsec: new Date().getTime() - time_first_token_millsec + }) + break + case 'response.output_text.delta': { + let delta = chunk.delta + if (isEnabledBuiltinWebSearch) { + delta = convertLinks(delta) + } + onChunk({ + type: ChunkType.TEXT_DELTA, + text: delta + }) + content += delta + break + } + case 'response.output_text.done': + onChunk({ + type: ChunkType.TEXT_COMPLETE, + text: content + }) + break + case 'response.function_call_arguments.done': { + const outputItem: OpenAI.Responses.ResponseOutputItem | undefined = outputItems.find( + (item) => item.id === chunk.item_id + ) + if (outputItem) { + if (outputItem.type === 'function_call') { + toolCalls.push({ + ...outputItem, + arguments: chunk.arguments + }) + } + } + + break + } + case 'response.content_part.done': + if (chunk.part.type === 'output_text' && chunk.part.annotations && chunk.part.annotations.length > 0) { + onChunk({ + type: ChunkType.LLM_WEB_SEARCH_COMPLETE, + llm_web_search: { + source: WebSearchSource.OPENAI, + results: chunk.part.annotations + } + }) + } + break + case 'response.completed': { + const completion_tokens = + (chunk.response.usage?.output_tokens || 0) + + (chunk.response.usage?.output_tokens_details.reasoning_tokens ?? 0) + const total_tokens = + (chunk.response.usage?.total_tokens || 0) + + (chunk.response.usage?.output_tokens_details.reasoning_tokens ?? 0) + finalUsage.completion_tokens += completion_tokens + finalUsage.prompt_tokens += chunk.response.usage?.input_tokens || 0 + finalUsage.total_tokens += total_tokens + finalMetrics.completion_tokens += completion_tokens + finalMetrics.time_completion_millsec += new Date().getTime() - start_time_millsec + finalMetrics.time_first_token_millsec = time_first_token_millsec - start_time_millsec + break + } + case 'error': + onChunk({ + type: ChunkType.ERROR, + error: { + message: chunk.message, + code: chunk.code + } + }) + break + } + + // --- End of Incremental onChunk calls --- + } // End of for await loop + if (content) { + reqMessages.push({ + role: 'assistant', + content: content + }) + } + if (toolCalls.length) { + toolCalls.forEach((toolCall) => { + reqMessages.push(toolCall) + }) + } + + // Call processToolUses AFTER the loop finishes processing the main stream content + // Note: parseAndCallTools inside processToolUses should handle its own onChunk for tool responses + const toolResults: Awaited> = [] + if (toolCalls.length) { + toolResults.push(...(await processToolCalls(mcpTools, toolCalls))) + } + if (content) { + toolResults.push(...(await processToolUses(content))) + } + await processToolResults(toolResults, idx) + + onChunk({ + type: ChunkType.BLOCK_COMPLETE, + response: { + usage: finalUsage, + metrics: finalMetrics + } + }) + } + + onChunk({ type: ChunkType.LLM_RESPONSE_CREATED }) + const start_time_millsec = new Date().getTime() + const stream = await this.sdk.responses.create( + { + model: model.id, + input: reqMessages, + temperature: this.getTemperature(assistant, model), + top_p: this.getTopP(assistant, model), + max_output_tokens: maxTokens, + stream: streamOutput, + tools: tools.length > 0 ? tools : undefined, + tool_choice: isEnabledBuiltinWebSearch ? toolChoices : undefined, + service_tier: this.getServiceTier(model), + ...this.getResponseReasoningEffort(assistant, model), + ...this.getCustomParameters(assistant) + }, + { + signal, + timeout: this.getTimeout(model) + } + ) + + await processStream(stream, 0).finally(cleanup) + + // 捕获signal的错误 + await signalPromise?.promise?.catch((error) => { + throw error + }) + } + + /** + * Translate the content + * @param content - The content + * @param assistant - The assistant + * @param onResponse - The onResponse callback + * @returns The translated content + */ + async translate( + content: string, + assistant: Assistant, + onResponse?: (text: string, isComplete: boolean) => void + ): Promise { + const defaultModel = getDefaultModel() + const model = assistant.model || defaultModel + const messageForApi: OpenAI.Responses.EasyInputMessage[] = content + ? [ + { + role: 'system', + content: assistant.prompt + }, + { + role: 'user', + content + } + ] + : [{ role: 'user', content: assistant.prompt }] + + const isOpenAIReasoning = isOpenAIReasoningModel(model) + const isSupportedStreamOutput = () => { + if (!onResponse) { + return false + } + return !isOpenAIReasoning + } + + const stream = isSupportedStreamOutput() + let text = '' + if (stream) { + const response = await this.sdk.responses.create({ + model: model.id, + input: messageForApi, + stream: true, + temperature: this.getTemperature(assistant, model), + top_p: this.getTopP(assistant, model), + ...this.getResponseReasoningEffort(assistant, model) + }) + + for await (const chunk of response) { + switch (chunk.type) { + case 'response.output_text.delta': + text += chunk.delta + onResponse?.(text, false) + break + case 'response.output_text.done': + onResponse?.(chunk.text, true) + break + } + } + } else { + const response = await this.sdk.responses.create({ + model: model.id, + input: messageForApi, + stream: false, + temperature: this.getTemperature(assistant, model), + top_p: this.getTopP(assistant, model), + ...this.getResponseReasoningEffort(assistant, model) + }) + return response.output_text + } + + return text + } + + /** + * Summarize the messages + * @param messages - The messages + * @param assistant - The assistant + * @returns The summary + */ + public async summaries(messages: Message[], assistant: Assistant): Promise { + const model = getTopNamingModel() || assistant.model || getDefaultModel() + const userMessages = takeRight(messages, 5) + .filter((message) => !message.isPreset) + .map((message) => ({ + role: message.role, + content: getMainTextContent(message) + })) + const userMessageContent = userMessages.reduce((prev, curr) => { + const content = curr.role === 'user' ? `User: ${curr.content}` : `Assistant: ${curr.content}` + return prev + (prev ? '\n' : '') + content + }, '') + + const systemMessage: OpenAI.Responses.EasyInputMessage = { + role: 'system', + content: (getStoreSetting('topicNamingPrompt') as string) || i18n.t('prompts.title') + } + + const userMessage: OpenAI.Responses.EasyInputMessage = { + role: 'user', + content: userMessageContent + } + + const response = await this.sdk.responses.create({ + model: model.id, + input: [systemMessage, userMessage], + stream: false, + max_output_tokens: 1000 + }) + return removeSpecialCharactersForTopicName(response.output_text.substring(0, 50)) + } + + public async summaryForSearch(messages: Message[], assistant: Assistant): Promise { + const model = getTopNamingModel() || assistant.model || getDefaultModel() + const systemMessage: OpenAI.Responses.EasyInputMessage = { + role: 'system', + content: assistant.prompt + } + const messageContents = messages.map((m) => getMainTextContent(m)) + const userMessageContent = messageContents.join('\n') + const userMessage: OpenAI.Responses.EasyInputMessage = { + role: 'user', + content: userMessageContent + } + const lastUserMessage = messages[messages.length - 1] + const { abortController, cleanup } = this.createAbortController(lastUserMessage?.id) + const { signal } = abortController + + const response = await this.sdk.responses + .create( + { + model: model.id, + input: [systemMessage, userMessage], + stream: false, + max_output_tokens: 1000 + }, + { + signal, + timeout: 20 * 1000 + } + ) + .finally(cleanup) + + return response.output_text + } + + /** + * Generate suggestions + * @param messages - The messages + * @param assistant - The assistant + * @returns The suggestions + */ + async suggestions(messages: Message[], assistant: Assistant): Promise { + const model = assistant.model + + if (!model) { + return [] + } + + const userMessagesForApi = messages + .filter((m) => m.role === 'user') + .map((m) => ({ + role: m.role, + content: getMainTextContent(m) + })) + + const response: any = await this.sdk.request({ + method: 'post', + path: '/advice_questions', + body: { + messages: userMessagesForApi, + model: model.id, + max_tokens: 0, + temperature: 0, + n: 0 + } + }) + + return response?.questions?.filter(Boolean)?.map((q: any) => ({ content: q })) || [] + } + + /** + * Generate text + * @param prompt - The prompt + * @param content - The content + * @returns The generated text + */ + public async generateText({ prompt, content }: { prompt: string; content: string }): Promise { + const model = getDefaultModel() + const response = await this.sdk.responses.create({ + model: model.id, + stream: false, + input: [ + { role: 'system', content: prompt }, + { role: 'user', content } + ] + }) + return response.output_text + } + + /** + * Check if the model is valid + * @param model - The model + * @param stream - Whether to use streaming interface + * @returns The validity of the model + */ + public async check(model: Model, stream: boolean): Promise<{ valid: boolean; error: Error | null }> { + if (!model) { + return { valid: false, error: new Error('No model found') } + } + if (stream) { + const response = await this.sdk.responses.create({ + model: model.id, + input: [{ role: 'user', content: 'hi' }], + max_output_tokens: 1, + stream: true + }) + let hasContent = false + for await (const chunk of response) { + if (chunk.type === 'response.output_text.delta') { + hasContent = true + } + } + if (hasContent) { + return { valid: true, error: null } + } + throw new Error('Empty streaming response') + } else { + const response = await this.sdk.responses.create({ + model: model.id, + input: [{ role: 'user', content: 'hi' }], + max_output_tokens: 1, + stream: false + }) + if (!response.output_text) { + throw new Error('Empty response') + } + return { valid: true, error: null } + } + } + + /** + * Get the models + * @returns The models + */ + public async models(): Promise { + try { + const response = await this.sdk.models.list() + const models = response.data || [] + models.forEach((model) => { + model.id = model.id.trim() + }) + return models.filter(isSupportedModel) + } catch (error) { + return [] + } + } + + /** + * Generate an image + * @param params - The parameters + * @returns The generated image + */ + public async generateImage({ + model, + prompt, + negativePrompt, + imageSize, + batchSize, + seed, + numInferenceSteps, + guidanceScale, + signal, + promptEnhancement + }: GenerateImageParams): Promise { + const response = (await this.sdk.request({ + method: 'post', + path: '/images/generations', + signal, + body: { + model, + prompt, + negative_prompt: negativePrompt, + image_size: imageSize, + batch_size: batchSize, + seed: seed ? parseInt(seed) : undefined, + num_inference_steps: numInferenceSteps, + guidance_scale: guidanceScale, + prompt_enhancement: promptEnhancement + } + })) as { data: Array<{ url: string }> } + + return response.data.map((item) => item.url) + } + + public async generateImageByChat({ messages, assistant, onChunk }: CompletionsParams): Promise { + const defaultModel = getDefaultModel() + const model = assistant.model || defaultModel + // save image data from the last assistant message + messages = addImageFileToContents(messages) + const lastUserMessage = messages.findLast((m) => m.role === 'user') + const lastAssistantMessage = messages.findLast((m) => m.role === 'assistant') + if (!lastUserMessage) { + return + } + + const { abortController } = this.createAbortController(lastUserMessage?.id, true) + const { signal } = abortController + const content = getMainTextContent(lastUserMessage!) + let response: OpenAI.Images.ImagesResponse | null = null + let images: FileLike[] = [] + + try { + if (lastUserMessage) { + const UserFiles = findImageBlocks(lastUserMessage) + const validUserFiles = UserFiles.filter((f) => f.file) // Filter out files that are undefined first + const userImages = await Promise.all( + validUserFiles.map(async (f) => { + // f.file is guaranteed to exist here due to the filter above + const fileInfo = f.file! + const binaryData = await FileManager.readBinaryImage(fileInfo) + return await toFile(binaryData, fileInfo.origin_name || 'image.png', { + type: 'image/png' + }) + }) + ) + images = images.concat(userImages) + } + + if (lastAssistantMessage) { + const assistantFiles = findImageBlocks(lastAssistantMessage) + const assistantImages = await Promise.all( + assistantFiles.filter(Boolean).map(async (f) => { + const base64Data = f?.url?.replace(/^data:image\/\w+;base64,/, '') + if (!base64Data) return null + const binary = atob(base64Data) + const bytes = new Uint8Array(binary.length) + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i) + } + return await toFile(bytes, 'assistant_image.png', { + type: 'image/png' + }) + }) + ) + images = images.concat(assistantImages.filter(Boolean) as FileLike[]) + } + onChunk({ + type: ChunkType.IMAGE_CREATED + }) + + const start_time_millsec = new Date().getTime() + + if (images.length > 0) { + response = await this.sdk.images.edit( + { + model: model.id, + image: images, + prompt: content || '' + }, + { + signal, + timeout: 300_000 + } + ) + } else { + response = await this.sdk.images.generate( + { + model: model.id, + prompt: content || '', + response_format: model.id.includes('gpt-image-1') ? undefined : 'b64_json' + }, + { + signal, + timeout: 300_000 + } + ) + } + + onChunk({ + type: ChunkType.IMAGE_COMPLETE, + image: { + type: 'base64', + images: response?.data?.map((item) => `data:image/png;base64,${item.b64_json}`) || [] + } + }) + + onChunk({ + type: ChunkType.BLOCK_COMPLETE, + response: { + usage: { + completion_tokens: response.usage?.output_tokens || 0, + prompt_tokens: response.usage?.input_tokens || 0, + total_tokens: response.usage?.total_tokens || 0 + }, + metrics: { + completion_tokens: response.usage?.output_tokens || 0, + time_first_token_millsec: 0, // Non-streaming, first token time is not relevant + time_completion_millsec: new Date().getTime() - start_time_millsec + } + } + }) + } catch (error: any) { + console.error('[generateImageByChat] error', error) + onChunk({ + type: ChunkType.ERROR, + error + }) + } + } + + /** + * Get the embedding dimensions + * @param model - The model + * @returns The embedding dimensions + */ + public async getEmbeddingDimensions(model: Model): Promise { + const data = await this.sdk.embeddings.create({ + model: model.id, + input: 'hi' + }) + return data.data[0].embedding.length + } +} + +export default class OpenAIResponseProvider extends BaseOpenAiProvider { + constructor(provider: Provider) { + super(provider) + } + + public convertMcpTools(mcpTools: MCPTool[]) { + return mcpToolsToOpenAIResponseTools(mcpTools) as T[] + } + + public mcpToolCallResponseToMessage = ( + mcpToolResponse: MCPToolResponse, + resp: MCPCallToolResponse, + model: Model + ): OpenAI.Responses.ResponseInputItem | undefined => { + if ('toolUseId' in mcpToolResponse && mcpToolResponse.toolUseId) { + return mcpToolCallResponseToOpenAIMessage(mcpToolResponse, resp, isVisionModel(model)) + } else if ('toolCallId' in mcpToolResponse && mcpToolResponse.toolCallId) { + return { + type: 'function_call_output', + call_id: mcpToolResponse.toolCallId, + output: JSON.stringify(resp.content) + } + } + return + } +} diff --git a/src/renderer/src/providers/AiProvider/ProviderFactory.ts b/src/renderer/src/providers/AiProvider/ProviderFactory.ts index 6d3c10468e..d8c1f40e6f 100644 --- a/src/renderer/src/providers/AiProvider/ProviderFactory.ts +++ b/src/renderer/src/providers/AiProvider/ProviderFactory.ts @@ -4,25 +4,26 @@ import AihubmixProvider from './AihubmixProvider' import AnthropicProvider from './AnthropicProvider' import BaseProvider from './BaseProvider' import GeminiProvider from './GeminiProvider' -import OpenAICompatibleProvider from './OpenAICompatibleProvider' import OpenAIProvider from './OpenAIProvider' +import OpenAIResponseProvider from './OpenAIResponseProvider' export default class ProviderFactory { static create(provider: Provider): BaseProvider { + if (provider.id === 'aihubmix') { + return new AihubmixProvider(provider) + } + switch (provider.type) { case 'openai': return new OpenAIProvider(provider) - case 'openai-compatible': - if (provider.id === 'aihubmix') { - return new AihubmixProvider(provider) - } - return new OpenAICompatibleProvider(provider) + case 'openai-response': + return new OpenAIResponseProvider(provider) case 'anthropic': return new AnthropicProvider(provider) case 'gemini': return new GeminiProvider(provider) default: - return new OpenAICompatibleProvider(provider) + return new OpenAIProvider(provider) } } } diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index df7af60f69..abfc334986 100644 --- a/src/renderer/src/store/index.ts +++ b/src/renderer/src/store/index.ts @@ -46,7 +46,7 @@ const persistedReducer = persistReducer( { key: 'cherry-studio', storage, - version: 99, + version: 100, blacklist: ['runtime', 'messages', 'messageBlocks'], migrate }, diff --git a/src/renderer/src/store/llm.ts b/src/renderer/src/store/llm.ts index 27a68b342b..8bb70599b0 100644 --- a/src/renderer/src/store/llm.ts +++ b/src/renderer/src/store/llm.ts @@ -28,7 +28,7 @@ export const INITIAL_PROVIDERS: Provider[] = [ { id: 'silicon', name: 'Silicon', - type: 'openai-compatible', + type: 'openai', apiKey: '', apiHost: 'https://api.siliconflow.cn', models: SYSTEM_MODELS.silicon, @@ -38,7 +38,7 @@ export const INITIAL_PROVIDERS: Provider[] = [ { id: 'aihubmix', name: 'AiHubMix', - type: 'openai-compatible', + type: 'openai', apiKey: '', apiHost: 'https://aihubmix.com', models: SYSTEM_MODELS.aihubmix, @@ -48,7 +48,7 @@ export const INITIAL_PROVIDERS: Provider[] = [ { id: 'ocoolai', name: 'ocoolAI', - type: 'openai-compatible', + type: 'openai', apiKey: '', apiHost: 'https://api.ocoolai.com', models: SYSTEM_MODELS.ocoolai, @@ -58,7 +58,7 @@ export const INITIAL_PROVIDERS: Provider[] = [ { id: 'deepseek', name: 'deepseek', - type: 'openai-compatible', + type: 'openai', apiKey: '', apiHost: 'https://api.deepseek.com', models: SYSTEM_MODELS.deepseek, @@ -68,7 +68,7 @@ export const INITIAL_PROVIDERS: Provider[] = [ { id: 'openrouter', name: 'OpenRouter', - type: 'openai-compatible', + type: 'openai', apiKey: '', apiHost: 'https://openrouter.ai/api/v1/', models: SYSTEM_MODELS.openrouter, @@ -78,7 +78,7 @@ export const INITIAL_PROVIDERS: Provider[] = [ { id: 'ppio', name: 'PPIO', - type: 'openai-compatible', + type: 'openai', apiKey: '', apiHost: 'https://api.ppinfra.com/v3/openai', models: SYSTEM_MODELS.ppio, @@ -88,7 +88,7 @@ export const INITIAL_PROVIDERS: Provider[] = [ { id: 'alayanew', name: 'AlayaNew', - type: 'openai-compatible', + type: 'openai', apiKey: '', apiHost: 'https://deepseek.alayanew.com', models: SYSTEM_MODELS.alayanew, @@ -98,7 +98,7 @@ export const INITIAL_PROVIDERS: Provider[] = [ { id: 'infini', name: 'Infini', - type: 'openai-compatible', + type: 'openai', apiKey: '', apiHost: 'https://cloud.infini-ai.com/maas', models: SYSTEM_MODELS.infini, @@ -108,7 +108,7 @@ export const INITIAL_PROVIDERS: Provider[] = [ { id: 'qiniu', name: 'Qiniu', - type: 'openai-compatible', + type: 'openai', apiKey: '', apiHost: 'https://api.qnaigc.com', models: SYSTEM_MODELS.qiniu, @@ -118,7 +118,7 @@ export const INITIAL_PROVIDERS: Provider[] = [ { id: 'dmxapi', name: 'DMXAPI', - type: 'openai-compatible', + type: 'openai', apiKey: '', apiHost: 'https://www.dmxapi.cn', models: SYSTEM_MODELS.dmxapi, @@ -128,7 +128,7 @@ export const INITIAL_PROVIDERS: Provider[] = [ { id: 'o3', name: 'O3', - type: 'openai-compatible', + type: 'openai', apiKey: '', apiHost: 'https://api.o3.fan', models: SYSTEM_MODELS.o3, @@ -138,7 +138,7 @@ export const INITIAL_PROVIDERS: Provider[] = [ { id: 'ollama', name: 'Ollama', - type: 'openai-compatible', + type: 'openai', apiKey: '', apiHost: 'http://localhost:11434', models: SYSTEM_MODELS.ollama, @@ -148,7 +148,7 @@ export const INITIAL_PROVIDERS: Provider[] = [ { id: 'lmstudio', name: 'LM Studio', - type: 'openai-compatible', + type: 'openai', apiKey: '', apiHost: 'http://localhost:1234', models: SYSTEM_MODELS.lmstudio, @@ -168,7 +168,7 @@ export const INITIAL_PROVIDERS: Provider[] = [ { id: 'openai', name: 'OpenAI', - type: 'openai', + type: 'openai-response', apiKey: '', apiHost: 'https://api.openai.com', models: SYSTEM_MODELS.openai, @@ -178,7 +178,7 @@ export const INITIAL_PROVIDERS: Provider[] = [ { id: 'azure-openai', name: 'Azure OpenAI', - type: 'openai-compatible', + type: 'openai', apiKey: '', apiHost: '', apiVersion: '', @@ -199,7 +199,7 @@ export const INITIAL_PROVIDERS: Provider[] = [ { id: 'zhipu', name: 'ZhiPu', - type: 'openai-compatible', + type: 'openai', apiKey: '', apiHost: 'https://open.bigmodel.cn/api/paas/v4/', models: SYSTEM_MODELS.zhipu, @@ -209,7 +209,7 @@ export const INITIAL_PROVIDERS: Provider[] = [ { id: 'github', name: 'Github Models', - type: 'openai-compatible', + type: 'openai', apiKey: '', apiHost: 'https://models.inference.ai.azure.com/', models: SYSTEM_MODELS.github, @@ -219,7 +219,7 @@ export const INITIAL_PROVIDERS: Provider[] = [ { id: 'copilot', name: 'Github Copilot', - type: 'openai-compatible', + type: 'openai', apiKey: '', apiHost: 'https://api.githubcopilot.com/', models: SYSTEM_MODELS.copilot, @@ -230,7 +230,7 @@ export const INITIAL_PROVIDERS: Provider[] = [ { id: 'yi', name: 'Yi', - type: 'openai-compatible', + type: 'openai', apiKey: '', apiHost: 'https://api.lingyiwanwu.com', models: SYSTEM_MODELS.yi, @@ -240,7 +240,7 @@ export const INITIAL_PROVIDERS: Provider[] = [ { id: 'moonshot', name: 'Moonshot AI', - type: 'openai-compatible', + type: 'openai', apiKey: '', apiHost: 'https://api.moonshot.cn', models: SYSTEM_MODELS.moonshot, @@ -250,7 +250,7 @@ export const INITIAL_PROVIDERS: Provider[] = [ { id: 'baichuan', name: 'BAICHUAN AI', - type: 'openai-compatible', + type: 'openai', apiKey: '', apiHost: 'https://api.baichuan-ai.com', models: SYSTEM_MODELS.baichuan, @@ -260,7 +260,7 @@ export const INITIAL_PROVIDERS: Provider[] = [ { id: 'dashscope', name: 'Bailian', - type: 'openai-compatible', + type: 'openai', apiKey: '', apiHost: 'https://dashscope.aliyuncs.com/compatible-mode/v1/', models: SYSTEM_MODELS.bailian, @@ -270,7 +270,7 @@ export const INITIAL_PROVIDERS: Provider[] = [ { id: 'stepfun', name: 'StepFun', - type: 'openai-compatible', + type: 'openai', apiKey: '', apiHost: 'https://api.stepfun.com', models: SYSTEM_MODELS.stepfun, @@ -280,7 +280,7 @@ export const INITIAL_PROVIDERS: Provider[] = [ { id: 'doubao', name: 'doubao', - type: 'openai-compatible', + type: 'openai', apiKey: '', apiHost: 'https://ark.cn-beijing.volces.com/api/v3/', models: SYSTEM_MODELS.doubao, @@ -290,7 +290,7 @@ export const INITIAL_PROVIDERS: Provider[] = [ { id: 'minimax', name: 'MiniMax', - type: 'openai-compatible', + type: 'openai', apiKey: '', apiHost: 'https://api.minimax.chat/v1/', models: SYSTEM_MODELS.minimax, @@ -300,7 +300,7 @@ export const INITIAL_PROVIDERS: Provider[] = [ { id: 'groq', name: 'Groq', - type: 'openai-compatible', + type: 'openai', apiKey: '', apiHost: 'https://api.groq.com/openai', models: SYSTEM_MODELS.groq, @@ -310,7 +310,7 @@ export const INITIAL_PROVIDERS: Provider[] = [ { id: 'together', name: 'Together', - type: 'openai-compatible', + type: 'openai', apiKey: '', apiHost: 'https://api.together.xyz', models: SYSTEM_MODELS.together, @@ -320,7 +320,7 @@ export const INITIAL_PROVIDERS: Provider[] = [ { id: 'fireworks', name: 'Fireworks', - type: 'openai-compatible', + type: 'openai', apiKey: '', apiHost: 'https://api.fireworks.ai/inference', models: SYSTEM_MODELS.fireworks, @@ -330,7 +330,7 @@ export const INITIAL_PROVIDERS: Provider[] = [ { id: 'zhinao', name: 'zhinao', - type: 'openai-compatible', + type: 'openai', apiKey: '', apiHost: 'https://api.360.cn', models: SYSTEM_MODELS.zhinao, @@ -340,7 +340,7 @@ export const INITIAL_PROVIDERS: Provider[] = [ { id: 'hunyuan', name: 'hunyuan', - type: 'openai-compatible', + type: 'openai', apiKey: '', apiHost: 'https://api.hunyuan.cloud.tencent.com', models: SYSTEM_MODELS.hunyuan, @@ -350,7 +350,7 @@ export const INITIAL_PROVIDERS: Provider[] = [ { id: 'nvidia', name: 'nvidia', - type: 'openai-compatible', + type: 'openai', apiKey: '', apiHost: 'https://integrate.api.nvidia.com', models: SYSTEM_MODELS.nvidia, @@ -360,7 +360,7 @@ export const INITIAL_PROVIDERS: Provider[] = [ { id: 'grok', name: 'Grok', - type: 'openai-compatible', + type: 'openai', apiKey: '', apiHost: 'https://api.x.ai', models: SYSTEM_MODELS.grok, @@ -370,7 +370,7 @@ export const INITIAL_PROVIDERS: Provider[] = [ { id: 'hyperbolic', name: 'Hyperbolic', - type: 'openai-compatible', + type: 'openai', apiKey: '', apiHost: 'https://api.hyperbolic.xyz', models: SYSTEM_MODELS.hyperbolic, @@ -380,7 +380,7 @@ export const INITIAL_PROVIDERS: Provider[] = [ { id: 'mistral', name: 'Mistral', - type: 'openai-compatible', + type: 'openai', apiKey: '', apiHost: 'https://api.mistral.ai', models: SYSTEM_MODELS.mistral, @@ -390,7 +390,7 @@ export const INITIAL_PROVIDERS: Provider[] = [ { id: 'jina', name: 'Jina', - type: 'openai-compatible', + type: 'openai', apiKey: '', apiHost: 'https://api.jina.ai', models: SYSTEM_MODELS.jina, @@ -400,7 +400,7 @@ export const INITIAL_PROVIDERS: Provider[] = [ { id: 'gitee-ai', name: 'gitee ai', - type: 'openai-compatible', + type: 'openai', apiKey: '', apiHost: 'https://ai.gitee.com', models: SYSTEM_MODELS['gitee-ai'], @@ -410,7 +410,7 @@ export const INITIAL_PROVIDERS: Provider[] = [ { id: 'perplexity', name: 'Perplexity', - type: 'openai-compatible', + type: 'openai', apiKey: '', apiHost: 'https://api.perplexity.ai/', models: SYSTEM_MODELS.perplexity, @@ -420,7 +420,7 @@ export const INITIAL_PROVIDERS: Provider[] = [ { id: 'modelscope', name: 'ModelScope', - type: 'openai-compatible', + type: 'openai', apiKey: '', apiHost: 'https://api-inference.modelscope.cn/v1/', models: SYSTEM_MODELS.modelscope, @@ -430,7 +430,7 @@ export const INITIAL_PROVIDERS: Provider[] = [ { id: 'xirang', name: 'Xirang', - type: 'openai-compatible', + type: 'openai', apiKey: '', apiHost: 'https://wishub-x1.ctyun.cn', models: SYSTEM_MODELS.xirang, @@ -440,7 +440,7 @@ export const INITIAL_PROVIDERS: Provider[] = [ { id: 'tencent-cloud-ti', name: 'Tencent Cloud TI', - type: 'openai-compatible', + type: 'openai', apiKey: '', apiHost: 'https://api.lkeap.cloud.tencent.com', models: SYSTEM_MODELS['tencent-cloud-ti'], @@ -450,7 +450,7 @@ export const INITIAL_PROVIDERS: Provider[] = [ { id: 'baidu-cloud', name: 'Baidu Cloud', - type: 'openai-compatible', + type: 'openai', apiKey: '', apiHost: 'https://qianfan.baidubce.com/v2/', models: SYSTEM_MODELS['baidu-cloud'], @@ -460,7 +460,7 @@ export const INITIAL_PROVIDERS: Provider[] = [ { id: 'gpustack', name: 'GPUStack', - type: 'openai-compatible', + type: 'openai', apiKey: '', apiHost: '', models: SYSTEM_MODELS.gpustack, @@ -470,7 +470,7 @@ export const INITIAL_PROVIDERS: Provider[] = [ { id: 'voyageai', name: 'VoyageAI', - type: 'openai-compatible', + type: 'openai', apiKey: '', apiHost: 'https://api.voyageai.com', models: SYSTEM_MODELS.voyageai, diff --git a/src/renderer/src/store/messageBlock.ts b/src/renderer/src/store/messageBlock.ts index 33c00200da..2a4ac9845c 100644 --- a/src/renderer/src/store/messageBlock.ts +++ b/src/renderer/src/store/messageBlock.ts @@ -101,7 +101,7 @@ const formatCitationsFromBlock = (block: CitationMessageBlock | undefined): Cita })) || [] break } - case WebSearchSource.OPENAI: + case WebSearchSource.OPENAI_RESPONSE: formattedCitations = (block.response.results as OpenAI.Responses.ResponseOutputText.URLCitation[])?.map((result, index) => { let hostname: string | undefined @@ -120,7 +120,7 @@ const formatCitationsFromBlock = (block: CitationMessageBlock | undefined): Cita } }) || [] break - case WebSearchSource.OPENAI_COMPATIBLE: + case WebSearchSource.OPENAI: formattedCitations = (block.response.results as OpenAI.Chat.Completions.ChatCompletionMessage.Annotation[])?.map((url, index) => { const urlCitation = url.url_citation diff --git a/src/renderer/src/store/migrate.ts b/src/renderer/src/store/migrate.ts index ab852b18f6..e88cac33ef 100644 --- a/src/renderer/src/store/migrate.ts +++ b/src/renderer/src/store/migrate.ts @@ -1257,6 +1257,7 @@ const migrateConfig = { try { state.llm.providers.forEach((provider) => { if (provider.type === 'openai' && provider.id !== 'openai') { + // @ts-ignore eslint-disable-next-line provider.type = 'openai-compatible' } }) @@ -1296,6 +1297,22 @@ const migrateConfig = { } catch (error) { return state } + }, + '100': (state: RootState) => { + try { + state.llm.providers.forEach((provider) => { + // @ts-ignore eslint-disable-next-line + if (['openai-compatible', 'openai'].includes(provider.type)) { + provider.type = 'openai' + } + if (provider.id === 'openai') { + provider.type = 'openai-response' + } + }) + return state + } catch (error) { + return state + } } } diff --git a/src/renderer/src/types/index.ts b/src/renderer/src/types/index.ts index 0873fc0a99..e66e629043 100644 --- a/src/renderer/src/types/index.ts +++ b/src/renderer/src/types/index.ts @@ -162,7 +162,7 @@ export type Provider = { notes?: string } -export type ProviderType = 'openai' | 'openai-compatible' | 'anthropic' | 'gemini' | 'qwenlm' | 'azure-openai' +export type ProviderType = 'openai' | 'openai-response' | 'anthropic' | 'gemini' | 'qwenlm' | 'azure-openai' export type ModelType = 'text' | 'vision' | 'embedding' | 'reasoning' | 'function_calling' | 'web_search' @@ -462,7 +462,7 @@ export type WebSearchResults = export enum WebSearchSource { WEBSEARCH = 'websearch', OPENAI = 'openai', - OPENAI_COMPATIBLE = 'openai-compatible', + OPENAI_RESPONSE = 'openai-response', OPENROUTER = 'openrouter', ANTHROPIC = 'anthropic', GEMINI = 'gemini', From f414b1881c7556fdaa071732ba505a967d0867f0 Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Mon, 12 May 2025 23:16:07 +0800 Subject: [PATCH 22/50] fix: OpenAIResponseProvider summaryForSearch impl model wrong --- src/renderer/src/providers/AiProvider/OpenAIProvider.ts | 1 + .../src/providers/AiProvider/OpenAIResponseProvider.ts | 7 ++++++- src/renderer/src/store/migrate.ts | 3 +++ 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/providers/AiProvider/OpenAIProvider.ts b/src/renderer/src/providers/AiProvider/OpenAIProvider.ts index 650960fc65..e7a553cc24 100644 --- a/src/renderer/src/providers/AiProvider/OpenAIProvider.ts +++ b/src/renderer/src/providers/AiProvider/OpenAIProvider.ts @@ -1011,6 +1011,7 @@ export default class OpenAIProvider extends BaseOpenAiProvider { } const lastUserMessage = messages[messages.length - 1] + const { abortController, cleanup } = this.createAbortController(lastUserMessage?.id) const { signal } = abortController diff --git a/src/renderer/src/providers/AiProvider/OpenAIResponseProvider.ts b/src/renderer/src/providers/AiProvider/OpenAIResponseProvider.ts index 8ebcc475a1..db94c0df54 100644 --- a/src/renderer/src/providers/AiProvider/OpenAIResponseProvider.ts +++ b/src/renderer/src/providers/AiProvider/OpenAIResponseProvider.ts @@ -925,18 +925,23 @@ export abstract class BaseOpenAiProvider extends BaseProvider { } public async summaryForSearch(messages: Message[], assistant: Assistant): Promise { - const model = getTopNamingModel() || assistant.model || getDefaultModel() + const model = assistant.model || getDefaultModel() + const systemMessage: OpenAI.Responses.EasyInputMessage = { role: 'system', content: assistant.prompt } + const messageContents = messages.map((m) => getMainTextContent(m)) const userMessageContent = messageContents.join('\n') + const userMessage: OpenAI.Responses.EasyInputMessage = { role: 'user', content: userMessageContent } + const lastUserMessage = messages[messages.length - 1] + const { abortController, cleanup } = this.createAbortController(lastUserMessage?.id) const { signal } = abortController diff --git a/src/renderer/src/store/migrate.ts b/src/renderer/src/store/migrate.ts index e88cac33ef..4e60ee922b 100644 --- a/src/renderer/src/store/migrate.ts +++ b/src/renderer/src/store/migrate.ts @@ -1309,6 +1309,9 @@ const migrateConfig = { provider.type = 'openai-response' } }) + state.assistants.assistants.forEach((assistant) => { + assistant.knowledgeRecognition = 'off' + }) return state } catch (error) { return state From fa00ceac1a363e121f8e569e7a65d7fb1b8833d6 Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Tue, 13 May 2025 20:41:15 +0800 Subject: [PATCH 23/50] fix: Grouped message should not reset model and modelId * Updated the reset logic to conditionally handle model and modelId for grouped messages. * Ensured that the original model is retained when regenerating responses for grouped messages. --- src/renderer/src/store/thunk/messageThunk.ts | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/src/renderer/src/store/thunk/messageThunk.ts b/src/renderer/src/store/thunk/messageThunk.ts index 4e631bba65..09311ae8ba 100644 --- a/src/renderer/src/store/thunk/messageThunk.ts +++ b/src/renderer/src/store/thunk/messageThunk.ts @@ -987,12 +987,20 @@ export const regenerateAssistantResponseThunk = const blockIdsToDelete = [...(messageToResetEntity.blocks || [])] // 5. Reset the message entity in Redux - const resetAssistantMsg = resetAssistantMessage(messageToResetEntity, { - status: AssistantMessageStatus.PENDING, - updatedAt: new Date().toISOString(), - model: assistant.model, - modelId: assistant?.model?.id - }) + const resetAssistantMsg = resetAssistantMessage( + messageToResetEntity, + // Grouped message (mentioned model message) should not reset model and modelId, always use the original model + assistantMessageToRegenerate.modelId + ? { + status: AssistantMessageStatus.PENDING, + updatedAt: new Date().toISOString() + } + : { + status: AssistantMessageStatus.PENDING, + updatedAt: new Date().toISOString(), + model: assistant.model + } + ) dispatch( newMessagesActions.updateMessage({ From 151bc78e013a40b3adf1e6aa7f12b2e9247ba924 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=8A=E6=88=BF=E6=8F=AD=E7=93=A6?= Date: Tue, 13 May 2025 23:09:38 +0800 Subject: [PATCH 24/50] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E6=99=BA=E8=83=BD?= =?UTF-8?q?=E4=BD=93=E8=AE=A2=E9=98=85=E5=8A=9F=E8=83=BD=20(#5954)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 添加智能体订阅功能 * 修改图标 * 修改hook点 修改图标 * 优雅的引用图标 * feat(i18n): add settings title for agents in multiple languages * fix(i18n): update translations for improved clarity * Merge branch 'main' into Subscribe --------- Co-authored-by: VM 96 Co-authored-by: suyao --- src/renderer/src/i18n/locales/en-us.json | 3 ++ src/renderer/src/i18n/locales/ja-jp.json | 5 +- src/renderer/src/i18n/locales/ru-ru.json | 3 ++ src/renderer/src/i18n/locales/zh-cn.json | 5 +- src/renderer/src/i18n/locales/zh-tw.json | 5 +- src/renderer/src/pages/agents/index.ts | 29 ++++++++++-- .../AgentsSubscribeUrlSettings.tsx | 47 +++++++++++++++++++ .../settings/DataSettings/DataSettings.tsx | 10 +++- src/renderer/src/store/settings.ts | 7 +++ 9 files changed, 105 insertions(+), 9 deletions(-) create mode 100755 src/renderer/src/pages/settings/DataSettings/AgentsSubscribeUrlSettings.tsx diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 171d5a9786..a8d8a173e6 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -44,6 +44,9 @@ "my_agents": "My Agents", "search.no_results": "No results found", "sorting.title": "Sorting", + "settings": { + "title": "Agent Setting" + }, "tag.agent": "Agent", "tag.default": "Default", "tag.new": "New", diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index a403b3292c..f30f699611 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -48,7 +48,10 @@ "tag.default": "デフォルト", "tag.new": "新規", "tag.system": "システム", - "title": "エージェント" + "title": "エージェント", + "settings": { + "title": "エージェント設定" + } }, "assistants": { "title": "アシスタント", diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index d0920fd2cf..254f65b009 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -48,6 +48,9 @@ }, "export": { "agent": "Экспорт агента" + }, + "settings": { + "title": "Настройки агента" } }, "assistants": { diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index b194461eba..71aa95a73e 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -48,7 +48,10 @@ "tag.default": "默认", "tag.new": "新建", "tag.system": "系统", - "title": "智能体" + "title": "智能体", + "settings": { + "title": "智能体配置" + } }, "assistants": { "title": "助手", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 0279639c8c..16facb9ae0 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -48,7 +48,10 @@ "tag.default": "預設", "tag.new": "新增", "tag.system": "系統", - "title": "智慧代理人" + "title": "智慧代理人", + "settings": { + "title": "智慧代理人設定" + } }, "assistants": { "title": "助手", diff --git a/src/renderer/src/pages/agents/index.ts b/src/renderer/src/pages/agents/index.ts index cf07d1df95..708cacc1f3 100644 --- a/src/renderer/src/pages/agents/index.ts +++ b/src/renderer/src/pages/agents/index.ts @@ -2,6 +2,8 @@ import { useRuntime } from '@renderer/hooks/useRuntime' import { useSettings } from '@renderer/hooks/useSettings' import { Agent } from '@renderer/types' import { useEffect, useState } from 'react' +import store from '@renderer/store' + let _agents: Agent[] = [] export const getAgentsFromSystemAgents = (systemAgents: any) => { @@ -19,27 +21,44 @@ export function useSystemAgents() { const { defaultAgent } = useSettings() const [agents, setAgents] = useState([]) const { resourcesPath } = useRuntime() + const { agentssubscribeUrl } = store.getState().settings useEffect(() => { const loadAgents = async () => { try { - // 始终加载本地 agents + // 检查是否使用远程数据源 + if (agentssubscribeUrl && agentssubscribeUrl.startsWith('http')) { + try { + await new Promise(resolve => setTimeout(resolve, 500)); + const response = await fetch(agentssubscribeUrl); + if (!response.ok) { + throw new Error(`HTTP error! Status: ${response.status}`); + } + const agentsData = await response.json() as Agent[]; + setAgents(agentsData); + return; + } catch (error) { + console.error("Failed to load remote agents:", error); + // 远程加载失败,继续尝试加载本地数据 + } + } + + // 如果没有远程配置或获取失败,加载本地代理 if (resourcesPath && _agents.length === 0) { const localAgentsData = await window.api.fs.read(resourcesPath + '/data/agents.json') _agents = JSON.parse(localAgentsData) as Agent[] } - - // 如果没有远程配置或获取失败,使用本地 agents + setAgents(_agents) } catch (error) { console.error('Failed to load agents:', error) - // 发生错误时使用本地 agents + // 发生错误时使用已加载的本地 agents setAgents(_agents) } } loadAgents() - }, [defaultAgent, resourcesPath]) + }, [defaultAgent, resourcesPath, agentssubscribeUrl]) return agents } diff --git a/src/renderer/src/pages/settings/DataSettings/AgentsSubscribeUrlSettings.tsx b/src/renderer/src/pages/settings/DataSettings/AgentsSubscribeUrlSettings.tsx new file mode 100755 index 0000000000..eb37f41737 --- /dev/null +++ b/src/renderer/src/pages/settings/DataSettings/AgentsSubscribeUrlSettings.tsx @@ -0,0 +1,47 @@ +import { HStack } from '@renderer/components/Layout' +import { useTheme } from '@renderer/context/ThemeProvider' +import { useSettings } from '@renderer/hooks/useSettings' +import { useAppDispatch } from '@renderer/store' +import { setAgentssubscribeUrl } from '@renderer/store/settings' +import Input from 'antd/es/input/Input' +import { FC } from 'react' +import { useTranslation } from 'react-i18next' + +import { SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '..' + +const AgentsSubscribeUrlSettings: FC = () => { + const { t } = useTranslation() + const { theme } = useTheme() + const dispatch = useAppDispatch() + + const { agentssubscribeUrl } = useSettings() + + const handleAgentChange = (e: React.ChangeEvent) => { + dispatch(setAgentssubscribeUrl(e.target.value)) + } + + return ( + + + {t('agents.tag.agent')} + {t('settings.websearch.subscribe_add')} + + + + {t('settings.websearch.subscribe_url')} + + + + + + + ) +} + +export default AgentsSubscribeUrlSettings diff --git a/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx b/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx index 32d6e0e926..50f838ce37 100644 --- a/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx +++ b/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx @@ -17,12 +17,13 @@ import { reset } from '@renderer/services/BackupService' import { AppInfo } from '@renderer/types' import { formatFileSize } from '@renderer/utils' import { Button, Typography } from 'antd' -import { FileText, FolderCog, FolderInput } from 'lucide-react' +import { FileText, FolderCog, FolderInput, Sparkle } from 'lucide-react' import { FC, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' import { SettingContainer, SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '..' +import AgentsSubscribeUrlSettings from './AgentsSubscribeUrlSettings' import ExportMenuOptions from './ExportMenuSettings' import JoplinSettings from './JoplinSettings' import MarkdownExportSettings from './MarkdownExportSettings' @@ -81,6 +82,7 @@ const DataSettings: FC = () => { title: 'settings.data.markdown_export.title', icon: }, + { key: 'divider_3', isDivider: true, text: t('settings.data.divider.third_party') }, { key: 'notion', title: 'settings.data.notion.title', icon: }, { @@ -102,6 +104,11 @@ const DataSettings: FC = () => { key: 'siyuan', title: 'settings.data.siyuan.title', icon: + }, + { + key: 'agentssubscribe_url', + title: 'agents.settings.title', + icon: } ] @@ -253,6 +260,7 @@ const DataSettings: FC = () => { {menu === 'joplin' && } {menu === 'obsidian' && } {menu === 'siyuan' && } + {menu === 'agentssubscribe_url' && } ) diff --git a/src/renderer/src/store/settings.ts b/src/renderer/src/store/settings.ts index 639646717b..4dcc7203a8 100644 --- a/src/renderer/src/store/settings.ts +++ b/src/renderer/src/store/settings.ts @@ -111,6 +111,8 @@ export interface SettingsState { siyuanToken: string | null siyuanBoxId: string | null siyuanRootPath: string | null + // 订阅的助手地址 + agentssubscribeUrl: string | null // MinApps maxKeepAliveMinapps: number showOpenedMinappsInSidebar: boolean @@ -218,6 +220,7 @@ export const initialState: SettingsState = { siyuanToken: null, siyuanBoxId: null, siyuanRootPath: null, + agentssubscribeUrl: '', // MinApps maxKeepAliveMinapps: 3, showOpenedMinappsInSidebar: true, @@ -493,6 +496,9 @@ const settingsSlice = createSlice({ setSiyuanRootPath: (state, action: PayloadAction) => { state.siyuanRootPath = action.payload }, + setAgentssubscribeUrl: (state, action: PayloadAction) => { + state.agentssubscribeUrl = action.payload + }, setMaxKeepAliveMinapps: (state, action: PayloadAction) => { state.maxKeepAliveMinapps = action.payload }, @@ -599,6 +605,7 @@ export const { setSiyuanApiUrl, setSiyuanToken, setSiyuanBoxId, + setAgentssubscribeUrl, setSiyuanRootPath, setMaxKeepAliveMinapps, setShowOpenedMinappsInSidebar, From 3a1ba4eb20a023bfa44834e3f05276724876a65f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=87=AA=E7=94=B1=E7=9A=84=E4=B8=96=E7=95=8C=E4=BA=BA?= <3196812536@qq.com> Date: Wed, 14 May 2025 00:13:00 +0800 Subject: [PATCH 25/50] feat: add citation content copy button (#5966) * feat: add citation content copy button * fix: build error --- .../home/Messages/Blocks/MainTextBlock.tsx | 11 +++- .../src/pages/home/Messages/CitationsList.tsx | 61 +++++++++++++------ src/renderer/src/utils/extract.ts | 4 +- src/renderer/src/utils/formats.ts | 20 +++++- 4 files changed, 72 insertions(+), 24 deletions(-) diff --git a/src/renderer/src/pages/home/Messages/Blocks/MainTextBlock.tsx b/src/renderer/src/pages/home/Messages/Blocks/MainTextBlock.tsx index f21cc47131..b004e784a9 100644 --- a/src/renderer/src/pages/home/Messages/Blocks/MainTextBlock.tsx +++ b/src/renderer/src/pages/home/Messages/Blocks/MainTextBlock.tsx @@ -5,6 +5,7 @@ import type { RootState } from '@renderer/store' import { selectFormattedCitationsByBlockId } from '@renderer/store/messageBlock' import { type Model, WebSearchSource } from '@renderer/types' import type { MainTextMessageBlock, Message } from '@renderer/types/newMessage' +import { cleanMarkdownContent } from '@renderer/utils/formats' import { Flex } from 'antd' import React, { useMemo } from 'react' import { useSelector } from 'react-redux' @@ -37,9 +38,13 @@ const MainTextBlock: React.FC = ({ block, citationBlockId, role, mentions // Use the passed citationBlockId directly in the selector const { renderInputMessageAsMarkdown } = useSettings() - const formattedCitations = useSelector((state: RootState) => - selectFormattedCitationsByBlockId(state, citationBlockId) - ) + const formattedCitations = useSelector((state: RootState) => { + const citations = selectFormattedCitationsByBlockId(state, citationBlockId) + return citations.map((citation) => ({ + ...citation, + content: citation.content ? cleanMarkdownContent(citation.content) : citation.content + })) + }) const processedContent = useMemo(() => { let content = block.content diff --git a/src/renderer/src/pages/home/Messages/CitationsList.tsx b/src/renderer/src/pages/home/Messages/CitationsList.tsx index d674db4e18..230767a3a8 100644 --- a/src/renderer/src/pages/home/Messages/CitationsList.tsx +++ b/src/renderer/src/pages/home/Messages/CitationsList.tsx @@ -1,9 +1,10 @@ import Favicon from '@renderer/components/Icons/FallbackFavicon' import { HStack } from '@renderer/components/Layout' import { fetchWebContent } from '@renderer/utils/fetch' +import { cleanMarkdownContent } from '@renderer/utils/formats' import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query' -import { Button, Drawer, Skeleton } from 'antd' -import { FileSearch } from 'lucide-react' +import { Button, Drawer, message, Skeleton } from 'antd' +import { Check, Copy, FileSearch } from 'lucide-react' import React, { useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -44,21 +45,6 @@ const truncateText = (text: string, maxLength = 100) => { return text.length > maxLength ? text.slice(0, maxLength) + '...' : text } -/** - * 清理Markdown内容 - * @param text - */ -const cleanMarkdownContent = (text: string): string => { - if (!text) return '' - let cleaned = text.replace(/!\[.*?]\(.*?\)/g, '') - cleaned = cleaned.replace(/\[(.*?)]\(.*?\)/g, '$1') - cleaned = cleaned.replace(/https?:\/\/\S+/g, '') - cleaned = cleaned.replace(/[-—–_=+]{3,}/g, ' ') - cleaned = cleaned.replace(/[¥$€£¥%@#&*^()[\]{}<>~`'"\\|/_.]+/g, '') - cleaned = cleaned.replace(/\s+/g, ' ').trim() - return cleaned -} - const CitationsList: React.FC = ({ citations }) => { const { t } = useTranslation() const [open, setOpen] = useState(false) @@ -115,6 +101,27 @@ const handleLinkClick = (url: string, event: React.MouseEvent) => { else window.api.file.openPath(url) } +const CopyButton: React.FC<{ content: string }> = ({ content }) => { + const [copied, setCopied] = useState(false) + const { t } = useTranslation() + + const handleCopy = () => { + if (!content) return + navigator.clipboard + .writeText(content) + .then(() => { + setCopied(true) + message.success(t('common.copied')) + setTimeout(() => setCopied(false), 2000) + }) + .catch(() => { + message.error(t('message.copy.failed')) + }) + } + + return {copied ? : } +} + const WebSearchCitation: React.FC<{ citation: Citation }> = ({ citation }) => { const { data: fetchedContent, isLoading } = useQuery({ queryKey: ['webContent', citation.url], @@ -136,6 +143,7 @@ const WebSearchCitation: React.FC<{ citation: Citation }> = ({ citation }) => { handleLinkClick(citation.url, e)}> {citation.title || {citation.hostname}} + {fetchedContent && } {isLoading ? ( @@ -153,6 +161,7 @@ const KnowledgeCitation: React.FC<{ citation: Citation }> = ({ citation }) => ( handleLinkClick(citation.url, e)}> {citation.title} + {citation.content && } {citation.content && truncateText(citation.content, 100)} @@ -203,6 +212,23 @@ const CitationLink = styled.a` } ` +const CopyIconWrapper = styled.div` + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + color: var(--color-text-2); + opacity: 0.6; + margin-left: auto; + padding: 4px; + border-radius: 4px; + + &:hover { + opacity: 1; + background-color: var(--color-background-soft); + } +` + const WebSearchCard = styled.div` display: flex; flex-direction: column; @@ -219,6 +245,7 @@ const WebSearchCardHeader = styled.div` align-items: center; gap: 8px; margin-bottom: 6px; + width: 100%; ` const WebSearchCardContent = styled.div` diff --git a/src/renderer/src/utils/extract.ts b/src/renderer/src/utils/extract.ts index 4dd02ead69..2c71345255 100644 --- a/src/renderer/src/utils/extract.ts +++ b/src/renderer/src/utils/extract.ts @@ -1,4 +1,5 @@ import { XMLParser } from 'fast-xml-parser' + export interface ExtractResults { websearch?: WebsearchExtractResults knowledge?: KnowledgeExtractResults @@ -27,7 +28,6 @@ export const extractInfoFromXML = (text: string): ExtractResults => { return name === 'question' || name === 'links' } }) - const extractResults: ExtractResults = parser.parse(text) // Logger.log('Extracted results:', extractResults) - return extractResults + return parser.parse(text) } diff --git a/src/renderer/src/utils/formats.ts b/src/renderer/src/utils/formats.ts index 43f539d79f..a83ca4c632 100644 --- a/src/renderer/src/utils/formats.ts +++ b/src/renderer/src/utils/formats.ts @@ -2,6 +2,22 @@ import type { Message } from '@renderer/types/newMessage' import { findImageBlocks, getMainTextContent } from './messageUtils/find' +/** + * 清理Markdown内容 + * @param text 要清理的文本 + * @returns 清理后的文本 + */ +export function cleanMarkdownContent(text: string): string { + if (!text) return '' + let cleaned = text.replace(/!\[.*?]\(.*?\)/g, '') // 移除图片 + cleaned = cleaned.replace(/\[(.*?)]\(.*?\)/g, '$1') // 替换链接为纯文本 + cleaned = cleaned.replace(/https?:\/\/\S+/g, '') // 移除URL + cleaned = cleaned.replace(/[-—–_=+]{3,}/g, ' ') // 替换分隔符为空格 + cleaned = cleaned.replace(/[¥$€£¥%@#&*^()[\]{}<>~`'"\\|/_.]+/g, '') // 移除特殊字符 + cleaned = cleaned.replace(/\s+/g, ' ').trim() // 规范化空白 + return cleaned +} + export function escapeDollarNumber(text: string) { let escapedText = '' @@ -20,7 +36,7 @@ export function escapeDollarNumber(text: string) { } export function escapeBrackets(text: string) { - const pattern = /(```[\s\S]*?```|`.*?`)|\\\[([\s\S]*?[^\\])\\\]|\\\((.*?)\\\)/g + const pattern = /(```[\s\S]*?```|`.*?`)|\\\[([\s\S]*?[^\\])\\]|\\\((.*?)\\\)/g return text.replace(pattern, (match, codeBlock, squareBracket, roundBracket) => { if (codeBlock) { return codeBlock @@ -102,7 +118,7 @@ export function withGenerateImage(message: Message): { content: string; images?: const originalContent = getMainTextContent(message) const imagePattern = new RegExp(`!\\[[^\\]]*\\]\\((.*?)\\s*("(?:.*[^"])")?\\s*\\)`) const images: string[] = [] - let processedContent = originalContent + let processedContent: string processedContent = originalContent.replace(imagePattern, (_, url) => { if (url) { From e9afab7725648244adeaafc1c92f9728ce6b19d6 Mon Sep 17 00:00:00 2001 From: one Date: Wed, 14 May 2025 00:52:25 +0800 Subject: [PATCH 26/50] fix: quickpanel auto-scroll behaviour (#5950) * fix: quickpanel scrollto changed to smart * fix: add scrollTrigger as the replacement for scrollBlock * fix: add a 'none' trigger to prevent accidental scrolling --- .../src/components/QuickPanel/types.ts | 2 + .../src/components/QuickPanel/view.tsx | 38 +++++++++++++++---- 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/src/renderer/src/components/QuickPanel/types.ts b/src/renderer/src/components/QuickPanel/types.ts index e122aa1d29..7cef05be23 100644 --- a/src/renderer/src/components/QuickPanel/types.ts +++ b/src/renderer/src/components/QuickPanel/types.ts @@ -64,3 +64,5 @@ export interface QuickPanelContextType { readonly beforeAction?: (Options: QuickPanelCallBackOptions) => void readonly afterAction?: (Options: QuickPanelCallBackOptions) => void } + +export type QuickPanelScrollTrigger = 'initial' | 'keyboard' | 'none' diff --git a/src/renderer/src/components/QuickPanel/view.tsx b/src/renderer/src/components/QuickPanel/view.tsx index 2bd1b14349..1602b6a4ac 100644 --- a/src/renderer/src/components/QuickPanel/view.tsx +++ b/src/renderer/src/components/QuickPanel/view.tsx @@ -6,13 +6,19 @@ import { theme } from 'antd' import Color from 'color' import { t } from 'i18next' import { Check } from 'lucide-react' -import React, { use, useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react' +import React, { use, useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { FixedSizeList } from 'react-window' import styled from 'styled-components' import * as tinyPinyin from 'tiny-pinyin' import { QuickPanelContext } from './provider' -import { QuickPanelCallBackOptions, QuickPanelCloseAction, QuickPanelListItem, QuickPanelOpenOptions } from './types' +import { + QuickPanelCallBackOptions, + QuickPanelCloseAction, + QuickPanelListItem, + QuickPanelOpenOptions, + QuickPanelScrollTrigger +} from './types' const ITEM_HEIGHT = 31 @@ -45,6 +51,7 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { // 避免上下翻页时,鼠标干扰 const [isMouseOver, setIsMouseOver] = useState(false) + const scrollTriggerRef = useRef('initial') const [_index, setIndex] = useState(ctx.defaultIndex) const index = useDeferredValue(_index) const [historyPanel, setHistoryPanel] = useState([]) @@ -140,6 +147,7 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { (action?: QuickPanelCloseAction) => { ctx.close(action) setHistoryPanel([]) + scrollTriggerRef.current = 'initial' if (action === 'delete-symbol') { const textArea = document.querySelector('.inputbar textarea') as HTMLTextAreaElement @@ -249,10 +257,13 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [ctx.isVisible]) - useEffect(() => { - if (index >= 0) { - listRef.current?.scrollToItem(index, 'auto') - } + useLayoutEffect(() => { + if (!listRef.current || index < 0 || scrollTriggerRef.current === 'none') return + + const alignment = scrollTriggerRef.current === 'keyboard' ? 'auto' : 'smart' + listRef.current?.scrollToItem(index, alignment) + + scrollTriggerRef.current = 'none' }, [index]) // 处理键盘事件 @@ -277,6 +288,7 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { switch (e.key) { case 'ArrowUp': + scrollTriggerRef.current = 'keyboard' if (isAssistiveKeyPressed) { setIndex((prev) => { const newIndex = prev - ctx.pageSize @@ -289,6 +301,7 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { break case 'ArrowDown': + scrollTriggerRef.current = 'keyboard' if (isAssistiveKeyPressed) { setIndex((prev) => { const newIndex = prev + ctx.pageSize @@ -301,6 +314,7 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { break case 'PageUp': + scrollTriggerRef.current = 'keyboard' setIndex((prev) => { const newIndex = prev - ctx.pageSize return newIndex < 0 ? 0 : newIndex @@ -308,6 +322,7 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { break case 'PageDown': + scrollTriggerRef.current = 'keyboard' setIndex((prev) => { const newIndex = prev + ctx.pageSize return newIndex >= list.length ? list.length - 1 : newIndex @@ -317,6 +332,7 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { case 'ArrowLeft': if (!isAssistiveKeyPressed) return if (!historyPanel.length) return + scrollTriggerRef.current = 'initial' clearSearchText(false) if (historyPanel.length > 0) { const lastPanel = historyPanel.pop() @@ -329,6 +345,7 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { case 'ArrowRight': if (!isAssistiveKeyPressed) return if (!list?.[index]?.isMenu) return + scrollTriggerRef.current = 'initial' clearSearchText(false) handleItemAction(list[index], 'enter') break @@ -413,7 +430,14 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { $selectedColor={selectedColor} $selectedColorHover={selectedColorHover} className={ctx.isVisible ? 'visible' : ''}> - setIsMouseOver(true)}> + + setIsMouseOver((prev) => { + scrollTriggerRef.current = 'initial' + return prev ? prev : true + }) + }> Date: Wed, 14 May 2025 13:51:25 +0800 Subject: [PATCH 27/50] fix: improve citation deduplication logic for non-knowledge citations (#5981) --- src/renderer/src/store/messageBlock.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/store/messageBlock.ts b/src/renderer/src/store/messageBlock.ts index 2a4ac9845c..c9cc55cec3 100644 --- a/src/renderer/src/store/messageBlock.ts +++ b/src/renderer/src/store/messageBlock.ts @@ -236,10 +236,11 @@ const formatCitationsFromBlock = (block: CitationMessageBlock | undefined): Cita }) ) } - // 4. Deduplicate by URL and Renumber Sequentially + // 4. Deduplicate non-knowledge citations by URL and Renumber Sequentially const urlSet = new Set() return formattedCitations .filter((citation) => { + if (citation.type === 'knowledge') return true if (!citation.url || urlSet.has(citation.url)) return false urlSet.add(citation.url) return true From 51061d4d1aae4dcedd66515b6e63f0af8b66366a Mon Sep 17 00:00:00 2001 From: SuYao Date: Wed, 14 May 2025 13:52:31 +0800 Subject: [PATCH 28/50] fix: append topic prompt if exists (#5969) --- src/renderer/src/pages/home/Inputbar/Inputbar.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx index ab13fe2489..39621cc85d 100644 --- a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx +++ b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx @@ -215,6 +215,10 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = ) } + if (topic.prompt) { + baseUserMessage.assistant.prompt = assistant.prompt ? `${assistant.prompt}\n${topic.prompt}` : topic.prompt + } + baseUserMessage.usage = await estimateUserPromptUsage(baseUserMessage) const { message, blocks } = getUserMessage(baseUserMessage) From 4cb4890be750c283599a10eb4ef796113c2cc6fd Mon Sep 17 00:00:00 2001 From: Lao Date: Wed, 14 May 2025 17:01:33 +0800 Subject: [PATCH 29/50] fix lint errors (#5987) * Fix code snippets that don't comply with code standards by applying lint rules * update package.json:add test:lint script --- package.json | 1 + src/main/services/BackupManager.ts | 2 +- .../pages/agents/components/AddAgentPopup.tsx | 18 +++++++++-------- src/renderer/src/pages/agents/index.ts | 20 +++++++++---------- .../src/pages/home/Messages/MessageTokens.tsx | 4 ++-- 5 files changed, 24 insertions(+), 21 deletions(-) diff --git a/package.json b/package.json index 9d769d9a25..b1668efbb7 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "test:renderer": "vitest run", "test:renderer:ui": "vitest --ui", "test:renderer:coverage": "vitest run --coverage", + "test:lint":"eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts", "format": "prettier --write .", "lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix", "postinstall": "electron-builder install-app-deps", diff --git a/src/main/services/BackupManager.ts b/src/main/services/BackupManager.ts index 6be19d035b..ea8521aa16 100644 --- a/src/main/services/BackupManager.ts +++ b/src/main/services/BackupManager.ts @@ -4,8 +4,8 @@ import archiver from 'archiver' import { exec } from 'child_process' import { app } from 'electron' import Logger from 'electron-log' -import StreamZip from 'node-stream-zip' import * as fs from 'fs-extra' +import StreamZip from 'node-stream-zip' import * as path from 'path' import { createClient, CreateDirectoryOptions, FileStat } from 'webdav' diff --git a/src/renderer/src/pages/agents/components/AddAgentPopup.tsx b/src/renderer/src/pages/agents/components/AddAgentPopup.tsx index eeca7a39c8..fc341e970e 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, RollbackOutlined } from '@ant-design/icons' +import { CheckOutlined, LoadingOutlined, RollbackOutlined, ThunderboltOutlined } from '@ant-design/icons' import EmojiPicker from '@renderer/components/EmojiPicker' import { TopView } from '@renderer/components/TopView' import { AGENT_PROMPT } from '@renderer/config/prompts' @@ -132,8 +132,8 @@ const PopupContainer: React.FC = ({ resolve }) => { } const handleUndoButtonClick = async () => { - form.setFieldsValue({ prompt: originalPrompt }) - setShowUndoButton(false) + form.setFieldsValue({ prompt: originalPrompt }) + setShowUndoButton(false) } // Compute label width based on the longest label @@ -191,11 +191,13 @@ const PopupContainer: React.FC = ({ resolve }) => { style={{ position: 'absolute', top: 8, right: 8 }} disabled={loading} /> - {showUndoButton && + onClick={() => handleZoomFactor(0, true)} + style={{ marginLeft: 8 }} + icon={} + /> {isMac && ( diff --git a/src/renderer/src/pages/settings/ProviderSettings/ModelListSearchBar.tsx b/src/renderer/src/pages/settings/ProviderSettings/ModelListSearchBar.tsx index bf59cf2e50..8a9e7cd68d 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/ModelListSearchBar.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/ModelListSearchBar.tsx @@ -78,7 +78,7 @@ const ModelListSearchBar: React.FC = ({ onSearch }) => visible: { opacity: 1, transition: { duration: 0.1, delay: 0.3, ease: 'easeInOut' } }, hidden: { opacity: 0, transition: { duration: 0.1, ease: 'easeInOut' } } }} - style={{ cursor: 'pointer' }} + style={{ cursor: 'pointer', display: 'flex' }} onClick={() => setSearchVisible(true)}> diff --git a/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx b/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx index 2f58a924dd..31ea151cbb 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx @@ -404,7 +404,7 @@ const ProviderSetting: FC = ({ provider: _provider }) => { {provider.id === 'copilot' && } - + {t('common.models')} {!isEmpty(models) && }