From d424bb122447f289a91445cbd5bd1e6963b06341 Mon Sep 17 00:00:00 2001 From: one Date: Wed, 13 Aug 2025 14:41:13 +0800 Subject: [PATCH 01/22] fix: codeblock special view (#9120) * Revert "fix(CodeBlockView): initial view mode (#9047)" This reverts commit 28e6135f8c37117b585db56f15dece2bc9e251c1. * fix: code block border radius * chore: bump mermaid to 11.9.0 --- package.json | 2 +- .../src/components/CodeBlockView/view.tsx | 30 ++++++---------- yarn.lock | 34 +++++++++---------- 3 files changed, 29 insertions(+), 37 deletions(-) diff --git a/package.json b/package.json index 876609c6d8..2198986545 100644 --- a/package.json +++ b/package.json @@ -213,7 +213,7 @@ "lucide-react": "^0.525.0", "macos-release": "^3.4.0", "markdown-it": "^14.1.0", - "mermaid": "^11.7.0", + "mermaid": "^11.9.0", "mime": "^4.0.4", "motion": "^12.10.5", "notion-helper": "^1.3.22", diff --git a/src/renderer/src/components/CodeBlockView/view.tsx b/src/renderer/src/components/CodeBlockView/view.tsx index 356646f73a..ad95a44ca0 100644 --- a/src/renderer/src/components/CodeBlockView/view.tsx +++ b/src/renderer/src/components/CodeBlockView/view.tsx @@ -58,12 +58,9 @@ export const CodeBlockView: React.FC = memo(({ children, language, onSave const { t } = useTranslation() const { codeEditor, codeExecution, codeImageTools, codeCollapsible, codeWrappable } = useSettings() - const [viewState, setViewState] = useState(() => { - const initialMode = SPECIAL_VIEWS.includes(language) ? 'special' : 'source' - return { - mode: initialMode as ViewMode, - previousMode: initialMode as ViewMode - } + const [viewState, setViewState] = useState({ + mode: 'special' as ViewMode, + previousMode: 'special' as ViewMode }) const { mode: viewMode } = viewState @@ -99,18 +96,10 @@ export const CodeBlockView: React.FC = memo(({ children, language, onSave const hasSpecialView = useMemo(() => SPECIAL_VIEWS.includes(language), [language]) - // TODO: 考虑移除 const isInSpecialView = useMemo(() => { return hasSpecialView && viewMode === 'special' }, [hasSpecialView, viewMode]) - // 不支持特殊视图时回退到 source - useEffect(() => { - if (!hasSpecialView && viewMode !== 'source') { - setViewMode('source') - } - }, [hasSpecialView, viewMode, setViewMode]) - const [expandOverride, setExpandOverride] = useState(!codeCollapsible) const [unwrapOverride, setUnwrapOverride] = useState(!codeWrappable) @@ -298,11 +287,14 @@ export const CodeBlockView: React.FC = memo(({ children, language, onSave // 根据视图模式和语言选择组件,优先展示特殊视图,fallback是源代码视图 const renderContent = useMemo(() => { - const showSpecialView = specialView && ['special', 'split'].includes(viewMode) + const showSpecialView = !!specialView && ['special', 'split'].includes(viewMode) const showSourceView = !specialView || viewMode !== 'special' return ( - + {showSpecialView && specialView} {showSourceView && sourceView} @@ -373,7 +365,7 @@ const CodeHeader = styled.div<{ $isInSpecialView: boolean }>` background-color: ${(props) => (props.$isInSpecialView ? 'transparent' : 'var(--color-background-mute)')}; ` -const SplitViewWrapper = styled.div<{ $viewMode?: ViewMode }>` +const SplitViewWrapper = styled.div<{ $isSpecialView: boolean; $isSplitView: boolean }>` display: flex; > * { @@ -383,13 +375,13 @@ const SplitViewWrapper = styled.div<{ $viewMode?: ViewMode }>` &:not(:has(+ [class*='Container'])) { // 特殊视图的 header 会隐藏,所以全都使用圆角 - border-radius: ${(props) => (props.$viewMode === 'special' ? '8px' : '0 0 8px 8px')}; + border-radius: ${(props) => (props.$isSpecialView ? '8px' : '0 0 8px 8px')}; overflow: hidden; } // 在 split 模式下添加中间分隔线 ${(props) => - props.$viewMode === 'split' && + props.$isSplitView && css` position: relative; diff --git a/yarn.lock b/yarn.lock index 232bee090f..c970bca393 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4378,12 +4378,12 @@ __metadata: languageName: node linkType: hard -"@mermaid-js/parser@npm:^0.5.0": - version: 0.5.0 - resolution: "@mermaid-js/parser@npm:0.5.0" +"@mermaid-js/parser@npm:^0.6.2": + version: 0.6.2 + resolution: "@mermaid-js/parser@npm:0.6.2" dependencies: langium: "npm:3.3.1" - checksum: 10c0/af1c1cf6cfe808bf5f7c232a881e5f9d6778c2fc3997d8ea3da93f59097411d0e13f74649e2576488f82227bab58e47a49f4e77cb11cf4196176f3c4135c724d + checksum: 10c0/6059341a5dc3fdf56dd75c858843154e18c582e5cc41c3e73e9a076e218116c6bdbdba729d27154cef61430c900d87342423bbb81e37d8a9968c6c2fdd99e87a languageName: node linkType: hard @@ -8588,7 +8588,7 @@ __metadata: lucide-react: "npm:^0.525.0" macos-release: "npm:^3.4.0" markdown-it: "npm:^14.1.0" - mermaid: "npm:^11.7.0" + mermaid: "npm:^11.9.0" mime: "npm:^4.0.4" motion: "npm:^12.10.5" node-stream-zip: "npm:^1.15.0" @@ -14812,7 +14812,7 @@ __metadata: languageName: node linkType: hard -"katex@npm:^0.16.0, katex@npm:^0.16.9": +"katex@npm:^0.16.0, katex@npm:^0.16.22": version: 0.16.22 resolution: "katex@npm:0.16.22" dependencies: @@ -15664,12 +15664,12 @@ __metadata: languageName: node linkType: hard -"marked@npm:^15.0.7": - version: 15.0.11 - resolution: "marked@npm:15.0.11" +"marked@npm:^16.0.0": + version: 16.1.2 + resolution: "marked@npm:16.1.2" bin: marked: bin/marked.js - checksum: 10c0/d532db4955c1f2ac6efc65a644725e9e12e7944cb6af40c7148baecfd3b3c2f3564229b3daf12d2125635466448fb9b367ce52357be3aea0273e3d152efdbdcf + checksum: 10c0/4e5878f1aa89de139bed14835865af20f26527674f41dedf2b33d2f85360298a1a0cc0505c675f072175c86eb30684c7b4e287d18f5958daa26e36bc1308d321 languageName: node linkType: hard @@ -16087,13 +16087,13 @@ __metadata: languageName: node linkType: hard -"mermaid@npm:^11.7.0": - version: 11.7.0 - resolution: "mermaid@npm:11.7.0" +"mermaid@npm:^11.9.0": + version: 11.9.0 + resolution: "mermaid@npm:11.9.0" dependencies: "@braintree/sanitize-url": "npm:^7.0.4" "@iconify/utils": "npm:^2.1.33" - "@mermaid-js/parser": "npm:^0.5.0" + "@mermaid-js/parser": "npm:^0.6.2" "@types/d3": "npm:^7.4.3" cytoscape: "npm:^3.29.3" cytoscape-cose-bilkent: "npm:^4.1.0" @@ -16103,15 +16103,15 @@ __metadata: dagre-d3-es: "npm:7.0.11" dayjs: "npm:^1.11.13" dompurify: "npm:^3.2.5" - katex: "npm:^0.16.9" + katex: "npm:^0.16.22" khroma: "npm:^2.1.0" lodash-es: "npm:^4.17.21" - marked: "npm:^15.0.7" + marked: "npm:^16.0.0" roughjs: "npm:^4.6.6" stylis: "npm:^4.3.6" ts-dedent: "npm:^2.2.0" uuid: "npm:^11.1.0" - checksum: 10c0/ab37f563b54d53c513d792a91aae54c6e2ed20f4d8606cdec993d60b8c50534ac6ab740408d710a655c6190341704cf133f0a7fb47e230c0c94b38cf08e07775 + checksum: 10c0/f3420d0fd8919b31e36354cbf0ddd26398898c960e0bcb0e52aceae657245fcf1e5fe3e28651bff83c9b1fb8b6d3e07fc8b26d111ef3159fcf780d53ce40a437 languageName: node linkType: hard From 0634baf780cf8b3b44b64387535997c0dff0bf02 Mon Sep 17 00:00:00 2001 From: Phantom <59059173+EurFelux@users.noreply.github.com> Date: Wed, 13 Aug 2025 14:51:23 +0800 Subject: [PATCH 02/22] fix(providers): qiniu doesn't support developer role (#9125) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix(providers): 更新不支持developer角色的提供商列表 --- src/renderer/src/config/providers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/src/config/providers.ts b/src/renderer/src/config/providers.ts index 0da3a1b8b7..dd046a57ea 100644 --- a/src/renderer/src/config/providers.ts +++ b/src/renderer/src/config/providers.ts @@ -1246,7 +1246,7 @@ export const isSupportArrayContentProvider = (provider: Provider) => { ) } -const NOT_SUPPORT_DEVELOPER_ROLE_PROVIDERS = ['poe'] as const satisfies SystemProviderId[] +const NOT_SUPPORT_DEVELOPER_ROLE_PROVIDERS = ['poe', 'qiniu'] as const satisfies SystemProviderId[] /** * 判断提供商是否支持 developer 作为 message role。 Only for OpenAI API. From ceef19e55b243adf26ea9e9e529baa95a345cc00 Mon Sep 17 00:00:00 2001 From: Teo Date: Wed, 13 Aug 2025 14:57:58 +0800 Subject: [PATCH 03/22] feat: add message outline (#9090) * feat: add message outline feature --- src/renderer/src/i18n/locales/en-us.json | 1 + src/renderer/src/i18n/locales/ja-jp.json | 1 + src/renderer/src/i18n/locales/ru-ru.json | 1 + src/renderer/src/i18n/locales/zh-cn.json | 1 + src/renderer/src/i18n/locales/zh-tw.json | 1 + .../src/pages/home/Markdown/Markdown.tsx | 10 +- .../home/Markdown/plugins/rehypeHeadingIds.ts | 70 +++++++ .../src/pages/home/Messages/Message.tsx | 6 +- .../src/pages/home/Messages/MessageGroup.tsx | 2 +- .../pages/home/Messages/MessageOutline.tsx | 180 ++++++++++++++++++ .../src/pages/home/Tabs/SettingsTab.tsx | 13 +- src/renderer/src/store/settings.ts | 8 +- 12 files changed, 286 insertions(+), 8 deletions(-) create mode 100644 src/renderer/src/pages/home/Markdown/plugins/rehypeHeadingIds.ts create mode 100644 src/renderer/src/pages/home/Messages/MessageOutline.tsx diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index d1ec6e6b5c..b243bf7548 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -2960,6 +2960,7 @@ "none": "None" }, "prompt": "Show prompt", + "show_message_outline": "Show message outline", "title": "Message Settings", "use_serif_font": "Use serif font" }, diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index 94bf9b7faa..55229c8cdf 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -2960,6 +2960,7 @@ "none": "表示しない" }, "prompt": "プロンプト表示", + "show_message_outline": "メッセージの概要を表示します", "title": "メッセージ設定", "use_serif_font": "セリフフォントを使用" }, diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index 3da62da559..9cff784fd9 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -2960,6 +2960,7 @@ "none": "Не показывать" }, "prompt": "Показывать подсказки", + "show_message_outline": "Показать наброски сообщения", "title": "Настройки сообщений", "use_serif_font": "Использовать serif шрифт" }, diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index beac18d19e..81a7094f83 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -2960,6 +2960,7 @@ "none": "不显示" }, "prompt": "显示提示词", + "show_message_outline": "显示消息大纲", "title": "消息设置", "use_serif_font": "使用衬线字体" }, diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index e2f156a9d0..8af0f5477b 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -2960,6 +2960,7 @@ "none": "不顯示" }, "prompt": "提示詞顯示", + "show_message_outline": "顯示消息大綱", "title": "訊息設定", "use_serif_font": "使用襯線字型" }, diff --git a/src/renderer/src/pages/home/Markdown/Markdown.tsx b/src/renderer/src/pages/home/Markdown/Markdown.tsx index 0e3d0ef580..d0590b1496 100644 --- a/src/renderer/src/pages/home/Markdown/Markdown.tsx +++ b/src/renderer/src/pages/home/Markdown/Markdown.tsx @@ -29,6 +29,7 @@ import { Pluggable } from 'unified' import CodeBlock from './CodeBlock' import Link from './Link' +import rehypeHeadingIds from './plugins/rehypeHeadingIds' import remarkDisableConstructs from './plugins/remarkDisableConstructs' import Table from './Table' @@ -110,17 +111,18 @@ const Markdown: FC = ({ block, postProcess }) => { }, [block, displayedContent, t]) const rehypePlugins = useMemo(() => { - const plugins: any[] = [] + const plugins: Pluggable[] = [] if (ALLOWED_ELEMENTS.test(messageContent)) { plugins.push(rehypeRaw) } + plugins.push([rehypeHeadingIds, { prefix: `heading-${block.id}` }]) if (mathEngine === 'KaTeX') { - plugins.push(rehypeKatex as any) + plugins.push(rehypeKatex) } else if (mathEngine === 'MathJax') { - plugins.push(rehypeMathjax as any) + plugins.push(rehypeMathjax) } return plugins - }, [mathEngine, messageContent]) + }, [mathEngine, messageContent, block.id]) const onSaveCodeBlock = useCallback( (id: string, newContent: string) => { diff --git a/src/renderer/src/pages/home/Markdown/plugins/rehypeHeadingIds.ts b/src/renderer/src/pages/home/Markdown/plugins/rehypeHeadingIds.ts new file mode 100644 index 0000000000..e3e7e6db75 --- /dev/null +++ b/src/renderer/src/pages/home/Markdown/plugins/rehypeHeadingIds.ts @@ -0,0 +1,70 @@ +import type { Root, Node, Element, Text } from 'hast' +import { visit } from 'unist-util-visit' + +/** + * 基于 GitHub 风格的标题 slug 生成器(去重逻辑) + * - 小写 + * - 去除前后空白 + * - 移除部分标点 + * - 将空白与非字母数字字符合并为单个 '-' + * - 多次出现的相同 slug 加上递增后缀(-1, -2...) + */ +export function createSlugger() { + const seen = new Map() + const normalize = (text: string): string => { + const slug = (text || 'section') + .toLowerCase() + .trim() + // 移除常见分隔符和标点 + .replace(/[\u200B-\u200D\uFEFF]/g, '') // 零宽字符 + .replace(/["'`(){}[\]:;!?.,]/g, '') + // 将空白和非字母数字字符转换为 '-' + .replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '-') + // 合并多余的 '-' + .replace(/-{2,}/g, '-') + // 去除首尾 '-' + .replace(/^-|-$/g, '') + + return slug + } + + const slug = (text: string): string => { + const base = normalize(text) + const count = seen.get(base) || 0 + seen.set(base, count + 1) + return `${base}-${count}` + } + + return { slug } +} + +export function extractTextFromNode(node: Node | Text | Element | null | undefined): string { + if (!node) return '' + + if (typeof (node as Text).value === 'string') { + return (node as Text).value + } + + if ((node as Element).children?.length) { + return (node as Element).children.map(extractTextFromNode).join('') + } + + return '' +} + +export default function rehypeHeadingIds(options?: { prefix?: string }) { + return (tree: Root) => { + const slugger = createSlugger() + const prefix = options?.prefix ? `${options.prefix}--` : '' + visit(tree, 'element', (node) => { + if (!node || typeof node.tagName !== 'string') return + const tag = node.tagName.toLowerCase() + if (!/^h[1-6]$/.test(tag)) return + + const text = extractTextFromNode(node) + const id = prefix + slugger.slug(text) + node.properties = node.properties || {} + if (!node.properties.id) node.properties.id = id + }) + } +} diff --git a/src/renderer/src/pages/home/Messages/Message.tsx b/src/renderer/src/pages/home/Messages/Message.tsx index af69c18a4b..147a831f27 100644 --- a/src/renderer/src/pages/home/Messages/Message.tsx +++ b/src/renderer/src/pages/home/Messages/Message.tsx @@ -23,6 +23,7 @@ import MessageEditor from './MessageEditor' import MessageErrorBoundary from './MessageErrorBoundary' import MessageHeader from './MessageHeader' import MessageMenubar from './MessageMenubar' +import MessageOutline from './MessageOutline' interface Props { message: Message @@ -66,7 +67,7 @@ const MessageItem: FC = ({ const { assistant, setModel } = useAssistant(message.assistantId) const { isMultiSelectMode } = useChatContext(topic) const model = useModel(getMessageModelId(message), message.model?.provider) || message.model - const { messageFont, fontSize, messageStyle } = useSettings() + const { messageFont, fontSize, messageStyle, showMessageOutline } = useSettings() const { editMessageBlocks, resendUserMessageWithEdit, editMessage } = useMessageOperations(topic) const messageContainerRef = useRef(null) const { editingMessageId, stopEditing } = useMessageEditing() @@ -183,6 +184,9 @@ const MessageItem: FC = ({ )} {!isEditing && ( <> + {!isMultiSelectMode && message.role === 'assistant' && showMessageOutline && ( + + )} ` &.horizontal { - padding-right: 1px; + padding: 1px; overflow-y: auto; .message { height: 100%; diff --git a/src/renderer/src/pages/home/Messages/MessageOutline.tsx b/src/renderer/src/pages/home/Messages/MessageOutline.tsx new file mode 100644 index 0000000000..1f967a2f65 --- /dev/null +++ b/src/renderer/src/pages/home/Messages/MessageOutline.tsx @@ -0,0 +1,180 @@ +import Scrollbar from '@renderer/components/Scrollbar' +import { RootState } from '@renderer/store' +import { messageBlocksSelectors } from '@renderer/store/messageBlock' +import { Message, MessageBlockType } from '@renderer/types/newMessage' +import React, { FC, useMemo, useRef } from 'react' +import { useSelector } from 'react-redux' +import remarkParse from 'remark-parse' +import styled from 'styled-components' +import { unified } from 'unified' +import { visit } from 'unist-util-visit' + +import { createSlugger, extractTextFromNode } from '../Markdown/plugins/rehypeHeadingIds' + +interface MessageOutlineProps { + message: Message +} + +interface HeadingItem { + id: string + level: number + text: string +} + +const MessageOutline: FC = ({ message }) => { + const blockEntities = useSelector((state: RootState) => messageBlocksSelectors.selectEntities(state)) + + const headings: HeadingItem[] = useMemo(() => { + const mainTextBlocks = message.blocks + .map((blockId) => blockEntities[blockId]) + .filter((b) => b?.type === MessageBlockType.MAIN_TEXT) + + if (!mainTextBlocks?.length) return [] + + const result: HeadingItem[] = [] + mainTextBlocks.forEach((mainTextBlock) => { + const tree = unified().use(remarkParse).parse(mainTextBlock?.content) + const slugger = createSlugger() + visit(tree, ['heading', 'html'], (node) => { + if (node.type === 'heading') { + const level = node.depth ?? 0 + if (!level || level < 1 || level > 6) return + const text = extractTextFromNode(node) + if (!text) return + const id = `heading-${mainTextBlock.id}--` + slugger.slug(text || '') + result.push({ id, level, text: text }) + } else if (node.type === 'html') { + // 匹配

...

...
+ const match = node.value.match(/]*>(.*?)<\/h\1>/i) + if (match) { + const level = parseInt(match[1], 10) + const text = match[2].replace(/<[^>]*>/g, '').trim() // 移除内部的HTML标签 + if (text) { + const id = `heading-${mainTextBlock.id}--${slugger.slug(text || '')}` + result.push({ id, level, text }) + } + } + } + }) + }) + + return result + }, [message.blocks, blockEntities]) + + const miniLevel = useMemo(() => { + return headings.length ? Math.min(...headings.map((heading) => heading.level)) : 1 + }, [headings]) + + const messageOutlineContainerRef = useRef(null) + const scrollToHeading = (id: string) => { + const parent = messageOutlineContainerRef.current?.parentElement + const messageContentContainer = parent?.querySelector('.message-content-container') + if (messageContentContainer) { + const headingElement = messageContentContainer.querySelector(`#${id}`) + if (headingElement) { + const scrollBlock = ['horizontal', 'grid'].includes(message.multiModelMessageStyle ?? '') ? 'nearest' : 'start' + headingElement.scrollIntoView({ behavior: 'smooth', block: scrollBlock }) + } + } + } + + // 暂时不支持 grid,因为在锚点滚动时会导致渲染错位 + if (message.multiModelMessageStyle === 'grid' || !headings.length) return null + + return ( + + + {headings.map((heading, index) => ( + scrollToHeading(heading.id)}> + + + {heading.text} + + + ))} + + + ) +} + +const MessageOutlineContainer = styled.div` + position: absolute; + inset: 63px 0 36px 10px; + z-index: 999; + pointer-events: none; + & ~ .message-content-container { + padding-left: 46px !important; + } + & ~ .MessageFooter { + margin-left: 46px !important; + } +` + +const MessageOutlineItemDot = styled.div<{ $level: number }>` + width: ${({ $level }) => 16 - $level * 2}px; + height: 4px; + background: var(--color-border); + border-radius: 2px; + margin-right: 4px; + flex-shrink: 0; + transition: background 0.2s ease; +` + +const MessageOutlineItemText = styled.div<{ $level: number; $miniLevel: number }>` + overflow: hidden; + color: var(--color-text-3); + opacity: 0; + display: none; + transition: opacity 0.2s ease; + padding: 2px 8px; + padding-left: ${({ $level, $miniLevel }) => ($level - $miniLevel) * 8}px; + font-size: ${({ $level }) => 16 - $level}px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +` + +const MessageOutlineItem = styled.div` + height: 24px; + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + flex-shrink: 0; + &:hover { + ${MessageOutlineItemText} { + color: var(--color-text-2); + } + ${MessageOutlineItemDot} { + background: var(--color-text-3); + } + } +` + +const MessageOutlineBody = styled(Scrollbar)<{ $count: number }>` + max-width: 50%; + max-height: min(100%, 70vh); + position: sticky; + top: max(calc(50% - ${({ $count }) => ($count * 24) / 2 + 10}px), 20px); + bottom: 0; + overflow-x: hidden; + overflow-y: hidden; + display: inline-flex; + flex-direction: column; + padding: 10px 0 10px 10px; + gap: 4px; + border-radius: 10px; + pointer-events: auto; + &:hover { + padding: 10px 10px 10px 10px; + overflow-y: auto; + background: var(--color-background); + box-shadow: 0 0 10px 0 rgba(128, 128, 128, 0.2); + ${MessageOutlineItemText} { + opacity: 1; + display: block; + } + } +` + +export default React.memo(MessageOutline) diff --git a/src/renderer/src/pages/home/Tabs/SettingsTab.tsx b/src/renderer/src/pages/home/Tabs/SettingsTab.tsx index e609905411..b290c34754 100644 --- a/src/renderer/src/pages/home/Tabs/SettingsTab.tsx +++ b/src/renderer/src/pages/home/Tabs/SettingsTab.tsx @@ -38,6 +38,7 @@ import { setPasteLongTextThreshold, setRenderInputMessageAsMarkdown, setShowInputEstimatedTokens, + setShowMessageOutline, setShowPrompt, setShowTranslateConfirm, setThoughtAutoCollapse @@ -103,7 +104,8 @@ const SettingsTab: FC = (props) => { messageNavigation, enableQuickPanelTriggers, enableBackspaceDeleteModel, - showTranslateConfirm + showTranslateConfirm, + showMessageOutline } = useSettings() const onUpdateAssistantSettings = (settings: Partial) => { @@ -332,6 +334,15 @@ const SettingsTab: FC = (props) => { /> + + {t('settings.messages.show_message_outline')} + dispatch(setShowMessageOutline(checked))} + /> + + {t('message.message.style.label')} ) => { + state.showMessageOutline = action.payload } } }) @@ -958,6 +963,7 @@ export const { setS3Partial, setEnableDeveloperMode, setNavbarPosition, + setShowMessageOutline, // API Server actions setApiServerEnabled, setApiServerPort, From 4cda5f17875c520e2bbedb4286f3dedc87f34b3f Mon Sep 17 00:00:00 2001 From: one Date: Wed, 13 Aug 2025 16:14:59 +0800 Subject: [PATCH 04/22] feat(Markdown): support disabling single dollar math (#9131) * feat(Markdown): support disabling single dollar math * fix: lint error --- src/renderer/src/i18n/locales/en-us.json | 15 ++++-- src/renderer/src/i18n/locales/ja-jp.json | 15 ++++-- src/renderer/src/i18n/locales/ru-ru.json | 15 ++++-- src/renderer/src/i18n/locales/zh-cn.json | 15 ++++-- src/renderer/src/i18n/locales/zh-tw.json | 15 ++++-- .../src/pages/home/Markdown/Markdown.tsx | 6 +-- .../home/Markdown/__tests__/Markdown.test.tsx | 12 ++--- .../home/Markdown/plugins/rehypeHeadingIds.ts | 2 +- .../src/pages/home/Tabs/SettingsTab.tsx | 46 +++++++++++++------ src/renderer/src/store/index.ts | 2 +- src/renderer/src/store/migrate.ts | 9 ++++ src/renderer/src/store/settings.ts | 6 +++ 12 files changed, 114 insertions(+), 44 deletions(-) diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index b243bf7548..5b2169ebdd 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -2715,6 +2715,17 @@ "title": "Launch", "totray": "Minimize to Tray on Launch" }, + "math": { + "engine": { + "label": "Math engine", + "none": "None" + }, + "single_dollar": { + "label": "Enable $...$", + "tip": "Render math equations quoted by single dollar signs $...$. Default is enabled." + }, + "title": "Math Settings" + }, "mcp": { "actions": "Actions", "active": "Active", @@ -2945,10 +2956,6 @@ "title": "Input Settings" }, "markdown_rendering_input_message": "Markdown render input message", - "math_engine": { - "label": "Math engine", - "none": "None" - }, "metrics": "{{time_first_token_millsec}}ms to first token | {{token_speed}} tok/sec", "model": { "title": "Model Settings" diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index 55229c8cdf..c9dfd6418b 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -2715,6 +2715,17 @@ "title": "起動", "totray": "起動時にトレイに最小化" }, + "math": { + "engine": { + "label": "数式エンジン", + "none": "なし" + }, + "single_dollar": { + "label": "$...$ を有効にする", + "tip": "単一のドル記号 $...$ で囲まれた数式をレンダリングします。デフォルトで有効です。" + }, + "title": "数式設定" + }, "mcp": { "actions": "操作", "active": "有効", @@ -2945,10 +2956,6 @@ "title": "入力設定" }, "markdown_rendering_input_message": "Markdownで入力メッセージをレンダリング", - "math_engine": { - "label": "数式エンジン", - "none": "なし" - }, "metrics": "最初のトークンまでの時間 {{time_first_token_millsec}}ms | トークン速度 {{token_speed}} tok/sec", "model": { "title": "モデル設定" diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index 9cff784fd9..e7b000c671 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -2715,6 +2715,17 @@ "title": "Запуск", "totray": "Свернуть в трей при запуске" }, + "math": { + "engine": { + "label": "Математический движок", + "none": "Нет" + }, + "single_dollar": { + "label": "Включить $...$", + "tip": "Отображать математические формулы, заключенные в одиночные символы доллара $...$. По умолчанию включено." + }, + "title": "Настройки математических формул" + }, "mcp": { "actions": "Действия", "active": "Активен", @@ -2945,10 +2956,6 @@ "title": "Настройки ввода" }, "markdown_rendering_input_message": "Отображение ввода в формате Markdown", - "math_engine": { - "label": "Математический движок", - "none": "Нет" - }, "metrics": "{{time_first_token_millsec}}ms до первого токена | {{token_speed}} tok/sec", "model": { "title": "Настройки модели" diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 81a7094f83..8aedda3c48 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -2715,6 +2715,17 @@ "title": "启动", "totray": "启动时最小化到托盘" }, + "math": { + "engine": { + "label": "数学公式引擎", + "none": "无" + }, + "single_dollar": { + "label": "启用 $...$", + "tip": "渲染单个美元符号 $...$ 包裹的数学公式,默认启用。" + }, + "title": "数学公式设置" + }, "mcp": { "actions": "操作", "active": "启用", @@ -2945,10 +2956,6 @@ "title": "输入设置" }, "markdown_rendering_input_message": "Markdown 渲染输入消息", - "math_engine": { - "label": "数学公式引擎", - "none": "无" - }, "metrics": "首字时延 {{time_first_token_millsec}} ms | 每秒 {{token_speed}} tokens", "model": { "title": "模型设置" diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 8af0f5477b..188a7782c1 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -2715,6 +2715,17 @@ "title": "啟動", "totray": "啟動時最小化到系统匣" }, + "math": { + "engine": { + "label": "數學公式引擎", + "none": "無" + }, + "single_dollar": { + "label": "啟用 $...$", + "tip": "渲染單個美元符號 $...$ 包裹的數學公式,默認啟用。" + }, + "title": "數學公式設定" + }, "mcp": { "actions": "操作", "active": "啟用", @@ -2945,10 +2956,6 @@ "title": "輸入設定" }, "markdown_rendering_input_message": "Markdown 渲染輸入訊息", - "math_engine": { - "label": "數學公式引擎", - "none": "無" - }, "metrics": "首字延遲 {{time_first_token_millsec}} ms | 每秒 {{token_speed}} tokens", "model": { "title": "模型設定" diff --git a/src/renderer/src/pages/home/Markdown/Markdown.tsx b/src/renderer/src/pages/home/Markdown/Markdown.tsx index d0590b1496..98a24b8735 100644 --- a/src/renderer/src/pages/home/Markdown/Markdown.tsx +++ b/src/renderer/src/pages/home/Markdown/Markdown.tsx @@ -46,7 +46,7 @@ interface Props { const Markdown: FC = ({ block, postProcess }) => { const { t } = useTranslation() - const { mathEngine } = useSettings() + const { mathEngine, mathEnableSingleDollar } = useSettings() const isTrulyDone = 'status' in block && block.status === 'success' const [displayedContent, setDisplayedContent] = useState(postProcess ? postProcess(block.content) : block.content) @@ -98,10 +98,10 @@ const Markdown: FC = ({ block, postProcess }) => { remarkDisableConstructs(['codeIndented']) ] if (mathEngine !== 'none') { - plugins.push(remarkMath) + plugins.push([remarkMath, { singleDollarTextMath: mathEnableSingleDollar }]) } return plugins - }, [mathEngine]) + }, [mathEngine, mathEnableSingleDollar]) const messageContent = useMemo(() => { if ('status' in block && block.status === 'paused' && isEmpty(block.content)) { diff --git a/src/renderer/src/pages/home/Markdown/__tests__/Markdown.test.tsx b/src/renderer/src/pages/home/Markdown/__tests__/Markdown.test.tsx index 6adb5f5736..b7e1ee8b52 100644 --- a/src/renderer/src/pages/home/Markdown/__tests__/Markdown.test.tsx +++ b/src/renderer/src/pages/home/Markdown/__tests__/Markdown.test.tsx @@ -144,7 +144,7 @@ describe('Markdown', () => { vi.clearAllMocks() // Default settings - mockUseSettings.mockReturnValue({ mathEngine: 'KaTeX' }) + mockUseSettings.mockReturnValue({ mathEngine: 'KaTeX', mathEnableSingleDollar: true }) mockUseTranslation.mockReturnValue({ t: (key: string) => (key === 'message.chat.completion.paused' ? 'Paused' : key) }) @@ -270,7 +270,7 @@ describe('Markdown', () => { describe('math engine configuration', () => { it('should configure KaTeX when mathEngine is KaTeX', () => { - mockUseSettings.mockReturnValue({ mathEngine: 'KaTeX' }) + mockUseSettings.mockReturnValue({ mathEngine: 'KaTeX', mathEnableSingleDollar: true }) render() @@ -279,7 +279,7 @@ describe('Markdown', () => { }) it('should configure MathJax when mathEngine is MathJax', () => { - mockUseSettings.mockReturnValue({ mathEngine: 'MathJax' }) + mockUseSettings.mockReturnValue({ mathEngine: 'MathJax', mathEnableSingleDollar: true }) render() @@ -288,7 +288,7 @@ describe('Markdown', () => { }) it('should not load math plugins when mathEngine is none', () => { - mockUseSettings.mockReturnValue({ mathEngine: 'none' }) + mockUseSettings.mockReturnValue({ mathEngine: 'none', mathEnableSingleDollar: true }) render() @@ -384,12 +384,12 @@ describe('Markdown', () => { }) it('should re-render when math engine changes', () => { - mockUseSettings.mockReturnValue({ mathEngine: 'KaTeX' }) + mockUseSettings.mockReturnValue({ mathEngine: 'KaTeX', mathEnableSingleDollar: true }) const { rerender } = render() expect(screen.getByTestId('markdown-content')).toBeInTheDocument() - mockUseSettings.mockReturnValue({ mathEngine: 'MathJax' }) + mockUseSettings.mockReturnValue({ mathEngine: 'MathJax', mathEnableSingleDollar: true }) rerender() // Should still render correctly with new math engine diff --git a/src/renderer/src/pages/home/Markdown/plugins/rehypeHeadingIds.ts b/src/renderer/src/pages/home/Markdown/plugins/rehypeHeadingIds.ts index e3e7e6db75..bb5862336f 100644 --- a/src/renderer/src/pages/home/Markdown/plugins/rehypeHeadingIds.ts +++ b/src/renderer/src/pages/home/Markdown/plugins/rehypeHeadingIds.ts @@ -1,4 +1,4 @@ -import type { Root, Node, Element, Text } from 'hast' +import type { Element, Node, Root, Text } from 'hast' import { visit } from 'unist-util-visit' /** diff --git a/src/renderer/src/pages/home/Tabs/SettingsTab.tsx b/src/renderer/src/pages/home/Tabs/SettingsTab.tsx index b290c34754..8c8309d04e 100644 --- a/src/renderer/src/pages/home/Tabs/SettingsTab.tsx +++ b/src/renderer/src/pages/home/Tabs/SettingsTab.tsx @@ -29,6 +29,7 @@ import { setEnableBackspaceDeleteModel, setEnableQuickPanelTriggers, setFontSize, + setMathEnableSingleDollar, setMathEngine, setMessageFont, setMessageNavigation, @@ -97,6 +98,7 @@ const SettingsTab: FC = (props) => { codeImageTools, codeExecution, mathEngine, + mathEnableSingleDollar, autoTranslateWithSpace, pasteLongTextThreshold, multiModelMessageStyle, @@ -382,19 +384,6 @@ const SettingsTab: FC = (props) => { /> - - {t('settings.messages.math_engine.label')} - dispatch(setMathEngine(value as MathEngine))} - options={[ - { value: 'KaTeX', label: 'KaTeX' }, - { value: 'MathJax', label: 'MathJax' }, - { value: 'none', label: t('settings.messages.math_engine.none') } - ]} - /> - - {t('settings.font_size.title')} @@ -418,6 +407,37 @@ const SettingsTab: FC = (props) => { + + + + {t('settings.math.engine.label')} + dispatch(setMathEngine(value as MathEngine))} + options={[ + { value: 'KaTeX', label: 'KaTeX' }, + { value: 'MathJax', label: 'MathJax' }, + { value: 'none', label: t('settings.math.engine.none') } + ]} + /> + + + + + {t('settings.math.single_dollar.label')}{' '} + + + + + dispatch(setMathEnableSingleDollar(checked))} + /> + + + + diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index adcb9c1b9c..7cf350dafa 100644 --- a/src/renderer/src/store/index.ts +++ b/src/renderer/src/store/index.ts @@ -62,7 +62,7 @@ const persistedReducer = persistReducer( { key: 'cherry-studio', storage, - version: 130, + version: 131, blacklist: ['runtime', 'messages', 'messageBlocks', 'tabs'], migrate }, diff --git a/src/renderer/src/store/migrate.ts b/src/renderer/src/store/migrate.ts index c1a50a46d9..9c9133168a 100644 --- a/src/renderer/src/store/migrate.ts +++ b/src/renderer/src/store/migrate.ts @@ -2094,6 +2094,15 @@ const migrateConfig = { logger.error('migrate 130 error', error as Error) return state } + }, + '131': (state: RootState) => { + try { + state.settings.mathEnableSingleDollar = true + return state + } catch (error) { + logger.error('migrate 131 error', error as Error) + return state + } } } diff --git a/src/renderer/src/store/settings.ts b/src/renderer/src/store/settings.ts index f5861e8494..68b9b8d02c 100644 --- a/src/renderer/src/store/settings.ts +++ b/src/renderer/src/store/settings.ts @@ -105,6 +105,7 @@ export interface SettingsState { codeWrappable: boolean codeImageTools: boolean mathEngine: MathEngine + mathEnableSingleDollar: boolean messageStyle: 'plain' | 'bubble' foldDisplayMode: 'expanded' | 'compact' gridColumns: number @@ -287,6 +288,7 @@ export const initialState: SettingsState = { codeWrappable: false, codeImageTools: false, mathEngine: 'KaTeX', + mathEnableSingleDollar: true, messageStyle: 'plain', foldDisplayMode: 'expanded', gridColumns: 2, @@ -616,6 +618,9 @@ const settingsSlice = createSlice({ setMathEngine: (state, action: PayloadAction) => { state.mathEngine = action.payload }, + setMathEnableSingleDollar: (state, action: PayloadAction) => { + state.mathEnableSingleDollar = action.payload + }, setFoldDisplayMode: (state, action: PayloadAction<'expanded' | 'compact'>) => { state.foldDisplayMode = action.payload }, @@ -898,6 +903,7 @@ export const { setCodeWrappable, setCodeImageTools, setMathEngine, + setMathEnableSingleDollar, setFoldDisplayMode, setGridColumns, setGridPopoverTrigger, From f4ef2ec9344c3f647a4dd5a223ebd3b36f3d829f Mon Sep 17 00:00:00 2001 From: Phantom <59059173+EurFelux@users.noreply.github.com> Date: Wed, 13 Aug 2025 16:30:24 +0800 Subject: [PATCH 05/22] fix: remove gpt-5-chat from OpenAI reasoning models (#9136) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix: 从OpenAI推理模型判断中移除gpt-5-chat --- src/renderer/src/config/models.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/src/config/models.ts b/src/renderer/src/config/models.ts index d84bb90d26..dff5b37831 100644 --- a/src/renderer/src/config/models.ts +++ b/src/renderer/src/config/models.ts @@ -2469,7 +2469,7 @@ export function isVisionModel(model: Model): boolean { export function isOpenAIReasoningModel(model: Model): boolean { const modelId = getLowerBaseModelName(model.id, '/') - return isSupportedReasoningEffortOpenAIModel(model) || modelId.includes('o1') || modelId.includes('gpt-5-chat') + return isSupportedReasoningEffortOpenAIModel(model) || modelId.includes('o1') } export function isOpenAILLMModel(model: Model): boolean { From a172a1052a8273f0f9abf3bade37156d712ad414 Mon Sep 17 00:00:00 2001 From: one Date: Wed, 13 Aug 2025 16:58:50 +0800 Subject: [PATCH 06/22] refactor: use hook useTemporaryValue in Table, CitationList, TranslatePage (#9134) --- src/renderer/src/pages/home/Markdown/Table.tsx | 8 ++++---- src/renderer/src/pages/home/Messages/CitationsList.tsx | 6 +++--- src/renderer/src/pages/translate/TranslatePage.tsx | 7 ++++--- 3 files changed, 11 insertions(+), 10 deletions(-) diff --git a/src/renderer/src/pages/home/Markdown/Table.tsx b/src/renderer/src/pages/home/Markdown/Table.tsx index 6b2ad1d365..4ee414834a 100644 --- a/src/renderer/src/pages/home/Markdown/Table.tsx +++ b/src/renderer/src/pages/home/Markdown/Table.tsx @@ -1,9 +1,10 @@ import { CopyIcon } from '@renderer/components/Icons' +import { useTemporaryValue } from '@renderer/hooks/useTemporaryValue' import store from '@renderer/store' import { messageBlocksSelectors } from '@renderer/store/messageBlock' import { Tooltip } from 'antd' import { Check } from 'lucide-react' -import React, { memo, useCallback, useState } from 'react' +import React, { memo, useCallback } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -18,7 +19,7 @@ interface Props { */ const Table: React.FC = ({ children, node, blockId }) => { const { t } = useTranslation() - const [copied, setCopied] = useState(false) + const [copied, setCopied] = useTemporaryValue(false, 2000) const handleCopyTable = useCallback(() => { const tableMarkdown = extractTableMarkdown(blockId ?? '', node?.position) @@ -28,12 +29,11 @@ const Table: React.FC = ({ children, node, blockId }) => { .writeText(tableMarkdown) .then(() => { setCopied(true) - setTimeout(() => setCopied(false), 2000) }) .catch((error) => { window.message?.error({ content: `${t('message.copy.failed')}: ${error}`, key: 'copy-table-error' }) }) - }, [node, blockId, t]) + }, [blockId, node?.position, setCopied, t]) return ( diff --git a/src/renderer/src/pages/home/Messages/CitationsList.tsx b/src/renderer/src/pages/home/Messages/CitationsList.tsx index 56a8ce584c..279ab14647 100644 --- a/src/renderer/src/pages/home/Messages/CitationsList.tsx +++ b/src/renderer/src/pages/home/Messages/CitationsList.tsx @@ -1,13 +1,14 @@ import ContextMenu from '@renderer/components/ContextMenu' import Favicon from '@renderer/components/Icons/FallbackFavicon' import Scrollbar from '@renderer/components/Scrollbar' +import { useTemporaryValue } from '@renderer/hooks/useTemporaryValue' import { Citation } from '@renderer/types' import { fetchWebContent } from '@renderer/utils/fetch' import { cleanMarkdownContent } from '@renderer/utils/formats' import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query' import { Button, message, Popover, Skeleton } from 'antd' import { Check, Copy, FileSearch } from 'lucide-react' -import React, { useState } from 'react' +import React from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -116,7 +117,7 @@ const handleLinkClick = (url: string, event: React.MouseEvent) => { } const CopyButton: React.FC<{ content: string }> = ({ content }) => { - const [copied, setCopied] = useState(false) + const [copied, setCopied] = useTemporaryValue(false, 2000) const { t } = useTranslation() const handleCopy = () => { @@ -126,7 +127,6 @@ const CopyButton: React.FC<{ content: string }> = ({ content }) => { .then(() => { setCopied(true) window.message.success(t('common.copied')) - setTimeout(() => setCopied(false), 2000) }) .catch(() => { message.error(t('message.copy.failed')) diff --git a/src/renderer/src/pages/translate/TranslatePage.tsx b/src/renderer/src/pages/translate/TranslatePage.tsx index 7ba579ebfb..f4aab2c5b2 100644 --- a/src/renderer/src/pages/translate/TranslatePage.tsx +++ b/src/renderer/src/pages/translate/TranslatePage.tsx @@ -1,6 +1,7 @@ -import { CheckOutlined, SendOutlined, SwapOutlined } from '@ant-design/icons' +import { SendOutlined, SwapOutlined } from '@ant-design/icons' import { loggerService } from '@logger' import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar' +import { CopyIcon } from '@renderer/components/Icons' import LanguageSelect from '@renderer/components/LanguageSelect' import ModelSelectButton from '@renderer/components/ModelSelectButton' import { isEmbeddingModel, isRerankModel, isTextToImageModel } from '@renderer/config/models' @@ -25,7 +26,7 @@ import { import { Button, Flex, Popover, Tooltip, Typography } from 'antd' import TextArea, { TextAreaRef } from 'antd/es/input/TextArea' import { isEmpty, throttle } from 'lodash' -import { CopyIcon, FolderClock, Settings2 } from 'lucide-react' +import { Check, FolderClock, Settings2 } from 'lucide-react' import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -473,7 +474,7 @@ const TranslatePage: FC = () => { className="copy-button" onClick={onCopy} disabled={!translatedContent} - icon={copied ? : } + icon={copied ? : } /> {!translatedContent ? ( From a4c61bcd666584f8f7bc2877ad23a385fb971c30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=91=A8=E5=AD=90=E5=81=A5?= <1384621+jiange1236@users.noreply.github.com> Date: Thu, 14 Aug 2025 10:00:45 +0800 Subject: [PATCH 07/22] fix: @cherry/memory i18n key wrong (#9164) --- src/renderer/src/i18n/label.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/src/i18n/label.ts b/src/renderer/src/i18n/label.ts index bdc90368e5..8be3bbb3e3 100644 --- a/src/renderer/src/i18n/label.ts +++ b/src/renderer/src/i18n/label.ts @@ -293,7 +293,7 @@ export const getFileFieldLabel = (key: string): string => { const builtInMcpDescriptionKeyMap = { '@cherry/mcp-auto-install': 'settings.mcp.builtinServersDescriptions.mcp_auto_install', - '@cherry/memory': 'settings.mcp.builtinServersDescriptions.mcp_auto_install', + '@cherry/memory': 'settings.mcp.builtinServersDescriptions.memory', '@cherry/sequentialthinking': 'settings.mcp.builtinServersDescriptions.sequentialthinking', '@cherry/brave-search': 'settings.mcp.builtinServersDescriptions.brave_search', '@cherry/fetch': 'settings.mcp.builtinServersDescriptions.fetch', From 1bf380a921f6a05d27ec8b46c2f063608056c0a5 Mon Sep 17 00:00:00 2001 From: Jason Young <44939412+farion1231@users.noreply.github.com> Date: Thu, 14 Aug 2025 16:35:14 +0800 Subject: [PATCH 08/22] fix: auto-close panel when no commands match (#7824) (#8784) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(QuickPanel): auto-close panel when no commands match (#7824) Fixes the issue where QuickPanel remains visible when user types invalid slash commands. Now the panel intelligently closes after 300ms when no matching commands are found. - Add smart delayed closing mechanism for unmatched searches - Optimize memory management with proper timer cleanup - Preserve existing trigger behavior for / and @ symbols * feat(inputbar): intelligent @ symbol handling on model selection panel close - Add smart @ character deletion when user selects models and closes panel with ESC/Backspace - Preserve @ character when user closes panel without selecting any models - Implement action tracking using useRef to detect user model interactions - Support both ESC key and Backspace key for consistent behavior - Use React setState instead of DOM manipulation for proper state management Resolves user experience issue where @ symbol always remained after closing model selection panel * perf(QuickPanel): optimize timer management and fix React anti-patterns - Move side effects from useMemo to useEffect for proper React lifecycle - Add automatic timer cleanup on component unmount and dependency changes - Remove unnecessary timer creation/destruction on each search input - Improve memory management and prevent potential memory leaks - Maintain existing smart auto-close functionality with better performance Fixes React anti-pattern where side effects were executed in useMemo, which should be a pure function. This improves performance especially when users type quickly in the search input. * refactor(QuickPanel): remove redundant timer cleanup useEffect Remove duplicate timer cleanup logic as the existing useEffect at line 141-164 already handles component unmount cleanup properly. * refactor(QuickPanel): optimize useEffect dependencies and timer cleanup logic - Replace overly broad `ctx` dependency with precise `[ctx.isVisible, searchText, list.length, ctx.close]` - Move timer cleanup before visibility check to ensure proper cleanup on panel hide - Add early return when panel is invisible to prevent unnecessary timer creation - Improve performance by avoiding redundant effect executions - Fix edge case where timers might not be cleared when panel becomes invisible Addresses review feedback about dependency array optimization while maintaining existing auto-close functionality and improving memory management. * feat(QuickPanel): implement smart re-opening with dependency optimization Features: - Implement smart re-opening during deletion with real-time matching - Only reopen panel when actual matches exist to avoid unnecessary interactions - Add intelligent @ symbol handling on model selection panel close - Optimize search text length limits (≤10 chars) for performance Fixes: - Fix useMemo dependency from overly broad [ctx, searchText] to precise [ctx.isVisible, ctx.symbol, ctx.list, searchText] - Resolve trailing whitespace formatting issues - Eliminate ESLint exhaustive-deps warnings while maintaining stability - Prevent unnecessary re-renders when unrelated ctx properties change Performance improvements ensure optimal QuickPanel responsiveness while maintaining existing auto-close functionality and improving user experience. * fix(ci): add eslint-disable comment for exhaustive-deps warning The useEffect dependency array [ctx.isVisible, searchText, list.length, ctx.close] is intentionally precise to avoid unnecessary re-renders when unrelated ctx properties change. Adding ctx object would cause performance degradation. * refactor(QuickPanel): remove smart re-opening logic during deletion - Remove 62 lines of complex deletion detection logic from Inputbar component - Eliminates performance overhead from frequent string matching during typing - Reduces code complexity and potential edge cases - Maintains simple and predictable QuickPanel behavior - Improves maintainability by removing unnecessary "smart" features The deletion-triggered smart reopening feature added unnecessary complexity without significant user benefit. Users can simply type / or @ again to reopen panels when needed. --- .../src/components/QuickPanel/view.tsx | 39 ++++++++++++++++++- .../src/pages/home/Inputbar/InputbarTools.tsx | 1 + .../home/Inputbar/MentionModelsButton.tsx | 38 ++++++++++++++++-- 3 files changed, 72 insertions(+), 6 deletions(-) diff --git a/src/renderer/src/components/QuickPanel/view.tsx b/src/renderer/src/components/QuickPanel/view.tsx index 36957aaf63..c955453903 100644 --- a/src/renderer/src/components/QuickPanel/view.tsx +++ b/src/renderer/src/components/QuickPanel/view.tsx @@ -66,6 +66,9 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { const prevSearchTextRef = useRef('') const prevSymbolRef = useRef('') + // 无匹配项自动关闭的定时器 + const noMatchTimeoutRef = useRef(null) + // 处理搜索,过滤列表 const list = useMemo(() => { if (!ctx.isVisible && !ctx.symbol) return [] @@ -128,12 +131,44 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { prevSymbolRef.current = ctx.symbol return newList - }, [ctx.isVisible, ctx.list, ctx.symbol, searchText]) + }, [ctx.isVisible, ctx.symbol, ctx.list, searchText]) const canForwardAndBackward = useMemo(() => { return list.some((item) => item.isMenu) || historyPanel.length > 0 }, [list, historyPanel]) + // 智能关闭逻辑:当有搜索文本但无匹配项时,延迟关闭面板 + useEffect(() => { + const _searchText = searchText.replace(/^[/@]/, '') + + // 清除之前的定时器(无论面板是否可见都要清理) + if (noMatchTimeoutRef.current) { + clearTimeout(noMatchTimeoutRef.current) + noMatchTimeoutRef.current = null + } + + // 面板不可见时不设置新定时器 + if (!ctx.isVisible) { + return + } + + // 只有在有搜索文本但无匹配项时才设置延迟关闭 + if (_searchText && _searchText.length > 0 && list.length === 0) { + noMatchTimeoutRef.current = setTimeout(() => { + ctx.close('no-matches') + }, 300) + } + + // 清理函数 + return () => { + if (noMatchTimeoutRef.current) { + clearTimeout(noMatchTimeoutRef.current) + noMatchTimeoutRef.current = null + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps -- ctx对象引用不稳定,使用具体属性避免过度重渲染 + }, [ctx.isVisible, searchText, list.length, ctx.close]) + const clearSearchText = useCallback( (includeSymbol = false) => { const textArea = document.querySelector('.inputbar textarea') as HTMLTextAreaElement @@ -275,7 +310,7 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { const newSearchText = textBeforeCursor.slice(lastSymbolIndex) setSearchText(newSearchText) } else { - handleClose('delete-symbol') + ctx.close('delete-symbol') } } diff --git a/src/renderer/src/pages/home/Inputbar/InputbarTools.tsx b/src/renderer/src/pages/home/Inputbar/InputbarTools.tsx index 9053a4e02e..4f6f264ace 100644 --- a/src/renderer/src/pages/home/Inputbar/InputbarTools.tsx +++ b/src/renderer/src/pages/home/Inputbar/InputbarTools.tsx @@ -397,6 +397,7 @@ const InputbarTools = ({ ToolbarButton={ToolbarButton} couldMentionNotVisionModel={couldMentionNotVisionModel} files={files} + setText={setText} /> ) }, diff --git a/src/renderer/src/pages/home/Inputbar/MentionModelsButton.tsx b/src/renderer/src/pages/home/Inputbar/MentionModelsButton.tsx index e76b85877b..2aff40c0de 100644 --- a/src/renderer/src/pages/home/Inputbar/MentionModelsButton.tsx +++ b/src/renderer/src/pages/home/Inputbar/MentionModelsButton.tsx @@ -27,6 +27,7 @@ interface Props { couldMentionNotVisionModel: boolean files: FileType[] ToolbarButton: any + setText: React.Dispatch> } const MentionModelsButton: FC = ({ @@ -35,13 +36,17 @@ const MentionModelsButton: FC = ({ onMentionModel, couldMentionNotVisionModel, files, - ToolbarButton + ToolbarButton, + setText }) => { const { providers } = useProviders() const { t } = useTranslation() const navigate = useNavigate() const quickPanel = useQuickPanel() + // 记录是否有模型被选择的动作发生 + const hasModelActionRef = useRef(false) + const pinnedModels = useLiveQuery( async () => { const setting = await db.settings.get('pinned:models') @@ -74,7 +79,10 @@ const MentionModelsButton: FC = ({ ), filterText: getFancyProviderName(p) + m.name, - action: () => onMentionModel(m), + action: () => { + hasModelActionRef.current = true // 标记有模型动作发生 + onMentionModel(m) + }, isSelected: mentionedModels.some((selected) => getModelUniqId(selected) === getModelUniqId(m)) })) ) @@ -107,7 +115,10 @@ const MentionModelsButton: FC = ({ ), filterText: getFancyProviderName(p) + m.name, - action: () => onMentionModel(m), + action: () => { + hasModelActionRef.current = true // 标记有模型动作发生 + onMentionModel(m) + }, isSelected: mentionedModels.some((selected) => getModelUniqId(selected) === getModelUniqId(m)) })) @@ -127,6 +138,9 @@ const MentionModelsButton: FC = ({ }, [pinnedModels, providers, t, couldMentionNotVisionModel, mentionedModels, onMentionModel, navigate]) const openQuickPanel = useCallback(() => { + // 重置模型动作标记 + hasModelActionRef.current = false + quickPanel.open({ title: t('agents.edit.model.select.title'), list: modelItems, @@ -134,9 +148,25 @@ const MentionModelsButton: FC = ({ multiple: true, afterAction({ item }) { item.isSelected = !item.isSelected + }, + onClose({ action }) { + // ESC或Backspace关闭时的特殊处理 + if (action === 'esc' || action === 'delete-symbol') { + // 如果有模型选择动作发生,删除@字符 + if (hasModelActionRef.current) { + // 使用React的setText来更新状态,而不是直接操作DOM + setText((currentText) => { + const lastAtIndex = currentText.lastIndexOf('@') + if (lastAtIndex !== -1) { + return currentText.slice(0, lastAtIndex) + currentText.slice(lastAtIndex + 1) + } + return currentText + }) + } + } } }) - }, [modelItems, quickPanel, t]) + }, [modelItems, quickPanel, t, setText]) const handleOpenQuickPanel = useCallback(() => { if (quickPanel.isVisible && quickPanel.symbol === '@') { From bf30bf28a9a099ab7485428ab9b08a869947f519 Mon Sep 17 00:00:00 2001 From: Teo Date: Thu, 14 Aug 2025 16:55:08 +0800 Subject: [PATCH 09/22] fix(TopicMessages): fix topic style (#9178) * fix(TopicMessages): fix topic style --- .../pages/history/components/TopicMessages.tsx | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/pages/history/components/TopicMessages.tsx b/src/renderer/src/pages/history/components/TopicMessages.tsx index 1b4be00029..8c98c458df 100644 --- a/src/renderer/src/pages/history/components/TopicMessages.tsx +++ b/src/renderer/src/pages/history/components/TopicMessages.tsx @@ -3,6 +3,7 @@ import { HStack } from '@renderer/components/Layout' import SearchPopup from '@renderer/components/Popups/SearchPopup' import { MessageEditingProvider } from '@renderer/context/MessageEditingContext' import useScrollPosition from '@renderer/hooks/useScrollPosition' +import { useSettings } from '@renderer/hooks/useSettings' import { getAssistantById } from '@renderer/services/AssistantService' import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' import { isGenerating, locateToMessage } from '@renderer/services/MessagesService' @@ -10,6 +11,7 @@ import NavigationService from '@renderer/services/NavigationService' import { useAppDispatch } from '@renderer/store' import { loadTopicMessagesThunk } from '@renderer/store/thunk/messageThunk' import { Topic } from '@renderer/types' +import { classNames } from '@renderer/utils' import { Button, Divider, Empty } from 'antd' import { t } from 'i18next' import { Forward } from 'lucide-react' @@ -26,6 +28,7 @@ const TopicMessages: FC = ({ topic, ...props }) => { const navigate = NavigationService.navigate! const { handleScroll, containerRef } = useScrollPosition('TopicMessages') const dispatch = useAppDispatch() + const { messageStyle } = useSettings() useEffect(() => { topic && dispatch(loadTopicMessagesThunk(topic.id)) @@ -48,9 +51,9 @@ const TopicMessages: FC = ({ topic, ...props }) => { return ( - + {topic?.messages.map((message) => ( -
+
+ ))} {isEmpty && } {!isEmpty && ( @@ -91,4 +94,11 @@ const ContainerWrapper = styled.div` flex-direction: column; ` +const MessageWrapper = styled.div` + position: relative; + &.bubble.user { + padding-top: 26px; + } +` + export default TopicMessages From 37dccd93e97662fadf59464a233cfa8b7dc6644e Mon Sep 17 00:00:00 2001 From: SuYao Date: Thu, 14 Aug 2025 20:06:57 +0800 Subject: [PATCH 10/22] fix: modelname (#9183) --- src/renderer/src/config/models.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/src/config/models.ts b/src/renderer/src/config/models.ts index dff5b37831..b5164dfe5b 100644 --- a/src/renderer/src/config/models.ts +++ b/src/renderer/src/config/models.ts @@ -2721,7 +2721,7 @@ export function isSupportedThinkingTokenDoubaoModel(model?: Model): boolean { const modelId = getLowerBaseModelName(model.id, '/') - return DOUBAO_THINKING_MODEL_REGEX.test(modelId) || DOUBAO_THINKING_MODEL_REGEX.test(modelId) + return DOUBAO_THINKING_MODEL_REGEX.test(modelId) || DOUBAO_THINKING_MODEL_REGEX.test(model.name) } export function isClaudeReasoningModel(model?: Model): boolean { From 31e59ab3954ae0b2582c448c6209ba549bd60841 Mon Sep 17 00:00:00 2001 From: fullex <106392080+0xfullex@users.noreply.github.com> Date: Thu, 14 Aug 2025 20:08:46 +0800 Subject: [PATCH 11/22] fix: update selection-hook to v1.0.9 (#9180) chore: update selection-hook to v1.0.9 --- package.json | 2 +- yarn.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 2198986545..a824984845 100644 --- a/package.json +++ b/package.json @@ -78,7 +78,7 @@ "node-stream-zip": "^1.15.0", "officeparser": "^4.2.0", "os-proxy-config": "^1.1.2", - "selection-hook": "^1.0.8", + "selection-hook": "^1.0.9", "turndown": "7.2.0" }, "devDependencies": { diff --git a/yarn.lock b/yarn.lock index c970bca393..276a523034 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8629,7 +8629,7 @@ __metadata: remove-markdown: "npm:^0.6.2" rollup-plugin-visualizer: "npm:^5.12.0" sass: "npm:^1.88.0" - selection-hook: "npm:^1.0.8" + selection-hook: "npm:^1.0.9" shiki: "npm:^3.9.1" strict-url-sanitise: "npm:^0.0.1" string-width: "npm:^7.2.0" @@ -20066,14 +20066,14 @@ __metadata: languageName: node linkType: hard -"selection-hook@npm:^1.0.8": - version: 1.0.8 - resolution: "selection-hook@npm:1.0.8" +"selection-hook@npm:^1.0.9": + version: 1.0.9 + resolution: "selection-hook@npm:1.0.9" dependencies: node-addon-api: "npm:^8.4.0" node-gyp: "npm:latest" node-gyp-build: "npm:^4.8.4" - checksum: 10c0/ed7e230ddf10fcd1974b166c5e73170900260664e40454e4e1fcdf0ba21d2a08cf95824c085fa07069aa99b663e0ee3f2aed74c3fbdba0f4e99abe6956bd51dc + checksum: 10c0/5f3114b528d9e1545a5dc4b99927a0ab441570063bb348b52784d757c8f250f0d6a875175d371adf5dc2bfc82bf6bb86f99d3ee66fefe0749040c0b50f3217c3 languageName: node linkType: hard From bef0180e4c034cac9cd8a7d6210d0b2b24ac9016 Mon Sep 17 00:00:00 2001 From: Phantom <59059173+EurFelux@users.noreply.github.com> Date: Thu, 14 Aug 2025 23:19:17 +0800 Subject: [PATCH 12/22] feat: web search icons (#9147) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(类型): 添加WebSearchProviderIds常量并更新WebSearchProvider类型 * refactor(web-search): 重构网络搜索提供商配置和logo获取逻辑 将webSearchProviders.ts中的提供商logo获取函数移动到使用组件中 并优化提供商配置的类型定义 * feat(WebSearchButton): 添加不同搜索引擎的图标支持 为WebSearchButton组件添加多个搜索引擎的图标支持,包括Baidu、Google、Bing等 * feat(types): 添加预处理和网页搜索提供者的类型校验函数 添加 PreprocessProviderId 和 WebSearchProviderId 的类型校验函数 isPreprocessProviderId 和 isWebSearchProviderId,用于验证字符串是否为有效的提供者 ID * refactor(types): 重命名ApiProviderUnion并添加更新函数类型 添加用于更新不同类型API提供者的函数类型,提高类型安全性 * refactor(websearch): 将搜索提供商配置提取到单独文件 将websearch store中的搜索提供商配置提取到单独的配置文件,提高代码可维护性 * refactor(PreprocessSettings): 移除未使用的 system 选项禁用逻辑 由于 system 字段实际未使用,移除相关代码以简化逻辑 * refactor(api-key-popup): 移除providerKind参数,改用providerId判断类型 * refactor(preprocessProviders): 使用类型定义优化预处理提供者配置 将 providerId 参数类型从 string 改为 PreprocessProviderId 为 PREPROCESS_PROVIDER_CONFIG 添加类型定义 * refactor(hooks): 使用PreprocessProviderId类型替换字符串类型参数 * refactor(hooks): 使用 WebSearchProviderId 类型替换字符串类型参数 将 useWebSearchProvider 钩子的 id 参数类型从 string 改为 WebSearchProviderId,提高类型安全性 * refactor(knowledge): 将providerId类型改为PreprocessProviderId * refactor(PreprocessSettings): 移除未使用的options相关代码 清理PreprocessSettings组件中已被注释掉的options状态和相关逻辑,简化代码结构 * refactor(WebSearchProviderSetting): 将providerId类型从string改为WebSearchProviderId * refactor(websearch): 移除WebSearchProvider类型中不必要的id字段约束 * style(WebSearchButton): 调整图标大小和样式以保持视觉一致性 * fix(ApiKeyListPopup): 修正LLM提供者判断逻辑 使用'models'属性检查替代原有逻辑,更准确地判断是否为LLM provider * fix(ApiKeyListPopup): 修复预处理provider判断逻辑 处理mistral同时提供预处理和llm服务的情况,避免误判 --- src/renderer/src/components/Icons/SVGIcon.tsx | 126 ++++++++++++++++++ .../components/Popups/ApiKeyListPopup/hook.ts | 48 ++++--- .../Popups/ApiKeyListPopup/list.tsx | 64 +++------ .../Popups/ApiKeyListPopup/popup.tsx | 26 ++-- .../Popups/ApiKeyListPopup/types.ts | 10 +- .../src/config/preprocessProviders.ts | 7 +- src/renderer/src/config/webSearchProviders.ts | 66 ++++++--- src/renderer/src/hooks/usePreprocess.ts | 4 +- .../src/hooks/useWebSearchProviders.ts | 8 +- .../pages/home/Inputbar/WebSearchButton.tsx | 41 ++++-- .../pages/knowledge/components/QuotaTag.tsx | 4 +- .../PreprocessSettings/PreprocessSettings.tsx | 32 ++--- .../settings/PreprocessSettings/index.tsx | 6 +- .../ProviderSettings/ProviderSetting.tsx | 1 - .../WebSearchProviderSetting.tsx | 25 +++- src/renderer/src/store/websearch.ts | 46 +------ src/renderer/src/types/index.ts | 32 ++++- 17 files changed, 359 insertions(+), 187 deletions(-) diff --git a/src/renderer/src/components/Icons/SVGIcon.tsx b/src/renderer/src/components/Icons/SVGIcon.tsx index b9a3eff899..88598bb02e 100644 --- a/src/renderer/src/components/Icons/SVGIcon.tsx +++ b/src/renderer/src/components/Icons/SVGIcon.tsx @@ -112,3 +112,129 @@ export function MdiLightbulbOn(props: SVGProps) { ) } + +export function BingLogo(props: SVGProps) { + return ( + + + + ) +} + +export function SearXNGLogo(props: SVGProps) { + return ( + + + + + + + + ) +} + +export function TavilyLogo(props: SVGProps) { + return ( + + + + + + + + + ) +} + +export function ExaLogo(props: SVGProps) { + return ( + + Exa + + + ) +} + +export function BochaLogo(props: SVGProps) { + return ( + + + + + + + ) +} diff --git a/src/renderer/src/components/Popups/ApiKeyListPopup/hook.ts b/src/renderer/src/components/Popups/ApiKeyListPopup/hook.ts index 541dd2f156..c4c9459ed0 100644 --- a/src/renderer/src/components/Popups/ApiKeyListPopup/hook.ts +++ b/src/renderer/src/components/Popups/ApiKeyListPopup/hook.ts @@ -3,7 +3,14 @@ import { isEmbeddingModel, isRerankModel } from '@renderer/config/models' import SelectProviderModelPopup from '@renderer/pages/settings/ProviderSettings/SelectProviderModelPopup' import { checkApi } from '@renderer/services/ApiService' import WebSearchService from '@renderer/services/WebSearchService' -import { Model, PreprocessProvider, Provider, WebSearchProvider } from '@renderer/types' +import { + isPreprocessProviderId, + isWebSearchProviderId, + Model, + PreprocessProvider, + Provider, + WebSearchProvider +} from '@renderer/types' import { ApiKeyConnectivity, ApiKeyWithStatus, HealthStatus } from '@renderer/types/healthCheck' import { formatApiKeys, splitApiKeyString } from '@renderer/utils/api' import { formatErrorMessage } from '@renderer/utils/error' @@ -12,12 +19,11 @@ import { isEmpty } from 'lodash' import { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import { ApiKeyValidity, ApiProviderKind, ApiProviderUnion } from './types' +import { ApiKeyValidity, ApiProvider, UpdateApiProviderFunc } from './types' interface UseApiKeysProps { - provider: ApiProviderUnion - updateProvider: (provider: Partial) => void - providerKind: ApiProviderKind + provider: ApiProvider + updateProvider: UpdateApiProviderFunc } const logger = loggerService.withContext('ApiKeyListPopup') @@ -25,7 +31,7 @@ const logger = loggerService.withContext('ApiKeyListPopup') /** * API Keys 管理 hook */ -export function useApiKeys({ provider, updateProvider, providerKind }: UseApiKeysProps) { +export function useApiKeys({ provider, updateProvider }: UseApiKeysProps) { const { t } = useTranslation() // 连通性检查的 UI 状态管理 @@ -199,11 +205,13 @@ export function useApiKeys({ provider, updateProvider, providerKind }: UseApiKey try { const startTime = Date.now() - if (isLlmProvider(provider, providerKind) && model) { + if (isLlmProvider(provider) && model) { await checkApi({ ...provider, apiKey: keyToCheck }, model) - } else { + } else if (isWebSearchProvider(provider)) { const result = await WebSearchService.checkSearch({ ...provider, apiKey: keyToCheck }) if (!result.valid) throw new Error(result.error) + } else { + // 不处理预处理供应商 } const latency = Date.now() - startTime @@ -228,7 +236,7 @@ export function useApiKeys({ provider, updateProvider, providerKind }: UseApiKey logger.error('failed to validate the connectivity of the api key', error) } }, - [keys, connectivityStates, updateConnectivityState, provider, providerKind] + [keys, connectivityStates, updateConnectivityState, provider] ) // 检查单个 key 的连通性 @@ -240,23 +248,23 @@ export function useApiKeys({ provider, updateProvider, providerKind }: UseApiKey const currentState = connectivityStates.get(keyToCheck) if (currentState?.checking) return - const model = isLlmProvider(provider, providerKind) ? await getModelForCheck(provider, t) : undefined + const model = isLlmProvider(provider) ? await getModelForCheck(provider, t) : undefined if (model === null) return await runConnectivityCheck(index, model) }, - [provider, keys, connectivityStates, providerKind, t, runConnectivityCheck] + [provider, keys, connectivityStates, t, runConnectivityCheck] ) // 检查所有 keys 的连通性 const checkAllKeysConnectivity = useCallback(async () => { if (!provider || keys.length === 0) return - const model = isLlmProvider(provider, providerKind) ? await getModelForCheck(provider, t) : undefined + const model = isLlmProvider(provider) ? await getModelForCheck(provider, t) : undefined if (model === null) return await Promise.allSettled(keys.map((_, index) => runConnectivityCheck(index, model))) - }, [provider, keys, providerKind, t, runConnectivityCheck]) + }, [provider, keys, t, runConnectivityCheck]) // 计算是否有 key 正在检查 const isChecking = useMemo(() => { @@ -275,16 +283,18 @@ export function useApiKeys({ provider, updateProvider, providerKind }: UseApiKey } } -export function isLlmProvider(obj: any, kind: ApiProviderKind): obj is Provider { - return kind === 'llm' && 'type' in obj && 'models' in obj +export function isLlmProvider(provider: ApiProvider): provider is Provider { + return 'models' in provider } -export function isWebSearchProvider(obj: any, kind: ApiProviderKind): obj is WebSearchProvider { - return kind === 'websearch' && ('url' in obj || 'engines' in obj) +export function isWebSearchProvider(provider: ApiProvider): provider is WebSearchProvider { + return isWebSearchProviderId(provider.id) } -export function isPreprocessProvider(obj: any, kind: ApiProviderKind): obj is PreprocessProvider { - return kind === 'doc-preprocess' && ('quota' in obj || 'options' in obj) +export function isPreprocessProvider(provider: ApiProvider): provider is PreprocessProvider { + // NOTE: mistral 同时提供预处理和llm服务,所以其llm provier可能被误判为预处理provider + // 后面需要使用更严格的判断方式 + return isPreprocessProviderId(provider.id) && !isLlmProvider(provider) } // 获取模型用于检查 diff --git a/src/renderer/src/components/Popups/ApiKeyListPopup/list.tsx b/src/renderer/src/components/Popups/ApiKeyListPopup/list.tsx index 03e9796886..86076b4ca8 100644 --- a/src/renderer/src/components/Popups/ApiKeyListPopup/list.tsx +++ b/src/renderer/src/components/Popups/ApiKeyListPopup/list.tsx @@ -6,6 +6,7 @@ import { useProvider } from '@renderer/hooks/useProvider' import { useWebSearchProvider } from '@renderer/hooks/useWebSearchProviders' import { SettingHelpText } from '@renderer/pages/settings' import { isProviderSupportAuth } from '@renderer/services/ProviderService' +import { PreprocessProviderId, WebSearchProviderId } from '@renderer/types' import { ApiKeyWithStatus, HealthStatus } from '@renderer/types/healthCheck' import { Button, Card, Flex, List, Popconfirm, Space, Tooltip, Typography } from 'antd' import { Plus } from 'lucide-react' @@ -15,19 +16,18 @@ import styled from 'styled-components' import { isLlmProvider, useApiKeys } from './hook' import ApiKeyItem from './item' -import { ApiProviderKind, ApiProviderUnion } from './types' +import { ApiProvider, UpdateApiProviderFunc } from './types' interface ApiKeyListProps { - provider: ApiProviderUnion - updateProvider: (provider: Partial) => void - providerKind: ApiProviderKind + provider: ApiProvider + updateProvider: UpdateApiProviderFunc showHealthCheck?: boolean } /** * Api key 列表,管理 CRUD 操作、连接检查 */ -export const ApiKeyList: FC = ({ provider, updateProvider, providerKind, showHealthCheck = true }) => { +export const ApiKeyList: FC = ({ provider, updateProvider, showHealthCheck = true }) => { const { t } = useTranslation() // 临时新项状态 @@ -42,7 +42,7 @@ export const ApiKeyList: FC = ({ provider, updateProvider, prov checkKeyConnectivity, checkAllKeysConnectivity, isChecking - } = useApiKeys({ provider, updateProvider, providerKind: providerKind }) + } = useApiKeys({ provider, updateProvider }) // 创建一个临时新项 const handleAddNew = () => { @@ -73,7 +73,7 @@ export const ApiKeyList: FC = ({ provider, updateProvider, prov const shouldAutoFocus = () => { if (provider.apiKey) return false - return isLlmProvider(provider, providerKind) && provider.enabled && !isProviderSupportAuth(provider) + return isLlmProvider(provider) && provider.enabled && !isProviderSupportAuth(provider) } // 合并真实 keys 和临时新项 @@ -179,55 +179,33 @@ export const ApiKeyList: FC = ({ provider, updateProvider, prov interface SpecificApiKeyListProps { providerId: string - providerKind: ApiProviderKind showHealthCheck?: boolean } -export const LlmApiKeyList: FC = ({ providerId, providerKind, showHealthCheck = true }) => { +type WebSearchApiKeyList = SpecificApiKeyListProps & { + providerId: WebSearchProviderId +} + +type DocPreprocessApiKeyListProps = SpecificApiKeyListProps & { + providerId: PreprocessProviderId +} + +export const LlmApiKeyList: FC = ({ providerId, showHealthCheck = true }) => { const { provider, updateProvider } = useProvider(providerId) - return ( - - ) + return } -export const WebSearchApiKeyList: FC = ({ - providerId, - providerKind, - showHealthCheck = true -}) => { +export const WebSearchApiKeyList: FC = ({ providerId, showHealthCheck = true }) => { const { provider, updateProvider } = useWebSearchProvider(providerId) - return ( - - ) + return } -export const DocPreprocessApiKeyList: FC = ({ - providerId, - providerKind, - showHealthCheck = true -}) => { +export const DocPreprocessApiKeyList: FC = ({ providerId, showHealthCheck = true }) => { const { provider, updateProvider } = usePreprocessProvider(providerId) - return ( - - ) + return } const ListContainer = styled.div` diff --git a/src/renderer/src/components/Popups/ApiKeyListPopup/popup.tsx b/src/renderer/src/components/Popups/ApiKeyListPopup/popup.tsx index 096e00ca58..b4ca91186b 100644 --- a/src/renderer/src/components/Popups/ApiKeyListPopup/popup.tsx +++ b/src/renderer/src/components/Popups/ApiKeyListPopup/popup.tsx @@ -1,14 +1,13 @@ import { TopView } from '@renderer/components/TopView' +import { isPreprocessProviderId, isWebSearchProviderId } from '@renderer/types' import { Modal } from 'antd' import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { DocPreprocessApiKeyList, LlmApiKeyList, WebSearchApiKeyList } from './list' -import { ApiProviderKind } from './types' interface ShowParams { providerId: string - providerKind: ApiProviderKind title?: string showHealthCheck?: boolean } @@ -20,7 +19,7 @@ interface Props extends ShowParams { /** * API Key 列表弹窗容器组件 */ -const PopupContainer: React.FC = ({ providerId, providerKind, title, resolve, showHealthCheck = true }) => { +const PopupContainer: React.FC = ({ providerId, title, resolve, showHealthCheck = true }) => { const [open, setOpen] = useState(true) const { t } = useTranslation() @@ -33,17 +32,14 @@ const PopupContainer: React.FC = ({ providerId, providerKind, title, reso } const ListComponent = useMemo(() => { - switch (providerKind) { - case 'llm': - return LlmApiKeyList - case 'websearch': - return WebSearchApiKeyList - case 'doc-preprocess': - return DocPreprocessApiKeyList - default: - return null + if (isWebSearchProviderId(providerId)) { + return } - }, [providerKind]) + if (isPreprocessProviderId(providerId)) { + return + } + return + }, [providerId, showHealthCheck]) return ( = ({ providerId, providerKind, title, reso centered width={600} footer={null}> - {ListComponent && ( - - )} + {ListComponent} ) } diff --git a/src/renderer/src/components/Popups/ApiKeyListPopup/types.ts b/src/renderer/src/components/Popups/ApiKeyListPopup/types.ts index 4663e70715..bc230c577d 100644 --- a/src/renderer/src/components/Popups/ApiKeyListPopup/types.ts +++ b/src/renderer/src/components/Popups/ApiKeyListPopup/types.ts @@ -8,6 +8,12 @@ export type ApiKeyValidity = { error?: string } -export type ApiProviderUnion = Provider | WebSearchProvider | PreprocessProvider +export type ApiProvider = Provider | WebSearchProvider | PreprocessProvider -export type ApiProviderKind = 'llm' | 'websearch' | 'doc-preprocess' +export type UpdateProviderFunc = (p: Partial) => void + +export type UpdateWebSearchProviderFunc = (p: Partial) => void + +export type UpdatePreprocessProviderFunc = (p: Partial) => void + +export type UpdateApiProviderFunc = UpdateProviderFunc | UpdateWebSearchProviderFunc | UpdatePreprocessProviderFunc diff --git a/src/renderer/src/config/preprocessProviders.ts b/src/renderer/src/config/preprocessProviders.ts index 587e6ea7f9..88215b328d 100644 --- a/src/renderer/src/config/preprocessProviders.ts +++ b/src/renderer/src/config/preprocessProviders.ts @@ -1,8 +1,9 @@ import Doc2xLogo from '@renderer/assets/images/ocr/doc2x.png' import MinerULogo from '@renderer/assets/images/ocr/mineru.jpg' import MistralLogo from '@renderer/assets/images/providers/mistral.png' +import { PreprocessProviderId } from '@renderer/types' -export function getPreprocessProviderLogo(providerId: string) { +export function getPreprocessProviderLogo(providerId: PreprocessProviderId) { switch (providerId) { case 'doc2x': return Doc2xLogo @@ -15,7 +16,9 @@ export function getPreprocessProviderLogo(providerId: string) { } } -export const PREPROCESS_PROVIDER_CONFIG = { +type PreprocessProviderConfig = { websites: { official: string; apiKey: string } } + +export const PREPROCESS_PROVIDER_CONFIG: Record = { doc2x: { websites: { official: 'https://doc2x.noedgeai.com', diff --git a/src/renderer/src/config/webSearchProviders.ts b/src/renderer/src/config/webSearchProviders.ts index d33e9cba35..62c0536f4d 100644 --- a/src/renderer/src/config/webSearchProviders.ts +++ b/src/renderer/src/config/webSearchProviders.ts @@ -1,24 +1,13 @@ -import BochaLogo from '@renderer/assets/images/search/bocha.webp' -import ExaLogo from '@renderer/assets/images/search/exa.png' -import SearxngLogo from '@renderer/assets/images/search/searxng.svg' -import TavilyLogo from '@renderer/assets/images/search/tavily.png' +import { WebSearchProvider, WebSearchProviderId } from '@renderer/types' -export function getWebSearchProviderLogo(providerId: string) { - switch (providerId) { - case 'tavily': - return TavilyLogo - case 'searxng': - return SearxngLogo - case 'exa': - return ExaLogo - case 'bocha': - return BochaLogo - default: - return undefined +type WebSearchProviderConfig = { + websites: { + official: string + apiKey?: string } } -export const WEB_SEARCH_PROVIDER_CONFIG = { +export const WEB_SEARCH_PROVIDER_CONFIG: Record = { tavily: { websites: { official: 'https://tavily.com', @@ -58,3 +47,46 @@ export const WEB_SEARCH_PROVIDER_CONFIG = { } } } + +export const WEB_SEARCH_PROVIDERS: WebSearchProvider[] = [ + { + id: 'tavily', + name: 'Tavily', + apiHost: 'https://api.tavily.com', + apiKey: '' + }, + { + id: 'searxng', + name: 'Searxng', + apiHost: '', + basicAuthUsername: '', + basicAuthPassword: '' + }, + { + id: 'exa', + name: 'Exa', + apiHost: 'https://api.exa.ai', + apiKey: '' + }, + { + id: 'bocha', + name: 'Bocha', + apiHost: 'https://api.bochaai.com', + apiKey: '' + }, + { + id: 'local-google', + name: 'Google', + url: 'https://www.google.com/search?q=%s' + }, + { + id: 'local-bing', + name: 'Bing', + url: 'https://cn.bing.com/search?q=%s&ensearch=1' + }, + { + id: 'local-baidu', + name: 'Baidu', + url: 'https://www.baidu.com/s?wd=%s' + } +] as const diff --git a/src/renderer/src/hooks/usePreprocess.ts b/src/renderer/src/hooks/usePreprocess.ts index 41463227ad..5172e2c68a 100644 --- a/src/renderer/src/hooks/usePreprocess.ts +++ b/src/renderer/src/hooks/usePreprocess.ts @@ -4,10 +4,10 @@ import { updatePreprocessProvider as _updatePreprocessProvider, updatePreprocessProviders as _updatePreprocessProviders } from '@renderer/store/preprocess' -import { PreprocessProvider } from '@renderer/types' +import { PreprocessProvider, PreprocessProviderId } from '@renderer/types' import { useDispatch, useSelector } from 'react-redux' -export const usePreprocessProvider = (id: string) => { +export const usePreprocessProvider = (id: PreprocessProviderId) => { const dispatch = useDispatch() const preprocessProviders = useSelector((state: RootState) => state.preprocess.providers) const provider = preprocessProviders.find((provider) => provider.id === id) diff --git a/src/renderer/src/hooks/useWebSearchProviders.ts b/src/renderer/src/hooks/useWebSearchProviders.ts index 32f9238abf..34ee07403e 100644 --- a/src/renderer/src/hooks/useWebSearchProviders.ts +++ b/src/renderer/src/hooks/useWebSearchProviders.ts @@ -11,7 +11,7 @@ import { updateWebSearchProvider, updateWebSearchProviders } from '@renderer/store/websearch' -import { WebSearchProvider } from '@renderer/types' +import { WebSearchProvider, WebSearchProviderId } from '@renderer/types' export const useDefaultWebSearchProvider = () => { const defaultProvider = useAppSelector((state) => state.websearch.defaultProvider) @@ -49,7 +49,7 @@ export const useWebSearchProviders = () => { } } -export const useWebSearchProvider = (id: string) => { +export const useWebSearchProvider = (id: WebSearchProviderId) => { const providers = useAppSelector((state) => state.websearch.providers) const provider = providers.find((provider) => provider.id === id) const dispatch = useAppDispatch() @@ -60,7 +60,9 @@ export const useWebSearchProvider = (id: string) => { return { provider, - updateProvider: (updates: Partial) => dispatch(updateWebSearchProvider({ id, ...updates })) + updateProvider: (updates: Partial) => { + dispatch(updateWebSearchProvider({ id, ...updates })) + } } } diff --git a/src/renderer/src/pages/home/Inputbar/WebSearchButton.tsx b/src/renderer/src/pages/home/Inputbar/WebSearchButton.tsx index 7a6a6e6334..70b25084d5 100644 --- a/src/renderer/src/pages/home/Inputbar/WebSearchButton.tsx +++ b/src/renderer/src/pages/home/Inputbar/WebSearchButton.tsx @@ -1,9 +1,11 @@ +import { BaiduOutlined, GoogleOutlined } from '@ant-design/icons' +import { BingLogo, BochaLogo, ExaLogo, SearXNGLogo, TavilyLogo } from '@renderer/components/Icons' import { QuickPanelListItem, useQuickPanel } from '@renderer/components/QuickPanel' import { isWebSearchModel } from '@renderer/config/models' import { useAssistant } from '@renderer/hooks/useAssistant' import { useWebSearchProviders } from '@renderer/hooks/useWebSearchProviders' import WebSearchService from '@renderer/services/WebSearchService' -import { Assistant, WebSearchProvider } from '@renderer/types' +import { Assistant, WebSearchProvider, WebSearchProviderId } from '@renderer/types' import { hasObjectKey } from '@renderer/utils' import { Tooltip } from 'antd' import { Globe } from 'lucide-react' @@ -28,6 +30,33 @@ const WebSearchButton: FC = ({ ref, assistant, ToolbarButton }) => { const enableWebSearch = assistant?.webSearchProviderId || assistant.enableWebSearch + const WebSearchIcon = useCallback( + ({ pid, size = 18 }: { pid?: WebSearchProviderId; size?: number }) => { + const iconColor = enableWebSearch ? 'var(--color-primary)' : 'var(--color-icon)' + + switch (pid) { + case 'bocha': + return + case 'exa': + // size微调,视觉上和其他图标平衡一些 + return + case 'tavily': + return + case 'searxng': + return + case 'local-baidu': + return + case 'local-bing': + return + case 'local-google': + return + default: + return + } + }, + [enableWebSearch] + ) + const updateSelectedWebSearchProvider = useCallback( async (providerId?: WebSearchProvider['id']) => { // TODO: updateAssistant有性能问题,会导致关闭快捷面板卡顿 @@ -58,7 +87,7 @@ const WebSearchButton: FC = ({ ref, assistant, ToolbarButton }) => { ? t('settings.tool.websearch.apikey') : t('settings.tool.websearch.free') : t('chat.input.web_search.enable_content'), - icon: , + icon: , isSelected: p.id === assistant?.webSearchProviderId, disabled: !WebSearchService.isWebSearchEnabled(p.id), action: () => updateSelectedWebSearchProvider(p.id) @@ -80,6 +109,7 @@ const WebSearchButton: FC = ({ ref, assistant, ToolbarButton }) => { return items }, [ + WebSearchIcon, assistant.enableWebSearch, assistant.model, assistant?.webSearchProviderId, @@ -135,12 +165,7 @@ const WebSearchButton: FC = ({ ref, assistant, ToolbarButton }) => { mouseLeaveDelay={0} arrow> - + ) diff --git a/src/renderer/src/pages/knowledge/components/QuotaTag.tsx b/src/renderer/src/pages/knowledge/components/QuotaTag.tsx index 8eb8868e98..b6f29ce5d1 100644 --- a/src/renderer/src/pages/knowledge/components/QuotaTag.tsx +++ b/src/renderer/src/pages/knowledge/components/QuotaTag.tsx @@ -2,14 +2,14 @@ import { loggerService } from '@logger' import { usePreprocessProvider } from '@renderer/hooks/usePreprocess' import { getStoreSetting } from '@renderer/hooks/useSettings' import { getKnowledgeBaseParams } from '@renderer/services/KnowledgeService' -import { KnowledgeBase } from '@renderer/types' +import { KnowledgeBase, PreprocessProviderId } from '@renderer/types' import { Tag } from 'antd' import { FC, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' const logger = loggerService.withContext('QuotaTag') -const QuotaTag: FC<{ base: KnowledgeBase; providerId: string; quota?: number }> = ({ +const QuotaTag: FC<{ base: KnowledgeBase; providerId: PreprocessProviderId; quota?: number }> = ({ base, providerId, quota: _quota diff --git a/src/renderer/src/pages/settings/PreprocessSettings/PreprocessSettings.tsx b/src/renderer/src/pages/settings/PreprocessSettings/PreprocessSettings.tsx index c1592ac73f..d19eec4d6d 100644 --- a/src/renderer/src/pages/settings/PreprocessSettings/PreprocessSettings.tsx +++ b/src/renderer/src/pages/settings/PreprocessSettings/PreprocessSettings.tsx @@ -4,23 +4,14 @@ import { getPreprocessProviderLogo, PREPROCESS_PROVIDER_CONFIG } from '@renderer import { usePreprocessProvider } from '@renderer/hooks/usePreprocess' import { PreprocessProvider } from '@renderer/types' import { formatApiKeys, hasObjectKey } from '@renderer/utils' -import { Avatar, Button, Divider, Flex, Input, InputNumber, Segmented, Tooltip } from 'antd' +import { Avatar, Button, Divider, Flex, Input, Tooltip } from 'antd' import Link from 'antd/es/typography/Link' import { List } from 'lucide-react' import { FC, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' -import { - SettingDivider, - SettingHelpLink, - SettingHelpText, - SettingHelpTextRow, - SettingRow, - SettingRowTitle, - SettingSubtitle, - SettingTitle -} from '..' +import { SettingHelpLink, SettingHelpText, SettingHelpTextRow, SettingSubtitle, SettingTitle } from '..' interface Props { provider: PreprocessProvider @@ -31,7 +22,7 @@ const PreprocessProviderSettings: FC = ({ provider: _provider }) => { const { t } = useTranslation() const [apiKey, setApiKey] = useState(preprocessProvider.apiKey || '') const [apiHost, setApiHost] = useState(preprocessProvider.apiHost || '') - const [options, setOptions] = useState(preprocessProvider.options || {}) + // const [options, setOptions] = useState(preprocessProvider.options || {}) const preprocessProviderConfig = PREPROCESS_PROVIDER_CONFIG[preprocessProvider.id] const apiKeyWebsite = preprocessProviderConfig?.websites?.apiKey @@ -40,7 +31,7 @@ const PreprocessProviderSettings: FC = ({ provider: _provider }) => { useEffect(() => { setApiKey(preprocessProvider.apiKey ?? '') setApiHost(preprocessProvider.apiHost ?? '') - setOptions(preprocessProvider.options ?? {}) + // setOptions(preprocessProvider.options ?? {}) }, [preprocessProvider.apiKey, preprocessProvider.apiHost, preprocessProvider.options]) const onUpdateApiKey = () => { @@ -52,7 +43,6 @@ const PreprocessProviderSettings: FC = ({ provider: _provider }) => { const openApiKeyList = async () => { await ApiKeyListPopup.show({ providerId: preprocessProvider.id, - providerKind: 'doc-preprocess', title: `${preprocessProvider.name} ${t('settings.provider.api.key.list.title')}`, showHealthCheck: false // FIXME: 目前还没有检查功能 }) @@ -70,11 +60,11 @@ const PreprocessProviderSettings: FC = ({ provider: _provider }) => { } } - const onUpdateOptions = (key: string, value: any) => { - const newOptions = { ...options, [key]: value } - setOptions(newOptions) - updateProvider({ options: newOptions }) - } + // const onUpdateOptions = (key: string, value: any) => { + // const newOptions = { ...options, [key]: value } + // setOptions(newOptions) + // updateProvider({ options: newOptions }) + // } return ( <> @@ -145,7 +135,7 @@ const PreprocessProviderSettings: FC = ({ provider: _provider }) => { )} {/* 这部分看起来暂时用不上了 */} - {hasObjectKey(preprocessProvider, 'options') && preprocessProvider.id === 'system' && ( + {/* {hasObjectKey(preprocessProvider, 'options') && preprocessProvider.id === 'system' && ( <> @@ -177,7 +167,7 @@ const PreprocessProviderSettings: FC = ({ provider: _provider }) => { /> - )} + )} */} ) } diff --git a/src/renderer/src/pages/settings/PreprocessSettings/index.tsx b/src/renderer/src/pages/settings/PreprocessSettings/index.tsx index daa76c042c..f80c0cd679 100644 --- a/src/renderer/src/pages/settings/PreprocessSettings/index.tsx +++ b/src/renderer/src/pages/settings/PreprocessSettings/index.tsx @@ -1,4 +1,3 @@ -import { isMac } from '@renderer/config/constant' import { useTheme } from '@renderer/context/ThemeProvider' import { useDefaultPreprocessProvider, usePreprocessProviders } from '@renderer/hooks/usePreprocess' import { PreprocessProvider } from '@renderer/types' @@ -40,8 +39,9 @@ const PreprocessSettings: FC = () => { placeholder={t('settings.tool.preprocess.provider_placeholder')} options={preprocessProviders.map((p) => ({ value: p.id, - label: p.name, - disabled: !isMac && p.id === 'system' // 在非 Mac 系统下禁用 system 选项 + label: p.name + // 由于system字段实际未使用,先注释掉 + // disabled: !isMac && p.id === 'system' // 在非 Mac 系统下禁用 system 选项 }))} /> diff --git a/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx b/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx index b82acaa511..191cce4778 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx @@ -128,7 +128,6 @@ const ProviderSetting: FC = ({ providerId }) => { const openApiKeyList = async () => { await ApiKeyListPopup.show({ providerId: provider.id, - providerKind: 'llm', title: `${fancyProviderName} ${t('settings.provider.api.key.list.title')}` }) } diff --git a/src/renderer/src/pages/settings/WebSearchSettings/WebSearchProviderSetting.tsx b/src/renderer/src/pages/settings/WebSearchSettings/WebSearchProviderSetting.tsx index 68bb689bd6..5c6faa7b78 100644 --- a/src/renderer/src/pages/settings/WebSearchSettings/WebSearchProviderSetting.tsx +++ b/src/renderer/src/pages/settings/WebSearchSettings/WebSearchProviderSetting.tsx @@ -1,9 +1,14 @@ import { CheckOutlined, ExportOutlined, LoadingOutlined } from '@ant-design/icons' import { loggerService } from '@logger' +import BochaLogo from '@renderer/assets/images/search/bocha.webp' +import ExaLogo from '@renderer/assets/images/search/exa.png' +import SearxngLogo from '@renderer/assets/images/search/searxng.svg' +import TavilyLogo from '@renderer/assets/images/search/tavily.png' import ApiKeyListPopup from '@renderer/components/Popups/ApiKeyListPopup/popup' -import { getWebSearchProviderLogo, WEB_SEARCH_PROVIDER_CONFIG } from '@renderer/config/webSearchProviders' +import { WEB_SEARCH_PROVIDER_CONFIG } from '@renderer/config/webSearchProviders' import { useWebSearchProvider } from '@renderer/hooks/useWebSearchProviders' import WebSearchService from '@renderer/services/WebSearchService' +import { WebSearchProviderId } from '@renderer/types' import { formatApiKeys, hasObjectKey } from '@renderer/utils' import { Button, Divider, Flex, Form, Input, Space, Tooltip } from 'antd' import Link from 'antd/es/typography/Link' @@ -16,7 +21,7 @@ import { SettingDivider, SettingHelpLink, SettingHelpText, SettingHelpTextRow, S const logger = loggerService.withContext('WebSearchProviderSetting') interface Props { - providerId: string + providerId: WebSearchProviderId } const WebSearchProviderSetting: FC = ({ providerId }) => { @@ -74,7 +79,6 @@ const WebSearchProviderSetting: FC = ({ providerId }) => { const openApiKeyList = async () => { await ApiKeyListPopup.show({ providerId: provider.id, - providerKind: 'websearch', title: `${provider.name} ${t('settings.provider.api.key.list.title')}` }) } @@ -132,6 +136,21 @@ const WebSearchProviderSetting: FC = ({ providerId }) => { setBasicAuthPassword(provider.basicAuthPassword ?? '') }, [provider.apiKey, provider.apiHost, provider.basicAuthUsername, provider.basicAuthPassword]) + const getWebSearchProviderLogo = (providerId: WebSearchProviderId) => { + switch (providerId) { + case 'tavily': + return TavilyLogo + case 'searxng': + return SearxngLogo + case 'exa': + return ExaLogo + case 'bocha': + return BochaLogo + default: + return undefined + } + } + return ( <> diff --git a/src/renderer/src/store/websearch.ts b/src/renderer/src/store/websearch.ts index d7f0799814..1e3fe2a25b 100644 --- a/src/renderer/src/store/websearch.ts +++ b/src/renderer/src/store/websearch.ts @@ -1,4 +1,5 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit' +import { WEB_SEARCH_PROVIDERS } from '@renderer/config/webSearchProviders' import type { Model, WebSearchProvider } from '@renderer/types' export interface SubscribeSource { key: number @@ -42,48 +43,7 @@ export interface WebSearchState { export const initialState: WebSearchState = { defaultProvider: 'local-bing', - providers: [ - { - id: 'tavily', - name: 'Tavily', - apiHost: 'https://api.tavily.com', - apiKey: '' - }, - { - id: 'searxng', - name: 'Searxng', - apiHost: '', - basicAuthUsername: '', - basicAuthPassword: '' - }, - { - id: 'exa', - name: 'Exa', - apiHost: 'https://api.exa.ai', - apiKey: '' - }, - { - id: 'bocha', - name: 'Bocha', - apiHost: 'https://api.bochaai.com', - apiKey: '' - }, - { - id: 'local-google', - name: 'Google', - url: 'https://www.google.com/search?q=%s' - }, - { - id: 'local-bing', - name: 'Bing', - url: 'https://cn.bing.com/search?q=%s&ensearch=1' - }, - { - id: 'local-baidu', - name: 'Baidu', - url: 'https://www.baidu.com/s?wd=%s' - } - ], + providers: WEB_SEARCH_PROVIDERS, searchWithTime: true, maxResults: 5, excludeDomains: [], @@ -111,7 +71,7 @@ const websearchSlice = createSlice({ updateWebSearchProviders: (state, action: PayloadAction) => { state.providers = action.payload }, - updateWebSearchProvider: (state, action: PayloadAction & { id: string }>) => { + updateWebSearchProvider: (state, action: PayloadAction>) => { const index = state.providers.findIndex((provider) => provider.id === action.payload.id) if (index !== -1) { Object.assign(state.providers[index], action.payload) diff --git a/src/renderer/src/types/index.ts b/src/renderer/src/types/index.ts index 87e5843449..ca4a1fe4fd 100644 --- a/src/renderer/src/types/index.ts +++ b/src/renderer/src/types/index.ts @@ -609,8 +609,20 @@ export type KnowledgeBaseParams = { } } +export const PreprocessProviderIds = { + doc2x: 'doc2x', + mistral: 'mistral', + mineru: 'mineru' +} as const + +export type PreprocessProviderId = keyof typeof PreprocessProviderIds + +export const isPreprocessProviderId = (id: string): id is PreprocessProviderId => { + return Object.hasOwn(PreprocessProviderIds, id) +} + export interface PreprocessProvider { - id: string + id: PreprocessProviderId name: string apiKey?: string apiHost?: string @@ -675,8 +687,24 @@ export type ExternalToolResult = { memories?: MemoryItem[] } +export const WebSearchProviderIds = { + tavily: 'tavily', + searxng: 'searxng', + exa: 'exa', + bocha: 'bocha', + 'local-google': 'local-google', + 'local-bing': 'local-bing', + 'local-baidu': 'local-baidu' +} as const + +export type WebSearchProviderId = keyof typeof WebSearchProviderIds + +export const isWebSearchProviderId = (id: string): id is WebSearchProviderId => { + return Object.hasOwn(WebSearchProviderIds, id) +} + export type WebSearchProvider = { - id: string + id: WebSearchProviderId name: string apiKey?: string apiHost?: string From 5d34e49c57b93547e87611d3f1c4637a84dff2c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?George=C2=B7Dong?= <98630204+GeorgeDong32@users.noreply.github.com> Date: Fri, 15 Aug 2025 01:55:19 +0800 Subject: [PATCH 13/22] =?UTF-8?q?refactor(bakcup):=20=E5=8D=95=E4=BE=8B?= =?UTF-8?q?=E5=8C=96S3/WebDAV=20(#9181)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(backup): 单例化S3/WebDAV并动态更新配置 * feat(backup): reuse storage instances by comparing core configs * feat(backup): cache only connection fields for storages --- src/main/services/BackupManager.ts | 125 ++++++++++++++++++++++++++--- 1 file changed, 114 insertions(+), 11 deletions(-) diff --git a/src/main/services/BackupManager.ts b/src/main/services/BackupManager.ts index 56d3a97379..c6d3ee1841 100644 --- a/src/main/services/BackupManager.ts +++ b/src/main/services/BackupManager.ts @@ -21,6 +21,27 @@ class BackupManager { private tempDir = path.join(app.getPath('temp'), 'cherry-studio', 'backup', 'temp') private backupDir = path.join(app.getPath('temp'), 'cherry-studio', 'backup') + // 缓存实例,避免重复创建 + private s3Storage: S3Storage | null = null + private webdavInstance: WebDav | null = null + + // 缓存核心连接配置,用于检测连接配置是否变更 + private cachedS3ConnectionConfig: { + endpoint: string + region: string + bucket: string + accessKeyId: string + secretAccessKey: string + root?: string + } | null = null + + private cachedWebdavConnectionConfig: { + webdavHost: string + webdavUser?: string + webdavPass?: string + webdavPath?: string + } | null = null + constructor() { this.checkConnection = this.checkConnection.bind(this) this.backup = this.backup.bind(this) @@ -87,6 +108,88 @@ class BackupManager { } } + /** + * 比较两个配置对象是否相等,只比较影响客户端连接的核心字段,忽略 fileName 等易变字段 + */ + private isS3ConfigEqual(cachedConfig: typeof this.cachedS3ConnectionConfig, config: S3Config): boolean { + if (!cachedConfig) return false + + return ( + cachedConfig.endpoint === config.endpoint && + cachedConfig.region === config.region && + cachedConfig.bucket === config.bucket && + cachedConfig.accessKeyId === config.accessKeyId && + cachedConfig.secretAccessKey === config.secretAccessKey && + cachedConfig.root === config.root + ) + } + + /** + * 深度比较两个 WebDAV 配置对象是否相等,只比较影响客户端连接的核心字段,忽略 fileName 等易变字段 + */ + private isWebDavConfigEqual(cachedConfig: typeof this.cachedWebdavConnectionConfig, config: WebDavConfig): boolean { + if (!cachedConfig) return false + + return ( + cachedConfig.webdavHost === config.webdavHost && + cachedConfig.webdavUser === config.webdavUser && + cachedConfig.webdavPass === config.webdavPass && + cachedConfig.webdavPath === config.webdavPath + ) + } + + /** + * 获取 S3Storage 实例,如果连接配置未变且实例已存在则复用,否则创建新实例 + * 注意:只有连接相关的配置变更才会重新创建实例,其他配置变更不影响实例复用 + */ + private getS3Storage(config: S3Config): S3Storage { + // 检查核心连接配置是否变更 + const configChanged = !this.isS3ConfigEqual(this.cachedS3ConnectionConfig, config) + + if (configChanged || !this.s3Storage) { + this.s3Storage = new S3Storage(config) + // 只缓存连接相关的配置字段 + this.cachedS3ConnectionConfig = { + endpoint: config.endpoint, + region: config.region, + bucket: config.bucket, + accessKeyId: config.accessKeyId, + secretAccessKey: config.secretAccessKey, + root: config.root + } + logger.debug('[BackupManager] Created new S3Storage instance') + } else { + logger.debug('[BackupManager] Reusing existing S3Storage instance') + } + + return this.s3Storage + } + + /** + * 获取 WebDav 实例,如果连接配置未变且实例已存在则复用,否则创建新实例 + * 注意:只有连接相关的配置变更才会重新创建实例,其他配置变更不影响实例复用 + */ + private getWebDavInstance(config: WebDavConfig): WebDav { + // 检查核心连接配置是否变更 + const configChanged = !this.isWebDavConfigEqual(this.cachedWebdavConnectionConfig, config) + + if (configChanged || !this.webdavInstance) { + this.webdavInstance = new WebDav(config) + // 只缓存连接相关的配置字段 + this.cachedWebdavConnectionConfig = { + webdavHost: config.webdavHost, + webdavUser: config.webdavUser, + webdavPass: config.webdavPass, + webdavPath: config.webdavPath + } + logger.debug('[BackupManager] Created new WebDav instance') + } else { + logger.debug('[BackupManager] Reusing existing WebDav instance') + } + + return this.webdavInstance + } + async backup( _: Electron.IpcMainInvokeEvent, fileName: string, @@ -322,7 +425,7 @@ class BackupManager { async backupToWebdav(_: Electron.IpcMainInvokeEvent, data: string, webdavConfig: WebDavConfig) { const filename = webdavConfig.fileName || 'cherry-studio.backup.zip' const backupedFilePath = await this.backup(_, filename, data, undefined, webdavConfig.skipBackupFile) - const webdavClient = new WebDav(webdavConfig) + const webdavClient = this.getWebDavInstance(webdavConfig) try { let result if (webdavConfig.disableStream) { @@ -349,7 +452,7 @@ class BackupManager { async restoreFromWebdav(_: Electron.IpcMainInvokeEvent, webdavConfig: WebDavConfig) { const filename = webdavConfig.fileName || 'cherry-studio.backup.zip' - const webdavClient = new WebDav(webdavConfig) + const webdavClient = this.getWebDavInstance(webdavConfig) try { const retrievedFile = await webdavClient.getFileContents(filename) const backupedFilePath = path.join(this.backupDir, filename) @@ -377,7 +480,7 @@ class BackupManager { listWebdavFiles = async (_: Electron.IpcMainInvokeEvent, config: WebDavConfig) => { try { - const client = new WebDav(config) + const client = this.getWebDavInstance(config) const response = await client.getDirectoryContents() const files = Array.isArray(response) ? response : response.data @@ -467,7 +570,7 @@ class BackupManager { } async checkConnection(_: Electron.IpcMainInvokeEvent, webdavConfig: WebDavConfig) { - const webdavClient = new WebDav(webdavConfig) + const webdavClient = this.getWebDavInstance(webdavConfig) return await webdavClient.checkConnection() } @@ -477,13 +580,13 @@ class BackupManager { path: string, options?: CreateDirectoryOptions ) { - const webdavClient = new WebDav(webdavConfig) + const webdavClient = this.getWebDavInstance(webdavConfig) return await webdavClient.createDirectory(path, options) } async deleteWebdavFile(_: Electron.IpcMainInvokeEvent, fileName: string, webdavConfig: WebDavConfig) { try { - const webdavClient = new WebDav(webdavConfig) + const webdavClient = this.getWebDavInstance(webdavConfig) return await webdavClient.deleteFile(fileName) } catch (error: any) { logger.error('Failed to delete WebDAV file:', error) @@ -525,7 +628,7 @@ class BackupManager { logger.debug(`Starting S3 backup to ${filename}`) const backupedFilePath = await this.backup(_, filename, data, undefined, s3Config.skipBackupFile) - const s3Client = new S3Storage(s3Config) + const s3Client = this.getS3Storage(s3Config) try { const fileBuffer = await fs.promises.readFile(backupedFilePath) const result = await s3Client.putFileContents(filename, fileBuffer) @@ -603,7 +706,7 @@ class BackupManager { logger.debug(`Starting restore from S3: ${filename}`) - const s3Client = new S3Storage(s3Config) + const s3Client = this.getS3Storage(s3Config) try { const retrievedFile = await s3Client.getFileContents(filename) const backupedFilePath = path.join(this.backupDir, filename) @@ -628,7 +731,7 @@ class BackupManager { listS3Files = async (_: Electron.IpcMainInvokeEvent, s3Config: S3Config) => { try { - const s3Client = new S3Storage(s3Config) + const s3Client = this.getS3Storage(s3Config) const objects = await s3Client.listFiles() const files = objects @@ -652,7 +755,7 @@ class BackupManager { async deleteS3File(_: Electron.IpcMainInvokeEvent, fileName: string, s3Config: S3Config) { try { - const s3Client = new S3Storage(s3Config) + const s3Client = this.getS3Storage(s3Config) return await s3Client.deleteFile(fileName) } catch (error: any) { logger.error('Failed to delete S3 file:', error) @@ -661,7 +764,7 @@ class BackupManager { } async checkS3Connection(_: Electron.IpcMainInvokeEvent, s3Config: S3Config) { - const s3Client = new S3Storage(s3Config) + const s3Client = this.getS3Storage(s3Config) return await s3Client.checkConnection() } } From d1e19aad516359c3b8343a3b1f45a15509403be4 Mon Sep 17 00:00:00 2001 From: SuYao Date: Fri, 15 Aug 2025 09:28:43 +0800 Subject: [PATCH 14/22] fix: unexpected loading (#9193) fix --- src/renderer/src/services/ApiService.ts | 8 +++----- .../messageStreaming/callbacks/citationCallbacks.ts | 1 + 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/renderer/src/services/ApiService.ts b/src/renderer/src/services/ApiService.ts index da83fdb660..d447fa7322 100644 --- a/src/renderer/src/services/ApiService.ts +++ b/src/renderer/src/services/ApiService.ts @@ -104,9 +104,9 @@ async function fetchExternalTool( const showListTools = enabledMCPs && enabledMCPs.length > 0 // 是否使用工具 - const hasAnyTool = shouldWebSearch || shouldKnowledgeSearch || shouldSearchMemory || showListTools + const hasAnyTool = shouldWebSearch || shouldKnowledgeSearch || showListTools - // 在工具链开始时发送进度通知 + // 在工具链开始时发送进度通知(不包括记忆搜索) if (hasAnyTool) { onChunkReceived({ type: ChunkType.EXTERNEL_TOOL_IN_PROGRESS }) } @@ -456,8 +456,6 @@ export async function fetchChatCompletion({ const { mcpTools } = await fetchExternalTool(lastUserMessage, assistant, onChunkReceived, lastAnswer) const model = assistant.model || getDefaultModel() - onChunkReceived({ type: ChunkType.LLM_RESPONSE_CREATED }) - const { maxTokens, contextCount } = getAssistantSettings(assistant) const filteredMessages2 = filterUsefulMessages(filteredMessages1) @@ -488,7 +486,7 @@ export async function fetchChatCompletion({ isGenerateImageModel(model) && (isSupportedDisableGenerationModel(model) ? assistant.enableGenerateImage : true) // --- Call AI Completions --- - + onChunkReceived({ type: ChunkType.LLM_RESPONSE_CREATED }) const completionsParams: CompletionsParams = { callType: 'chat', messages: _messages, diff --git a/src/renderer/src/services/messageStreaming/callbacks/citationCallbacks.ts b/src/renderer/src/services/messageStreaming/callbacks/citationCallbacks.ts index 56d0680839..9ba743b2cd 100644 --- a/src/renderer/src/services/messageStreaming/callbacks/citationCallbacks.ts +++ b/src/renderer/src/services/messageStreaming/callbacks/citationCallbacks.ts @@ -40,6 +40,7 @@ export const createCitationCallbacks = (deps: CitationCallbacksDependencies) => status: MessageBlockStatus.SUCCESS } blockManager.smartBlockUpdate(citationBlockId, changes, MessageBlockType.CITATION, true) + citationBlockId = null } else { logger.error('[onExternalToolComplete] citationBlockId is null. Cannot update.') } From f2b7b07e511c4ec9ea7567d9e841f7f74693ac35 Mon Sep 17 00:00:00 2001 From: beyondkmp Date: Fri, 15 Aug 2025 10:45:11 +0800 Subject: [PATCH 15/22] refactor(AppUpdater): streamline release version fetching and improve update logic (#9167) - Renamed method from _getPreReleaseVersionFromGithub to _getReleaseVersionFromGithub for clarity. - Enhanced logic to check for the latest release version using semver. - Removed unnecessary checks related to test plans when updates are not available. - Improved logging for better traceability of release version fetching. --- src/main/services/AppUpdater.ts | 71 +++++++++++++++++++-------------- 1 file changed, 41 insertions(+), 30 deletions(-) diff --git a/src/main/services/AppUpdater.ts b/src/main/services/AppUpdater.ts index e60dac31f0..ea3b1f3f1e 100644 --- a/src/main/services/AppUpdater.ts +++ b/src/main/services/AppUpdater.ts @@ -9,6 +9,7 @@ import { CancellationToken, UpdateInfo } from 'builder-util-runtime' import { app, BrowserWindow, dialog } from 'electron' import { AppUpdater as _AppUpdater, autoUpdater, Logger, NsisUpdater, UpdateCheckResult } from 'electron-updater' import path from 'path' +import semver from 'semver' import icon from '../../../build/icon.png?asset' import { configManager } from './ConfigManager' @@ -44,12 +45,6 @@ export default class AppUpdater { // 检测到不需要更新时 autoUpdater.on('update-not-available', () => { - if (configManager.getTestPlan() && this.autoUpdater.channel !== UpgradeChannel.LATEST) { - logger.info('test plan is enabled, but update is not available, do not send update not available event') - // will not send update not available event, because will check for updates with latest channel - return - } - windowService.getMainWindow()?.webContents.send(IpcChannel.UpdateNotAvailable) }) @@ -72,18 +67,24 @@ export default class AppUpdater { this.autoUpdater = autoUpdater } - private async _getPreReleaseVersionFromGithub(channel: UpgradeChannel) { + private async _getReleaseVersionFromGithub(channel: UpgradeChannel) { + const headers = { + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + 'Accept-Language': 'en-US,en;q=0.9' + } try { - logger.info(`get pre release version from github: ${channel}`) + logger.info(`get release version from github: ${channel}`) const responses = await fetch('https://api.github.com/repos/CherryHQ/cherry-studio/releases?per_page=8', { - headers: { - Accept: 'application/vnd.github+json', - 'X-GitHub-Api-Version': '2022-11-28', - 'Accept-Language': 'en-US,en;q=0.9' - } + headers }) const data = (await responses.json()) as GithubReleaseInfo[] + let mightHaveLatest = false const release: GithubReleaseInfo | undefined = data.find((item: GithubReleaseInfo) => { + if (!item.draft && !item.prerelease) { + mightHaveLatest = true + } + return item.prerelease && item.tag_name.includes(`-${channel}.`) }) @@ -91,8 +92,29 @@ export default class AppUpdater { return null } - logger.info(`prerelease url is ${release.tag_name}, set channel to ${channel}`) + // if the release version is the same as the current version, return null + if (release.tag_name === app.getVersion()) { + return null + } + if (mightHaveLatest) { + logger.info(`might have latest release, get latest release`) + const latestReleaseResponse = await fetch( + 'https://api.github.com/repos/CherryHQ/cherry-studio/releases/latest', + { + headers + } + ) + const latestRelease = (await latestReleaseResponse.json()) as GithubReleaseInfo + if (semver.gt(latestRelease.tag_name, release.tag_name)) { + logger.info( + `latest release version is ${latestRelease.tag_name}, prerelease version is ${release.tag_name}, return null` + ) + return null + } + } + + logger.info(`release url is ${release.tag_name}, set channel to ${channel}`) return `https://github.com/CherryHQ/cherry-studio/releases/download/${release.tag_name}` } catch (error) { logger.error('Failed to get latest not draft version from github:', error as Error) @@ -151,14 +173,14 @@ export default class AppUpdater { return } - const preReleaseUrl = await this._getPreReleaseVersionFromGithub(channel) - if (preReleaseUrl) { - logger.info(`prerelease url is ${preReleaseUrl}, set channel to ${channel}`) - this._setChannel(channel, preReleaseUrl) + const releaseUrl = await this._getReleaseVersionFromGithub(channel) + if (releaseUrl) { + logger.info(`release url is ${releaseUrl}, set channel to ${channel}`) + this._setChannel(channel, releaseUrl) return } - // if no prerelease url, use github latest to avoid error + // if no prerelease url, use github latest to get release this._setChannel(UpgradeChannel.LATEST, FeedUrl.GITHUB_LATEST) return } @@ -195,17 +217,6 @@ export default class AppUpdater { `update check result: ${this.updateCheckResult?.isUpdateAvailable}, channel: ${this.autoUpdater.channel}, currentVersion: ${this.autoUpdater.currentVersion}` ) - // if the update is not available, and the test plan is enabled, set the feed url to the github latest - if ( - !this.updateCheckResult?.isUpdateAvailable && - configManager.getTestPlan() && - this.autoUpdater.channel !== UpgradeChannel.LATEST - ) { - logger.info('test plan is enabled, but update is not available, set channel to latest') - this._setChannel(UpgradeChannel.LATEST, FeedUrl.GITHUB_LATEST) - this.updateCheckResult = await this.autoUpdater.checkForUpdates() - } - if (this.updateCheckResult?.isUpdateAvailable && !this.autoUpdater.autoDownload) { // 如果 autoDownload 为 false,则需要再调用下面的函数触发下 // do not use await, because it will block the return of this function From c2561726e08e4f502382a056dd8134dfd06b0e13 Mon Sep 17 00:00:00 2001 From: Phantom <59059173+EurFelux@users.noreply.github.com> Date: Fri, 15 Aug 2025 14:31:49 +0800 Subject: [PATCH 16/22] style(Inputbar): use primary color for buttons (#9174) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit style(Inputbar): 统一按钮激活状态颜色为主题色 将输入栏中多个按钮的激活状态颜色从链接色(--color-link)统一为主题色(--color-primary),保持UI一致性 --- src/renderer/src/pages/home/Inputbar/GenerateImageButton.tsx | 2 +- src/renderer/src/pages/home/Inputbar/KnowledgeBaseButton.tsx | 5 ++++- src/renderer/src/pages/home/Inputbar/MentionModelsButton.tsx | 2 +- src/renderer/src/pages/home/Inputbar/UrlContextbutton.tsx | 2 +- 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/pages/home/Inputbar/GenerateImageButton.tsx b/src/renderer/src/pages/home/Inputbar/GenerateImageButton.tsx index c7d930bf5d..eadcea8b82 100644 --- a/src/renderer/src/pages/home/Inputbar/GenerateImageButton.tsx +++ b/src/renderer/src/pages/home/Inputbar/GenerateImageButton.tsx @@ -24,7 +24,7 @@ const GenerateImageButton: FC = ({ model, ToolbarButton, assistant, onEna mouseLeaveDelay={0} arrow> - + ) diff --git a/src/renderer/src/pages/home/Inputbar/KnowledgeBaseButton.tsx b/src/renderer/src/pages/home/Inputbar/KnowledgeBaseButton.tsx index 8e4782c8a7..735796dfad 100644 --- a/src/renderer/src/pages/home/Inputbar/KnowledgeBaseButton.tsx +++ b/src/renderer/src/pages/home/Inputbar/KnowledgeBaseButton.tsx @@ -87,7 +87,10 @@ const KnowledgeBaseButton: FC = ({ ref, selectedBases, onSelect, disabled return ( - + 0 ? 'var(--color-primary)' : 'var(--color-icon)'} + /> ) diff --git a/src/renderer/src/pages/home/Inputbar/MentionModelsButton.tsx b/src/renderer/src/pages/home/Inputbar/MentionModelsButton.tsx index 2aff40c0de..822d52fef6 100644 --- a/src/renderer/src/pages/home/Inputbar/MentionModelsButton.tsx +++ b/src/renderer/src/pages/home/Inputbar/MentionModelsButton.tsx @@ -195,7 +195,7 @@ const MentionModelsButton: FC = ({ return ( - + 0 ? 'var(--color-primary)' : 'var(--color-icon)'} /> ) diff --git a/src/renderer/src/pages/home/Inputbar/UrlContextbutton.tsx b/src/renderer/src/pages/home/Inputbar/UrlContextbutton.tsx index 2f5a228bb8..11cfde3f72 100644 --- a/src/renderer/src/pages/home/Inputbar/UrlContextbutton.tsx +++ b/src/renderer/src/pages/home/Inputbar/UrlContextbutton.tsx @@ -33,7 +33,7 @@ const UrlContextButton: FC = ({ assistant, ToolbarButton }) => { From 748ac600fa7149639173492fbef1afff4dea8918 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E5=A4=A9=E5=AF=92?= Date: Fri, 15 Aug 2025 15:13:48 +0800 Subject: [PATCH 17/22] fix(aws-bedrock): support thinking mode (#9172) * fix(aws-bedrock): support thinking mode * fix(aws-bedrock): fix code review suggestions * fix(aws-bedrock): Add thinking processing for other models --- .../__tests__/ApiClientFactory.test.ts | 23 + .../aiCore/clients/aws/AwsBedrockAPIClient.ts | 432 ++++++++++++++++-- src/renderer/src/types/sdk.ts | 19 + 3 files changed, 426 insertions(+), 48 deletions(-) diff --git a/src/renderer/src/aiCore/clients/__tests__/ApiClientFactory.test.ts b/src/renderer/src/aiCore/clients/__tests__/ApiClientFactory.test.ts index 172798dc38..5ec3bf6404 100644 --- a/src/renderer/src/aiCore/clients/__tests__/ApiClientFactory.test.ts +++ b/src/renderer/src/aiCore/clients/__tests__/ApiClientFactory.test.ts @@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { AihubmixAPIClient } from '../AihubmixAPIClient' import { AnthropicAPIClient } from '../anthropic/AnthropicAPIClient' import { ApiClientFactory } from '../ApiClientFactory' +import { AwsBedrockAPIClient } from '../aws/AwsBedrockAPIClient' import { GeminiAPIClient } from '../gemini/GeminiAPIClient' import { VertexAPIClient } from '../gemini/VertexAPIClient' import { NewAPIClient } from '../NewAPIClient' @@ -54,6 +55,19 @@ vi.mock('../openai/OpenAIResponseAPIClient', () => ({ vi.mock('../ppio/PPIOAPIClient', () => ({ PPIOAPIClient: vi.fn().mockImplementation(() => ({})) })) +vi.mock('../aws/AwsBedrockAPIClient', () => ({ + AwsBedrockAPIClient: vi.fn().mockImplementation(() => ({})) +})) + +// Mock the models config to prevent circular dependency issues +vi.mock('@renderer/config/models', () => ({ + findTokenLimit: vi.fn(), + isReasoningModel: vi.fn(), + SYSTEM_MODELS: { + silicon: [], + defaultModel: [] + } +})) describe('ApiClientFactory', () => { beforeEach(() => { @@ -144,6 +158,15 @@ describe('ApiClientFactory', () => { expect(client).toBeDefined() }) + it('should create AwsBedrockAPIClient for aws-bedrock type', () => { + const provider = createTestProvider('aws-bedrock', 'aws-bedrock') + + const client = ApiClientFactory.create(provider) + + expect(AwsBedrockAPIClient).toHaveBeenCalledWith(provider) + expect(client).toBeDefined() + }) + // 测试默认情况 it('should create OpenAIAPIClient as default for unknown type', () => { const provider = createTestProvider('unknown', 'unknown-type') diff --git a/src/renderer/src/aiCore/clients/aws/AwsBedrockAPIClient.ts b/src/renderer/src/aiCore/clients/aws/AwsBedrockAPIClient.ts index 4e29a0cb5c..d9bd9af9c8 100644 --- a/src/renderer/src/aiCore/clients/aws/AwsBedrockAPIClient.ts +++ b/src/renderer/src/aiCore/clients/aws/AwsBedrockAPIClient.ts @@ -2,19 +2,23 @@ import { BedrockClient, ListFoundationModelsCommand, ListInferenceProfilesComman import { BedrockRuntimeClient, ConverseCommand, - ConverseStreamCommand, - InvokeModelCommand + InvokeModelCommand, + InvokeModelWithResponseStreamCommand } from '@aws-sdk/client-bedrock-runtime' import { loggerService } from '@logger' import { GenericChunk } from '@renderer/aiCore/middleware/schemas' import { DEFAULT_MAX_TOKENS } from '@renderer/config/constant' +import { findTokenLimit, isReasoningModel } from '@renderer/config/models' import { getAwsBedrockAccessKeyId, getAwsBedrockRegion, getAwsBedrockSecretAccessKey } from '@renderer/hooks/useAwsBedrock' +import { getAssistantSettings } from '@renderer/services/AssistantService' import { estimateTextTokens } from '@renderer/services/TokenService' import { + Assistant, + EFFORT_RATIO, GenerateImageParams, MCPCallToolResponse, MCPTool, @@ -23,7 +27,13 @@ import { Provider, ToolCallResponse } from '@renderer/types' -import { ChunkType, MCPToolCreatedChunk, TextDeltaChunk } from '@renderer/types/chunk' +import { + ChunkType, + MCPToolCreatedChunk, + TextDeltaChunk, + ThinkingDeltaChunk, + ThinkingStartChunk +} from '@renderer/types/chunk' import { Message } from '@renderer/types/newMessage' import { AwsBedrockSdkInstance, @@ -33,6 +43,7 @@ import { AwsBedrockSdkRawOutput, AwsBedrockSdkTool, AwsBedrockSdkToolCall, + AwsBedrockStreamChunk, SdkModel } from '@renderer/types/sdk' import { convertBase64ImageToAwsBedrockFormat } from '@renderer/utils/aws-bedrock-utils' @@ -103,46 +114,65 @@ export class AwsBedrockAPIClient extends BaseApiClient< override async createCompletions(payload: AwsBedrockSdkParams): Promise { const sdk = await this.getSdkInstance() - // 转换消息格式到AWS SDK原生格式 + // 转换消息格式(用于 InvokeModelWithResponseStreamCommand) const awsMessages = payload.messages.map((msg) => ({ role: msg.role, content: msg.content.map((content) => { if (content.text) { - return { text: content.text } + return { type: 'text', text: content.text } } if (content.image) { + // 处理图片数据,将 Uint8Array 或数字数组转换为 base64 字符串 + let base64Data = '' + if (content.image.source.bytes) { + if (typeof content.image.source.bytes === 'string') { + // 如果已经是字符串,直接使用 + base64Data = content.image.source.bytes + } else { + // 如果是数组或 Uint8Array,转换为 base64 + const uint8Array = new Uint8Array(Object.values(content.image.source.bytes)) + const binaryString = Array.from(uint8Array) + .map((byte) => String.fromCharCode(byte)) + .join('') + base64Data = btoa(binaryString) + } + } + return { - image: { - format: content.image.format, - source: content.image.source + type: 'image', + source: { + type: 'base64', + media_type: `image/${content.image.format}`, + data: base64Data } } } if (content.toolResult) { return { - toolResult: { - toolUseId: content.toolResult.toolUseId, - content: content.toolResult.content, - status: content.toolResult.status - } + type: 'tool_result', + tool_use_id: content.toolResult.toolUseId, + content: content.toolResult.content } } if (content.toolUse) { return { - toolUse: { - toolUseId: content.toolUse.toolUseId, - name: content.toolUse.name, - input: content.toolUse.input - } + type: 'tool_use', + id: content.toolUse.toolUseId, + name: content.toolUse.name, + input: content.toolUse.input } } - // 返回符合AWS SDK ContentBlock类型的对象 - return { text: 'Unknown content type' } + return { type: 'text', text: 'Unknown content type' } }) })) logger.info('Creating completions with model ID:', { modelId: payload.modelId }) + const excludeKeys = ['modelId', 'messages', 'system', 'maxTokens', 'temperature', 'topP', 'stream', 'tools'] + const additionalParams = Object.keys(payload) + .filter((key) => !excludeKeys.includes(key)) + .reduce((acc, key) => ({ ...acc, [key]: payload[key] }), {}) + const commonParams = { modelId: payload.modelId, messages: awsMessages as any, @@ -162,10 +192,18 @@ export class AwsBedrockAPIClient extends BaseApiClient< try { if (payload.stream) { - const command = new ConverseStreamCommand(commonParams) + // 根据模型类型选择正确的 API 格式 + const requestBody = this.createRequestBodyForModel(commonParams, additionalParams) + + const command = new InvokeModelWithResponseStreamCommand({ + modelId: commonParams.modelId, + body: JSON.stringify(requestBody), + contentType: 'application/json', + accept: 'application/json' + }) + const response = await sdk.client.send(command) - // 直接返回AWS Bedrock流式响应的异步迭代器 - return this.createStreamIterator(response) + return this.createInvokeModelStreamIterator(response) } else { const command = new ConverseCommand(commonParams) const response = await sdk.client.send(command) @@ -177,32 +215,236 @@ export class AwsBedrockAPIClient extends BaseApiClient< } } - private async *createStreamIterator(response: any): AsyncIterable { - try { - if (response.stream) { - for await (const chunk of response.stream) { - logger.debug('AWS Bedrock chunk received:', chunk) + /** + * 根据模型类型创建请求体 + */ + private createRequestBodyForModel(commonParams: any, additionalParams: any): any { + const modelId = commonParams.modelId.toLowerCase() - // AWS Bedrock的流式响应格式转换为标准格式 - if (chunk.contentBlockDelta?.delta?.text) { - yield { - contentBlockDelta: { - delta: { text: chunk.contentBlockDelta.delta.text } + // Claude 系列模型使用 Anthropic API 格式 + if (modelId.includes('claude')) { + return { + anthropic_version: 'bedrock-2023-05-31', + max_tokens: commonParams.inferenceConfig.maxTokens, + temperature: commonParams.inferenceConfig.temperature, + top_p: commonParams.inferenceConfig.topP, + messages: commonParams.messages, + ...(commonParams.system && commonParams.system[0]?.text ? { system: commonParams.system[0].text } : {}), + ...(commonParams.toolConfig?.tools ? { tools: commonParams.toolConfig.tools } : {}), + ...additionalParams + } + } + + // OpenAI 系列模型 + if (modelId.includes('gpt') || modelId.includes('openai')) { + const messages: any[] = [] + + // 添加系统消息 + if (commonParams.system && commonParams.system[0]?.text) { + messages.push({ + role: 'system', + content: commonParams.system[0].text + }) + } + + // 转换消息格式 + for (const message of commonParams.messages) { + const content: any[] = [] + for (const part of message.content) { + if (part.text) { + content.push({ type: 'text', text: part.text }) + } else if (part.image) { + content.push({ + type: 'image_url', + image_url: { + url: `data:image/${part.image.format};base64,${part.image.source.bytes}` + } + }) + } + } + messages.push({ + role: message.role, + content: content.length === 1 && content[0].type === 'text' ? content[0].text : content + }) + } + + const baseBody: any = { + model: commonParams.modelId, + messages: messages, + max_tokens: commonParams.inferenceConfig.maxTokens, + temperature: commonParams.inferenceConfig.temperature, + top_p: commonParams.inferenceConfig.topP, + stream: true, + ...(commonParams.toolConfig?.tools ? { tools: commonParams.toolConfig.tools } : {}) + } + + // OpenAI 模型的 thinking 参数格式 + if (additionalParams.reasoning_effort) { + baseBody.reasoning_effort = additionalParams.reasoning_effort + delete additionalParams.reasoning_effort + } + + return { + ...baseBody, + ...additionalParams + } + } + + // Llama 系列模型 + if (modelId.includes('llama')) { + const baseBody: any = { + prompt: this.convertMessagesToPrompt(commonParams.messages, commonParams.system), + max_gen_len: commonParams.inferenceConfig.maxTokens, + temperature: commonParams.inferenceConfig.temperature, + top_p: commonParams.inferenceConfig.topP + } + + // Llama 模型的 thinking 参数格式 + if (additionalParams.thinking_mode) { + baseBody.thinking_mode = additionalParams.thinking_mode + delete additionalParams.thinking_mode + } + + return { + ...baseBody, + ...additionalParams + } + } + + // Amazon Titan 系列模型 + if (modelId.includes('titan')) { + const textGenerationConfig: any = { + maxTokenCount: commonParams.inferenceConfig.maxTokens, + temperature: commonParams.inferenceConfig.temperature, + topP: commonParams.inferenceConfig.topP + } + + // 将 thinking 相关参数添加到 textGenerationConfig 中 + if (additionalParams.thinking) { + textGenerationConfig.thinking = additionalParams.thinking + delete additionalParams.thinking + } + + return { + inputText: this.convertMessagesToPrompt(commonParams.messages, commonParams.system), + textGenerationConfig: { + ...textGenerationConfig, + ...Object.keys(additionalParams).reduce((acc, key) => { + if (['thinking_tokens', 'reasoning_mode'].includes(key)) { + acc[key] = additionalParams[key] + delete additionalParams[key] + } + return acc + }, {} as any) + }, + ...additionalParams + } + } + + // Cohere Command 系列模型 + if (modelId.includes('cohere') || modelId.includes('command')) { + const baseBody: any = { + message: this.convertMessagesToPrompt(commonParams.messages, commonParams.system), + max_tokens: commonParams.inferenceConfig.maxTokens, + temperature: commonParams.inferenceConfig.temperature, + p: commonParams.inferenceConfig.topP + } + + // Cohere 模型的 thinking 参数格式 + if (additionalParams.thinking) { + baseBody.thinking = additionalParams.thinking + delete additionalParams.thinking + } + if (additionalParams.reasoning_tokens) { + baseBody.reasoning_tokens = additionalParams.reasoning_tokens + delete additionalParams.reasoning_tokens + } + + return { + ...baseBody, + ...additionalParams + } + } + + // 默认使用通用格式 + const baseBody: any = { + prompt: this.convertMessagesToPrompt(commonParams.messages, commonParams.system), + max_tokens: commonParams.inferenceConfig.maxTokens, + temperature: commonParams.inferenceConfig.temperature, + top_p: commonParams.inferenceConfig.topP + } + + return { + ...baseBody, + ...additionalParams + } + } + + /** + * 将消息转换为简单的 prompt 格式 + */ + private convertMessagesToPrompt(messages: any[], system?: any[]): string { + let prompt = '' + + // 添加系统消息 + if (system && system[0]?.text) { + prompt += `System: ${system[0].text}\n\n` + } + + // 添加对话消息 + for (const message of messages) { + const role = message.role === 'assistant' ? 'Assistant' : 'Human' + let content = '' + + for (const part of message.content) { + if (part.text) { + content += part.text + } else if (part.image) { + content += '[Image]' + } + } + + prompt += `${role}: ${content}\n\n` + } + + prompt += 'Assistant:' + return prompt + } + + private async *createInvokeModelStreamIterator(response: any): AsyncIterable { + try { + if (response.body) { + for await (const event of response.body) { + if (event.chunk) { + const chunk: AwsBedrockStreamChunk = JSON.parse(new TextDecoder().decode(event.chunk.bytes)) + + // 转换为标准格式 + if (chunk.type === 'content_block_delta') { + yield { + contentBlockDelta: { + delta: chunk.delta, + contentBlockIndex: chunk.index + } + } + } else if (chunk.type === 'message_start') { + yield { messageStart: chunk } + } else if (chunk.type === 'message_stop') { + yield { messageStop: chunk } + } else if (chunk.type === 'content_block_start') { + yield { + contentBlockStart: { + start: chunk.content_block, + contentBlockIndex: chunk.index + } + } + } else if (chunk.type === 'content_block_stop') { + yield { + contentBlockStop: { + contentBlockIndex: chunk.index + } } } } - - if (chunk.messageStart) { - yield { messageStart: chunk.messageStart } - } - - if (chunk.messageStop) { - yield { messageStop: chunk.messageStop } - } - - if (chunk.metadata) { - yield { metadata: chunk.metadata } - } } } } catch (error) { @@ -485,6 +727,38 @@ export class AwsBedrockAPIClient extends BaseApiClient< } } + // 获取推理预算token(对所有支持推理的模型) + const budgetTokens = this.getBudgetToken(assistant, model) + + // 构建基础自定义参数 + const customParams: Record = + coreRequest.callType === 'chat' ? this.getCustomParameters(assistant) : {} + + // 根据模型类型添加 thinking 参数 + if (budgetTokens) { + const modelId = model.id.toLowerCase() + + if (modelId.includes('claude')) { + // Claude 模型使用 Anthropic 格式 + customParams.thinking = { type: 'enabled', budget_tokens: budgetTokens } + } else if (modelId.includes('gpt') || modelId.includes('openai')) { + // OpenAI 模型格式 + customParams.reasoning_effort = assistant?.settings?.reasoning_effort + } else if (modelId.includes('llama')) { + // Llama 模型格式 + customParams.thinking_mode = true + customParams.thinking_tokens = budgetTokens + } else if (modelId.includes('titan')) { + // Titan 模型格式 + customParams.thinking = { enabled: true } + customParams.thinking_tokens = budgetTokens + } else if (modelId.includes('cohere') || modelId.includes('command')) { + // Cohere 模型格式 + customParams.thinking = { enabled: true } + customParams.reasoning_tokens = budgetTokens + } + } + const payload: AwsBedrockSdkParams = { modelId: model.id, messages: @@ -497,9 +771,7 @@ export class AwsBedrockAPIClient extends BaseApiClient< topP: this.getTopP(assistant, model), stream: streamOutput !== false, tools: tools.length > 0 ? tools : undefined, - // 只在对话场景下应用自定义参数,避免影响翻译、总结等其他业务逻辑 - // 注意:用户自定义参数总是应该覆盖其他参数 - ...(coreRequest.callType === 'chat' ? this.getCustomParameters(assistant) : {}) + ...customParams } const timeout = this.getTimeout(model) @@ -511,6 +783,7 @@ export class AwsBedrockAPIClient extends BaseApiClient< getResponseChunkTransformer(): ResponseChunkTransformer { return () => { let hasStartedText = false + let hasStartedThinking = false let accumulatedJson = '' const toolCalls: Record = {} @@ -570,6 +843,24 @@ export class AwsBedrockAPIClient extends BaseApiClient< } as TextDeltaChunk) } + // 处理thinking增量 + if ( + rawChunk.contentBlockDelta?.delta?.type === 'thinking_delta' && + rawChunk.contentBlockDelta?.delta?.thinking + ) { + if (!hasStartedThinking) { + controller.enqueue({ + type: ChunkType.THINKING_START + } as ThinkingStartChunk) + hasStartedThinking = true + } + + controller.enqueue({ + type: ChunkType.THINKING_DELTA, + text: rawChunk.contentBlockDelta.delta.thinking + } as ThinkingDeltaChunk) + } + // 处理内容块停止事件 - 参考 Anthropic 的 content_block_stop 处理 if (rawChunk.contentBlockStop) { const blockIndex = rawChunk.contentBlockStop.contentBlockIndex || 0 @@ -708,4 +999,49 @@ export class AwsBedrockAPIClient extends BaseApiClient< extractMessagesFromSdkPayload(sdkPayload: AwsBedrockSdkParams): AwsBedrockSdkMessageParam[] { return sdkPayload.messages || [] } + + /** + * 获取 AWS Bedrock 的推理工作量预算token + * @param assistant - The assistant + * @param model - The model + * @returns The budget tokens for reasoning effort + */ + private getBudgetToken(assistant: Assistant, model: Model): number | undefined { + try { + if (!isReasoningModel(model)) { + return undefined + } + + const { maxTokens } = getAssistantSettings(assistant) + const reasoningEffort = assistant?.settings?.reasoning_effort + + if (reasoningEffort === undefined) { + return undefined + } + + const effortRatio = EFFORT_RATIO[reasoningEffort] + const tokenLimits = findTokenLimit(model.id) + + if (tokenLimits) { + // 使用模型特定的 token 限制 + const budgetTokens = Math.max( + 1024, + Math.floor( + Math.min( + (tokenLimits.max - tokenLimits.min) * effortRatio + tokenLimits.min, + (maxTokens || DEFAULT_MAX_TOKENS) * effortRatio + ) + ) + ) + return budgetTokens + } else { + // 对于没有特定限制的模型,使用简化计算 + const budgetTokens = Math.max(1024, Math.floor((maxTokens || DEFAULT_MAX_TOKENS) * effortRatio)) + return budgetTokens + } + } catch (error) { + logger.warn('Failed to calculate budget tokens for reasoning effort:', error as Error) + return undefined + } + } } diff --git a/src/renderer/src/types/sdk.ts b/src/renderer/src/types/sdk.ts index a5413e54fb..36608ab9fe 100644 --- a/src/renderer/src/types/sdk.ts +++ b/src/renderer/src/types/sdk.ts @@ -162,6 +162,7 @@ export interface AwsBedrockSdkParams { topP?: number stream?: boolean tools?: AwsBedrockSdkTool[] + [key: string]: any // Allow any additional custom parameters } export interface AwsBedrockSdkMessageParam { @@ -206,6 +207,22 @@ export interface AwsBedrockSdkMessageParam { }> } +export interface AwsBedrockStreamChunk { + type: string + delta?: { + text?: string + toolUse?: { input?: string } + type?: string + thinking?: string + } + index?: number + content_block?: any + usage?: { + inputTokens?: number + outputTokens?: number + } +} + export interface AwsBedrockSdkRawChunk { contentBlockStart?: { start?: { @@ -222,6 +239,8 @@ export interface AwsBedrockSdkRawChunk { toolUse?: { input?: string } + type?: string // 支持 'thinking_delta' 等类型 + thinking?: string // 支持 thinking 内容 } contentBlockIndex?: number } From 4a62bb6ad71614d3553511992cf3bed3984cda26 Mon Sep 17 00:00:00 2001 From: beyondkmp Date: Fri, 15 Aug 2025 22:48:22 +0800 Subject: [PATCH 18/22] refactor: replace axios and node fetch with electron's net module (#9212) * refactor: replace axios and node fetch with electron's net module for network requests in preprocess providers - Updated Doc2xPreprocessProvider and MineruPreprocessProvider to use net.fetch instead of axios for making HTTP requests. - Improved error handling for network responses across various methods. - Removed unnecessary AxiosRequestConfig and related code to streamline the implementation. * lint * refactor(Doc2xPreprocessProvider): enhance file validation and upload process - Added file size validation to prevent loading files larger than 300MB into memory. - Implemented file size check before reading the PDF to ensure efficient memory usage. - Updated the file upload method to use a stream, setting the 'Content-Length' header for better handling of large files. * refactor(brave-search): update net.fetch calls to use url.toString() - Modified all instances of net.fetch to use url.toString() for better URL handling. - Ensured consistency in how URLs are passed to the fetch method across various functions. * refactor(MCPService): improve URL handling in net.fetch calls - Updated net.fetch to use url.toString() for better type handling of URLs. - Ensured consistent URL processing across the MCPService class. * feat(ProxyManager): integrate axios with fetch proxy support - Added axios as a dependency to enable fetch proxy usage. - Implemented logic to set axios's adapter to 'fetch' for proxy handling. - Preserved original axios adapter for restoration when disabling the proxy. --- .../preprocess/Doc2xPreprocessProvider.ts | 136 ++++++++++++------ .../preprocess/MineruPreprocessProvider.ts | 18 ++- .../knowledge/reranker/GeneralReranker.ts | 14 +- src/main/mcpServers/brave-search.ts | 9 +- src/main/mcpServers/dify-knowledge.ts | 5 +- src/main/mcpServers/fetch.ts | 3 +- src/main/services/AppUpdater.ts | 6 +- src/main/services/CopilotService.ts | 70 +++++---- src/main/services/FileStorage.ts | 3 +- src/main/services/MCPService.ts | 4 +- src/main/services/NutstoreService.ts | 3 +- src/main/utils/ipService.ts | 3 +- 12 files changed, 182 insertions(+), 92 deletions(-) diff --git a/src/main/knowledge/preprocess/Doc2xPreprocessProvider.ts b/src/main/knowledge/preprocess/Doc2xPreprocessProvider.ts index afc8d1ba9b..834ff2f27e 100644 --- a/src/main/knowledge/preprocess/Doc2xPreprocessProvider.ts +++ b/src/main/knowledge/preprocess/Doc2xPreprocessProvider.ts @@ -5,7 +5,7 @@ import { loggerService } from '@logger' import { fileStorage } from '@main/services/FileStorage' import { FileMetadata, PreprocessProvider } from '@types' import AdmZip from 'adm-zip' -import axios, { AxiosRequestConfig } from 'axios' +import { net } from 'electron' import BasePreprocessProvider from './BasePreprocessProvider' @@ -38,19 +38,24 @@ export default class Doc2xPreprocessProvider extends BasePreprocessProvider { } private async validateFile(filePath: string): Promise { - const pdfBuffer = await fs.promises.readFile(filePath) + // 首先检查文件大小,避免读取大文件到内存 + const stats = await fs.promises.stat(filePath) + const fileSizeBytes = stats.size + // 文件大小小于300MB + if (fileSizeBytes >= 300 * 1024 * 1024) { + const fileSizeMB = Math.round(fileSizeBytes / (1024 * 1024)) + throw new Error(`PDF file size (${fileSizeMB}MB) exceeds the limit of 300MB`) + } + + // 只有在文件大小合理的情况下才读取文件内容检查页数 + const pdfBuffer = await fs.promises.readFile(filePath) const doc = await this.readPdf(pdfBuffer) // 文件页数小于1000页 if (doc.numPages >= 1000) { throw new Error(`PDF page count (${doc.numPages}) exceeds the limit of 1000 pages`) } - // 文件大小小于300MB - if (pdfBuffer.length >= 300 * 1024 * 1024) { - const fileSizeMB = Math.round(pdfBuffer.length / (1024 * 1024)) - throw new Error(`PDF file size (${fileSizeMB}MB) exceeds the limit of 300MB`) - } } public async parseFile(sourceId: string, file: FileMetadata): Promise<{ processedFile: FileMetadata }> { @@ -160,11 +165,23 @@ export default class Doc2xPreprocessProvider extends BasePreprocessProvider { * @returns 预上传响应的url和uid */ private async preupload(): Promise { - const config = this.createAuthConfig() const endpoint = `${this.provider.apiHost}/api/v2/parse/preupload` try { - const { data } = await axios.post>(endpoint, null, config) + const response = await net.fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.provider.apiKey}` + }, + body: null + }) + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`) + } + + const data = (await response.json()) as ApiResponse if (data.code === 'success' && data.data) { return data.data @@ -178,17 +195,29 @@ export default class Doc2xPreprocessProvider extends BasePreprocessProvider { } /** - * 上传文件 + * 上传文件(使用流式上传) * @param filePath 文件路径 * @param url 预上传响应的url */ private async putFile(filePath: string, url: string): Promise { try { - const fileStream = fs.createReadStream(filePath) - const response = await axios.put(url, fileStream) + // 获取文件大小用于设置 Content-Length + const stats = await fs.promises.stat(filePath) + const fileSize = stats.size - if (response.status !== 200) { - throw new Error(`HTTP status ${response.status}: ${response.statusText}`) + // 创建可读流 + const fileStream = fs.createReadStream(filePath) + + const response = await net.fetch(url, { + method: 'PUT', + body: fileStream as any, // TypeScript 类型转换,net.fetch 支持 ReadableStream + headers: { + 'Content-Length': fileSize.toString() + } + }) + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`) } } catch (error) { logger.error(`Failed to upload file ${filePath}: ${error instanceof Error ? error.message : String(error)}`) @@ -197,16 +226,25 @@ export default class Doc2xPreprocessProvider extends BasePreprocessProvider { } private async getStatus(uid: string): Promise { - const config = this.createAuthConfig() const endpoint = `${this.provider.apiHost}/api/v2/parse/status?uid=${uid}` try { - const response = await axios.get>(endpoint, config) + const response = await net.fetch(endpoint, { + method: 'GET', + headers: { + Authorization: `Bearer ${this.provider.apiKey}` + } + }) - if (response.data.code === 'success' && response.data.data) { - return response.data.data + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`) + } + + const data = (await response.json()) as ApiResponse + if (data.code === 'success' && data.data) { + return data.data } else { - throw new Error(`API returned error: ${response.data.message || JSON.stringify(response.data)}`) + throw new Error(`API returned error: ${data.message || JSON.stringify(data)}`) } } catch (error) { logger.error(`Failed to get status for uid ${uid}: ${error instanceof Error ? error.message : String(error)}`) @@ -221,13 +259,6 @@ export default class Doc2xPreprocessProvider extends BasePreprocessProvider { */ private async convertFile(uid: string, filePath: string): Promise { const fileName = path.parse(filePath).name - const config = { - ...this.createAuthConfig(), - headers: { - ...this.createAuthConfig().headers, - 'Content-Type': 'application/json' - } - } const payload = { uid, @@ -239,10 +270,22 @@ export default class Doc2xPreprocessProvider extends BasePreprocessProvider { const endpoint = `${this.provider.apiHost}/api/v2/convert/parse` try { - const response = await axios.post>(endpoint, payload, config) + const response = await net.fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.provider.apiKey}` + }, + body: JSON.stringify(payload) + }) - if (response.data.code !== 'success') { - throw new Error(`API returned error: ${response.data.message || JSON.stringify(response.data)}`) + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`) + } + + const data = (await response.json()) as ApiResponse + if (data.code !== 'success') { + throw new Error(`API returned error: ${data.message || JSON.stringify(data)}`) } } catch (error) { logger.error(`Failed to convert file ${filePath}: ${error instanceof Error ? error.message : String(error)}`) @@ -256,16 +299,25 @@ export default class Doc2xPreprocessProvider extends BasePreprocessProvider { * @returns 解析后的文件信息 */ private async getParsedFile(uid: string): Promise { - const config = this.createAuthConfig() const endpoint = `${this.provider.apiHost}/api/v2/convert/parse/result?uid=${uid}` try { - const response = await axios.get>(endpoint, config) + const response = await net.fetch(endpoint, { + method: 'GET', + headers: { + Authorization: `Bearer ${this.provider.apiKey}` + } + }) - if (response.status === 200 && response.data.data) { - return response.data.data + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`) + } + + const data = (await response.json()) as ApiResponse + if (data.data) { + return data.data } else { - throw new Error(`HTTP status ${response.status}: ${response.statusText}`) + throw new Error(`No data in response`) } } catch (error) { logger.error( @@ -295,8 +347,12 @@ export default class Doc2xPreprocessProvider extends BasePreprocessProvider { try { // 下载文件 - const response = await axios.get(url, { responseType: 'arraybuffer' }) - fs.writeFileSync(zipPath, response.data) + const response = await net.fetch(url, { method: 'GET' }) + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`) + } + const arrayBuffer = await response.arrayBuffer() + fs.writeFileSync(zipPath, Buffer.from(arrayBuffer)) // 确保提取目录存在 if (!fs.existsSync(extractPath)) { @@ -318,14 +374,6 @@ export default class Doc2xPreprocessProvider extends BasePreprocessProvider { } } - private createAuthConfig(): AxiosRequestConfig { - return { - headers: { - Authorization: `Bearer ${this.provider.apiKey}` - } - } - } - public checkQuota(): Promise { throw new Error('Method not implemented.') } diff --git a/src/main/knowledge/preprocess/MineruPreprocessProvider.ts b/src/main/knowledge/preprocess/MineruPreprocessProvider.ts index 0e29a6443f..1976f64c05 100644 --- a/src/main/knowledge/preprocess/MineruPreprocessProvider.ts +++ b/src/main/knowledge/preprocess/MineruPreprocessProvider.ts @@ -5,7 +5,7 @@ import { loggerService } from '@logger' import { fileStorage } from '@main/services/FileStorage' import { FileMetadata, PreprocessProvider } from '@types' import AdmZip from 'adm-zip' -import axios from 'axios' +import { net } from 'electron' import BasePreprocessProvider from './BasePreprocessProvider' @@ -95,7 +95,7 @@ export default class MineruPreprocessProvider extends BasePreprocessProvider { public async checkQuota() { try { - const quota = await fetch(`${this.provider.apiHost}/api/v4/quota`, { + const quota = await net.fetch(`${this.provider.apiHost}/api/v4/quota`, { method: 'GET', headers: { 'Content-Type': 'application/json', @@ -179,8 +179,12 @@ export default class MineruPreprocessProvider extends BasePreprocessProvider { try { // 下载ZIP文件 - const response = await axios.get(zipUrl, { responseType: 'arraybuffer' }) - fs.writeFileSync(zipPath, Buffer.from(response.data)) + const response = await net.fetch(zipUrl, { method: 'GET' }) + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`) + } + const arrayBuffer = await response.arrayBuffer() + fs.writeFileSync(zipPath, Buffer.from(arrayBuffer)) logger.info(`Downloaded ZIP file: ${zipPath}`) // 确保提取目录存在 @@ -236,7 +240,7 @@ export default class MineruPreprocessProvider extends BasePreprocessProvider { } try { - const response = await fetch(endpoint, { + const response = await net.fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -271,7 +275,7 @@ export default class MineruPreprocessProvider extends BasePreprocessProvider { try { const fileBuffer = await fs.promises.readFile(filePath) - const response = await fetch(uploadUrl, { + const response = await net.fetch(uploadUrl, { method: 'PUT', body: fileBuffer, headers: { @@ -316,7 +320,7 @@ export default class MineruPreprocessProvider extends BasePreprocessProvider { const endpoint = `${this.provider.apiHost}/api/v4/extract-results/batch/${batchId}` try { - const response = await fetch(endpoint, { + const response = await net.fetch(endpoint, { method: 'GET', headers: { 'Content-Type': 'application/json', diff --git a/src/main/knowledge/reranker/GeneralReranker.ts b/src/main/knowledge/reranker/GeneralReranker.ts index 1252ecad57..5a0e240a9d 100644 --- a/src/main/knowledge/reranker/GeneralReranker.ts +++ b/src/main/knowledge/reranker/GeneralReranker.ts @@ -1,6 +1,6 @@ import { ExtractChunkData } from '@cherrystudio/embedjs-interfaces' import { KnowledgeBaseParams } from '@types' -import axios from 'axios' +import { net } from 'electron' import BaseReranker from './BaseReranker' @@ -15,7 +15,17 @@ export default class GeneralReranker extends BaseReranker { const requestBody = this.getRerankRequestBody(query, searchResults) try { - const { data } = await axios.post(url, requestBody, { headers: this.defaultHeaders() }) + const response = await net.fetch(url, { + method: 'POST', + headers: this.defaultHeaders(), + body: JSON.stringify(requestBody) + }) + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`) + } + + const data = await response.json() const rerankResults = this.extractRerankResult(data) return this.getRerankResult(searchResults, rerankResults) diff --git a/src/main/mcpServers/brave-search.ts b/src/main/mcpServers/brave-search.ts index 6f219e1eb8..d11a4f2580 100644 --- a/src/main/mcpServers/brave-search.ts +++ b/src/main/mcpServers/brave-search.ts @@ -3,6 +3,7 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js' import { CallToolRequestSchema, ListToolsRequestSchema, Tool } from '@modelcontextprotocol/sdk/types.js' +import { net } from 'electron' const WEB_SEARCH_TOOL: Tool = { name: 'brave_web_search', @@ -159,7 +160,7 @@ async function performWebSearch(apiKey: string, query: string, count: number = 1 url.searchParams.set('count', Math.min(count, 20).toString()) // API limit url.searchParams.set('offset', offset.toString()) - const response = await fetch(url, { + const response = await net.fetch(url.toString(), { headers: { Accept: 'application/json', 'Accept-Encoding': 'gzip', @@ -192,7 +193,7 @@ async function performLocalSearch(apiKey: string, query: string, count: number = webUrl.searchParams.set('result_filter', 'locations') webUrl.searchParams.set('count', Math.min(count, 20).toString()) - const webResponse = await fetch(webUrl, { + const webResponse = await net.fetch(webUrl.toString(), { headers: { Accept: 'application/json', 'Accept-Encoding': 'gzip', @@ -225,7 +226,7 @@ async function getPoisData(apiKey: string, ids: string[]): Promise url.searchParams.append('ids', id)) - const response = await fetch(url, { + const response = await net.fetch(url.toString(), { headers: { Accept: 'application/json', 'Accept-Encoding': 'gzip', @@ -244,7 +245,7 @@ async function getDescriptionsData(apiKey: string, ids: string[]): Promise url.searchParams.append('ids', id)) - const response = await fetch(url, { + const response = await net.fetch(url.toString(), { headers: { Accept: 'application/json', 'Accept-Encoding': 'gzip', diff --git a/src/main/mcpServers/dify-knowledge.ts b/src/main/mcpServers/dify-knowledge.ts index 2bd2c4adda..83a352fd4f 100644 --- a/src/main/mcpServers/dify-knowledge.ts +++ b/src/main/mcpServers/dify-knowledge.ts @@ -2,6 +2,7 @@ import { loggerService } from '@logger' import { Server } from '@modelcontextprotocol/sdk/server/index.js' import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js' +import { net } from 'electron' import * as z from 'zod/v4' const logger = loggerService.withContext('DifyKnowledgeServer') @@ -134,7 +135,7 @@ class DifyKnowledgeServer { private async performListKnowledges(difyKey: string, apiHost: string): Promise { try { const url = `${apiHost.replace(/\/$/, '')}/datasets` - const response = await fetch(url, { + const response = await net.fetch(url, { method: 'GET', headers: { Authorization: `Bearer ${difyKey}` @@ -186,7 +187,7 @@ class DifyKnowledgeServer { try { const url = `${apiHost.replace(/\/$/, '')}/datasets/${id}/retrieve` - const response = await fetch(url, { + const response = await net.fetch(url, { method: 'POST', headers: { Authorization: `Bearer ${difyKey}`, diff --git a/src/main/mcpServers/fetch.ts b/src/main/mcpServers/fetch.ts index 04839d8a92..e55b114776 100644 --- a/src/main/mcpServers/fetch.ts +++ b/src/main/mcpServers/fetch.ts @@ -2,6 +2,7 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js' import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js' +import { net } from 'electron' import { JSDOM } from 'jsdom' import TurndownService from 'turndown' import { z } from 'zod' @@ -16,7 +17,7 @@ export type RequestPayload = z.infer export class Fetcher { private static async _fetch({ url, headers }: RequestPayload): Promise { try { - const response = await fetch(url, { + const response = await net.fetch(url, { headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', diff --git a/src/main/services/AppUpdater.ts b/src/main/services/AppUpdater.ts index ea3b1f3f1e..bdfb8e3cc8 100644 --- a/src/main/services/AppUpdater.ts +++ b/src/main/services/AppUpdater.ts @@ -6,7 +6,7 @@ import { generateUserAgent } from '@main/utils/systemInfo' import { FeedUrl, UpgradeChannel } from '@shared/config/constant' import { IpcChannel } from '@shared/IpcChannel' import { CancellationToken, UpdateInfo } from 'builder-util-runtime' -import { app, BrowserWindow, dialog } from 'electron' +import { app, BrowserWindow, dialog, net } from 'electron' import { AppUpdater as _AppUpdater, autoUpdater, Logger, NsisUpdater, UpdateCheckResult } from 'electron-updater' import path from 'path' import semver from 'semver' @@ -75,7 +75,7 @@ export default class AppUpdater { } try { logger.info(`get release version from github: ${channel}`) - const responses = await fetch('https://api.github.com/repos/CherryHQ/cherry-studio/releases?per_page=8', { + const responses = await net.fetch('https://api.github.com/repos/CherryHQ/cherry-studio/releases?per_page=8', { headers }) const data = (await responses.json()) as GithubReleaseInfo[] @@ -99,7 +99,7 @@ export default class AppUpdater { if (mightHaveLatest) { logger.info(`might have latest release, get latest release`) - const latestReleaseResponse = await fetch( + const latestReleaseResponse = await net.fetch( 'https://api.github.com/repos/CherryHQ/cherry-studio/releases/latest', { headers diff --git a/src/main/services/CopilotService.ts b/src/main/services/CopilotService.ts index bb54e74932..f5c773a7cc 100644 --- a/src/main/services/CopilotService.ts +++ b/src/main/services/CopilotService.ts @@ -1,6 +1,5 @@ import { loggerService } from '@logger' -import { AxiosRequestConfig } from 'axios' -import axios from 'axios' +import { net } from 'electron' import { app, safeStorage } from 'electron' import fs from 'fs/promises' import path from 'path' @@ -86,7 +85,8 @@ class CopilotService { */ public getUser = async (_: Electron.IpcMainInvokeEvent, token: string): Promise => { try { - const config: AxiosRequestConfig = { + const response = await net.fetch(CONFIG.API_URLS.GITHUB_USER, { + method: 'GET', headers: { Connection: 'keep-alive', 'user-agent': 'Visual Studio Code (desktop)', @@ -95,12 +95,16 @@ class CopilotService { 'Sec-Fetch-Dest': 'empty', authorization: `token ${token}` } + }) + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`) } - const response = await axios.get(CONFIG.API_URLS.GITHUB_USER, config) + const data = await response.json() return { - login: response.data.login, - avatar: response.data.avatar_url + login: data.login, + avatar: data.avatar_url } } catch (error) { logger.error('Failed to get user information:', error as Error) @@ -118,16 +122,23 @@ class CopilotService { try { this.updateHeaders(headers) - const response = await axios.post( - CONFIG.API_URLS.GITHUB_DEVICE_CODE, - { + const response = await net.fetch(CONFIG.API_URLS.GITHUB_DEVICE_CODE, { + method: 'POST', + headers: { + ...this.headers, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ client_id: CONFIG.GITHUB_CLIENT_ID, scope: 'read:user' - }, - { headers: this.headers } - ) + }) + }) - return response.data + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`) + } + + return (await response.json()) as AuthResponse } catch (error) { logger.error('Failed to get auth message:', error as Error) throw new CopilotServiceError('无法获取GitHub授权信息', error) @@ -150,17 +161,25 @@ class CopilotService { await this.delay(currentDelay) try { - const response = await axios.post( - CONFIG.API_URLS.GITHUB_ACCESS_TOKEN, - { + const response = await net.fetch(CONFIG.API_URLS.GITHUB_ACCESS_TOKEN, { + method: 'POST', + headers: { + ...this.headers, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ client_id: CONFIG.GITHUB_CLIENT_ID, device_code, grant_type: 'urn:ietf:params:oauth:grant-type:device_code' - }, - { headers: this.headers } - ) + }) + }) - const { access_token } = response.data + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`) + } + + const data = (await response.json()) as TokenResponse + const { access_token } = data if (access_token) { return { access_token } } @@ -205,16 +224,19 @@ class CopilotService { const encryptedToken = await fs.readFile(this.tokenFilePath) const access_token = safeStorage.decryptString(Buffer.from(encryptedToken)) - const config: AxiosRequestConfig = { + const response = await net.fetch(CONFIG.API_URLS.COPILOT_TOKEN, { + method: 'GET', headers: { ...this.headers, authorization: `token ${access_token}` } + }) + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`) } - const response = await axios.get(CONFIG.API_URLS.COPILOT_TOKEN, config) - - return response.data + return (await response.json()) as CopilotTokenResponse } catch (error) { logger.error('Failed to get Copilot token:', error as Error) throw new CopilotServiceError('无法获取Copilot令牌,请重新授权', error) diff --git a/src/main/services/FileStorage.ts b/src/main/services/FileStorage.ts index e34c51f299..f5df9ed3f7 100644 --- a/src/main/services/FileStorage.ts +++ b/src/main/services/FileStorage.ts @@ -5,6 +5,7 @@ import { FileMetadata } from '@types' import * as crypto from 'crypto' import { dialog, + net, OpenDialogOptions, OpenDialogReturnValue, SaveDialogOptions, @@ -509,7 +510,7 @@ class FileStorage { isUseContentType?: boolean ): Promise => { try { - const response = await fetch(url) + const response = await net.fetch(url) if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`) } diff --git a/src/main/services/MCPService.ts b/src/main/services/MCPService.ts index d3909cc86f..a7f907f65f 100644 --- a/src/main/services/MCPService.ts +++ b/src/main/services/MCPService.ts @@ -29,7 +29,7 @@ import { } from '@modelcontextprotocol/sdk/types.js' import { nanoid } from '@reduxjs/toolkit' import type { GetResourceResponse, MCPCallToolResponse, MCPPrompt, MCPResource, MCPServer, MCPTool } from '@types' -import { app } from 'electron' +import { app, net } from 'electron' import { EventEmitter } from 'events' import { memoize } from 'lodash' import { v4 as uuidv4 } from 'uuid' @@ -205,7 +205,7 @@ class McpService { } } - return fetch(url, { ...init, headers }) + return net.fetch(typeof url === 'string' ? url : url.toString(), { ...init, headers }) } }, requestInit: { diff --git a/src/main/services/NutstoreService.ts b/src/main/services/NutstoreService.ts index 4422ea8a07..f4ad5a2c33 100644 --- a/src/main/services/NutstoreService.ts +++ b/src/main/services/NutstoreService.ts @@ -2,6 +2,7 @@ import path from 'node:path' import { loggerService } from '@logger' import { NUTSTORE_HOST } from '@shared/config/nutstore' +import { net } from 'electron' import { XMLParser } from 'fast-xml-parser' import { isNil, partial } from 'lodash' import { type FileStat } from 'webdav' @@ -62,7 +63,7 @@ export async function getDirectoryContents(token: string, target: string): Promi let currentUrl = `${NUTSTORE_HOST}${target}` while (true) { - const response = await fetch(currentUrl, { + const response = await net.fetch(currentUrl, { method: 'PROPFIND', headers: { Authorization: `Basic ${token}`, diff --git a/src/main/utils/ipService.ts b/src/main/utils/ipService.ts index ec5ab78215..3180f9457c 100644 --- a/src/main/utils/ipService.ts +++ b/src/main/utils/ipService.ts @@ -1,4 +1,5 @@ import { loggerService } from '@logger' +import { net } from 'electron' const logger = loggerService.withContext('IpService') @@ -12,7 +13,7 @@ export async function getIpCountry(): Promise { const controller = new AbortController() const timeoutId = setTimeout(() => controller.abort(), 5000) - const ipinfo = await fetch('https://ipinfo.io/json', { + const ipinfo = await net.fetch('https://ipinfo.io/json', { signal: controller.signal, headers: { 'User-Agent': From e0dbd2d2dbea06ca1abf335066964c6bdc34c669 Mon Sep 17 00:00:00 2001 From: SuYao Date: Fri, 15 Aug 2025 22:56:40 +0800 Subject: [PATCH 19/22] fix/9165 (#9194) * fix/9165 * fix: early return --- .../aiCore/clients/openai/OpenAIApiClient.ts | 12 ++++--- src/renderer/src/config/models.ts | 36 ++++++++++--------- src/renderer/src/types/index.ts | 1 + 3 files changed, 27 insertions(+), 22 deletions(-) diff --git a/src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts b/src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts index 60173551b4..7568ee69be 100644 --- a/src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts +++ b/src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts @@ -9,6 +9,7 @@ import { isGPT5SeriesModel, isGrokReasoningModel, isNotSupportSystemMessageModel, + isOpenAIReasoningModel, isQwenAlwaysThinkModel, isQwenMTModel, isQwenReasoningModel, @@ -146,7 +147,7 @@ export class OpenAIAPIClient extends OpenAIBaseClient< return {} } // Don't disable reasoning for models that require it - if (isGrokReasoningModel(model)) { + if (isGrokReasoningModel(model) || isOpenAIReasoningModel(model)) { return {} } return { reasoning: { enabled: false, exclude: true } } @@ -524,12 +525,13 @@ export class OpenAIAPIClient extends OpenAIBaseClient< } // 1. 处理系统消息 - let systemMessage = { role: 'system', content: assistant.prompt || '' } + const systemMessage = { role: 'system', content: assistant.prompt || '' } if (isSupportedReasoningEffortOpenAIModel(model)) { - systemMessage = { - role: isSupportDeveloperRoleProvider(this.provider) ? 'developer' : 'system', - content: `Formatting re-enabled${systemMessage ? '\n' + systemMessage.content : ''}` + if (isSupportDeveloperRoleProvider(this.provider)) { + systemMessage.role = 'developer' + } else { + systemMessage.role = 'system' } } diff --git a/src/renderer/src/config/models.ts b/src/renderer/src/config/models.ts index b5164dfe5b..e6276cbb98 100644 --- a/src/renderer/src/config/models.ts +++ b/src/renderer/src/config/models.ts @@ -292,6 +292,7 @@ export const CLAUDE_SUPPORTED_WEBSEARCH_REGEX = new RegExp( // 模型类型到支持的reasoning_effort的映射表 export const MODEL_SUPPORTED_REASONING_EFFORT: ReasoningEffortConfig = { default: ['low', 'medium', 'high'] as const, + o: ['low', 'medium', 'high'] as const, gpt5: ['minimal', 'low', 'medium', 'high'] as const, grok: ['low', 'high'] as const, gemini: ['low', 'medium', 'high', 'auto'] as const, @@ -307,7 +308,8 @@ export const MODEL_SUPPORTED_REASONING_EFFORT: ReasoningEffortConfig = { // 模型类型到支持选项的映射表 export const MODEL_SUPPORTED_OPTIONS: ThinkingOptionConfig = { default: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.default] as const, - gpt5: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.gpt5] as const, + o: MODEL_SUPPORTED_REASONING_EFFORT.o, + gpt5: [...MODEL_SUPPORTED_REASONING_EFFORT.gpt5] as const, grok: MODEL_SUPPORTED_REASONING_EFFORT.grok, gemini: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.gemini] as const, gemini_pro: MODEL_SUPPORTED_REASONING_EFFORT.gemini_pro, @@ -320,28 +322,28 @@ export const MODEL_SUPPORTED_OPTIONS: ThinkingOptionConfig = { } as const export const getThinkModelType = (model: Model): ThinkingModelType => { + let thinkingModelType: ThinkingModelType = 'default' if (isGPT5SeriesModel(model)) { - return 'gpt5' - } - if (isSupportedThinkingTokenGeminiModel(model)) { + thinkingModelType = 'gpt5' + } else if (isSupportedReasoningEffortOpenAIModel(model)) { + thinkingModelType = 'o' + } else if (isSupportedThinkingTokenGeminiModel(model)) { if (GEMINI_FLASH_MODEL_REGEX.test(model.id)) { - return 'gemini' + thinkingModelType = 'gemini' } else { - return 'gemini_pro' + thinkingModelType = 'gemini_pro' } - } - if (isSupportedReasoningEffortGrokModel(model)) return 'grok' - if (isSupportedThinkingTokenQwenModel(model)) { + } else if (isSupportedReasoningEffortGrokModel(model)) thinkingModelType = 'grok' + else if (isSupportedThinkingTokenQwenModel(model)) { if (isQwenAlwaysThinkModel(model)) { - return 'qwen_thinking' + thinkingModelType = 'qwen_thinking' } - return 'qwen' - } - if (isSupportedThinkingTokenDoubaoModel(model)) return 'doubao' - if (isSupportedThinkingTokenHunyuanModel(model)) return 'hunyuan' - if (isSupportedReasoningEffortPerplexityModel(model)) return 'perplexity' - if (isSupportedThinkingTokenZhipuModel(model)) return 'zhipu' - return 'default' + thinkingModelType = 'qwen' + } else if (isSupportedThinkingTokenDoubaoModel(model)) thinkingModelType = 'doubao' + else if (isSupportedThinkingTokenHunyuanModel(model)) thinkingModelType = 'hunyuan' + else if (isSupportedReasoningEffortPerplexityModel(model)) thinkingModelType = 'perplexity' + else if (isSupportedThinkingTokenZhipuModel(model)) thinkingModelType = 'zhipu' + return thinkingModelType } export function isFunctionCallingModel(model?: Model): boolean { diff --git a/src/renderer/src/types/index.ts b/src/renderer/src/types/index.ts index ca4a1fe4fd..5f649c5e8f 100644 --- a/src/renderer/src/types/index.ts +++ b/src/renderer/src/types/index.ts @@ -56,6 +56,7 @@ export type ReasoningEffortOption = NonNullable | 'auto' export type ThinkingOption = ReasoningEffortOption | 'off' export type ThinkingModelType = | 'default' + | 'o' | 'gpt5' | 'grok' | 'gemini' From a02b4b3955d742fb5b10455bacf357c9fd9dec82 Mon Sep 17 00:00:00 2001 From: Pleasure1234 <3196812536@qq.com> Date: Sat, 16 Aug 2025 04:00:32 +0800 Subject: [PATCH 20/22] fix: websearch (#9222) Update LocalSearchProvider.ts --- .../providers/WebSearchProvider/LocalSearchProvider.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/renderer/src/providers/WebSearchProvider/LocalSearchProvider.ts b/src/renderer/src/providers/WebSearchProvider/LocalSearchProvider.ts index abdf9fc826..7911646630 100644 --- a/src/renderer/src/providers/WebSearchProvider/LocalSearchProvider.ts +++ b/src/renderer/src/providers/WebSearchProvider/LocalSearchProvider.ts @@ -89,15 +89,9 @@ export default class LocalSearchProvider extends BaseWebSearchProvider { * @returns 带有语言过滤的查询 */ protected applyLanguageFilter(query: string, language: string): string { - if (this.provider.id.includes('local-google')) { + if (this.provider.id.includes('local-google') || this.provider.id.includes('local-bing')) { return `${query} lang:${language.split('-')[0]}` } - if (this.provider.id.includes('local-bing')) { - return `${query} language:${language}` - } - if (this.provider.id.includes('local-baidu')) { - return `${query} language:${language.split('-')[0]}` - } return query } From 04326eba21ddf14fe62199ff11d8b1c50fa51858 Mon Sep 17 00:00:00 2001 From: miro <16189212+miroklarin@users.noreply.github.com> Date: Sat, 16 Aug 2025 04:21:29 +0100 Subject: [PATCH 21/22] feat: Use different window name for Quick Assistant (#9217) Co-authored-by: Miro Klarin --- src/renderer/miniWindow.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/miniWindow.html b/src/renderer/miniWindow.html index 83b108b8a4..7f3b936444 100644 --- a/src/renderer/miniWindow.html +++ b/src/renderer/miniWindow.html @@ -6,7 +6,7 @@ - Cherry Studio + Cherry Studio Quick Assistant