From 26cb37c9be572b68c9364cf741fdc559a245cb67 Mon Sep 17 00:00:00 2001 From: SuYao Date: Thu, 19 Jun 2025 13:34:36 +0800 Subject: [PATCH 001/111] refactor: remove deprecated MCP server handling and knowledge base ID logic from Inputbar and related services (#7339) - Removed unused MCP server handling from Inputbar and MessagesService. - Updated ApiService to fetch active MCP servers directly from the store. - Deprecated knowledgeBaseIds and enabledMCPs in Message types and related functions. - Cleaned up related utility functions to enhance code clarity and maintainability. --- .../src/pages/home/Inputbar/Inputbar.tsx | 27 +------------------ src/renderer/src/services/ApiService.ts | 12 ++++++--- src/renderer/src/services/MessagesService.ts | 11 +++----- src/renderer/src/types/newMessage.ts | 9 +++++++ src/renderer/src/utils/messageUtils/find.ts | 11 -------- 5 files changed, 22 insertions(+), 48 deletions(-) diff --git a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx index a0de1fb58e..54a54f727e 100644 --- a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx +++ b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx @@ -13,7 +13,6 @@ import { import db from '@renderer/databases' import { useAssistant } from '@renderer/hooks/useAssistant' import { useKnowledgeBases } from '@renderer/hooks/useKnowledge' -import { useMCPServers } from '@renderer/hooks/useMCPServers' import { useMessageOperations, useTopicLoading } from '@renderer/hooks/useMessageOperations' import { modelGenerating, useRuntime } from '@renderer/hooks/useRuntime' import { useMessageStyle, useSettings } from '@renderer/hooks/useSettings' @@ -105,7 +104,6 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = const currentMessageId = useRef('') const isVision = useMemo(() => isVisionModel(model), [model]) const supportExts = useMemo(() => [...textExts, ...documentExts, ...(isVision ? imageExts : [])], [isVision]) - const { activedMcpServers } = useMCPServers() const { bases: knowledgeBases } = useKnowledgeBases() const isMultiSelectMode = useAppSelector((state) => state.runtime.chat.isMultiSelectMode) @@ -176,22 +174,11 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = if (uploadedFiles) { baseUserMessage.files = uploadedFiles } - const knowledgeBaseIds = selectedKnowledgeBases?.map((base) => base.id) - - if (knowledgeBaseIds) { - baseUserMessage.knowledgeBaseIds = knowledgeBaseIds - } if (mentionModels) { baseUserMessage.mentions = mentionModels } - if (!isEmpty(assistant.mcpServers) && !isEmpty(activedMcpServers)) { - baseUserMessage.enabledMCPs = activedMcpServers.filter((server) => - assistant.mcpServers?.some((s) => s.id === server.id) - ) - } - const assistantWithTopicPrompt = topic.prompt ? { ...assistant, prompt: `${assistant.prompt}\n${topic.prompt}` } : assistant @@ -212,19 +199,7 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = } catch (error) { console.error('Failed to send message:', error) } - }, [ - activedMcpServers, - assistant, - dispatch, - files, - inputEmpty, - loading, - mentionModels, - resizeTextArea, - selectedKnowledgeBases, - text, - topic - ]) + }, [assistant, dispatch, files, inputEmpty, loading, mentionModels, resizeTextArea, text, topic]) const translate = useCallback(async () => { if (isTranslating) { diff --git a/src/renderer/src/services/ApiService.ts b/src/renderer/src/services/ApiService.ts index b081e3dcf7..46c5fd849c 100644 --- a/src/renderer/src/services/ApiService.ts +++ b/src/renderer/src/services/ApiService.ts @@ -33,10 +33,11 @@ import { SdkModel } from '@renderer/types/sdk' import { removeSpecialCharactersForTopicName } from '@renderer/utils' import { isAbortError } from '@renderer/utils/error' import { extractInfoFromXML, ExtractResults } from '@renderer/utils/extract' -import { findFileBlocks, getKnowledgeBaseIds, getMainTextContent } from '@renderer/utils/messageUtils/find' +import { findFileBlocks, getMainTextContent } from '@renderer/utils/messageUtils/find' import { findLast, isEmpty, takeRight } from 'lodash' import AiProvider from '../aiCore' +import store from '../store' import { getAssistantProvider, getAssistantSettings, @@ -63,7 +64,7 @@ async function fetchExternalTool( lastAnswer?: Message ): Promise { // 可能会有重复? - const knowledgeBaseIds = getKnowledgeBaseIds(lastUserMessage) + const knowledgeBaseIds = assistant.knowledge_bases?.map((base) => base.id) const hasKnowledgeBase = !isEmpty(knowledgeBaseIds) const knowledgeRecognition = assistant.knowledgeRecognition || 'on' const webSearchProvider = WebSearchService.getWebSearchProvider(assistant.webSearchProviderId) @@ -251,7 +252,12 @@ async function fetchExternalTool( // Get MCP tools (Fix duplicate declaration) let mcpTools: MCPTool[] = [] // Initialize as empty array - const enabledMCPs = assistant.mcpServers + const allMcpServers = store.getState().mcp.servers || [] + const activedMcpServers = allMcpServers.filter((s) => s.isActive) + const assistantMcpServers = assistant.mcpServers || [] + + const enabledMCPs = activedMcpServers.filter((server) => assistantMcpServers.some((s) => s.id === server.id)) + if (enabledMCPs && enabledMCPs.length > 0) { try { const toolPromises = enabledMCPs.map>(async (mcpServer) => { diff --git a/src/renderer/src/services/MessagesService.ts b/src/renderer/src/services/MessagesService.ts index f4e764f4cb..16272376a1 100644 --- a/src/renderer/src/services/MessagesService.ts +++ b/src/renderer/src/services/MessagesService.ts @@ -6,7 +6,7 @@ import { fetchMessagesSummary } from '@renderer/services/ApiService' import store from '@renderer/store' import { messageBlocksSelectors, removeManyBlocks } from '@renderer/store/messageBlock' import { selectMessagesForTopic } from '@renderer/store/newMessage' -import type { Assistant, FileType, MCPServer, Model, Topic, Usage } from '@renderer/types' +import type { Assistant, FileType, Model, Topic, Usage } from '@renderer/types' import { FileTypes } from '@renderer/types' import type { Message, MessageBlock } from '@renderer/types/newMessage' import { AssistantMessageStatus, MessageBlockStatus, MessageBlockType } from '@renderer/types/newMessage' @@ -108,9 +108,7 @@ export function getUserMessage({ content, files, // Keep other potential params if needed by createMessage - knowledgeBaseIds, mentions, - enabledMCPs, usage }: { assistant: Assistant @@ -120,7 +118,6 @@ export function getUserMessage({ files?: FileType[] knowledgeBaseIds?: string[] mentions?: Model[] - enabledMCPs?: MCPServer[] usage?: Usage }): { message: Message; blocks: MessageBlock[] } { const defaultModel = getDefaultModel() @@ -133,8 +130,7 @@ export function getUserMessage({ if (content !== undefined) { // Pass messageId when creating blocks const textBlock = createMainTextBlock(messageId, content, { - status: MessageBlockStatus.SUCCESS, - knowledgeBaseIds + status: MessageBlockStatus.SUCCESS }) blocks.push(textBlock) blockIds.push(textBlock.id) @@ -165,7 +161,7 @@ export function getUserMessage({ blocks: blockIds, // 移除knowledgeBaseIds mentions, - enabledMCPs, + // 移除mcp type, usage } @@ -203,7 +199,6 @@ export function resetAssistantMessage(message: Message, model?: Model): Message useful: undefined, askId: undefined, mentions: undefined, - enabledMCPs: undefined, blocks: [], createdAt: new Date().toISOString() } diff --git a/src/renderer/src/types/newMessage.ts b/src/renderer/src/types/newMessage.ts index dbdd344c6e..8e1a5c26dd 100644 --- a/src/renderer/src/types/newMessage.ts +++ b/src/renderer/src/types/newMessage.ts @@ -173,6 +173,9 @@ export type Message = { useful?: boolean askId?: string // 关联的问题消息ID mentions?: Model[] + /** + * @deprecated + */ enabledMCPs?: MCPServer[] usage?: Usage @@ -204,8 +207,14 @@ export interface MessageInputBaseParams { topic: Topic content?: string files?: FileType[] + /** + * @deprecated + */ knowledgeBaseIds?: string[] mentions?: Model[] + /** + * @deprecated + */ enabledMCPs?: MCPServer[] usage?: CompletionUsage } diff --git a/src/renderer/src/utils/messageUtils/find.ts b/src/renderer/src/utils/messageUtils/find.ts index 106f2725ab..9fcc80b463 100644 --- a/src/renderer/src/utils/messageUtils/find.ts +++ b/src/renderer/src/utils/messageUtils/find.ts @@ -140,17 +140,6 @@ export const getCitationContent = (message: Message): string => { .join('\n\n') } -/** - * Gets the knowledgeBaseIds array from the *first* MainTextMessageBlock of a message. - * Note: Assumes knowledgeBaseIds are only relevant on the first text block, adjust if needed. - * @param message - The message object. - * @returns The knowledgeBaseIds array or undefined if not found. - */ -export const getKnowledgeBaseIds = (message: Message): string[] | undefined => { - const firstTextBlock = findMainTextBlocks(message) - return firstTextBlock?.flatMap((block) => block.knowledgeBaseIds).filter((id): id is string => Boolean(id)) -} - /** * Gets the file content from all FileMessageBlocks and ImageMessageBlocks of a message. * @param message - The message object. From 28b58d8e498750d45ee3ea1d8bb87afd9bfc85a2 Mon Sep 17 00:00:00 2001 From: one Date: Thu, 19 Jun 2025 15:09:01 +0800 Subject: [PATCH 002/111] refactor(CodeBlock): support more file extensions for code downloading (#7192) --- electron.vite.config.ts | 6 +- package.json | 1 + .../src/components/CodeBlockView/index.tsx | 16 +++-- .../src/utils/__tests__/markdown.test.ts | 62 +++++++++++++++++++ src/renderer/src/utils/markdown.ts | 54 ++++++++++++++++ yarn.lock | 8 +++ 6 files changed, 137 insertions(+), 10 deletions(-) diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 9b22ffc33b..fa4b234a54 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -68,12 +68,16 @@ export default defineConfig({ } }, optimizeDeps: { - exclude: ['pyodide'] + exclude: ['pyodide'], + esbuildOptions: { + target: 'esnext' // for dev + } }, worker: { format: 'es' }, build: { + target: 'esnext', // for build rollupOptions: { input: { index: resolve(__dirname, 'src/renderer/index.html'), diff --git a/package.json b/package.json index 3ee50ee69a..72e1f64856 100644 --- a/package.json +++ b/package.json @@ -168,6 +168,7 @@ "husky": "^9.1.7", "i18next": "^23.11.5", "jest-styled-components": "^7.2.0", + "linguist-languages": "^8.0.0", "lint-staged": "^15.5.0", "lodash": "^4.17.21", "lru-cache": "^11.1.0", diff --git a/src/renderer/src/components/CodeBlockView/index.tsx b/src/renderer/src/components/CodeBlockView/index.tsx index 944e6f66e3..811b8665cc 100644 --- a/src/renderer/src/components/CodeBlockView/index.tsx +++ b/src/renderer/src/components/CodeBlockView/index.tsx @@ -4,7 +4,7 @@ import { CodeTool, CodeToolbar, TOOL_SPECS, useCodeTool } from '@renderer/compon import { useSettings } from '@renderer/hooks/useSettings' import { pyodideService } from '@renderer/services/PyodideService' import { extractTitle } from '@renderer/utils/formats' -import { isValidPlantUML } from '@renderer/utils/markdown' +import { getExtensionByLanguage, isValidPlantUML } from '@renderer/utils/markdown' import dayjs from 'dayjs' import { CirclePlay, CodeXml, Copy, Download, Eye, Square, SquarePen, SquareSplitHorizontal } from 'lucide-react' import React, { memo, useCallback, useEffect, useMemo, useState } from 'react' @@ -67,23 +67,21 @@ const CodeBlockView: React.FC = ({ children, language, onSave }) => { window.message.success({ content: t('code_block.copy.success'), key: 'copy-code' }) }, [children, t]) - const handleDownloadSource = useCallback(() => { + const handleDownloadSource = useCallback(async () => { let fileName = '' - // 尝试提取标题 + // 尝试提取 HTML 标题 if (language === 'html' && children.includes('')) { - const title = extractTitle(children) - if (title) { - fileName = `${title}.html` - } + fileName = extractTitle(children) || '' } // 默认使用日期格式命名 if (!fileName) { - fileName = `${dayjs().format('YYYYMMDDHHmm')}.${language}` + fileName = `${dayjs().format('YYYYMMDDHHmm')}` } - window.api.file.save(fileName, children) + const ext = await getExtensionByLanguage(language) + window.api.file.save(`${fileName}${ext}`, children) }, [children, language]) const handleRunScript = useCallback(() => { diff --git a/src/renderer/src/utils/__tests__/markdown.test.ts b/src/renderer/src/utils/__tests__/markdown.test.ts index cbde058d24..7b82816f17 100644 --- a/src/renderer/src/utils/__tests__/markdown.test.ts +++ b/src/renderer/src/utils/__tests__/markdown.test.ts @@ -7,6 +7,7 @@ import { convertMathFormula, findCitationInChildren, getCodeBlockId, + getExtensionByLanguage, markdownToPlainText, removeTrailingDoubleSpaces, updateCodeBlock @@ -143,6 +144,67 @@ describe('markdown', () => { }) }) + describe('getExtensionByLanguage', () => { + // 批量测试语言名称到扩展名的映射 + const testLanguageExtensions = async (testCases: Record) => { + for (const [language, expectedExtension] of Object.entries(testCases)) { + const result = await getExtensionByLanguage(language) + expect(result).toBe(expectedExtension) + } + } + + it('should return extension for exact language name match', async () => { + await testLanguageExtensions({ + '4D': '.4dm', + 'C#': '.cs', + JavaScript: '.js', + TypeScript: '.ts', + 'Objective-C++': '.mm', + Python: '.py', + SVG: '.svg', + 'Visual Basic .NET': '.vb' + }) + }) + + it('should return extension for case-insensitive language name match', async () => { + await testLanguageExtensions({ + '4d': '.4dm', + 'c#': '.cs', + javascript: '.js', + typescript: '.ts', + 'objective-c++': '.mm', + python: '.py', + svg: '.svg', + 'visual basic .net': '.vb' + }) + }) + + it('should return extension for language aliases', async () => { + await testLanguageExtensions({ + js: '.js', + node: '.js', + 'obj-c++': '.mm', + 'objc++': '.mm', + 'objectivec++': '.mm', + py: '.py', + 'visual basic': '.vb' + }) + }) + + it('should return fallback extension for unknown languages', async () => { + await testLanguageExtensions({ + 'unknown-language': '.unknown-language', + custom: '.custom' + }) + }) + + it('should handle empty string input', async () => { + await testLanguageExtensions({ + '': '.' + }) + }) + }) + describe('getCodeBlockId', () => { it('should generate ID from position information', () => { // 从位置信息生成ID diff --git a/src/renderer/src/utils/markdown.ts b/src/renderer/src/utils/markdown.ts index e4881ed062..3c409d39b4 100644 --- a/src/renderer/src/utils/markdown.ts +++ b/src/renderer/src/utils/markdown.ts @@ -54,6 +54,60 @@ export function removeTrailingDoubleSpaces(markdown: string): string { return markdown.replace(/ {2}$/gm, '') } +const predefinedExtensionMap: Record = { + html: '.html', + javascript: '.js', + typescript: '.ts', + python: '.py', + json: '.json', + markdown: '.md', + text: '.txt' +} + +/** + * 根据语言名称获取文件扩展名 + * - 先精确匹配,再忽略大小写,最后匹配别名 + * - 返回第一个扩展名 + * @param language 语言名称 + * @returns 文件扩展名 + */ +export async function getExtensionByLanguage(language: string): Promise { + const lowerLanguage = language.toLowerCase() + + // 常用的扩展名 + const predefined = predefinedExtensionMap[lowerLanguage] + if (predefined) { + return predefined + } + + const languages = await import('linguist-languages') + + // 精确匹配语言名称 + const directMatch = languages[language as keyof typeof languages] as any + if (directMatch?.extensions?.[0]) { + return directMatch.extensions[0] + } + + // 大小写不敏感的语言名称匹配 + for (const [langName, data] of Object.entries(languages)) { + const languageData = data as any + if (langName.toLowerCase() === lowerLanguage && languageData.extensions?.[0]) { + return languageData.extensions[0] + } + } + + // 通过别名匹配 + for (const [, data] of Object.entries(languages)) { + const languageData = data as any + if (languageData.aliases?.includes(lowerLanguage)) { + return languageData.extensions?.[0] || `.${language}` + } + } + + // 回退到语言名称 + return `.${language}` +} + /** * 根据代码块节点的起始位置生成 ID * @param start 代码块节点的起始位置 diff --git a/yarn.lock b/yarn.lock index a0daa3b3ce..688f7f8f5d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5688,6 +5688,7 @@ __metadata: i18next: "npm:^23.11.5" jest-styled-components: "npm:^7.2.0" jsdom: "npm:26.1.0" + linguist-languages: "npm:^8.0.0" lint-staged: "npm:^15.5.0" lodash: "npm:^4.17.21" lru-cache: "npm:^11.1.0" @@ -11874,6 +11875,13 @@ __metadata: languageName: node linkType: hard +"linguist-languages@npm:^8.0.0": + version: 8.0.0 + resolution: "linguist-languages@npm:8.0.0" + checksum: 10c0/eaae46254247b9aa5b287ac98e062e7fe859314328ce305e34e152bc7bb172d69633999320cb47dc2a710388179712a76bb1ddd6e39e249af2684a4f0a66256c + languageName: node + linkType: hard + "linkify-it@npm:^5.0.0": version: 5.0.0 resolution: "linkify-it@npm:5.0.0" From 439ec286b57c767e4044efbb6ac18d739e672294 Mon Sep 17 00:00:00 2001 From: one Date: Thu, 19 Jun 2025 17:13:29 +0800 Subject: [PATCH 003/111] refactor: hard-coded language map (#7360) --- package.json | 1 - packages/shared/config/languages.ts | 2904 +++++++++++++++++ .../src/utils/__tests__/markdown.test.ts | 24 +- src/renderer/src/utils/markdown.ts | 33 +- yarn.lock | 8 - 5 files changed, 2923 insertions(+), 47 deletions(-) create mode 100644 packages/shared/config/languages.ts diff --git a/package.json b/package.json index 72e1f64856..3ee50ee69a 100644 --- a/package.json +++ b/package.json @@ -168,7 +168,6 @@ "husky": "^9.1.7", "i18next": "^23.11.5", "jest-styled-components": "^7.2.0", - "linguist-languages": "^8.0.0", "lint-staged": "^15.5.0", "lodash": "^4.17.21", "lru-cache": "^11.1.0", diff --git a/packages/shared/config/languages.ts b/packages/shared/config/languages.ts new file mode 100644 index 0000000000..4cd7d533b4 --- /dev/null +++ b/packages/shared/config/languages.ts @@ -0,0 +1,2904 @@ +/** + * 代码语言扩展名列表 + */ + +type LanguageData = { + type: string + aliases?: string[] + extensions?: string[] +} + +export const languages: Record = { + 'c2hs haskell': { + extensions: ['.chs'], + type: 'programming', + aliases: ['c2hs'] + }, + tsql: { + extensions: ['.sql'], + type: 'programming' + }, + uno: { + extensions: ['.uno'], + type: 'programming' + }, + 'html+ecr': { + extensions: ['.ecr'], + type: 'markup', + aliases: ['ecr'] + }, + xpages: { + extensions: ['.xsp-config', '.xsp.metadata'], + type: 'data' + }, + 'module management system': { + extensions: ['.mms', '.mmk'], + type: 'programming' + }, + turing: { + extensions: ['.t', '.tu'], + type: 'programming' + }, + harbour: { + extensions: ['.hb'], + type: 'programming' + }, + sass: { + extensions: ['.sass'], + type: 'markup' + }, + cobol: { + extensions: ['.cob', '.cbl', '.ccp', '.cobol', '.cpy'], + type: 'programming' + }, + ioke: { + extensions: ['.ik'], + type: 'programming' + }, + 'standard ml': { + extensions: ['.ml', '.fun', '.sig', '.sml'], + type: 'programming', + aliases: ['sml'] + }, + less: { + extensions: ['.less'], + type: 'markup', + aliases: ['less-css'] + }, + cue: { + extensions: ['.cue'], + type: 'programming' + }, + 'q#': { + extensions: ['.qs'], + type: 'programming', + aliases: ['qsharp'] + }, + 'c#': { + extensions: ['.cs', '.cake', '.cs.pp', '.csx', '.linq'], + type: 'programming', + aliases: ['csharp', 'cake', 'cakescript'] + }, + 'closure templates': { + extensions: ['.soy'], + type: 'markup', + aliases: ['soy'] + }, + 'modula-2': { + extensions: ['.mod'], + type: 'programming' + }, + cirru: { + extensions: ['.cirru'], + type: 'programming' + }, + prisma: { + extensions: ['.prisma'], + type: 'data' + }, + xojo: { + extensions: ['.xojo_code', '.xojo_menu', '.xojo_report', '.xojo_script', '.xojo_toolbar', '.xojo_window'], + type: 'programming' + }, + 'vim script': { + extensions: ['.vim', '.vba', '.vimrc', '.vmb'], + type: 'programming', + aliases: ['vim', 'viml', 'nvim', 'vimscript'] + }, + unrealscript: { + extensions: ['.uc'], + type: 'programming' + }, + 'kicad layout': { + extensions: ['.kicad_pcb', '.kicad_mod', '.kicad_wks'], + type: 'data', + aliases: ['pcbnew'] + }, + urweb: { + extensions: ['.ur', '.urs'], + type: 'programming', + aliases: ['Ur/Web', 'Ur'] + }, + 'rpm spec': { + extensions: ['.spec'], + type: 'data', + aliases: ['specfile'] + }, + hcl: { + extensions: ['.hcl', '.nomad', '.tf', '.tfvars', '.workflow'], + type: 'programming', + aliases: ['HashiCorp Configuration Language', 'terraform'] + }, + 'vim help file': { + extensions: ['.txt'], + type: 'prose', + aliases: ['help', 'vimhelp'] + }, + 'component pascal': { + extensions: ['.cp', '.cps'], + type: 'programming' + }, + realbasic: { + extensions: ['.rbbas', '.rbfrm', '.rbmnu', '.rbres', '.rbtbar', '.rbuistate'], + type: 'programming' + }, + cil: { + extensions: ['.cil'], + type: 'data' + }, + nix: { + extensions: ['.nix'], + type: 'programming', + aliases: ['nixos'] + }, + mirah: { + extensions: ['.druby', '.duby', '.mirah'], + type: 'programming' + }, + red: { + extensions: ['.red', '.reds'], + type: 'programming', + aliases: ['red/system'] + }, + zimpl: { + extensions: ['.zimpl', '.zmpl', '.zpl'], + type: 'programming' + }, + 'world of warcraft addon data': { + extensions: ['.toc'], + type: 'data' + }, + logtalk: { + extensions: ['.lgt', '.logtalk'], + type: 'programming' + }, + 'digital command language': { + extensions: ['.com'], + type: 'programming', + aliases: ['dcl'] + }, + 'inno setup': { + extensions: ['.iss', '.isl'], + type: 'programming' + }, + ruby: { + extensions: [ + '.rb', + '.builder', + '.eye', + '.fcgi', + '.gemspec', + '.god', + '.jbuilder', + '.mspec', + '.pluginspec', + '.podspec', + '.prawn', + '.rabl', + '.rake', + '.rbi', + '.rbuild', + '.rbw', + '.rbx', + '.ru', + '.ruby', + '.spec', + '.thor', + '.watchr' + ], + type: 'programming', + aliases: ['jruby', 'macruby', 'rake', 'rb', 'rbx'] + }, + sqlpl: { + extensions: ['.sql', '.db2'], + type: 'programming' + }, + qmake: { + extensions: ['.pro', '.pri'], + type: 'programming' + }, + faust: { + extensions: ['.dsp'], + type: 'programming' + }, + nextflow: { + extensions: ['.nf'], + type: 'programming' + }, + ox: { + extensions: ['.ox', '.oxh', '.oxo'], + type: 'programming' + }, + xproc: { + extensions: ['.xpl', '.xproc'], + type: 'programming' + }, + 'directx 3d file': { + extensions: ['.x'], + type: 'data' + }, + 'jupyter notebook': { + extensions: ['.ipynb'], + type: 'markup', + aliases: ['IPython Notebook'] + }, + jolie: { + extensions: ['.ol', '.iol'], + type: 'programming' + }, + cartocss: { + extensions: ['.mss'], + type: 'programming', + aliases: ['Carto'] + }, + 'ltspice symbol': { + extensions: ['.asy'], + type: 'data' + }, + slash: { + extensions: ['.sl'], + type: 'programming' + }, + 'pure data': { + extensions: ['.pd'], + type: 'data' + }, + yang: { + extensions: ['.yang'], + type: 'data' + }, + prolog: { + extensions: ['.pl', '.plt', '.pro', '.prolog', '.yap'], + type: 'programming' + }, + 'g-code': { + extensions: ['.g', '.cnc', '.gco', '.gcode'], + type: 'programming' + }, + minid: { + extensions: ['.minid'], + type: 'programming' + }, + 'ecere projects': { + extensions: ['.epj'], + type: 'data' + }, + org: { + extensions: ['.org'], + type: 'prose' + }, + tcsh: { + extensions: ['.tcsh', '.csh'], + type: 'programming' + }, + scilab: { + extensions: ['.sci', '.sce', '.tst'], + type: 'programming' + }, + hack: { + extensions: ['.hack', '.hh', '.hhi', '.php'], + type: 'programming' + }, + coffeescript: { + extensions: ['.coffee', '._coffee', '.cake', '.cjsx', '.iced'], + type: 'programming', + aliases: ['coffee', 'coffee-script'] + }, + 'visual basic .net': { + extensions: ['.vb', '.vbhtml'], + type: 'programming', + aliases: ['visual basic', 'vbnet', 'vb .net', 'vb.net'] + }, + opa: { + extensions: ['.opa'], + type: 'programming' + }, + clean: { + extensions: ['.icl', '.dcl'], + type: 'programming' + }, + batchfile: { + extensions: ['.bat', '.cmd'], + type: 'programming', + aliases: ['bat', 'batch', 'dosbatch', 'winbatch'] + }, + v: { + extensions: ['.v'], + type: 'programming', + aliases: ['vlang'] + }, + vhdl: { + extensions: ['.vhdl', '.vhd', '.vhf', '.vhi', '.vho', '.vhs', '.vht', '.vhw'], + type: 'programming' + }, + pawn: { + extensions: ['.pwn', '.inc', '.sma'], + type: 'programming' + }, + abap: { + extensions: ['.abap'], + type: 'programming' + }, + 'public key': { + extensions: ['.asc', '.pub'], + type: 'data' + }, + svelte: { + extensions: ['.svelte'], + type: 'markup' + }, + xonsh: { + extensions: ['.xsh'], + type: 'programming' + }, + 'api blueprint': { + extensions: ['.apib'], + type: 'markup' + }, + 'glyph bitmap distribution format': { + extensions: ['.bdf'], + type: 'data' + }, + 'common lisp': { + extensions: ['.lisp', '.asd', '.cl', '.l', '.lsp', '.ny', '.podsl', '.sexp'], + type: 'programming', + aliases: ['lisp'] + }, + julia: { + extensions: ['.jl'], + type: 'programming' + }, + rmarkdown: { + extensions: ['.qmd', '.rmd'], + type: 'prose' + }, + applescript: { + extensions: ['.applescript', '.scpt'], + type: 'programming', + aliases: ['osascript'] + }, + zap: { + extensions: ['.zap', '.xzap'], + type: 'programming' + }, + filterscript: { + extensions: ['.fs'], + type: 'programming' + }, + glsl: { + extensions: [ + '.glsl', + '.fp', + '.frag', + '.frg', + '.fs', + '.fsh', + '.fshader', + '.geo', + '.geom', + '.glslf', + '.glslv', + '.gs', + '.gshader', + '.rchit', + '.rmiss', + '.shader', + '.tesc', + '.tese', + '.vert', + '.vrx', + '.vs', + '.vsh', + '.vshader' + ], + type: 'programming' + }, + vcl: { + extensions: ['.vcl'], + type: 'programming' + }, + gdb: { + extensions: ['.gdb', '.gdbinit'], + type: 'programming' + }, + nanorc: { + extensions: ['.nanorc'], + type: 'data' + }, + 'parrot internal representation': { + extensions: ['.pir'], + type: 'programming', + aliases: ['pir'] + }, + pod: { + extensions: ['.pod'], + type: 'prose' + }, + m4sugar: { + extensions: ['.m4'], + type: 'programming', + aliases: ['autoconf'] + }, + mlir: { + extensions: ['.mlir'], + type: 'programming' + }, + monkey: { + extensions: ['.monkey', '.monkey2'], + type: 'programming' + }, + nim: { + extensions: ['.nim', '.nim.cfg', '.nimble', '.nimrod', '.nims'], + type: 'programming' + }, + 'gentoo ebuild': { + extensions: ['.ebuild'], + type: 'programming' + }, + racket: { + extensions: ['.rkt', '.rktd', '.rktl', '.scrbl'], + type: 'programming' + }, + ebnf: { + extensions: ['.ebnf'], + type: 'data' + }, + charity: { + extensions: ['.ch'], + type: 'programming' + }, + groovy: { + extensions: ['.groovy', '.grt', '.gtpl', '.gvy'], + type: 'programming' + }, + hiveql: { + extensions: ['.q', '.hql'], + type: 'programming' + }, + 'f*': { + extensions: ['.fst', '.fsti'], + type: 'programming', + aliases: ['fstar'] + }, + systemverilog: { + extensions: ['.sv', '.svh', '.vh'], + type: 'programming' + }, + jison: { + extensions: ['.jison'], + type: 'programming' + }, + fantom: { + extensions: ['.fan'], + type: 'programming' + }, + scheme: { + extensions: ['.scm', '.sch', '.sld', '.sls', '.sps', '.ss'], + type: 'programming' + }, + 'cpp-objdump': { + extensions: ['.cppobjdump', '.c++-objdump', '.c++objdump', '.cpp-objdump', '.cxx-objdump'], + type: 'data', + aliases: ['c++-objdump'] + }, + arc: { + extensions: ['.arc'], + type: 'programming' + }, + logos: { + extensions: ['.xm', '.x', '.xi'], + type: 'programming' + }, + assembly: { + extensions: ['.asm', '.a51', '.i', '.inc', '.nas', '.nasm', '.s'], + type: 'programming', + aliases: ['asm', 'nasm'] + }, + 'java properties': { + extensions: ['.properties'], + type: 'data' + }, + haskell: { + extensions: ['.hs', '.hs-boot', '.hsc'], + type: 'programming' + }, + ragel: { + extensions: ['.rl'], + type: 'programming', + aliases: ['ragel-rb', 'ragel-ruby'] + }, + gn: { + extensions: ['.gn', '.gni'], + type: 'data' + }, + '1c enterprise': { + extensions: ['.bsl', '.os'], + type: 'programming' + }, + diff: { + extensions: ['.diff', '.patch'], + type: 'data', + aliases: ['udiff'] + }, + http: { + extensions: ['.http'], + type: 'data' + }, + tex: { + extensions: [ + '.tex', + '.aux', + '.bbx', + '.cbx', + '.cls', + '.dtx', + '.ins', + '.lbx', + '.ltx', + '.mkii', + '.mkiv', + '.mkvi', + '.sty', + '.toc' + ], + type: 'markup', + aliases: ['latex'] + }, + mathematica: { + extensions: ['.mathematica', '.cdf', '.m', '.ma', '.mt', '.nb', '.nbp', '.wl', '.wlt'], + type: 'programming', + aliases: ['mma', 'wolfram', 'wolfram language', 'wolfram lang', 'wl'] + }, + 'javascript+erb': { + extensions: ['.js.erb'], + type: 'programming' + }, + muse: { + extensions: ['.muse'], + type: 'prose', + aliases: ['amusewiki', 'emacs muse'] + }, + 'openedge abl': { + extensions: ['.p', '.cls', '.w'], + type: 'programming', + aliases: ['progress', 'openedge', 'abl'] + }, + ninja: { + extensions: ['.ninja'], + type: 'data' + }, + agda: { + extensions: ['.agda'], + type: 'programming' + }, + aspectj: { + extensions: ['.aj'], + type: 'programming' + }, + jq: { + extensions: ['.jq'], + type: 'programming' + }, + apex: { + extensions: ['.cls', '.apex', '.trigger'], + type: 'programming' + }, + bluespec: { + extensions: ['.bsv'], + type: 'programming', + aliases: ['bluespec bsv', 'bsv'] + }, + forth: { + extensions: ['.fth', '.4th', '.f', '.for', '.forth', '.fr', '.frt', '.fs'], + type: 'programming' + }, + xc: { + extensions: ['.xc'], + type: 'programming' + }, + fortran: { + extensions: ['.f', '.f77', '.for', '.fpp'], + type: 'programming' + }, + haxe: { + extensions: ['.hx', '.hxsl'], + type: 'programming' + }, + rust: { + extensions: ['.rs', '.rs.in'], + type: 'programming', + aliases: ['rs'] + }, + 'cabal config': { + extensions: ['.cabal'], + type: 'data', + aliases: ['Cabal'] + }, + netlogo: { + extensions: ['.nlogo'], + type: 'programming' + }, + 'imagej macro': { + extensions: ['.ijm'], + type: 'programming', + aliases: ['ijm'] + }, + autohotkey: { + extensions: ['.ahk', '.ahkl'], + type: 'programming', + aliases: ['ahk'] + }, + haproxy: { + extensions: ['.cfg'], + type: 'data' + }, + zil: { + extensions: ['.zil', '.mud'], + type: 'programming' + }, + 'abap cds': { + extensions: ['.asddls'], + type: 'programming' + }, + 'html+razor': { + extensions: ['.cshtml', '.razor'], + type: 'markup', + aliases: ['razor'] + }, + boo: { + extensions: ['.boo'], + type: 'programming' + }, + smarty: { + extensions: ['.tpl'], + type: 'programming' + }, + mako: { + extensions: ['.mako', '.mao'], + type: 'programming' + }, + nearley: { + extensions: ['.ne', '.nearley'], + type: 'programming' + }, + llvm: { + extensions: ['.ll'], + type: 'programming' + }, + piglatin: { + extensions: ['.pig'], + type: 'programming' + }, + 'unix assembly': { + extensions: ['.s', '.ms'], + type: 'programming', + aliases: ['gas', 'gnu asm', 'unix asm'] + }, + metal: { + extensions: ['.metal'], + type: 'programming' + }, + shen: { + extensions: ['.shen'], + type: 'programming' + }, + labview: { + extensions: ['.lvproj', '.lvclass', '.lvlib'], + type: 'programming' + }, + nemerle: { + extensions: ['.n'], + type: 'programming' + }, + rpc: { + extensions: ['.x'], + type: 'programming', + aliases: ['rpcgen', 'oncrpc', 'xdr'] + }, + 'python traceback': { + extensions: ['.pytb'], + type: 'data' + }, + clojure: { + extensions: ['.clj', '.bb', '.boot', '.cl2', '.cljc', '.cljs', '.cljs.hl', '.cljscm', '.cljx', '.hic'], + type: 'programming' + }, + eiffel: { + extensions: ['.e'], + type: 'programming' + }, + genie: { + extensions: ['.gs'], + type: 'programming' + }, + shaderlab: { + extensions: ['.shader'], + type: 'programming' + }, + makefile: { + extensions: ['.mak', '.d', '.make', '.makefile', '.mk', '.mkfile'], + type: 'programming', + aliases: ['bsdmake', 'make', 'mf'] + }, + rouge: { + extensions: ['.rg'], + type: 'programming' + }, + dircolors: { + extensions: ['.dircolors'], + type: 'data' + }, + ncl: { + extensions: ['.ncl'], + type: 'programming' + }, + puppet: { + extensions: ['.pp'], + type: 'programming' + }, + sparql: { + extensions: ['.sparql', '.rq'], + type: 'data' + }, + 'qt script': { + extensions: ['.qs'], + type: 'programming' + }, + golo: { + extensions: ['.golo'], + type: 'programming' + }, + lark: { + extensions: ['.lark'], + type: 'data' + }, + nginx: { + extensions: ['.nginx', '.nginxconf', '.vhost'], + type: 'data', + aliases: ['nginx configuration file'] + }, + wikitext: { + extensions: ['.mediawiki', '.wiki', '.wikitext'], + type: 'prose', + aliases: ['mediawiki', 'wiki'] + }, + ceylon: { + extensions: ['.ceylon'], + type: 'programming' + }, + stan: { + extensions: ['.stan'], + type: 'programming' + }, + cmake: { + extensions: ['.cmake', '.cmake.in'], + type: 'programming' + }, + loomscript: { + extensions: ['.ls'], + type: 'programming' + }, + ooc: { + extensions: ['.ooc'], + type: 'programming' + }, + json: { + extensions: [ + '.json', + '.4DForm', + '.4DProject', + '.avsc', + '.geojson', + '.gltf', + '.har', + '.ice', + '.JSON-tmLanguage', + '.json.example', + '.jsonl', + '.mcmeta', + '.sarif', + '.tact', + '.tfstate', + '.tfstate.backup', + '.topojson', + '.webapp', + '.webmanifest', + '.yy', + '.yyp' + ], + type: 'data', + aliases: ['geojson', 'jsonl', 'sarif', 'topojson'] + }, + formatted: { + extensions: ['.for', '.eam.fs'], + type: 'data' + }, + 'html+eex': { + extensions: ['.html.eex', '.heex', '.leex'], + type: 'markup', + aliases: ['eex', 'heex', 'leex'] + }, + q: { + extensions: ['.q'], + type: 'programming' + }, + pike: { + extensions: ['.pike', '.pmod'], + type: 'programming' + }, + robotframework: { + extensions: ['.robot', '.resource'], + type: 'programming' + }, + gedcom: { + extensions: ['.ged'], + type: 'data' + }, + rdoc: { + extensions: ['.rdoc'], + type: 'prose' + }, + 'literate agda': { + extensions: ['.lagda'], + type: 'programming' + }, + dm: { + extensions: ['.dm'], + type: 'programming', + aliases: ['byond'] + }, + ec: { + extensions: ['.ec', '.eh'], + type: 'programming' + }, + kusto: { + extensions: ['.csl', '.kql'], + type: 'data' + }, + "cap'n proto": { + extensions: ['.capnp'], + type: 'programming' + }, + 'darcs patch': { + extensions: ['.darcspatch', '.dpatch'], + type: 'data', + aliases: ['dpatch'] + }, + 'srecode template': { + extensions: ['.srt'], + type: 'markup' + }, + factor: { + extensions: ['.factor'], + type: 'programming' + }, + tsx: { + extensions: ['.tsx'], + type: 'programming' + }, + css: { + extensions: ['.css'], + type: 'markup' + }, + json5: { + extensions: ['.json5'], + type: 'data' + }, + 'jison lex': { + extensions: ['.jisonlex'], + type: 'programming' + }, + mtml: { + extensions: ['.mtml'], + type: 'markup' + }, + ballerina: { + extensions: ['.bal'], + type: 'programming' + }, + brainfuck: { + extensions: ['.b', '.bf'], + type: 'programming' + }, + swift: { + extensions: ['.swift'], + type: 'programming' + }, + gherkin: { + extensions: ['.feature', '.story'], + type: 'programming', + aliases: ['cucumber'] + }, + textile: { + extensions: ['.textile'], + type: 'prose' + }, + mql4: { + extensions: ['.mq4', '.mqh'], + type: 'programming' + }, + ejs: { + extensions: ['.ejs', '.ect', '.ejs.t', '.jst'], + type: 'markup' + }, + 'asn.1': { + extensions: ['.asn', '.asn1'], + type: 'data' + }, + parrot: { + extensions: ['.parrot'], + type: 'programming' + }, + plantuml: { + extensions: ['.puml', '.iuml', '.plantuml'], + type: 'data' + }, + brightscript: { + extensions: ['.brs'], + type: 'programming' + }, + slim: { + extensions: ['.slim'], + type: 'markup' + }, + svg: { + extensions: ['.svg'], + type: 'data' + }, + e: { + extensions: ['.e'], + type: 'programming' + }, + text: { + extensions: ['.txt', '.fr', '.nb', '.ncl', '.no'], + type: 'prose', + aliases: ['fundamental', 'plain text'] + }, + 'fortran free form': { + extensions: ['.f90', '.f03', '.f08', '.f95'], + type: 'programming' + }, + grace: { + extensions: ['.grace'], + type: 'programming' + }, + clarion: { + extensions: ['.clw'], + type: 'programming' + }, + 'kicad legacy layout': { + extensions: ['.brd'], + type: 'data' + }, + asymptote: { + extensions: ['.asy'], + type: 'programming' + }, + kotlin: { + extensions: ['.kt', '.ktm', '.kts'], + type: 'programming' + }, + texinfo: { + extensions: ['.texinfo', '.texi', '.txi'], + type: 'prose' + }, + pogoscript: { + extensions: ['.pogo'], + type: 'programming' + }, + xml: { + extensions: [ + '.xml', + '.adml', + '.admx', + '.ant', + '.axaml', + '.axml', + '.builds', + '.ccproj', + '.ccxml', + '.clixml', + '.cproject', + '.cscfg', + '.csdef', + '.csl', + '.csproj', + '.ct', + '.depproj', + '.dita', + '.ditamap', + '.ditaval', + '.dll.config', + '.dotsettings', + '.filters', + '.fsproj', + '.fxml', + '.glade', + '.gml', + '.gmx', + '.gpx', + '.grxml', + '.gst', + '.hzp', + '.iml', + '.ivy', + '.jelly', + '.jsproj', + '.kml', + '.launch', + '.mdpolicy', + '.mjml', + '.mm', + '.mod', + '.mojo', + '.mxml', + '.natvis', + '.ncl', + '.ndproj', + '.nproj', + '.nuspec', + '.odd', + '.osm', + '.pkgproj', + '.pluginspec', + '.proj', + '.props', + '.ps1xml', + '.psc1', + '.pt', + '.qhelp', + '.rdf', + '.res', + '.resx', + '.rs', + '.rss', + '.sch', + '.scxml', + '.sfproj', + '.shproj', + '.slnx', + '.srdf', + '.storyboard', + '.sublime-snippet', + '.sw', + '.targets', + '.tml', + '.ts', + '.tsx', + '.typ', + '.ui', + '.urdf', + '.ux', + '.vbproj', + '.vcxproj', + '.vsixmanifest', + '.vssettings', + '.vstemplate', + '.vxml', + '.wixproj', + '.workflow', + '.wsdl', + '.wsf', + '.wxi', + '.wxl', + '.wxs', + '.x3d', + '.xacro', + '.xaml', + '.xib', + '.xlf', + '.xliff', + '.xmi', + '.xml.dist', + '.xmp', + '.xproj', + '.xsd', + '.xspec', + '.xul', + '.zcml' + ], + type: 'data', + aliases: ['rss', 'xsd', 'wsdl'] + }, + raml: { + extensions: ['.raml'], + type: 'markup' + }, + flux: { + extensions: ['.fx', '.flux'], + type: 'programming' + }, + nasl: { + extensions: ['.nasl', '.inc'], + type: 'programming' + }, + saltstack: { + extensions: ['.sls'], + type: 'programming', + aliases: ['saltstate', 'salt'] + }, + markdown: { + extensions: [ + '.md', + '.livemd', + '.markdown', + '.mdown', + '.mdwn', + '.mkd', + '.mkdn', + '.mkdown', + '.ronn', + '.scd', + '.workbook' + ], + type: 'prose', + aliases: ['md', 'pandoc'] + }, + starlark: { + extensions: ['.bzl', '.star'], + type: 'programming', + aliases: ['bazel', 'bzl'] + }, + dylan: { + extensions: ['.dylan', '.dyl', '.intr', '.lid'], + type: 'programming' + }, + 'altium designer': { + extensions: ['.OutJob', '.PcbDoc', '.PrjPCB', '.SchDoc'], + type: 'data', + aliases: ['altium'] + }, + mask: { + extensions: ['.mask'], + type: 'markup' + }, + aidl: { + extensions: ['.aidl'], + type: 'programming' + }, + powerbuilder: { + extensions: ['.pbt', '.sra', '.sru', '.srw'], + type: 'programming' + }, + max: { + extensions: ['.maxpat', '.maxhelp', '.maxproj', '.mxt', '.pat'], + type: 'programming', + aliases: ['max/msp', 'maxmsp'] + }, + 'ti program': { + extensions: ['.8xp', '.8xp.txt'], + type: 'programming' + }, + moocode: { + extensions: ['.moo'], + type: 'programming' + }, + sql: { + extensions: ['.sql', '.cql', '.ddl', '.inc', '.mysql', '.prc', '.tab', '.udf', '.viw'], + type: 'data' + }, + dhall: { + extensions: ['.dhall'], + type: 'programming' + }, + befunge: { + extensions: ['.befunge', '.bf'], + type: 'programming' + }, + 'irc log': { + extensions: ['.irclog', '.weechatlog'], + type: 'data', + aliases: ['irc', 'irc logs'] + }, + krl: { + extensions: ['.krl'], + type: 'programming' + }, + 'apollo guidance computer': { + extensions: ['.agc'], + type: 'programming' + }, + ring: { + extensions: ['.ring'], + type: 'programming' + }, + ada: { + extensions: ['.adb', '.ada', '.ads'], + type: 'programming', + aliases: ['ada95', 'ada2005'] + }, + lua: { + extensions: ['.lua', '.fcgi', '.nse', '.p8', '.pd_lua', '.rbxs', '.rockspec', '.wlua'], + type: 'programming' + }, + gams: { + extensions: ['.gms'], + type: 'programming' + }, + csv: { + extensions: ['.csv'], + type: 'data' + }, + asl: { + extensions: ['.asl', '.dsl'], + type: 'programming' + }, + 'graphviz (dot)': { + extensions: ['.dot', '.gv'], + type: 'data' + }, + 'figlet font': { + extensions: ['.flf'], + type: 'data', + aliases: ['FIGfont'] + }, + edn: { + extensions: ['.edn'], + type: 'data' + }, + txl: { + extensions: ['.txl'], + type: 'programming' + }, + roff: { + extensions: [ + '.roff', + '.1', + '.1in', + '.1m', + '.1x', + '.2', + '.3', + '.3in', + '.3m', + '.3p', + '.3pm', + '.3qt', + '.3x', + '.4', + '.5', + '.6', + '.7', + '.8', + '.9', + '.l', + '.man', + '.mdoc', + '.me', + '.ms', + '.n', + '.nr', + '.rno', + '.tmac' + ], + type: 'markup', + aliases: ['groff', 'man', 'manpage', 'man page', 'man-page', 'mdoc', 'nroff', 'troff'] + }, + idl: { + extensions: ['.pro', '.dlm'], + type: 'programming' + }, + neon: { + extensions: ['.neon'], + type: 'data', + aliases: ['nette object notation', 'ne-on'] + }, + 'rich text format': { + extensions: ['.rtf'], + type: 'markup' + }, + 'peg.js': { + extensions: ['.pegjs', '.peggy'], + type: 'programming' + }, + glyph: { + extensions: ['.glf'], + type: 'programming' + }, + io: { + extensions: ['.io'], + type: 'programming' + }, + nsis: { + extensions: ['.nsi', '.nsh'], + type: 'programming' + }, + papyrus: { + extensions: ['.psc'], + type: 'programming' + }, + 'raw token data': { + extensions: ['.raw'], + type: 'data', + aliases: ['raw'] + }, + 'windows registry entries': { + extensions: ['.reg'], + type: 'data' + }, + zephir: { + extensions: ['.zep'], + type: 'programming' + }, + 'objective-c++': { + extensions: ['.mm'], + type: 'programming', + aliases: ['obj-c++', 'objc++', 'objectivec++'] + }, + wisp: { + extensions: ['.wisp'], + type: 'programming' + }, + 'protocol buffer': { + extensions: ['.proto'], + type: 'data', + aliases: ['proto', 'protobuf', 'Protocol Buffers'] + }, + 'object data instance notation': { + extensions: ['.odin'], + type: 'data' + }, + modelica: { + extensions: ['.mo'], + type: 'programming' + }, + easybuild: { + extensions: ['.eb'], + type: 'data' + }, + 'web ontology language': { + extensions: ['.owl'], + type: 'data' + }, + sage: { + extensions: ['.sage', '.sagews'], + type: 'programming' + }, + basic: { + extensions: ['.bas'], + type: 'programming' + }, + smt: { + extensions: ['.smt2', '.smt', '.z3'], + type: 'programming' + }, + tea: { + extensions: ['.tea'], + type: 'markup' + }, + powershell: { + extensions: ['.ps1', '.psd1', '.psm1'], + type: 'programming', + aliases: ['posh', 'pwsh'] + }, + boogie: { + extensions: ['.bpl'], + type: 'programming' + }, + maxscript: { + extensions: ['.ms', '.mcr'], + type: 'programming' + }, + gaml: { + extensions: ['.gaml'], + type: 'programming' + }, + vbscript: { + extensions: ['.vbs'], + type: 'programming' + }, + antlr: { + extensions: ['.g4'], + type: 'programming' + }, + verilog: { + extensions: ['.v', '.veo'], + type: 'programming' + }, + limbo: { + extensions: ['.b', '.m'], + type: 'programming' + }, + j: { + extensions: ['.ijs'], + type: 'programming' + }, + fennel: { + extensions: ['.fnl'], + type: 'programming' + }, + tla: { + extensions: ['.tla'], + type: 'programming' + }, + eq: { + extensions: ['.eq'], + type: 'programming' + }, + 'igor pro': { + extensions: ['.ipf'], + type: 'programming', + aliases: ['igor', 'igorpro'] + }, + 'regular expression': { + extensions: ['.regexp', '.regex'], + type: 'data', + aliases: ['regexp', 'regex'] + }, + apacheconf: { + extensions: ['.apacheconf', '.vhost'], + type: 'data', + aliases: ['aconf', 'apache'] + }, + objdump: { + extensions: ['.objdump'], + type: 'data' + }, + pickle: { + extensions: ['.pkl'], + type: 'data' + }, + cweb: { + extensions: ['.w'], + type: 'programming' + }, + plsql: { + extensions: [ + '.pls', + '.bdy', + '.ddl', + '.fnc', + '.pck', + '.pkb', + '.pks', + '.plb', + '.plsql', + '.prc', + '.spc', + '.sql', + '.tpb', + '.tps', + '.trg', + '.vw' + ], + type: 'programming' + }, + shellsession: { + extensions: ['.sh-session'], + type: 'programming', + aliases: ['bash session', 'console'] + }, + x10: { + extensions: ['.x10'], + type: 'programming', + aliases: ['xten'] + }, + thrift: { + extensions: ['.thrift'], + type: 'programming' + }, + 'microsoft visual studio solution': { + extensions: ['.sln'], + type: 'data' + }, + freemarker: { + extensions: ['.ftl'], + type: 'programming', + aliases: ['ftl'] + }, + creole: { + extensions: ['.creole'], + type: 'prose' + }, + python: { + extensions: [ + '.py', + '.cgi', + '.fcgi', + '.gyp', + '.gypi', + '.lmi', + '.py3', + '.pyde', + '.pyi', + '.pyp', + '.pyt', + '.pyw', + '.rpy', + '.spec', + '.tac', + '.wsgi', + '.xpy' + ], + type: 'programming', + aliases: ['python3', 'rusthon'] + }, + livescript: { + extensions: ['.ls', '._ls'], + type: 'programming', + aliases: ['live-script', 'ls'] + }, + numpy: { + extensions: ['.numpy', '.numpyw', '.numsc'], + type: 'programming' + }, + objectscript: { + extensions: ['.cls'], + type: 'programming' + }, + 'jest snapshot': { + extensions: ['.snap'], + type: 'data' + }, + 'unified parallel c': { + extensions: ['.upc'], + type: 'programming' + }, + 'openstep property list': { + extensions: ['.plist', '.glyphs'], + type: 'data' + }, + 'conll-u': { + extensions: ['.conllu', '.conll'], + type: 'data', + aliases: ['CoNLL', 'CoNLL-X'] + }, + frege: { + extensions: ['.fr'], + type: 'programming' + }, + toml: { + extensions: ['.toml'], + type: 'data' + }, + haml: { + extensions: ['.haml', '.haml.deface'], + type: 'markup' + }, + jsoniq: { + extensions: ['.jq'], + type: 'programming' + }, + picolisp: { + extensions: ['.l'], + type: 'programming' + }, + collada: { + extensions: ['.dae'], + type: 'data' + }, + erlang: { + extensions: ['.erl', '.app', '.app.src', '.es', '.escript', '.hrl', '.xrl', '.yrl'], + type: 'programming' + }, + 'ignore list': { + extensions: ['.gitignore'], + type: 'data', + aliases: ['ignore', 'gitignore', 'git-ignore'] + }, + ini: { + extensions: ['.ini', '.cfg', '.cnf', '.dof', '.frm', '.lektorproject', '.prefs', '.pro', '.properties', '.url'], + type: 'data', + aliases: ['dosini'] + }, + '4d': { + extensions: ['.4dm'], + type: 'programming' + }, + freebasic: { + extensions: ['.bi', '.bas'], + type: 'programming', + aliases: ['fb'] + }, + 'classic asp': { + extensions: ['.asp'], + type: 'programming', + aliases: ['asp'] + }, + 'c-objdump': { + extensions: ['.c-objdump'], + type: 'data' + }, + gradle: { + extensions: ['.gradle'], + type: 'data' + }, + dataweave: { + extensions: ['.dwl'], + type: 'programming' + }, + matlab: { + extensions: ['.matlab', '.m'], + type: 'programming', + aliases: ['octave'] + }, + bicep: { + extensions: ['.bicep', '.bicepparam'], + type: 'programming' + }, + 'e-mail': { + extensions: ['.eml', '.mbox'], + type: 'data', + aliases: ['email', 'eml', 'mail', 'mbox'] + }, + rebol: { + extensions: ['.reb', '.r', '.r2', '.r3', '.rebol'], + type: 'programming' + }, + r: { + extensions: ['.r', '.rd', '.rsx'], + type: 'programming', + aliases: ['Rscript', 'splus'] + }, + restructuredtext: { + extensions: ['.rst', '.rest', '.rest.txt', '.rst.txt'], + type: 'prose', + aliases: ['rst'] + }, + pug: { + extensions: ['.jade', '.pug'], + type: 'markup' + }, + ecl: { + extensions: ['.ecl', '.eclxml'], + type: 'programming' + }, + myghty: { + extensions: ['.myt'], + type: 'programming' + }, + 'game maker language': { + extensions: ['.gml'], + type: 'programming' + }, + redcode: { + extensions: ['.cw'], + type: 'programming' + }, + 'x pixmap': { + extensions: ['.xpm', '.pm'], + type: 'data', + aliases: ['xpm'] + }, + 'propeller spin': { + extensions: ['.spin'], + type: 'programming' + }, + xslt: { + extensions: ['.xslt', '.xsl'], + type: 'programming', + aliases: ['xsl'] + }, + dart: { + extensions: ['.dart'], + type: 'programming' + }, + astro: { + extensions: ['.astro'], + type: 'markup' + }, + java: { + extensions: ['.java', '.jav', '.jsh'], + type: 'programming' + }, + 'groovy server pages': { + extensions: ['.gsp'], + type: 'programming', + aliases: ['gsp', 'java server page'] + }, + postscript: { + extensions: ['.ps', '.eps', '.epsi', '.pfa'], + type: 'markup', + aliases: ['postscr'] + }, + bibtex: { + extensions: ['.bib', '.bibtex'], + type: 'markup' + }, + cython: { + extensions: ['.pyx', '.pxd', '.pxi'], + type: 'programming', + aliases: ['pyrex'] + }, + gosu: { + extensions: ['.gs', '.gst', '.gsx', '.vark'], + type: 'programming' + }, + ston: { + extensions: ['.ston'], + type: 'data' + }, + renderscript: { + extensions: ['.rs', '.rsh'], + type: 'programming' + }, + lfe: { + extensions: ['.lfe'], + type: 'programming' + }, + ampl: { + extensions: ['.ampl', '.mod'], + type: 'programming' + }, + beef: { + extensions: ['.bf'], + type: 'programming' + }, + 'cue sheet': { + extensions: ['.cue'], + type: 'data' + }, + 'objective-c': { + extensions: ['.m', '.h'], + type: 'programming', + aliases: ['obj-c', 'objc', 'objectivec'] + }, + scaml: { + extensions: ['.scaml'], + type: 'markup' + }, + slice: { + extensions: ['.ice'], + type: 'programming' + }, + zig: { + extensions: ['.zig', '.zig.zon'], + type: 'programming' + }, + 'open policy agent': { + extensions: ['.rego'], + type: 'programming' + }, + opal: { + extensions: ['.opal'], + type: 'programming' + }, + macaulay2: { + extensions: ['.m2'], + type: 'programming', + aliases: ['m2'] + }, + twig: { + extensions: ['.twig'], + type: 'markup' + }, + autoit: { + extensions: ['.au3'], + type: 'programming', + aliases: ['au3', 'AutoIt3', 'AutoItScript'] + }, + mupad: { + extensions: ['.mu'], + type: 'programming' + }, + coldfusion: { + extensions: ['.cfm', '.cfml'], + type: 'programming', + aliases: ['cfm', 'cfml', 'coldfusion html'] + }, + 'valve data format': { + extensions: ['.vdf'], + type: 'data', + aliases: ['keyvalues', 'vdf'] + }, + sourcepawn: { + extensions: ['.sp', '.inc'], + type: 'programming', + aliases: ['sourcemod'] + }, + p4: { + extensions: ['.p4'], + type: 'programming' + }, + 'spline font database': { + extensions: ['.sfd'], + type: 'data' + }, + c: { + extensions: ['.c', '.cats', '.h', '.h.in', '.idc'], + type: 'programming' + }, + 'xml property list': { + extensions: ['.plist', '.stTheme', '.tmCommand', '.tmLanguage', '.tmPreferences', '.tmSnippet', '.tmTheme'], + type: 'data' + }, + blitzmax: { + extensions: ['.bmx'], + type: 'programming', + aliases: ['bmax'] + }, + 'literate coffeescript': { + extensions: ['.litcoffee', '.coffee.md'], + type: 'programming', + aliases: ['litcoffee'] + }, + moonscript: { + extensions: ['.moon'], + type: 'programming' + }, + zenscript: { + extensions: ['.zs'], + type: 'programming' + }, + desktop: { + extensions: ['.desktop', '.desktop.in', '.service'], + type: 'data' + }, + angelscript: { + extensions: ['.as', '.angelscript'], + type: 'programming' + }, + 'csound score': { + extensions: ['.sco'], + type: 'programming', + aliases: ['csound-sco'] + }, + scss: { + extensions: ['.scss'], + type: 'markup' + }, + eagle: { + extensions: ['.sch', '.brd'], + type: 'data' + }, + jsonld: { + extensions: ['.jsonld'], + type: 'data' + }, + 'microsoft developer studio project': { + extensions: ['.dsp'], + type: 'data' + }, + liquid: { + extensions: ['.liquid'], + type: 'markup' + }, + yara: { + extensions: ['.yar', '.yara'], + type: 'programming' + }, + yasnippet: { + extensions: ['.yasnippet'], + type: 'markup', + aliases: ['snippet', 'yas'] + }, + qml: { + extensions: ['.qml', '.qbs'], + type: 'programming' + }, + newlisp: { + extensions: ['.nl', '.lisp', '.lsp'], + type: 'programming' + }, + m4: { + extensions: ['.m4', '.mc'], + type: 'programming' + }, + 'gcc machine description': { + extensions: ['.md'], + type: 'programming' + }, + odin: { + extensions: ['.odin'], + type: 'programming', + aliases: ['odinlang', 'odin-lang'] + }, + 'subrip text': { + extensions: ['.srt'], + type: 'data' + }, + nesc: { + extensions: ['.nc'], + type: 'programming' + }, + isabelle: { + extensions: ['.thy'], + type: 'programming' + }, + jsonnet: { + extensions: ['.jsonnet', '.libsonnet'], + type: 'programming' + }, + purebasic: { + extensions: ['.pb', '.pbi'], + type: 'programming' + }, + proguard: { + extensions: ['.pro'], + type: 'data' + }, + nunjucks: { + extensions: ['.njk'], + type: 'markup', + aliases: ['njk'] + }, + stringtemplate: { + extensions: ['.st'], + type: 'markup' + }, + 'roff manpage': { + extensions: [ + '.1', + '.1in', + '.1m', + '.1x', + '.2', + '.3', + '.3in', + '.3m', + '.3p', + '.3pm', + '.3qt', + '.3x', + '.4', + '.5', + '.6', + '.7', + '.8', + '.9', + '.man', + '.mdoc' + ], + type: 'markup' + }, + 'vim snippet': { + extensions: ['.snip', '.snippet', '.snippets'], + type: 'markup', + aliases: ['SnipMate', 'UltiSnip', 'UltiSnips', 'NeoSnippet'] + }, + 'html+erb': { + extensions: ['.erb', '.erb.deface', '.rhtml'], + type: 'markup', + aliases: ['erb', 'rhtml', 'html+ruby'] + }, + fluent: { + extensions: ['.ftl'], + type: 'programming' + }, + turtle: { + extensions: ['.ttl'], + type: 'data' + }, + 'objective-j': { + extensions: ['.j', '.sj'], + type: 'programming', + aliases: ['obj-j', 'objectivej', 'objj'] + }, + 'kaitai struct': { + extensions: ['.ksy'], + type: 'programming', + aliases: ['ksy'] + }, + scala: { + extensions: ['.scala', '.kojo', '.sbt', '.sc'], + type: 'programming' + }, + sas: { + extensions: ['.sas'], + type: 'programming' + }, + zeek: { + extensions: ['.zeek', '.bro'], + type: 'programming', + aliases: ['bro'] + }, + vba: { + extensions: ['.bas', '.cls', '.frm', '.vba'], + type: 'programming', + aliases: ['visual basic for applications'] + }, + go: { + extensions: ['.go'], + type: 'programming', + aliases: ['golang'] + }, + php: { + extensions: ['.php', '.aw', '.ctp', '.fcgi', '.inc', '.php3', '.php4', '.php5', '.phps', '.phpt'], + type: 'programming', + aliases: ['inc'] + }, + smali: { + extensions: ['.smali'], + type: 'programming' + }, + gnuplot: { + extensions: ['.gp', '.gnu', '.gnuplot', '.p', '.plot', '.plt'], + type: 'programming' + }, + fish: { + extensions: ['.fish'], + type: 'programming' + }, + 'selinux policy': { + extensions: ['.te'], + type: 'data', + aliases: ['SELinux Kernel Policy Language', 'sepolicy'] + }, + tcl: { + extensions: ['.tcl', '.adp', '.sdc', '.tcl.in', '.tm', '.xdc'], + type: 'programming', + aliases: ['sdc', 'xdc'] + }, + webvtt: { + extensions: ['.vtt'], + type: 'data', + aliases: ['vtt'] + }, + 'graph modeling language': { + extensions: ['.gml'], + type: 'data' + }, + netlinx: { + extensions: ['.axs', '.axi'], + type: 'programming' + }, + fancy: { + extensions: ['.fy', '.fancypack'], + type: 'programming' + }, + 'edje data collection': { + extensions: ['.edc'], + type: 'data' + }, + rascal: { + extensions: ['.rsc'], + type: 'programming' + }, + vue: { + extensions: ['.vue'], + type: 'markup' + }, + chuck: { + extensions: ['.ck'], + type: 'programming' + }, + nwscript: { + extensions: ['.nss'], + type: 'programming' + }, + eclipse: { + extensions: ['.ecl'], + type: 'programming' + }, + 'pod 6': { + extensions: ['.pod', '.pod6'], + type: 'prose' + }, + rescript: { + extensions: ['.res', '.resi'], + type: 'programming' + }, + idris: { + extensions: ['.idr', '.lidr'], + type: 'programming' + }, + hy: { + extensions: ['.hy'], + type: 'programming', + aliases: ['hylang'] + }, + apl: { + extensions: ['.apl', '.dyalog'], + type: 'programming' + }, + hlsl: { + extensions: ['.hlsl', '.cginc', '.fx', '.fxh', '.hlsli'], + type: 'programming' + }, + csound: { + extensions: ['.orc', '.udo'], + type: 'programming', + aliases: ['csound-orc'] + }, + genshi: { + extensions: ['.kid'], + type: 'programming', + aliases: ['xml+genshi', 'xml+kid'] + }, + elm: { + extensions: ['.elm'], + type: 'programming' + }, + swig: { + extensions: ['.i'], + type: 'programming' + }, + reason: { + extensions: ['.re', '.rei'], + type: 'programming' + }, + processing: { + extensions: ['.pde'], + type: 'programming' + }, + 'common workflow language': { + extensions: ['.cwl'], + type: 'programming', + aliases: ['cwl'] + }, + mustache: { + extensions: ['.mustache'], + type: 'markup' + }, + 'asp.net': { + extensions: ['.asax', '.ascx', '.ashx', '.asmx', '.aspx', '.axd'], + type: 'programming', + aliases: ['aspx', 'aspx-vb'] + }, + rexx: { + extensions: ['.rexx', '.pprx', '.rex'], + type: 'programming', + aliases: ['arexx'] + }, + lsl: { + extensions: ['.lsl', '.lslp'], + type: 'programming' + }, + 'pov-ray sdl': { + extensions: ['.pov', '.inc'], + type: 'programming', + aliases: ['pov-ray', 'povray'] + }, + pep8: { + extensions: ['.pep'], + type: 'programming' + }, + 'ags script': { + extensions: ['.asc', '.ash'], + type: 'programming', + aliases: ['ags'] + }, + dockerfile: { + extensions: ['.dockerfile', '.containerfile'], + type: 'programming', + aliases: ['Containerfile'] + }, + muf: { + extensions: ['.muf', '.m'], + type: 'programming' + }, + javascript: { + extensions: [ + '.js', + '._js', + '.bones', + '.cjs', + '.es', + '.es6', + '.frag', + '.gs', + '.jake', + '.javascript', + '.jsb', + '.jscad', + '.jsfl', + '.jslib', + '.jsm', + '.jspre', + '.jss', + '.jsx', + '.mjs', + '.njs', + '.pac', + '.sjs', + '.ssjs', + '.xsjs', + '.xsjslib' + ], + type: 'programming', + aliases: ['js', 'node'] + }, + 'type language': { + extensions: ['.tl'], + type: 'data', + aliases: ['tl'] + }, + runoff: { + extensions: ['.rnh', '.rno'], + type: 'markup' + }, + wdl: { + extensions: ['.wdl'], + type: 'programming', + aliases: ['Workflow Description Language'] + }, + blitzbasic: { + extensions: ['.bb', '.decls'], + type: 'programming', + aliases: ['b3d', 'blitz3d', 'blitzplus', 'bplus'] + }, + actionscript: { + extensions: ['.as'], + type: 'programming', + aliases: ['actionscript 3', 'actionscript3', 'as3'] + }, + pic: { + extensions: ['.pic', '.chem'], + type: 'markup', + aliases: ['pikchr'] + }, + xbase: { + extensions: ['.prg', '.ch', '.prw'], + type: 'programming', + aliases: ['advpl', 'clipper', 'foxpro'] + }, + sed: { + extensions: ['.sed'], + type: 'programming' + }, + 'gettext catalog': { + extensions: ['.po', '.pot'], + type: 'prose', + aliases: ['pot'] + }, + cool: { + extensions: ['.cl'], + type: 'programming' + }, + 'java server pages': { + extensions: ['.jsp', '.tag'], + type: 'programming', + aliases: ['jsp'] + }, + ocaml: { + extensions: ['.ml', '.eliom', '.eliomi', '.ml4', '.mli', '.mll', '.mly'], + type: 'programming' + }, + bison: { + extensions: ['.bison'], + type: 'programming' + }, + stylus: { + extensions: ['.styl'], + type: 'markup' + }, + click: { + extensions: ['.click'], + type: 'programming' + }, + marko: { + extensions: ['.marko'], + type: 'markup', + aliases: ['markojs'] + }, + clips: { + extensions: ['.clp'], + type: 'programming' + }, + wollok: { + extensions: ['.wlk'], + type: 'programming' + }, + sqf: { + extensions: ['.sqf', '.hqf'], + type: 'programming' + }, + al: { + extensions: ['.al'], + type: 'programming' + }, + alloy: { + extensions: ['.als'], + type: 'programming' + }, + futhark: { + extensions: ['.fut'], + type: 'programming' + }, + shell: { + extensions: [ + '.sh', + '.bash', + '.bats', + '.cgi', + '.command', + '.fcgi', + '.ksh', + '.sh.in', + '.tmux', + '.tool', + '.trigger', + '.zsh', + '.zsh-theme' + ], + type: 'programming', + aliases: ['sh', 'shell-script', 'bash', 'zsh', 'envrc'] + }, + codeql: { + extensions: ['.ql', '.qll'], + type: 'programming', + aliases: ['ql'] + }, + 'motorola 68k assembly': { + extensions: ['.asm', '.i', '.inc', '.s', '.x68'], + type: 'programming', + aliases: ['m68k'] + }, + postcss: { + extensions: ['.pcss', '.postcss'], + type: 'markup' + }, + xs: { + extensions: ['.xs'], + type: 'programming' + }, + pascal: { + extensions: ['.pas', '.dfm', '.dpr', '.inc', '.lpr', '.pascal', '.pp'], + type: 'programming', + aliases: ['delphi', 'objectpascal'] + }, + 'html+php': { + extensions: ['.phtml'], + type: 'markup' + }, + bitbake: { + extensions: ['.bb', '.bbappend', '.bbclass', '.inc'], + type: 'programming' + }, + 'kicad schematic': { + extensions: ['.kicad_sch', '.kicad_sym', '.sch'], + type: 'data', + aliases: ['eeschema schematic'] + }, + 'mirc script': { + extensions: ['.mrc'], + type: 'programming' + }, + emberscript: { + extensions: ['.em', '.emberscript'], + type: 'programming' + }, + oxygene: { + extensions: ['.oxygene'], + type: 'programming' + }, + awk: { + extensions: ['.awk', '.auk', '.gawk', '.mawk', '.nawk'], + type: 'programming' + }, + jinja: { + extensions: ['.jinja', '.j2', '.jinja2'], + type: 'markup', + aliases: ['django', 'html+django', 'html+jinja', 'htmldjango'] + }, + augeas: { + extensions: ['.aug'], + type: 'programming' + }, + webidl: { + extensions: ['.webidl'], + type: 'programming' + }, + 'opentype feature file': { + extensions: ['.fea'], + type: 'data', + aliases: ['AFDKO'] + }, + 'emacs lisp': { + extensions: ['.el', '.emacs', '.emacs.desktop'], + type: 'programming', + aliases: ['elisp', 'emacs'] + }, + 'gentoo eclass': { + extensions: ['.eclass'], + type: 'programming' + }, + pony: { + extensions: ['.pony'], + type: 'programming' + }, + chapel: { + extensions: ['.chpl'], + type: 'programming', + aliases: ['chpl'] + }, + ats: { + extensions: ['.dats', '.hats', '.sats'], + type: 'programming', + aliases: ['ats2'] + }, + 'git config': { + extensions: ['.gitconfig'], + type: 'data', + aliases: ['gitconfig', 'gitmodules'] + }, + 'd-objdump': { + extensions: ['.d-objdump'], + type: 'data' + }, + hxml: { + extensions: ['.hxml'], + type: 'data' + }, + 'dns zone': { + extensions: ['.zone', '.arpa'], + type: 'data' + }, + handlebars: { + extensions: ['.handlebars', '.hbs'], + type: 'markup', + aliases: ['hbs', 'htmlbars'] + }, + sieve: { + extensions: ['.sieve'], + type: 'programming' + }, + sugarss: { + extensions: ['.sss'], + type: 'markup' + }, + 'csound document': { + extensions: ['.csd'], + type: 'programming', + aliases: ['csound-csd'] + }, + tsv: { + extensions: ['.tsv', '.vcf'], + type: 'data', + aliases: ['tab-seperated values'] + }, + jasmin: { + extensions: ['.j'], + type: 'programming' + }, + 'linux kernel module': { + extensions: ['.mod'], + type: 'data' + }, + supercollider: { + extensions: ['.sc', '.scd'], + type: 'programming' + }, + 'x bitmap': { + extensions: ['.xbm'], + type: 'data', + aliases: ['xbm'] + }, + opencl: { + extensions: ['.cl', '.opencl'], + type: 'programming' + }, + 'literate haskell': { + extensions: ['.lhs'], + type: 'programming', + aliases: ['lhaskell', 'lhs'] + }, + html: { + extensions: ['.html', '.hta', '.htm', '.html.hl', '.inc', '.xht', '.xhtml'], + type: 'markup', + aliases: ['xhtml'] + }, + typescript: { + extensions: ['.ts', '.cts', '.mts'], + type: 'programming', + aliases: ['ts'] + }, + smalltalk: { + extensions: ['.st', '.cs'], + type: 'programming', + aliases: ['squeak'] + }, + cson: { + extensions: ['.cson'], + type: 'data' + }, + riot: { + extensions: ['.riot'], + type: 'markup' + }, + solidity: { + extensions: ['.sol'], + type: 'programming' + }, + volt: { + extensions: ['.volt'], + type: 'programming' + }, + lex: { + extensions: ['.l', '.lex'], + type: 'programming', + aliases: ['flex'] + }, + 'inform 7': { + extensions: ['.ni', '.i7x'], + type: 'programming', + aliases: ['i7', 'inform7'] + }, + yaml: { + extensions: [ + '.yml', + '.mir', + '.reek', + '.rviz', + '.sublime-syntax', + '.syntax', + '.yaml', + '.yaml-tmlanguage', + '.yaml.sed', + '.yml.mysql' + ], + type: 'data', + aliases: ['yml'] + }, + 'avro idl': { + extensions: ['.avdl'], + type: 'data' + }, + omgrofl: { + extensions: ['.omgrofl'], + type: 'programming' + }, + kit: { + extensions: ['.kit'], + type: 'markup' + }, + 'modula-3': { + extensions: ['.i3', '.ig', '.m3', '.mg'], + type: 'programming' + }, + xquery: { + extensions: ['.xquery', '.xq', '.xql', '.xqm', '.xqy'], + type: 'programming' + }, + nu: { + extensions: ['.nu'], + type: 'programming', + aliases: ['nush'] + }, + lasso: { + extensions: ['.lasso', '.las', '.lasso8', '.lasso9'], + type: 'programming', + aliases: ['lassoscript'] + }, + openscad: { + extensions: ['.scad'], + type: 'programming' + }, + vala: { + extensions: ['.vala', '.vapi'], + type: 'programming' + }, + lookml: { + extensions: ['.lkml', '.lookml'], + type: 'programming' + }, + hyphy: { + extensions: ['.bf'], + type: 'programming' + }, + openqasm: { + extensions: ['.qasm'], + type: 'programming' + }, + 'wavefront material': { + extensions: ['.mtl'], + type: 'data' + }, + 'linker script': { + extensions: ['.ld', '.lds', '.x'], + type: 'programming' + }, + nl: { + extensions: ['.nl'], + type: 'data' + }, + dogescript: { + extensions: ['.djs'], + type: 'programming' + }, + 'adobe font metrics': { + extensions: ['.afm'], + type: 'data', + aliases: ['acfm', 'adobe composite font metrics', 'adobe multiple font metrics', 'amfm'] + }, + 'gerber image': { + extensions: [ + '.gbr', + '.cmp', + '.gbl', + '.gbo', + '.gbp', + '.gbs', + '.gko', + '.gml', + '.gpb', + '.gpt', + '.gtl', + '.gto', + '.gtp', + '.gts', + '.ncl', + '.sol' + ], + type: 'data', + aliases: ['rs-274x'] + }, + nit: { + extensions: ['.nit'], + type: 'programming' + }, + 'grammatical framework': { + extensions: ['.gf'], + type: 'programming', + aliases: ['gf'] + }, + pan: { + extensions: ['.pan'], + type: 'programming' + }, + self: { + extensions: ['.self'], + type: 'programming' + }, + purescript: { + extensions: ['.purs'], + type: 'programming' + }, + latte: { + extensions: ['.latte'], + type: 'markup' + }, + blade: { + extensions: ['.blade', '.blade.php'], + type: 'markup' + }, + lolcode: { + extensions: ['.lol'], + type: 'programming' + }, + 'coldfusion cfc': { + extensions: ['.cfc'], + type: 'programming', + aliases: ['cfc'] + }, + mql5: { + extensions: ['.mq5', '.mqh'], + type: 'programming' + }, + 'wavefront object': { + extensions: ['.obj'], + type: 'data' + }, + cuda: { + extensions: ['.cu', '.cuh'], + type: 'programming' + }, + smpl: { + extensions: ['.cocci'], + type: 'programming', + aliases: ['coccinelle'] + }, + crystal: { + extensions: ['.cr'], + type: 'programming' + }, + 'netlinx+erb': { + extensions: ['.axs.erb', '.axi.erb'], + type: 'programming' + }, + xtend: { + extensions: ['.xtend'], + type: 'programming' + }, + mcfunction: { + extensions: ['.mcfunction'], + type: 'programming' + }, + 'f#': { + extensions: ['.fs', '.fsi', '.fsx'], + type: 'programming', + aliases: ['fsharp'] + }, + gdscript: { + extensions: ['.gd'], + type: 'programming' + }, + dtrace: { + extensions: ['.d'], + type: 'programming', + aliases: ['dtrace-script'] + }, + gap: { + extensions: ['.g', '.gap', '.gd', '.gi', '.tst'], + type: 'programming' + }, + oz: { + extensions: ['.oz'], + type: 'programming' + }, + "ren'py": { + extensions: ['.rpy'], + type: 'programming', + aliases: ['renpy'] + }, + elixir: { + extensions: ['.ex', '.exs'], + type: 'programming' + }, + webassembly: { + extensions: ['.wast', '.wat'], + type: 'programming', + aliases: ['wast', 'wasm'] + }, + lean: { + extensions: ['.lean', '.hlean'], + type: 'programming' + }, + lilypond: { + extensions: ['.ly', '.ily'], + type: 'programming' + }, + squirrel: { + extensions: ['.nut'], + type: 'programming' + }, + asciidoc: { + extensions: ['.asciidoc', '.adoc', '.asc'], + type: 'prose' + }, + yacc: { + extensions: ['.y', '.yacc', '.yy'], + type: 'programming' + }, + 'filebench wml': { + extensions: ['.f'], + type: 'programming' + }, + dafny: { + extensions: ['.dfy'], + type: 'programming' + }, + plpgsql: { + extensions: ['.pgsql', '.sql'], + type: 'programming' + }, + 'parrot assembly': { + extensions: ['.pasm'], + type: 'programming', + aliases: ['pasm'] + }, + kakounescript: { + extensions: ['.kak'], + type: 'programming', + aliases: ['kak', 'kakscript'] + }, + raku: { + extensions: [ + '.6pl', + '.6pm', + '.nqp', + '.p6', + '.p6l', + '.p6m', + '.pl', + '.pl6', + '.pm', + '.pm6', + '.raku', + '.rakumod', + '.t' + ], + type: 'programming', + aliases: ['perl6', 'perl-6'] + }, + stata: { + extensions: ['.do', '.ado', '.doh', '.ihlp', '.mata', '.matah', '.sthlp'], + type: 'programming' + }, + 'c++': { + extensions: [ + '.cpp', + '.c++', + '.cc', + '.cp', + '.cppm', + '.cxx', + '.h', + '.h++', + '.hh', + '.hpp', + '.hxx', + '.inc', + '.inl', + '.ino', + '.ipp', + '.ixx', + '.re', + '.tcc', + '.tpp', + '.txx' + ], + type: 'programming', + aliases: ['cpp'] + }, + holyc: { + extensions: ['.hc'], + type: 'programming' + }, + mercury: { + extensions: ['.m', '.moo'], + type: 'programming' + }, + 'unity3d asset': { + extensions: ['.anim', '.asset', '.mask', '.mat', '.meta', '.prefab', '.unity'], + type: 'data' + }, + 'json with comments': { + extensions: [ + '.jsonc', + '.code-snippets', + '.code-workspace', + '.sublime-build', + '.sublime-color-scheme', + '.sublime-commands', + '.sublime-completions', + '.sublime-keymap', + '.sublime-macro', + '.sublime-menu', + '.sublime-mousemap', + '.sublime-project', + '.sublime-settings', + '.sublime-theme', + '.sublime-workspace', + '.sublime_metrics', + '.sublime_session' + ], + type: 'data', + aliases: ['jsonc'] + }, + abnf: { + extensions: ['.abnf'], + type: 'data' + }, + perl: { + extensions: ['.pl', '.al', '.cgi', '.fcgi', '.perl', '.ph', '.plx', '.pm', '.psgi', '.t'], + type: 'programming', + aliases: ['cperl'] + }, + graphql: { + extensions: ['.graphql', '.gql', '.graphqls'], + type: 'data' + }, + d: { + extensions: ['.d', '.di'], + type: 'programming', + aliases: ['Dlang'] + }, + m: { + extensions: ['.mumps', '.m'], + type: 'programming', + aliases: ['mumps'] + }, + terra: { + extensions: ['.t'], + type: 'programming' + }, + jflex: { + extensions: ['.flex', '.jflex'], + type: 'programming' + }, + cycript: { + extensions: ['.cy'], + type: 'programming' + } +} diff --git a/src/renderer/src/utils/__tests__/markdown.test.ts b/src/renderer/src/utils/__tests__/markdown.test.ts index 7b82816f17..e35550bf49 100644 --- a/src/renderer/src/utils/__tests__/markdown.test.ts +++ b/src/renderer/src/utils/__tests__/markdown.test.ts @@ -146,15 +146,15 @@ describe('markdown', () => { describe('getExtensionByLanguage', () => { // 批量测试语言名称到扩展名的映射 - const testLanguageExtensions = async (testCases: Record) => { + const testLanguageExtensions = (testCases: Record) => { for (const [language, expectedExtension] of Object.entries(testCases)) { - const result = await getExtensionByLanguage(language) + const result = getExtensionByLanguage(language) expect(result).toBe(expectedExtension) } } - it('should return extension for exact language name match', async () => { - await testLanguageExtensions({ + it('should return extension for exact language name match', () => { + testLanguageExtensions({ '4D': '.4dm', 'C#': '.cs', JavaScript: '.js', @@ -166,8 +166,8 @@ describe('markdown', () => { }) }) - it('should return extension for case-insensitive language name match', async () => { - await testLanguageExtensions({ + it('should return extension for case-insensitive language name match', () => { + testLanguageExtensions({ '4d': '.4dm', 'c#': '.cs', javascript: '.js', @@ -179,8 +179,8 @@ describe('markdown', () => { }) }) - it('should return extension for language aliases', async () => { - await testLanguageExtensions({ + it('should return extension for language aliases', () => { + testLanguageExtensions({ js: '.js', node: '.js', 'obj-c++': '.mm', @@ -191,15 +191,15 @@ describe('markdown', () => { }) }) - it('should return fallback extension for unknown languages', async () => { - await testLanguageExtensions({ + it('should return fallback extension for unknown languages', () => { + testLanguageExtensions({ 'unknown-language': '.unknown-language', custom: '.custom' }) }) - it('should handle empty string input', async () => { - await testLanguageExtensions({ + it('should handle empty string input', () => { + testLanguageExtensions({ '': '.' }) }) diff --git a/src/renderer/src/utils/markdown.ts b/src/renderer/src/utils/markdown.ts index 3c409d39b4..a54e3d69d0 100644 --- a/src/renderer/src/utils/markdown.ts +++ b/src/renderer/src/utils/markdown.ts @@ -1,3 +1,4 @@ +import { languages } from '@shared/config/languages' import remarkParse from 'remark-parse' import remarkStringify from 'remark-stringify' import removeMarkdown from 'remove-markdown' @@ -54,16 +55,6 @@ export function removeTrailingDoubleSpaces(markdown: string): string { return markdown.replace(/ {2}$/gm, '') } -const predefinedExtensionMap: Record = { - html: '.html', - javascript: '.js', - typescript: '.ts', - python: '.py', - json: '.json', - markdown: '.md', - text: '.txt' -} - /** * 根据语言名称获取文件扩展名 * - 先精确匹配,再忽略大小写,最后匹配别名 @@ -71,36 +62,26 @@ const predefinedExtensionMap: Record = { * @param language 语言名称 * @returns 文件扩展名 */ -export async function getExtensionByLanguage(language: string): Promise { +export function getExtensionByLanguage(language: string): string { const lowerLanguage = language.toLowerCase() - // 常用的扩展名 - const predefined = predefinedExtensionMap[lowerLanguage] - if (predefined) { - return predefined - } - - const languages = await import('linguist-languages') - // 精确匹配语言名称 - const directMatch = languages[language as keyof typeof languages] as any + const directMatch = languages[language] if (directMatch?.extensions?.[0]) { return directMatch.extensions[0] } // 大小写不敏感的语言名称匹配 for (const [langName, data] of Object.entries(languages)) { - const languageData = data as any - if (langName.toLowerCase() === lowerLanguage && languageData.extensions?.[0]) { - return languageData.extensions[0] + if (langName.toLowerCase() === lowerLanguage && data.extensions?.[0]) { + return data.extensions[0] } } // 通过别名匹配 for (const [, data] of Object.entries(languages)) { - const languageData = data as any - if (languageData.aliases?.includes(lowerLanguage)) { - return languageData.extensions?.[0] || `.${language}` + if (data.aliases?.some((alias) => alias.toLowerCase() === lowerLanguage)) { + return data.extensions?.[0] || `.${language}` } } diff --git a/yarn.lock b/yarn.lock index 688f7f8f5d..a0daa3b3ce 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5688,7 +5688,6 @@ __metadata: i18next: "npm:^23.11.5" jest-styled-components: "npm:^7.2.0" jsdom: "npm:26.1.0" - linguist-languages: "npm:^8.0.0" lint-staged: "npm:^15.5.0" lodash: "npm:^4.17.21" lru-cache: "npm:^11.1.0" @@ -11875,13 +11874,6 @@ __metadata: languageName: node linkType: hard -"linguist-languages@npm:^8.0.0": - version: 8.0.0 - resolution: "linguist-languages@npm:8.0.0" - checksum: 10c0/eaae46254247b9aa5b287ac98e062e7fe859314328ce305e34e152bc7bb172d69633999320cb47dc2a710388179712a76bb1ddd6e39e249af2684a4f0a66256c - languageName: node - linkType: hard - "linkify-it@npm:^5.0.0": version: 5.0.0 resolution: "linkify-it@npm:5.0.0" From ec36f78ffbe308556ab9a3a582740c04828b5407 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=A2=E5=A5=8B=E7=8C=AB?= Date: Thu, 19 Jun 2025 18:37:53 +0800 Subject: [PATCH 004/111] =?UTF-8?q?fix:=20update=20WindowService=20transpa?= =?UTF-8?q?rency=20and=20improve=20Inputbar=20resizing=20=E2=80=A6=20(#736?= =?UTF-8?q?2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/services/WindowService.ts | 2 +- src/renderer/src/assets/styles/ant.scss | 25 +++++++++++++++---- .../src/pages/home/Inputbar/Inputbar.tsx | 9 +++---- src/renderer/src/pages/home/Tabs/index.tsx | 2 +- .../home/components/SelectModelButton.tsx | 10 +++++--- 5 files changed, 32 insertions(+), 16 deletions(-) diff --git a/src/main/services/WindowService.ts b/src/main/services/WindowService.ts index 3f37d7c406..f6322e8939 100644 --- a/src/main/services/WindowService.ts +++ b/src/main/services/WindowService.ts @@ -56,7 +56,7 @@ export class WindowService { minHeight: 600, show: false, autoHideMenuBar: true, - transparent: isMac, + transparent: false, vibrancy: 'sidebar', visualEffectState: 'active', titleBarStyle: 'hidden', diff --git a/src/renderer/src/assets/styles/ant.scss b/src/renderer/src/assets/styles/ant.scss index d4788439e5..ebe45ef5c6 100644 --- a/src/renderer/src/assets/styles/ant.scss +++ b/src/renderer/src/assets/styles/ant.scss @@ -197,11 +197,26 @@ } } -.ant-dropdown-menu .ant-dropdown-menu-sub { - max-height: 350px; - width: max-content; - overflow-y: auto; - overflow-x: hidden; +.ant-dropdown { + .ant-dropdown-menu { + max-height: 50vh; + overflow-y: auto; + border: 0.5px solid var(--color-border); + .ant-dropdown-menu-sub { + max-height: 50vh; + width: max-content; + overflow-y: auto; + overflow-x: hidden; + border: 0.5px solid var(--color-border); + } + } + .ant-dropdown-arrow + .ant-dropdown-menu { + border: none; + } +} + +.ant-select-dropdown { + border: 0.5px solid var(--color-border); } .ant-collapse { diff --git a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx index 54a54f727e..958b779030 100644 --- a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx +++ b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx @@ -15,7 +15,7 @@ import { useAssistant } from '@renderer/hooks/useAssistant' import { useKnowledgeBases } from '@renderer/hooks/useKnowledge' import { useMessageOperations, useTopicLoading } from '@renderer/hooks/useMessageOperations' import { modelGenerating, useRuntime } from '@renderer/hooks/useRuntime' -import { useMessageStyle, useSettings } from '@renderer/hooks/useSettings' +import { useSettings } from '@renderer/hooks/useSettings' import { useShortcut, useShortcutDisplay } from '@renderer/hooks/useShortcuts' import { useSidebarIconShow } from '@renderer/hooks/useSidebarIcon' import { getDefaultTopic } from '@renderer/services/AssistantService' @@ -87,7 +87,6 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = const { t } = useTranslation() const containerRef = useRef(null) const { searching } = useRuntime() - const { isBubbleStyle } = useMessageStyle() const { pauseMessages } = useMessageOperations(topic) const loading = useTopicLoading(topic) const dispatch = useAppDispatch() @@ -673,8 +672,6 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = setSelectedKnowledgeBases(showKnowledgeIcon ? (assistant.knowledge_bases ?? []) : []) }, [assistant.id, assistant.knowledge_bases, showKnowledgeIcon]) - const textareaRows = window.innerHeight >= 1000 || isBubbleStyle ? 2 : 1 - const handleKnowledgeBaseSelect = (bases?: KnowledgeBase[]) => { updateAssistant({ ...assistant, knowledge_bases: bases }) setSelectedKnowledgeBases(bases ?? []) @@ -786,7 +783,7 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = contextMenu="true" variant="borderless" spellCheck={false} - rows={textareaRows} + rows={2} ref={textareaRef} style={{ fontSize, @@ -933,7 +930,7 @@ const Textarea = styled(TextArea)` overflow: auto; width: 100%; box-sizing: border-box; - transition: height 0.2s ease; + transition: none !important; &.ant-input { line-height: 1.4; } diff --git a/src/renderer/src/pages/home/Tabs/index.tsx b/src/renderer/src/pages/home/Tabs/index.tsx index fcd1aede02..21f3a21e43 100644 --- a/src/renderer/src/pages/home/Tabs/index.tsx +++ b/src/renderer/src/pages/home/Tabs/index.tsx @@ -157,7 +157,7 @@ const Container = styled.div` flex-direction: column; max-width: var(--assistants-width); min-width: var(--assistants-width); - background-color: transparent; + background-color: var(--color-background); overflow: hidden; .collapsed { width: 0; diff --git a/src/renderer/src/pages/home/components/SelectModelButton.tsx b/src/renderer/src/pages/home/components/SelectModelButton.tsx index 27cc392969..82d27abce4 100644 --- a/src/renderer/src/pages/home/components/SelectModelButton.tsx +++ b/src/renderer/src/pages/home/components/SelectModelButton.tsx @@ -6,6 +6,7 @@ import { useAssistant } from '@renderer/hooks/useAssistant' import { getProviderName } from '@renderer/services/ProviderService' import { Assistant } from '@renderer/types' import { Button } from 'antd' +import { ChevronsUpDown } from 'lucide-react' import { FC } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -45,9 +46,10 @@ const SelectModelButton: FC = ({ assistant }) => { - {model ? model.name : t('button.select_model')} {providerName ? '| ' + providerName : ''} + {model ? model.name : t('button.select_model')} {providerName ? ' | ' + providerName : ''} + ) } @@ -55,21 +57,23 @@ const SelectModelButton: FC = ({ assistant }) => { const DropdownButton = styled(Button)` font-size: 11px; border-radius: 15px; - padding: 12px 8px 12px 3px; + padding: 13px 5px; -webkit-app-region: none; box-shadow: none; background-color: transparent; border: 1px solid transparent; + margin-top: 1px; ` const ButtonContent = styled.div` display: flex; align-items: center; - gap: 5px; + gap: 6px; ` const ModelName = styled.span` font-weight: 500; + margin-right: -2px; ` export default SelectModelButton From c9f94a3b15ad6bd0c1803520a436f0425ab2e6c6 Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Thu, 19 Jun 2025 19:09:28 +0800 Subject: [PATCH 005/111] chore(version): 1.4.4 --- electron-builder.yml | 14 +++---- package.json | 2 +- src/renderer/src/store/index.ts | 2 +- src/renderer/src/store/llm.ts | 64 +++++++++++++------------------ src/renderer/src/store/migrate.ts | 1 - 5 files changed, 35 insertions(+), 48 deletions(-) diff --git a/electron-builder.yml b/electron-builder.yml index c1279a67e2..0da2d7f5fe 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -107,11 +107,9 @@ afterSign: scripts/notarize.js artifactBuildCompleted: scripts/artifact-build-completed.js releaseInfo: releaseNotes: | - 划词助手:支持文本选择快捷键、开关快捷键、思考块支持和引用功能 - 复制功能:新增纯文本复制(去除Markdown格式符号) - 知识库:支持设置向量维度,修复Ollama分数错误和维度编辑问题 - 多语言:增加模型名称多语言提示和翻译源语言手动选择 - 文件管理:修复主题/消息删除时文件未清理问题,优化文件选择流程 - 模型:修复Gemini模型推理预算、Voyage AI嵌入问题和DeepSeek翻译模型更新 - 图像功能:统一图片查看器,支持Base64图片渲染,修复图片预览相关问题 - UI:实现标签折叠/拖拽排序,修复气泡溢出,增加引文索引显示 + - 新功能:可选数据保存目录 + - 快捷助手:支持单独选择助手,支持暂停、上下文、思考过程、流式 + - 划词助手:系统托盘菜单开关 + - 翻译:新增 Markdown 预览选项 + - 新供应商:新增 Vertex AI 服务商 + - 错误修复和界面优化 diff --git a/package.json b/package.json index 3ee50ee69a..2f7446c706 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "CherryStudio", - "version": "1.4.2", + "version": "1.4.4", "private": true, "description": "A powerful AI assistant for producer.", "main": "./out/main/index.js", diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index 62c22b4a1e..81b547eed1 100644 --- a/src/renderer/src/store/index.ts +++ b/src/renderer/src/store/index.ts @@ -50,7 +50,7 @@ const persistedReducer = persistReducer( { key: 'cherry-studio', storage, - version: 112, + version: 114, blacklist: ['runtime', 'messages', 'messageBlocks'], migrate }, diff --git a/src/renderer/src/store/llm.ts b/src/renderer/src/store/llm.ts index a281bc977b..f70c4ef2ba 100644 --- a/src/renderer/src/store/llm.ts +++ b/src/renderer/src/store/llm.ts @@ -237,14 +237,15 @@ export const INITIAL_PROVIDERS: Provider[] = [ isVertex: false }, { - id: 'zhipu', - name: 'ZhiPu', - type: 'openai', + id: 'vertexai', + name: 'VertexAI', + type: 'vertexai', apiKey: '', - apiHost: 'https://open.bigmodel.cn/api/paas/v4/', - models: SYSTEM_MODELS.zhipu, + apiHost: 'https://aiplatform.googleapis.com', + models: [], isSystem: true, - enabled: false + enabled: false, + isVertex: true }, { id: 'github', @@ -267,6 +268,16 @@ export const INITIAL_PROVIDERS: Provider[] = [ enabled: false, isAuthed: false }, + { + id: 'zhipu', + name: 'ZhiPu', + type: 'openai', + apiKey: '', + apiHost: 'https://open.bigmodel.cn/api/paas/v4/', + models: SYSTEM_MODELS.zhipu, + isSystem: true, + enabled: false + }, { id: 'yi', name: 'Yi', @@ -377,26 +388,6 @@ export const INITIAL_PROVIDERS: Provider[] = [ isSystem: true, enabled: false }, - { - id: 'zhinao', - name: 'zhinao', - type: 'openai', - apiKey: '', - apiHost: 'https://api.360.cn', - models: SYSTEM_MODELS.zhinao, - isSystem: true, - enabled: false - }, - { - id: 'hunyuan', - name: 'hunyuan', - type: 'openai', - apiKey: '', - apiHost: 'https://api.hunyuan.cloud.tencent.com', - models: SYSTEM_MODELS.hunyuan, - isSystem: true, - enabled: false - }, { id: 'nvidia', name: 'nvidia', @@ -477,6 +468,16 @@ export const INITIAL_PROVIDERS: Provider[] = [ isSystem: true, enabled: false }, + { + id: 'hunyuan', + name: 'hunyuan', + type: 'openai', + apiKey: '', + apiHost: 'https://api.hunyuan.cloud.tencent.com', + models: SYSTEM_MODELS.hunyuan, + isSystem: true, + enabled: false + }, { id: 'tencent-cloud-ti', name: 'Tencent Cloud TI', @@ -516,17 +517,6 @@ export const INITIAL_PROVIDERS: Provider[] = [ models: SYSTEM_MODELS.voyageai, isSystem: true, enabled: false - }, - { - id: 'vertexai', - name: 'VertexAI', - type: 'vertexai', - apiKey: '', - apiHost: 'https://aiplatform.googleapis.com', - models: [], - isSystem: true, - enabled: false, - isVertex: true } ] diff --git a/src/renderer/src/store/migrate.ts b/src/renderer/src/store/migrate.ts index 007b9fb71b..305d26b758 100644 --- a/src/renderer/src/store/migrate.ts +++ b/src/renderer/src/store/migrate.ts @@ -1582,7 +1582,6 @@ const migrateConfig = { '113': (state: RootState) => { try { addProvider(state, 'vertexai') - state.llm.providers = moveProvider(state.llm.providers, 'vertexai', 10) if (!state.llm.settings.vertexai) { state.llm.settings.vertexai = llmInitialState.settings.vertexai } From ed0bb7fd166e4ab663b468710913066d73ee53a5 Mon Sep 17 00:00:00 2001 From: one Date: Thu, 19 Jun 2025 19:39:33 +0800 Subject: [PATCH 006/111] feat(Markdown): disable indented code blocks (#7288) * feat(Markdown): disable indented code blocks * chore: update remark/rehype packages --- package.json | 8 +- .../src/components/MarkdownEditor/index.tsx | 5 +- src/renderer/src/pages/agents/AgentsPage.tsx | 4 +- .../src/pages/home/Markdown/Markdown.tsx | 32 ++-- .../home/Markdown/__tests__/Markdown.test.tsx | 20 ++- .../__snapshots__/Markdown.test.tsx.snap | 85 +++++----- .../__tests__/disableIndentedCode.test.tsx | 155 ++++++++++++++++++ .../__tests__/remarkDisableConstructs.test.ts | 107 ++++++++++++ .../plugins/remarkDisableConstructs.ts | 53 ++++++ .../AssistantPromptSettings.tsx | 4 +- yarn.lock | 48 +++--- 11 files changed, 425 insertions(+), 96 deletions(-) create mode 100644 src/renderer/src/pages/home/Markdown/plugins/__tests__/disableIndentedCode.test.tsx create mode 100644 src/renderer/src/pages/home/Markdown/plugins/__tests__/remarkDisableConstructs.test.ts create mode 100644 src/renderer/src/pages/home/Markdown/plugins/remarkDisableConstructs.ts diff --git a/package.json b/package.json index 2f7446c706..57f14e728d 100644 --- a/package.json +++ b/package.json @@ -190,7 +190,7 @@ "react-hotkeys-hook": "^4.6.1", "react-i18next": "^14.1.2", "react-infinite-scroll-component": "^6.1.0", - "react-markdown": "^9.0.1", + "react-markdown": "^10.1.0", "react-redux": "^9.1.2", "react-router": "6", "react-router-dom": "6", @@ -199,10 +199,10 @@ "redux": "^5.0.1", "redux-persist": "^6.0.0", "rehype-katex": "^7.0.1", - "rehype-mathjax": "^7.0.0", + "rehype-mathjax": "^7.1.0", "rehype-raw": "^7.0.0", - "remark-cjk-friendly": "^1.1.0", - "remark-gfm": "^4.0.0", + "remark-cjk-friendly": "^1.2.0", + "remark-gfm": "^4.0.1", "remark-math": "^6.0.0", "remove-markdown": "^0.6.2", "rollup-plugin-visualizer": "^5.12.0", diff --git a/src/renderer/src/components/MarkdownEditor/index.tsx b/src/renderer/src/components/MarkdownEditor/index.tsx index 5a355a07c2..427ff1ccc8 100644 --- a/src/renderer/src/components/MarkdownEditor/index.tsx +++ b/src/renderer/src/components/MarkdownEditor/index.tsx @@ -41,11 +41,10 @@ const MarkdownEditor: FC = ({ return ( - + + rehypePlugins={[rehypeRaw, rehypeKatex]}> {inputValue || t('settings.provider.notes.markdown_editor_default_value')} diff --git a/src/renderer/src/pages/agents/AgentsPage.tsx b/src/renderer/src/pages/agents/AgentsPage.tsx index b9873c310b..1a8e3ce990 100644 --- a/src/renderer/src/pages/agents/AgentsPage.tsx +++ b/src/renderer/src/pages/agents/AgentsPage.tsx @@ -75,8 +75,8 @@ const AgentsPage: FC = () => { {agent.description && {agent.description}} {agent.prompt && ( - - {agent.prompt}{' '} + + {agent.prompt} )} diff --git a/src/renderer/src/pages/home/Markdown/Markdown.tsx b/src/renderer/src/pages/home/Markdown/Markdown.tsx index 8ee90a859a..2a6446fec7 100644 --- a/src/renderer/src/pages/home/Markdown/Markdown.tsx +++ b/src/renderer/src/pages/home/Markdown/Markdown.tsx @@ -24,6 +24,7 @@ import remarkMath from 'remark-math' import CodeBlock from './CodeBlock' import Link from './Link' +import remarkDisableConstructs from './plugins/remarkDisableConstructs' import Table from './Table' const ALLOWED_ELEMENTS = @@ -40,7 +41,7 @@ const Markdown: FC = ({ block }) => { const { mathEngine } = useSettings() const remarkPlugins = useMemo(() => { - const plugins = [remarkGfm, remarkCjkFriendly] + const plugins = [remarkGfm, remarkCjkFriendly, remarkDisableConstructs(['codeIndented'])] if (mathEngine !== 'none') { plugins.push(remarkMath) } @@ -105,20 +106,21 @@ const Markdown: FC = ({ block }) => { }, []) return ( - - {messageContent} - +
+ + {messageContent} + +
) } 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 c72f30de98..abd7067ab0 100644 --- a/src/renderer/src/pages/home/Markdown/__tests__/Markdown.test.tsx +++ b/src/renderer/src/pages/home/Markdown/__tests__/Markdown.test.tsx @@ -103,6 +103,12 @@ vi.mock('rehype-katex', () => ({ __esModule: true, default: vi.fn() })) vi.mock('rehype-mathjax', () => ({ __esModule: true, default: vi.fn() })) vi.mock('rehype-raw', () => ({ __esModule: true, default: vi.fn() })) +// Mock custom plugins +vi.mock('../plugins/remarkDisableConstructs', () => ({ + __esModule: true, + default: vi.fn() +})) + // Mock ReactMarkdown with realistic rendering vi.mock('react-markdown', () => ({ __esModule: true, @@ -162,12 +168,16 @@ describe('Markdown', () => { describe('rendering', () => { it('should render markdown content with correct structure', () => { const block = createMainTextBlock({ content: 'Test content' }) - render() + const { container } = render() - const markdown = screen.getByTestId('markdown-content') - expect(markdown).toBeInTheDocument() - expect(markdown).toHaveClass('markdown') - expect(markdown).toHaveTextContent('Test content') + // Check that the outer container has the markdown class + const markdownContainer = container.querySelector('.markdown') + expect(markdownContainer).toBeInTheDocument() + + // Check that the markdown content is rendered inside + const markdownContent = screen.getByTestId('markdown-content') + expect(markdownContent).toBeInTheDocument() + expect(markdownContent).toHaveTextContent('Test content') }) it('should handle empty content gracefully', () => { diff --git a/src/renderer/src/pages/home/Markdown/__tests__/__snapshots__/Markdown.test.tsx.snap b/src/renderer/src/pages/home/Markdown/__tests__/__snapshots__/Markdown.test.tsx.snap index 29aae68dc0..975aa2e09a 100644 --- a/src/renderer/src/pages/home/Markdown/__tests__/__snapshots__/Markdown.test.tsx.snap +++ b/src/renderer/src/pages/home/Markdown/__tests__/__snapshots__/Markdown.test.tsx.snap @@ -3,55 +3,58 @@ exports[`Markdown > rendering > should match snapshot 1`] = `
- # Test Markdown +
+ # Test Markdown This is **bold** text. - - link - -
-
- - test code - - -
-
-
+ link +
- - test table -
- + + test code + + +
+
+
+ + test table +
+ +
+
+ + img +
- - img -
`; diff --git a/src/renderer/src/pages/home/Markdown/plugins/__tests__/disableIndentedCode.test.tsx b/src/renderer/src/pages/home/Markdown/plugins/__tests__/disableIndentedCode.test.tsx new file mode 100644 index 0000000000..0a20e79b81 --- /dev/null +++ b/src/renderer/src/pages/home/Markdown/plugins/__tests__/disableIndentedCode.test.tsx @@ -0,0 +1,155 @@ +import { render } from '@testing-library/react' +import ReactMarkdown from 'react-markdown' +import { describe, expect, it } from 'vitest' + +import remarkDisableConstructs from '../remarkDisableConstructs' + +describe('disableIndentedCode', () => { + const renderMarkdown = (markdown: string, constructs: string[] = ['codeIndented']) => { + return render({markdown}) + } + + describe('normal path', () => { + it('should disable indented code blocks while preserving other code types', () => { + const markdown = ` +# Test Document + +Regular paragraph. + + This should be treated as a regular paragraph, not code + +\`inline code\` should work + +\`\`\`javascript +// This fenced code should work +console.log('hello') +\`\`\` + +Another paragraph. +` + + const { container } = renderMarkdown(markdown) + + // Verify only fenced code (pre element) + expect(container.querySelectorAll('pre')).toHaveLength(1) + + // Verify inline code + const inlineCode = container.querySelector('code:not(pre code)') + expect(inlineCode?.textContent).toBe('inline code') + + // Verify fenced code + const fencedCode = container.querySelector('pre code') + expect(fencedCode?.textContent).toContain('console.log') + + // Verify indented content becomes paragraph + const paragraphs = container.querySelectorAll('p') + const indentedParagraph = Array.from(paragraphs).find((p) => + p.textContent?.includes('This should be treated as a regular paragraph') + ) + expect(indentedParagraph).toBeTruthy() + }) + + it('should handle indented code in nested structures', () => { + const markdown = ` +> Blockquote with \`inline code\` +> +> This indented code in blockquote should become text + +1. List item + + This indented code in list should become text + +* Bullet list + * Nested item + + More indented code to convert +` + + const { container } = renderMarkdown(markdown) + + // Verify no indented code blocks + expect(container.querySelectorAll('pre')).toHaveLength(0) + + // Verify blockquote exists and contains converted text + const blockquote = container.querySelector('blockquote') + expect(blockquote?.textContent).toContain('This indented code in blockquote should become text') + + // Verify lists exist + const lists = container.querySelectorAll('ul, ol') + expect(lists.length).toBeGreaterThan(0) + }) + + it('should preserve other markdown elements when disabling constructs', () => { + const markdown = ` +# Heading + +Paragraph text. + + Indented code to disable + +[Link text](https://example.com) + +\`\`\` +Fenced code to keep +\`\`\` +` + + const { container } = renderMarkdown(markdown) + + // Verify heading + expect(container.querySelector('h1')?.textContent).toBe('Heading') + + // Verify link + const link = container.querySelector('a') + expect(link?.textContent).toBe('Link text') + expect(link?.getAttribute('href')).toBe('https://example.com') + + // Verify only fenced code + expect(container.querySelectorAll('pre')).toHaveLength(1) + }) + }) + + describe('edge cases', () => { + it('should not affect markdown when no constructs are disabled', () => { + const markdown = ` + This is indented code + +\`inline code\` + +\`\`\`javascript +console.log('fenced') +\`\`\` +` + + const { container } = renderMarkdown(markdown, []) + + // Should have indented code and fenced code + expect(container.querySelectorAll('pre')).toHaveLength(2) + }) + + it('should handle markdown with only inline and fenced code', () => { + const markdown = ` +Regular paragraph with \`inline code\`. + +\`\`\`typescript +function test(): string { + return "hello"; +} +\`\`\` +` + + const { container } = renderMarkdown(markdown) + + // Should have only fenced code + expect(container.querySelectorAll('pre')).toHaveLength(1) + + // Verify fenced code content + const fencedCode = container.querySelector('pre code') + expect(fencedCode?.textContent).toContain('function test()') + + // Verify inline code + const inlineCode = container.querySelector('code:not(pre code)') + expect(inlineCode?.textContent).toBe('inline code') + }) + }) +}) diff --git a/src/renderer/src/pages/home/Markdown/plugins/__tests__/remarkDisableConstructs.test.ts b/src/renderer/src/pages/home/Markdown/plugins/__tests__/remarkDisableConstructs.test.ts new file mode 100644 index 0000000000..dcf0b32b28 --- /dev/null +++ b/src/renderer/src/pages/home/Markdown/plugins/__tests__/remarkDisableConstructs.test.ts @@ -0,0 +1,107 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import remarkDisableConstructs from '../remarkDisableConstructs' + +describe('remarkDisableConstructs', () => { + let mockData: any + let mockThis: any + + beforeEach(() => { + mockData = {} + mockThis = { + data: vi.fn().mockReturnValue(mockData) + } + }) + + describe('plugin creation', () => { + it('should return a function when called', () => { + const plugin = remarkDisableConstructs(['codeIndented']) + + expect(typeof plugin).toBe('function') + }) + }) + + describe('normal path', () => { + it('should add micromarkExtensions for single construct', () => { + const plugin = remarkDisableConstructs(['codeIndented']) + plugin.call(mockThis as any) + + expect(mockData).toHaveProperty('micromarkExtensions') + expect(Array.isArray(mockData.micromarkExtensions)).toBe(true) + expect(mockData.micromarkExtensions).toHaveLength(1) + expect(mockData.micromarkExtensions[0]).toEqual({ + disable: { + null: ['codeIndented'] + } + }) + }) + + it('should handle multiple constructs', () => { + const constructs = ['codeIndented', 'autolink', 'htmlFlow'] + const plugin = remarkDisableConstructs(constructs) + plugin.call(mockThis as any) + + expect(mockData.micromarkExtensions[0]).toEqual({ + disable: { + null: constructs + } + }) + }) + }) + + describe('edge cases', () => { + it('should not add extensions when empty array is provided', () => { + const plugin = remarkDisableConstructs([]) + plugin.call(mockThis as any) + + expect(mockData).not.toHaveProperty('micromarkExtensions') + }) + + it('should not add extensions when undefined is passed', () => { + const plugin = remarkDisableConstructs() + plugin.call(mockThis as any) + + expect(mockData).not.toHaveProperty('micromarkExtensions') + }) + + it('should handle empty construct names', () => { + const plugin = remarkDisableConstructs(['', ' ']) + plugin.call(mockThis as any) + + expect(mockData.micromarkExtensions[0]).toEqual({ + disable: { + null: ['', ' '] + } + }) + }) + + it('should handle mixed valid and empty construct names', () => { + const plugin = remarkDisableConstructs(['codeIndented', '', 'autolink']) + plugin.call(mockThis as any) + + expect(mockData.micromarkExtensions[0]).toEqual({ + disable: { + null: ['codeIndented', '', 'autolink'] + } + }) + }) + }) + + describe('interaction with existing data', () => { + it('should append to existing micromarkExtensions', () => { + const existingExtension = { some: 'extension' } + mockData.micromarkExtensions = [existingExtension] + + const plugin = remarkDisableConstructs(['codeIndented']) + plugin.call(mockThis as any) + + expect(mockData.micromarkExtensions).toHaveLength(2) + expect(mockData.micromarkExtensions[0]).toBe(existingExtension) + expect(mockData.micromarkExtensions[1]).toEqual({ + disable: { + null: ['codeIndented'] + } + }) + }) + }) +}) diff --git a/src/renderer/src/pages/home/Markdown/plugins/remarkDisableConstructs.ts b/src/renderer/src/pages/home/Markdown/plugins/remarkDisableConstructs.ts new file mode 100644 index 0000000000..967e67ef5f --- /dev/null +++ b/src/renderer/src/pages/home/Markdown/plugins/remarkDisableConstructs.ts @@ -0,0 +1,53 @@ +import type { Plugin } from 'unified' + +/** + * Custom remark plugin to disable specific markdown constructs + * + * This plugin allows you to disable specific markdown constructs by passing + * them as micromark extensions to the underlying parser. + * + * @see https://github.com/micromark/micromark + * + * @example + * ```typescript + * // Disable indented code blocks + * remarkDisableConstructs(['codeIndented']) + * + * // Disable multiple constructs + * remarkDisableConstructs(['codeIndented', 'autolink', 'htmlFlow']) + * ``` + */ + +/** + * Helper function to add values to plugin data + * @param data - The plugin data object + * @param field - The field name to add to + * @param value - The value to add + */ +function add(data: any, field: string, value: unknown): void { + const list = data[field] ? data[field] : (data[field] = []) + list.push(value) +} + +/** + * Remark plugin to disable specific markdown constructs + * @param constructs - Array of construct names to disable (e.g., ['codeIndented', 'autolink']) + * @returns A remark plugin function + */ +function remarkDisableConstructs(constructs: string[] = []): Plugin<[], any, any> { + return function () { + const data = this.data() + + if (constructs.length > 0) { + const disableExtension = { + disable: { + null: constructs + } + } + + add(data, 'micromarkExtensions', disableExtension) + } + } +} + +export default remarkDisableConstructs diff --git a/src/renderer/src/pages/settings/AssistantSettings/AssistantPromptSettings.tsx b/src/renderer/src/pages/settings/AssistantSettings/AssistantPromptSettings.tsx index 02b8185046..d5b2882986 100644 --- a/src/renderer/src/pages/settings/AssistantSettings/AssistantPromptSettings.tsx +++ b/src/renderer/src/pages/settings/AssistantSettings/AssistantPromptSettings.tsx @@ -103,8 +103,8 @@ const AssistantPromptSettings: React.FC = ({ assistant, updateAssistant } {showMarkdown ? ( - setShowMarkdown(false)}> - {prompt} + setShowMarkdown(false)}> + {prompt}
) : ( diff --git a/yarn.lock b/yarn.lock index a0daa3b3ce..b066f44a11 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5712,7 +5712,7 @@ __metadata: react-hotkeys-hook: "npm:^4.6.1" react-i18next: "npm:^14.1.2" react-infinite-scroll-component: "npm:^6.1.0" - react-markdown: "npm:^9.0.1" + react-markdown: "npm:^10.1.0" react-redux: "npm:^9.1.2" react-router: "npm:6" react-router-dom: "npm:6" @@ -5721,10 +5721,10 @@ __metadata: redux: "npm:^5.0.1" redux-persist: "npm:^6.0.0" rehype-katex: "npm:^7.0.1" - rehype-mathjax: "npm:^7.0.0" + rehype-mathjax: "npm:^7.1.0" rehype-raw: "npm:^7.0.0" - remark-cjk-friendly: "npm:^1.1.0" - remark-gfm: "npm:^4.0.0" + remark-cjk-friendly: "npm:^1.2.0" + remark-gfm: "npm:^4.0.1" remark-math: "npm:^6.0.0" remove-markdown: "npm:^0.6.2" rollup-plugin-visualizer: "npm:^5.12.0" @@ -12737,9 +12737,9 @@ __metadata: languageName: node linkType: hard -"micromark-extension-cjk-friendly-util@npm:^1.1.0": - version: 1.1.0 - resolution: "micromark-extension-cjk-friendly-util@npm:1.1.0" +"micromark-extension-cjk-friendly-util@npm:^2.0.0": + version: 2.0.0 + resolution: "micromark-extension-cjk-friendly-util@npm:2.0.0" dependencies: get-east-asian-width: "npm:^1.3.0" micromark-util-character: "npm:^2.0.0" @@ -12747,16 +12747,16 @@ __metadata: peerDependenciesMeta: micromark-util-types: optional: true - checksum: 10c0/3ae1d4fd92f03a6c8e34e314c14a42b35cdd1bcbe043fceb1d2d45cd1a7b364e77643a3ca181910666cb11cc3606a1595fae9a15e87b0a4988fc57d5e4f65f67 + checksum: 10c0/194c799d88982ebf785e65a1c29cbded17d5dd3510a1769123ec30ddb7e256502b97753f63e8994d91ebafa1e9b96aa2dc2a90aa4e2f2072269b05652a412886 languageName: node linkType: hard -"micromark-extension-cjk-friendly@npm:^1.1.0": - version: 1.1.0 - resolution: "micromark-extension-cjk-friendly@npm:1.1.0" +"micromark-extension-cjk-friendly@npm:^1.2.0": + version: 1.2.0 + resolution: "micromark-extension-cjk-friendly@npm:1.2.0" dependencies: devlop: "npm:^1.0.0" - micromark-extension-cjk-friendly-util: "npm:^1.1.0" + micromark-extension-cjk-friendly-util: "npm:^2.0.0" micromark-util-chunked: "npm:^2.0.0" micromark-util-resolve-all: "npm:^2.0.0" micromark-util-symbol: "npm:^2.0.0" @@ -12766,7 +12766,7 @@ __metadata: peerDependenciesMeta: micromark-util-types: optional: true - checksum: 10c0/95be6d8b4164b9b3b5281d77ed4f9337d95b2041ad4f7a775baa0d7f8ec495818101881eea2c7cc0ee4ee11738716899f20b3fbfbc2e6b80106544065d2ec04d + checksum: 10c0/5be1841629310e21c803b64feb00453fb8ac939be80c2ff473d8b4486d8eca973347520912a6e4abeda5bea4ed8ef39d3db48c4bad8285dd380d9ed34417dd0d languageName: node linkType: hard @@ -15611,9 +15611,9 @@ __metadata: languageName: node linkType: hard -"react-markdown@npm:^9.0.1": - version: 9.1.0 - resolution: "react-markdown@npm:9.1.0" +"react-markdown@npm:^10.1.0": + version: 10.1.0 + resolution: "react-markdown@npm:10.1.0" dependencies: "@types/hast": "npm:^3.0.0" "@types/mdast": "npm:^4.0.0" @@ -15629,7 +15629,7 @@ __metadata: peerDependencies: "@types/react": ">=18" react: ">=18" - checksum: 10c0/5bd645d39379f776d64588105f4060c390c3c8e4ff048552c9fa0ad31b756bb3ff7c11081542dc58d840ccf183a6dd4fd4d4edab44d8c24dee8b66551a5fd30d + checksum: 10c0/4a5dc7d15ca6d05e9ee95318c1904f83b111a76f7588c44f50f1d54d4c97193b84e4f64c4b592057c989228238a2590306cedd0c4d398e75da49262b2b5ae1bf languageName: node linkType: hard @@ -15915,7 +15915,7 @@ __metadata: languageName: node linkType: hard -"rehype-mathjax@npm:^7.0.0": +"rehype-mathjax@npm:^7.1.0": version: 7.1.0 resolution: "rehype-mathjax@npm:7.1.0" dependencies: @@ -15942,18 +15942,18 @@ __metadata: languageName: node linkType: hard -"remark-cjk-friendly@npm:^1.1.0": - version: 1.1.0 - resolution: "remark-cjk-friendly@npm:1.1.0" +"remark-cjk-friendly@npm:^1.2.0": + version: 1.2.0 + resolution: "remark-cjk-friendly@npm:1.2.0" dependencies: - micromark-extension-cjk-friendly: "npm:^1.1.0" + micromark-extension-cjk-friendly: "npm:^1.2.0" peerDependencies: "@types/mdast": ^4.0.0 unified: ^11.0.0 peerDependenciesMeta: "@types/mdast": optional: true - checksum: 10c0/ef43a4c404baaaa3e3d888ea68db8ffa101746faadb96d19d6b7ee8d00f0a025613c2e508527236961b226e41d8fb34f6cc6ac217ae8770fcbf47b9f496ab32a + checksum: 10c0/ca7dc4fd50491693c4a84164650b30c3ae027cc7aa11b7a2e3811ab07ad0bf0c73484e37f9aed710bb68f95ca03cc540effe64cbe94bbc055b40e1aa951e2013 languageName: node linkType: hard @@ -15967,7 +15967,7 @@ __metadata: languageName: node linkType: hard -"remark-gfm@npm:^4.0.0": +"remark-gfm@npm:^4.0.1": version: 4.0.1 resolution: "remark-gfm@npm:4.0.1" dependencies: From b4b456ae06b0a37bfbaed87d699d4adabe2a4369 Mon Sep 17 00:00:00 2001 From: Tristan Zhang <82869104+ABucket@users.noreply.github.com> Date: Thu, 19 Jun 2025 22:56:46 +0800 Subject: [PATCH 007/111] fix(AssistantService): add default settings configuration to assistant initialization (#7371) --- src/renderer/src/services/AssistantService.ts | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/services/AssistantService.ts b/src/renderer/src/services/AssistantService.ts index 7619e0e3b4..e8ec416b1e 100644 --- a/src/renderer/src/services/AssistantService.ts +++ b/src/renderer/src/services/AssistantService.ts @@ -14,7 +14,17 @@ export function getDefaultAssistant(): Assistant { topics: [getDefaultTopic('default')], messages: [], type: 'assistant', - regularPhrases: [] // Added regularPhrases + regularPhrases: [], // Added regularPhrases + settings: { + temperature: DEFAULT_TEMPERATURE, + contextCount: DEFAULT_CONTEXTCOUNT, + enableMaxTokens: false, + maxTokens: 0, + streamOutput: true, + topP: 1, + toolUseMode: 'prompt', + customParameters: [] + } } } @@ -127,7 +137,17 @@ export async function createAssistantFromAgent(agent: Agent) { topics: [topic], model: agent.defaultModel, type: 'assistant', - regularPhrases: agent.regularPhrases || [] // Ensured regularPhrases + regularPhrases: agent.regularPhrases || [], // Ensured regularPhrases + settings: agent.settings || { + temperature: DEFAULT_TEMPERATURE, + contextCount: DEFAULT_CONTEXTCOUNT, + enableMaxTokens: false, + maxTokens: 0, + streamOutput: true, + topP: 1, + toolUseMode: 'prompt', + customParameters: [] + } } store.dispatch(addAssistant(assistant)) From 3e142f67ad9683b61edd82e0ca6dc79ccd3bfc48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?George=C2=B7Dong?= <98630204+GeorgeDong32@users.noreply.github.com> Date: Thu, 19 Jun 2025 23:32:32 +0800 Subject: [PATCH 008/111] fix(i18n): fix model name export help text (#7372) --- src/renderer/src/i18n/locales/en-us.json | 2 +- src/renderer/src/i18n/locales/ja-jp.json | 2 +- src/renderer/src/i18n/locales/ru-ru.json | 2 +- src/renderer/src/i18n/locales/zh-cn.json | 2 +- src/renderer/src/i18n/locales/zh-tw.json | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index a9ff753b39..b62c708825 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -1163,7 +1163,7 @@ "markdown_export.select": "Select", "markdown_export.title": "Markdown Export", "markdown_export.show_model_name.title": "Use Model Name on Export", - "markdown_export.show_model_name.help": "When enabled, the topic-naming model will be used to create titles for exported messages. Note: This option also affects all export methods through Markdown, such as Notion, Yuque, etc.", + "markdown_export.show_model_name.help": "When enabled, the model name will be displayed when exporting to Markdown. Note: This option also affects all export methods through Markdown, such as Notion, Yuque, etc.", "markdown_export.show_model_provider.title": "Show Model Provider", "markdown_export.show_model_provider.help": "Display the model provider (e.g., OpenAI, Gemini) when exporting to Markdown", "minute_interval_one": "{{count}} minute", diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index 903fa6d635..bc363a0e91 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -1161,7 +1161,7 @@ "markdown_export.select": "選択", "markdown_export.title": "Markdown エクスポート", "markdown_export.show_model_name.title": "エクスポート時にモデル名を使用", - "markdown_export.show_model_name.help": "有効にすると、トピック命名モデルがエクスポートされたメッセージのタイトル作成に使用されます。注意:この設定はNotion、Yuqueなど、Markdownを通じたすべてのエクスポート方法にも影響します。", + "markdown_export.show_model_name.help": "有効にすると、Markdownエクスポート時にモデル名を表示します。注意:この設定はNotion、Yuqueなど、Markdownを通じたすべてのエクスポート方法にも影響します。", "markdown_export.show_model_provider.title": "モデルプロバイダーを表示", "markdown_export.show_model_provider.help": "Markdownエクスポート時にモデルプロバイダー(例:OpenAI、Geminiなど)を表示します。", "minute_interval_one": "{{count}} 分", diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index 46424b5fe2..8b325b4b31 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -1161,7 +1161,7 @@ "markdown_export.select": "Выбрать", "markdown_export.title": "Экспорт в Markdown", "markdown_export.show_model_name.title": "Использовать имя модели при экспорте", - "markdown_export.show_model_name.help": "Если включено, для создания заголовков экспортируемых сообщений будет использоваться модель именования темы. Примечание: Эта опция также влияет на все методы экспорта через Markdown, такие как Notion, Yuque и т.д.", + "markdown_export.show_model_name.help": "Если включено, при экспорте в Markdown будет отображаться имя модели. Примечание: Эта опция также влияет на все методы экспорта через Markdown, такие как Notion, Yuque и т.д.", "markdown_export.show_model_provider.title": "Показать поставщика модели", "markdown_export.show_model_provider.help": "Показывать поставщика модели (например, OpenAI, Gemini) при экспорте в Markdown", "minute_interval_one": "{{count}} минута", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 83b8f144fb..a60589c71a 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -1163,7 +1163,7 @@ "markdown_export.select": "选择", "markdown_export.title": "Markdown 导出", "markdown_export.show_model_name.title": "导出时使用模型名称", - "markdown_export.show_model_name.help": "开启后,使用话题命名模型为导出的消息创建标题。注意:该项也会影响所有通过Markdown导出的方式,如Notion、语雀等。", + "markdown_export.show_model_name.help": "开启后,导出Markdown时会显示模型名称。注意:该项也会影响所有通过Markdown导出的方式,如Notion、语雀等。", "markdown_export.show_model_provider.title": "显示模型供应商", "markdown_export.show_model_provider.help": "在导出Markdown时显示模型供应商,如OpenAI、Gemini等", "message_title.use_topic_naming.title": "使用话题命名模型为导出的消息创建标题", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index b92718e4fb..4742cff3a3 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -1163,7 +1163,7 @@ "markdown_export.select": "選擇", "markdown_export.title": "Markdown 匯出", "markdown_export.show_model_name.title": "匯出時使用模型名稱", - "markdown_export.show_model_name.help": "啟用後,將以主題命名模型為匯出的訊息建立標題。注意:該項也會影響所有透過Markdown匯出的方式,如Notion、語雀等。", + "markdown_export.show_model_name.help": "啟用後,匯出Markdown時會顯示模型名稱。注意:該項也會影響所有透過Markdown匯出的方式,如Notion、語雀等。", "markdown_export.show_model_provider.title": "顯示模型供應商", "markdown_export.show_model_provider.help": "在匯出Markdown時顯示模型供應商,如OpenAI、Gemini等", "minute_interval_one": "{{count}} 分鐘", From 1915ba5bfb1c43dfc923fcc4302fcc1686b73dd2 Mon Sep 17 00:00:00 2001 From: SuYao Date: Fri, 20 Jun 2025 14:46:22 +0800 Subject: [PATCH 009/111] fix(GeminiAPIClient): update abortSignal option and ensure userLastMessage is pushed to messages (#7387) --- src/renderer/src/aiCore/clients/gemini/GeminiAPIClient.ts | 3 ++- src/renderer/src/types/sdk.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/aiCore/clients/gemini/GeminiAPIClient.ts b/src/renderer/src/aiCore/clients/gemini/GeminiAPIClient.ts index 7f08def6cc..dd4fb1d516 100644 --- a/src/renderer/src/aiCore/clients/gemini/GeminiAPIClient.ts +++ b/src/renderer/src/aiCore/clients/gemini/GeminiAPIClient.ts @@ -85,7 +85,7 @@ export class GeminiAPIClient extends BaseApiClient< ...rest, config: { ...rest.config, - abortSignal: options?.abortSignal, + abortSignal: options?.signal, httpOptions: { ...rest.config?.httpOptions, timeout: options?.timeout @@ -479,6 +479,7 @@ export class GeminiAPIClient extends BaseApiClient< for (const message of messages) { history.push(await this.convertMessageToSdkParam(message)) } + messages.push(userLastMessage) } } diff --git a/src/renderer/src/types/sdk.ts b/src/renderer/src/types/sdk.ts index c066952ec1..559e02ecae 100644 --- a/src/renderer/src/types/sdk.ts +++ b/src/renderer/src/types/sdk.ts @@ -102,6 +102,6 @@ export type GeminiSdkToolCall = FunctionCall export type GeminiOptions = { streamOutput: boolean - abortSignal?: AbortSignal + signal?: AbortSignal timeout?: number } From a813df993cc7cfbe870c58bd98272a909febe237 Mon Sep 17 00:00:00 2001 From: Wang Jiyuan <59059173+EurFelux@users.noreply.github.com> Date: Fri, 20 Jun 2025 17:46:45 +0800 Subject: [PATCH 010/111] fix: Chat does not work properly when configuring multiple API keys (#7385) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(openai): 使用getApiKey方法替代直接访问apiKey属性 * refactor(openai): 使用getApiKey方法替代直接访问provider.apiKey * refactor(api客户端): 直接使用apiKey属性替代getApiKey方法 --- src/renderer/src/aiCore/clients/anthropic/AnthropicAPIClient.ts | 2 +- src/renderer/src/aiCore/clients/openai/OpenAIBaseClient.ts | 2 +- .../src/aiCore/clients/openai/OpenAIResponseAPIClient.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/aiCore/clients/anthropic/AnthropicAPIClient.ts b/src/renderer/src/aiCore/clients/anthropic/AnthropicAPIClient.ts index 864f2fff30..eb7d930c5d 100644 --- a/src/renderer/src/aiCore/clients/anthropic/AnthropicAPIClient.ts +++ b/src/renderer/src/aiCore/clients/anthropic/AnthropicAPIClient.ts @@ -90,7 +90,7 @@ export class AnthropicAPIClient extends BaseApiClient< return this.sdkInstance } this.sdkInstance = new Anthropic({ - apiKey: this.getApiKey(), + apiKey: this.apiKey, baseURL: this.getBaseURL(), dangerouslyAllowBrowser: true, defaultHeaders: { diff --git a/src/renderer/src/aiCore/clients/openai/OpenAIBaseClient.ts b/src/renderer/src/aiCore/clients/openai/OpenAIBaseClient.ts index cd03607c29..7730f228af 100644 --- a/src/renderer/src/aiCore/clients/openai/OpenAIBaseClient.ts +++ b/src/renderer/src/aiCore/clients/openai/OpenAIBaseClient.ts @@ -135,7 +135,7 @@ export abstract class OpenAIBaseClient< return this.sdkInstance } - let apiKeyForSdkInstance = this.provider.apiKey + let apiKeyForSdkInstance = this.apiKey if (this.provider.id === 'copilot') { const defaultHeaders = store.getState().copilot.defaultHeaders diff --git a/src/renderer/src/aiCore/clients/openai/OpenAIResponseAPIClient.ts b/src/renderer/src/aiCore/clients/openai/OpenAIResponseAPIClient.ts index 71b809c338..5871a04cab 100644 --- a/src/renderer/src/aiCore/clients/openai/OpenAIResponseAPIClient.ts +++ b/src/renderer/src/aiCore/clients/openai/OpenAIResponseAPIClient.ts @@ -78,7 +78,7 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient< return new OpenAI({ dangerouslyAllowBrowser: true, - apiKey: this.provider.apiKey, + apiKey: this.apiKey, baseURL: this.getBaseURL(), defaultHeaders: { ...this.defaultHeaders() From 8d247add9821a555f0429192636eaaf3ecee7117 Mon Sep 17 00:00:00 2001 From: Wang Jiyuan <59059173+EurFelux@users.noreply.github.com> Date: Fri, 20 Jun 2025 18:06:44 +0800 Subject: [PATCH 011/111] fix(ApiService): correct enableWebSearch conditional logic error (#7396) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(ApiService): 修复enableWebSearch条件判断逻辑错误 * fix(web搜索): 修正web搜索模型判断逻辑 --- 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 4a842c59ca..23ba31557a 100644 --- a/src/renderer/src/config/models.ts +++ b/src/renderer/src/config/models.ts @@ -2688,7 +2688,7 @@ export function isOpenRouterBuiltInWebSearchModel(model: Model): boolean { return false } - return isOpenAIWebSearchModel(model) || model.id.includes('sonar') + return isOpenAIWebSearchChatCompletionOnlyModel(model) || model.id.includes('sonar') } export function isGenerateImageModel(model: Model): boolean { From b91ac0de1dd900aae4b476425db6df763ed454d8 Mon Sep 17 00:00:00 2001 From: Wang Jiyuan <59059173+EurFelux@users.noreply.github.com> Date: Fri, 20 Jun 2025 22:30:14 +0800 Subject: [PATCH 012/111] fix(models): Unexpected inability to disable image generation feature (#7401) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(models): 修复禁用图片生成模型检查逻辑 * fix(models): use getBaseName() --- 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 23ba31557a..9cb07528e3 100644 --- a/src/renderer/src/config/models.ts +++ b/src/renderer/src/config/models.ts @@ -2720,7 +2720,7 @@ export function isSupportedDisableGenerationModel(model: Model): boolean { return false } - return SUPPORTED_DISABLE_GENERATION_MODELS.includes(model.id) + return SUPPORTED_DISABLE_GENERATION_MODELS.includes(getBaseModelName(model.id)) } export function getOpenAIWebSearchParams(model: Model, isEnableWebSearch?: boolean): Record { From 37aaaee0866c214c5643518a796dad7009aa27cf Mon Sep 17 00:00:00 2001 From: beyondkmp Date: Sat, 21 Jun 2025 19:47:15 +0800 Subject: [PATCH 013/111] fix: add node-stream-zip for zip file extraction in install-bun script (#7403) * chore(package): add node-stream-zip for zip file extraction in install-bun script * refactor(install-uv): replace AdmZip with node-stream-zip for improved zip file extraction * fix(install-uv): ensure correct extraction of uv binary for Unix/Linux/macOS * refactor(install-uv): remove redundant file handling and cleanup for Unix/Linux/macOS installation * fix(install-uv): update tar extraction command to strip leading components for Unix/Linux/macOS * fix(install-uv): clarify comment for zip file extraction on Windows * fix(install-bun): correct extraction directory for bun binary * fix(install-bun, install-uv): update default versions and improve zip extraction process * fix(install-bun): remove redundant cleanup of source directory after bun installation --- package.json | 2 +- resources/scripts/install-bun.js | 45 ++++++++--------- resources/scripts/install-uv.js | 84 ++++++++++++++------------------ 3 files changed, 60 insertions(+), 71 deletions(-) diff --git a/package.json b/package.json index 57f14e728d..dc29289380 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "@libsql/win32-x64-msvc": "^0.4.7", "@strongtz/win32-arm64-msvc": "^0.4.7", "jsdom": "26.1.0", + "node-stream-zip": "^1.15.0", "notion-helper": "^1.3.22", "os-proxy-config": "^1.1.2", "selection-hook": "^0.9.23", @@ -176,7 +177,6 @@ "mermaid": "^11.6.0", "mime": "^4.0.4", "motion": "^12.10.5", - "node-stream-zip": "^1.15.0", "npx-scope-finder": "^1.2.0", "officeparser": "^4.1.1", "openai": "patch:openai@npm%3A5.1.0#~/.yarn/patches/openai-npm-5.1.0-0e7b3ccb07.patch", diff --git a/resources/scripts/install-bun.js b/resources/scripts/install-bun.js index 9637c60f3a..8e232dfa9c 100644 --- a/resources/scripts/install-bun.js +++ b/resources/scripts/install-bun.js @@ -2,12 +2,12 @@ const fs = require('fs') const path = require('path') const os = require('os') const { execSync } = require('child_process') -const AdmZip = require('adm-zip') +const StreamZip = require('node-stream-zip') const { downloadWithRedirects } = require('./download') // Base URL for downloading bun binaries const BUN_RELEASE_BASE_URL = 'https://gitcode.com/CherryHQ/bun/releases/download' -const DEFAULT_BUN_VERSION = '1.2.9' // Default fallback version +const DEFAULT_BUN_VERSION = '1.2.17' // Default fallback version // Mapping of platform+arch to binary package name const BUN_PACKAGES = { @@ -66,35 +66,36 @@ async function downloadBunBinary(platform, arch, version = DEFAULT_BUN_VERSION, // Extract the zip file using adm-zip console.log(`Extracting ${packageName} to ${binDir}...`) - const zip = new AdmZip(tempFilename) - zip.extractAllTo(tempdir, true) + const zip = new StreamZip.async({ file: tempFilename }) - // Move files using Node.js fs - const sourceDir = path.join(tempdir, packageName.split('.')[0]) - const files = fs.readdirSync(sourceDir) + // Get all entries in the zip file + const entries = await zip.entries() - for (const file of files) { - const sourcePath = path.join(sourceDir, file) - const destPath = path.join(binDir, file) + // Extract files directly to binDir, flattening the directory structure + for (const entry of Object.values(entries)) { + if (!entry.isDirectory) { + // Get just the filename without path + const filename = path.basename(entry.name) + const outputPath = path.join(binDir, filename) - fs.copyFileSync(sourcePath, destPath) - fs.unlinkSync(sourcePath) - - // Set executable permissions for non-Windows platforms - if (platform !== 'win32') { - try { - // 755 permission: rwxr-xr-x - fs.chmodSync(destPath, '755') - } catch (error) { - console.warn(`Warning: Failed to set executable permissions: ${error.message}`) + console.log(`Extracting ${entry.name} -> ${filename}`) + await zip.extract(entry.name, outputPath) + // Make executable files executable on Unix-like systems + if (platform !== 'win32') { + try { + fs.chmodSync(outputPath, 0o755) + } catch (chmodError) { + console.error(`Warning: Failed to set executable permissions on ${filename}`) + return false + } } + console.log(`Extracted ${entry.name} -> ${outputPath}`) } } + await zip.close() // Clean up fs.unlinkSync(tempFilename) - fs.rmSync(sourceDir, { recursive: true }) - console.log(`Successfully installed bun ${version} for ${platformKey}`) return true } catch (error) { diff --git a/resources/scripts/install-uv.js b/resources/scripts/install-uv.js index 32892b9c63..2c882d07da 100644 --- a/resources/scripts/install-uv.js +++ b/resources/scripts/install-uv.js @@ -2,34 +2,33 @@ const fs = require('fs') const path = require('path') const os = require('os') const { execSync } = require('child_process') -const tar = require('tar') -const AdmZip = require('adm-zip') +const StreamZip = require('node-stream-zip') const { downloadWithRedirects } = require('./download') // Base URL for downloading uv binaries const UV_RELEASE_BASE_URL = 'https://gitcode.com/CherryHQ/uv/releases/download' -const DEFAULT_UV_VERSION = '0.6.14' +const DEFAULT_UV_VERSION = '0.7.13' // Mapping of platform+arch to binary package name const UV_PACKAGES = { - 'darwin-arm64': 'uv-aarch64-apple-darwin.tar.gz', - 'darwin-x64': 'uv-x86_64-apple-darwin.tar.gz', + 'darwin-arm64': 'uv-aarch64-apple-darwin.zip', + 'darwin-x64': 'uv-x86_64-apple-darwin.zip', 'win32-arm64': 'uv-aarch64-pc-windows-msvc.zip', 'win32-ia32': 'uv-i686-pc-windows-msvc.zip', 'win32-x64': 'uv-x86_64-pc-windows-msvc.zip', - 'linux-arm64': 'uv-aarch64-unknown-linux-gnu.tar.gz', - 'linux-ia32': 'uv-i686-unknown-linux-gnu.tar.gz', - 'linux-ppc64': 'uv-powerpc64-unknown-linux-gnu.tar.gz', - 'linux-ppc64le': 'uv-powerpc64le-unknown-linux-gnu.tar.gz', - 'linux-s390x': 'uv-s390x-unknown-linux-gnu.tar.gz', - 'linux-x64': 'uv-x86_64-unknown-linux-gnu.tar.gz', - 'linux-armv7l': 'uv-armv7-unknown-linux-gnueabihf.tar.gz', + 'linux-arm64': 'uv-aarch64-unknown-linux-gnu.zip', + 'linux-ia32': 'uv-i686-unknown-linux-gnu.zip', + 'linux-ppc64': 'uv-powerpc64-unknown-linux-gnu.zip', + 'linux-ppc64le': 'uv-powerpc64le-unknown-linux-gnu.zip', + 'linux-s390x': 'uv-s390x-unknown-linux-gnu.zip', + 'linux-x64': 'uv-x86_64-unknown-linux-gnu.zip', + 'linux-armv7l': 'uv-armv7-unknown-linux-gnueabihf.zip', // MUSL variants - 'linux-musl-arm64': 'uv-aarch64-unknown-linux-musl.tar.gz', - 'linux-musl-ia32': 'uv-i686-unknown-linux-musl.tar.gz', - 'linux-musl-x64': 'uv-x86_64-unknown-linux-musl.tar.gz', - 'linux-musl-armv6l': 'uv-arm-unknown-linux-musleabihf.tar.gz', - 'linux-musl-armv7l': 'uv-armv7-unknown-linux-musleabihf.tar.gz' + 'linux-musl-arm64': 'uv-aarch64-unknown-linux-musl.zip', + 'linux-musl-ia32': 'uv-i686-unknown-linux-musl.zip', + 'linux-musl-x64': 'uv-x86_64-unknown-linux-musl.zip', + 'linux-musl-armv6l': 'uv-arm-unknown-linux-musleabihf.zip', + 'linux-musl-armv7l': 'uv-armv7-unknown-linux-musleabihf.zip' } /** @@ -66,46 +65,35 @@ async function downloadUvBinary(platform, arch, version = DEFAULT_UV_VERSION, is console.log(`Extracting ${packageName} to ${binDir}...`) - // 根据文件扩展名选择解压方法 - if (packageName.endsWith('.zip')) { - // 使用 adm-zip 处理 zip 文件 - const zip = new AdmZip(tempFilename) - zip.extractAllTo(binDir, true) - fs.unlinkSync(tempFilename) - console.log(`Successfully installed uv ${version} for ${platform}-${arch}`) - return true - } else { - // tar.gz 文件的处理保持不变 - await tar.x({ - file: tempFilename, - cwd: tempdir, - z: true - }) + const zip = new StreamZip.async({ file: tempFilename }) - // Move files using Node.js fs - const sourceDir = path.join(tempdir, packageName.split('.')[0]) - const files = fs.readdirSync(sourceDir) - for (const file of files) { - const sourcePath = path.join(sourceDir, file) - const destPath = path.join(binDir, file) - fs.copyFileSync(sourcePath, destPath) - fs.unlinkSync(sourcePath) + // Get all entries in the zip file + const entries = await zip.entries() - // Set executable permissions for non-Windows platforms + // Extract files directly to binDir, flattening the directory structure + for (const entry of Object.values(entries)) { + if (!entry.isDirectory) { + // Get just the filename without path + const filename = path.basename(entry.name) + const outputPath = path.join(binDir, filename) + + console.log(`Extracting ${entry.name} -> ${filename}`) + await zip.extract(entry.name, outputPath) + // Make executable files executable on Unix-like systems if (platform !== 'win32') { try { - fs.chmodSync(destPath, '755') - } catch (error) { - console.warn(`Warning: Failed to set executable permissions: ${error.message}`) + fs.chmodSync(outputPath, 0o755) + } catch (chmodError) { + console.error(`Warning: Failed to set executable permissions on ${filename}`) + return false } } + console.log(`Extracted ${entry.name} -> ${outputPath}`) } - - // Clean up - fs.unlinkSync(tempFilename) - fs.rmSync(sourceDir, { recursive: true }) } + await zip.close() + fs.unlinkSync(tempFilename) console.log(`Successfully installed uv ${version} for ${platform}-${arch}`) return true } catch (error) { From 60b37876b1ab3bc555583a3bc9f5cd42a71b6a08 Mon Sep 17 00:00:00 2001 From: Wang Jiyuan <59059173+EurFelux@users.noreply.github.com> Date: Sat, 21 Jun 2025 21:20:40 +0800 Subject: [PATCH 014/111] fix: remove duplicated deepseek-v3 in volcengine (#7406) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix: 移除重复的DeepSeek-V3模型配置 --- src/renderer/src/config/models.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/renderer/src/config/models.ts b/src/renderer/src/config/models.ts index 9cb07528e3..a847f6d491 100644 --- a/src/renderer/src/config/models.ts +++ b/src/renderer/src/config/models.ts @@ -1352,12 +1352,6 @@ export const SYSTEM_MODELS: Record = { name: 'DeepSeek-V3', group: 'DeepSeek' }, - { - id: 'deepseek-v3-250324', - provider: 'doubao', - name: 'DeepSeek-V3', - group: 'DeepSeek' - }, { id: 'doubao-pro-32k-241215', provider: 'doubao', From c660aaba3db6df6e6b5edfa135341da07c176446 Mon Sep 17 00:00:00 2001 From: beyondkmp Date: Sun, 22 Jun 2025 10:32:23 +0800 Subject: [PATCH 015/111] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E7=9B=AE=E5=BD=95=E8=BF=81=E7=A7=BB=E7=9A=84bug=20(#7?= =?UTF-8?q?386)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: move initAppDataDir function inline and remove export from utils/file.ts * fix some bugs * fix shouldcopy error * fix: handle appDataPath initialization and update logic in file.ts; update defaultChecked in DataSettings component * fix: improve appDataPath handling and migration logic in file.ts * fix: add error message for selecting the same app data path in DataSettings component and update localization files * fix: ensure migration confirmation modal is shown correctly in DataSettings component * feat: add new IPC channel for retrieving data path from arguments and update related components for migration handling * fix: update app data path validation to check for prefix match in DataSettings component * refactor: simplify data migration logic in DataSettings component by removing unnecessary flag * fix: update initAppDataDir invocation to check for app packaging status in bootstrap.ts --- packages/shared/IpcChannel.ts | 3 + src/main/bootstrap.ts | 5 + src/main/index.ts | 7 +- src/main/ipc.ts | 26 +++- src/main/utils/file.ts | 84 ++++++++---- src/preload/index.ts | 5 +- src/renderer/src/hooks/useAppInit.ts | 8 ++ src/renderer/src/i18n/locales/en-us.json | 9 +- src/renderer/src/i18n/locales/ja-jp.json | 11 +- src/renderer/src/i18n/locales/ru-ru.json | 9 +- src/renderer/src/i18n/locales/zh-cn.json | 9 +- src/renderer/src/i18n/locales/zh-tw.json | 9 +- .../settings/DataSettings/DataSettings.tsx | 129 ++++++++++++++---- 13 files changed, 240 insertions(+), 74 deletions(-) create mode 100644 src/main/bootstrap.ts diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index d02cd15be8..4c6988cf6e 100644 --- a/packages/shared/IpcChannel.ts +++ b/packages/shared/IpcChannel.ts @@ -20,6 +20,9 @@ export enum IpcChannel { App_Copy = 'app:copy', App_SetStopQuitApp = 'app:set-stop-quit-app', App_SetAppDataPath = 'app:set-app-data-path', + App_GetDataPathFromArgs = 'app:get-data-path-from-args', + App_FlushAppData = 'app:flush-app-data', + App_IsNotEmptyDir = 'app:is-not-empty-dir', App_RelaunchApp = 'app:relaunch-app', App_IsBinaryExist = 'app:is-binary-exist', App_GetBinaryPath = 'app:get-binary-path', diff --git a/src/main/bootstrap.ts b/src/main/bootstrap.ts new file mode 100644 index 0000000000..f682c06fcd --- /dev/null +++ b/src/main/bootstrap.ts @@ -0,0 +1,5 @@ +import { app } from 'electron' + +import { initAppDataDir } from './utils/file' + +app.isPackaged && initAppDataDir() diff --git a/src/main/index.ts b/src/main/index.ts index 102264317a..3699335a90 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,7 +1,11 @@ +// don't reorder this file, it's used to initialize the app data dir and +// other which should be run before the main process is ready +// eslint-disable-next-line +import './bootstrap' + import '@main/config' import { electronApp, optimizer } from '@electron-toolkit/utils' -import { initAppDataDir } from '@main/utils/file' import { replaceDevtoolsFont } from '@main/utils/windowUtil' import { app } from 'electron' import installExtension, { REACT_DEVELOPER_TOOLS, REDUX_DEVTOOLS } from 'electron-devtools-installer' @@ -22,7 +26,6 @@ import { registerShortcuts } from './services/ShortcutService' import { TrayService } from './services/TrayService' import { windowService } from './services/WindowService' -initAppDataDir() Logger.initialize() /** diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 1fc398b8c6..5e1ac819a9 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -34,7 +34,7 @@ import { setOpenLinkExternal } from './services/WebviewService' import { windowService } from './services/WindowService' import { calculateDirectorySize, getResourcePath } from './utils' import { decrypt, encrypt } from './utils/aes' -import { getCacheDir, getConfigDir, getFilesDir, hasWritePermission, updateConfig } from './utils/file' +import { getCacheDir, getConfigDir, getFilesDir, hasWritePermission, updateAppDataConfig } from './utils/file' import { compress, decompress } from './utils/zip' const fileManager = new FileStorage() @@ -218,10 +218,28 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { // Set app data path ipcMain.handle(IpcChannel.App_SetAppDataPath, async (_, filePath: string) => { - updateConfig(filePath) + updateAppDataConfig(filePath) app.setPath('userData', filePath) }) + ipcMain.handle(IpcChannel.App_GetDataPathFromArgs, () => { + return process.argv + .slice(1) + .find((arg) => arg.startsWith('--new-data-path=')) + ?.split('--new-data-path=')[1] + }) + + ipcMain.handle(IpcChannel.App_FlushAppData, () => { + BrowserWindow.getAllWindows().forEach((w) => { + w.webContents.session.flushStorageData() + w.webContents.session.cookies.flushStore() + }) + }) + + ipcMain.handle(IpcChannel.App_IsNotEmptyDir, async (_, path: string) => { + return fs.readdirSync(path).length > 0 + }) + // Copy user data to new location ipcMain.handle(IpcChannel.App_Copy, async (_, oldPath: string, newPath: string) => { try { @@ -234,8 +252,8 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { }) // Relaunch app - ipcMain.handle(IpcChannel.App_RelaunchApp, () => { - app.relaunch() + ipcMain.handle(IpcChannel.App_RelaunchApp, (_, options?: Electron.RelaunchOptions) => { + app.relaunch(options) app.exit(0) }) diff --git a/src/main/utils/file.ts b/src/main/utils/file.ts index 737dcee0ba..177a28a90f 100644 --- a/src/main/utils/file.ts +++ b/src/main/utils/file.ts @@ -8,6 +8,20 @@ import { FileType, FileTypes } from '@types' import { app } from 'electron' import { v4 as uuidv4 } from 'uuid' +export function initAppDataDir() { + const appDataPath = getAppDataPathFromConfig() + if (appDataPath) { + app.setPath('userData', appDataPath) + return + } + + if (isPortable) { + const portableDir = process.env.PORTABLE_EXECUTABLE_DIR + app.setPath('userData', path.join(portableDir || app.getPath('exe'), 'data')) + return + } +} + // 创建文件类型映射表,提高查找效率 const fileTypeMap = new Map() @@ -35,46 +49,70 @@ export function hasWritePermission(path: string) { function getAppDataPathFromConfig() { try { const configPath = path.join(getConfigDir(), 'config.json') - if (fs.existsSync(configPath)) { - const config = JSON.parse(fs.readFileSync(configPath, 'utf-8')) - if (config.appDataPath && fs.existsSync(config.appDataPath) && hasWritePermission(config.appDataPath)) { - return config.appDataPath - } + if (!fs.existsSync(configPath)) { + return null } + + const config = JSON.parse(fs.readFileSync(configPath, 'utf-8')) + + if (!config.appDataPath) { + return null + } + + let appDataPath = null + // 兼容旧版本 + if (config.appDataPath && typeof config.appDataPath === 'string') { + appDataPath = config.appDataPath + // 将旧版本数据迁移到新版本 + appDataPath && updateAppDataConfig(appDataPath) + } else { + appDataPath = config.appDataPath.find( + (item: { executablePath: string }) => item.executablePath === app.getPath('exe') + )?.dataPath + } + + if (appDataPath && fs.existsSync(appDataPath) && hasWritePermission(appDataPath)) { + return appDataPath + } + + return null } catch (error) { return null } - return null } -export function initAppDataDir() { - const appDataPath = getAppDataPathFromConfig() - if (appDataPath) { - app.setPath('userData', appDataPath) - return - } - - if (isPortable) { - const portableDir = process.env.PORTABLE_EXECUTABLE_DIR - app.setPath('userData', path.join(portableDir || app.getPath('exe'), 'data')) - return - } -} - -export function updateConfig(appDataPath: string) { +export function updateAppDataConfig(appDataPath: string) { const configDir = getConfigDir() if (!fs.existsSync(configDir)) { fs.mkdirSync(configDir, { recursive: true }) } + // config.json + // appDataPath: [{ executablePath: string, dataPath: string }] const configPath = path.join(getConfigDir(), 'config.json') if (!fs.existsSync(configPath)) { - fs.writeFileSync(configPath, JSON.stringify({ appDataPath }, null, 2)) + fs.writeFileSync( + configPath, + JSON.stringify({ appDataPath: [{ executablePath: app.getPath('exe'), dataPath: appDataPath }] }, null, 2) + ) return } const config = JSON.parse(fs.readFileSync(configPath, 'utf-8')) - config.appDataPath = appDataPath + if (!config.appDataPath || (config.appDataPath && typeof config.appDataPath !== 'object')) { + config.appDataPath = [] + } + + const existingPath = config.appDataPath.find( + (item: { executablePath: string }) => item.executablePath === app.getPath('exe') + ) + + if (existingPath) { + existingPath.dataPath = appDataPath + } else { + config.appDataPath.push({ executablePath: app.getPath('exe'), dataPath: appDataPath }) + } + fs.writeFileSync(configPath, JSON.stringify(config, null, 2)) } diff --git a/src/preload/index.ts b/src/preload/index.ts index 2a8ac3df89..5138d4e4de 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -29,9 +29,12 @@ const api = { select: (options: Electron.OpenDialogOptions) => ipcRenderer.invoke(IpcChannel.App_Select, options), hasWritePermission: (path: string) => ipcRenderer.invoke(IpcChannel.App_HasWritePermission, path), setAppDataPath: (path: string) => ipcRenderer.invoke(IpcChannel.App_SetAppDataPath, path), + getDataPathFromArgs: () => ipcRenderer.invoke(IpcChannel.App_GetDataPathFromArgs), copy: (oldPath: string, newPath: string) => ipcRenderer.invoke(IpcChannel.App_Copy, oldPath, newPath), setStopQuitApp: (stop: boolean, reason: string) => ipcRenderer.invoke(IpcChannel.App_SetStopQuitApp, stop, reason), - relaunchApp: () => ipcRenderer.invoke(IpcChannel.App_RelaunchApp), + flushAppData: () => ipcRenderer.invoke(IpcChannel.App_FlushAppData), + isNotEmptyDir: (path: string) => ipcRenderer.invoke(IpcChannel.App_IsNotEmptyDir, path), + relaunchApp: (options?: Electron.RelaunchOptions) => ipcRenderer.invoke(IpcChannel.App_RelaunchApp, options), openWebsite: (url: string) => ipcRenderer.invoke(IpcChannel.Open_Website, url), getCacheSize: () => ipcRenderer.invoke(IpcChannel.App_GetCacheSize), clearCache: () => ipcRenderer.invoke(IpcChannel.App_ClearCache), diff --git a/src/renderer/src/hooks/useAppInit.ts b/src/renderer/src/hooks/useAppInit.ts index 531582708d..8b5fe0ade6 100644 --- a/src/renderer/src/hooks/useAppInit.ts +++ b/src/renderer/src/hooks/useAppInit.ts @@ -30,6 +30,14 @@ export function useAppInit() { console.timeEnd('init') }, []) + useEffect(() => { + window.api.getDataPathFromArgs().then((dataPath) => { + if (dataPath) { + window.navigate('/settings/data', { replace: true }) + } + }) + }, []) + useUpdateHandler() useFullScreenNotice() diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index b62c708825..b8bcfb66b2 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -1087,10 +1087,10 @@ "app_data": "App Data", "app_data.select": "Modify Directory", "app_data.select_title": "Change App Data Directory", - "app_data.restart_notice": "The app will need to restart to apply the changes", - "app_data.copy_data_option": "Copy data from original directory to new directory", + "app_data.restart_notice": "The app may need to restart multiple times to apply the changes", + "app_data.copy_data_option": "Copy data, will automatically restart after copying the original directory data to the new directory", "app_data.copy_time_notice": "Copying data may take a while, do not force quit app", - "app_data.path_changed_without_copy": "Path changed successfully, but data not copied", + "app_data.path_changed_without_copy": "Path changed successfully", "app_data.copying_warning": "Data copying, do not force quit app", "app_data.copying": "Copying data to new location...", "app_data.copy_success": "Successfully copied data to new location", @@ -1103,6 +1103,9 @@ "app_data.select_error_root_path": "New path cannot be the root path", "app_data.select_error_write_permission": "New path does not have write permission", "app_data.stop_quit_app_reason": "The app is currently migrating data and cannot be exited", + "app_data.select_not_empty_dir": "New path is not empty", + "app_data.select_not_empty_dir_content": "New path is not empty, if you select copy, it will overwrite the data in the new path, there is a risk of data loss, continue?", + "app_data.select_error_same_path": "New path is the same as the old path, please select another path", "app_knowledge": "Knowledge Base Files", "app_knowledge.button.delete": "Delete File", "app_knowledge.remove_all": "Remove Knowledge Base Files", diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index bc363a0e91..430cba8351 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -1085,10 +1085,10 @@ "app_data": "アプリデータ", "app_data.select": "ディレクトリを変更", "app_data.select_title": "アプリデータディレクトリの変更", - "app_data.restart_notice": "変更を適用するには、アプリを再起動する必要があります", - "app_data.copy_data_option": "データをコピーする, 開くと元のディレクトリのデータが新しいディレクトリにコピーされます", - "app_data.copy_time_notice": "データコピーには時間がかかります。アプリを強制終了しないでください", - "app_data.path_changed_without_copy": "パスが変更されましたが、データがコピーされていません", + "app_data.restart_notice": "変更を適用するには、アプリを再起動する必要があります。", + "app_data.copy_data_option": "データをコピーする, 開くと元のディレクトリのデータが新しいディレクトリにコピーされます。", + "app_data.copy_time_notice": "データコピーには時間がかかります。アプリを強制終了しないでください。", + "app_data.path_changed_without_copy": "パスが変更されました。", "app_data.copying_warning": "データコピー中、アプリを強制終了しないでください", "app_data.copying": "新しい場所にデータをコピーしています...", "app_data.copy_success": "データを新しい場所に正常にコピーしました", @@ -1101,6 +1101,9 @@ "app_data.select_error_root_path": "新しいパスはルートパスにできません", "app_data.select_error_write_permission": "新しいパスに書き込み権限がありません", "app_data.stop_quit_app_reason": "アプリは現在データを移行しているため、終了できません", + "app_data.select_not_empty_dir": "新しいパスは空ではありません", + "app_data.select_not_empty_dir_content": "新しいパスは空ではありません。コピーを選択すると、新しいパスのデータが上書きされます。データが失われるリスクがあります。続行しますか?", + "app_data.select_error_same_path": "新しいパスは元のパスと同じです。別のパスを選択してください", "app_knowledge": "知識ベースファイル", "app_knowledge.button.delete": "ファイルを削除", "app_knowledge.remove_all": "ナレッジベースファイルを削除", diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index 8b325b4b31..af26cddf92 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -1085,10 +1085,10 @@ "app_data": "Данные приложения", "app_data.select": "Изменить директорию", "app_data.select_title": "Изменить директорию данных приложения", - "app_data.restart_notice": "Для применения изменений потребуется перезапуск приложения", - "app_data.copy_data_option": "Копировать данные из исходной директории в новую директорию", + "app_data.restart_notice": "Для применения изменений может потребоваться несколько перезапусков приложения", + "app_data.copy_data_option": "Копировать данные, будет автоматически перезапущено после копирования данных из исходной директории в новую директорию", "app_data.copy_time_notice": "Копирование данных из исходной директории займет некоторое время, пожалуйста, будьте терпеливы", - "app_data.path_changed_without_copy": "Путь изменен успешно, но данные не скопированы", + "app_data.path_changed_without_copy": "Путь изменен успешно", "app_data.copying_warning": "Копирование данных, нельзя взаимодействовать с приложением, не закрывайте приложение", "app_data.copying": "Копирование данных в новое место...", "app_data.copy_success": "Данные успешно скопированы в новое место", @@ -1101,6 +1101,9 @@ "app_data.select_error_root_path": "Новый путь не может быть корневым", "app_data.select_error_write_permission": "Новый путь не имеет разрешения на запись", "app_data.stop_quit_app_reason": "Приложение в настоящее время перемещает данные и не может быть закрыто", + "app_data.select_not_empty_dir": "Новый путь не пуст", + "app_data.select_not_empty_dir_content": "Новый путь не пуст, если вы выбираете копирование, он перезапишет данные в новом пути, есть риск потери данных, продолжить?", + "app_data.select_error_same_path": "Новый путь совпадает с исходным путем, пожалуйста, выберите другой путь", "app_knowledge": "Файлы базы знаний", "app_knowledge.button.delete": "Удалить файл", "app_knowledge.remove_all": "Удалить файлы базы знаний", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index a60589c71a..fd393e36ce 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -1087,10 +1087,10 @@ "app_data": "应用数据", "app_data.select": "修改目录", "app_data.select_title": "更改应用数据目录", - "app_data.restart_notice": "应用需要重启以应用更改", - "app_data.copy_data_option": "复制数据,开启后会将原始目录数据复制到新目录", + "app_data.restart_notice": "应用可能会重启多次以应用更改", + "app_data.copy_data_option": "复制数据,会自动重启后将原始目录数据复制到新目录", "app_data.copy_time_notice": "复制数据将需要一些时间,复制期间不要关闭应用", - "app_data.path_changed_without_copy": "路径已更改成功,但数据未复制", + "app_data.path_changed_without_copy": "路径已更改成功", "app_data.copying_warning": "数据复制中,不要强制退出app", "app_data.copying": "正在将数据复制到新位置...", "app_data.copy_success": "已成功复制数据到新位置", @@ -1103,6 +1103,9 @@ "app_data.select_error_root_path": "新路径不能是根路径", "app_data.select_error_write_permission": "新路径没有写入权限", "app_data.stop_quit_app_reason": "应用目前在迁移数据, 不能退出", + "app_data.select_not_empty_dir": "新路径不为空", + "app_data.select_not_empty_dir_content": "新路径不为空,选择复制将覆盖新路径中的数据, 有数据丢失的风险,是否继续?", + "app_data.select_error_same_path": "新路径与旧路径相同,请选择其他路径", "app_knowledge": "知识库文件", "app_knowledge.button.delete": "删除文件", "app_knowledge.remove_all": "删除知识库文件", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 4742cff3a3..995bee24bc 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -1087,10 +1087,10 @@ "app_data": "應用數據", "app_data.select": "修改目錄", "app_data.select_title": "變更應用數據目錄", - "app_data.restart_notice": "變更數據目錄後需要重啟應用才能生效", - "app_data.copy_data_option": "複製數據, 開啟後會將原始目錄數據複製到新目錄", + "app_data.restart_notice": "變更數據目錄後可能需要重啟應用才能生效", + "app_data.copy_data_option": "複製數據, 會自動重啟後將原始目錄數據複製到新目錄", "app_data.copy_time_notice": "複製數據將需要一些時間,複製期間不要關閉應用", - "app_data.path_changed_without_copy": "路徑已變更成功,但數據未複製", + "app_data.path_changed_without_copy": "路徑已變更成功", "app_data.copying_warning": "數據複製中,不要強制退出應用", "app_data.copying": "正在複製數據到新位置...", "app_data.copy_success": "成功複製數據到新位置", @@ -1103,6 +1103,9 @@ "app_data.select_error_root_path": "新路徑不能是根路徑", "app_data.select_error_write_permission": "新路徑沒有寫入權限", "app_data.stop_quit_app_reason": "應用目前正在遷移數據,不能退出", + "app_data.select_not_empty_dir": "新路徑不為空", + "app_data.select_not_empty_dir_content": "新路徑不為空,選擇複製將覆蓋新路徑中的數據, 有數據丟失的風險,是否繼續?", + "app_data.select_error_same_path": "新路徑與舊路徑相同,請選擇其他路徑", "app_knowledge": "知識庫文件", "app_knowledge.button.delete": "刪除檔案", "app_knowledge.remove_all": "刪除知識庫檔案", diff --git a/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx b/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx index 5a80545345..52b1c0bbb7 100644 --- a/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx +++ b/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx @@ -202,6 +202,12 @@ const DataSettings: FC = () => { return } + // check new app data path is same as old app data path + if (newAppDataPath.startsWith(appInfo!.appDataPath)) { + window.message.error(t('settings.data.app_data.select_error_same_path')) + return + } + // check new app data path has write permission const hasWritePermission = await window.api.hasWritePermission(newAppDataPath) if (!hasWritePermission) { @@ -213,22 +219,34 @@ const DataSettings: FC = () => {
{t('settings.data.app_data.migration_title')}
) const migrationClassName = 'migration-modal' - const messageKey = 'data-migration' - // 显示确认对话框 - showMigrationConfirmModal(appInfo.appDataPath, newAppDataPath, migrationTitle, migrationClassName, messageKey) + if (await window.api.isNotEmptyDir(newAppDataPath)) { + const modal = window.modal.confirm({ + title: t('settings.data.app_data.select_not_empty_dir'), + content: t('settings.data.app_data.select_not_empty_dir_content'), + centered: true, + okText: t('common.confirm'), + cancelText: t('common.cancel'), + onOk: () => { + modal.destroy() + // 显示确认对话框 + showMigrationConfirmModal(appInfo.appDataPath, newAppDataPath, migrationTitle, migrationClassName) + } + }) + return + } + showMigrationConfirmModal(appInfo.appDataPath, newAppDataPath, migrationTitle, migrationClassName) } // 显示确认迁移的对话框 - const showMigrationConfirmModal = ( + const showMigrationConfirmModal = async ( originalPath: string, newPath: string, title: React.ReactNode, - className: string, - messageKey: string + className: string ) => { // 复制数据选项状态 - let shouldCopyData = true + let shouldCopyData = !(await window.api.isNotEmptyDir(newPath)) // 创建路径内容组件 const PathsContent = () => ( @@ -248,7 +266,7 @@ const DataSettings: FC = () => {
{ shouldCopyData = checked }} @@ -290,22 +308,17 @@ const DataSettings: FC = () => { // 立即关闭确认对话框 modal.destroy() - // 设置停止退出应用 - window.api.setStopQuitApp(true, t('settings.data.app_data.stop_quit_app_reason')) - if (shouldCopyData) { // 如果选择复制数据,显示进度模态框并执行迁移 - const { loadingModal, progressInterval, updateProgress } = showProgressModal(title, className, PathsContent) - - try { - await startMigration(originalPath, newPath, progressInterval, updateProgress, loadingModal, messageKey) - } catch (error) { - if (progressInterval) { - clearInterval(progressInterval) - } - loadingModal.destroy() - throw error - } + window.message.info({ + content: t('settings.data.app_data.restart_notice'), + duration: 3 + }) + setTimeout(() => { + window.api.relaunchApp({ + args: ['--new-data-path=' + newPath] + }) + }, 300) } else { // 如果不复制数据,直接设置新的应用数据路径 await window.api.setAppDataPath(newPath) @@ -324,12 +337,7 @@ const DataSettings: FC = () => { } catch (error) { window.api.setStopQuitApp(false, '') window.message.error({ - content: - (shouldCopyData - ? t('settings.data.app_data.copy_failed') - : t('settings.data.app_data.path_change_failed')) + - ': ' + - error, + content: t('settings.data.app_data.path_change_failed') + ': ' + error, duration: 5 }) } @@ -337,6 +345,68 @@ const DataSettings: FC = () => { }) } + useEffect(() => { + const handleDataMigration = async () => { + const newDataPath = await window.api.getDataPathFromArgs() + if (!newDataPath) return + + const originalPath = (await window.api.getAppInfo())?.appDataPath + if (!originalPath) return + + const title = ( +
{t('settings.data.app_data.migration_title')}
+ ) + const className = 'migration-modal' + const messageKey = 'data-migration' + + // Create PathsContent component for this specific migration + const PathsContent = () => ( +
+ + {t('settings.data.app_data.original_path')}: + {originalPath} + + + {t('settings.data.app_data.new_path')}: + {newDataPath} + +
+ ) + + const { loadingModal, progressInterval, updateProgress } = showProgressModal(title, className, PathsContent) + try { + window.api.setStopQuitApp(true, t('settings.data.app_data.stop_quit_app_reason')) + await startMigration(originalPath, newDataPath, progressInterval, updateProgress, loadingModal, messageKey) + + // 更新应用数据路径 + setAppInfo(await window.api.getAppInfo()) + + // 通知用户并重启应用 + setTimeout(() => { + window.message.success(t('settings.data.app_data.select_success')) + window.api.setStopQuitApp(false, '') + window.api.relaunchApp({ + args: ['--user-data-dir=' + newDataPath] + }) + }, 1000) + } catch (error) { + window.api.setStopQuitApp(false, '') + window.message.error({ + content: t('settings.data.app_data.copy_failed') + ': ' + error, + key: messageKey, + duration: 5 + }) + } finally { + if (progressInterval) { + clearInterval(progressInterval) + } + loadingModal.destroy() + } + } + + handleDataMigration() + }, []) + // 显示进度模态框 const showProgressModal = (title: React.ReactNode, className: string, PathsContent: React.FC) => { let currentProgress = 0 @@ -411,6 +481,9 @@ const DataSettings: FC = () => { loadingModal: { destroy: () => void }, messageKey: string ): Promise => { + // flush app data + await window.api.flushAppData() + // 开始复制过程 const copyResult = await window.api.copy(originalPath, newPath) From d9b8e68c300aaf2fca605424144a685f67555ba1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=87=AA=E7=94=B1=E7=9A=84=E4=B8=96=E7=95=8C=E4=BA=BA?= <3196812536@qq.com> Date: Sun, 22 Jun 2025 12:28:31 +0800 Subject: [PATCH 016/111] fix: update source language handling and persist user selection in TranslatePage component (#7243) --- .../src/pages/translate/TranslatePage.tsx | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/renderer/src/pages/translate/TranslatePage.tsx b/src/renderer/src/pages/translate/TranslatePage.tsx index 1d8edd93d0..11db557bf9 100644 --- a/src/renderer/src/pages/translate/TranslatePage.tsx +++ b/src/renderer/src/pages/translate/TranslatePage.tsx @@ -229,7 +229,7 @@ const TranslatePage: FC = () => { const [bidirectionalPair, setBidirectionalPair] = useState<[string, string]>(['english', 'chinese']) const [settingsVisible, setSettingsVisible] = useState(false) const [detectedLanguage, setDetectedLanguage] = useState(null) - const [sourceLanguage, setSourceLanguage] = useState('auto') // 添加用户选择的源语言状态 + const [sourceLanguage, setSourceLanguage] = useState('auto') const contentContainerRef = useRef(null) const textAreaRef = useRef(null) const outputTextRef = useRef(null) @@ -307,8 +307,7 @@ const TranslatePage: FC = () => { let actualSourceLanguage: string if (sourceLanguage === 'auto') { actualSourceLanguage = await detectLanguage(text) - console.log('检测到的语言:', actualSourceLanguage) - setDetectedLanguage(actualSourceLanguage) // 更新检测到的语言 + setDetectedLanguage(actualSourceLanguage) } else { actualSourceLanguage = sourceLanguage } @@ -385,6 +384,9 @@ const TranslatePage: FC = () => { const targetLang = await db.settings.get({ id: 'translate:target:language' }) targetLang && setTargetLanguage(targetLang.value) + const sourceLang = await db.settings.get({ id: 'translate:source:language' }) + sourceLang && setSourceLanguage(sourceLang.value) + const bidirectionalPairSetting = await db.settings.get({ id: 'translate:bidirectional:pair' }) if (bidirectionalPairSetting) { const langPair = bidirectionalPairSetting.value @@ -526,12 +528,15 @@ const TranslatePage: FC = () => { value={sourceLanguage} style={{ width: 180 }} optionFilterProp="label" - onChange={(value) => setSourceLanguage(value)} + onChange={(value) => { + setSourceLanguage(value) + db.settings.put({ id: 'translate:source:language', value }) + }} options={[ { value: 'auto', label: detectedLanguage - ? `${t('translate.detected.language')}(${t(`languages.${detectedLanguage.toLowerCase()}`)})` + ? `${t('translate.detected.language')} (${t(`languages.${detectedLanguage.toLowerCase()}`)})` : t('translate.detected.language') }, ...translateLanguageOptions().map((lang) => ({ From 50d6f1f8314e53eebcfb1bd576074f8fc5466c84 Mon Sep 17 00:00:00 2001 From: Wang Jiyuan <59059173+EurFelux@users.noreply.github.com> Date: Sun, 22 Jun 2025 17:03:43 +0800 Subject: [PATCH 017/111] refactor(middleware): Add error property to CompletionResult and handle errors when checking API (#7407) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(aiCore): 添加错误处理 * remove console.log --- .../src/aiCore/middleware/common/ErrorHandlerMiddleware.ts | 3 ++- src/renderer/src/aiCore/middleware/schemas.ts | 2 +- src/renderer/src/services/ApiService.ts | 6 ++++++ 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/aiCore/middleware/common/ErrorHandlerMiddleware.ts b/src/renderer/src/aiCore/middleware/common/ErrorHandlerMiddleware.ts index 8875a0b627..ad023e4285 100644 --- a/src/renderer/src/aiCore/middleware/common/ErrorHandlerMiddleware.ts +++ b/src/renderer/src/aiCore/middleware/common/ErrorHandlerMiddleware.ts @@ -25,7 +25,7 @@ export const ErrorHandlerMiddleware = // 尝试执行下一个中间件 return await next(ctx, params) } catch (error: any) { - console.log('ErrorHandlerMiddleware_error', error) + console.error('ErrorHandlerMiddleware_error', error) // 1. 使用通用的工具函数将错误解析为标准格式 const errorChunk = createErrorChunk(error) // 2. 调用从外部传入的 onError 回调 @@ -50,6 +50,7 @@ export const ErrorHandlerMiddleware = rawOutput: undefined, stream: errorStream, // 将包含错误的流传递下去 controller: undefined, + error: typeof error?.message === 'string' ? error.message : 'unknown error', getText: () => '' // 错误情况下没有文本结果 } } diff --git a/src/renderer/src/aiCore/middleware/schemas.ts b/src/renderer/src/aiCore/middleware/schemas.ts index 33d9816b4f..02d7199454 100644 --- a/src/renderer/src/aiCore/middleware/schemas.ts +++ b/src/renderer/src/aiCore/middleware/schemas.ts @@ -62,7 +62,7 @@ export interface CompletionsResult { rawOutput?: SdkRawOutput stream?: ReadableStream | ReadableStream | AsyncIterable controller?: AbortController - + error?: string getText: () => string } diff --git a/src/renderer/src/services/ApiService.ts b/src/renderer/src/services/ApiService.ts index 46c5fd849c..b962ec8917 100644 --- a/src/renderer/src/services/ApiService.ts +++ b/src/renderer/src/services/ApiService.ts @@ -595,6 +595,9 @@ export async function checkApi(provider: Provider, model: Model): Promise // Try streaming check first const result = await ai.completions(params) + if (result.error) { + throw new Error(result.error) + } if (!result.getText()) { throw new Error('No response received') } @@ -608,6 +611,9 @@ export async function checkApi(provider: Provider, model: Model): Promise streamOutput: false } const result = await ai.completions(params) + if (result.error) { + throw new Error(result.error) + } if (!result.getText()) { throw new Error('No response received') } From 355d2aebb40332c6615ca631f1e4174a7693ca56 Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Sun, 22 Jun 2025 17:31:43 +0800 Subject: [PATCH 018/111] chore(version): 1.4.5 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index dc29289380..1cdfc9296b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "CherryStudio", - "version": "1.4.4", + "version": "1.4.5", "private": true, "description": "A powerful AI assistant for producer.", "main": "./out/main/index.js", From 2350919f366f3b289169e84553cdee879ee8925a Mon Sep 17 00:00:00 2001 From: Wang Jiyuan <59059173+EurFelux@users.noreply.github.com> Date: Sun, 22 Jun 2025 21:33:17 +0800 Subject: [PATCH 019/111] fix: use shouldThrow param in checkApi instead of adding error property to CompletionsResult (#7457) * Revert "refactor(middleware): Add error property to CompletionResult and handle errors when checking API (#7407)" This reverts commit 50d6f1f8314e53eebcfb1bd576074f8fc5466c84. * fix: use shouldThrow param in checkApi --- .../middleware/common/ErrorHandlerMiddleware.ts | 3 +-- src/renderer/src/aiCore/middleware/schemas.ts | 2 +- src/renderer/src/services/ApiService.ts | 12 ++++-------- 3 files changed, 6 insertions(+), 11 deletions(-) diff --git a/src/renderer/src/aiCore/middleware/common/ErrorHandlerMiddleware.ts b/src/renderer/src/aiCore/middleware/common/ErrorHandlerMiddleware.ts index ad023e4285..8875a0b627 100644 --- a/src/renderer/src/aiCore/middleware/common/ErrorHandlerMiddleware.ts +++ b/src/renderer/src/aiCore/middleware/common/ErrorHandlerMiddleware.ts @@ -25,7 +25,7 @@ export const ErrorHandlerMiddleware = // 尝试执行下一个中间件 return await next(ctx, params) } catch (error: any) { - console.error('ErrorHandlerMiddleware_error', error) + console.log('ErrorHandlerMiddleware_error', error) // 1. 使用通用的工具函数将错误解析为标准格式 const errorChunk = createErrorChunk(error) // 2. 调用从外部传入的 onError 回调 @@ -50,7 +50,6 @@ export const ErrorHandlerMiddleware = rawOutput: undefined, stream: errorStream, // 将包含错误的流传递下去 controller: undefined, - error: typeof error?.message === 'string' ? error.message : 'unknown error', getText: () => '' // 错误情况下没有文本结果 } } diff --git a/src/renderer/src/aiCore/middleware/schemas.ts b/src/renderer/src/aiCore/middleware/schemas.ts index 02d7199454..33d9816b4f 100644 --- a/src/renderer/src/aiCore/middleware/schemas.ts +++ b/src/renderer/src/aiCore/middleware/schemas.ts @@ -62,7 +62,7 @@ export interface CompletionsResult { rawOutput?: SdkRawOutput stream?: ReadableStream | ReadableStream | AsyncIterable controller?: AbortController - error?: string + getText: () => string } diff --git a/src/renderer/src/services/ApiService.ts b/src/renderer/src/services/ApiService.ts index b962ec8917..0dfad984af 100644 --- a/src/renderer/src/services/ApiService.ts +++ b/src/renderer/src/services/ApiService.ts @@ -590,14 +590,12 @@ export async function checkApi(provider: Provider, model: Model): Promise callType: 'check', messages: 'hi', assistant, - streamOutput: true + streamOutput: true, + shouldThrow: true } // Try streaming check first const result = await ai.completions(params) - if (result.error) { - throw new Error(result.error) - } if (!result.getText()) { throw new Error('No response received') } @@ -608,12 +606,10 @@ export async function checkApi(provider: Provider, model: Model): Promise callType: 'check', messages: 'hi', assistant, - streamOutput: false + streamOutput: false, + shouldThrow: true } const result = await ai.completions(params) - if (result.error) { - throw new Error(result.error) - } if (!result.getText()) { throw new Error('No response received') } From a8e23966fa1d3d97e15dddacd4d40c0a6368c481 Mon Sep 17 00:00:00 2001 From: Tristan Zhang <82869104+ABucket@users.noreply.github.com> Date: Mon, 23 Jun 2025 08:55:03 +0800 Subject: [PATCH 020/111] feat(FileStorage): add support for .doc files using word-extractor (#7374) * feat(FileStorage): add support for .doc files and integrate word-extractor * chore(package): add word-extractor to devdependencies --- package.json | 2 ++ packages/shared/config/constant.ts | 2 +- src/main/loader/index.ts | 1 + src/main/services/FileStorage.ts | 13 +++++++++++- src/main/utils/__tests__/file.test.ts | 1 + yarn.lock | 30 +++++++++++++++++++++++++++ 6 files changed, 47 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 1cdfc9296b..7a673b8417 100644 --- a/package.json +++ b/package.json @@ -124,6 +124,7 @@ "@types/react-infinite-scroll-component": "^5.0.0", "@types/react-window": "^1", "@types/tinycolor2": "^1", + "@types/word-extractor": "^1", "@uiw/codemirror-extensions-langs": "^4.23.12", "@uiw/codemirror-themes-all": "^4.23.12", "@uiw/react-codemirror": "^4.23.12", @@ -218,6 +219,7 @@ "vite": "6.2.6", "vitest": "^3.1.4", "webdav": "^5.8.0", + "word-extractor": "^1.0.4", "zipread": "^1.3.3" }, "resolutions": { diff --git a/packages/shared/config/constant.ts b/packages/shared/config/constant.ts index 5a3465f648..719600650e 100644 --- a/packages/shared/config/constant.ts +++ b/packages/shared/config/constant.ts @@ -1,7 +1,7 @@ export const imageExts = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp'] export const videoExts = ['.mp4', '.avi', '.mov', '.wmv', '.flv', '.mkv'] export const audioExts = ['.mp3', '.wav', '.ogg', '.flac', '.aac'] -export const documentExts = ['.pdf', '.docx', '.pptx', '.xlsx', '.odt', '.odp', '.ods'] +export const documentExts = ['.pdf', '.doc', '.docx', '.pptx', '.xlsx', '.odt', '.odp', '.ods'] export const thirdPartyApplicationExts = ['.draftsExport'] export const bookExts = ['.epub'] const textExtsByCategory = new Map([ diff --git a/src/main/loader/index.ts b/src/main/loader/index.ts index db837f414f..ba66b33e3d 100644 --- a/src/main/loader/index.ts +++ b/src/main/loader/index.ts @@ -16,6 +16,7 @@ const FILE_LOADER_MAP: Record = { // 内置类型 '.pdf': 'common', '.csv': 'common', + '.doc': 'common', '.docx': 'common', '.pptx': 'common', '.xlsx': 'common', diff --git a/src/main/services/FileStorage.ts b/src/main/services/FileStorage.ts index 2ac689b8cc..437f25f78c 100644 --- a/src/main/services/FileStorage.ts +++ b/src/main/services/FileStorage.ts @@ -220,10 +220,21 @@ class FileStorage { public readFile = async (_: Electron.IpcMainInvokeEvent, id: string): Promise => { const filePath = path.join(this.storageDir, id) - if (documentExts.includes(path.extname(filePath))) { + const fileExtension = path.extname(filePath) + + if (documentExts.includes(fileExtension)) { const originalCwd = process.cwd() try { chdir(this.tempDir) + + if (fileExtension === '.doc') { + const WordExtractor = require('word-extractor') + const extractor = new WordExtractor() + const extracted = await extractor.extract(filePath) + chdir(originalCwd) + return extracted.getBody() + } + const data = await officeParser.parseOfficeAsync(filePath) chdir(originalCwd) return data diff --git a/src/main/utils/__tests__/file.test.ts b/src/main/utils/__tests__/file.test.ts index aae00e85d4..14f4801524 100644 --- a/src/main/utils/__tests__/file.test.ts +++ b/src/main/utils/__tests__/file.test.ts @@ -92,6 +92,7 @@ describe('file', () => { it('should return DOCUMENT for document extensions', () => { expect(getFileType('.pdf')).toBe(FileTypes.DOCUMENT) expect(getFileType('.pptx')).toBe(FileTypes.DOCUMENT) + expect(getFileType('.doc')).toBe(FileTypes.DOCUMENT) expect(getFileType('.docx')).toBe(FileTypes.DOCUMENT) expect(getFileType('.xlsx')).toBe(FileTypes.DOCUMENT) expect(getFileType('.odt')).toBe(FileTypes.DOCUMENT) diff --git a/yarn.lock b/yarn.lock index b066f44a11..eefde56f9c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4754,6 +4754,15 @@ __metadata: languageName: node linkType: hard +"@types/word-extractor@npm:^1": + version: 1.0.6 + resolution: "@types/word-extractor@npm:1.0.6" + dependencies: + "@types/node": "npm:*" + checksum: 10c0/84f89c458213db5aec4d6badad14e0f2c07ac4b92f16165d19a95548f2b98fd5fff00419d49547464cb75c9432b5e9cb3b452d75eb5f07d808e31b44be390453 + languageName: node + linkType: hard + "@types/ws@npm:^8.5.4": version: 8.18.1 resolution: "@types/ws@npm:8.18.1" @@ -5642,6 +5651,7 @@ __metadata: "@types/react-infinite-scroll-component": "npm:^5.0.0" "@types/react-window": "npm:^1" "@types/tinycolor2": "npm:^1" + "@types/word-extractor": "npm:^1" "@uiw/codemirror-extensions-langs": "npm:^4.23.12" "@uiw/codemirror-themes-all": "npm:^4.23.12" "@uiw/react-codemirror": "npm:^4.23.12" @@ -5742,6 +5752,7 @@ __metadata: vite: "npm:6.2.6" vitest: "npm:^3.1.4" webdav: "npm:^5.8.0" + word-extractor: "npm:^1.0.4" zipread: "npm:^1.3.3" languageName: unknown linkType: soft @@ -16428,6 +16439,15 @@ __metadata: languageName: node linkType: hard +"saxes@npm:^5.0.1": + version: 5.0.1 + resolution: "saxes@npm:5.0.1" + dependencies: + xmlchars: "npm:^2.2.0" + checksum: 10c0/b7476c41dbe1c3a89907d2546fecfba234de5e66743ef914cde2603f47b19bed09732ab51b528ad0f98b958369d8be72b6f5af5c9cfad69972a73d061f0b3952 + languageName: node + linkType: hard + "saxes@npm:^6.0.0": version: 6.0.0 resolution: "saxes@npm:6.0.0" @@ -18632,6 +18652,16 @@ __metadata: languageName: node linkType: hard +"word-extractor@npm:^1.0.4": + version: 1.0.4 + resolution: "word-extractor@npm:1.0.4" + dependencies: + saxes: "npm:^5.0.1" + yauzl: "npm:^2.10.0" + checksum: 10c0/f8c6b4f9278802d0c803479c1441713e351e67f7b0d2f85bd8cbe94b76298d4adb058b5f23ee0a01faa02f3b1f01c507a4a2f44fa39cfcbd498a51769dd9e8e7 + languageName: node + linkType: hard + "word-wrap@npm:^1.2.5": version: 1.2.5 resolution: "word-wrap@npm:1.2.5" From b4c8e42d879d81e73d43980945df70d1b036dd45 Mon Sep 17 00:00:00 2001 From: purefkh Date: Mon, 23 Jun 2025 09:48:29 +0800 Subject: [PATCH 021/111] fix(rename): disable thinking for topic rename (#7461) --- src/renderer/src/services/ApiService.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/services/ApiService.ts b/src/renderer/src/services/ApiService.ts index 0dfad984af..fb2c073109 100644 --- a/src/renderer/src/services/ApiService.ts +++ b/src/renderer/src/services/ApiService.ts @@ -462,12 +462,23 @@ export async function fetchMessagesSummary({ messages, assistant }: { messages: }) const conversation = JSON.stringify(structredMessages) + // 复制 assistant 对象,并强制关闭思考预算 + const summaryAssistant = { + ...assistant, + settings: { + ...assistant.settings, + reasoning_effort: undefined, + qwenThinkMode: false + } + } + const params: CompletionsParams = { callType: 'summary', messages: conversation, - assistant: { ...assistant, prompt, model }, + assistant: { ...summaryAssistant, prompt, model }, maxTokens: 1000, - streamOutput: false + streamOutput: false, + enableReasoning: false } try { From 32d6c2e1d896f7ab90ea61565c64c75b401ae730 Mon Sep 17 00:00:00 2001 From: Wang Jiyuan <59059173+EurFelux@users.noreply.github.com> Date: Mon, 23 Jun 2025 09:59:50 +0800 Subject: [PATCH 022/111] feat(TopicsTab): Allow deletion of inactive topics (#7415) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(主题列表): 修复主题列表项悬停样式和菜单显示条件 调整主题列表项悬停时的背景色过渡效果,并修正菜单显示逻辑,仅在非挂起状态显示 * fix(TopicsTab): 移除话题待处理状态检查 * fix(TopicsTab): 修复删除话题时未检查当前活跃话题的问题 --- src/renderer/src/pages/home/Tabs/TopicsTab.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/pages/home/Tabs/TopicsTab.tsx b/src/renderer/src/pages/home/Tabs/TopicsTab.tsx index 6d370484fb..01a548b8c1 100644 --- a/src/renderer/src/pages/home/Tabs/TopicsTab.tsx +++ b/src/renderer/src/pages/home/Tabs/TopicsTab.tsx @@ -127,11 +127,13 @@ const Topics: FC = ({ assistant: _assistant, activeTopic, setActiveTopic } await modelGenerating() const index = findIndex(assistant.topics, (t) => t.id === topic.id) - setActiveTopic(assistant.topics[index + 1 === assistant.topics.length ? index - 1 : index + 1]) + if (topic.id === activeTopic.id) { + setActiveTopic(assistant.topics[index + 1 === assistant.topics.length ? index - 1 : index + 1]) + } removeTopic(topic) setDeletingTopicId(null) }, - [assistant.topics, onClearMessages, removeTopic, setActiveTopic] + [activeTopic.id, assistant.topics, onClearMessages, removeTopic, setActiveTopic] ) const onPinTopic = useCallback( @@ -471,7 +473,7 @@ const Topics: FC = ({ assistant: _assistant, activeTopic, setActiveTopic {topicName} - {isActive && !topic.pinned && ( + {!topic.pinned && ( Date: Mon, 23 Jun 2025 12:51:08 +0800 Subject: [PATCH 023/111] fix: re-add newline separator between reasoning_summary parts after openai middleware refactor (#7390) re-add newline separator between reasoning_summary parts after openai client refactor Signed-off-by: MurphyLo --- .../aiCore/clients/openai/OpenAIResponseAPIClient.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/renderer/src/aiCore/clients/openai/OpenAIResponseAPIClient.ts b/src/renderer/src/aiCore/clients/openai/OpenAIResponseAPIClient.ts index 5871a04cab..1f9af6ca77 100644 --- a/src/renderer/src/aiCore/clients/openai/OpenAIResponseAPIClient.ts +++ b/src/renderer/src/aiCore/clients/openai/OpenAIResponseAPIClient.ts @@ -425,6 +425,7 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient< const toolCalls: OpenAIResponseSdkToolCall[] = [] const outputItems: OpenAI.Responses.ResponseOutputItem[] = [] let hasBeenCollectedToolCalls = false + let hasReasoningSummary = false return () => ({ async transform(chunk: OpenAIResponseSdkRawChunk, controller: TransformStreamDefaultController) { // 处理chunk @@ -496,6 +497,16 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient< outputItems.push(chunk.item) } break + case 'response.reasoning_summary_part.added': + if (hasReasoningSummary) { + const separator = '\n\n' + controller.enqueue({ + type: ChunkType.THINKING_DELTA, + text: separator + }) + } + hasReasoningSummary = true + break case 'response.reasoning_summary_text.delta': controller.enqueue({ type: ChunkType.THINKING_DELTA, From 4f2c8bd905f1b8fb0785881dd48528d26aff6350 Mon Sep 17 00:00:00 2001 From: one Date: Mon, 23 Jun 2025 15:19:21 +0800 Subject: [PATCH 024/111] fix(Markdown): improve latex brackets handling (#7358) --- package.json | 2 + .../src/pages/home/Markdown/Markdown.tsx | 6 +- .../home/Markdown/__tests__/Markdown.test.tsx | 14 +- .../src/utils/__tests__/formats.test.ts | 31 --- .../src/utils/__tests__/markdown.test.ts | 195 ++++++++++++++++++ src/renderer/src/utils/formats.ts | 18 -- src/renderer/src/utils/markdown.ts | 80 +++++++ yarn.lock | 16 ++ 8 files changed, 298 insertions(+), 64 deletions(-) diff --git a/package.json b/package.json index 7a673b8417..ee795f17ee 100644 --- a/package.json +++ b/package.json @@ -112,6 +112,7 @@ "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", "@tryfabric/martian": "^1.2.4", + "@types/balanced-match": "^3", "@types/diff": "^7", "@types/fs-extra": "^11", "@types/lodash": "^4.17.5", @@ -138,6 +139,7 @@ "archiver": "^7.0.1", "async-mutex": "^0.5.0", "axios": "^1.7.3", + "balanced-match": "^3.0.1", "browser-image-compression": "^2.0.2", "color": "^5.0.0", "dayjs": "^1.11.11", diff --git a/src/renderer/src/pages/home/Markdown/Markdown.tsx b/src/renderer/src/pages/home/Markdown/Markdown.tsx index 2a6446fec7..454550c5c8 100644 --- a/src/renderer/src/pages/home/Markdown/Markdown.tsx +++ b/src/renderer/src/pages/home/Markdown/Markdown.tsx @@ -8,8 +8,8 @@ import { useSettings } from '@renderer/hooks/useSettings' import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' import type { MainTextMessageBlock, ThinkingMessageBlock, TranslationMessageBlock } from '@renderer/types/newMessage' import { parseJSON } from '@renderer/utils' -import { escapeBrackets, removeSvgEmptyLines } from '@renderer/utils/formats' -import { findCitationInChildren, getCodeBlockId } from '@renderer/utils/markdown' +import { removeSvgEmptyLines } from '@renderer/utils/formats' +import { findCitationInChildren, getCodeBlockId, processLatexBrackets } from '@renderer/utils/markdown' import { isEmpty } from 'lodash' import { type FC, memo, useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' @@ -52,7 +52,7 @@ const Markdown: FC = ({ block }) => { const empty = isEmpty(block.content) const paused = block.status === 'paused' const content = empty && paused ? t('message.chat.completion.paused') : block.content - return removeSvgEmptyLines(escapeBrackets(content)) + return removeSvgEmptyLines(processLatexBrackets(content)) }, [block, t]) const rehypePlugins = useMemo(() => { 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 abd7067ab0..be9b18c13b 100644 --- a/src/renderer/src/pages/home/Markdown/__tests__/Markdown.test.tsx +++ b/src/renderer/src/pages/home/Markdown/__tests__/Markdown.test.tsx @@ -42,13 +42,13 @@ vi.mock('@renderer/utils', () => ({ })) vi.mock('@renderer/utils/formats', () => ({ - escapeBrackets: vi.fn((str) => str), removeSvgEmptyLines: vi.fn((str) => str) })) vi.mock('@renderer/utils/markdown', () => ({ findCitationInChildren: vi.fn(() => '{"id": 1, "url": "https://example.com"}'), - getCodeBlockId: vi.fn(() => 'code-block-1') + getCodeBlockId: vi.fn(() => 'code-block-1'), + processLatexBrackets: vi.fn((str) => str) })) // Mock components with more realistic behavior @@ -212,16 +212,6 @@ describe('Markdown', () => { expect(markdown).not.toHaveTextContent('Paused') }) - it('should process content through format utilities', async () => { - const { escapeBrackets, removeSvgEmptyLines } = await import('@renderer/utils/formats') - const content = 'Content with [brackets] and SVG' - - render() - - expect(escapeBrackets).toHaveBeenCalledWith(content) - expect(removeSvgEmptyLines).toHaveBeenCalledWith(content) - }) - it('should match snapshot', () => { const { container } = render() expect(container.firstChild).toMatchSnapshot() diff --git a/src/renderer/src/utils/__tests__/formats.test.ts b/src/renderer/src/utils/__tests__/formats.test.ts index 5a817f45d9..09189b4526 100644 --- a/src/renderer/src/utils/__tests__/formats.test.ts +++ b/src/renderer/src/utils/__tests__/formats.test.ts @@ -6,7 +6,6 @@ import { describe, expect, it, vi } from 'vitest' import { addImageFileToContents, encodeHTML, - escapeBrackets, escapeDollarNumber, extractTitle, removeSvgEmptyLines, @@ -180,36 +179,6 @@ describe('formats', () => { }) }) - describe('escapeBrackets', () => { - it('should convert \\[...\\] to display math format', () => { - expect(escapeBrackets('The formula is \\[a+b=c\\]')).toBe('The formula is \n$$\na+b=c\n$$\n') - }) - - it('should convert \\(...\\) to inline math format', () => { - expect(escapeBrackets('The formula is \\(a+b=c\\)')).toBe('The formula is $a+b=c$') - }) - - it('should not affect code blocks', () => { - const codeBlock = 'This is text with a code block ```const x = \\[1, 2, 3\\]```' - expect(escapeBrackets(codeBlock)).toBe(codeBlock) - }) - - it('should not affect inline code', () => { - const inlineCode = 'This is text with `const x = \\[1, 2, 3\\]` inline code' - expect(escapeBrackets(inlineCode)).toBe(inlineCode) - }) - - it('should handle multiple occurrences', () => { - const input = 'Formula 1: \\[a+b=c\\] and formula 2: \\(x+y=z\\)' - const expected = 'Formula 1: \n$$\na+b=c\n$$\n and formula 2: $x+y=z$' - expect(escapeBrackets(input)).toBe(expected) - }) - - it('should handle empty string', () => { - expect(escapeBrackets('')).toBe('') - }) - }) - describe('extractTitle', () => { it('should extract title from HTML string', () => { const html = 'Page TitleContent' diff --git a/src/renderer/src/utils/__tests__/markdown.test.ts b/src/renderer/src/utils/__tests__/markdown.test.ts index e35550bf49..4f48deba0c 100644 --- a/src/renderer/src/utils/__tests__/markdown.test.ts +++ b/src/renderer/src/utils/__tests__/markdown.test.ts @@ -9,6 +9,7 @@ import { getCodeBlockId, getExtensionByLanguage, markdownToPlainText, + processLatexBrackets, removeTrailingDoubleSpaces, updateCodeBlock } from '../markdown' @@ -461,4 +462,198 @@ describe('markdown', () => { expect(markdownToPlainText('This is plain text.')).toBe('This is plain text.') }) }) + + describe('processLatexBrackets', () => { + describe('basic LaTeX conversion', () => { + it('should convert display math \\[...\\] to $$...$$', () => { + expect(processLatexBrackets('The formula is \\[a+b=c\\]')).toBe('The formula is $$a+b=c$$') + }) + + it('should convert inline math \\(...\\) to $...$', () => { + expect(processLatexBrackets('The formula is \\(a+b=c\\)')).toBe('The formula is $a+b=c$') + }) + }) + + describe('code block protection', () => { + it('should not affect multi-line code blocks', () => { + const input = 'Text ```const arr = \\[1, 2, 3\\]\\nconst func = \\(x\\) => x``` more text' + expect(processLatexBrackets(input)).toBe(input) + }) + + it('should not affect inline code', () => { + const input = 'This is text with `const x = \\[1, 2, 3\\]` inline code' + expect(processLatexBrackets(input)).toBe(input) + }) + + it('should handle mixed code and LaTeX', () => { + const input = 'Math: \\[x + y\\] and code: `arr = \\[1, 2\\]` and more math: \\(z\\)' + const expected = 'Math: $$x + y$$ and code: `arr = \\[1, 2\\]` and more math: $z$' + expect(processLatexBrackets(input)).toBe(expected) + }) + + it('should protect complex code blocks', () => { + for (const [input, expected] of new Map([ + [ + '```javascript\\nconst latex = "\\\\[formula\\\\]"\\n```', + '```javascript\\nconst latex = "\\\\[formula\\\\]"\\n```' + ], + ['`\\[escaped brackets\\]`', '`\\[escaped brackets\\]`'], + [ + '```\\narray = \\[\\n \\(item1\\),\\n \\(item2\\)\\n\\]\\n```', + '```\\narray = \\[\\n \\(item1\\),\\n \\(item2\\)\\n\\]\\n```' + ] + ])) { + expect(processLatexBrackets(input)).toBe(expected) + } + }) + }) + + describe('link protection', () => { + it('should not affect LaTeX in link text', () => { + const input = '[\\[pdf\\] Document](https://example.com/doc.pdf)' + expect(processLatexBrackets(input)).toBe(input) + }) + + it('should not affect LaTeX in link URLs', () => { + const input = '[Click here](https://example.com/path\\[with\\]brackets)' + expect(processLatexBrackets(input)).toBe(input) + }) + + it('should handle mixed links and LaTeX', () => { + const input = 'See [\\[pdf\\] file](url) for formula \\[x + y = z\\]' + const expected = 'See [\\[pdf\\] file](url) for formula $$x + y = z$$' + expect(processLatexBrackets(input)).toBe(expected) + }) + + it('should protect complex link patterns', () => { + for (const [input, expected] of new Map([ + ['[Title with \\(math\\)](https://example.com)', '[Title with \\(math\\)](https://example.com)'], + ['[Link](https://example.com/\\[path\\]/file)', '[Link](https://example.com/\\[path\\]/file)'], + [ + '[\\[Section 1\\] Overview](url) and \\[math formula\\]', + '[\\[Section 1\\] Overview](url) and $$math formula$$' + ] + ])) { + expect(processLatexBrackets(input)).toBe(expected) + } + }) + }) + + describe('edge cases', () => { + it('should handle empty string', () => { + expect(processLatexBrackets('')).toBe('') + }) + + it('should handle content without LaTeX', () => { + for (const [input, expected] of new Map([ + ['Regular text without math', 'Regular text without math'], + ['Text with [regular] brackets', 'Text with [regular] brackets'], + ['Text with (parentheses)', 'Text with (parentheses)'], + ['No special characters here', 'No special characters here'] + ])) { + expect(processLatexBrackets(input)).toBe(expected) + } + }) + + it('should handle malformed LaTeX patterns', () => { + for (const [input, expected] of new Map([ + ['\\[unclosed bracket', '\\[unclosed bracket'], + ['unopened bracket\\]', 'unopened bracket\\]'], + ['\\(unclosed paren', '\\(unclosed paren'], + ['unopened paren\\)', 'unopened paren\\)'], + ['\\[\\]', '$$$$'], // Empty LaTeX block + ['\\(\\)', '$$'] // Empty LaTeX inline + ])) { + expect(processLatexBrackets(input)).toBe(expected) + } + }) + + it('should handle nested brackets', () => { + for (const [input, expected] of new Map([ + ['\\[outer \\[inner\\] formula\\]', '$$outer \\[inner\\] formula$$'], + ['\\(a + \\(b + c\\)\\)', '$a + \\(b + c\\)$'] + ])) { + expect(processLatexBrackets(input)).toBe(expected) + } + }) + }) + + describe('complex cases', () => { + it('should handle complex mixed content', () => { + const complexInput = ` +# Mathematical Document + +Here's a simple formula \\(E = mc^2\\) in text. + +## Section 1: Equations + +The quadratic formula is \\[x = \\frac{-b \\pm \\sqrt{b^2-4ac}}{2a}\\]. + +- Item 1: See formula \\(\\alpha + \\beta = \\gamma\\) in this list +- Item 2: Check [\\[PDF\\] Complex Analysis](https://example.com/math.pdf) + - Subitem 2.1: Basic concepts and definitions + - Subitem 2.2: The Cauchy-Riemann equations \\[\\frac{\\partial u}{\\partial x} = \\frac{\\partial v}{\\partial y}, \\quad \\frac{\\partial u}{\\partial y} = -\\frac{\\partial v}{\\partial x}\\] + - Subitem 2.3: Green's theorem connects line integrals and double integrals + \\[ + \\oint_C (P dx + Q dy) = \\iint_D \\left(\\frac{\\partial Q}{\\partial x} - \\frac{\\partial P}{\\partial y}\\right) dx dy + \\] + - Subitem 2.4: Applications in engineering and physics +- Item 3: The sum \\[\\sum_{i=1}^{n} \\frac{1}{i^2} = \\frac{\\pi^2}{6}\\] is famous + +\`\`\`javascript +// Code should not be affected +const matrix = \\[ + \\[1, 2\\], + \\[3, 4\\] +\\]; +const func = \\(x\\) => x * 2; +\`\`\` + +Read more in [Section \\[3.2\\]: Advanced Topics](url) and see inline code \`\\[array\\]\`. + +Final thoughts on \\(\\nabla \\cdot \\vec{F} = \\rho\\) and display math: + +\\[\\int_0^\\infty e^{-x^2} dx = \\frac{\\sqrt{\\pi}}{2}\\] +` + + const expectedOutput = ` +# Mathematical Document + +Here's a simple formula $E = mc^2$ in text. + +## Section 1: Equations + +The quadratic formula is $$x = \\frac{-b \\pm \\sqrt{b^2-4ac}}{2a}$$. + +- Item 1: See formula $\\alpha + \\beta = \\gamma$ in this list +- Item 2: Check [\\[PDF\\] Complex Analysis](https://example.com/math.pdf) + - Subitem 2.1: Basic concepts and definitions + - Subitem 2.2: The Cauchy-Riemann equations $$\\frac{\\partial u}{\\partial x} = \\frac{\\partial v}{\\partial y}, \\quad \\frac{\\partial u}{\\partial y} = -\\frac{\\partial v}{\\partial x}$$ + - Subitem 2.3: Green's theorem connects line integrals and double integrals + $$ + \\oint_C (P dx + Q dy) = \\iint_D \\left(\\frac{\\partial Q}{\\partial x} - \\frac{\\partial P}{\\partial y}\\right) dx dy + $$ + - Subitem 2.4: Applications in engineering and physics +- Item 3: The sum $$\\sum_{i=1}^{n} \\frac{1}{i^2} = \\frac{\\pi^2}{6}$$ is famous + +\`\`\`javascript +// Code should not be affected +const matrix = \\[ + \\[1, 2\\], + \\[3, 4\\] +\\]; +const func = \\(x\\) => x * 2; +\`\`\` + +Read more in [Section \\[3.2\\]: Advanced Topics](url) and see inline code \`\\[array\\]\`. + +Final thoughts on $\\nabla \\cdot \\vec{F} = \\rho$ and display math: + +$$\\int_0^\\infty e^{-x^2} dx = \\frac{\\sqrt{\\pi}}{2}$$ +` + + expect(processLatexBrackets(complexInput)).toBe(expectedOutput) + }) + }) + }) }) diff --git a/src/renderer/src/utils/formats.ts b/src/renderer/src/utils/formats.ts index 559b4e7a52..ee64efd443 100644 --- a/src/renderer/src/utils/formats.ts +++ b/src/renderer/src/utils/formats.ts @@ -53,24 +53,6 @@ export function escapeDollarNumber(text: string) { return escapedText } -export function escapeBrackets(text: string) { - const pattern = /(```[\s\S]*?```|`.*?`)|\\\[([\s\S]*?[^\\])\\]|\\\((.*?)\\\)/g - return text.replace(pattern, (match, codeBlock, squareBracket, roundBracket) => { - if (codeBlock) { - return codeBlock - } else if (squareBracket) { - return ` -$$ -${squareBracket} -$$ -` - } else if (roundBracket) { - return `$${roundBracket}$` - } - return match - }) -} - export function extractTitle(html: string): string | null { if (!html) return null diff --git a/src/renderer/src/utils/markdown.ts b/src/renderer/src/utils/markdown.ts index a54e3d69d0..57025ca633 100644 --- a/src/renderer/src/utils/markdown.ts +++ b/src/renderer/src/utils/markdown.ts @@ -1,4 +1,5 @@ import { languages } from '@shared/config/languages' +import balanced from 'balanced-match' import remarkParse from 'remark-parse' import remarkStringify from 'remark-stringify' import removeMarkdown from 'remove-markdown' @@ -29,6 +30,85 @@ export const findCitationInChildren = (children: any): string => { return '' } +// 检查是否包含潜在的 LaTeX 模式 +const containsLatexRegex = /\\\(.*?\\\)|\\\[.*?\\\]|\$.*?\$|\\begin\{equation\}.*?\\end\{equation\}/ + +/** + * 转换 LaTeX 公式括号 `\[\]` 和 `\(\)` 为 Markdown 格式 `$$...$$` 和 `$...$` + * + * remark-math 本身不支持 LaTeX 原生语法,作为替代的一些插件效果也不理想。 + * + * 目前的实现: + * - 保护代码块和链接,避免被 remark-math 处理 + * - 支持嵌套括号的平衡匹配 + * - 转义 `\\(x\\)` 会被处理为 `\$x\$`,`\\[x\\]` 会被处理为 `\$$x\$$` + * + * @see https://github.com/remarkjs/remark-math/issues/39 + * @param text 输入的 Markdown 文本 + * @returns 处理后的字符串 + */ +export const processLatexBrackets = (text: string) => { + // 没有 LaTeX 模式直接返回 + if (!containsLatexRegex.test(text)) { + return text + } + + // 保护代码块和链接 + const protectedItems: string[] = [] + let processedContent = text + + processedContent = processedContent + // 保护代码块(包括多行代码块和行内代码) + .replace(/(```[\s\S]*?```|`[^`]*`)/g, (match) => { + const index = protectedItems.length + protectedItems.push(match) + return `__CHERRY_STUDIO_PROTECTED_${index}__` + }) + // 保护链接 [text](url) + .replace(/\[([^[\]]*(?:\[[^\]]*\][^[\]]*)*)\]\([^)]*?\)/g, (match) => { + const index = protectedItems.length + protectedItems.push(match) + return `__CHERRY_STUDIO_PROTECTED_${index}__` + }) + + // LaTeX 括号转换函数 + const processMath = (content: string, openDelim: string, closeDelim: string, wrapper: string): string => { + let result = '' + let remaining = content + + while (remaining.length > 0) { + const match = balanced(openDelim, closeDelim, remaining) + if (!match) { + result += remaining + break + } + + result += match.pre + result += `${wrapper}${match.body}${wrapper}` + remaining = match.post + } + + return result + } + + // 先处理块级公式,再处理内联公式 + let result = processMath(processedContent, '\\[', '\\]', '$$') + result = processMath(result, '\\(', '\\)', '$') + + // 还原被保护的内容 + result = result.replace(/__CHERRY_STUDIO_PROTECTED_(\d+)__/g, (match, indexStr) => { + const index = parseInt(indexStr, 10) + // 添加边界检查,防止数组越界 + if (index >= 0 && index < protectedItems.length) { + return protectedItems[index] + } + // 如果索引无效,保持原始匹配 + return match + }) + + return result +} + /** * 转换数学公式格式: * - 将 LaTeX 格式的 '\\[' 和 '\\]' 转换为 '$$$$'。 diff --git a/yarn.lock b/yarn.lock index eefde56f9c..75f9e2f434 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4115,6 +4115,13 @@ __metadata: languageName: node linkType: hard +"@types/balanced-match@npm:^3": + version: 3.0.2 + resolution: "@types/balanced-match@npm:3.0.2" + checksum: 10c0/833f6499609363537026c4ec2770af5c5a36e71b80f7b5b23884b15296301bfcf974cd40bc75fda940dea4994acd96c9222b284c248383a1ade59bf8835940b0 + languageName: node + linkType: hard + "@types/cacheable-request@npm:^6.0.1": version: 6.0.3 resolution: "@types/cacheable-request@npm:6.0.3" @@ -5639,6 +5646,7 @@ __metadata: "@testing-library/jest-dom": "npm:^6.6.3" "@testing-library/react": "npm:^16.3.0" "@tryfabric/martian": "npm:^1.2.4" + "@types/balanced-match": "npm:^3" "@types/diff": "npm:^7" "@types/fs-extra": "npm:^11" "@types/lodash": "npm:^4.17.5" @@ -5665,6 +5673,7 @@ __metadata: archiver: "npm:^7.0.1" async-mutex: "npm:^0.5.0" axios: "npm:^1.7.3" + balanced-match: "npm:^3.0.1" browser-image-compression: "npm:^2.0.2" color: "npm:^5.0.0" dayjs: "npm:^1.11.11" @@ -6297,6 +6306,13 @@ __metadata: languageName: node linkType: hard +"balanced-match@npm:^3.0.1": + version: 3.0.1 + resolution: "balanced-match@npm:3.0.1" + checksum: 10c0/ac8dd63a5b260610c2cbda982f436e964c1b9ae8764d368a523769da40a31710abd6e19f0fdf1773c4ad7b2ea7ba7b285d547375dc723f6e754369835afc8e9f + languageName: node + linkType: hard + "bare-events@npm:^2.2.0": version: 2.5.4 resolution: "bare-events@npm:2.5.4" From aee8fe61969f446ad68e51bd44ef7ab307c89224 Mon Sep 17 00:00:00 2001 From: Wang Jiyuan <59059173+EurFelux@users.noreply.github.com> Date: Mon, 23 Jun 2025 15:27:20 +0800 Subject: [PATCH 025/111] feat(mcpServers): Add a thought field to sequential thinking mcp server (#7465) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat(mcpServers): 在sequentialthinking中添加thought字段 --- src/main/mcpServers/sequentialthinking.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/mcpServers/sequentialthinking.ts b/src/main/mcpServers/sequentialthinking.ts index 4589c0bf34..bcda96e192 100644 --- a/src/main/mcpServers/sequentialthinking.ts +++ b/src/main/mcpServers/sequentialthinking.ts @@ -106,6 +106,7 @@ class SequentialThinkingServer { type: 'text', text: JSON.stringify( { + thought: validatedInput.thought, thoughtNumber: validatedInput.thoughtNumber, totalThoughts: validatedInput.totalThoughts, nextThoughtNeeded: validatedInput.nextThoughtNeeded, From be15206234ffbb5232ac48f06eaa3a1aabaa4e2f Mon Sep 17 00:00:00 2001 From: beyondkmp Date: Mon, 23 Jun 2025 17:18:46 +0800 Subject: [PATCH 026/111] fix: Data config improvement (#7471) * fix: update localization files for data migration warnings and path validation messages * fix: update app data path validation and localization messages for installation path consistency * fix: enhance app data flushing process by adding connection closure and delay in DataSettings component --- src/main/ipc.ts | 10 ++- src/renderer/src/i18n/locales/en-us.json | 5 +- src/renderer/src/i18n/locales/ja-jp.json | 5 +- src/renderer/src/i18n/locales/ru-ru.json | 5 +- src/renderer/src/i18n/locales/zh-cn.json | 5 +- src/renderer/src/i18n/locales/zh-tw.json | 5 +- .../settings/DataSettings/DataSettings.tsx | 73 +++++++++++-------- src/renderer/src/types/index.ts | 1 + 8 files changed, 69 insertions(+), 40 deletions(-) diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 5e1ac819a9..5f54d64e07 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -1,5 +1,6 @@ import fs from 'node:fs' import { arch } from 'node:os' +import path from 'node:path' import { isMac, isWin } from '@main/constant' import { getBinaryPath, isBinaryExists, runInstallScript } from '@main/utils/process' @@ -57,7 +58,8 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { resourcesPath: getResourcePath(), logsPath: log.transports.file.getFile().path, arch: arch(), - isPortable: isWin && 'PORTABLE_EXECUTABLE_DIR' in process.env + isPortable: isWin && 'PORTABLE_EXECUTABLE_DIR' in process.env, + installPath: path.dirname(app.getPath('exe')) })) ipcMain.handle(IpcChannel.App_Proxy, async (_, proxy: string) => { @@ -233,7 +235,13 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { BrowserWindow.getAllWindows().forEach((w) => { w.webContents.session.flushStorageData() w.webContents.session.cookies.flushStore() + + w.webContents.session.closeAllConnections() }) + + session.defaultSession.flushStorageData() + session.defaultSession.cookies.flushStore() + session.defaultSession.closeAllConnections() }) ipcMain.handle(IpcChannel.App_IsNotEmptyDir, async (_, path: string) => { diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index b8bcfb66b2..fefebd6394 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -1091,7 +1091,7 @@ "app_data.copy_data_option": "Copy data, will automatically restart after copying the original directory data to the new directory", "app_data.copy_time_notice": "Copying data may take a while, do not force quit app", "app_data.path_changed_without_copy": "Path changed successfully", - "app_data.copying_warning": "Data copying, do not force quit app", + "app_data.copying_warning": "Data copying, do not force quit app, the app will restart after copied", "app_data.copying": "Copying data to new location...", "app_data.copy_success": "Successfully copied data to new location", "app_data.copy_failed": "Failed to copy data", @@ -1104,8 +1104,9 @@ "app_data.select_error_write_permission": "New path does not have write permission", "app_data.stop_quit_app_reason": "The app is currently migrating data and cannot be exited", "app_data.select_not_empty_dir": "New path is not empty", - "app_data.select_not_empty_dir_content": "New path is not empty, if you select copy, it will overwrite the data in the new path, there is a risk of data loss, continue?", + "app_data.select_not_empty_dir_content": "New path is not empty, it will overwrite the data in the new path, there is a risk of data loss and copy failure, continue?", "app_data.select_error_same_path": "New path is the same as the old path, please select another path", + "app_data.select_error_in_app_path": "New path is the same as the application installation path, please select another path", "app_knowledge": "Knowledge Base Files", "app_knowledge.button.delete": "Delete File", "app_knowledge.remove_all": "Remove Knowledge Base Files", diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index 430cba8351..612df65d71 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -1089,7 +1089,7 @@ "app_data.copy_data_option": "データをコピーする, 開くと元のディレクトリのデータが新しいディレクトリにコピーされます。", "app_data.copy_time_notice": "データコピーには時間がかかります。アプリを強制終了しないでください。", "app_data.path_changed_without_copy": "パスが変更されました。", - "app_data.copying_warning": "データコピー中、アプリを強制終了しないでください", + "app_data.copying_warning": "データコピー中、アプリを強制終了しないでください。コピーが完了すると、アプリが自動的に再起動します。", "app_data.copying": "新しい場所にデータをコピーしています...", "app_data.copy_success": "データを新しい場所に正常にコピーしました", "app_data.copy_failed": "データのコピーに失敗しました", @@ -1102,8 +1102,9 @@ "app_data.select_error_write_permission": "新しいパスに書き込み権限がありません", "app_data.stop_quit_app_reason": "アプリは現在データを移行しているため、終了できません", "app_data.select_not_empty_dir": "新しいパスは空ではありません", - "app_data.select_not_empty_dir_content": "新しいパスは空ではありません。コピーを選択すると、新しいパスのデータが上書きされます。データが失われるリスクがあります。続行しますか?", + "app_data.select_not_empty_dir_content": "新しいパスは空ではありません。新しいパスのデータが上書きされます。データが失われるリスクがあります。続行しますか?", "app_data.select_error_same_path": "新しいパスは元のパスと同じです。別のパスを選択してください", + "app_data.select_error_in_app_path": "新しいパスはアプリのインストールパスと同じです。別のパスを選択してください", "app_knowledge": "知識ベースファイル", "app_knowledge.button.delete": "ファイルを削除", "app_knowledge.remove_all": "ナレッジベースファイルを削除", diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index af26cddf92..ec2ad7785f 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -1089,7 +1089,7 @@ "app_data.copy_data_option": "Копировать данные, будет автоматически перезапущено после копирования данных из исходной директории в новую директорию", "app_data.copy_time_notice": "Копирование данных из исходной директории займет некоторое время, пожалуйста, будьте терпеливы", "app_data.path_changed_without_copy": "Путь изменен успешно", - "app_data.copying_warning": "Копирование данных, нельзя взаимодействовать с приложением, не закрывайте приложение", + "app_data.copying_warning": "Копирование данных, нельзя взаимодействовать с приложением, не закрывайте приложение, приложение будет перезапущено после копирования", "app_data.copying": "Копирование данных в новое место...", "app_data.copy_success": "Данные успешно скопированы в новое место", "app_data.copy_failed": "Не удалось скопировать данные", @@ -1102,7 +1102,8 @@ "app_data.select_error_write_permission": "Новый путь не имеет разрешения на запись", "app_data.stop_quit_app_reason": "Приложение в настоящее время перемещает данные и не может быть закрыто", "app_data.select_not_empty_dir": "Новый путь не пуст", - "app_data.select_not_empty_dir_content": "Новый путь не пуст, если вы выбираете копирование, он перезапишет данные в новом пути, есть риск потери данных, продолжить?", + "app_data.select_not_empty_dir_content": "Новый путь не пуст, он перезапишет данные в новом пути, есть риск потери данных и ошибки копирования, продолжить?", + "app_data.select_error_in_app_path": "Новый путь совпадает с исходным путем, пожалуйста, выберите другой путь", "app_data.select_error_same_path": "Новый путь совпадает с исходным путем, пожалуйста, выберите другой путь", "app_knowledge": "Файлы базы знаний", "app_knowledge.button.delete": "Удалить файл", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index fd393e36ce..8d7c30f323 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -1091,7 +1091,7 @@ "app_data.copy_data_option": "复制数据,会自动重启后将原始目录数据复制到新目录", "app_data.copy_time_notice": "复制数据将需要一些时间,复制期间不要关闭应用", "app_data.path_changed_without_copy": "路径已更改成功", - "app_data.copying_warning": "数据复制中,不要强制退出app", + "app_data.copying_warning": "数据复制中,不要强制退出app, 复制完成后会自动重启应用", "app_data.copying": "正在将数据复制到新位置...", "app_data.copy_success": "已成功复制数据到新位置", "app_data.copy_failed": "复制数据失败", @@ -1104,8 +1104,9 @@ "app_data.select_error_write_permission": "新路径没有写入权限", "app_data.stop_quit_app_reason": "应用目前在迁移数据, 不能退出", "app_data.select_not_empty_dir": "新路径不为空", - "app_data.select_not_empty_dir_content": "新路径不为空,选择复制将覆盖新路径中的数据, 有数据丢失的风险,是否继续?", + "app_data.select_not_empty_dir_content": "新路径不为空,将覆盖新路径中的数据, 有数据丢失和复制失败的风险,是否继续?", "app_data.select_error_same_path": "新路径与旧路径相同,请选择其他路径", + "app_data.select_error_in_app_path": "新路径与应用安装路径相同,请选择其他路径", "app_knowledge": "知识库文件", "app_knowledge.button.delete": "删除文件", "app_knowledge.remove_all": "删除知识库文件", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 995bee24bc..1d23fb540a 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -1091,7 +1091,7 @@ "app_data.copy_data_option": "複製數據, 會自動重啟後將原始目錄數據複製到新目錄", "app_data.copy_time_notice": "複製數據將需要一些時間,複製期間不要關閉應用", "app_data.path_changed_without_copy": "路徑已變更成功", - "app_data.copying_warning": "數據複製中,不要強制退出應用", + "app_data.copying_warning": "數據複製中,不要強制退出應用, 複製完成後會自動重啟應用", "app_data.copying": "正在複製數據到新位置...", "app_data.copy_success": "成功複製數據到新位置", "app_data.copy_failed": "複製數據失敗", @@ -1104,8 +1104,9 @@ "app_data.select_error_write_permission": "新路徑沒有寫入權限", "app_data.stop_quit_app_reason": "應用目前正在遷移數據,不能退出", "app_data.select_not_empty_dir": "新路徑不為空", - "app_data.select_not_empty_dir_content": "新路徑不為空,選擇複製將覆蓋新路徑中的數據, 有數據丟失的風險,是否繼續?", + "app_data.select_not_empty_dir_content": "新路徑不為空,選擇複製將覆蓋新路徑中的數據, 有數據丟失和複製失敗的風險,是否繼續?", "app_data.select_error_same_path": "新路徑與舊路徑相同,請選擇其他路徑", + "app_data.select_error_in_app_path": "新路徑與應用安裝路徑相同,請選擇其他路徑", "app_knowledge": "知識庫文件", "app_knowledge.button.delete": "刪除檔案", "app_knowledge.remove_all": "刪除知識庫檔案", diff --git a/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx b/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx index 52b1c0bbb7..0038f1947c 100644 --- a/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx +++ b/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx @@ -202,12 +202,18 @@ const DataSettings: FC = () => { return } - // check new app data path is same as old app data path - if (newAppDataPath.startsWith(appInfo!.appDataPath)) { + // check new app data path is not in old app data path + if (newAppDataPath.startsWith(appInfo.appDataPath)) { window.message.error(t('settings.data.app_data.select_error_same_path')) return } + // check new app data path is not in app install path + if (newAppDataPath.startsWith(appInfo.installPath)) { + window.message.error(t('settings.data.app_data.select_error_in_app_path')) + return + } + // check new app data path has write permission const hasWritePermission = await window.api.hasWritePermission(newAppDataPath) if (!hasWritePermission) { @@ -219,25 +225,30 @@ const DataSettings: FC = () => {
{t('settings.data.app_data.migration_title')}
) const migrationClassName = 'migration-modal' - - if (await window.api.isNotEmptyDir(newAppDataPath)) { - const modal = window.modal.confirm({ - title: t('settings.data.app_data.select_not_empty_dir'), - content: t('settings.data.app_data.select_not_empty_dir_content'), - centered: true, - okText: t('common.confirm'), - cancelText: t('common.cancel'), - onOk: () => { - modal.destroy() - // 显示确认对话框 - showMigrationConfirmModal(appInfo.appDataPath, newAppDataPath, migrationTitle, migrationClassName) - } - }) - return - } showMigrationConfirmModal(appInfo.appDataPath, newAppDataPath, migrationTitle, migrationClassName) } + const doubleConfirmModalBeforeCopyData = (newPath: string) => { + window.modal.confirm({ + title: t('settings.data.app_data.select_not_empty_dir'), + content: t('settings.data.app_data.select_not_empty_dir_content'), + centered: true, + okText: t('common.confirm'), + cancelText: t('common.cancel'), + onOk: () => { + window.message.info({ + content: t('settings.data.app_data.restart_notice'), + duration: 2 + }) + setTimeout(() => { + window.api.relaunchApp({ + args: ['--new-data-path=' + newPath] + }) + }, 500) + } + }) + } + // 显示确认迁移的对话框 const showMigrationConfirmModal = async ( originalPath: string, @@ -280,7 +291,7 @@ const DataSettings: FC = () => { ) // 显示确认模态框 - const modal = window.modal.confirm({ + window.modal.confirm({ title, className, width: 'min(600px, 90vw)', @@ -305,11 +316,12 @@ const DataSettings: FC = () => { cancelText: t('common.cancel'), onOk: async () => { try { - // 立即关闭确认对话框 - modal.destroy() - if (shouldCopyData) { - // 如果选择复制数据,显示进度模态框并执行迁移 + if (await window.api.isNotEmptyDir(newPath)) { + doubleConfirmModalBeforeCopyData(newPath) + return + } + window.message.info({ content: t('settings.data.app_data.restart_notice'), duration: 3 @@ -318,12 +330,12 @@ const DataSettings: FC = () => { window.api.relaunchApp({ args: ['--new-data-path=' + newPath] }) - }, 300) - } else { - // 如果不复制数据,直接设置新的应用数据路径 - await window.api.setAppDataPath(newPath) - window.message.success(t('settings.data.app_data.path_changed_without_copy')) + }, 500) + return } + // 如果不复制数据,直接设置新的应用数据路径 + await window.api.setAppDataPath(newPath) + window.message.success(t('settings.data.app_data.path_changed_without_copy')) // 更新应用数据路径 setAppInfo(await window.api.getAppInfo()) @@ -333,7 +345,7 @@ const DataSettings: FC = () => { window.message.success(t('settings.data.app_data.select_success')) window.api.setStopQuitApp(false, '') window.api.relaunchApp() - }, 1000) + }, 500) } catch (error) { window.api.setStopQuitApp(false, '') window.message.error({ @@ -484,6 +496,9 @@ const DataSettings: FC = () => { // flush app data await window.api.flushAppData() + // wait 2 seconds to flush app data + await new Promise((resolve) => setTimeout(resolve, 2000)) + // 开始复制过程 const copyResult = await window.api.copy(originalPath, newPath) diff --git a/src/renderer/src/types/index.ts b/src/renderer/src/types/index.ts index e60f374ba7..75e1da19ce 100644 --- a/src/renderer/src/types/index.ts +++ b/src/renderer/src/types/index.ts @@ -387,6 +387,7 @@ export type AppInfo = { logsPath: string arch: string isPortable: boolean + installPath: string } export interface Shortcut { From bbe380cc9e43e68eb8a6c175edd7f7c9f057f97b Mon Sep 17 00:00:00 2001 From: beyondkmp Date: Mon, 23 Jun 2025 21:19:21 +0800 Subject: [PATCH 027/111] feat(ContextMenu): add spell check and dictionary suggestions to context menu (#7067) * feat(ContextMenu): add spell check and dictionary suggestions to context menu - Implemented spell check functionality in the context menu with options to learn spelling and view dictionary suggestions. - Updated WindowService to enable spellcheck in the webview. - Enabled spell check in Inputbar and MessageEditor components. * feat(SpellCheck): implement spell check language settings and initialization - Added support for configuring spell check languages based on user-selected language. - Introduced IPC channel for setting spell check languages. - Updated settings to manage spell check enablement and languages. - Enhanced UI to allow users to toggle spell check functionality and select languages. - Default spell check languages are set based on the current UI language if none are specified. * refactor(SpellCheck): enhance spell check language mapping and UI settings - Updated spell check language mapping to default to English for unsupported languages. - Improved UI logic to only update spell check languages when enabled and no manual selections are made. - Added a new selection component for users to choose from commonly supported spell check languages. * feat(SpellCheck): integrate spell check functionality into Inputbar and MessageEditor - Added enableSpellCheck setting to control spell check functionality in both Inputbar and MessageEditor components. - Updated spellCheck prop to utilize the new setting, enhancing user experience by allowing customization of spell check behavior. * refactor(SpellCheck): move spell check initialization to WindowService - Removed spell check language initialization from index.ts and integrated it into WindowService. - Added setupSpellCheck method to configure spell check languages based on user settings. - Enhanced error handling for spell check language setup. * feat(SpellCheck): add enable spell check functionality and IPC channel - Introduced a new IPC channel for enabling/disabling spell check functionality. - Updated the preload API to include a method for setting spell check enablement. - Modified the main IPC handler to manage spell check settings based on user input. - Simplified spell check language handling in the settings component by directly invoking the new API method. * refactor(SpellCheck): remove spellcheck option from WindowService configuration - Removed the spellcheck property from the WindowService configuration object. - This change streamlines the configuration setup as spell check functionality is now managed through IPC channels. * feat(i18n): add spell check translations for Japanese, Russian, and Traditional Chinese - Added new translations for spell check functionality in ja-jp, ru-ru, and zh-tw locale files. - Included descriptions and language selection options for spell check settings to enhance user experience. * feat(migrate): add spell check configuration migration - Implemented migration for spell check settings, disabling spell check and clearing selected languages in the new configuration. - Enhanced error handling to ensure state consistency during migration process. * fix(migrate): ensure spell check settings are updated safely - Added a check to ensure state.settings exists before modifying spell check settings during migration. - Removed redundant error handling that returned the state unmodified in case of an error. * fix(WindowService): set default values for spell check configuration and update related UI texts * refactor(Inputbar, MessageEditor): remove contextMenu attribute and add context menu handling in MessageEditor --------- Co-authored-by: beyondkmp --- packages/shared/IpcChannel.ts | 2 + src/main/ipc.ts | 20 ++++++ src/main/services/ContextMenu.ts | 60 +++++++++++++++- src/main/services/WindowService.ts | 13 ++++ src/preload/index.ts | 2 + src/renderer/src/i18n/locales/en-us.json | 4 +- src/renderer/src/i18n/locales/ja-jp.json | 2 + src/renderer/src/i18n/locales/ru-ru.json | 2 + src/renderer/src/i18n/locales/zh-cn.json | 12 ++-- src/renderer/src/i18n/locales/zh-tw.json | 2 + .../src/pages/home/Inputbar/Inputbar.tsx | 6 +- .../src/pages/home/Messages/MessageEditor.tsx | 9 ++- .../src/pages/settings/GeneralSettings.tsx | 69 ++++++++++++++++++- src/renderer/src/store/migrate.ts | 4 ++ src/renderer/src/store/settings.ts | 14 +++- 15 files changed, 204 insertions(+), 17 deletions(-) diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index 4c6988cf6e..a3988d1c42 100644 --- a/packages/shared/IpcChannel.ts +++ b/packages/shared/IpcChannel.ts @@ -3,6 +3,8 @@ export enum IpcChannel { App_ClearCache = 'app:clear-cache', App_SetLaunchOnBoot = 'app:set-launch-on-boot', App_SetLanguage = 'app:set-language', + App_SetEnableSpellCheck = 'app:set-enable-spell-check', + App_SetSpellCheckLanguages = 'app:set-spell-check-languages', App_ShowUpdateDialog = 'app:show-update-dialog', App_CheckForUpdate = 'app:check-for-update', App_Reload = 'app:reload', diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 5f54d64e07..88d66d4a39 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -87,6 +87,26 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { configManager.setLanguage(language) }) + // spell check + ipcMain.handle(IpcChannel.App_SetEnableSpellCheck, (_, isEnable: boolean) => { + const windows = BrowserWindow.getAllWindows() + windows.forEach((window) => { + window.webContents.session.setSpellCheckerEnabled(isEnable) + }) + }) + + // spell check languages + ipcMain.handle(IpcChannel.App_SetSpellCheckLanguages, (_, languages: string[]) => { + if (languages.length === 0) { + return + } + const windows = BrowserWindow.getAllWindows() + windows.forEach((window) => { + window.webContents.session.setSpellCheckerLanguages(languages) + }) + configManager.set('spellCheckLanguages', languages) + }) + // launch on boot ipcMain.handle(IpcChannel.App_SetLaunchOnBoot, (_, openAtLogin: boolean) => { // Set login item settings for windows and mac diff --git a/src/main/services/ContextMenu.ts b/src/main/services/ContextMenu.ts index 2f4f5aa20f..34ec4b911a 100644 --- a/src/main/services/ContextMenu.ts +++ b/src/main/services/ContextMenu.ts @@ -9,7 +9,18 @@ class ContextMenu { const template: MenuItemConstructorOptions[] = this.createEditMenuItems(properties) const filtered = template.filter((item) => item.visible !== false) if (filtered.length > 0) { - const menu = Menu.buildFromTemplate([...filtered, ...this.createInspectMenuItems(w)]) + let template = [...filtered, ...this.createInspectMenuItems(w)] + const dictionarySuggestions = this.createDictionarySuggestions(properties, w) + if (dictionarySuggestions.length > 0) { + template = [ + ...dictionarySuggestions, + { type: 'separator' }, + this.createSpellCheckMenuItem(properties, w), + { type: 'separator' }, + ...template + ] + } + const menu = Menu.buildFromTemplate(template) menu.popup() } }) @@ -72,6 +83,53 @@ class ContextMenu { return template } + + private createSpellCheckMenuItem( + properties: Electron.ContextMenuParams, + mainWindow: Electron.BrowserWindow + ): MenuItemConstructorOptions { + const hasText = properties.selectionText.length > 0 + + return { + id: 'learnSpelling', + label: '&Learn Spelling', + visible: Boolean(properties.isEditable && hasText && properties.misspelledWord), + click: () => { + mainWindow.webContents.session.addWordToSpellCheckerDictionary(properties.misspelledWord) + } + } + } + + private createDictionarySuggestions( + properties: Electron.ContextMenuParams, + mainWindow: Electron.BrowserWindow + ): MenuItemConstructorOptions[] { + const hasText = properties.selectionText.length > 0 + + if (!hasText || !properties.misspelledWord) { + return [] + } + + if (properties.dictionarySuggestions.length === 0) { + return [ + { + id: 'dictionarySuggestions', + label: 'No Guesses Found', + visible: true, + enabled: false + } + ] + } + + return properties.dictionarySuggestions.map((suggestion) => ({ + id: 'dictionarySuggestions', + label: suggestion, + visible: Boolean(properties.isEditable && hasText && properties.misspelledWord), + click: (menuItem: Electron.MenuItem) => { + mainWindow.webContents.replaceMisspelling(menuItem.label) + } + })) + } } export const contextMenu = new ContextMenu() diff --git a/src/main/services/WindowService.ts b/src/main/services/WindowService.ts index f6322e8939..78784120b0 100644 --- a/src/main/services/WindowService.ts +++ b/src/main/services/WindowService.ts @@ -95,6 +95,7 @@ export class WindowService { this.setupMaximize(mainWindow, mainWindowState.isMaximized) this.setupContextMenu(mainWindow) + this.setupSpellCheck(mainWindow) this.setupWindowEvents(mainWindow) this.setupWebContentsHandlers(mainWindow) this.setupWindowLifecycleEvents(mainWindow) @@ -102,6 +103,18 @@ export class WindowService { this.loadMainWindowContent(mainWindow) } + private setupSpellCheck(mainWindow: BrowserWindow) { + const enableSpellCheck = configManager.get('enableSpellCheck', false) + if (enableSpellCheck) { + try { + const spellCheckLanguages = configManager.get('spellCheckLanguages', []) as string[] + spellCheckLanguages.length > 0 && mainWindow.webContents.session.setSpellCheckerLanguages(spellCheckLanguages) + } catch (error) { + Logger.error('Failed to set spell check languages:', error as Error) + } + } + } + private setupMainWindowMonitor(mainWindow: BrowserWindow) { mainWindow.webContents.on('render-process-gone', (_, details) => { Logger.error(`Renderer process crashed with: ${JSON.stringify(details)}`) diff --git a/src/preload/index.ts b/src/preload/index.ts index 5138d4e4de..114ad13ef6 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -17,6 +17,8 @@ const api = { checkForUpdate: () => ipcRenderer.invoke(IpcChannel.App_CheckForUpdate), showUpdateDialog: () => ipcRenderer.invoke(IpcChannel.App_ShowUpdateDialog), setLanguage: (lang: string) => ipcRenderer.invoke(IpcChannel.App_SetLanguage, lang), + setEnableSpellCheck: (isEnable: boolean) => ipcRenderer.invoke(IpcChannel.App_SetEnableSpellCheck, isEnable), + setSpellCheckLanguages: (languages: string[]) => ipcRenderer.invoke(IpcChannel.App_SetSpellCheckLanguages, languages), setLaunchOnBoot: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetLaunchOnBoot, isActive), setLaunchToTray: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetLaunchToTray, isActive), setTray: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetTray, isActive), diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index fefebd6394..fa1f493d80 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -864,7 +864,7 @@ "paint_course": "tutorial", "prompt_placeholder_edit": "Enter your image description, text drawing uses \"double quotes\" to wrap", "prompt_placeholder_en": "Enter your image description, currently Imagen only supports English prompts", - "proxy_required": "Open the proxy and enable “TUN mode” to view generated images or copy them to the browser for opening. In the future, domestic direct connection will be supported", + "proxy_required": "Open the proxy and enable \"TUN mode\" to view generated images or copy them to the browser for opening. In the future, domestic direct connection will be supported", "image_file_required": "Please upload an image first", "image_file_retry": "Please re-upload an image first", "image_placeholder": "No image available", @@ -1392,6 +1392,8 @@ "general.user_name": "User Name", "general.user_name.placeholder": "Enter your name", "general.view_webdav_settings": "View WebDAV settings", + "general.spell_check": "Spell Check", + "general.spell_check.languages": "Use spell check for", "input.auto_translate_with_space": "Quickly translate with 3 spaces", "input.show_translate_confirm": "Show translation confirmation dialog", "input.target_language": "Target language", diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index 612df65d71..ffa579d563 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -1387,6 +1387,8 @@ "general.user_name": "ユーザー名", "general.user_name.placeholder": "ユーザー名を入力", "general.view_webdav_settings": "WebDAV設定を表示", + "general.spell_check": "スペルチェック", + "general.spell_check.languages": "スペルチェック言語", "input.auto_translate_with_space": "スペースを3回押して翻訳", "input.target_language": "目標言語", "input.target_language.chinese": "簡体字中国語", diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index ec2ad7785f..a713da42ee 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -1387,6 +1387,8 @@ "general.user_name": "Имя пользователя", "general.user_name.placeholder": "Введите ваше имя", "general.view_webdav_settings": "Просмотр настроек WebDAV", + "general.spell_check": "Проверка орфографии", + "general.spell_check.languages": "Языки проверки орфографии", "input.auto_translate_with_space": "Быстрый перевод с помощью 3-х пробелов", "input.target_language": "Целевой язык", "input.target_language.chinese": "Китайский упрощенный", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 8d7c30f323..c16089db5e 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -863,8 +863,8 @@ "learn_more": "了解更多", "paint_course": "教程", "prompt_placeholder_edit": "输入你的图片描述,文本绘制用 \"双引号\" 包裹", - "prompt_placeholder_en": "输入”英文“图片描述,目前 Imagen 仅支持英文提示词", - "proxy_required": "打开代理并开启”TUN模式“查看生成图片或复制到浏览器打开,后续会支持国内直连", + "prompt_placeholder_en": "输入\"英文\"图片描述,目前 Imagen 仅支持英文提示词", + "proxy_required": "打开代理并开启\"TUN模式\"查看生成图片或复制到浏览器打开,后续会支持国内直连", "image_file_required": "请先上传图片", "image_file_retry": "请重新上传图片", "image_placeholder": "暂无图片", @@ -960,7 +960,7 @@ "magic_prompt_option_tip": "智能优化放大提示词" }, "text_desc_required": "请先输入图片描述", - "req_error_text": "运行失败,请重试。提示词避免“版权词”和”敏感词”哦。", + "req_error_text": "运行失败,请重试。提示词避免\"版权词\"和\"敏感词\"哦。", "req_error_token": "请检查令牌有效性", "req_error_no_balance": "请检查令牌有效性", "image_handle_required": "请先上传图片", @@ -1390,9 +1390,11 @@ "general.restore.button": "恢复", "general.title": "常规设置", "general.user_name": "用户名", - "general.user_name.placeholder": "请输入用户名", + "general.user_name.placeholder": "输入您的姓名", "general.view_webdav_settings": "查看 WebDAV 设置", - "input.auto_translate_with_space": "快速敲击3次空格翻译", + "general.spell_check": "拼写检查", + "general.spell_check.languages": "拼写检查语言", + "input.auto_translate_with_space": "3个空格快速翻译", "input.show_translate_confirm": "显示翻译确认对话框", "input.target_language": "目标语言", "input.target_language.chinese": "简体中文", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 1d23fb540a..72be4f02e6 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -1389,6 +1389,8 @@ "general.user_name": "使用者名稱", "general.user_name.placeholder": "輸入您的名稱", "general.view_webdav_settings": "檢視 WebDAV 設定", + "general.spell_check": "拼寫檢查", + "general.spell_check.languages": "拼寫檢查語言", "input.auto_translate_with_space": "快速敲擊 3 次空格翻譯", "input.show_translate_confirm": "顯示翻譯確認對話框", "input.target_language": "目標語言", diff --git a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx index 958b779030..360a76d8a8 100644 --- a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx +++ b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx @@ -77,7 +77,8 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = showInputEstimatedTokens, autoTranslateWithSpace, enableQuickPanelTriggers, - enableBackspaceDeleteModel + enableBackspaceDeleteModel, + enableSpellCheck } = useSettings() const [expended, setExpend] = useState(false) const [estimateTokenCount, setEstimateTokenCount] = useState(0) @@ -780,9 +781,8 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = : t('chat.input.placeholder', { key: getSendMessageShortcutLabel(sendMessageShortcut) }) } autoFocus - contextMenu="true" variant="borderless" - spellCheck={false} + spellCheck={enableSpellCheck} rows={2} ref={textareaRef} style={{ diff --git a/src/renderer/src/pages/home/Messages/MessageEditor.tsx b/src/renderer/src/pages/home/Messages/MessageEditor.tsx index 895eb787d2..62636ccd68 100644 --- a/src/renderer/src/pages/home/Messages/MessageEditor.tsx +++ b/src/renderer/src/pages/home/Messages/MessageEditor.tsx @@ -40,7 +40,7 @@ const MessageBlockEditor: FC = ({ message, onSave, onResend, onCancel }) const model = assistant.model || assistant.defaultModel const isVision = useMemo(() => isVisionModel(model), [model]) const supportExts = useMemo(() => [...textExts, ...documentExts, ...(isVision ? imageExts : [])], [isVision]) - const { pasteLongTextAsFile, pasteLongTextThreshold, fontSize, sendMessageShortcut } = useSettings() + const { pasteLongTextAsFile, pasteLongTextThreshold, fontSize, sendMessageShortcut, enableSpellCheck } = useSettings() const { t } = useTranslation() const textareaRef = useRef(null) const attachmentButtonRef = useRef(null) @@ -222,13 +222,16 @@ const MessageBlockEditor: FC = ({ message, onSave, onResend, onCancel }) }} onKeyDown={(e) => handleKeyDown(e, block.id)} autoFocus - contextMenu="true" - spellCheck={false} + spellCheck={enableSpellCheck} onPaste={(e) => onPaste(e.nativeEvent)} onFocus={() => { // 记录当前聚焦的组件 PasteService.setLastFocusedComponent('messageEditor') }} + onContextMenu={(e) => { + // 阻止事件冒泡,避免触发全局的 Electron contextMenu + e.stopPropagation() + }} style={{ fontSize, padding: '0px 15px 8px 15px' diff --git a/src/renderer/src/pages/settings/GeneralSettings.tsx b/src/renderer/src/pages/settings/GeneralSettings.tsx index 7665099f23..ba0de7fdf9 100644 --- a/src/renderer/src/pages/settings/GeneralSettings.tsx +++ b/src/renderer/src/pages/settings/GeneralSettings.tsx @@ -2,8 +2,15 @@ import { useTheme } from '@renderer/context/ThemeProvider' import { useSettings } from '@renderer/hooks/useSettings' import i18n from '@renderer/i18n' import { RootState, useAppDispatch } from '@renderer/store' -import { setEnableDataCollection, setLanguage, setNotificationSettings } from '@renderer/store/settings' -import { setProxyMode, setProxyUrl as _setProxyUrl } from '@renderer/store/settings' +import { + setEnableDataCollection, + setEnableSpellCheck, + setLanguage, + setNotificationSettings, + setProxyMode, + setProxyUrl as _setProxyUrl, + setSpellCheckLanguages +} from '@renderer/store/settings' import { LanguageVarious } from '@renderer/types' import { NotificationSource } from '@renderer/types/notification' import { isValidProxyUrl } from '@renderer/utils' @@ -26,7 +33,8 @@ const GeneralSettings: FC = () => { trayOnClose, tray, proxyMode: storeProxyMode, - enableDataCollection + enableDataCollection, + enableSpellCheck } = useSettings() const [proxyUrl, setProxyUrl] = useState(storeProxyUrl) const { theme } = useTheme() @@ -69,6 +77,11 @@ const GeneralSettings: FC = () => { i18n.changeLanguage(value) } + const handleSpellCheckChange = (checked: boolean) => { + dispatch(setEnableSpellCheck(checked)) + window.api.setEnableSpellCheck(checked) + } + const onSetProxyUrl = () => { if (proxyUrl && !isValidProxyUrl(proxyUrl)) { window.message.error({ content: t('message.error.invalid.proxy.url'), key: 'proxy-error' }) @@ -109,11 +122,30 @@ const GeneralSettings: FC = () => { ] const notificationSettings = useSelector((state: RootState) => state.settings.notification) + const spellCheckLanguages = useSelector((state: RootState) => state.settings.spellCheckLanguages) const handleNotificationChange = (type: NotificationSource, value: boolean) => { dispatch(setNotificationSettings({ ...notificationSettings, [type]: value })) } + // Define available spell check languages with display names (only commonly supported languages) + const spellCheckLanguageOptions = [ + { value: 'en-US', label: 'English (US)', flag: '🇺🇸' }, + { value: 'es', label: 'Español', flag: '🇪🇸' }, + { value: 'fr', label: 'Français', flag: '🇫🇷' }, + { value: 'de', label: 'Deutsch', flag: '🇩🇪' }, + { value: 'it', label: 'Italiano', flag: '🇮🇹' }, + { value: 'pt', label: 'Português', flag: '🇵🇹' }, + { value: 'ru', label: 'Русский', flag: '🇷🇺' }, + { value: 'nl', label: 'Nederlands', flag: '🇳🇱' }, + { value: 'pl', label: 'Polski', flag: '🇵🇱' } + ] + + const handleSpellCheckLanguagesChange = (selectedLanguages: string[]) => { + dispatch(setSpellCheckLanguages(selectedLanguages)) + window.api.setSpellCheckLanguages(selectedLanguages) + } + return ( @@ -135,6 +167,37 @@ const GeneralSettings: FC = () => { + + {t('settings.general.spell_check')} + + + {enableSpellCheck && ( + <> + + + {t('settings.general.spell_check.languages')} + ) => { state.enableDataCollection = action.payload }, + setEnableSpellCheck: (state, action: PayloadAction) => { + state.enableSpellCheck = action.payload + }, + setSpellCheckLanguages: (state, action: PayloadAction) => { + state.spellCheckLanguages = action.payload + }, setExportMenuOptions: (state, action: PayloadAction) => { state.exportMenuOptions = action.payload }, @@ -776,8 +786,10 @@ export const { setShowOpenedMinappsInSidebar, setMinappsOpenLinkExternal, setEnableDataCollection, - setEnableQuickPanelTriggers, + setEnableSpellCheck, + setSpellCheckLanguages, setExportMenuOptions, + setEnableQuickPanelTriggers, setEnableBackspaceDeleteModel, setOpenAISummaryText, setOpenAIServiceTier, From f69ea8648c2b5c1ce1bf9854016b76018889be1e Mon Sep 17 00:00:00 2001 From: Ying-xi <62348590+Ying-xi@users.noreply.github.com> Date: Tue, 24 Jun 2025 00:06:52 +0800 Subject: [PATCH 028/111] fix: display updated timestamp when available in knowledge base (#7453) * fix: display updated timestamp when available in knowledge base - Add updated_at field when creating knowledge items - Show updated_at timestamp if it's newer than created_at - Fallback to created_at if updated_at is not available or older Fixes #4587 Signed-off-by: Ying-xi <62348590+Ying-xi@users.noreply.github.com> * refactor(knowledge): extract display time logic into a reusable function Signed-off-by: Ying-xi <62348590+Ying-xi@users.noreply.github.com> --------- Signed-off-by: Ying-xi <62348590+Ying-xi@users.noreply.github.com> --- src/renderer/src/hooks/useKnowledge.ts | 3 ++- .../src/pages/knowledge/KnowledgeContent.tsx | 15 ++++++++++----- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/renderer/src/hooks/useKnowledge.ts b/src/renderer/src/hooks/useKnowledge.ts index 662829612e..efdc9bd120 100644 --- a/src/renderer/src/hooks/useKnowledge.ts +++ b/src/renderer/src/hooks/useKnowledge.ts @@ -169,7 +169,8 @@ export const useKnowledge = (baseId: string) => { processingStatus: 'pending', processingProgress: 0, processingError: '', - uniqueId: undefined + uniqueId: undefined, + updated_at: Date.now() }) setTimeout(() => KnowledgeQueue.checkAllBases(), 0) } diff --git a/src/renderer/src/pages/knowledge/KnowledgeContent.tsx b/src/renderer/src/pages/knowledge/KnowledgeContent.tsx index 37362bd1d6..7d5d251660 100644 --- a/src/renderer/src/pages/knowledge/KnowledgeContent.tsx +++ b/src/renderer/src/pages/knowledge/KnowledgeContent.tsx @@ -35,6 +35,11 @@ interface KnowledgeContentProps { const fileTypes = [...bookExts, ...thirdPartyApplicationExts, ...documentExts, ...textExts] +const getDisplayTime = (item: KnowledgeItem) => { + const timestamp = item.updated_at && item.updated_at > item.created_at ? item.updated_at : item.created_at + return dayjs(timestamp).format('MM-DD HH:mm') +} + const KnowledgeContent: FC = ({ selectedBase }) => { const { t } = useTranslation() const [expandAll, setExpandAll] = useState(false) @@ -335,7 +340,7 @@ const KnowledgeContent: FC = ({ selectedBase }) => { ), ext: file.ext, - extra: `${dayjs(file.created_at).format('MM-DD HH:mm')} · ${formatFileSize(file.size)}`, + extra: `${getDisplayTime(item)} · ${formatFileSize(file.size)}`, actions: ( {item.uniqueId && ( @@ -392,7 +397,7 @@ const KnowledgeContent: FC = ({ selectedBase }) => { ), ext: '.folder', - extra: `${dayjs(item.created_at).format('MM-DD HH:mm')}`, + extra: getDisplayTime(item), actions: ( {item.uniqueId && - - - - - - - }> - - ( - - - {maskApiKey(status.key)} - - {status.checking && ( - - } /> - - )} - {status.isValid === true && !status.checking && } - {status.isValid === false && !status.checking && } - {status.isValid === undefined && !status.checking && ( - {t('settings.provider.not_checked')} - )} - - !isChecking && !isCheckingSingle && removeKey(index)} - style={{ - cursor: isChecking || isCheckingSingle ? 'not-allowed' : 'pointer', - opacity: isChecking || isCheckingSingle ? 0.5 : 1 - }} - /> - - - - )} - /> - - - ) -} - -export default class ApiCheckPopup { - static topviewId = 0 - static hide() { - TopView.hide('ApiCheckPopup') - } - static show(props: ShowParams) { - return new Promise((resolve) => { - TopView.show( - { - resolve(v) - this.hide() - }} - />, - 'ApiCheckPopup' - ) - }) - } -} - -const RemoveIcon = styled(MinusCircleOutlined)` - display: flex; - align-items: center; - justify-content: center; - font-size: 18px; - color: var(--color-error); - cursor: pointer; - transition: all 0.2s ease-in-out; -` diff --git a/src/renderer/src/pages/settings/ProviderSettings/ApiKeyList.tsx b/src/renderer/src/pages/settings/ProviderSettings/ApiKeyList.tsx new file mode 100644 index 0000000000..def4ba061a --- /dev/null +++ b/src/renderer/src/pages/settings/ProviderSettings/ApiKeyList.tsx @@ -0,0 +1,638 @@ +import { + CheckCircleFilled, + CloseCircleFilled, + CloseCircleOutlined, + DeleteOutlined, + EditOutlined, + LoadingOutlined, + MinusCircleOutlined, + PlusOutlined +} from '@ant-design/icons' +import Scrollbar from '@renderer/components/Scrollbar' +import { isEmbeddingModel, isRerankModel } from '@renderer/config/models' +import { checkApi, formatApiKeys } from '@renderer/services/ApiService' +import { isProviderSupportAuth } from '@renderer/services/ProviderService' +import WebSearchService from '@renderer/services/WebSearchService' +import { Model, Provider, WebSearchProvider } from '@renderer/types' +import { maskApiKey, splitApiKeyString } from '@renderer/utils/api' +import { Button, Card, Flex, Input, List, Space, Spin, Tooltip, Typography } from 'antd' +import { isEmpty } from 'lodash' +import { FC, useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import styled from 'styled-components' + +import SelectProviderModelPopup from './SelectProviderModelPopup' + +interface Props { + provider: Provider | WebSearchProvider + apiKeys: string + onChange: (keys: string) => void + type?: 'provider' | 'websearch' +} + +interface KeyStatus { + key: string + isValid?: boolean + checking?: boolean + error?: string + model?: Model + latency?: number +} + +const STATUS_COLORS = { + success: '#52c41a', + error: '#ff4d4f' +} + +const formatAndConvertKeysToArray = (apiKeys: string): KeyStatus[] => { + const formattedApiKeys = formatApiKeys(apiKeys) + if (formattedApiKeys.includes(',')) { + const keys = splitApiKeyString(formattedApiKeys) + const uniqueKeys = new Set(keys) + return Array.from(uniqueKeys).map((key) => ({ key })) + } else { + return formattedApiKeys ? [{ key: formattedApiKeys }] : [] + } +} + +const ApiKeyList: FC = ({ provider, apiKeys, onChange, type = 'provider' }) => { + const [keyStatuses, setKeyStatuses] = useState(() => formatAndConvertKeysToArray(apiKeys)) + const [isAddingNew, setIsAddingNew] = useState(false) + const [newApiKey, setNewApiKey] = useState('') + const [isCancelingNewKey, setIsCancelingNewKey] = useState(false) + const newInputRef = useRef(null) + const [editingIndex, setEditingIndex] = useState(null) + const [editValue, setEditValue] = useState('') + const editInputRef = useRef(null) + const { t } = useTranslation() + const [isChecking, setIsChecking] = useState(false) + const [isCheckingSingle, setIsCheckingSingle] = useState(false) + const [confirmDeleteIndex, setConfirmDeleteIndex] = useState(null) + const isCopilot = provider.id === 'copilot' + + useEffect(() => { + if (isAddingNew && newInputRef.current) { + newInputRef.current.focus() + } + }, [isAddingNew]) + + useEffect(() => { + const newKeyStatuses = formatAndConvertKeysToArray(apiKeys) + + setKeyStatuses((currentStatuses) => { + const newKeys = newKeyStatuses.map((k) => k.key) + const currentKeys = currentStatuses.map((k) => k.key) + + // If the keys are the same, no need to update, prevents re-render loops. + if (newKeys.join(',') === currentKeys.join(',')) { + return currentStatuses + } + + // Merge new keys with existing statuses to preserve them. + const statusesMap = new Map(currentStatuses.map((s) => [s.key, s])) + return newKeyStatuses.map((k) => statusesMap.get(k.key) || k) + }) + }, [apiKeys]) + + useEffect(() => { + if (editingIndex !== null && editInputRef.current) { + editInputRef.current.focus() + } + }, [editingIndex]) + + const handleAddNewKey = () => { + setIsCancelingNewKey(false) + setIsAddingNew(true) + setNewApiKey('') + } + + const handleSaveNewKey = () => { + if (isCancelingNewKey) { + setIsCancelingNewKey(false) + return + } + + if (newApiKey.trim()) { + // Check if the key already exists + const keyExists = keyStatuses.some((status) => status.key === newApiKey.trim()) + + if (keyExists) { + window.message.error({ + key: 'duplicate-key', + style: { marginTop: '3vh' }, + duration: 3, + content: t('settings.provider.key_already_exists') + }) + return + } + + if (newApiKey.includes(',')) { + window.message.error({ + key: 'invalid-key', + style: { marginTop: '3vh' }, + duration: 3, + content: t('settings.provider.invalid_key') + }) + return + } + + const updatedKeyStatuses = [...keyStatuses, { key: newApiKey.trim() }] + setKeyStatuses(updatedKeyStatuses) + // Update parent component with new keys + onChange(updatedKeyStatuses.map((status) => status.key).join(',')) + } + + // Add a small delay before resetting to prevent immediate re-triggering + setTimeout(() => { + setIsAddingNew(false) + setNewApiKey('') + }, 100) + } + + const handleCancelNewKey = () => { + setIsCancelingNewKey(true) + setIsAddingNew(false) + setNewApiKey('') + } + + const getModelForCheck = async (selectedModel?: Model): Promise => { + if (type !== 'provider') return null + + const modelsToCheck = (provider as Provider).models.filter( + (model) => !isEmbeddingModel(model) && !isRerankModel(model) + ) + + if (isEmpty(modelsToCheck)) { + window.message.error({ + key: 'no-models', + style: { marginTop: '3vh' }, + duration: 5, + content: t('settings.provider.no_models_for_check') + }) + return null + } + + try { + return ( + selectedModel || + (await SelectProviderModelPopup.show({ + provider: provider as Provider + })) + ) + } catch (err) { + // User canceled the popup + return null + } + } + + const checkSingleKey = async (keyIndex: number, selectedModel?: Model, isCheckingAll: boolean = false) => { + if (isChecking || keyStatuses[keyIndex].checking) { + return + } + + try { + let latency: number + let model: Model | undefined + + if (type === 'provider') { + const selectedModelForCheck = await getModelForCheck(selectedModel) + if (!selectedModelForCheck) { + setKeyStatuses((prev) => + prev.map((status, idx) => (idx === keyIndex ? { ...status, checking: false } : status)) + ) + setIsCheckingSingle(false) + return + } + model = selectedModelForCheck + + setIsCheckingSingle(true) + setKeyStatuses((prev) => prev.map((status, idx) => (idx === keyIndex ? { ...status, checking: true } : status))) + + const startTime = Date.now() + await checkApi({ ...(provider as Provider), apiKey: keyStatuses[keyIndex].key }, model) + latency = Date.now() - startTime + } else { + setIsCheckingSingle(true) + setKeyStatuses((prev) => prev.map((status, idx) => (idx === keyIndex ? { ...status, checking: true } : status))) + + const startTime = Date.now() + await WebSearchService.checkSearch({ + ...(provider as WebSearchProvider), + apiKey: keyStatuses[keyIndex].key + }) + latency = Date.now() - startTime + } + + // Only show notification when checking a single key + if (!isCheckingAll) { + window.message.success({ + key: 'api-check', + style: { marginTop: '3vh' }, + duration: 2, + content: t('message.api.connection.success') + }) + } + + setKeyStatuses((prev) => + prev.map((status, idx) => + idx === keyIndex + ? { + ...status, + checking: false, + isValid: true, + model: selectedModel || model, + latency + } + : status + ) + ) + } catch (error: any) { + // Only show notification when checking a single key + if (!isCheckingAll) { + const errorMessage = error?.message ? ' ' + error.message : '' + window.message.error({ + key: 'api-check', + style: { marginTop: '3vh' }, + duration: 8, + content: t('message.api.connection.failed') + errorMessage + }) + } + + setKeyStatuses((prev) => + prev.map((status, idx) => + idx === keyIndex + ? { + ...status, + checking: false, + isValid: false, + error: error instanceof Error ? error.message : String(error) + } + : status + ) + ) + } finally { + setIsCheckingSingle(false) + } + } + + const checkAllKeys = async () => { + setIsChecking(true) + + try { + let selectedModel + if (type === 'provider') { + selectedModel = await getModelForCheck() + if (!selectedModel) { + return + } + } + + await Promise.all(keyStatuses.map((_, index) => checkSingleKey(index, selectedModel, true))) + } finally { + setIsChecking(false) + } + } + + const removeInvalidKeys = () => { + const updatedKeyStatuses = keyStatuses.filter((status) => status.isValid !== false) + setKeyStatuses(updatedKeyStatuses) + onChange(updatedKeyStatuses.map((status) => status.key).join(',')) + } + + const removeKey = (keyIndex: number) => { + if (confirmDeleteIndex === keyIndex) { + // Second click - actually remove the key + const updatedKeyStatuses = keyStatuses.filter((_, idx) => idx !== keyIndex) + setKeyStatuses(updatedKeyStatuses) + onChange(updatedKeyStatuses.map((status) => status.key).join(',')) + setConfirmDeleteIndex(null) + } else { + // First click - show confirmation state + setConfirmDeleteIndex(keyIndex) + // Auto-reset after 3 seconds + setTimeout(() => { + setConfirmDeleteIndex(null) + }, 3000) + } + } + + const renderKeyCheckResultTooltip = (status: KeyStatus) => { + if (status.checking) { + return t('settings.models.check.checking') + } + + const statusTitle = status.isValid ? t('settings.models.check.passed') : t('settings.models.check.failed') + const statusColor = status.isValid ? STATUS_COLORS.success : STATUS_COLORS.error + + return ( +
+ {statusTitle} + {type === 'provider' && status.model && ( +
+ {t('common.model')}: {status.model.name} +
+ )} + {status.latency && status.isValid && ( +
+ {t('settings.provider.check_tooltip.latency')}: {(status.latency / 1000).toFixed(2)}s +
+ )} + {status.error &&
{status.error}
} +
+ ) + } + + const shouldAutoFocus = () => { + if (type === 'provider') { + return (provider as Provider).enabled && apiKeys === '' && !isProviderSupportAuth(provider as Provider) + } else if (type === 'websearch') { + return apiKeys === '' + } + return false + } + + const handleEditKey = (index: number) => { + setEditingIndex(index) + setEditValue(keyStatuses[index].key) + } + + const handleSaveEdit = () => { + if (editingIndex === null) return + + if (editValue.trim()) { + const keyExists = keyStatuses.some((status, idx) => idx !== editingIndex && status.key === editValue.trim()) + + if (keyExists) { + window.message.error({ + key: 'duplicate-key', + style: { marginTop: '3vh' }, + duration: 3, + content: t('settings.provider.key_already_exists') + }) + return + } + + if (editValue.includes(',')) { + window.message.error({ + key: 'invalid-key', + style: { marginTop: '3vh' }, + duration: 3, + content: t('settings.provider.invalid_key') + }) + return + } + + const updatedKeyStatuses = [...keyStatuses] + updatedKeyStatuses[editingIndex] = { + ...updatedKeyStatuses[editingIndex], + key: editValue.trim(), + isValid: undefined + } + + setKeyStatuses(updatedKeyStatuses) + onChange(updatedKeyStatuses.map((status) => status.key).join(',')) + } + + // Add a small delay before resetting to prevent immediate re-triggering + setTimeout(() => { + setEditingIndex(null) + setEditValue('') + }, 100) + } + + const handleCancelEdit = () => { + setEditingIndex(null) + setEditValue('') + } + + return ( + <> + + {keyStatuses.length === 0 && !isAddingNew ? ( + + {t('error.no_api_key')} + + ) : ( + <> + {keyStatuses.length > 0 && ( + + ( + + + + {editingIndex === index ? ( + setEditValue(e.target.value)} + onBlur={handleSaveEdit} + onPressEnter={handleSaveEdit} + onKeyDown={(e) => { + if (e.key === 'Escape') { + e.preventDefault() + handleCancelEdit() + } + }} + style={{ width: '100%', fontSize: '14px' }} + spellCheck={false} + type="password" + /> + ) : ( + {maskApiKey(status.key)} + )} + + + {editingIndex === index ? ( + + ) : ( + <> + + {status.checking && ( + + } /> + + )} + {status.isValid === true && !status.checking && ( + + )} + {status.isValid === false && !status.checking && ( + + )} + + + {!isCopilot && ( + <> + !isChecking && !isCheckingSingle && handleEditKey(index)} + style={{ + cursor: isChecking || isCheckingSingle ? 'not-allowed' : 'pointer', + opacity: isChecking || isCheckingSingle ? 0.5 : 1, + fontSize: '16px' + }} + title={t('common.edit')} + /> + {confirmDeleteIndex === index ? ( + !isChecking && !isCheckingSingle && removeKey(index)} + style={{ + cursor: isChecking || isCheckingSingle ? 'not-allowed' : 'pointer', + opacity: isChecking || isCheckingSingle ? 0.5 : 1, + fontSize: '16px', + color: 'var(--color-error)' + }} + title={t('common.delete')} + /> + ) : ( + !isChecking && !isCheckingSingle && removeKey(index)} + style={{ + cursor: isChecking || isCheckingSingle ? 'not-allowed' : 'pointer', + opacity: isChecking || isCheckingSingle ? 0.5 : 1, + fontSize: '16px', + color: 'var(--color-error)' + }} + title={t('common.delete')} + /> + )} + + )} + + )} + + + + )} + /> + + )} + {isAddingNew && ( + + + setNewApiKey(e.target.value)} + placeholder={t('settings.provider.enter_new_api_key')} + style={{ width: '60%', fontSize: '14px' }} + onPressEnter={handleSaveNewKey} + onBlur={handleSaveNewKey} + onKeyDown={(e) => { + if (e.key === 'Escape') { + e.preventDefault() + handleCancelNewKey() + } + }} + spellCheck={false} + type="password" + /> + + + + + + )} + + )} + + + + {!isCopilot && ( + <> + + + + {keyStatuses.length > 1 && ( + + + + + )} + + )} + + + ) +} + +// Styled components for the list items +const ApiKeyListItem = styled.div` + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + width: 100%; + padding: 0; + margin: 0; +` + +const ApiKeyContainer = styled.div` + display: flex; + flex-direction: row; + align-items: center; +` + +const ApiKeyActions = styled.div` + display: flex; + flex-direction: row; + align-items: center; + gap: 10px; + + @keyframes pulse { + 0% { + opacity: 1; + } + 50% { + opacity: 0.5; + } + 100% { + opacity: 1; + } + } +` + +export default ApiKeyList diff --git a/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx b/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx index 74a414d81c..f708b28679 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx @@ -1,14 +1,11 @@ -import { CheckOutlined, LoadingOutlined } from '@ant-design/icons' import { isOpenAIProvider } from '@renderer/aiCore/clients/ApiClientFactory' import OpenAIAlert from '@renderer/components/Alert/OpenAIAlert' import { StreamlineGoodHealthAndWellBeing } from '@renderer/components/Icons/SVGIcon' import { HStack } from '@renderer/components/Layout' -import { isEmbeddingModel, isRerankModel } from '@renderer/config/models' +import { isRerankModel } from '@renderer/config/models' import { PROVIDER_CONFIG } from '@renderer/config/providers' import { useTheme } from '@renderer/context/ThemeProvider' import { useAllProviders, useProvider, useProviders } from '@renderer/hooks/useProvider' -import i18n from '@renderer/i18n' -import { checkApi, formatApiKeys } from '@renderer/services/ApiService' import { checkModelsHealth, getModelCheckSummary } from '@renderer/services/HealthCheckService' import { isProviderSupportAuth } from '@renderer/services/ProviderService' import { Provider } from '@renderer/types' @@ -16,7 +13,7 @@ import { formatApiHost, splitApiKeyString } from '@renderer/utils/api' import { lightbulbVariants } from '@renderer/utils/motionVariants' import { Button, Divider, Flex, Input, Space, Switch, Tooltip } from 'antd' import Link from 'antd/es/typography/Link' -import { debounce, isEmpty } from 'lodash' +import { isEmpty } from 'lodash' import { Settings2, SquareArrowOutUpRight } from 'lucide-react' import { motion } from 'motion/react' import { FC, useCallback, useDeferredValue, useEffect, useState } from 'react' @@ -31,7 +28,7 @@ import { SettingSubtitle, SettingTitle } from '..' -import ApiCheckPopup from './ApiCheckPopup' +import ApiKeyList from './ApiKeyList' import DMXAPISettings from './DMXAPISettings' import GithubCopilotSettings from './GithubCopilotSettings' import GPUStackSettings from './GPUStackSettings' @@ -41,7 +38,6 @@ import ModelList, { ModelStatus } from './ModelList' import ModelListSearchBar from './ModelListSearchBar' import ProviderOAuth from './ProviderOAuth' import ProviderSettingsPopup from './ProviderSettingsPopup' -import SelectProviderModelPopup from './SelectProviderModelPopup' import VertexAISettings from './VertexAISettings' interface Props { @@ -55,14 +51,11 @@ const ProviderSetting: FC = ({ provider: _provider }) => { const [apiKey, setApiKey] = useState(provider.apiKey) const [apiHost, setApiHost] = useState(provider.apiHost) const [apiVersion, setApiVersion] = useState(provider.apiVersion) - const [apiValid, setApiValid] = useState(false) - const [apiChecking, setApiChecking] = useState(false) const [modelSearchText, setModelSearchText] = useState('') const deferredModelSearchText = useDeferredValue(modelSearchText) const { updateProvider, models } = useProvider(provider.id) const { t } = useTranslation() const { theme } = useTheme() - const [inputValue, setInputValue] = useState(apiKey) const isAzureOpenAI = provider.id === 'azure-openai' || provider.type === 'azure-openai' @@ -76,14 +69,6 @@ const ProviderSetting: FC = ({ provider: _provider }) => { const [modelStatuses, setModelStatuses] = useState([]) const [isHealthChecking, setIsHealthChecking] = useState(false) - // eslint-disable-next-line react-hooks/exhaustive-deps - const debouncedSetApiKey = useCallback( - debounce((value) => { - setApiKey(formatApiKeys(value)) - }, 100), - [] - ) - const moveProviderToTop = useCallback( (providerId: string) => { const reorderedProviders = [...allProviders] @@ -99,12 +84,6 @@ const ProviderSetting: FC = ({ provider: _provider }) => { [allProviders, updateProviders] ) - const onUpdateApiKey = () => { - if (apiKey !== provider.apiKey) { - updateProvider({ ...provider, apiKey }) - } - } - const onUpdateApiHost = () => { if (apiHost.trim()) { updateProvider({ ...provider, apiHost }) @@ -113,6 +92,11 @@ const ProviderSetting: FC = ({ provider: _provider }) => { } } + const handleApiKeyChange = (newApiKey: string) => { + setApiKey(newApiKey) + updateProvider({ ...provider, apiKey: newApiKey }) + } + const onUpdateApiVersion = () => updateProvider({ ...provider, apiVersion }) const onHealthCheck = async () => { @@ -192,75 +176,6 @@ const ProviderSetting: FC = ({ provider: _provider }) => { setIsHealthChecking(false) } - const onCheckApi = async () => { - const modelsToCheck = models.filter((model) => !isEmbeddingModel(model) && !isRerankModel(model)) - - if (isEmpty(modelsToCheck)) { - window.message.error({ - key: 'no-models', - style: { marginTop: '3vh' }, - duration: 5, - content: t('settings.provider.no_models_for_check') - }) - return - } - - const model = await SelectProviderModelPopup.show({ provider }) - - if (!model) { - window.message.error({ content: i18n.t('message.error.enter.model'), key: 'api-check' }) - return - } - - if (apiKey.includes(',')) { - const keys = splitApiKeyString(apiKey) - - const result = await ApiCheckPopup.show({ - title: t('settings.provider.check_multiple_keys'), - provider: { ...provider, apiHost }, - model, - apiKeys: keys, - type: 'provider' - }) - - if (result?.validKeys) { - const newApiKey = result.validKeys.join(',') - setInputValue(newApiKey) - setApiKey(newApiKey) - updateProvider({ ...provider, apiKey: newApiKey }) - } - } else { - setApiChecking(true) - - try { - await checkApi({ ...provider, apiKey, apiHost }, model) - - window.message.success({ - key: 'api-check', - style: { marginTop: '3vh' }, - duration: 2, - content: i18n.t('message.api.connection.success') - }) - - setApiValid(true) - setTimeout(() => setApiValid(false), 3000) - } catch (error: any) { - const errorMessage = error?.message ? ' ' + error.message : '' - - window.message.error({ - key: 'api-check', - style: { marginTop: '3vh' }, - duration: 8, - content: i18n.t('message.api.connection.failed') + errorMessage - }) - - setApiValid(false) - } finally { - setApiChecking(false) - } - } - } - const onReset = () => { setApiHost(configedApiHost) updateProvider({ ...provider, apiHost: configedApiHost }) @@ -329,7 +244,6 @@ const ProviderSetting: FC = ({ provider: _provider }) => { provider={provider} setApiKey={(v) => { setApiKey(v) - setInputValue(v) updateProvider({ ...provider, apiKey: v }) }} /> @@ -338,35 +252,14 @@ const ProviderSetting: FC = ({ provider: _provider }) => { {isDmxapi && } {provider.id !== 'vertexai' && ( <> - {t('settings.provider.api_key')} - - { - setInputValue(e.target.value) - debouncedSetApiKey(e.target.value) - }} - onBlur={() => { - const formattedValue = formatApiKeys(inputValue) - setInputValue(formattedValue) - setApiKey(formattedValue) - onUpdateApiKey() - }} - spellCheck={false} - autoFocus={provider.enabled && apiKey === '' && !isProviderSupportAuth(provider)} - disabled={provider.id === 'copilot'} - /> - - + + + {t('settings.provider.api_key')} + + + {apiKeyWebsite && ( - + {!isDmxapi && ( @@ -374,7 +267,6 @@ const ProviderSetting: FC = ({ provider: _provider }) => { )} - {t('settings.provider.api_key.tip')} )} {!isDmxapi && ( diff --git a/src/renderer/src/pages/settings/WebSearchSettings/WebSearchProviderSetting.tsx b/src/renderer/src/pages/settings/WebSearchSettings/WebSearchProviderSetting.tsx index 44a3f01714..97d01d4f5e 100644 --- a/src/renderer/src/pages/settings/WebSearchSettings/WebSearchProviderSetting.tsx +++ b/src/renderer/src/pages/settings/WebSearchSettings/WebSearchProviderSetting.tsx @@ -1,19 +1,17 @@ -import { CheckOutlined, ExportOutlined, LoadingOutlined } from '@ant-design/icons' +import { ExportOutlined } from '@ant-design/icons' import { getWebSearchProviderLogo, WEB_SEARCH_PROVIDER_CONFIG } from '@renderer/config/webSearchProviders' import { useWebSearchProvider } from '@renderer/hooks/useWebSearchProviders' -import { formatApiKeys } from '@renderer/services/ApiService' -import WebSearchService from '@renderer/services/WebSearchService' import { WebSearchProvider } from '@renderer/types' import { hasObjectKey } from '@renderer/utils' -import { Button, Divider, Flex, Form, Input, Tooltip } from 'antd' +import { Divider, Flex, Form, Input, Tooltip } from 'antd' import Link from 'antd/es/typography/Link' import { Info } from 'lucide-react' import { FC, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' -import { SettingDivider, SettingHelpLink, SettingHelpText, SettingHelpTextRow, SettingSubtitle, SettingTitle } from '..' -import ApiCheckPopup from '../ProviderSettings/ApiCheckPopup' +import { SettingDivider, SettingHelpLink, SettingHelpTextRow, SettingSubtitle, SettingTitle } from '..' +import ApiKeyList from '../ProviderSettings/ApiKeyList' interface Props { provider: WebSearchProvider @@ -24,19 +22,16 @@ const WebSearchProviderSetting: FC = ({ provider: _provider }) => { const { t } = useTranslation() const [apiKey, setApiKey] = useState(provider.apiKey || '') const [apiHost, setApiHost] = useState(provider.apiHost || '') - const [apiChecking, setApiChecking] = useState(false) const [basicAuthUsername, setBasicAuthUsername] = useState(provider.basicAuthUsername || '') const [basicAuthPassword, setBasicAuthPassword] = useState(provider.basicAuthPassword || '') - const [apiValid, setApiValid] = useState(false) const webSearchProviderConfig = WEB_SEARCH_PROVIDER_CONFIG[provider.id] const apiKeyWebsite = webSearchProviderConfig?.websites?.apiKey const officialWebsite = webSearchProviderConfig?.websites?.official - const onUpdateApiKey = () => { - if (apiKey !== provider.apiKey) { - updateProvider({ ...provider, apiKey }) - } + const handleApiKeyChange = (newApiKey: string) => { + setApiKey(newApiKey) + updateProvider({ ...provider, apiKey: newApiKey }) } const onUpdateApiHost = () => { @@ -71,65 +66,6 @@ const WebSearchProviderSetting: FC = ({ provider: _provider }) => { } } - async function checkSearch() { - if (!provider) { - window.message.error({ - content: t('settings.websearch.no_provider_selected'), - duration: 3, - icon: , - key: 'no-provider-selected' - }) - return - } - - if (apiKey.includes(',')) { - const keys = apiKey - .split(',') - .map((k) => k.trim()) - .filter((k) => k) - - const result = await ApiCheckPopup.show({ - title: t('settings.provider.check_multiple_keys'), - provider: { ...provider, apiHost }, - apiKeys: keys, - type: 'websearch' - }) - - if (result?.validKeys) { - setApiKey(result.validKeys.join(',')) - updateProvider({ ...provider, apiKey: result.validKeys.join(',') }) - } - return - } - - try { - setApiChecking(true) - const { valid, error } = await WebSearchService.checkSearch(provider) - - const errorMessage = error && error?.message ? ' ' + error?.message : '' - window.message[valid ? 'success' : 'error']({ - key: 'api-check', - style: { marginTop: '3vh' }, - duration: valid ? 2 : 8, - content: valid ? t('settings.websearch.check_success') : t('settings.websearch.check_failed') + errorMessage - }) - - setApiValid(valid) - } catch (err) { - console.error('Check search error:', err) - setApiValid(false) - window.message.error({ - key: 'check-search-error', - style: { marginTop: '3vh' }, - duration: 8, - content: t('settings.websearch.check_failed') - }) - } finally { - setApiChecking(false) - setTimeout(() => setApiValid(false), 2500) - } - } - useEffect(() => { setApiKey(provider.apiKey ?? '') setApiHost(provider.apiHost ?? '') @@ -154,30 +90,14 @@ const WebSearchProviderSetting: FC = ({ provider: _provider }) => { {hasObjectKey(provider, 'apiKey') && ( <> {t('settings.provider.api_key')} - - setApiKey(formatApiKeys(e.target.value))} - onBlur={onUpdateApiKey} - spellCheck={false} - type="password" - autoFocus={apiKey === ''} - /> - - - - - {t('settings.websearch.get_api_key')} - - {t('settings.provider.api_key.tip')} - + + {apiKeyWebsite && ( + + + {t('settings.websearch.get_api_key')} + + + )} )} {hasObjectKey(provider, 'apiHost') && ( From f2c9bf433e25912bced3bca0ba525f986dc4ae60 Mon Sep 17 00:00:00 2001 From: one Date: Tue, 24 Jun 2025 04:01:05 +0800 Subject: [PATCH 030/111] refactor(CodePreview): auto resize gutters (#7481) * refactor(CodePreview): auto resize gutters * refactor: remove unnecessary usememo --- .../components/CodeBlockView/CodePreview.tsx | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/components/CodeBlockView/CodePreview.tsx b/src/renderer/src/components/CodeBlockView/CodePreview.tsx index d3c56f295b..566a980f67 100644 --- a/src/renderer/src/components/CodeBlockView/CodePreview.tsx +++ b/src/renderer/src/components/CodeBlockView/CodePreview.tsx @@ -168,9 +168,15 @@ const CodePreview = ({ children, language, setTools }: CodePreviewProps) => { } }, [highlightCode]) - const hasHighlightedCode = useMemo(() => { - return tokenLines.length > 0 - }, [tokenLines.length]) + useEffect(() => { + const container = codeContentRef.current + if (!container || !codeShowLineNumbers) return + + const digits = Math.max(tokenLines.length.toString().length, 1) + container.style.setProperty('--line-digits', digits.toString()) + }, [codeShowLineNumbers, tokenLines.length]) + + const hasHighlightedCode = tokenLines.length > 0 return ( (props.$lineNumbers ? '2rem' : '0')}; + padding-left: ${(props) => (props.$lineNumbers ? 'var(--gutter-width)' : '0')}; * { overflow-wrap: ${(props) => (props.$wrap ? 'break-word' : 'normal')}; From e2b813372950b041cf81c777d5b9236d097ff1e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=87=AA=E7=94=B1=E7=9A=84=E4=B8=96=E7=95=8C=E4=BA=BA?= <3196812536@qq.com> Date: Tue, 24 Jun 2025 18:51:58 +0800 Subject: [PATCH 031/111] refactor: file actions into FileAction service (#7413) * refactor: file actions into FileAction service Moved file sorting, deletion, and renaming logic from FilesPage to a new FileAction service for better modularity and reuse. Updated FileList and FilesPage to use the new service functions, and improved the delete button UI in FileList. --- src/renderer/src/pages/files/FileList.tsx | 42 +++++++ src/renderer/src/pages/files/FilesPage.tsx | 137 +-------------------- src/renderer/src/services/FileAction.ts | 98 +++++++++++++++ 3 files changed, 143 insertions(+), 134 deletions(-) create mode 100644 src/renderer/src/services/FileAction.ts diff --git a/src/renderer/src/pages/files/FileList.tsx b/src/renderer/src/pages/files/FileList.tsx index cdb0421439..a08de9912f 100644 --- a/src/renderer/src/pages/files/FileList.tsx +++ b/src/renderer/src/pages/files/FileList.tsx @@ -1,3 +1,5 @@ +import { DeleteOutlined, ExclamationCircleOutlined } from '@ant-design/icons' +import { handleDelete } from '@renderer/services/FileAction' import FileManager from '@renderer/services/FileManager' import { FileType, FileTypes } from '@renderer/types' import { formatFileSize } from '@renderer/utils' @@ -48,6 +50,24 @@ const FileList: React.FC = ({ id, list, files }) => {
{formatFileSize(file.size)}
+ { + e.stopPropagation() + window.modal.confirm({ + title: t('files.delete.title'), + content: t('files.delete.content'), + okText: t('common.confirm'), + cancelText: t('common.cancel'), + centered: true, + onOk: () => { + handleDelete(file.id, t) + }, + icon: + }) + }}> + + ))} @@ -159,4 +179,26 @@ const ImageInfo = styled.div` } ` +const DeleteButton = styled.div` + position: absolute; + top: 8px; + right: 8px; + width: 24px; + height: 24px; + border-radius: 50%; + background-color: rgba(0, 0, 0, 0.6); + color: white; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + opacity: 0; + transition: opacity 0.3s ease; + z-index: 1; + + &:hover { + background-color: rgba(255, 0, 0, 0.8); + } +` + export default memo(FileList) diff --git a/src/renderer/src/pages/files/FilesPage.tsx b/src/renderer/src/pages/files/FilesPage.tsx index c070478fea..2890a0cb85 100644 --- a/src/renderer/src/pages/files/FilesPage.tsx +++ b/src/renderer/src/pages/files/FilesPage.tsx @@ -7,13 +7,10 @@ import { } from '@ant-design/icons' import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar' import ListItem from '@renderer/components/ListItem' -import TextEditPopup from '@renderer/components/Popups/TextEditPopup' -import Logger from '@renderer/config/logger' import db from '@renderer/databases' +import { handleDelete, handleRename, sortFiles, tempFilesSort } from '@renderer/services/FileAction' import FileManager from '@renderer/services/FileManager' -import store from '@renderer/store' import { FileType, FileTypes } from '@renderer/types' -import { Message } from '@renderer/types/newMessage' import { formatFileSize } from '@renderer/utils' import { Button, Empty, Flex, Popconfirm } from 'antd' import dayjs from 'dayjs' @@ -34,34 +31,6 @@ const FilesPage: FC = () => { const [sortField, setSortField] = useState('created_at') const [sortOrder, setSortOrder] = useState('desc') - const tempFilesSort = (files: FileType[]) => { - return files.sort((a, b) => { - const aIsTemp = a.origin_name.startsWith('temp_file') - const bIsTemp = b.origin_name.startsWith('temp_file') - if (aIsTemp && !bIsTemp) return 1 - if (!aIsTemp && bIsTemp) return -1 - return 0 - }) - } - - const sortFiles = (files: FileType[]) => { - return [...files].sort((a, b) => { - let comparison = 0 - switch (sortField) { - case 'created_at': - comparison = dayjs(a.created_at).unix() - dayjs(b.created_at).unix() - break - case 'size': - comparison = a.size - b.size - break - case 'name': - comparison = a.origin_name.localeCompare(b.origin_name) - break - } - return sortOrder === 'asc' ? comparison : -comparison - }) - } - const files = useLiveQuery(() => { if (fileType === 'all') { return db.files.orderBy('count').toArray().then(tempFilesSort) @@ -69,106 +38,7 @@ const FilesPage: FC = () => { return db.files.where('type').equals(fileType).sortBy('count').then(tempFilesSort) }, [fileType]) - const sortedFiles = files ? sortFiles(files) : [] - - const handleDelete = async (fileId: string) => { - const file = await FileManager.getFile(fileId) - if (!file) return - - const paintings = await store.getState().paintings.paintings - const paintingsFiles = paintings.flatMap((p) => p.files) - - if (paintingsFiles.some((p) => p.id === fileId)) { - window.modal.warning({ content: t('files.delete.paintings.warning'), centered: true }) - return - } - if (file) { - await FileManager.deleteFile(fileId, true) - } - - const relatedBlocks = await db.message_blocks.where('file.id').equals(fileId).toArray() - - const blockIdsToDelete = relatedBlocks.map((block) => block.id) - - const blocksByMessageId: Record = {} - for (const block of relatedBlocks) { - if (!blocksByMessageId[block.messageId]) { - blocksByMessageId[block.messageId] = [] - } - blocksByMessageId[block.messageId].push(block.id) - } - - try { - const affectedMessageIds = [...new Set(relatedBlocks.map((b) => b.messageId))] - - if (affectedMessageIds.length === 0 && blockIdsToDelete.length > 0) { - // This case should ideally not happen if relatedBlocks were found, - // but handle it just in case: only delete blocks. - await db.message_blocks.bulkDelete(blockIdsToDelete) - Logger.log( - `Deleted ${blockIdsToDelete.length} blocks related to file ${fileId}. No associated messages found (unexpected).` - ) - return - } - - await db.transaction('rw', db.topics, db.message_blocks, async () => { - // Fetch all topics (potential performance bottleneck if many topics) - const allTopics = await db.topics.toArray() - const topicsToUpdate: Record = {} // Store updates keyed by topicId - - for (const topic of allTopics) { - let topicModified = false - // Ensure topic.messages exists and is an array before mapping - const currentMessages = Array.isArray(topic.messages) ? topic.messages : [] - const updatedMessages = currentMessages.map((message) => { - // Check if this message is affected - if (affectedMessageIds.includes(message.id)) { - // Ensure message.blocks exists and is an array - const currentBlocks = Array.isArray(message.blocks) ? message.blocks : [] - const originalBlockCount = currentBlocks.length - // Filter out the blocks marked for deletion - const newBlocks = currentBlocks.filter((blockId) => !blockIdsToDelete.includes(blockId)) - if (newBlocks.length < originalBlockCount) { - topicModified = true - return { ...message, blocks: newBlocks } // Return updated message - } - } - return message // Return original message - }) - - if (topicModified) { - // Store the update for this topic - topicsToUpdate[topic.id] = { messages: updatedMessages } - } - } - - // Apply updates to topics - const updatePromises = Object.entries(topicsToUpdate).map(([topicId, updateData]) => - db.topics.update(topicId, updateData) - ) - await Promise.all(updatePromises) - - // Finally, delete the MessageBlocks - await db.message_blocks.bulkDelete(blockIdsToDelete) - }) - - Logger.log(`Deleted ${blockIdsToDelete.length} blocks and updated relevant topic messages for file ${fileId}.`) - } catch (error) { - Logger.error(`Error updating topics or deleting blocks for file ${fileId}:`, error) - window.modal.error({ content: t('files.delete.db_error'), centered: true }) // 提示数据库操作失败 - // Consider whether to attempt to restore the physical file (usually difficult) - } - } - - const handleRename = async (fileId: string) => { - const file = await FileManager.getFile(fileId) - if (file) { - const newName = await TextEditPopup.show({ text: file.origin_name }) - if (newName) { - FileManager.updateFile({ ...file, origin_name: newName }) - } - } - } + const sortedFiles = files ? sortFiles(files, sortField, sortOrder) : [] const dataSource = sortedFiles?.map((file) => { return { @@ -189,7 +59,7 @@ const FilesPage: FC = () => { description={t('files.delete.content')} okText={t('common.confirm')} cancelText={t('common.cancel')} - onConfirm={() => handleDelete(file.id)} + onConfirm={() => handleDelete(file.id, t)} icon={}> + + + + + + + }> + + ( + + + {maskApiKey(status.key)} + + {status.checking && ( + + } /> + + )} + {status.isValid === true && !status.checking && } + {status.isValid === false && !status.checking && } + {status.isValid === undefined && !status.checking && ( + {t('settings.provider.not_checked')} + )} + + !isChecking && !isCheckingSingle && removeKey(index)} + style={{ + cursor: isChecking || isCheckingSingle ? 'not-allowed' : 'pointer', + opacity: isChecking || isCheckingSingle ? 0.5 : 1 + }} + /> + + + + )} + /> + + + ) +} + +export default class ApiCheckPopup { + static topviewId = 0 + static hide() { + TopView.hide('ApiCheckPopup') + } + static show(props: ShowParams) { + return new Promise((resolve) => { + TopView.show( + { + resolve(v) + this.hide() + }} + />, + 'ApiCheckPopup' + ) + }) + } +} + +const RemoveIcon = styled(MinusCircleOutlined)` + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + color: var(--color-error); + cursor: pointer; + transition: all 0.2s ease-in-out; +` diff --git a/src/renderer/src/pages/settings/ProviderSettings/ApiKeyList.tsx b/src/renderer/src/pages/settings/ProviderSettings/ApiKeyList.tsx deleted file mode 100644 index def4ba061a..0000000000 --- a/src/renderer/src/pages/settings/ProviderSettings/ApiKeyList.tsx +++ /dev/null @@ -1,638 +0,0 @@ -import { - CheckCircleFilled, - CloseCircleFilled, - CloseCircleOutlined, - DeleteOutlined, - EditOutlined, - LoadingOutlined, - MinusCircleOutlined, - PlusOutlined -} from '@ant-design/icons' -import Scrollbar from '@renderer/components/Scrollbar' -import { isEmbeddingModel, isRerankModel } from '@renderer/config/models' -import { checkApi, formatApiKeys } from '@renderer/services/ApiService' -import { isProviderSupportAuth } from '@renderer/services/ProviderService' -import WebSearchService from '@renderer/services/WebSearchService' -import { Model, Provider, WebSearchProvider } from '@renderer/types' -import { maskApiKey, splitApiKeyString } from '@renderer/utils/api' -import { Button, Card, Flex, Input, List, Space, Spin, Tooltip, Typography } from 'antd' -import { isEmpty } from 'lodash' -import { FC, useEffect, useRef, useState } from 'react' -import { useTranslation } from 'react-i18next' -import styled from 'styled-components' - -import SelectProviderModelPopup from './SelectProviderModelPopup' - -interface Props { - provider: Provider | WebSearchProvider - apiKeys: string - onChange: (keys: string) => void - type?: 'provider' | 'websearch' -} - -interface KeyStatus { - key: string - isValid?: boolean - checking?: boolean - error?: string - model?: Model - latency?: number -} - -const STATUS_COLORS = { - success: '#52c41a', - error: '#ff4d4f' -} - -const formatAndConvertKeysToArray = (apiKeys: string): KeyStatus[] => { - const formattedApiKeys = formatApiKeys(apiKeys) - if (formattedApiKeys.includes(',')) { - const keys = splitApiKeyString(formattedApiKeys) - const uniqueKeys = new Set(keys) - return Array.from(uniqueKeys).map((key) => ({ key })) - } else { - return formattedApiKeys ? [{ key: formattedApiKeys }] : [] - } -} - -const ApiKeyList: FC = ({ provider, apiKeys, onChange, type = 'provider' }) => { - const [keyStatuses, setKeyStatuses] = useState(() => formatAndConvertKeysToArray(apiKeys)) - const [isAddingNew, setIsAddingNew] = useState(false) - const [newApiKey, setNewApiKey] = useState('') - const [isCancelingNewKey, setIsCancelingNewKey] = useState(false) - const newInputRef = useRef(null) - const [editingIndex, setEditingIndex] = useState(null) - const [editValue, setEditValue] = useState('') - const editInputRef = useRef(null) - const { t } = useTranslation() - const [isChecking, setIsChecking] = useState(false) - const [isCheckingSingle, setIsCheckingSingle] = useState(false) - const [confirmDeleteIndex, setConfirmDeleteIndex] = useState(null) - const isCopilot = provider.id === 'copilot' - - useEffect(() => { - if (isAddingNew && newInputRef.current) { - newInputRef.current.focus() - } - }, [isAddingNew]) - - useEffect(() => { - const newKeyStatuses = formatAndConvertKeysToArray(apiKeys) - - setKeyStatuses((currentStatuses) => { - const newKeys = newKeyStatuses.map((k) => k.key) - const currentKeys = currentStatuses.map((k) => k.key) - - // If the keys are the same, no need to update, prevents re-render loops. - if (newKeys.join(',') === currentKeys.join(',')) { - return currentStatuses - } - - // Merge new keys with existing statuses to preserve them. - const statusesMap = new Map(currentStatuses.map((s) => [s.key, s])) - return newKeyStatuses.map((k) => statusesMap.get(k.key) || k) - }) - }, [apiKeys]) - - useEffect(() => { - if (editingIndex !== null && editInputRef.current) { - editInputRef.current.focus() - } - }, [editingIndex]) - - const handleAddNewKey = () => { - setIsCancelingNewKey(false) - setIsAddingNew(true) - setNewApiKey('') - } - - const handleSaveNewKey = () => { - if (isCancelingNewKey) { - setIsCancelingNewKey(false) - return - } - - if (newApiKey.trim()) { - // Check if the key already exists - const keyExists = keyStatuses.some((status) => status.key === newApiKey.trim()) - - if (keyExists) { - window.message.error({ - key: 'duplicate-key', - style: { marginTop: '3vh' }, - duration: 3, - content: t('settings.provider.key_already_exists') - }) - return - } - - if (newApiKey.includes(',')) { - window.message.error({ - key: 'invalid-key', - style: { marginTop: '3vh' }, - duration: 3, - content: t('settings.provider.invalid_key') - }) - return - } - - const updatedKeyStatuses = [...keyStatuses, { key: newApiKey.trim() }] - setKeyStatuses(updatedKeyStatuses) - // Update parent component with new keys - onChange(updatedKeyStatuses.map((status) => status.key).join(',')) - } - - // Add a small delay before resetting to prevent immediate re-triggering - setTimeout(() => { - setIsAddingNew(false) - setNewApiKey('') - }, 100) - } - - const handleCancelNewKey = () => { - setIsCancelingNewKey(true) - setIsAddingNew(false) - setNewApiKey('') - } - - const getModelForCheck = async (selectedModel?: Model): Promise => { - if (type !== 'provider') return null - - const modelsToCheck = (provider as Provider).models.filter( - (model) => !isEmbeddingModel(model) && !isRerankModel(model) - ) - - if (isEmpty(modelsToCheck)) { - window.message.error({ - key: 'no-models', - style: { marginTop: '3vh' }, - duration: 5, - content: t('settings.provider.no_models_for_check') - }) - return null - } - - try { - return ( - selectedModel || - (await SelectProviderModelPopup.show({ - provider: provider as Provider - })) - ) - } catch (err) { - // User canceled the popup - return null - } - } - - const checkSingleKey = async (keyIndex: number, selectedModel?: Model, isCheckingAll: boolean = false) => { - if (isChecking || keyStatuses[keyIndex].checking) { - return - } - - try { - let latency: number - let model: Model | undefined - - if (type === 'provider') { - const selectedModelForCheck = await getModelForCheck(selectedModel) - if (!selectedModelForCheck) { - setKeyStatuses((prev) => - prev.map((status, idx) => (idx === keyIndex ? { ...status, checking: false } : status)) - ) - setIsCheckingSingle(false) - return - } - model = selectedModelForCheck - - setIsCheckingSingle(true) - setKeyStatuses((prev) => prev.map((status, idx) => (idx === keyIndex ? { ...status, checking: true } : status))) - - const startTime = Date.now() - await checkApi({ ...(provider as Provider), apiKey: keyStatuses[keyIndex].key }, model) - latency = Date.now() - startTime - } else { - setIsCheckingSingle(true) - setKeyStatuses((prev) => prev.map((status, idx) => (idx === keyIndex ? { ...status, checking: true } : status))) - - const startTime = Date.now() - await WebSearchService.checkSearch({ - ...(provider as WebSearchProvider), - apiKey: keyStatuses[keyIndex].key - }) - latency = Date.now() - startTime - } - - // Only show notification when checking a single key - if (!isCheckingAll) { - window.message.success({ - key: 'api-check', - style: { marginTop: '3vh' }, - duration: 2, - content: t('message.api.connection.success') - }) - } - - setKeyStatuses((prev) => - prev.map((status, idx) => - idx === keyIndex - ? { - ...status, - checking: false, - isValid: true, - model: selectedModel || model, - latency - } - : status - ) - ) - } catch (error: any) { - // Only show notification when checking a single key - if (!isCheckingAll) { - const errorMessage = error?.message ? ' ' + error.message : '' - window.message.error({ - key: 'api-check', - style: { marginTop: '3vh' }, - duration: 8, - content: t('message.api.connection.failed') + errorMessage - }) - } - - setKeyStatuses((prev) => - prev.map((status, idx) => - idx === keyIndex - ? { - ...status, - checking: false, - isValid: false, - error: error instanceof Error ? error.message : String(error) - } - : status - ) - ) - } finally { - setIsCheckingSingle(false) - } - } - - const checkAllKeys = async () => { - setIsChecking(true) - - try { - let selectedModel - if (type === 'provider') { - selectedModel = await getModelForCheck() - if (!selectedModel) { - return - } - } - - await Promise.all(keyStatuses.map((_, index) => checkSingleKey(index, selectedModel, true))) - } finally { - setIsChecking(false) - } - } - - const removeInvalidKeys = () => { - const updatedKeyStatuses = keyStatuses.filter((status) => status.isValid !== false) - setKeyStatuses(updatedKeyStatuses) - onChange(updatedKeyStatuses.map((status) => status.key).join(',')) - } - - const removeKey = (keyIndex: number) => { - if (confirmDeleteIndex === keyIndex) { - // Second click - actually remove the key - const updatedKeyStatuses = keyStatuses.filter((_, idx) => idx !== keyIndex) - setKeyStatuses(updatedKeyStatuses) - onChange(updatedKeyStatuses.map((status) => status.key).join(',')) - setConfirmDeleteIndex(null) - } else { - // First click - show confirmation state - setConfirmDeleteIndex(keyIndex) - // Auto-reset after 3 seconds - setTimeout(() => { - setConfirmDeleteIndex(null) - }, 3000) - } - } - - const renderKeyCheckResultTooltip = (status: KeyStatus) => { - if (status.checking) { - return t('settings.models.check.checking') - } - - const statusTitle = status.isValid ? t('settings.models.check.passed') : t('settings.models.check.failed') - const statusColor = status.isValid ? STATUS_COLORS.success : STATUS_COLORS.error - - return ( -
- {statusTitle} - {type === 'provider' && status.model && ( -
- {t('common.model')}: {status.model.name} -
- )} - {status.latency && status.isValid && ( -
- {t('settings.provider.check_tooltip.latency')}: {(status.latency / 1000).toFixed(2)}s -
- )} - {status.error &&
{status.error}
} -
- ) - } - - const shouldAutoFocus = () => { - if (type === 'provider') { - return (provider as Provider).enabled && apiKeys === '' && !isProviderSupportAuth(provider as Provider) - } else if (type === 'websearch') { - return apiKeys === '' - } - return false - } - - const handleEditKey = (index: number) => { - setEditingIndex(index) - setEditValue(keyStatuses[index].key) - } - - const handleSaveEdit = () => { - if (editingIndex === null) return - - if (editValue.trim()) { - const keyExists = keyStatuses.some((status, idx) => idx !== editingIndex && status.key === editValue.trim()) - - if (keyExists) { - window.message.error({ - key: 'duplicate-key', - style: { marginTop: '3vh' }, - duration: 3, - content: t('settings.provider.key_already_exists') - }) - return - } - - if (editValue.includes(',')) { - window.message.error({ - key: 'invalid-key', - style: { marginTop: '3vh' }, - duration: 3, - content: t('settings.provider.invalid_key') - }) - return - } - - const updatedKeyStatuses = [...keyStatuses] - updatedKeyStatuses[editingIndex] = { - ...updatedKeyStatuses[editingIndex], - key: editValue.trim(), - isValid: undefined - } - - setKeyStatuses(updatedKeyStatuses) - onChange(updatedKeyStatuses.map((status) => status.key).join(',')) - } - - // Add a small delay before resetting to prevent immediate re-triggering - setTimeout(() => { - setEditingIndex(null) - setEditValue('') - }, 100) - } - - const handleCancelEdit = () => { - setEditingIndex(null) - setEditValue('') - } - - return ( - <> - - {keyStatuses.length === 0 && !isAddingNew ? ( - - {t('error.no_api_key')} - - ) : ( - <> - {keyStatuses.length > 0 && ( - - ( - - - - {editingIndex === index ? ( - setEditValue(e.target.value)} - onBlur={handleSaveEdit} - onPressEnter={handleSaveEdit} - onKeyDown={(e) => { - if (e.key === 'Escape') { - e.preventDefault() - handleCancelEdit() - } - }} - style={{ width: '100%', fontSize: '14px' }} - spellCheck={false} - type="password" - /> - ) : ( - {maskApiKey(status.key)} - )} - - - {editingIndex === index ? ( - - ) : ( - <> - - {status.checking && ( - - } /> - - )} - {status.isValid === true && !status.checking && ( - - )} - {status.isValid === false && !status.checking && ( - - )} - - - {!isCopilot && ( - <> - !isChecking && !isCheckingSingle && handleEditKey(index)} - style={{ - cursor: isChecking || isCheckingSingle ? 'not-allowed' : 'pointer', - opacity: isChecking || isCheckingSingle ? 0.5 : 1, - fontSize: '16px' - }} - title={t('common.edit')} - /> - {confirmDeleteIndex === index ? ( - !isChecking && !isCheckingSingle && removeKey(index)} - style={{ - cursor: isChecking || isCheckingSingle ? 'not-allowed' : 'pointer', - opacity: isChecking || isCheckingSingle ? 0.5 : 1, - fontSize: '16px', - color: 'var(--color-error)' - }} - title={t('common.delete')} - /> - ) : ( - !isChecking && !isCheckingSingle && removeKey(index)} - style={{ - cursor: isChecking || isCheckingSingle ? 'not-allowed' : 'pointer', - opacity: isChecking || isCheckingSingle ? 0.5 : 1, - fontSize: '16px', - color: 'var(--color-error)' - }} - title={t('common.delete')} - /> - )} - - )} - - )} - - - - )} - /> - - )} - {isAddingNew && ( - - - setNewApiKey(e.target.value)} - placeholder={t('settings.provider.enter_new_api_key')} - style={{ width: '60%', fontSize: '14px' }} - onPressEnter={handleSaveNewKey} - onBlur={handleSaveNewKey} - onKeyDown={(e) => { - if (e.key === 'Escape') { - e.preventDefault() - handleCancelNewKey() - } - }} - spellCheck={false} - type="password" - /> - - - - - - )} - - )} - - - - {!isCopilot && ( - <> - - - - {keyStatuses.length > 1 && ( - - - - - )} - - )} - - - ) -} - -// Styled components for the list items -const ApiKeyListItem = styled.div` - display: flex; - flex-direction: row; - align-items: center; - justify-content: space-between; - width: 100%; - padding: 0; - margin: 0; -` - -const ApiKeyContainer = styled.div` - display: flex; - flex-direction: row; - align-items: center; -` - -const ApiKeyActions = styled.div` - display: flex; - flex-direction: row; - align-items: center; - gap: 10px; - - @keyframes pulse { - 0% { - opacity: 1; - } - 50% { - opacity: 0.5; - } - 100% { - opacity: 1; - } - } -` - -export default ApiKeyList diff --git a/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx b/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx index f708b28679..74a414d81c 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx @@ -1,11 +1,14 @@ +import { CheckOutlined, LoadingOutlined } from '@ant-design/icons' import { isOpenAIProvider } from '@renderer/aiCore/clients/ApiClientFactory' import OpenAIAlert from '@renderer/components/Alert/OpenAIAlert' import { StreamlineGoodHealthAndWellBeing } from '@renderer/components/Icons/SVGIcon' import { HStack } from '@renderer/components/Layout' -import { isRerankModel } from '@renderer/config/models' +import { isEmbeddingModel, isRerankModel } from '@renderer/config/models' import { PROVIDER_CONFIG } from '@renderer/config/providers' import { useTheme } from '@renderer/context/ThemeProvider' import { useAllProviders, useProvider, useProviders } from '@renderer/hooks/useProvider' +import i18n from '@renderer/i18n' +import { checkApi, formatApiKeys } from '@renderer/services/ApiService' import { checkModelsHealth, getModelCheckSummary } from '@renderer/services/HealthCheckService' import { isProviderSupportAuth } from '@renderer/services/ProviderService' import { Provider } from '@renderer/types' @@ -13,7 +16,7 @@ import { formatApiHost, splitApiKeyString } from '@renderer/utils/api' import { lightbulbVariants } from '@renderer/utils/motionVariants' import { Button, Divider, Flex, Input, Space, Switch, Tooltip } from 'antd' import Link from 'antd/es/typography/Link' -import { isEmpty } from 'lodash' +import { debounce, isEmpty } from 'lodash' import { Settings2, SquareArrowOutUpRight } from 'lucide-react' import { motion } from 'motion/react' import { FC, useCallback, useDeferredValue, useEffect, useState } from 'react' @@ -28,7 +31,7 @@ import { SettingSubtitle, SettingTitle } from '..' -import ApiKeyList from './ApiKeyList' +import ApiCheckPopup from './ApiCheckPopup' import DMXAPISettings from './DMXAPISettings' import GithubCopilotSettings from './GithubCopilotSettings' import GPUStackSettings from './GPUStackSettings' @@ -38,6 +41,7 @@ import ModelList, { ModelStatus } from './ModelList' import ModelListSearchBar from './ModelListSearchBar' import ProviderOAuth from './ProviderOAuth' import ProviderSettingsPopup from './ProviderSettingsPopup' +import SelectProviderModelPopup from './SelectProviderModelPopup' import VertexAISettings from './VertexAISettings' interface Props { @@ -51,11 +55,14 @@ const ProviderSetting: FC = ({ provider: _provider }) => { const [apiKey, setApiKey] = useState(provider.apiKey) const [apiHost, setApiHost] = useState(provider.apiHost) const [apiVersion, setApiVersion] = useState(provider.apiVersion) + const [apiValid, setApiValid] = useState(false) + const [apiChecking, setApiChecking] = useState(false) const [modelSearchText, setModelSearchText] = useState('') const deferredModelSearchText = useDeferredValue(modelSearchText) const { updateProvider, models } = useProvider(provider.id) const { t } = useTranslation() const { theme } = useTheme() + const [inputValue, setInputValue] = useState(apiKey) const isAzureOpenAI = provider.id === 'azure-openai' || provider.type === 'azure-openai' @@ -69,6 +76,14 @@ const ProviderSetting: FC = ({ provider: _provider }) => { const [modelStatuses, setModelStatuses] = useState([]) const [isHealthChecking, setIsHealthChecking] = useState(false) + // eslint-disable-next-line react-hooks/exhaustive-deps + const debouncedSetApiKey = useCallback( + debounce((value) => { + setApiKey(formatApiKeys(value)) + }, 100), + [] + ) + const moveProviderToTop = useCallback( (providerId: string) => { const reorderedProviders = [...allProviders] @@ -84,6 +99,12 @@ const ProviderSetting: FC = ({ provider: _provider }) => { [allProviders, updateProviders] ) + const onUpdateApiKey = () => { + if (apiKey !== provider.apiKey) { + updateProvider({ ...provider, apiKey }) + } + } + const onUpdateApiHost = () => { if (apiHost.trim()) { updateProvider({ ...provider, apiHost }) @@ -92,11 +113,6 @@ const ProviderSetting: FC = ({ provider: _provider }) => { } } - const handleApiKeyChange = (newApiKey: string) => { - setApiKey(newApiKey) - updateProvider({ ...provider, apiKey: newApiKey }) - } - const onUpdateApiVersion = () => updateProvider({ ...provider, apiVersion }) const onHealthCheck = async () => { @@ -176,6 +192,75 @@ const ProviderSetting: FC = ({ provider: _provider }) => { setIsHealthChecking(false) } + const onCheckApi = async () => { + const modelsToCheck = models.filter((model) => !isEmbeddingModel(model) && !isRerankModel(model)) + + if (isEmpty(modelsToCheck)) { + window.message.error({ + key: 'no-models', + style: { marginTop: '3vh' }, + duration: 5, + content: t('settings.provider.no_models_for_check') + }) + return + } + + const model = await SelectProviderModelPopup.show({ provider }) + + if (!model) { + window.message.error({ content: i18n.t('message.error.enter.model'), key: 'api-check' }) + return + } + + if (apiKey.includes(',')) { + const keys = splitApiKeyString(apiKey) + + const result = await ApiCheckPopup.show({ + title: t('settings.provider.check_multiple_keys'), + provider: { ...provider, apiHost }, + model, + apiKeys: keys, + type: 'provider' + }) + + if (result?.validKeys) { + const newApiKey = result.validKeys.join(',') + setInputValue(newApiKey) + setApiKey(newApiKey) + updateProvider({ ...provider, apiKey: newApiKey }) + } + } else { + setApiChecking(true) + + try { + await checkApi({ ...provider, apiKey, apiHost }, model) + + window.message.success({ + key: 'api-check', + style: { marginTop: '3vh' }, + duration: 2, + content: i18n.t('message.api.connection.success') + }) + + setApiValid(true) + setTimeout(() => setApiValid(false), 3000) + } catch (error: any) { + const errorMessage = error?.message ? ' ' + error.message : '' + + window.message.error({ + key: 'api-check', + style: { marginTop: '3vh' }, + duration: 8, + content: i18n.t('message.api.connection.failed') + errorMessage + }) + + setApiValid(false) + } finally { + setApiChecking(false) + } + } + } + const onReset = () => { setApiHost(configedApiHost) updateProvider({ ...provider, apiHost: configedApiHost }) @@ -244,6 +329,7 @@ const ProviderSetting: FC = ({ provider: _provider }) => { provider={provider} setApiKey={(v) => { setApiKey(v) + setInputValue(v) updateProvider({ ...provider, apiKey: v }) }} /> @@ -252,14 +338,35 @@ const ProviderSetting: FC = ({ provider: _provider }) => { {isDmxapi && } {provider.id !== 'vertexai' && ( <> - - - {t('settings.provider.api_key')} - - - + {t('settings.provider.api_key')} + + { + setInputValue(e.target.value) + debouncedSetApiKey(e.target.value) + }} + onBlur={() => { + const formattedValue = formatApiKeys(inputValue) + setInputValue(formattedValue) + setApiKey(formattedValue) + onUpdateApiKey() + }} + spellCheck={false} + autoFocus={provider.enabled && apiKey === '' && !isProviderSupportAuth(provider)} + disabled={provider.id === 'copilot'} + /> + + {apiKeyWebsite && ( - + {!isDmxapi && ( @@ -267,6 +374,7 @@ const ProviderSetting: FC = ({ provider: _provider }) => { )} + {t('settings.provider.api_key.tip')} )} {!isDmxapi && ( diff --git a/src/renderer/src/pages/settings/WebSearchSettings/WebSearchProviderSetting.tsx b/src/renderer/src/pages/settings/WebSearchSettings/WebSearchProviderSetting.tsx index 97d01d4f5e..44a3f01714 100644 --- a/src/renderer/src/pages/settings/WebSearchSettings/WebSearchProviderSetting.tsx +++ b/src/renderer/src/pages/settings/WebSearchSettings/WebSearchProviderSetting.tsx @@ -1,17 +1,19 @@ -import { ExportOutlined } from '@ant-design/icons' +import { CheckOutlined, ExportOutlined, LoadingOutlined } from '@ant-design/icons' import { getWebSearchProviderLogo, WEB_SEARCH_PROVIDER_CONFIG } from '@renderer/config/webSearchProviders' import { useWebSearchProvider } from '@renderer/hooks/useWebSearchProviders' +import { formatApiKeys } from '@renderer/services/ApiService' +import WebSearchService from '@renderer/services/WebSearchService' import { WebSearchProvider } from '@renderer/types' import { hasObjectKey } from '@renderer/utils' -import { Divider, Flex, Form, Input, Tooltip } from 'antd' +import { Button, Divider, Flex, Form, Input, Tooltip } from 'antd' import Link from 'antd/es/typography/Link' import { Info } from 'lucide-react' import { FC, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' -import { SettingDivider, SettingHelpLink, SettingHelpTextRow, SettingSubtitle, SettingTitle } from '..' -import ApiKeyList from '../ProviderSettings/ApiKeyList' +import { SettingDivider, SettingHelpLink, SettingHelpText, SettingHelpTextRow, SettingSubtitle, SettingTitle } from '..' +import ApiCheckPopup from '../ProviderSettings/ApiCheckPopup' interface Props { provider: WebSearchProvider @@ -22,16 +24,19 @@ const WebSearchProviderSetting: FC = ({ provider: _provider }) => { const { t } = useTranslation() const [apiKey, setApiKey] = useState(provider.apiKey || '') const [apiHost, setApiHost] = useState(provider.apiHost || '') + const [apiChecking, setApiChecking] = useState(false) const [basicAuthUsername, setBasicAuthUsername] = useState(provider.basicAuthUsername || '') const [basicAuthPassword, setBasicAuthPassword] = useState(provider.basicAuthPassword || '') + const [apiValid, setApiValid] = useState(false) const webSearchProviderConfig = WEB_SEARCH_PROVIDER_CONFIG[provider.id] const apiKeyWebsite = webSearchProviderConfig?.websites?.apiKey const officialWebsite = webSearchProviderConfig?.websites?.official - const handleApiKeyChange = (newApiKey: string) => { - setApiKey(newApiKey) - updateProvider({ ...provider, apiKey: newApiKey }) + const onUpdateApiKey = () => { + if (apiKey !== provider.apiKey) { + updateProvider({ ...provider, apiKey }) + } } const onUpdateApiHost = () => { @@ -66,6 +71,65 @@ const WebSearchProviderSetting: FC = ({ provider: _provider }) => { } } + async function checkSearch() { + if (!provider) { + window.message.error({ + content: t('settings.websearch.no_provider_selected'), + duration: 3, + icon: , + key: 'no-provider-selected' + }) + return + } + + if (apiKey.includes(',')) { + const keys = apiKey + .split(',') + .map((k) => k.trim()) + .filter((k) => k) + + const result = await ApiCheckPopup.show({ + title: t('settings.provider.check_multiple_keys'), + provider: { ...provider, apiHost }, + apiKeys: keys, + type: 'websearch' + }) + + if (result?.validKeys) { + setApiKey(result.validKeys.join(',')) + updateProvider({ ...provider, apiKey: result.validKeys.join(',') }) + } + return + } + + try { + setApiChecking(true) + const { valid, error } = await WebSearchService.checkSearch(provider) + + const errorMessage = error && error?.message ? ' ' + error?.message : '' + window.message[valid ? 'success' : 'error']({ + key: 'api-check', + style: { marginTop: '3vh' }, + duration: valid ? 2 : 8, + content: valid ? t('settings.websearch.check_success') : t('settings.websearch.check_failed') + errorMessage + }) + + setApiValid(valid) + } catch (err) { + console.error('Check search error:', err) + setApiValid(false) + window.message.error({ + key: 'check-search-error', + style: { marginTop: '3vh' }, + duration: 8, + content: t('settings.websearch.check_failed') + }) + } finally { + setApiChecking(false) + setTimeout(() => setApiValid(false), 2500) + } + } + useEffect(() => { setApiKey(provider.apiKey ?? '') setApiHost(provider.apiHost ?? '') @@ -90,14 +154,30 @@ const WebSearchProviderSetting: FC = ({ provider: _provider }) => { {hasObjectKey(provider, 'apiKey') && ( <> {t('settings.provider.api_key')} - - {apiKeyWebsite && ( - - - {t('settings.websearch.get_api_key')} - - - )} + + setApiKey(formatApiKeys(e.target.value))} + onBlur={onUpdateApiKey} + spellCheck={false} + type="password" + autoFocus={apiKey === ''} + /> + + + + + {t('settings.websearch.get_api_key')} + + {t('settings.provider.api_key.tip')} + )} {hasObjectKey(provider, 'apiHost') && ( From 64b01cce47b234d0f11e9492d275e996e843aa21 Mon Sep 17 00:00:00 2001 From: Teo Date: Wed, 25 Jun 2025 14:34:18 +0800 Subject: [PATCH 038/111] =?UTF-8?q?feat:=20=20=E4=B8=80=E4=BA=9BUI?= =?UTF-8?q?=E4=B8=8A=E7=9A=84=E4=BC=98=E5=8C=96=E5=92=8C=E9=87=8D=E6=9E=84?= =?UTF-8?q?=20(#7479)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 调整AntdProvider中主题配置,包括颜色、尺寸 - 重构聊天气泡模式的样式 - 重构多选模式的样式 - 添加Selector组件取代ant Select组件 - 重构消息搜索弹窗界面 - 重构知识库搜索弹窗界面 - 优化其他弹框UI --- src/renderer/src/assets/styles/ant.scss | 214 +++++----------- src/renderer/src/assets/styles/color.scss | 14 +- src/renderer/src/assets/styles/index.scss | 71 +++--- src/renderer/src/assets/styles/markdown.scss | 28 ++- .../components/CodeBlockView/CodePreview.tsx | 6 +- .../src/components/CodeBlockView/index.tsx | 5 + .../src/components/CodeEditor/index.tsx | 6 +- .../src/components/ContextMenu/index.tsx | 106 +++----- .../src/components/CustomCollapse.tsx | 9 + .../src/components/EditableNumber/index.tsx | 114 +++++++++ .../components/Popups/MultiSelectionPopup.tsx | 68 +++-- .../src/components/Popups/SearchPopup.tsx | 13 +- .../Popups/SelectModelPopup/popup.tsx | 7 +- .../src/components/Scrollbar/index.tsx | 6 +- src/renderer/src/components/Selector.tsx | 192 ++++++++++++++ src/renderer/src/context/AntdProvider.tsx | 55 +++- 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 + .../pages/agents/components/AddAgentPopup.tsx | 4 +- .../agents/components/ImportAgentPopup.tsx | 20 +- .../src/pages/history/HistoryPage.tsx | 102 ++++---- .../history/components/SearchMessage.tsx | 21 +- .../history/components/SearchResults.tsx | 3 +- .../history/components/TopicMessages.tsx | 17 +- .../history/components/TopicsHistory.tsx | 3 +- src/renderer/src/pages/home/Chat.tsx | 3 +- .../src/pages/home/Inputbar/Inputbar.tsx | 60 ++--- .../src/pages/home/Inputbar/TokenCount.tsx | 2 +- .../pages/home/Markdown/CitationTooltip.tsx | 3 +- .../src/pages/home/Markdown/CodeBlock.tsx | 2 +- .../__tests__/CitationTooltip.test.tsx | 2 +- .../CitationTooltip.test.tsx.snap | 2 +- .../pages/home/Messages/Blocks/ImageBlock.tsx | 5 +- .../home/Messages/Blocks/ThinkingBlock.tsx | 10 +- .../src/pages/home/Messages/Blocks/index.tsx | 12 +- .../src/pages/home/Messages/CitationsList.tsx | 137 ++++++---- .../src/pages/home/Messages/Message.tsx | 138 +++-------- .../pages/home/Messages/MessageAnchorLine.tsx | 32 ++- .../src/pages/home/Messages/MessageEditor.tsx | 3 +- .../src/pages/home/Messages/MessageGroup.tsx | 223 ++++++++--------- .../home/Messages/MessageGroupMenuBar.tsx | 9 +- .../home/Messages/MessageGroupSettings.tsx | 19 +- .../src/pages/home/Messages/MessageHeader.tsx | 128 +++++----- .../pages/home/Messages/MessageMenubar.tsx | 3 +- .../src/pages/home/Messages/Messages.tsx | 43 ++-- .../src/pages/home/Messages/NarrowLayout.tsx | 6 +- .../src/pages/home/Messages/Prompt.tsx | 4 +- .../src/pages/home/Tabs/SettingsTab.tsx | 119 ++++----- .../Tabs/components/OpenAISettingsGroup.tsx | 6 +- .../components/AddKnowledgePopup.tsx | 24 +- .../components/KnowledgeSearchPopup.tsx | 199 +++++++++------ .../components/KnowledgeSettingsPopup.tsx | 46 ++-- .../AssistantKnowledgeBaseSettings.tsx | 3 +- .../AssistantModelSettings.tsx | 234 +++++++++++------- .../AssistantPromptSettings.tsx | 2 +- .../settings/AssistantSettings/index.tsx | 16 +- .../AgentsSubscribeUrlSettings.tsx | 1 - .../settings/DataSettings/JoplinSettings.tsx | 19 +- .../settings/DataSettings/NotionSettings.tsx | 21 +- .../DataSettings/NutstoreSettings.tsx | 32 ++- .../settings/DataSettings/SiyuanSettings.tsx | 19 +- .../settings/DataSettings/WebDavSettings.tsx | 57 +++-- .../settings/DataSettings/YuqueSettings.tsx | 19 +- .../DisplaySettings/DisplaySettings.tsx | 8 +- .../src/pages/settings/GeneralSettings.tsx | 96 +++---- .../DefaultAssistantSettings.tsx | 24 +- .../ProviderSettings/AddModelPopup.tsx | 2 +- .../ProviderSettings/EditModelsPopup.tsx | 10 +- .../ProviderSettings/ModelEditContent.tsx | 49 ++-- .../WebSearchSettings/AddSubscribePopup.tsx | 8 +- .../settings/WebSearchSettings/index.tsx | 6 +- .../src/pages/translate/TranslatePage.tsx | 7 +- .../windows/mini/chat/components/Message.tsx | 1 - 76 files changed, 1637 insertions(+), 1326 deletions(-) create mode 100644 src/renderer/src/components/EditableNumber/index.tsx create mode 100644 src/renderer/src/components/Selector.tsx diff --git a/src/renderer/src/assets/styles/ant.scss b/src/renderer/src/assets/styles/ant.scss index ebe45ef5c6..225cbe8a9d 100644 --- a/src/renderer/src/assets/styles/ant.scss +++ b/src/renderer/src/assets/styles/ant.scss @@ -58,166 +58,80 @@ } } -.mention-models-dropdown { - &.ant-dropdown { - background: rgba(var(--color-base-rgb), 0.65) !important; - backdrop-filter: blur(35px) saturate(150%) !important; - animation-duration: 0.15s !important; - } - - /* 移动其他样式到 mention-models-dropdown 类下 */ - .ant-slide-up-enter .ant-dropdown-menu, - .ant-slide-up-appear .ant-dropdown-menu, - .ant-slide-up-leave .ant-dropdown-menu, - .ant-slide-up-enter-active .ant-dropdown-menu, - .ant-slide-up-appear-active .ant-dropdown-menu, - .ant-slide-up-leave-active .ant-dropdown-menu { - background: rgba(var(--color-base-rgb), 0.65) !important; - backdrop-filter: blur(35px) saturate(150%) !important; - } - - .ant-dropdown-menu { - /* 保持原有的下拉菜单样式,但限定在 mention-models-dropdown 类下 */ - max-height: 400px; - overflow-y: auto; - overflow-x: hidden; - padding: 4px 12px; - position: relative; - background: rgba(var(--color-base-rgb), 0.65) !important; - backdrop-filter: blur(35px) saturate(150%) !important; - border: 0.5px solid rgba(var(--color-border-rgb), 0.3); - border-radius: 10px; - box-shadow: - 0 0 0 0.5px rgba(0, 0, 0, 0.15), - 0 4px 16px rgba(0, 0, 0, 0.15), - 0 2px 8px rgba(0, 0, 0, 0.12), - inset 0 0 0 0.5px rgba(255, 255, 255, var(--inner-glow-opacity, 0.1)); - transform-origin: top; - will-change: transform, opacity; - transition: all 0.15s cubic-bezier(0.4, 0, 0.2, 1); - margin-bottom: 0; - - &.no-scrollbar { - padding-right: 12px; - } - - &.has-scrollbar { - padding-right: 2px; - } - - // Scrollbar styles - &::-webkit-scrollbar { - width: 14px; - height: 6px; - } - - &::-webkit-scrollbar-thumb { - border: 4px solid transparent; - background-clip: padding-box; - border-radius: 7px; - background-color: var(--color-scrollbar-thumb); - min-height: 50px; - transition: all 0.2s; - } - - &:hover::-webkit-scrollbar-thumb { - background-color: var(--color-scrollbar-thumb); - } - - &::-webkit-scrollbar-thumb:hover { - background-color: var(--color-scrollbar-thumb-hover); - } - - &::-webkit-scrollbar-thumb:active { - background-color: var(--color-scrollbar-thumb-hover); - } - - &::-webkit-scrollbar-track { - background: transparent; - border-radius: 7px; - } - } - - .ant-dropdown-menu-item-group { - margin-bottom: 4px; - - &:not(:first-child) { - margin-top: 4px; - } - - .ant-dropdown-menu-item-group-title { - padding: 5px 12px; - color: var(--color-text-3); - font-size: 12px; - font-weight: 500; - text-transform: uppercase; - letter-spacing: 0.03em; - opacity: 0.7; - } - } - - // Handle no-results case margin - .no-results { - padding: 8px 12px; - color: var(--color-text-3); - cursor: default; - font-size: 13px; - opacity: 0.8; - margin-bottom: 40px; - - &:hover { - background: none; - } - } - - .ant-dropdown-menu-item { - padding: 5px 12px; - margin: 0 -12px; - cursor: pointer; - transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); - display: flex; - align-items: center; - gap: 8px; - border-radius: 6px; - font-size: 13px; - - &:hover { - background: rgba(var(--color-hover-rgb), 0.5); - } - - &.ant-dropdown-menu-item-selected { - background-color: rgba(var(--color-primary-rgb), 0.12); - color: var(--color-primary); - } - - .ant-dropdown-menu-item-icon { - margin-right: 0; - opacity: 0.9; - } - } +.ant-dropdown-menu .ant-dropdown-menu-sub { + max-height: 50vh; + width: max-content; + overflow-y: auto; + overflow-x: hidden; + border: 0.5px solid var(--color-border); } - .ant-dropdown { + background-color: var(--ant-color-bg-elevated); + overflow: hidden; + border-radius: var(--ant-border-radius-lg); .ant-dropdown-menu { max-height: 50vh; overflow-y: auto; border: 0.5px solid var(--color-border); - .ant-dropdown-menu-sub { - max-height: 50vh; - width: max-content; - overflow-y: auto; - overflow-x: hidden; - border: 0.5px solid var(--color-border); - } } .ant-dropdown-arrow + .ant-dropdown-menu { border: none; } } - .ant-select-dropdown { border: 0.5px solid var(--color-border); } +.ant-dropdown-menu-submenu { + background-color: var(--ant-color-bg-elevated); + overflow: hidden; + border-radius: var(--ant-border-radius-lg); +} + +.ant-popover { + .ant-popover-inner { + border: 0.5px solid var(--color-border); + .ant-popover-inner-content { + max-height: 70vh; + overflow-y: auto; + } + } + .ant-popover-arrow + .ant-popover-content { + .ant-popover-inner { + border: none; + } + } +} + +.ant-modal:not(.ant-modal-confirm) { + .ant-modal-confirm-body-has-title { + padding: 16px 0 0 0; + } + .ant-modal-content { + border-radius: 10px; + border: 0.5px solid var(--color-border); + padding: 0 0 8px 0; + .ant-modal-header { + padding: 16px 16px 0 16px; + border-radius: 10px; + } + .ant-modal-body { + max-height: 80vh; + overflow-y: auto; + padding: 0 16px 0 16px; + } + .ant-modal-footer { + padding: 0 16px 8px 16px; + } + .ant-modal-confirm-btns { + margin-bottom: 8px; + } + } +} +.ant-modal.ant-modal-confirm.ant-modal-confirm-confirm { + .ant-modal-content { + padding: 16px; + } +} .ant-collapse { border: 1px solid var(--color-border); @@ -227,8 +141,14 @@ } .ant-collapse-content { - border-top: 1px solid var(--color-border) !important; + border-top: 0.5px solid var(--color-border) !important; .ant-color-picker & { border-top: none !important; } } + +.ant-slider { + .ant-slider-handle::after { + box-shadow: 0 1px 4px 0px rgb(128 128 128 / 50%) !important; + } +} diff --git a/src/renderer/src/assets/styles/color.scss b/src/renderer/src/assets/styles/color.scss index 6100e1d0ee..ce7e9cefe9 100644 --- a/src/renderer/src/assets/styles/color.scss +++ b/src/renderer/src/assets/styles/color.scss @@ -47,7 +47,7 @@ --color-list-item: #222; --color-list-item-hover: #1e1e1e; - --modal-background: #1f1f1f; + --modal-background: #111111; --color-highlight: rgba(0, 0, 0, 1); --color-background-highlight: rgba(255, 255, 0, 0.9); @@ -66,9 +66,9 @@ --settings-width: 250px; --scrollbar-width: 5px; - --chat-background: #111111; - --chat-background-user: #28b561; - --chat-background-assistant: #2c2c2c; + --chat-background: transparent; + --chat-background-user: rgba(255, 255, 255, 0.08); + --chat-background-assistant: transparent; --chat-text-user: var(--color-black); --list-item-border-radius: 20px; @@ -132,8 +132,8 @@ --navbar-background-mac: rgba(255, 255, 255, 0.55); --navbar-background: rgba(244, 244, 244); - --chat-background: #f3f3f3; - --chat-background-user: #95ec69; - --chat-background-assistant: #ffffff; + --chat-background: transparent; + --chat-background-user: rgba(0, 0, 0, 0.045); + --chat-background-assistant: transparent; --chat-text-user: var(--color-text); } diff --git a/src/renderer/src/assets/styles/index.scss b/src/renderer/src/assets/styles/index.scss index 9974f19596..da28abc8c5 100644 --- a/src/renderer/src/assets/styles/index.scss +++ b/src/renderer/src/assets/styles/index.scss @@ -111,27 +111,7 @@ ul { word-wrap: break-word; } -.bubble { - background-color: var(--chat-background); - #chat-main { - background-color: var(--chat-background); - } - #messages { - background-color: var(--chat-background); - } - #inputbar { - margin: -5px 15px 15px 15px; - background: var(--color-background); - } - .system-prompt { - background-color: var(--chat-background-assistant); - } - .message-content-container { - margin: 5px 0; - border-radius: 8px; - padding: 0.5rem 1rem; - } - +.bubble:not(.multi-select-mode) { .block-wrapper { display: flow-root; } @@ -149,30 +129,35 @@ ul { } .message-user { - color: var(--chat-text-user); - .message-content-container-user .anticon { - color: var(--chat-text-user) !important; + .message-header { + flex-direction: row-reverse; + text-align: right; + .message-header-info-wrap { + flex-direction: row-reverse; + text-align: right; + } } - - .markdown { - color: var(--chat-text-user); - } - } - .group-grid-container.horizontal, - .group-grid-container.grid { - .message-content-container-assistant { - padding: 0; - } - } - .group-message-wrapper { - background-color: var(--color-background); .message-content-container { - width: 100%; + border-radius: 10px 0 10px 10px; + padding: 10px 16px 10px 16px; + background-color: var(--chat-background-user); + align-self: self-end; + } + .MessageFooter { + margin-top: 2px; + align-self: self-end; } } - .group-menu-bar { - background-color: var(--color-background); + + .message-assistant { + .message-content-container { + padding-left: 0; + } + .MessageFooter { + margin-left: 0; + } } + code { color: var(--color-text); } @@ -196,3 +181,9 @@ span.highlight { span.highlight.selected { background-color: var(--color-background-highlight-accent); } + +textarea { + &::-webkit-resizer { + display: none; + } +} diff --git a/src/renderer/src/assets/styles/markdown.scss b/src/renderer/src/assets/styles/markdown.scss index 0c80d9f68a..eea9070cae 100644 --- a/src/renderer/src/assets/styles/markdown.scss +++ b/src/renderer/src/assets/styles/markdown.scss @@ -98,7 +98,6 @@ border: none; border-top: 0.5px solid var(--color-border); margin: 20px 0; - background-color: var(--color-border); } span { @@ -119,7 +118,7 @@ } pre { - border-radius: 5px; + border-radius: 8px; overflow-x: auto; font-family: 'Fira Code', 'Courier New', Courier, monospace; background-color: var(--color-background-mute); @@ -157,15 +156,28 @@ } table { - border-collapse: collapse; + --table-border-radius: 8px; margin: 1em 0; width: 100%; + border-radius: var(--table-border-radius); + overflow: hidden; + border-collapse: separate; + border: 0.5px solid var(--color-border); + border-spacing: 0; } th, td { - border: 0.5px solid var(--color-border); + border-right: 0.5px solid var(--color-border); + border-bottom: 0.5px solid var(--color-border); padding: 0.5em; + &:last-child { + border-right: none; + } + } + + tr:last-child td { + border-bottom: none; } th { @@ -238,6 +250,10 @@ text-decoration: underline; } } + + > *:last-child { + margin-bottom: 0 !important; + } } .footnotes { @@ -309,7 +325,7 @@ mjx-container { /* CodeMirror 相关样式 */ .cm-editor { - border-radius: 5px; + border-radius: inherit; &.cm-focused { outline: none; @@ -317,7 +333,7 @@ mjx-container { .cm-scroller { font-family: var(--code-font-family); - border-radius: 5px; + border-radius: inherit; .cm-gutters { line-height: 1.6; diff --git a/src/renderer/src/components/CodeBlockView/CodePreview.tsx b/src/renderer/src/components/CodeBlockView/CodePreview.tsx index 566a980f67..dde163283d 100644 --- a/src/renderer/src/components/CodeBlockView/CodePreview.tsx +++ b/src/renderer/src/components/CodeBlockView/CodePreview.tsx @@ -244,8 +244,7 @@ const ContentContainer = styled.div<{ }>` position: relative; overflow: auto; - border: 0.5px solid transparent; - border-radius: 5px; + border-radius: inherit; margin-top: 0; /* 动态宽度计算 */ @@ -254,6 +253,7 @@ const ContentContainer = styled.div<{ .shiki { padding: 1em; + border-radius: inherit; code { display: flex; @@ -301,7 +301,7 @@ const ContentContainer = styled.div<{ } } - animation: ${(props) => (props.$fadeIn ? 'contentFadeIn 0.3s ease-in-out forwards' : 'none')}; + animation: ${(props) => (props.$fadeIn ? 'contentFadeIn 0.1s ease-in forwards' : 'none')}; ` const CodePlaceholder = styled.div` diff --git a/src/renderer/src/components/CodeBlockView/index.tsx b/src/renderer/src/components/CodeBlockView/index.tsx index 811b8665cc..c25ab3079d 100644 --- a/src/renderer/src/components/CodeBlockView/index.tsx +++ b/src/renderer/src/components/CodeBlockView/index.tsx @@ -273,6 +273,7 @@ const CodeHeader = styled.div<{ $isInSpecialView: boolean }>` align-items: center; color: var(--color-text); font-size: 14px; + line-height: 1; font-weight: bold; padding: 0 10px; border-top-left-radius: 8px; @@ -288,6 +289,10 @@ const SplitViewWrapper = styled.div` flex: 1 1 auto; width: 100%; } + + &:not(:has(+ [class*='Container'])) { + border-radius: 0 0 8px 8px; + } ` export default memo(CodeBlockView) diff --git a/src/renderer/src/components/CodeEditor/index.tsx b/src/renderer/src/components/CodeEditor/index.tsx index d92fd91e8e..db699fa030 100644 --- a/src/renderer/src/components/CodeEditor/index.tsx +++ b/src/renderer/src/components/CodeEditor/index.tsx @@ -227,10 +227,10 @@ const CodeEditor = ({ ...customBasicSetup // override basicSetup }} style={{ - ...style, fontSize: `${fontSize - 1}px`, - border: '0.5px solid transparent', - marginTop: 0 + marginTop: 0, + borderRadius: 'inherit', + ...style }} /> ) diff --git a/src/renderer/src/components/ContextMenu/index.tsx b/src/renderer/src/components/ContextMenu/index.tsx index 195fcb2a38..610afa695f 100644 --- a/src/renderer/src/components/ContextMenu/index.tsx +++ b/src/renderer/src/components/ContextMenu/index.tsx @@ -1,87 +1,59 @@ import { Dropdown } from 'antd' -import { useCallback, useEffect, useState } from 'react' +import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import styled from 'styled-components' interface ContextMenuProps { children: React.ReactNode - onContextMenu?: (e: React.MouseEvent) => void - style?: React.CSSProperties } -const ContextMenu: React.FC = ({ children, onContextMenu, style }) => { +const ContextMenu: React.FC = ({ children }) => { const { t } = useTranslation() - const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number } | null>(null) - const [selectedText, setSelectedText] = useState('') + const [selectedText, setSelectedText] = useState(undefined) - const handleContextMenu = useCallback( - (e: React.MouseEvent) => { - e.preventDefault() - const _selectedText = window.getSelection()?.toString() - if (_selectedText) { - setContextMenuPosition({ x: e.clientX, y: e.clientY }) - setSelectedText(_selectedText) - } - onContextMenu?.(e) - }, - [onContextMenu] - ) + const contextMenuItems = useMemo(() => { + if (!selectedText) return [] - useEffect(() => { - const handleClick = () => { - setContextMenuPosition(null) - } - document.addEventListener('click', handleClick) - return () => { - document.removeEventListener('click', handleClick) - } - }, []) - - // 获取右键菜单项 - const getContextMenuItems = (t: (key: string) => string, selectedText: string) => [ - { - key: 'copy', - label: t('common.copy'), - onClick: () => { - if (selectedText) { - navigator.clipboard - .writeText(selectedText) - .then(() => { - window.message.success({ content: t('message.copied'), key: 'copy-message' }) - }) - .catch(() => { - window.message.error({ content: t('message.copy.failed'), key: 'copy-message-failed' }) - }) - } - } - }, - { - key: 'quote', - label: t('chat.message.quote'), - onClick: () => { - if (selectedText) { - window.api?.quoteToMainWindow(selectedText) + return [ + { + key: 'copy', + label: t('common.copy'), + onClick: () => { + if (selectedText) { + navigator.clipboard + .writeText(selectedText) + .then(() => { + window.message.success({ content: t('message.copied'), key: 'copy-message' }) + }) + .catch(() => { + window.message.error({ content: t('message.copy.failed'), key: 'copy-message-failed' }) + }) + } + } + }, + { + key: 'quote', + label: t('chat.message.quote'), + onClick: () => { + if (selectedText) { + window.api?.quoteToMainWindow(selectedText) + } } } + ] + }, [selectedText, t]) + + const onOpenChange = (open: boolean) => { + if (open) { + const selectedText = window.getSelection()?.toString() + setSelectedText(selectedText) } - ] + } return ( - - {contextMenuPosition && ( - -
- - )} + {children} - + ) } -const ContextContainer = styled.div`` - export default ContextMenu diff --git a/src/renderer/src/components/CustomCollapse.tsx b/src/renderer/src/components/CustomCollapse.tsx index 9c94084d70..c6f4f79a78 100644 --- a/src/renderer/src/components/CustomCollapse.tsx +++ b/src/renderer/src/components/CustomCollapse.tsx @@ -1,5 +1,6 @@ import { Collapse } from 'antd' import { merge } from 'lodash' +import { ChevronRight } from 'lucide-react' import { FC, memo, useMemo, useState } from 'react' interface CustomCollapseProps { @@ -78,6 +79,14 @@ const CustomCollapse: FC = ({ destroyInactivePanel={destroyInactivePanel} collapsible={collapsible} onChange={setActiveKeys} + expandIcon={({ isActive }) => ( + + )} items={[ { styles: collapseItemStyles, diff --git a/src/renderer/src/components/EditableNumber/index.tsx b/src/renderer/src/components/EditableNumber/index.tsx new file mode 100644 index 0000000000..3cc0f09507 --- /dev/null +++ b/src/renderer/src/components/EditableNumber/index.tsx @@ -0,0 +1,114 @@ +import { InputNumber } from 'antd' +import { FC, useEffect, useRef, useState } from 'react' +import styled from 'styled-components' + +export interface EditableNumberProps { + value?: number | null + min?: number + max?: number + step?: number + precision?: number + placeholder?: string + disabled?: boolean + changeOnBlur?: boolean + onChange?: (value: number | null) => void + onBlur?: () => void + style?: React.CSSProperties + className?: string + size?: 'small' | 'middle' | 'large' + suffix?: string + prefix?: string + align?: 'start' | 'center' | 'end' +} + +const EditableNumber: FC = ({ + value, + min, + max, + step = 0.01, + precision, + placeholder, + disabled = false, + onChange, + onBlur, + changeOnBlur = false, + style, + className, + size = 'middle', + align = 'end' +}) => { + const [isEditing, setIsEditing] = useState(false) + const [inputValue, setInputValue] = useState(value) + const inputRef = useRef(null) + + useEffect(() => { + setInputValue(value) + }, [value]) + + const handleFocus = () => { + if (disabled) return + setIsEditing(true) + } + + const handleInputChange = (newValue: number | null) => { + onChange?.(newValue ?? null) + } + + const handleBlur = () => { + setIsEditing(false) + onBlur?.() + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + handleBlur() + } else if (e.key === 'Escape') { + setInputValue(value) + setIsEditing(false) + } + } + + return ( + + + + {value ?? placeholder} + + + ) +} + +const Container = styled.div` + display: inline-block; + position: relative; +` + +const DisplayText = styled.div<{ + $align: 'start' | 'center' | 'end' + $isEditing: boolean +}>` + position: absolute; + inset: 0; + display: ${({ $isEditing }) => ($isEditing ? 'none' : 'flex')}; + align-items: center; + justify-content: ${({ $align }) => $align}; + pointer-events: none; +` + +export default EditableNumber diff --git a/src/renderer/src/components/Popups/MultiSelectionPopup.tsx b/src/renderer/src/components/Popups/MultiSelectionPopup.tsx index f021b631f9..f277fbe3a8 100644 --- a/src/renderer/src/components/Popups/MultiSelectionPopup.tsx +++ b/src/renderer/src/components/Popups/MultiSelectionPopup.tsx @@ -35,17 +35,38 @@ const MultiSelectActionPopup: FC = ({ topic }) => { {t('common.selectedMessages', { count: selectedMessageIds.length })} - } disabled={isActionDisabled} onClick={() => handleAction('save')} /> + + + + } transitionName="animation-move-down" centered> @@ -120,15 +127,6 @@ const PopupContainer: React.FC = ({ resolve }) => { )} - - - - - - - ) diff --git a/src/renderer/src/pages/history/HistoryPage.tsx b/src/renderer/src/pages/history/HistoryPage.tsx index f1b3890660..d20accfd87 100644 --- a/src/renderer/src/pages/history/HistoryPage.tsx +++ b/src/renderer/src/pages/history/HistoryPage.tsx @@ -1,11 +1,11 @@ -import { ArrowLeftOutlined, EnterOutlined } from '@ant-design/icons' +import { HStack } from '@renderer/components/Layout' import { useAppDispatch } from '@renderer/store' import { loadTopicMessagesThunk } from '@renderer/store/thunk/messageThunk' import { Topic } from '@renderer/types' import type { Message } from '@renderer/types/newMessage' -import { Input, InputRef } from 'antd' +import { Divider, Input, InputRef } from 'antd' import { last } from 'lodash' -import { Search } from 'lucide-react' +import { ChevronLeft, CornerDownLeft, Search } from 'lucide-react' import { FC, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -73,26 +73,35 @@ const TopicsPage: FC = () => { return ( -
- {stack.length > 1 && ( - - - - - - )} - + 1 ? ( + + + + ) : ( + + + + ) + } + suffix={search.length >= 2 ? : null} ref={inputRef} + placeholder={t('history.search.placeholder')} + value={search} onChange={(e) => setSearch(e.target.value.trimStart())} - suffix={search.length >= 2 ? : } + allowClear + autoFocus + spellCheck={false} + style={{ paddingLeft: 0 }} + variant="borderless" + size="middle" onPressEnter={onSearch} /> -
+ + + { const SearchMessage: FC = ({ message, ...props }) => { const navigate = NavigationService.navigate! - const { messageStyle } = useSettings() const { t } = useTranslation() const [topic, setTopic] = useState(null) @@ -43,18 +41,18 @@ const SearchMessage: FC = ({ message, ...props }) => { return ( - - + + @@ -74,12 +72,11 @@ const MessagesContainer = styled.div` ` const ContainerWrapper = styled.div` - width: 800px; + width: 100%; display: flex; flex-direction: column; - .message { - padding: 0; - } + padding: 16px; + position: relative; ` export default SearchMessage diff --git a/src/renderer/src/pages/history/components/SearchResults.tsx b/src/renderer/src/pages/history/components/SearchResults.tsx index 5882f4945c..2fd299a388 100644 --- a/src/renderer/src/pages/history/components/SearchResults.tsx +++ b/src/renderer/src/pages/history/components/SearchResults.tsx @@ -151,7 +151,8 @@ const Container = styled.div` ` const ContainerWrapper = styled.div` - width: 800px; + width: 100%; + padding: 0 16px; display: flex; flex-direction: column; ` diff --git a/src/renderer/src/pages/history/components/TopicMessages.tsx b/src/renderer/src/pages/history/components/TopicMessages.tsx index 27372db4f3..1b4be00029 100644 --- a/src/renderer/src/pages/history/components/TopicMessages.tsx +++ b/src/renderer/src/pages/history/components/TopicMessages.tsx @@ -1,9 +1,8 @@ -import { ArrowRightOutlined, MessageOutlined } from '@ant-design/icons' +import { MessageOutlined } from '@ant-design/icons' 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' @@ -13,6 +12,7 @@ import { loadTopicMessagesThunk } from '@renderer/store/thunk/messageThunk' import { Topic } from '@renderer/types' import { Button, Divider, Empty } from 'antd' import { t } from 'i18next' +import { Forward } from 'lucide-react' import { FC, useEffect } from 'react' import styled from 'styled-components' @@ -25,7 +25,6 @@ interface Props extends React.HTMLAttributes { const TopicMessages: FC = ({ topic, ...props }) => { const navigate = NavigationService.navigate! const { handleScroll, containerRef } = useScrollPosition('TopicMessages') - const { messageStyle } = useSettings() const dispatch = useAppDispatch() useEffect(() => { @@ -48,8 +47,8 @@ const TopicMessages: FC = ({ topic, ...props }) => { return ( - - + + {topic?.messages.map((message) => (
@@ -58,7 +57,7 @@ const TopicMessages: FC = ({ topic, ...props }) => { size="middle" style={{ color: 'var(--color-text-3)', position: 'absolute', right: 0, top: 5 }} onClick={() => locateToMessage(navigate, message)} - icon={} + icon={} />
@@ -86,12 +85,10 @@ const MessagesContainer = styled.div` ` const ContainerWrapper = styled.div` - width: 800px; + width: 100%; + padding: 16px; display: flex; flex-direction: column; - .message { - padding: 0; - } ` export default TopicMessages diff --git a/src/renderer/src/pages/history/components/TopicsHistory.tsx b/src/renderer/src/pages/history/components/TopicsHistory.tsx index 85d8ef5a26..d95a3f7ae6 100644 --- a/src/renderer/src/pages/history/components/TopicsHistory.tsx +++ b/src/renderer/src/pages/history/components/TopicsHistory.tsx @@ -78,7 +78,8 @@ const TopicsHistory: React.FC = ({ keywords, onClick, onSearch, ...props } const ContainerWrapper = styled.div` - width: 800px; + width: 100%; + padding: 0 16px; display: flex; flex-direction: column; ` diff --git a/src/renderer/src/pages/home/Chat.tsx b/src/renderer/src/pages/home/Chat.tsx index e2fdbb740c..8d16c5a36c 100644 --- a/src/renderer/src/pages/home/Chat.tsx +++ b/src/renderer/src/pages/home/Chat.tsx @@ -7,6 +7,7 @@ import { useSettings } from '@renderer/hooks/useSettings' import { useShortcut } from '@renderer/hooks/useShortcuts' import { useShowTopics } from '@renderer/hooks/useStore' import { Assistant, Topic } from '@renderer/types' +import { classNames } from '@renderer/utils' import { Flex } from 'antd' import { debounce } from 'lodash' import React, { FC, useMemo, useState } from 'react' @@ -106,7 +107,7 @@ const Chat: FC = (props) => { } return ( - +
= ({ assistant: _assistant, setActiveTopic, topic }) = _text = text _files = files - const resizeTextArea = useCallback(() => { - const textArea = textareaRef.current?.resizableTextArea?.textArea - if (textArea) { - // 如果已经手动设置了高度,则不自动调整 - if (textareaHeight) { - return + const resizeTextArea = useCallback( + (force: boolean = false) => { + const textArea = textareaRef.current?.resizableTextArea?.textArea + if (textArea) { + // 如果已经手动设置了高度,则不自动调整 + if (textareaHeight && !force) { + return + } + if (textArea?.scrollHeight) { + textArea.style.height = Math.min(textArea.scrollHeight, 400) + 'px' + } } - textArea.style.height = 'auto' - textArea.style.height = textArea?.scrollHeight > 400 ? '400px' : `${textArea?.scrollHeight}px` - } - }, [textareaHeight]) + }, + [textareaHeight] + ) const sendMessage = useCallback(async () => { if (inputEmpty || loading) { @@ -749,13 +753,13 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = } return ( - - + + = ({ assistant: _assistant, setActiveTopic, topic }) = ref={textareaRef} style={{ fontSize, - minHeight: textareaHeight ? `${textareaHeight}px` : undefined + minHeight: textareaHeight ? `${textareaHeight}px` : '30px' }} styles={{ textarea: TextareaStyle }} onFocus={(e: React.FocusEvent) => { @@ -851,8 +855,8 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = - - + + ) } @@ -887,16 +891,15 @@ const Container = styled.div` flex-direction: column; position: relative; z-index: 2; + padding: 0 16px 16px 16px; ` const InputBarContainer = styled.div` border: 0.5px solid var(--color-border); transition: all 0.2s ease; position: relative; - margin: 14px 20px; - margin-top: 0; border-radius: 15px; - padding-top: 6px; // 为拖动手柄留出空间 + padding-top: 8px; // 为拖动手柄留出空间 background-color: var(--color-background-opacity); &.file-dragging { @@ -919,7 +922,7 @@ const InputBarContainer = styled.div` const TextareaStyle: CSSProperties = { paddingLeft: 0, - padding: '6px 15px 8px' // 减小顶部padding + padding: '6px 15px 0px' // 减小顶部padding } const Textarea = styled(TextArea)` @@ -934,16 +937,17 @@ const Textarea = styled(TextArea)` &.ant-input { line-height: 1.4; } + &::-webkit-scrollbar { + width: 3px; + } ` const Toolbar = styled.div` display: flex; flex-direction: row; justify-content: space-between; - padding: 0 8px; - padding-bottom: 0; - margin-bottom: 4px; - height: 30px; + padding: 5px 8px; + height: 40px; gap: 16px; position: relative; z-index: 2; diff --git a/src/renderer/src/pages/home/Inputbar/TokenCount.tsx b/src/renderer/src/pages/home/Inputbar/TokenCount.tsx index 0f556a1d15..bad0729b8e 100644 --- a/src/renderer/src/pages/home/Inputbar/TokenCount.tsx +++ b/src/renderer/src/pages/home/Inputbar/TokenCount.tsx @@ -45,7 +45,7 @@ const TokenCount: FC = ({ estimateTokenCount, inputTokenCount, contextCou return ( - + {contextCount.current} / {formatMaxCount(contextCount.max)} diff --git a/src/renderer/src/pages/home/Markdown/CitationTooltip.tsx b/src/renderer/src/pages/home/Markdown/CitationTooltip.tsx index 45b804c851..6041b562af 100644 --- a/src/renderer/src/pages/home/Markdown/CitationTooltip.tsx +++ b/src/renderer/src/pages/home/Markdown/CitationTooltip.tsx @@ -54,9 +54,10 @@ const CitationTooltip: React.FC = ({ children, citation }) return ( = ({ children, className, id, onSave }) => { {children} ) : ( - + {children} ) diff --git a/src/renderer/src/pages/home/Markdown/__tests__/CitationTooltip.test.tsx b/src/renderer/src/pages/home/Markdown/__tests__/CitationTooltip.test.tsx index 06a390c06a..072bf3047e 100644 --- a/src/renderer/src/pages/home/Markdown/__tests__/CitationTooltip.test.tsx +++ b/src/renderer/src/pages/home/Markdown/__tests__/CitationTooltip.test.tsx @@ -93,7 +93,7 @@ describe('CitationTooltip', () => { const tooltip = screen.getByTestId('tooltip-wrapper') expect(tooltip).toHaveAttribute('data-placement', 'top') - expect(tooltip).toHaveAttribute('data-color', 'var(--color-background-mute)') + expect(tooltip).toHaveAttribute('data-color', 'var(--color-background)') const styles = JSON.parse(tooltip.getAttribute('data-styles') || '{}') expect(styles.body).toEqual({ diff --git a/src/renderer/src/pages/home/Markdown/__tests__/__snapshots__/CitationTooltip.test.tsx.snap b/src/renderer/src/pages/home/Markdown/__tests__/__snapshots__/CitationTooltip.test.tsx.snap index ff5c69767e..e9c6def351 100644 --- a/src/renderer/src/pages/home/Markdown/__tests__/__snapshots__/CitationTooltip.test.tsx.snap +++ b/src/renderer/src/pages/home/Markdown/__tests__/__snapshots__/CitationTooltip.test.tsx.snap @@ -47,7 +47,7 @@ exports[`CitationTooltip > basic rendering > should match snapshot 1`] = ` }
= ({ block }) => { ? [`file://${block?.file?.path}`] : [] return ( - + {images.map((src, index) => ( ))} @@ -34,6 +34,5 @@ const Container = styled.div` display: flex; flex-direction: row; gap: 10px; - margin-top: 8px; ` export default React.memo(ImageBlock) diff --git a/src/renderer/src/pages/home/Messages/Blocks/ThinkingBlock.tsx b/src/renderer/src/pages/home/Messages/Blocks/ThinkingBlock.tsx index 74d16a80f0..e1420ba6cb 100644 --- a/src/renderer/src/pages/home/Messages/Blocks/ThinkingBlock.tsx +++ b/src/renderer/src/pages/home/Messages/Blocks/ThinkingBlock.tsx @@ -3,7 +3,7 @@ import { useSettings } from '@renderer/hooks/useSettings' import { MessageBlockStatus, type ThinkingMessageBlock } from '@renderer/types/newMessage' import { lightbulbVariants } from '@renderer/utils/motionVariants' import { Collapse, message as antdMessage, Tooltip } from 'antd' -import { Lightbulb } from 'lucide-react' +import { ChevronRight, Lightbulb } from 'lucide-react' import { motion } from 'motion/react' import { memo, useCallback, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -57,6 +57,14 @@ const ThinkingBlock: React.FC = ({ block }) => { size="small" onChange={() => setActiveKey((key) => (key ? '' : 'thought'))} className="message-thought-container" + expandIcon={({ isActive }) => ( + + )} expandIconPosition="end" items={[ { diff --git a/src/renderer/src/pages/home/Messages/Blocks/index.tsx b/src/renderer/src/pages/home/Messages/Blocks/index.tsx index b469f03264..9f4d6e838a 100644 --- a/src/renderer/src/pages/home/Messages/Blocks/index.tsx +++ b/src/renderer/src/pages/home/Messages/Blocks/index.tsx @@ -164,17 +164,7 @@ export default React.memo(MessageBlockRenderer) const ImageBlockGroup = styled.div` display: grid; - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + grid-template-columns: repeat(3, minmax(200px, 1fr)); gap: 8px; max-width: 960px; - /* > * { - min-width: 200px; - } */ - @media (min-width: 1536px) { - grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); - max-width: 1280px; - > * { - min-width: 250px; - } - } ` diff --git a/src/renderer/src/pages/home/Messages/CitationsList.tsx b/src/renderer/src/pages/home/Messages/CitationsList.tsx index 672587fff5..0c40f83ed6 100644 --- a/src/renderer/src/pages/home/Messages/CitationsList.tsx +++ b/src/renderer/src/pages/home/Messages/CitationsList.tsx @@ -1,10 +1,9 @@ import ContextMenu from '@renderer/components/ContextMenu' import Favicon from '@renderer/components/Icons/FallbackFavicon' -import { HStack } from '@renderer/components/Layout' import { fetchWebContent } from '@renderer/utils/fetch' import { cleanMarkdownContent } from '@renderer/utils/formats' import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query' -import { Button, Drawer, message, Skeleton } from 'antd' +import { Button, message, Popover, Skeleton } from 'antd' import { Check, Copy, FileSearch } from 'lucide-react' import React, { useState } from 'react' import { useTranslation } from 'react-i18next' @@ -48,16 +47,49 @@ const truncateText = (text: string, maxLength = 100) => { const CitationsList: React.FC = ({ citations }) => { const { t } = useTranslation() - const [open, setOpen] = useState(false) const previewItems = citations.slice(0, 3) const count = citations.length if (!count) return null + const popoverContent = ( + + {citations.map((citation) => ( + + {citation.type === 'websearch' ? ( + + ) : ( + + )} + + ))} + + ) + return ( - <> - setOpen(true)}> + + {t('message.citations')} +
+ } + placement="right" + trigger="hover" + styles={{ + body: { + padding: '0 0 8px 0' + } + }}> + {previewItems.map((c, i) => ( @@ -71,27 +103,7 @@ const CitationsList: React.FC = ({ citations }) => { {t('message.citation', { count })} - - setOpen(false)} - open={open} - width={680} - styles={{ header: { border: 'none' }, body: { paddingTop: 0 } }} - destroyOnClose={false}> - {open && - citations.map((citation) => ( - - {citation.type === 'websearch' ? ( - - ) : ( - - )} - - ))} - - +
) } @@ -136,16 +148,17 @@ const WebSearchCitation: React.FC<{ citation: Citation }> = ({ citation }) => { }) return ( - - + + - {citation.number} {citation.showFavicon && citation.url && ( )} handleLinkClick(citation.url, e)}> {citation.title || {citation.hostname}} + + {citation.number} {fetchedContent && } {isLoading ? ( @@ -153,28 +166,29 @@ const WebSearchCitation: React.FC<{ citation: Citation }> = ({ citation }) => { ) : ( {fetchedContent} )} - - + + ) } const KnowledgeCitation: React.FC<{ citation: Citation }> = ({ citation }) => { return ( - - + + - {citation.number} {citation.showFavicon && } handleLinkClick(citation.url, e)}> {citation.title} + + {citation.number} {citation.content && } {citation.content && truncateText(citation.content, 100)} - - + + ) } @@ -213,10 +227,19 @@ const PreviewIcon = styled.div` ` const CitationIndex = styled.div` - font-size: 14px; + width: 14px; + height: 14px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + background-color: var(--color-reference); + font-size: 10px; line-height: 1.6; - color: var(--color-text-2); - margin-right: 8px; + color: var(--color-reference-text); + flex-shrink: 0; + opacity: 1; + transition: opacity 0.3s ease; ` const CitationLink = styled.a` @@ -224,7 +247,7 @@ const CitationLink = styled.a` line-height: 1.6; color: var(--color-text-1); text-decoration: none; - + flex: 1; .hostname { color: var(--color-link); } @@ -236,10 +259,14 @@ const CopyIconWrapper = styled.div` align-items: center; justify-content: center; color: var(--color-text-2); - opacity: 0.6; - margin-left: auto; + opacity: 0; padding: 4px; border-radius: 4px; + position: absolute; + right: 0; + top: 50%; + transform: translateY(-50%); + transition: opacity 0.3s ease; &:hover { opacity: 1; @@ -251,11 +278,17 @@ const WebSearchCard = styled.div` display: flex; flex-direction: column; width: 100%; - padding: 12px; - border-radius: var(--list-item-border-radius); - background-color: var(--color-background); + padding: 12px 0; transition: all 0.3s ease; position: relative; + &:hover { + ${CopyIconWrapper} { + opacity: 1; + } + ${CitationIndex} { + opacity: 0; + } + } ` const WebSearchCardHeader = styled.div` @@ -265,6 +298,7 @@ const WebSearchCardHeader = styled.div` gap: 8px; margin-bottom: 6px; width: 100%; + position: relative; ` const WebSearchCardContent = styled.div` @@ -273,6 +307,7 @@ const WebSearchCardContent = styled.div` color: var(--color-text-2); user-select: text; cursor: text; + word-break: break-all; &.selectable-text { -webkit-user-select: text; @@ -282,4 +317,16 @@ const WebSearchCardContent = styled.div` } ` +const PopoverContent = styled.div` + max-width: min(340px, 60vw); + max-height: 60vh; + padding: 0 12px; +` +const PopoverContentItem = styled.div` + border-bottom: 0.5px solid var(--color-border); + &:last-child { + border-bottom: none; + } +` + export default CitationsList diff --git a/src/renderer/src/pages/home/Messages/Message.tsx b/src/renderer/src/pages/home/Messages/Message.tsx index 19fcbdaded..96067bc375 100644 --- a/src/renderer/src/pages/home/Messages/Message.tsx +++ b/src/renderer/src/pages/home/Messages/Message.tsx @@ -1,9 +1,8 @@ -import ContextMenu from '@renderer/components/ContextMenu' import { useMessageEditing } from '@renderer/context/MessageEditingContext' import { useAssistant } from '@renderer/hooks/useAssistant' import { useMessageOperations } from '@renderer/hooks/useMessageOperations' import { useModel } from '@renderer/hooks/useModel' -import { useMessageStyle, useSettings } from '@renderer/hooks/useSettings' +import { useSettings } from '@renderer/hooks/useSettings' import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' import { getMessageModelId } from '@renderer/services/MessagesService' import { getModelUniqId } from '@renderer/services/ModelService' @@ -42,14 +41,12 @@ const MessageItem: FC = ({ index, hideMenuBar = false, isGrouped, - isStreaming = false, - style + isStreaming = false }) => { const { t } = useTranslation() const { assistant, setModel } = useAssistant(message.assistantId) const model = useModel(getMessageModelId(message), message.model?.provider) || message.model - const { isBubbleStyle } = useMessageStyle() - const { showMessageDivider, messageFont, fontSize, narrowMode, messageStyle } = useSettings() + const { messageFont, fontSize } = useSettings() const { editMessageBlocks, resendUserMessageWithEdit, editMessage } = useMessageOperations(topic) const messageContainerRef = useRef(null) const { editingMessageId, stopEditing } = useMessageEditing() @@ -101,9 +98,6 @@ const MessageItem: FC = ({ const isAssistantMessage = message.role === 'assistant' const showMenubar = !hideMenuBar && !isStreaming && !message.status.includes('ing') && !isEditing - const messageBorder = !isBubbleStyle && showMessageDivider ? '1px dotted var(--color-border)' : 'none' - const messageBackground = getMessageBackground(isBubbleStyle, isAssistantMessage) - const messageHighlightHandler = useCallback((highlight: boolean = true) => { if (messageContainerRef.current) { messageContainerRef.current.scrollIntoView({ behavior: 'smooth' }) @@ -140,101 +134,38 @@ const MessageItem: FC = ({ 'message-assistant': isAssistantMessage, 'message-user': !isAssistantMessage })} - ref={messageContainerRef} - style={{ - ...style, - justifyContent: isBubbleStyle ? (isAssistantMessage ? 'flex-start' : 'flex-end') : undefined, - flex: isBubbleStyle ? undefined : 1 - }}> + ref={messageContainerRef}> + {isEditing && ( - - -
- -
-
+ )} {!isEditing && ( - - + <> - {showMenubar && !isBubbleStyle && ( - - } - setModel={setModel} - /> - - )} - {showMenubar && isBubbleStyle && ( - + {showMenubar && ( + = ({ /> )} - + )} ) } -const getMessageBackground = (isBubbleStyle: boolean, isAssistantMessage: boolean) => { - return isBubbleStyle - ? isAssistantMessage - ? 'var(--chat-background-assistant)' - : 'var(--chat-background-user)' - : undefined -} - const MessageContainer = styled.div` display: flex; + flex-direction: column; width: 100%; position: relative; transition: background-color 0.3s ease; - padding: 0 20px; transform: translateZ(0); will-change: transform; + padding: 10px 10px 0 10px; + border-radius: 10px; &.message-highlight { background-color: var(--color-primary-mute); } @@ -292,11 +217,7 @@ const MessageContainer = styled.div` const MessageContentContainer = styled.div` max-width: 100%; - display: flex; - flex: 1; - flex-direction: column; - justify-content: space-between; - margin-left: 46px; + padding-left: 46px; margin-top: 5px; overflow-y: auto; ` @@ -306,9 +227,8 @@ const MessageFooter = styled.div` flex-direction: row; justify-content: space-between; align-items: center; - padding: 2px 0; - margin-top: 2px; gap: 20px; + margin-left: 46px; ` const NewContextMessage = styled.div` diff --git a/src/renderer/src/pages/home/Messages/MessageAnchorLine.tsx b/src/renderer/src/pages/home/Messages/MessageAnchorLine.tsx index 3e4773dcd1..7b116f8495 100644 --- a/src/renderer/src/pages/home/Messages/MessageAnchorLine.tsx +++ b/src/renderer/src/pages/home/Messages/MessageAnchorLine.tsx @@ -184,7 +184,7 @@ const MessageAnchorLine: FC = ({ messages }) => { else messageItemsRef.current.delete('bottom-anchor') }} style={{ - opacity: mouseY ? 0.5 + calculateValueByDistance('bottom-anchor', 1) : 0.6 + opacity: mouseY ? 0.5 : Math.max(0, 0.6 - (0.3 * Math.abs(0 - messages.length / 2)) / 5) }} onClick={scrollToBottom}> = ({ messages }) => { {messages.map((message, index) => { const opacity = 0.5 + calculateValueByDistance(message.id, 1) - const scale = 1 + calculateValueByDistance(message.id, 1) + const scale = 1 + calculateValueByDistance(message.id, 1.2) const size = 10 + calculateValueByDistance(message.id, 20) const avatarSource = getAvatarSource(isLocalAi, getMessageModelId(message)) const username = removeLeadingEmoji(getUserName(message)) @@ -219,15 +219,14 @@ const MessageAnchorLine: FC = ({ messages }) => { {message.role === 'assistant' ? ( - - A - + }} + /> ) : ( <> {isEmoji(avatar) ? ( @@ -241,7 +240,7 @@ const MessageAnchorLine: FC = ({ messages }) => { {avatar} ) : ( - + )} )} @@ -260,17 +259,28 @@ const MessageItemContainer = styled.div` align-items: flex-end; justify-content: space-between; text-align: right; - gap: 4px; + gap: 3px; opacity: 0; transform-origin: right center; + transition: transform cubic-bezier(0.25, 1, 0.5, 1) 150ms; + will-change: transform; +` + +const MessageItemAvatar = styled(Avatar)` + transition: + width, + height, + cubic-bezier(0.25, 1, 0.5, 1) 150ms; + will-change: width, height; ` const MessageLineContainer = styled.div<{ $height: number | null }>` width: 14px; position: fixed; - top: ${(props) => (props.$height ? `calc(${props.$height / 2}px + var(--status-bar-height))` : '50%')}; + top: calc(50% - var(--status-bar-height) - 10px); right: 13px; - max-height: ${(props) => (props.$height ? `${props.$height}px` : 'calc(100% - var(--status-bar-height) * 2)')}; + max-height: ${(props) => + props.$height ? `${props.$height - 20}px` : 'calc(100% - var(--status-bar-height) * 2 - 20px)'}; transform: translateY(-50%); z-index: 0; user-select: none; @@ -280,7 +290,7 @@ const MessageLineContainer = styled.div<{ $height: number | null }>` font-size: 5px; overflow: hidden; &:hover { - width: 440px; + width: 500px; overflow-x: visible; overflow-y: hidden; ${MessageItemContainer} { diff --git a/src/renderer/src/pages/home/Messages/MessageEditor.tsx b/src/renderer/src/pages/home/Messages/MessageEditor.tsx index 62636ccd68..36449c8efd 100644 --- a/src/renderer/src/pages/home/Messages/MessageEditor.tsx +++ b/src/renderer/src/pages/home/Messages/MessageEditor.tsx @@ -308,10 +308,11 @@ const MessageBlockEditor: FC = ({ message, onSave, onResend, onCancel }) const EditorContainer = styled.div` padding: 8px 0; - border: 1px solid var(--color-border); + border: 0.5px solid var(--color-border); transition: all 0.2s ease; border-radius: 15px; margin-top: 5px; + margin-bottom: 10px; background-color: var(--color-background-opacity); width: 100%; diff --git a/src/renderer/src/pages/home/Messages/MessageGroup.tsx b/src/renderer/src/pages/home/Messages/MessageGroup.tsx index efc9cd8f5d..69b7e7243b 100644 --- a/src/renderer/src/pages/home/Messages/MessageGroup.tsx +++ b/src/renderer/src/pages/home/Messages/MessageGroup.tsx @@ -1,4 +1,3 @@ -import Scrollbar from '@renderer/components/Scrollbar' import { MessageEditingProvider } from '@renderer/context/MessageEditingContext' import { useChatContext } from '@renderer/hooks/useChatContext' import { useMessageOperations } from '@renderer/hooks/useMessageOperations' @@ -10,11 +9,10 @@ import type { Message } from '@renderer/types/newMessage' import { classNames } from '@renderer/utils' import { Popover } from 'antd' import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' -import styled, { css } from 'styled-components' +import styled from 'styled-components' import MessageItem from './Message' import MessageGroupMenuBar from './MessageGroupMenuBar' -import SelectableMessage from './MessageSelect' interface Props { messages: (Message & { index: number })[] @@ -62,7 +60,6 @@ const MessageGroup = ({ messages, topic, registerMessageElement }: Props) => { ) const isGrouped = isMultiSelectMode ? false : messageLength > 1 && messages.every((m) => m.role === 'assistant') - const isHorizontal = multiModelMessageStyle === 'horizontal' const isGrid = multiModelMessageStyle === 'grid' useEffect(() => { @@ -166,25 +163,19 @@ const MessageGroup = ({ messages, topic, registerMessageElement }: Props) => { isGrouped, message, topic, - index: message.index, - style: { - paddingTop: isGrouped && ['horizontal', 'grid'].includes(multiModelMessageStyle) ? 0 : 15 - } + index: message.index } const messageContent = ( + className={classNames([ + { + [multiModelMessageStyle]: message.role === 'assistant', + selected: message.id === selectedMessageId + } + ])}> ) @@ -193,47 +184,43 @@ const MessageGroup = ({ messages, topic, registerMessageElement }: Props) => { return ( + className={classNames([ + 'in-popover', + { + [multiModelMessageStyle]: message.role === 'assistant', + selected: message.id === selectedMessageId + } + ])}> } trigger={gridPopoverTrigger} - styles={{ root: { maxWidth: '60vw', minWidth: '550px', overflowY: 'auto', zIndex: 1000 } }}> -
{messageContent}
+ styles={{ + root: { maxWidth: '60vw', overflowY: 'auto', zIndex: 1000 }, + body: { padding: 2 } + }}> + {messageContent}
) } - return ( - - {messageContent} - - ) + return messageContent }, - [isGrid, isGrouped, topic, multiModelMessageStyle, isHorizontal, selectedMessageId, gridPopoverTrigger] + [isGrid, isGrouped, topic, multiModelMessageStyle, selectedMessageId, gridPopoverTrigger] ) return ( + id={messages[0].askId ? `message-group-${messages[0].askId}` : undefined} + className={classNames([multiModelMessageStyle, { 'multi-select-mode': isMultiSelectMode }])}> + className={classNames([multiModelMessageStyle, { 'multi-select-mode': isMultiSelectMode }])}> {messages.map(renderMessage)} {isGrouped && ( @@ -256,73 +243,103 @@ const MessageGroup = ({ messages, topic, registerMessageElement }: Props) => { ) } -const GroupContainer = styled.div<{ $isGrouped: boolean; $layout: MultiModelMessageStyle }>` - padding-top: ${({ $isGrouped, $layout }) => ($isGrouped && 'horizontal' === $layout ? '15px' : '0')}; - &.group-container.horizontal, - &.group-container.grid { - padding: 0 20px; - .message { - padding: 0; - } +const GroupContainer = styled.div` + &.horizontal, + &.grid { + padding: 4px 10px; .group-menu-bar { margin-left: 0; margin-right: 0; } } + &.multi-select-mode { + padding: 5px 10px; + } ` -const GridContainer = styled.div<{ $count: number; $layout: MultiModelMessageStyle; $gridColumns: number }>` +const GridContainer = styled.div<{ $count: number; $gridColumns: number }>` width: 100%; display: grid; - gap: ${({ $layout }) => ($layout === 'horizontal' ? '16px' : '0')}; - grid-template-columns: repeat( - ${({ $layout, $count }) => (['fold', 'vertical'].includes($layout) ? 1 : $count)}, - minmax(480px, 1fr) - ); - @media (max-width: 800px) { - grid-template-columns: repeat( - ${({ $layout, $count }) => (['fold', 'vertical'].includes($layout) ? 1 : $count)}, - minmax(400px, 1fr) - ); + overflow-y: visible; + gap: 16px; + &.horizontal { + padding-bottom: 4px; + grid-template-columns: repeat(${({ $count }) => $count}, minmax(480px, 1fr)); + overflow-x: auto; + } + &.fold, + &.vertical { + grid-template-columns: repeat(1, minmax(0, 1fr)); + gap: 8px; + } + &.grid { + grid-template-columns: repeat( + ${({ $count, $gridColumns }) => ($count > 1 ? $gridColumns || 2 : 1)}, + minmax(0, 1fr) + ); + grid-template-rows: auto; + } + + &.multi-select-mode { + grid-template-columns: repeat(1, minmax(0, 1fr)); + gap: 10px; + .message { + border: 0.5px solid var(--color-border); + border-radius: 10px; + padding: 10px; + .message-content-container { + max-height: 200px; + overflow-y: hidden !important; + } + .MessageFooter { + display: none; + } + } } - ${({ $layout }) => - $layout === 'horizontal' && - css` - margin-top: 15px; - `} - ${({ $gridColumns, $layout, $count }) => - $layout === 'grid' && - css` - margin-top: 15px; - grid-template-columns: repeat(${$count > 1 ? $gridColumns || 2 : 1}, minmax(0, 1fr)); - grid-template-rows: auto; - gap: 16px; - `} - ${({ $layout }) => { - return $layout === 'horizontal' - ? css` - overflow-y: auto; - ` - : 'overflow-y: visible;' - }} ` interface MessageWrapperProps { - $layout: 'fold' | 'horizontal' | 'vertical' | 'grid' - // $selected: boolean - $isGrouped: boolean $isInPopover?: boolean } -const MessageWrapper = styled(Scrollbar)` - width: 100%; - display: flex; - +const MessageWrapper = styled.div` &.horizontal { - display: inline-block; + overflow-y: auto; + .message { + border: 0.5px solid var(--color-border); + border-radius: 10px; + } + .message-content-container { + padding-left: 0; + max-height: calc(100vh - 350px); + overflow-y: auto !important; + margin-right: -10px; + } + .MessageFooter { + margin-left: 0; + margin-top: 2px; + margin-bottom: 2px; + } } &.grid { - display: inline-block; + height: 300px; + overflow-y: hidden; + border: 0.5px solid var(--color-border); + border-radius: 10px; + cursor: pointer; + } + &.in-popover { + height: auto; + border: none; + max-height: 50vh; + overflow-y: auto; + cursor: default; + .message-content-container { + padding-left: 0; + } + .MessageFooter { + margin-left: 0; + } } &.fold { display: none; @@ -330,38 +347,6 @@ const MessageWrapper = styled(Scrollbar)` display: inline-block; } } - - ${({ $layout, $isGrouped }) => { - if ($layout === 'horizontal' && $isGrouped) { - return css` - border: 0.5px solid var(--color-border); - padding: 10px; - border-radius: 6px; - max-height: 600px; - margin-bottom: 10px; - ` - } - return '' - }} - - ${({ $layout, $isInPopover, $isGrouped }) => { - // 如果布局是grid,并且是组消息,则设置最大高度和溢出行为(卡片不可滚动,点击展开后可滚动) - // 如果布局是horizontal,则设置溢出行为(卡片可滚动) - // 如果布局是fold、vertical,高度不限制,与正常消息流布局一致,则设置卡片不可滚动(visible) - return $layout === 'grid' && $isGrouped - ? css` - max-height: ${$isInPopover ? '50vh' : '300px'}; - overflow-y: ${$isInPopover ? 'auto' : 'hidden'}; - border: 0.5px solid ${$isInPopover ? 'transparent' : 'var(--color-border)'}; - padding: 10px; - border-radius: 6px; - background-color: var(--color-background); - ` - : css` - overflow-y: ${$layout === 'horizontal' ? 'auto' : 'visible'}; - border-radius: 6px; - ` - }} ` export default memo(MessageGroup) diff --git a/src/renderer/src/pages/home/Messages/MessageGroupMenuBar.tsx b/src/renderer/src/pages/home/Messages/MessageGroupMenuBar.tsx index bd639eb472..6c2e7766d7 100644 --- a/src/renderer/src/pages/home/Messages/MessageGroupMenuBar.tsx +++ b/src/renderer/src/pages/home/Messages/MessageGroupMenuBar.tsx @@ -59,6 +59,7 @@ const MessageGroupMenuBar: FC = ({ {['fold', 'vertical', 'horizontal', 'grid'].map((layout) => ( ` flex-direction: row; align-items: center; gap: 10px; - margin: 0 20px; - padding: 6px 10px; - border-radius: 6px; - margin-top: 10px; + padding: 8px; + border-radius: 10px; + margin: 8px 10px 16px; justify-content: space-between; overflow: hidden; border: 0.5px solid var(--color-border); height: 40px; - background-color: var(--color-background); ` const LayoutContainer = styled.div` diff --git a/src/renderer/src/pages/home/Messages/MessageGroupSettings.tsx b/src/renderer/src/pages/home/Messages/MessageGroupSettings.tsx index 0b52c4168e..208a75f952 100644 --- a/src/renderer/src/pages/home/Messages/MessageGroupSettings.tsx +++ b/src/renderer/src/pages/home/Messages/MessageGroupSettings.tsx @@ -1,10 +1,11 @@ import { SettingOutlined } from '@ant-design/icons' +import Selector from '@renderer/components/Selector' import { useSettings } from '@renderer/hooks/useSettings' import { SettingDivider } from '@renderer/pages/settings' import { SettingRow } from '@renderer/pages/settings' import { useAppDispatch } from '@renderer/store' import { setGridColumns, setGridPopoverTrigger } from '@renderer/store/settings' -import { Col, Row, Select, Slider } from 'antd' +import { Col, Row, Slider } from 'antd' import { Popover } from 'antd' import { FC, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -18,19 +19,21 @@ const MessageGroupSettings: FC = () => { return ( +
{t('settings.messages.grid_popover_trigger')}
- + options={[ + { label: t('settings.messages.grid_popover_trigger.hover'), value: 'hover' }, + { label: t('settings.messages.grid_popover_trigger.click'), value: 'click' } + ]} + />
diff --git a/src/renderer/src/pages/home/Messages/MessageHeader.tsx b/src/renderer/src/pages/home/Messages/MessageHeader.tsx index 285cfd516f..465ca923b8 100644 --- a/src/renderer/src/pages/home/Messages/MessageHeader.tsx +++ b/src/renderer/src/pages/home/Messages/MessageHeader.tsx @@ -4,16 +4,17 @@ import { APP_NAME, AppLogo, isLocalAi } from '@renderer/config/env' import { getModelLogo } from '@renderer/config/models' import { useTheme } from '@renderer/context/ThemeProvider' import useAvatar from '@renderer/hooks/useAvatar' +import { useChatContext } from '@renderer/hooks/useChatContext' import { useMinappPopup } from '@renderer/hooks/useMinappPopup' import { useMessageStyle, useSettings } from '@renderer/hooks/useSettings' import { getMessageModelId } from '@renderer/services/MessagesService' import { getModelName } from '@renderer/services/ModelService' -import type { Assistant, Model } from '@renderer/types' +import type { Assistant, Model, Topic } from '@renderer/types' import type { Message } from '@renderer/types/newMessage' import { firstLetter, isEmoji, removeLeadingEmoji } from '@renderer/utils' -import { Avatar } from 'antd' +import { Avatar, Checkbox } from 'antd' import dayjs from 'dayjs' -import { CSSProperties, FC, memo, useCallback, useMemo } from 'react' +import { FC, memo, useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -24,6 +25,7 @@ interface Props { assistant: Assistant model?: Model index: number | undefined + topic: Topic } const getAvatarSource = (isLocalAi: boolean, modelId: string | undefined) => { @@ -31,7 +33,7 @@ const getAvatarSource = (isLocalAi: boolean, modelId: string | undefined) => { return modelId ? getModelLogo(modelId) : undefined } -const MessageHeader: FC = memo(({ assistant, model, message, index }) => { +const MessageHeader: FC = memo(({ assistant, model, message, index, topic }) => { const avatar = useAvatar() const { theme } = useTheme() const { userName, sidebarIcons } = useSettings() @@ -39,6 +41,10 @@ const MessageHeader: FC = memo(({ assistant, model, message, index }) => const { isBubbleStyle } = useMessageStyle() const { openMinappById } = useMinappPopup() + const { isMultiSelectMode, selectedMessageIds, handleSelectMessage } = useChatContext(topic) + + const isSelected = selectedMessageIds?.includes(message.id) + const avatarSource = useMemo(() => getAvatarSource(isLocalAi, getMessageModelId(message)), [message]) const getUserName = useCallback(() => { @@ -67,65 +73,54 @@ const MessageHeader: FC = memo(({ assistant, model, message, index }) => // eslint-disable-next-line react-hooks/exhaustive-deps }, [model?.provider, showMinappIcon]) - const avatarStyle: CSSProperties | undefined = isBubbleStyle - ? { - flexDirection: isAssistantMessage ? 'row' : 'row-reverse', - textAlign: isAssistantMessage ? 'left' : 'right' - } - : undefined - - const containerStyle = isBubbleStyle - ? { - justifyContent: isAssistantMessage ? 'flex-start' : 'flex-end' - } - : undefined - return ( - - - {isAssistantMessage ? ( - - {avatarName} - - ) : ( - <> - {isEmoji(avatar) ? ( - UserPopup.show()} size={35} fontSize={20}> - {avatar} - - ) : ( - UserPopup.show()} - /> - )} - - )} - - - {username} - - - {dayjs(message?.updatedAt ?? message.createdAt).format('MM/DD HH:mm')} - {showTokens && | } - - - - + + {isAssistantMessage ? ( + + {avatarName} + + ) : ( + <> + {isEmoji(avatar) ? ( + UserPopup.show()} size={35} fontSize={20}> + {avatar} + + ) : ( + UserPopup.show()} + /> + )} + + )} + + + {username} + + + {dayjs(message?.updatedAt ?? message.createdAt).format('MM/DD HH:mm')} + {showTokens && | } + + + + {isMultiSelectMode && ( + handleSelectMessage(message.id, e.target.checked)} + style={{ position: 'absolute', right: 0, top: 0 }} + /> + )} ) }) @@ -133,23 +128,18 @@ const MessageHeader: FC = memo(({ assistant, model, message, index }) => MessageHeader.displayName = 'MessageHeader' const Container = styled.div` - display: flex; - flex-direction: row; - align-items: center; - padding-bottom: 4px; -` - -const AvatarWrapper = styled.div` display: flex; flex-direction: row; align-items: center; gap: 10px; + position: relative; ` const UserWrap = styled.div` display: flex; flex-direction: column; justify-content: space-between; + flex: 1; ` const InfoWrap = styled.div` diff --git a/src/renderer/src/pages/home/Messages/MessageMenubar.tsx b/src/renderer/src/pages/home/Messages/MessageMenubar.tsx index c7e39adf2d..50a4fc95ed 100644 --- a/src/renderer/src/pages/home/Messages/MessageMenubar.tsx +++ b/src/renderer/src/pages/home/Messages/MessageMenubar.tsx @@ -507,8 +507,7 @@ const MessageMenubar: FC = (props) => { e.domEvent.stopPropagation() }} trigger={['click']} - placement="topRight" - arrow> + placement="topRight"> e.stopPropagation()} diff --git a/src/renderer/src/pages/home/Messages/Messages.tsx b/src/renderer/src/pages/home/Messages/Messages.tsx index cae4237ffd..1a7d46024c 100644 --- a/src/renderer/src/pages/home/Messages/Messages.tsx +++ b/src/renderer/src/pages/home/Messages/Messages.tsx @@ -1,3 +1,4 @@ +import ContextMenu from '@renderer/components/ContextMenu' import SvgSpinners180Ring from '@renderer/components/Icons/SvgSpinners180Ring' import Scrollbar from '@renderer/components/Scrollbar' import { LOAD_MORE_COUNT } from '@renderer/config/constant' @@ -271,7 +272,6 @@ const Messages: React.FC = ({ assistant, topic, setActiveTopic, o id="messages" className="messages-container" ref={scrollContainerRef} - style={{ position: 'relative', paddingTop: showPrompt ? 10 : 0 }} key={assistant.id} onScroll={handleScrollPosition}> @@ -283,22 +283,25 @@ const Messages: React.FC = ({ assistant, topic, setActiveTopic, o scrollableTarget="messages" inverse style={{ overflow: 'visible' }}> - - {groupedMessages.map(([key, groupMessages]) => ( - - ))} - {isLoadingMore && ( - - - - )} - + + + {groupedMessages.map(([key, groupMessages]) => ( + + ))} + {isLoadingMore && ( + + + + )} + + + {showPrompt && } {messageNavigation === 'anchor' && } @@ -361,6 +364,10 @@ const LoaderContainer = styled.div` const ScrollContainer = styled.div` display: flex; flex-direction: column-reverse; + padding: 20px 10px 20px 16px; + .multi-select-mode & { + padding-bottom: 60px; + } ` interface ContainerProps { @@ -370,11 +377,9 @@ interface ContainerProps { const MessagesContainer = styled(Scrollbar)` display: flex; flex-direction: column-reverse; - padding: 10px 0 20px; overflow-x: hidden; - background-color: var(--color-background); z-index: 1; - margin-right: 2px; + position: relative; ` export default Messages diff --git a/src/renderer/src/pages/home/Messages/NarrowLayout.tsx b/src/renderer/src/pages/home/Messages/NarrowLayout.tsx index b1579b4dc5..6431bb151c 100644 --- a/src/renderer/src/pages/home/Messages/NarrowLayout.tsx +++ b/src/renderer/src/pages/home/Messages/NarrowLayout.tsx @@ -10,7 +10,11 @@ const NarrowLayout: FC = ({ children, ...props }) => { const { narrowMode } = useSettings() if (narrowMode) { - return {children} + return ( + + {children} + + ) } return children diff --git a/src/renderer/src/pages/home/Messages/Prompt.tsx b/src/renderer/src/pages/home/Messages/Prompt.tsx index 1fe67eca43..f0df2a460f 100644 --- a/src/renderer/src/pages/home/Messages/Prompt.tsx +++ b/src/renderer/src/pages/home/Messages/Prompt.tsx @@ -30,11 +30,11 @@ const Prompt: FC = ({ assistant, topic }) => { } const Container = styled.div<{ $isDark: boolean }>` - padding: 10px 20px; - margin: 5px 20px 0 20px; + padding: 10px 16px; border-radius: 10px; cursor: pointer; border: 0.5px solid var(--color-border); + margin: 10px 10px 0 10px; ` const Text = styled.div` diff --git a/src/renderer/src/pages/home/Tabs/SettingsTab.tsx b/src/renderer/src/pages/home/Tabs/SettingsTab.tsx index b3b7a844e7..67c3ba7b97 100644 --- a/src/renderer/src/pages/home/Tabs/SettingsTab.tsx +++ b/src/renderer/src/pages/home/Tabs/SettingsTab.tsx @@ -1,6 +1,7 @@ -import { CheckOutlined } from '@ant-design/icons' +import EditableNumber from '@renderer/components/EditableNumber' import { HStack } from '@renderer/components/Layout' import Scrollbar from '@renderer/components/Scrollbar' +import Selector from '@renderer/components/Selector' import { DEFAULT_CONTEXTCOUNT, DEFAULT_MAX_TOKENS, DEFAULT_TEMPERATURE } from '@renderer/config/constant' import { isOpenAIModel, @@ -38,7 +39,6 @@ import { setPasteLongTextThreshold, setRenderInputMessageAsMarkdown, setShowInputEstimatedTokens, - setShowMessageDivider, setShowPrompt, setShowTokens, setShowTranslateConfirm, @@ -54,7 +54,7 @@ import { } from '@renderer/types' import { modalConfirm } from '@renderer/utils' import { getSendMessageShortcutLabel } from '@renderer/utils/input' -import { Button, Col, InputNumber, Row, Select, Slider, Switch, Tooltip } from 'antd' +import { Button, Col, InputNumber, Row, Slider, Switch, Tooltip } from 'antd' import { CircleHelp, Settings2 } from 'lucide-react' import { FC, useCallback, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -86,7 +86,6 @@ const SettingsTab: FC = (props) => { const { showPrompt, - showMessageDivider, messageFont, showInputEstimatedTokens, sendMessageShortcut, @@ -312,20 +311,6 @@ const SettingsTab: FC = (props) => { dispatch(setShowTokens(checked))} /> - - - {t('settings.messages.divider')} - - - - - dispatch(setShowMessageDivider(checked))} - /> - - {t('settings.messages.use_serif_font')} = (props) => { {t('message.message.style')} - dispatch(setMessageStyle(value as 'plain' | 'bubble'))} - style={{ width: 135 }} - size="small"> - {t('message.message.style.plain')} - {t('message.message.style.bubble')} - + options={[ + { value: 'plain', label: t('message.message.style.plain') }, + { value: 'bubble', label: t('message.message.style.bubble') } + ]} + /> {t('message.message.multi_model_style')} - dispatch(setMultiModelMessageStyle(value as 'fold' | 'vertical' | 'horizontal' | 'grid')) } - style={{ width: 135 }}> - {t('message.message.multi_model_style.fold')} - {t('message.message.multi_model_style.vertical')} - {t('message.message.multi_model_style.horizontal')} - {t('message.message.multi_model_style.grid')} - + options={[ + { value: 'fold', label: t('message.message.multi_model_style.fold') }, + { value: 'vertical', label: t('message.message.multi_model_style.vertical') }, + { value: 'horizontal', label: t('message.message.multi_model_style.horizontal') }, + { value: 'grid', label: t('message.message.multi_model_style.grid') } + ]} + /> {t('settings.messages.navigation')} - dispatch(setMessageNavigation(value as 'none' | 'buttons' | 'anchor'))} - style={{ width: 135 }}> - {t('settings.messages.navigation.none')} - {t('settings.messages.navigation.buttons')} - {t('settings.messages.navigation.anchor')} - + options={[ + { value: 'none', label: t('settings.messages.navigation.none') }, + { value: 'buttons', label: t('settings.messages.navigation.buttons') }, + { value: 'anchor', label: t('settings.messages.navigation.anchor') } + ]} + /> {t('settings.messages.math_engine')} - dispatch(setMathEngine(value as MathEngine))} - style={{ width: 135 }} - size="small"> - KaTeX - MathJax - {t('settings.messages.math_engine.none')} - + options={[ + { value: 'KaTeX', label: 'KaTeX' }, + { value: 'MathJax', label: 'MathJax' }, + { value: 'none', label: t('settings.messages.math_engine.none') } + ]} + /> @@ -430,17 +415,14 @@ const SettingsTab: FC = (props) => { {t('message.message.code_style')} - onCodeStyleChange(value as CodeStyleVarious)} - style={{ width: 135 }} - size="small"> - {themeNames.map((theme) => ( - - {theme} - - ))} - + options={themeNames.map((theme) => ({ + value: theme, + label: theme + }))} + /> @@ -466,7 +448,7 @@ const SettingsTab: FC = (props) => { - = (props) => { {t('settings.messages.input.paste_long_text_threshold')} - = (props) => { {t('settings.input.target_language')} - } + onChange={(value) => setTargetLanguage(value as TranslateLanguageVarious)} options={[ { value: 'chinese', label: t('settings.input.target_language.chinese') }, { value: 'chinese-traditional', label: t('settings.input.target_language.chinese-traditional') }, @@ -653,17 +633,14 @@ const SettingsTab: FC = (props) => { { value: 'japanese', label: t('settings.input.target_language.japanese') }, { value: 'russian', label: t('settings.input.target_language.russian') } ]} - onChange={(value) => setTargetLanguage(value as TranslateLanguageVarious)} - style={{ width: 135 }} /> {t('settings.messages.input.send_shortcuts')} - } + onChange={(value) => setSendMessageShortcut(value as SendMessageShortcut)} options={[ { value: 'Enter', label: getSendMessageShortcutLabel('Enter') }, { value: 'Ctrl+Enter', label: getSendMessageShortcutLabel('Ctrl+Enter') }, @@ -671,8 +648,6 @@ const SettingsTab: FC = (props) => { { value: 'Command+Enter', label: getSendMessageShortcutLabel('Command+Enter') }, { value: 'Shift+Enter', label: getSendMessageShortcutLabel('Shift+Enter') } ]} - onChange={(value) => setSendMessageShortcut(value as SendMessageShortcut)} - style={{ width: 135 }} /> @@ -704,12 +679,4 @@ const SettingGroup = styled.div<{ theme?: ThemeMode }>` margin-bottom: 10px; ` -const StyledSelect = styled(Select)` - .ant-select-selector { - border-radius: 15px !important; - padding: 4px 10px !important; - height: 26px !important; - } -` - export default SettingsTab diff --git a/src/renderer/src/pages/home/Tabs/components/OpenAISettingsGroup.tsx b/src/renderer/src/pages/home/Tabs/components/OpenAISettingsGroup.tsx index 2aa25c5ff1..18b6800a6d 100644 --- a/src/renderer/src/pages/home/Tabs/components/OpenAISettingsGroup.tsx +++ b/src/renderer/src/pages/home/Tabs/components/OpenAISettingsGroup.tsx @@ -1,3 +1,4 @@ +import Selector from '@renderer/components/Selector' import { SettingDivider, SettingRow } from '@renderer/pages/settings' import { CollapsibleSettingGroup } from '@renderer/pages/settings/SettingGroup' import { RootState, useAppDispatch } from '@renderer/store' @@ -102,13 +103,11 @@ const OpenAISettingsGroup: FC = ({ - { setServiceTierMode(value as OpenAIServiceTier) }} - size="small" options={serviceTierOptions} /> @@ -135,6 +134,7 @@ const OpenAISettingsGroup: FC = ({ )} + ) } diff --git a/src/renderer/src/pages/knowledge/components/AddKnowledgePopup.tsx b/src/renderer/src/pages/knowledge/components/AddKnowledgePopup.tsx index c59c944415..528f64f41a 100644 --- a/src/renderer/src/pages/knowledge/components/AddKnowledgePopup.tsx +++ b/src/renderer/src/pages/knowledge/components/AddKnowledgePopup.tsx @@ -13,6 +13,7 @@ import { KnowledgeBase, Model } from '@renderer/types' import { getErrorMessage } from '@renderer/utils/error' import { Flex, Form, Input, InputNumber, Modal, Select, Slider, Switch } from 'antd' import { find, sortBy } from 'lodash' +import { ChevronDown } from 'lucide-react' import { nanoid } from 'nanoid' import { useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -116,6 +117,7 @@ const PopupContainer: React.FC = ({ title, resolve }) => { const aiProvider = new AiProvider(provider) values.dimensions = await aiProvider.getEmbeddingDimensions(selectedEmbeddingModel) } catch (error) { + console.error('Error getting embedding dimensions:', error) window.message.error(t('message.error.get_embedding_dimensions') + '\n' + getErrorMessage(error)) setLoading(false) return @@ -181,7 +183,12 @@ const PopupContainer: React.FC = ({ title, resolve }) => { label={t('models.embedding_model')} tooltip={{ title: t('models.embedding_model_tooltip'), placement: 'right' }} rules={[{ required: true, message: t('message.error.enter.model') }]}> - } + /> = ({ title, resolve }) => { label={t('models.rerank_model')} tooltip={{ title: t('models.rerank_model_tooltip'), placement: 'right' }} rules={[{ required: false, message: t('message.error.enter.model') }]}> - } + /> {t('models.rerank_model_not_support_provider', { @@ -201,13 +213,7 @@ const PopupContainer: React.FC = ({ title, resolve }) => { label={t('knowledge.document_count')} initialValue={DEFAULT_KNOWLEDGE_DOCUMENT_COUNT} // 设置初始值 tooltip={{ title: t('knowledge.document_count_help') }}> - + = ({ base, resolve }) => { const [results, setResults] = useState>([]) const [searchKeyword, setSearchKeyword] = useState('') const { t } = useTranslation() - const searchInputRef = useRef(null) const handleSearch = async (value: string) => { if (!value.trim()) { @@ -84,77 +84,98 @@ const PopupContainer: React.FC = ({ base, resolve }) => { return ( visible && searchInputRef.current?.focus()} - width={800} + width={700} footer={null} centered - transitionName="animation-move-down"> - - + + + + + } + value={searchKeyword} + placeholder={t('knowledge.search')} allowClear - enterButton - size="large" - onSearch={handleSearch} - ref={searchInputRef} + autoFocus + spellCheck={false} + style={{ paddingLeft: 0 }} + variant="borderless" + size="middle" + onChange={(e) => setSearchKeyword(e.target.value)} + onPressEnter={() => handleSearch(searchKeyword)} /> - - {loading ? ( - - - - ) : ( - ( - - - - Score: {(item.score * 100).toFixed(1)}% - - handleCopy(item.pageContent)}> - - - - - {highlightText(item.pageContent)} - - - {t('knowledge.source')}:{' '} - {item.file ? ( - - {item.file.origin_name} - - ) : ( - item.metadata.source - )} - - - - - )} - /> - )} - - + + + + + {loading ? ( + + + + ) : ( + ( + + + + + {t('knowledge.source')}:{' '} + {item.file ? ( + + {item.file.origin_name} + + ) : ( + item.metadata.source + )} + + Score: {(item.score * 100).toFixed(1)}% + + + + handleCopy(item.pageContent)}> + + + + + + {highlightText(item.pageContent)} + + + + )} + /> + )} + ) } -const SearchContainer = styled.div` - display: flex; - flex-direction: column; - gap: 20px; -` - const ResultsContainer = styled.div` - max-height: 60vh; + padding: 0 16px; overflow-y: auto; + max-height: 70vh; ` const LoadingContainer = styled.div` @@ -164,21 +185,29 @@ const LoadingContainer = styled.div` height: 200px; ` +const TagContainer = styled.div` + position: absolute; + top: 58px; + right: 16px; + display: flex; + align-items: center; + gap: 8px; + opacity: 0; + transition: opacity 0.2s; +` + const ResultItem = styled.div` width: 100%; position: relative; padding: 16px; background: var(--color-background-soft); border-radius: 8px; -` -const TagContainer = styled.div` - position: absolute; - top: 8px; - right: 8px; - display: flex; - align-items: center; - gap: 8px; + &:hover { + ${TagContainer} { + opacity: 1 !important; + } + } ` const ScoreTag = styled.div` @@ -187,6 +216,7 @@ const ScoreTag = styled.div` color: white; border-radius: 4px; font-size: 12px; + flex-shrink: 0; ` const CopyButton = styled.div` @@ -195,7 +225,7 @@ const CopyButton = styled.div` justify-content: center; width: 24px; height: 24px; - background: var(--color-background); + background: var(--color-background-mute); color: var(--color-text); border-radius: 4px; cursor: pointer; @@ -208,12 +238,35 @@ const CopyButton = styled.div` ` const MetadataContainer = styled.div` - margin-top: 8px; - padding-top: 8px; - border-top: 1px solid var(--color-border); + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; + margin-bottom: 8px; + padding-bottom: 8px; + border-bottom: 1px solid var(--color-border); user-select: text; ` +const SearchIcon = styled.div` + width: 32px; + height: 32px; + border-radius: 50%; + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + background-color: var(--color-background-soft); + margin-right: 2px; + &.back-icon { + cursor: pointer; + transition: background-color 0.2s; + &:hover { + background-color: var(--color-background-mute); + } + } +` + const TopViewKey = 'KnowledgeSearchPopup' export default class KnowledgeSearchPopup { diff --git a/src/renderer/src/pages/knowledge/components/KnowledgeSettingsPopup.tsx b/src/renderer/src/pages/knowledge/components/KnowledgeSettingsPopup.tsx index d994fa9ee1..625ca2c90f 100644 --- a/src/renderer/src/pages/knowledge/components/KnowledgeSettingsPopup.tsx +++ b/src/renderer/src/pages/knowledge/components/KnowledgeSettingsPopup.tsx @@ -1,4 +1,4 @@ -import { DownOutlined, WarningOutlined } from '@ant-design/icons' +import { WarningOutlined } from '@ant-design/icons' import { TopView } from '@renderer/components/TopView' import { DEFAULT_KNOWLEDGE_DOCUMENT_COUNT } from '@renderer/config/constant' import { getEmbeddingMaxContext } from '@renderer/config/embedings' @@ -10,11 +10,11 @@ import { useProviders } from '@renderer/hooks/useProvider' import { SettingHelpText } from '@renderer/pages/settings' import { getModelUniqId } from '@renderer/services/ModelService' import { KnowledgeBase } from '@renderer/types' -import { Alert, Form, Input, InputNumber, Modal, Select, Slider } from 'antd' +import { Alert, Button, Form, Input, InputNumber, Modal, Select, Slider } from 'antd' import { sortBy } from 'lodash' +import { ChevronDown } from 'lucide-react' import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' -import styled from 'styled-components' interface ShowParams { base: KnowledgeBase @@ -140,7 +140,13 @@ const PopupContainer: React.FC = ({ base: _base, resolve }) => { initialValue={getModelUniqId(base.model)} tooltip={{ title: t('models.embedding_model_tooltip'), placement: 'right' }} rules={[{ required: true, message: t('message.error.enter.model') }]}> - } + /> = ({ base: _base, resolve }) => { options={rerankSelectOptions} placeholder={t('settings.models.empty')} allowClear + suffixIcon={} /> @@ -166,27 +173,21 @@ const PopupContainer: React.FC = ({ base: _base, resolve }) => { name="documentCount" label={t('knowledge.document_count')} tooltip={{ title: t('knowledge.document_count_help') }}> - + - setShowAdvanced(!showAdvanced)}> - setShowAdvanced(!showAdvanced)}> + {t('common.advanced_settings')} + - {t('common.advanced_settings')} - + -
+
= ({ base: _base, resolve }) => { const TopViewKey = 'KnowledgeSettingsPopup' -const AdvancedSettingsButton = styled.div` - cursor: pointer; - margin-bottom: 16px; - margin-top: -10px; - color: var(--color-primary); - display: flex; - align-items: center; -` - export default class KnowledgeSettingsPopup { static hide() { TopView.hide(TopViewKey) diff --git a/src/renderer/src/pages/settings/AssistantSettings/AssistantKnowledgeBaseSettings.tsx b/src/renderer/src/pages/settings/AssistantSettings/AssistantKnowledgeBaseSettings.tsx index 169ed3ffd5..a593f41cbe 100644 --- a/src/renderer/src/pages/settings/AssistantSettings/AssistantKnowledgeBaseSettings.tsx +++ b/src/renderer/src/pages/settings/AssistantSettings/AssistantKnowledgeBaseSettings.tsx @@ -3,7 +3,7 @@ import { Box } from '@renderer/components/Layout' import { useAppSelector } from '@renderer/store' import { Assistant, AssistantSettings } from '@renderer/types' import { Row, Segmented, Select, SelectProps, Tooltip } from 'antd' -import { CircleHelp } from 'lucide-react' +import { ChevronDown, CircleHelp } from 'lucide-react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -46,6 +46,7 @@ const AssistantKnowledgeBaseSettings: React.FC = ({ assistant, updateAssi .toLowerCase() .includes(input.toLowerCase()) } + suffixIcon={} /> diff --git a/src/renderer/src/pages/settings/AssistantSettings/AssistantModelSettings.tsx b/src/renderer/src/pages/settings/AssistantSettings/AssistantModelSettings.tsx index 1a22848ce5..31e44abfbb 100644 --- a/src/renderer/src/pages/settings/AssistantSettings/AssistantModelSettings.tsx +++ b/src/renderer/src/pages/settings/AssistantSettings/AssistantModelSettings.tsx @@ -1,13 +1,16 @@ import { DeleteOutlined, PlusOutlined, QuestionCircleOutlined } from '@ant-design/icons' import ModelAvatar from '@renderer/components/Avatar/ModelAvatar' +import EditableNumber from '@renderer/components/EditableNumber' import { HStack } from '@renderer/components/Layout' import SelectModelPopup from '@renderer/components/Popups/SelectModelPopup' +import Selector from '@renderer/components/Selector' import { DEFAULT_CONTEXTCOUNT, DEFAULT_TEMPERATURE } from '@renderer/config/constant' import { SettingRow } from '@renderer/pages/settings' import { Assistant, AssistantSettingCustomParameters, AssistantSettings } from '@renderer/types' import { modalConfirm } from '@renderer/utils' import { Button, Col, Divider, Input, InputNumber, Row, Select, Slider, Switch, Tooltip } from 'antd' import { isNull } from 'lodash' +import { ChevronDown } from 'lucide-react' import { FC, useCallback, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -107,9 +110,15 @@ const AssistantModelSettings: FC = ({ assistant, updateAssistant, updateA ) case 'boolean': return ( - onUpdateCustomParameter(index, 'value', checked)} + { setToolUseMode(value) updateAssistantSettings({ toolUseMode: value }) - }}> - {t('assistants.settings.tool_use_mode.prompt')} - {t('assistants.settings.tool_use_mode.function')} - + }} + size={14} + /> @@ -409,20 +433,26 @@ const AssistantModelSettings: FC = ({ assistant, updateAssistant, updateA onChange={(e) => onUpdateCustomParameter(index, 'name', e.target.value)} /> - + - {renderParameterValueInput(param, index)} + {renderParameterValueInput(param, index)} - + {emoji && ( = ({ resolve, tab, ...prop styles={{ content: { padding: 0, - overflow: 'hidden', - background: 'var(--color-background)' + overflow: 'hidden' }, - header: { padding: '10px 15px', borderBottom: '0.5px solid var(--color-border)', margin: 0 } + header: { padding: '10px 15px', borderBottom: '0.5px solid var(--color-border)', margin: 0, borderRadius: 0 }, + body: { + padding: 0 + } }} - width="70vw" + width="min(800px, 70vw)" height="80vh" centered> @@ -145,15 +147,14 @@ const AssistantSettingPopupContainer: React.FC = ({ resolve, tab, ...prop } const LeftMenu = styled.div` - background-color: var(--color-background); height: calc(80vh - 20px); border-right: 0.5px solid var(--color-border); ` const Settings = styled.div` flex: 1; - padding: 10px 20px; - height: calc(80vh - 20px); + padding: 16px 16px; + height: calc(80vh - 16px); overflow-y: scroll; ` @@ -163,6 +164,7 @@ const StyledModal = styled(Modal)` } .ant-modal-close { top: 4px; + right: 4px; } .ant-menu-item { height: 36px; diff --git a/src/renderer/src/pages/settings/DataSettings/AgentsSubscribeUrlSettings.tsx b/src/renderer/src/pages/settings/DataSettings/AgentsSubscribeUrlSettings.tsx index eb37f41737..f4e76fadd9 100755 --- a/src/renderer/src/pages/settings/DataSettings/AgentsSubscribeUrlSettings.tsx +++ b/src/renderer/src/pages/settings/DataSettings/AgentsSubscribeUrlSettings.tsx @@ -39,7 +39,6 @@ const AgentsSubscribeUrlSettings: FC = () => { /> - ) } diff --git a/src/renderer/src/pages/settings/DataSettings/JoplinSettings.tsx b/src/renderer/src/pages/settings/DataSettings/JoplinSettings.tsx index 3574c808d1..32b3148541 100644 --- a/src/renderer/src/pages/settings/DataSettings/JoplinSettings.tsx +++ b/src/renderer/src/pages/settings/DataSettings/JoplinSettings.tsx @@ -4,7 +4,7 @@ import { useTheme } from '@renderer/context/ThemeProvider' import { useMinappPopup } from '@renderer/hooks/useMinappPopup' import { RootState, useAppDispatch } from '@renderer/store' import { setJoplinExportReasoning, setJoplinToken, setJoplinUrl } from '@renderer/store/settings' -import { Button, Switch, Tooltip } from 'antd' +import { Button, Space, Switch, Tooltip } from 'antd' import Input from 'antd/es/input/Input' import { FC } from 'react' import { useTranslation } from 'react-i18next' @@ -106,14 +106,15 @@ const JoplinSettings: FC = () => { - - + + + + diff --git a/src/renderer/src/pages/settings/DataSettings/NotionSettings.tsx b/src/renderer/src/pages/settings/DataSettings/NotionSettings.tsx index 719a2363d7..26e8d0872a 100644 --- a/src/renderer/src/pages/settings/DataSettings/NotionSettings.tsx +++ b/src/renderer/src/pages/settings/DataSettings/NotionSettings.tsx @@ -10,7 +10,7 @@ import { setNotionExportReasoning, setNotionPageNameKey } from '@renderer/store/settings' -import { Button, Switch, Tooltip } from 'antd' +import { Button, Space, Switch, Tooltip } from 'antd' import Input from 'antd/es/input/Input' import { FC } from 'react' import { useTranslation } from 'react-i18next' @@ -121,15 +121,16 @@ const NotionSettings: FC = () => { {t('settings.data.notion.api_key')} - - + + + + diff --git a/src/renderer/src/pages/settings/DataSettings/NutstoreSettings.tsx b/src/renderer/src/pages/settings/DataSettings/NutstoreSettings.tsx index 15207200b4..108452c133 100644 --- a/src/renderer/src/pages/settings/DataSettings/NutstoreSettings.tsx +++ b/src/renderer/src/pages/settings/DataSettings/NutstoreSettings.tsx @@ -1,6 +1,7 @@ import { CheckOutlined, FolderOutlined, LoadingOutlined, SyncOutlined, WarningOutlined } from '@ant-design/icons' import { HStack } from '@renderer/components/Layout' import NutstorePathPopup from '@renderer/components/Popups/NutsorePathPopup' +import Selector from '@renderer/components/Selector' import { WebdavBackupManager } from '@renderer/components/WebdavBackupManager' import { useWebdavBackupModal, WebdavBackupModal } from '@renderer/components/WebdavModals' import { useTheme } from '@renderer/context/ThemeProvider' @@ -23,7 +24,7 @@ import { } from '@renderer/store/nutstore' import { modalConfirm } from '@renderer/utils' import { NUTSTORE_HOST } from '@shared/config/nutstore' -import { Button, Input, Select, Switch, Tooltip, Typography } from 'antd' +import { Button, Input, Switch, Tooltip, Typography } from 'antd' import dayjs from 'dayjs' import { FC, useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -279,18 +280,23 @@ const NutstoreSettings: FC = () => { {t('settings.data.webdav.autoSync')} - + {nutstoreAutoSync && syncInterval > 0 && ( <> diff --git a/src/renderer/src/pages/settings/DataSettings/SiyuanSettings.tsx b/src/renderer/src/pages/settings/DataSettings/SiyuanSettings.tsx index 3ba6673eea..2681f13053 100644 --- a/src/renderer/src/pages/settings/DataSettings/SiyuanSettings.tsx +++ b/src/renderer/src/pages/settings/DataSettings/SiyuanSettings.tsx @@ -4,7 +4,7 @@ import { useTheme } from '@renderer/context/ThemeProvider' import { useMinappPopup } from '@renderer/hooks/useMinappPopup' import { RootState, useAppDispatch } from '@renderer/store' import { setSiyuanApiUrl, setSiyuanBoxId, setSiyuanRootPath, setSiyuanToken } from '@renderer/store/settings' -import { Button, Tooltip } from 'antd' +import { Button, Space, Tooltip } from 'antd' import Input from 'antd/es/input/Input' import { FC } from 'react' import { useTranslation } from 'react-i18next' @@ -108,14 +108,15 @@ const SiyuanSettings: FC = () => { - - + + + + diff --git a/src/renderer/src/pages/settings/DataSettings/WebDavSettings.tsx b/src/renderer/src/pages/settings/DataSettings/WebDavSettings.tsx index 8e2a7e5aa2..54db33f024 100644 --- a/src/renderer/src/pages/settings/DataSettings/WebDavSettings.tsx +++ b/src/renderer/src/pages/settings/DataSettings/WebDavSettings.tsx @@ -1,5 +1,6 @@ import { FolderOpenOutlined, SaveOutlined, SyncOutlined, WarningOutlined } from '@ant-design/icons' import { HStack } from '@renderer/components/Layout' +import Selector from '@renderer/components/Selector' import { WebdavBackupManager } from '@renderer/components/WebdavBackupManager' import { useWebdavBackupModal, WebdavBackupModal } from '@renderer/components/WebdavModals' import { useTheme } from '@renderer/context/ThemeProvider' @@ -16,7 +17,7 @@ import { setWebdavSyncInterval as _setWebdavSyncInterval, setWebdavUser as _setWebdavUser } from '@renderer/store/settings' -import { Button, Input, Select, Switch, Tooltip } from 'antd' +import { Button, Input, Switch, Tooltip } from 'antd' import dayjs from 'dayjs' import { FC, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -173,31 +174,43 @@ const WebDavSettings: FC = () => { {t('settings.data.webdav.autoSync')} - + {t('settings.data.webdav.maxBackups')} - + diff --git a/src/renderer/src/pages/settings/DataSettings/YuqueSettings.tsx b/src/renderer/src/pages/settings/DataSettings/YuqueSettings.tsx index 72a629e55b..60a8d6ef7c 100644 --- a/src/renderer/src/pages/settings/DataSettings/YuqueSettings.tsx +++ b/src/renderer/src/pages/settings/DataSettings/YuqueSettings.tsx @@ -4,7 +4,7 @@ import { useTheme } from '@renderer/context/ThemeProvider' import { useMinappPopup } from '@renderer/hooks/useMinappPopup' import { RootState, useAppDispatch } from '@renderer/store' import { setYuqueRepoId, setYuqueToken, setYuqueUrl } from '@renderer/store/settings' -import { Button, Tooltip } from 'antd' +import { Button, Space, Tooltip } from 'antd' import Input from 'antd/es/input/Input' import { FC } from 'react' import { useTranslation } from 'react-i18next' @@ -100,14 +100,15 @@ const YuqueSettings: FC = () => { - - + + + + diff --git a/src/renderer/src/pages/settings/DisplaySettings/DisplaySettings.tsx b/src/renderer/src/pages/settings/DisplaySettings/DisplaySettings.tsx index 48c8dcbe92..56d09bd8dc 100644 --- a/src/renderer/src/pages/settings/DisplaySettings/DisplaySettings.tsx +++ b/src/renderer/src/pages/settings/DisplaySettings/DisplaySettings.tsx @@ -196,7 +196,7 @@ const DisplaySettings: FC = () => { value={userTheme.colorPrimary} onChange={(color) => handleColorPrimaryChange(color.toHexString())} showText - style={{ width: '110px' }} + size="small" presets={[ { label: 'Presets', @@ -222,13 +222,15 @@ const DisplaySettings: FC = () => { {t('settings.zoom.title')} - + {emoji && ( { justifyContent: 'space-between' }}> {t('settings.assistant.model_params')} - @@ -156,7 +156,7 @@ const AssistantSettings: FC = () => { - + { step={0.01} /> - + { - + { step={0.01} /> - + @@ -207,7 +207,7 @@ const AssistantSettings: FC = () => { - + { step={1} /> - + { /> - + @@ -255,7 +255,7 @@ const AssistantSettings: FC = () => { onUpdateAssistantSettings({ enableMaxTokens: enabled }) }} /> - + {enableMaxTokens && ( @@ -307,7 +307,7 @@ const PopupContainer: React.FC = ({ resolve }) => { afterClose={onClose} transitionName="animation-move-down" centered - width={800} + width={500} footer={null}> diff --git a/src/renderer/src/pages/settings/ProviderSettings/AddModelPopup.tsx b/src/renderer/src/pages/settings/ProviderSettings/AddModelPopup.tsx index cf79088ac4..1ff9517f9b 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/AddModelPopup.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/AddModelPopup.tsx @@ -121,7 +121,7 @@ const PopupContainer: React.FC = ({ title, provider, resolve }) => { tooltip={t('settings.models.add.group_name.tooltip')}> - + {showMoreSettings && ( -
- - {t('models.type.select')} +
+ + {t('models.type.select')}: {(() => { const defaultTypes = [ ...(isVisionModel(model) ? ['vision'] : []), @@ -235,6 +238,7 @@ const ModelEditContent: FC = ({ model, onUpdateModel, ope } }} dropdownMatchSelectWidth={false} + suffixIcon={} /> @@ -281,32 +285,9 @@ const ModelEditContent: FC = ({ model, onUpdateModel, ope } const TypeTitle = styled.div` - margin-top: 16px; - margin-bottom: 12px; + margin: 12px 0; font-size: 14px; font-weight: 600; ` -const ExpandIcon = styled.div` - font-size: 12px; - color: var(--color-text-3); -` - -const MoreSettingsRow = styled.div` - display: flex; - align-items: center; - gap: 8px; - color: var(--color-text-3); - cursor: pointer; - padding: 4px 8px; - border-radius: 4px; - max-width: 150px; - overflow: hidden; - text-overflow: ellipsis; - - &:hover { - background-color: var(--color-background-soft); - } -` - export default ModelEditContent diff --git a/src/renderer/src/pages/settings/WebSearchSettings/AddSubscribePopup.tsx b/src/renderer/src/pages/settings/WebSearchSettings/AddSubscribePopup.tsx index 12f5e29925..7577ed8c07 100644 --- a/src/renderer/src/pages/settings/WebSearchSettings/AddSubscribePopup.tsx +++ b/src/renderer/src/pages/settings/WebSearchSettings/AddSubscribePopup.tsx @@ -1,5 +1,5 @@ import { TopView } from '@renderer/components/TopView' -import { Button, Form, FormProps, Input, Modal } from 'antd' +import { Button, Flex, Form, FormProps, Input, Modal } from 'antd' import { useState } from 'react' import { useTranslation } from 'react-i18next' @@ -66,7 +66,7 @@ const PopupContainer: React.FC = ({ title, resolve }) => { centered>
= ({ title, resolve }) => { - + - +
) diff --git a/src/renderer/src/pages/settings/WebSearchSettings/index.tsx b/src/renderer/src/pages/settings/WebSearchSettings/index.tsx index e00eb785e7..6fab13d388 100644 --- a/src/renderer/src/pages/settings/WebSearchSettings/index.tsx +++ b/src/renderer/src/pages/settings/WebSearchSettings/index.tsx @@ -1,8 +1,8 @@ +import Selector from '@renderer/components/Selector' import { useTheme } from '@renderer/context/ThemeProvider' import { useDefaultWebSearchProvider, useWebSearchProviders } from '@renderer/hooks/useWebSearchProviders' import { WebSearchProvider } from '@renderer/types' import { hasObjectKey } from '@renderer/utils' -import { Select } from 'antd' import { FC, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -37,9 +37,9 @@ const WebSearchSettings: FC = () => { {t('settings.websearch.search_provider')}
- ) }))} + suffixIcon={} /> )} @@ -452,6 +455,7 @@ const TranslatePage: FC = () => { ) }))} + suffixIcon={} /> ) } @@ -551,6 +555,7 @@ const TranslatePage: FC = () => { ) })) ]} + suffixIcon={} />
+
+ + + + {t('models.rerank_model')} + } + /> + + + + {compressionConfig?.method === 'cutoff' && } + {compressionConfig?.method === 'rag' && } + + ) +} + +export default CompressionSettings diff --git a/src/renderer/src/pages/settings/WebSearchSettings/index.tsx b/src/renderer/src/pages/settings/WebSearchSettings/index.tsx index 6fab13d388..ecf2b8375b 100644 --- a/src/renderer/src/pages/settings/WebSearchSettings/index.tsx +++ b/src/renderer/src/pages/settings/WebSearchSettings/index.tsx @@ -9,6 +9,7 @@ import { useTranslation } from 'react-i18next' import { SettingContainer, SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '..' import BasicSettings from './BasicSettings' import BlacklistSettings from './BlacklistSettings' +import CompressionSettings from './CompressionSettings' import WebSearchProviderSetting from './WebSearchProviderSetting' const WebSearchSettings: FC = () => { @@ -56,6 +57,7 @@ const WebSearchSettings: FC = () => { )} + ) diff --git a/src/renderer/src/providers/WebSearchProvider/BochaProvider.ts b/src/renderer/src/providers/WebSearchProvider/BochaProvider.ts index 6d2bc2401e..1a3d53d87c 100644 --- a/src/renderer/src/providers/WebSearchProvider/BochaProvider.ts +++ b/src/renderer/src/providers/WebSearchProvider/BochaProvider.ts @@ -26,15 +26,13 @@ export default class BochaProvider extends BaseWebSearchProvider { Authorization: `Bearer ${this.apiKey}` } - const contentLimit = websearch.contentLimit - const params: BochaSearchParams = { query, count: websearch.maxResults, exclude: websearch.excludeDomains.join(','), freshness: websearch.searchWithTime ? 'oneDay' : 'noLimit', - summary: false, - page: contentLimit ? Math.ceil(contentLimit / websearch.maxResults) : 1 + summary: true, + page: 1 } const response = await fetch(`${this.apiHost}/v1/web-search`, { @@ -58,7 +56,8 @@ export default class BochaProvider extends BaseWebSearchProvider { query: resp.data.queryContext.originalQuery, results: resp.data.webPages.value.map((result) => ({ title: result.name, - content: result.snippet, + // 优先使用 summary(更详细),如果没有则使用 snippet + content: result.summary || result.snippet || '', url: result.url })) } diff --git a/src/renderer/src/providers/WebSearchProvider/ExaProvider.ts b/src/renderer/src/providers/WebSearchProvider/ExaProvider.ts index 8f65449b05..7aee19609f 100644 --- a/src/renderer/src/providers/WebSearchProvider/ExaProvider.ts +++ b/src/renderer/src/providers/WebSearchProvider/ExaProvider.ts @@ -35,14 +35,9 @@ export default class ExaProvider extends BaseWebSearchProvider { return { query: response.autopromptString, results: response.results.slice(0, websearch.maxResults).map((result) => { - let content = result.text || '' - if (websearch.contentLimit && content.length > websearch.contentLimit) { - content = content.slice(0, websearch.contentLimit) + '...' - } - return { title: result.title || 'No title', - content: content, + content: result.text || '', url: result.url || '' } }) diff --git a/src/renderer/src/providers/WebSearchProvider/LocalSearchProvider.ts b/src/renderer/src/providers/WebSearchProvider/LocalSearchProvider.ts index 8f171dd3e5..8a09b76016 100644 --- a/src/renderer/src/providers/WebSearchProvider/LocalSearchProvider.ts +++ b/src/renderer/src/providers/WebSearchProvider/LocalSearchProvider.ts @@ -55,11 +55,7 @@ export default class LocalSearchProvider extends BaseWebSearchProvider { // Fetch content for each URL concurrently const fetchPromises = validItems.map(async (item) => { // Logger.log(`Fetching content for ${item.url}...`) - const result = await fetchWebContent(item.url, 'markdown', this.provider.usingBrowser, httpOptions) - if (websearch.contentLimit && result.content.length > websearch.contentLimit) { - result.content = result.content.slice(0, websearch.contentLimit) + '...' - } - return result + return await fetchWebContent(item.url, 'markdown', this.provider.usingBrowser, httpOptions) }) // Wait for all fetches to complete diff --git a/src/renderer/src/providers/WebSearchProvider/SearxngProvider.ts b/src/renderer/src/providers/WebSearchProvider/SearxngProvider.ts index 926da8a248..82b95142f6 100644 --- a/src/renderer/src/providers/WebSearchProvider/SearxngProvider.ts +++ b/src/renderer/src/providers/WebSearchProvider/SearxngProvider.ts @@ -122,11 +122,7 @@ export default class SearxngProvider extends BaseWebSearchProvider { // Fetch content for each URL concurrently const fetchPromises = validItems.map(async (item) => { // Logger.log(`Fetching content for ${item.url}...`) - const result = await fetchWebContent(item.url, 'markdown', this.provider.usingBrowser) - if (websearch.contentLimit && result.content.length > websearch.contentLimit) { - result.content = result.content.slice(0, websearch.contentLimit) + '...' - } - return result + return await fetchWebContent(item.url, 'markdown', this.provider.usingBrowser) }) // Wait for all fetches to complete diff --git a/src/renderer/src/providers/WebSearchProvider/TavilyProvider.ts b/src/renderer/src/providers/WebSearchProvider/TavilyProvider.ts index e38b2661d9..225bce308f 100644 --- a/src/renderer/src/providers/WebSearchProvider/TavilyProvider.ts +++ b/src/renderer/src/providers/WebSearchProvider/TavilyProvider.ts @@ -31,14 +31,9 @@ export default class TavilyProvider extends BaseWebSearchProvider { return { query: result.query, results: result.results.slice(0, websearch.maxResults).map((result) => { - let content = result.content || '' - if (websearch.contentLimit && content.length > websearch.contentLimit) { - content = content.slice(0, websearch.contentLimit) + '...' - } - return { title: result.title || 'No title', - content: content, + content: result.content || '', url: result.url || '' } }) diff --git a/src/renderer/src/services/ApiService.ts b/src/renderer/src/services/ApiService.ts index fb2c073109..4704a8bfd3 100644 --- a/src/renderer/src/services/ApiService.ts +++ b/src/renderer/src/services/ApiService.ts @@ -157,8 +157,13 @@ async function fetchExternalTool( try { // Use the consolidated processWebsearch function WebSearchService.createAbortSignal(lastUserMessage.id) + const webSearchResponse = await WebSearchService.processWebsearch( + webSearchProvider!, + extractResults, + lastUserMessage.id + ) return { - results: await WebSearchService.processWebsearch(webSearchProvider!, extractResults), + results: webSearchResponse, source: WebSearchSource.WEBSEARCH } } catch (error) { diff --git a/src/renderer/src/services/KnowledgeService.ts b/src/renderer/src/services/KnowledgeService.ts index da7b939161..707e4df0b8 100644 --- a/src/renderer/src/services/KnowledgeService.ts +++ b/src/renderer/src/services/KnowledgeService.ts @@ -130,7 +130,7 @@ export const searchKnowledgeBase = async ( ) } catch (error) { Logger.error(`Error searching knowledge base ${base.name}:`, error) - return [] + throw error } } diff --git a/src/renderer/src/services/WebSearchService.ts b/src/renderer/src/services/WebSearchService.ts index cf773a7512..efb726a0aa 100644 --- a/src/renderer/src/services/WebSearchService.ts +++ b/src/renderer/src/services/WebSearchService.ts @@ -1,13 +1,38 @@ +import { DEFAULT_WEBSEARCH_RAG_DOCUMENT_COUNT } from '@renderer/config/constant' import Logger from '@renderer/config/logger' +import i18n from '@renderer/i18n' import WebSearchEngineProvider from '@renderer/providers/WebSearchProvider' import store from '@renderer/store' -import { WebSearchState } from '@renderer/store/websearch' -import { WebSearchProvider, WebSearchProviderResponse } from '@renderer/types' -import { hasObjectKey } from '@renderer/utils' +import { setWebSearchStatus } from '@renderer/store/runtime' +import { CompressionConfig, WebSearchState } from '@renderer/store/websearch' +import { + KnowledgeBase, + KnowledgeItem, + KnowledgeReference, + WebSearchProvider, + WebSearchProviderResponse, + WebSearchProviderResult, + WebSearchStatus +} from '@renderer/types' +import { hasObjectKey, uuid } from '@renderer/utils' import { addAbortController } from '@renderer/utils/abortController' +import { formatErrorMessage } from '@renderer/utils/error' import { ExtractResults } from '@renderer/utils/extract' import { fetchWebContents } from '@renderer/utils/fetch' +import { consolidateReferencesByUrl, selectReferences } from '@renderer/utils/websearch' import dayjs from 'dayjs' +import { LRUCache } from 'lru-cache' + +import { getKnowledgeBaseParams } from './KnowledgeService' +import { getKnowledgeSourceUrl, searchKnowledgeBase } from './KnowledgeService' + +interface RequestState { + signal: AbortSignal | null + searchBase?: KnowledgeBase + isPaused: boolean + createdAt: number +} + /** * 提供网络搜索相关功能的服务类 */ @@ -19,12 +44,47 @@ class WebSearchService { isPaused = false - createAbortSignal(key: string) { + // 管理不同请求的状态 + private requestStates = new LRUCache({ + max: 5, // 最多5个并发请求 + ttl: 1000 * 60 * 2, // 2分钟过期 + dispose: (requestState: RequestState, requestId: string) => { + if (!requestState.searchBase) return + window.api.knowledgeBase + .delete(requestState.searchBase.id) + .catch((error) => Logger.warn(`[WebSearchService] Failed to cleanup search base for ${requestId}:`, error)) + } + }) + + /** + * 获取或创建单个请求的状态 + * @param requestId 请求 ID(通常是消息 ID) + */ + private getRequestState(requestId: string): RequestState { + let state = this.requestStates.get(requestId) + if (!state) { + state = { + signal: null, + isPaused: false, + createdAt: Date.now() + } + this.requestStates.set(requestId, state) + } + return state + } + + createAbortSignal(requestId: string) { const controller = new AbortController() - this.signal = controller.signal - addAbortController(key, () => { - this.isPaused = true + this.signal = controller.signal // 保持向后兼容 + + const state = this.getRequestState(requestId) + state.signal = controller.signal + + addAbortController(requestId, () => { + this.isPaused = true // 保持向后兼容 + state.isPaused = true this.signal = null + this.requestStates.delete(requestId) controller.abort() }) return controller @@ -137,45 +197,338 @@ class WebSearchService { } } + /** + * 设置网络搜索状态 + */ + private async setWebSearchStatus(requestId: string, status: WebSearchStatus, delayMs?: number) { + store.dispatch(setWebSearchStatus({ requestId, status })) + if (delayMs) { + await new Promise((resolve) => setTimeout(resolve, delayMs)) + } + } + + /** + * 确保搜索压缩知识库存在并配置正确 + */ + private async ensureSearchBase( + config: CompressionConfig, + documentCount: number, + requestId: string + ): Promise { + const baseId = `websearch-compression-${requestId}` + const state = this.getRequestState(requestId) + + // 如果已存在且配置未变,直接复用 + if (state.searchBase && this.isConfigMatched(state.searchBase, config)) { + return state.searchBase + } + + // 清理旧的知识库 + if (state.searchBase) { + await window.api.knowledgeBase.delete(state.searchBase.id) + } + + if (!config.embeddingModel) { + throw new Error('Embedding model is required for RAG compression') + } + + // 创建新的知识库 + state.searchBase = { + id: baseId, + name: `WebSearch-RAG-${requestId}`, + model: config.embeddingModel, + rerankModel: config.rerankModel, + dimensions: config.embeddingDimensions, + documentCount, + items: [], + created_at: Date.now(), + updated_at: Date.now(), + version: 1 + } + + // 更新LRU cache + this.requestStates.set(requestId, state) + + // 创建知识库 + const baseParams = getKnowledgeBaseParams(state.searchBase) + await window.api.knowledgeBase.create(baseParams) + + return state.searchBase + } + + /** + * 检查配置是否匹配 + */ + private isConfigMatched(base: KnowledgeBase, config: CompressionConfig): boolean { + return ( + base.model.id === config.embeddingModel?.id && + base.rerankModel?.id === config.rerankModel?.id && + base.dimensions === config.embeddingDimensions + ) + } + + /** + * 对搜索知识库执行多问题查询并按分数排序 + * @param questions 问题列表 + * @param searchBase 搜索知识库 + * @returns 排序后的知识引用列表 + */ + private async querySearchBase(questions: string[], searchBase: KnowledgeBase): Promise { + // 1. 单独搜索每个问题 + const searchPromises = questions.map((question) => searchKnowledgeBase(question, searchBase)) + const allResults = await Promise.all(searchPromises) + + // 2. 合并所有结果并按分数排序 + const flatResults = allResults.flat().sort((a, b) => b.score - a.score) + + // 3. 去重,保留最高分的重复内容 + const seen = new Set() + const uniqueResults = flatResults.filter((item) => { + if (seen.has(item.pageContent)) { + return false + } + seen.add(item.pageContent) + return true + }) + + // 4. 转换为引用格式 + return await Promise.all( + uniqueResults.map(async (result, index) => ({ + id: index + 1, + content: result.pageContent, + sourceUrl: await getKnowledgeSourceUrl(result), + type: 'url' as const + })) + ) + } + + /** + * 使用RAG压缩搜索结果。 + * - 一次性将所有搜索结果添加到知识库 + * - 从知识库中 retrieve 相关结果 + * - 根据 sourceUrl 映射回原始搜索结果 + * + * @param questions 问题列表 + * @param rawResults 原始搜索结果 + * @param config 压缩配置 + * @param requestId 请求ID + * @returns 压缩后的搜索结果 + */ + private async compressWithSearchBase( + questions: string[], + rawResults: WebSearchProviderResult[], + config: CompressionConfig, + requestId: string + ): Promise { + // 根据搜索次数计算所需的文档数量 + const totalDocumentCount = + Math.max(0, rawResults.length) * (config.documentCount ?? DEFAULT_WEBSEARCH_RAG_DOCUMENT_COUNT) + + const searchBase = await this.ensureSearchBase(config, totalDocumentCount, requestId) + + // 1. 清空知识库 + await window.api.knowledgeBase.reset(getKnowledgeBaseParams(searchBase)) + + // 2. 一次性添加所有搜索结果到知识库 + const addPromises = rawResults.map(async (result) => { + const item: KnowledgeItem & { sourceUrl?: string } = { + id: uuid(), + type: 'note', + content: result.content, + sourceUrl: result.url, // 设置 sourceUrl 用于映射 + created_at: Date.now(), + updated_at: Date.now(), + processingStatus: 'pending' + } + + await window.api.knowledgeBase.add({ + base: getKnowledgeBaseParams(searchBase), + item + }) + }) + + // 等待所有结果添加完成 + await Promise.all(addPromises) + + // 3. 对知识库执行多问题搜索获取压缩结果 + const references = await this.querySearchBase(questions, searchBase) + + // 4. 使用 Round Robin 策略选择引用 + const selectedReferences = selectReferences(rawResults, references, totalDocumentCount) + + Logger.log('[WebSearchService] With RAG, the number of search results:', { + raw: rawResults.length, + retrieved: references.length, + selected: selectedReferences.length + }) + + // 5. 按 sourceUrl 分组并合并同源片段 + return consolidateReferencesByUrl(rawResults, selectedReferences) + } + + /** + * 使用截断方式压缩搜索结果,可以选择单位 char 或 token。 + * + * @param rawResults 原始搜索结果 + * @param config 压缩配置 + * @returns 截断后的搜索结果 + */ + private async compressWithCutoff( + rawResults: WebSearchProviderResult[], + config: CompressionConfig + ): Promise { + if (!config.cutoffLimit) { + Logger.warn('[WebSearchService] Cutoff limit is not set, skipping compression') + return rawResults + } + + const perResultLimit = Math.max(1, Math.floor(config.cutoffLimit / rawResults.length)) + + // 动态导入 tokenx + const { sliceByTokens } = await import('tokenx') + + return rawResults.map((result) => { + if (config.cutoffUnit === 'token') { + // 使用 token 截断 + const slicedContent = sliceByTokens(result.content, 0, perResultLimit) + return { + ...result, + content: slicedContent.length < result.content.length ? slicedContent + '...' : slicedContent + } + } else { + // 使用字符截断(默认行为) + return { + ...result, + content: + result.content.length > perResultLimit ? result.content.slice(0, perResultLimit) + '...' : result.content + } + } + }) + } + + /** + * 处理网络搜索请求的核心方法,处理过程中会设置运行时状态供 UI 使用。 + * + * 该方法执行以下步骤: + * - 验证输入参数并处理边界情况 + * - 处理特殊的summarize请求 + * - 并行执行多个搜索查询 + * - 聚合搜索结果并处理失败情况 + * - 根据配置应用结果压缩(RAG或截断) + * - 返回最终的搜索响应 + * + * @param webSearchProvider - 要使用的网络搜索提供商 + * @param extractResults - 包含搜索问题和链接的提取结果对象 + * @param requestId - 唯一的请求标识符,用于状态跟踪和资源管理 + * + * @returns 包含搜索结果的响应对象 + */ public async processWebsearch( webSearchProvider: WebSearchProvider, - extractResults: ExtractResults + extractResults: ExtractResults, + requestId: string ): Promise { + // 重置状态 + await this.setWebSearchStatus(requestId, { phase: 'default' }) + // 检查 websearch 和 question 是否有效 if (!extractResults.websearch?.question || extractResults.websearch.question.length === 0) { Logger.log('[processWebsearch] No valid question found in extractResults.websearch') return { results: [] } } + // 使用请求特定的signal,如果没有则回退到全局signal + const signal = this.getRequestState(requestId).signal || this.signal + const questions = extractResults.websearch.question const links = extractResults.websearch.links - const firstQuestion = questions[0] - if (firstQuestion === 'summarize' && links && links.length > 0) { - const contents = await fetchWebContents(links, undefined, undefined, { - signal: this.signal - }) - return { - query: 'summaries', - results: contents - } - } - const searchPromises = questions.map((q) => this.search(webSearchProvider, q, { signal: this.signal })) - const searchResults = await Promise.allSettled(searchPromises) - const aggregatedResults: any[] = [] + // 处理 summarize + if (questions[0] === 'summarize' && links && links.length > 0) { + const contents = await fetchWebContents(links, undefined, undefined, { signal }) + return { query: 'summaries', results: contents } + } + + const searchPromises = questions.map((q) => this.search(webSearchProvider, q, { signal })) + const searchResults = await Promise.allSettled(searchPromises) + + // 统计成功完成的搜索数量 + const successfulSearchCount = searchResults.filter((result) => result.status === 'fulfilled').length + if (successfulSearchCount > 1) { + await this.setWebSearchStatus( + requestId, + { + phase: 'fetch_complete', + countAfter: successfulSearchCount + }, + 1000 + ) + } + + let finalResults: WebSearchProviderResult[] = [] searchResults.forEach((result) => { if (result.status === 'fulfilled') { if (result.value.results) { - aggregatedResults.push(...result.value.results) + finalResults.push(...result.value.results) } } if (result.status === 'rejected') { throw result.reason } }) + + // 如果没有搜索结果,直接返回空结果 + if (finalResults.length === 0) { + await this.setWebSearchStatus(requestId, { phase: 'default' }) + return { + query: questions.join(' | '), + results: [] + } + } + + const { compressionConfig } = this.getWebSearchState() + + // RAG压缩处理 + if (compressionConfig?.method === 'rag' && requestId) { + await this.setWebSearchStatus(requestId, { phase: 'rag' }, 500) + + const originalCount = finalResults.length + + try { + finalResults = await this.compressWithSearchBase(questions, finalResults, compressionConfig, requestId) + await this.setWebSearchStatus( + requestId, + { + phase: 'rag_complete', + countBefore: originalCount, + countAfter: finalResults.length + }, + 1000 + ) + } catch (error) { + Logger.warn('[WebSearchService] RAG compression failed, will return empty results:', error) + window.message.error({ + key: 'websearch-rag-failed', + duration: 10, + content: `${i18n.t('settings.websearch.compression.error.rag_failed')}: ${formatErrorMessage(error)}` + }) + + finalResults = [] + await this.setWebSearchStatus(requestId, { phase: 'rag_failed' }, 1000) + } + } + // 截断压缩处理 + else if (compressionConfig?.method === 'cutoff' && compressionConfig.cutoffLimit) { + await this.setWebSearchStatus(requestId, { phase: 'cutoff' }, 500) + finalResults = await this.compressWithCutoff(finalResults, compressionConfig) + } + + // 重置状态 + await this.setWebSearchStatus(requestId, { phase: 'default' }) + return { query: questions.join(' | '), - results: aggregatedResults + results: finalResults } } } diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index ae5c52a9fb..4a26bf96c0 100644 --- a/src/renderer/src/store/index.ts +++ b/src/renderer/src/store/index.ts @@ -50,7 +50,7 @@ const persistedReducer = persistReducer( { key: 'cherry-studio', storage, - version: 115, + version: 116, blacklist: ['runtime', 'messages', 'messageBlocks'], migrate }, diff --git a/src/renderer/src/store/migrate.ts b/src/renderer/src/store/migrate.ts index 0e9385de0c..c8a132180f 100644 --- a/src/renderer/src/store/migrate.ts +++ b/src/renderer/src/store/migrate.ts @@ -1631,6 +1631,31 @@ const migrateConfig = { if (state.settings) { state.settings.upgradeChannel = UpgradeChannel.LATEST } + return state + } catch (error) { + return state + } + }, + '116': (state: RootState) => { + try { + if (state.websearch) { + // migrate contentLimit to cutoffLimit + // @ts-ignore eslint-disable-next-line + if (state.websearch.contentLimit) { + state.websearch.compressionConfig = { + method: 'cutoff', + cutoffUnit: 'char', + // @ts-ignore eslint-disable-next-line + cutoffLimit: state.websearch.contentLimit + } + } else { + state.websearch.compressionConfig = { method: 'none', cutoffUnit: 'char' } + } + + // @ts-ignore eslint-disable-next-line + delete state.websearch.contentLimit + } + return state } catch (error) { return state diff --git a/src/renderer/src/store/runtime.ts b/src/renderer/src/store/runtime.ts index 5c84ab8000..d1e3752d10 100644 --- a/src/renderer/src/store/runtime.ts +++ b/src/renderer/src/store/runtime.ts @@ -1,6 +1,6 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit' import { AppLogo, UserAvatar } from '@renderer/config/env' -import type { MinAppType, Topic } from '@renderer/types' +import type { MinAppType, Topic, WebSearchStatus } from '@renderer/types' import type { UpdateInfo } from 'builder-util-runtime' export interface ChatState { @@ -13,6 +13,10 @@ export interface ChatState { newlyRenamedTopics: string[] } +export interface WebSearchState { + activeSearches: Record +} + export interface UpdateState { info: UpdateInfo | null checking: boolean @@ -39,6 +43,7 @@ export interface RuntimeState { update: UpdateState export: ExportState chat: ChatState + websearch: WebSearchState } export interface ExportState { @@ -72,6 +77,9 @@ const initialState: RuntimeState = { activeTopic: null, renamingTopics: [], newlyRenamedTopics: [] + }, + websearch: { + activeSearches: {} } } @@ -130,6 +138,17 @@ const runtimeSlice = createSlice({ }, setNewlyRenamedTopics: (state, action: PayloadAction) => { state.chat.newlyRenamedTopics = action.payload + }, + // WebSearch related actions + setActiveSearches: (state, action: PayloadAction>) => { + state.websearch.activeSearches = action.payload + }, + setWebSearchStatus: (state, action: PayloadAction<{ requestId: string; status: WebSearchStatus }>) => { + const { requestId, status } = action.payload + if (status.phase === 'default') { + delete state.websearch.activeSearches[requestId] + } + state.websearch.activeSearches[requestId] = status } } }) @@ -151,7 +170,10 @@ export const { setSelectedMessageIds, setActiveTopic, setRenamingTopics, - setNewlyRenamedTopics + setNewlyRenamedTopics, + // WebSearch related actions + setActiveSearches, + setWebSearchStatus } = runtimeSlice.actions export default runtimeSlice.reducer diff --git a/src/renderer/src/store/websearch.ts b/src/renderer/src/store/websearch.ts index 4f223ccbf1..ad6172065a 100644 --- a/src/renderer/src/store/websearch.ts +++ b/src/renderer/src/store/websearch.ts @@ -1,5 +1,5 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit' -import type { WebSearchProvider } from '@renderer/types' +import type { Model, WebSearchProvider } from '@renderer/types' export interface SubscribeSource { key: number url: string @@ -7,6 +7,16 @@ export interface SubscribeSource { blacklist?: string[] // 存储从该订阅源获取的黑名单 } +export interface CompressionConfig { + method: 'none' | 'cutoff' | 'rag' + cutoffLimit?: number + cutoffUnit?: 'char' | 'token' + embeddingModel?: Model + embeddingDimensions?: number // undefined表示自动获取 + documentCount?: number // 每个搜索结果的文档数量(只是预期值) + rerankModel?: Model +} + export interface WebSearchState { // 默认搜索提供商的ID /** @deprecated 支持在快捷菜单中自选搜索供应商,所以这个不再适用 */ @@ -24,12 +34,13 @@ export interface WebSearchState { // 是否覆盖服务商搜索 /** @deprecated 支持在快捷菜单中自选搜索供应商,所以这个不再适用 */ overwrite: boolean - contentLimit?: number + // 搜索结果压缩 + compressionConfig?: CompressionConfig // 具体供应商的配置 providerConfig: Record } -const initialState: WebSearchState = { +export const initialState: WebSearchState = { defaultProvider: 'local-bing', providers: [ { @@ -78,6 +89,10 @@ const initialState: WebSearchState = { excludeDomains: [], subscribeSources: [], overwrite: false, + compressionConfig: { + method: 'none', + cutoffUnit: 'char' + }, providerConfig: {} } @@ -150,8 +165,14 @@ const websearchSlice = createSlice({ state.providers.push(action.payload) } }, - setContentLimit: (state, action: PayloadAction) => { - state.contentLimit = action.payload + setCompressionConfig: (state, action: PayloadAction) => { + state.compressionConfig = action.payload + }, + updateCompressionConfig: (state, action: PayloadAction>) => { + state.compressionConfig = { + ...state.compressionConfig, + ...action.payload + } as CompressionConfig }, setProviderConfig: (state, action: PayloadAction>) => { state.providerConfig = action.payload @@ -176,7 +197,8 @@ export const { setSubscribeSources, setOverwrite, addWebSearchProvider, - setContentLimit, + setCompressionConfig, + updateCompressionConfig, setProviderConfig, updateProviderConfig } = websearchSlice.actions diff --git a/src/renderer/src/types/index.ts b/src/renderer/src/types/index.ts index 340119fa31..3b4cc5cdc3 100644 --- a/src/renderer/src/types/index.ts +++ b/src/renderer/src/types/index.ts @@ -500,7 +500,6 @@ export type WebSearchProvider = { url?: string basicAuthUsername?: string basicAuthPassword?: string - contentLimit?: number usingBrowser?: boolean } @@ -542,6 +541,14 @@ export type WebSearchResponse = { source: WebSearchSource } +export type WebSearchPhase = 'default' | 'fetch_complete' | 'rag' | 'rag_complete' | 'rag_failed' | 'cutoff' + +export type WebSearchStatus = { + phase: WebSearchPhase + countBefore?: number + countAfter?: number +} + export type KnowledgeReference = { id: number content: string diff --git a/src/renderer/src/utils/__tests__/websearch.test.ts b/src/renderer/src/utils/__tests__/websearch.test.ts new file mode 100644 index 0000000000..2f807d111e --- /dev/null +++ b/src/renderer/src/utils/__tests__/websearch.test.ts @@ -0,0 +1,226 @@ +import { KnowledgeReference, WebSearchProviderResult } from '@renderer/types' +import { describe, expect, it } from 'vitest' + +import { consolidateReferencesByUrl, selectReferences } from '../websearch' + +describe('websearch', () => { + describe('consolidateReferencesByUrl', () => { + const createMockRawResult = (url: string, title: string): WebSearchProviderResult => ({ + title, + url, + content: `Original content for ${title}` + }) + + const createMockReference = (sourceUrl: string, content: string, id: number = 1): KnowledgeReference => ({ + id, + sourceUrl, + content, + type: 'url' + }) + + it('should consolidate single reference to matching raw result', () => { + // 基本功能:单个引用与原始结果匹配 + const rawResults = [createMockRawResult('https://example.com', 'Example Title')] + const references = [createMockReference('https://example.com', 'Retrieved content')] + + const result = consolidateReferencesByUrl(rawResults, references) + + expect(result).toHaveLength(1) + expect(result[0]).toEqual({ + title: 'Example Title', + url: 'https://example.com', + content: 'Retrieved content' + }) + }) + + it('should consolidate multiple references from same source URL', () => { + // 多个片段合并到同一个URL + const rawResults = [createMockRawResult('https://example.com', 'Example Title')] + const references = [ + createMockReference('https://example.com', 'First content', 1), + createMockReference('https://example.com', 'Second content', 2) + ] + + const result = consolidateReferencesByUrl(rawResults, references) + + expect(result).toHaveLength(1) + expect(result[0]).toEqual({ + title: 'Example Title', + url: 'https://example.com', + content: 'First content\n\n---\n\nSecond content' + }) + }) + + it('should consolidate references from multiple source URLs', () => { + // 多个不同URL的引用 + const rawResults = [ + createMockRawResult('https://example.com', 'Example Title'), + createMockRawResult('https://test.com', 'Test Title') + ] + const references = [ + createMockReference('https://example.com', 'Example content', 1), + createMockReference('https://test.com', 'Test content', 2) + ] + + const result = consolidateReferencesByUrl(rawResults, references) + + expect(result).toHaveLength(2) + // 结果顺序可能不确定,使用 toContainEqual + expect(result).toContainEqual({ + title: 'Example Title', + url: 'https://example.com', + content: 'Example content' + }) + expect(result).toContainEqual({ + title: 'Test Title', + url: 'https://test.com', + content: 'Test content' + }) + }) + + it('should use custom separator for multiple references', () => { + // 自定义分隔符 + const rawResults = [createMockRawResult('https://example.com', 'Example Title')] + const references = [ + createMockReference('https://example.com', 'First content', 1), + createMockReference('https://example.com', 'Second content', 2) + ] + + const result = consolidateReferencesByUrl(rawResults, references, ' | ') + + expect(result).toHaveLength(1) + expect(result[0].content).toBe('First content | Second content') + }) + + it('should ignore references with no matching raw result', () => { + // 无匹配的引用 + const rawResults = [createMockRawResult('https://example.com', 'Example Title')] + const references = [ + createMockReference('https://example.com', 'Matching content', 1), + createMockReference('https://nonexistent.com', 'Non-matching content', 2) + ] + + const result = consolidateReferencesByUrl(rawResults, references) + + expect(result).toHaveLength(1) + expect(result[0]).toEqual({ + title: 'Example Title', + url: 'https://example.com', + content: 'Matching content' + }) + }) + + it('should return empty array when no references match raw results', () => { + // 完全无匹配的情况 + const rawResults = [createMockRawResult('https://example.com', 'Example Title')] + const references = [createMockReference('https://nonexistent.com', 'Non-matching content', 1)] + + const result = consolidateReferencesByUrl(rawResults, references) + + expect(result).toHaveLength(0) + }) + + it('should handle empty inputs', () => { + // 边界条件:空输入 + expect(consolidateReferencesByUrl([], [])).toEqual([]) + + const rawResults = [createMockRawResult('https://example.com', 'Example Title')] + expect(consolidateReferencesByUrl(rawResults, [])).toEqual([]) + + const references = [createMockReference('https://example.com', 'Content', 1)] + expect(consolidateReferencesByUrl([], references)).toEqual([]) + }) + + it('should preserve original result metadata', () => { + // 验证原始结果的元数据保持不变 + const rawResults = [createMockRawResult('https://example.com', 'Complex Title with Special Characters & Symbols')] + const references = [createMockReference('https://example.com', 'New content', 1)] + + const result = consolidateReferencesByUrl(rawResults, references) + + expect(result[0].title).toBe('Complex Title with Special Characters & Symbols') + expect(result[0].url).toBe('https://example.com') + }) + }) + + describe('selectReferences', () => { + const createMockRawResult = (url: string, title: string): WebSearchProviderResult => ({ + title, + url, + content: `Original content for ${title}` + }) + + const createMockReference = (sourceUrl: string, content: string, id: number = 1): KnowledgeReference => ({ + id, + sourceUrl, + content, + type: 'url' + }) + + it('should select references using round robin strategy', () => { + const rawResults = [ + createMockRawResult('https://a.com', 'A'), + createMockRawResult('https://b.com', 'B'), + createMockRawResult('https://c.com', 'C') + ] + + const references = [ + createMockReference('https://a.com', 'A1', 1), + createMockReference('https://a.com', 'A2', 2), + createMockReference('https://b.com', 'B1', 3), + createMockReference('https://c.com', 'C1', 4), + createMockReference('https://c.com', 'C2', 5) + ] + + const result = selectReferences(rawResults, references, 4) + + expect(result).toHaveLength(4) + // 按照 rawResults 顺序轮询:A1, B1, C1, A2 + expect(result[0].content).toBe('A1') + expect(result[1].content).toBe('B1') + expect(result[2].content).toBe('C1') + expect(result[3].content).toBe('A2') + }) + + it('should handle maxRefs larger than available references', () => { + const rawResults = [createMockRawResult('https://a.com', 'A')] + const references = [createMockReference('https://a.com', 'A1', 1)] + + const result = selectReferences(rawResults, references, 10) + + expect(result).toHaveLength(1) + expect(result[0].content).toBe('A1') + }) + + it('should return empty array for edge cases', () => { + const rawResults = [createMockRawResult('https://a.com', 'A')] + const references = [createMockReference('https://a.com', 'A1', 1)] + + // maxRefs is 0 + expect(selectReferences(rawResults, references, 0)).toEqual([]) + + // empty references + expect(selectReferences(rawResults, [], 5)).toEqual([]) + + // no matching URLs + const nonMatchingRefs = [createMockReference('https://different.com', 'Content', 1)] + expect(selectReferences(rawResults, nonMatchingRefs, 5)).toEqual([]) + }) + + it('should preserve rawResults order in round robin', () => { + // rawResults 的顺序应该影响轮询顺序 + const rawResults = [ + createMockRawResult('https://z.com', 'Z'), // 应该第一个被选择 + createMockRawResult('https://a.com', 'A') // 应该第二个被选择 + ] + + const references = [createMockReference('https://a.com', 'A1', 1), createMockReference('https://z.com', 'Z1', 2)] + + const result = selectReferences(rawResults, references, 2) + + expect(result).toHaveLength(2) + expect(result[0].content).toBe('Z1') // Z 先被选择 + expect(result[1].content).toBe('A1') // A 后被选择 + }) + }) +}) diff --git a/src/renderer/src/utils/websearch.ts b/src/renderer/src/utils/websearch.ts new file mode 100644 index 0000000000..05f82861c8 --- /dev/null +++ b/src/renderer/src/utils/websearch.ts @@ -0,0 +1,116 @@ +import { KnowledgeReference, WebSearchProviderResult } from '@renderer/types' + +/** + * 将检索到的知识片段按源URL整合为搜索结果 + * + * 这个函数接收原始搜索结果和从知识库检索到的相关片段, + * 将同源的片段按URL分组并合并为最终的搜索结果。 + * + * @param rawResults 原始搜索结果,用于提供标题和URL信息 + * @param references 从知识库检索到的相关片段 + * @param separator 合并片段时使用的分隔符,默认为 '\n\n---\n\n' + * @returns 合并后的搜索结果数组 + */ +export function consolidateReferencesByUrl( + rawResults: WebSearchProviderResult[], + references: KnowledgeReference[], + separator: string = '\n\n---\n\n' +): WebSearchProviderResult[] { + // 创建URL到原始结果的映射,用于快速查找 + const urlToOriginalResult = new Map(rawResults.map((result) => [result.url, result])) + + // 使用 reduce 进行分组和内容收集 + const sourceGroups = references.reduce((groups, reference) => { + const originalResult = urlToOriginalResult.get(reference.sourceUrl) + if (!originalResult) return groups + + const existing = groups.get(reference.sourceUrl) + if (existing) { + // 如果已存在该URL的分组,直接添加内容 + existing.contents.push(reference.content) + } else { + // 创建新的分组 + groups.set(reference.sourceUrl, { + originalResult, + contents: [reference.content] + }) + } + return groups + }, new Map()) + + // 转换为最终结果 + return Array.from(sourceGroups.values(), (group) => ({ + title: group.originalResult.title, + url: group.originalResult.url, + content: group.contents.join(separator) + })) +} + +/** + * 使用 Round Robin 策略从引用中选择指定数量的项目 + * 按照原始搜索结果的顺序轮询选择,确保每个源都有机会被选中 + * + * @param rawResults 原始搜索结果,用于确定轮询顺序 + * @param references 所有可选的引用项目 + * @param maxRefs 最大选择数量 + * @returns 按 Round Robin 策略选择的引用数组 + */ +export function selectReferences( + rawResults: WebSearchProviderResult[], + references: KnowledgeReference[], + maxRefs: number +): KnowledgeReference[] { + if (maxRefs <= 0 || references.length === 0) { + return [] + } + + // 建立URL到索引的映射,用于确定轮询顺序 + const urlToIndex = new Map() + rawResults.forEach((result, index) => { + urlToIndex.set(result.url, index) + }) + + // 按sourceUrl分组references,每组内按原顺序保持(已按分数排序) + const groupsByUrl = new Map() + references.forEach((ref) => { + if (!groupsByUrl.has(ref.sourceUrl)) { + groupsByUrl.set(ref.sourceUrl, []) + } + groupsByUrl.get(ref.sourceUrl)!.push(ref) + }) + + // 获取有效的URL列表,按rawResults顺序排序 + const availableUrls = Array.from(groupsByUrl.keys()) + .filter((url) => urlToIndex.has(url)) + .sort((a, b) => urlToIndex.get(a)! - urlToIndex.get(b)!) + + if (availableUrls.length === 0) { + return [] + } + + // Round Robin 选择 + const selected: KnowledgeReference[] = [] + let roundIndex = 0 + + while (selected.length < maxRefs && availableUrls.length > 0) { + const currentUrl = availableUrls[roundIndex] + const group = groupsByUrl.get(currentUrl)! + + if (group.length > 0) { + selected.push(group.shift()!) + } + + // 如果当前组为空,从可用URL列表中移除 + if (group.length === 0) { + availableUrls.splice(roundIndex, 1) + // 调整索引,避免跳过下一个URL + if (roundIndex >= availableUrls.length) { + roundIndex = 0 + } + } else { + roundIndex = (roundIndex + 1) % availableUrls.length + } + } + + return selected +} diff --git a/yarn.lock b/yarn.lock index eefde56f9c..2386409f15 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5745,7 +5745,7 @@ __metadata: styled-components: "npm:^6.1.11" tar: "npm:^7.4.3" tiny-pinyin: "npm:^1.3.2" - tokenx: "npm:^0.4.1" + tokenx: "npm:^1.1.0" turndown: "npm:7.2.0" typescript: "npm:^5.6.2" uuid: "npm:^10.0.0" @@ -17588,10 +17588,10 @@ __metadata: languageName: node linkType: hard -"tokenx@npm:^0.4.1": - version: 0.4.1 - resolution: "tokenx@npm:0.4.1" - checksum: 10c0/377f4e3c31ff9dc57b5b6af0fb1ae821227dee5e1d87b92a3ab1a0ed25454f01185c709d73592002b0d3024de1c904c8f029c46ae1806677816e4659fb8c481e +"tokenx@npm:^1.1.0": + version: 1.1.0 + resolution: "tokenx@npm:1.1.0" + checksum: 10c0/8214bce58b48e130bcf4a27ac1bb5abf486c395310fb0c8f54e31656acacf97da533372afb9e8ac8f7736e6c3f29af86ea9623d4875f1399e66a5203b80609db languageName: node linkType: hard From 98b12fb8009ed1e84454d38455b14e2a3705073a Mon Sep 17 00:00:00 2001 From: Chen Tao <70054568+eeee0717@users.noreply.github.com> Date: Fri, 27 Jun 2025 18:07:17 +0800 Subject: [PATCH 072/111] fix: tei reranker (#7606) fix(tei) --- src/main/reranker/BaseReranker.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/reranker/BaseReranker.ts b/src/main/reranker/BaseReranker.ts index 9a40dfbdf8..83d241fe85 100644 --- a/src/main/reranker/BaseReranker.ts +++ b/src/main/reranker/BaseReranker.ts @@ -86,7 +86,7 @@ export default abstract class BaseReranker { return data.output.results } else if (provider === 'voyageai') { return data.data - } else if (provider === 'mis-tei') { + } else if (provider?.includes('tei')) { return data.map((item: any) => { return { index: item.index, From c7c1cf2552ea96cb8728e333cc12d5c1801e7a1a Mon Sep 17 00:00:00 2001 From: one Date: Fri, 27 Jun 2025 21:53:43 +0800 Subject: [PATCH 073/111] refactor: increase css editor height, fix EditMcpJsonPopup (#7535) * refactor: increase css editor height * fix: lint warnings * refactor: use vh for height * fix: editmcpjsonpopup editor unavailable after deleting all the code --- .../settings/DataSettings/DataSettings.tsx | 2 +- .../DisplaySettings/DisplaySettings.tsx | 4 +- .../settings/MCPSettings/EditMcpJsonPopup.tsx | 42 ++++++++++--------- 3 files changed, 26 insertions(+), 22 deletions(-) diff --git a/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx b/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx index 93a138f847..fece64a16a 100644 --- a/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx +++ b/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx @@ -546,7 +546,7 @@ const DataSettings: FC = () => { } handleDataMigration() - }, []) + }, [t]) const onSkipBackupFilesChange = (value: boolean) => { setSkipBackupFile(value) diff --git a/src/renderer/src/pages/settings/DisplaySettings/DisplaySettings.tsx b/src/renderer/src/pages/settings/DisplaySettings/DisplaySettings.tsx index 56d09bd8dc..27453ef1cd 100644 --- a/src/renderer/src/pages/settings/DisplaySettings/DisplaySettings.tsx +++ b/src/renderer/src/pages/settings/DisplaySettings/DisplaySettings.tsx @@ -315,9 +315,9 @@ const DisplaySettings: FC = () => { language="css" placeholder={t('settings.display.custom.css.placeholder')} onChange={(value) => dispatch(setCustomCss(value))} - height="350px" + height="60vh" options={{ - collapsible: true, + collapsible: false, wrappable: true, autocompletion: true, lineNumbers: true, diff --git a/src/renderer/src/pages/settings/MCPSettings/EditMcpJsonPopup.tsx b/src/renderer/src/pages/settings/MCPSettings/EditMcpJsonPopup.tsx index 77ad41fb54..0e505b3840 100644 --- a/src/renderer/src/pages/settings/MCPSettings/EditMcpJsonPopup.tsx +++ b/src/renderer/src/pages/settings/MCPSettings/EditMcpJsonPopup.tsx @@ -3,7 +3,7 @@ import { TopView } from '@renderer/components/TopView' import { useAppDispatch, useAppSelector } from '@renderer/store' import { setMCPServers } from '@renderer/store/mcp' import { MCPServer } from '@renderer/types' -import { Modal, Typography } from 'antd' +import { Modal, Spin, Typography } from 'antd' import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -16,12 +16,14 @@ const PopupContainer: React.FC = ({ resolve }) => { const [jsonConfig, setJsonConfig] = useState('') const [jsonSaving, setJsonSaving] = useState(false) const [jsonError, setJsonError] = useState('') + const [isLoading, setIsLoading] = useState(true) const mcpServers = useAppSelector((state) => state.mcp.servers) const dispatch = useAppDispatch() const { t } = useTranslation() useEffect(() => { + setIsLoading(true) try { const mcpServersObj: Record = {} @@ -40,6 +42,8 @@ const PopupContainer: React.FC = ({ resolve }) => { } catch (error) { console.error('Failed to format JSON:', error) setJsonError(t('settings.mcp.jsonFormatError')) + } finally { + setIsLoading(false) } }, [mcpServers, t]) @@ -118,24 +122,24 @@ const PopupContainer: React.FC = ({ resolve }) => { {jsonError ? {jsonError} : ''}
- {jsonConfig && ( -
- setJsonConfig(value)} - maxHeight="60vh" - options={{ - lint: true, - collapsible: true, - wrappable: true, - lineNumbers: true, - foldGutter: true, - highlightActiveLine: true, - keymap: true - }} - /> -
+ {isLoading ? ( + + ) : ( + setJsonConfig(value)} + height="60vh" + options={{ + lint: true, + collapsible: false, + wrappable: true, + lineNumbers: true, + foldGutter: true, + highlightActiveLine: true, + keymap: true + }} + /> )} {t('settings.mcp.jsonModeHint')} From 2d3f5baf72c74ca498f265e68631cda7fce1f1f4 Mon Sep 17 00:00:00 2001 From: Wang Jiyuan <59059173+EurFelux@users.noreply.github.com> Date: Fri, 27 Jun 2025 22:33:27 +0800 Subject: [PATCH 074/111] feat: Increase the upper limit of web search results (#7439) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(WebSearchSettings): 将最大搜索结果限制从20增加到50 * fix(WebSearchSettings): 调整搜索结果滑块宽度并添加50的标记 --- .../src/pages/settings/WebSearchSettings/BasicSettings.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/pages/settings/WebSearchSettings/BasicSettings.tsx b/src/renderer/src/pages/settings/WebSearchSettings/BasicSettings.tsx index 2a85df4b7f..f891e2aee1 100644 --- a/src/renderer/src/pages/settings/WebSearchSettings/BasicSettings.tsx +++ b/src/renderer/src/pages/settings/WebSearchSettings/BasicSettings.tsx @@ -28,11 +28,11 @@ const BasicSettings: FC = () => { {t('settings.websearch.search_max_result')} dispatch(setMaxResult(value))} /> From 14e31018f7b50519da55ca986961db3426131ce8 Mon Sep 17 00:00:00 2001 From: beyondkmp Date: Sat, 28 Jun 2025 08:36:32 +0800 Subject: [PATCH 075/111] fix: support spell check for mini app (#7602) * feat(IpcChannel): add Webview_SetSpellCheckEnabled channel and implement spell check handling for webviews - Introduced a new IPC channel for enabling/disabling spell check in webviews. - Updated the registerIpc function to handle spell check settings for all webviews. - Enhanced WebviewContainer to set spell check state on DOM ready event. - Refactored context menu setup to accommodate webview context menus. * refactor(ContextMenu): update methods to use Electron.WebContents instead of BrowserWindow - Changed method signatures to accept Electron.WebContents for better context handling. - Updated internal calls to utilize the new WebContents reference for toggling dev tools and managing spell check functionality. * refactor(WebviewContainer): clean up import order and remove unused code - Adjusted the import order in WebviewContainer.tsx for better readability. - Removed redundant import of useSettings to streamline the component. --- packages/shared/IpcChannel.ts | 1 + src/main/ipc.ts | 15 +++++-- src/main/services/ContextMenu.ts | 16 +++---- src/main/services/WindowService.ts | 7 ++-- src/preload/index.ts | 4 +- .../components/MinApp/WebviewContainer.tsx | 11 +++++ .../src/pages/settings/GeneralSettings.tsx | 42 +++++++++---------- 7 files changed, 59 insertions(+), 37 deletions(-) diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index 8da9a67429..8782d02f24 100644 --- a/packages/shared/IpcChannel.ts +++ b/packages/shared/IpcChannel.ts @@ -38,6 +38,7 @@ export enum IpcChannel { Notification_OnClick = 'notification:on-click', Webview_SetOpenLinkExternal = 'webview:set-open-link-external', + Webview_SetSpellCheckEnabled = 'webview:set-spell-check-enabled', // Open Open_Path = 'open:path', diff --git a/src/main/ipc.ts b/src/main/ipc.ts index b2003f8db8..32baecac35 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -8,7 +8,7 @@ import { handleZoomFactor } from '@main/utils/zoom' import { UpgradeChannel } from '@shared/config/constant' import { IpcChannel } from '@shared/IpcChannel' import { Shortcut, ThemeMode } from '@types' -import { BrowserWindow, dialog, ipcMain, session, shell } from 'electron' +import { BrowserWindow, dialog, ipcMain, session, shell, webContents } from 'electron' import log from 'electron-log' import { Notification } from 'src/renderer/src/types/notification' @@ -93,9 +93,10 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { // spell check ipcMain.handle(IpcChannel.App_SetEnableSpellCheck, (_, isEnable: boolean) => { - const windows = BrowserWindow.getAllWindows() - windows.forEach((window) => { - window.webContents.session.setSpellCheckerEnabled(isEnable) + // disable spell check for all webviews + const webviews = webContents.getAllWebContents() + webviews.forEach((webview) => { + webview.session.setSpellCheckerEnabled(isEnable) }) }) @@ -494,6 +495,12 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { setOpenLinkExternal(webviewId, isExternal) ) + ipcMain.handle(IpcChannel.Webview_SetSpellCheckEnabled, (_, webviewId: number, isEnable: boolean) => { + const webview = webContents.fromId(webviewId) + if (!webview) return + webview.session.setSpellCheckerEnabled(isEnable) + }) + // store sync storeSyncService.registerIpcHandler() diff --git a/src/main/services/ContextMenu.ts b/src/main/services/ContextMenu.ts index 34ec4b911a..411d6e075d 100644 --- a/src/main/services/ContextMenu.ts +++ b/src/main/services/ContextMenu.ts @@ -4,8 +4,8 @@ import { locales } from '../utils/locales' import { configManager } from './ConfigManager' class ContextMenu { - public contextMenu(w: Electron.BrowserWindow) { - w.webContents.on('context-menu', (_event, properties) => { + public contextMenu(w: Electron.WebContents) { + w.on('context-menu', (_event, properties) => { const template: MenuItemConstructorOptions[] = this.createEditMenuItems(properties) const filtered = template.filter((item) => item.visible !== false) if (filtered.length > 0) { @@ -26,7 +26,7 @@ class ContextMenu { }) } - private createInspectMenuItems(w: Electron.BrowserWindow): MenuItemConstructorOptions[] { + private createInspectMenuItems(w: Electron.WebContents): MenuItemConstructorOptions[] { const locale = locales[configManager.getLanguage()] const { common } = locale.translation const template: MenuItemConstructorOptions[] = [ @@ -34,7 +34,7 @@ class ContextMenu { id: 'inspect', label: common.inspect, click: () => { - w.webContents.toggleDevTools() + w.toggleDevTools() }, enabled: true } @@ -86,7 +86,7 @@ class ContextMenu { private createSpellCheckMenuItem( properties: Electron.ContextMenuParams, - mainWindow: Electron.BrowserWindow + w: Electron.WebContents ): MenuItemConstructorOptions { const hasText = properties.selectionText.length > 0 @@ -95,14 +95,14 @@ class ContextMenu { label: '&Learn Spelling', visible: Boolean(properties.isEditable && hasText && properties.misspelledWord), click: () => { - mainWindow.webContents.session.addWordToSpellCheckerDictionary(properties.misspelledWord) + w.session.addWordToSpellCheckerDictionary(properties.misspelledWord) } } } private createDictionarySuggestions( properties: Electron.ContextMenuParams, - mainWindow: Electron.BrowserWindow + w: Electron.WebContents ): MenuItemConstructorOptions[] { const hasText = properties.selectionText.length > 0 @@ -126,7 +126,7 @@ class ContextMenu { label: suggestion, visible: Boolean(properties.isEditable && hasText && properties.misspelledWord), click: (menuItem: Electron.MenuItem) => { - mainWindow.webContents.replaceMisspelling(menuItem.label) + w.replaceMisspelling(menuItem.label) } })) } diff --git a/src/main/services/WindowService.ts b/src/main/services/WindowService.ts index 78784120b0..ada014f0db 100644 --- a/src/main/services/WindowService.ts +++ b/src/main/services/WindowService.ts @@ -143,9 +143,10 @@ export class WindowService { } private setupContextMenu(mainWindow: BrowserWindow) { - contextMenu.contextMenu(mainWindow) - app.on('browser-window-created', (_, win) => { - contextMenu.contextMenu(win) + contextMenu.contextMenu(mainWindow.webContents) + // setup context menu for all webviews like miniapp + app.on('web-contents-created', (_, webContents) => { + contextMenu.contextMenu(webContents) }) // Dangerous API diff --git a/src/preload/index.ts b/src/preload/index.ts index ed2a2042e0..7867c66917 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -229,7 +229,9 @@ const api = { }, webview: { setOpenLinkExternal: (webviewId: number, isExternal: boolean) => - ipcRenderer.invoke(IpcChannel.Webview_SetOpenLinkExternal, webviewId, isExternal) + ipcRenderer.invoke(IpcChannel.Webview_SetOpenLinkExternal, webviewId, isExternal), + setSpellCheckEnabled: (webviewId: number, isEnable: boolean) => + ipcRenderer.invoke(IpcChannel.Webview_SetSpellCheckEnabled, webviewId, isEnable) }, storeSync: { subscribe: () => ipcRenderer.invoke(IpcChannel.StoreSync_Subscribe), diff --git a/src/renderer/src/components/MinApp/WebviewContainer.tsx b/src/renderer/src/components/MinApp/WebviewContainer.tsx index e5f08c350b..507de765af 100644 --- a/src/renderer/src/components/MinApp/WebviewContainer.tsx +++ b/src/renderer/src/components/MinApp/WebviewContainer.tsx @@ -1,3 +1,4 @@ +import { useSettings } from '@renderer/hooks/useSettings' import { WebviewTag } from 'electron' import { memo, useEffect, useRef } from 'react' @@ -21,6 +22,7 @@ const WebviewContainer = memo( onNavigateCallback: (appid: string, url: string) => void }) => { const webviewRef = useRef(null) + const { enableSpellCheck } = useSettings() const setRef = (appid: string) => { onSetRefCallback(appid, null) @@ -46,6 +48,14 @@ const WebviewContainer = memo( onNavigateCallback(appid, event.url) } + const handleDomReady = () => { + const webviewId = webviewRef.current?.getWebContentsId() + if (webviewId) { + window.api?.webview?.setSpellCheckEnabled?.(webviewId, enableSpellCheck) + } + } + + webviewRef.current.addEventListener('dom-ready', handleDomReady) webviewRef.current.addEventListener('did-finish-load', handleLoaded) webviewRef.current.addEventListener('did-navigate-in-page', handleNavigate) @@ -55,6 +65,7 @@ const WebviewContainer = memo( return () => { webviewRef.current?.removeEventListener('did-finish-load', handleLoaded) webviewRef.current?.removeEventListener('did-navigate-in-page', handleNavigate) + webviewRef.current?.removeEventListener('dom-ready', handleDomReady) } // because the appid and url are enough, no need to add onLoadedCallback // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/src/renderer/src/pages/settings/GeneralSettings.tsx b/src/renderer/src/pages/settings/GeneralSettings.tsx index 3c9ebd84d4..3f166a1cd4 100644 --- a/src/renderer/src/pages/settings/GeneralSettings.tsx +++ b/src/renderer/src/pages/settings/GeneralSettings.tsx @@ -172,27 +172,6 @@ const GeneralSettings: FC = () => { /> - - {t('settings.proxy.mode.title')} - - - {storeProxyMode === 'custom' && ( - <> - - - {t('settings.proxy.title')} - setProxyUrl(e.target.value)} - style={{ width: 180 }} - onBlur={() => onSetProxyUrl()} - type="url" - /> - - - )} - {t('settings.general.spell_check')} @@ -223,6 +202,27 @@ const GeneralSettings: FC = () => { )} + + + {t('settings.proxy.mode.title')} + + + {storeProxyMode === 'custom' && ( + <> + + + {t('settings.proxy.title')} + setProxyUrl(e.target.value)} + style={{ width: 180 }} + onBlur={() => onSetProxyUrl()} + type="url" + /> + + + )} {t('settings.notification.title')} From 49653435c25be7c4b4ed68a9dede23b5ef365635 Mon Sep 17 00:00:00 2001 From: Wang Jiyuan <59059173+EurFelux@users.noreply.github.com> Date: Sat, 28 Jun 2025 14:10:55 +0800 Subject: [PATCH 076/111] fix(models): Add inference model detection for qwen-plus and qwen-turbo (#7622) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat(models): 添加对qwen-plus和qwen-turbo模型的推理模型判断 --- src/renderer/src/config/models.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/renderer/src/config/models.ts b/src/renderer/src/config/models.ts index 64b32c6699..0172bda938 100644 --- a/src/renderer/src/config/models.ts +++ b/src/renderer/src/config/models.ts @@ -2508,9 +2508,11 @@ export function isSupportedThinkingTokenQwenModel(model?: Model): boolean { return ( baseName.startsWith('qwen3') || [ + 'qwen-plus', 'qwen-plus-latest', 'qwen-plus-0428', 'qwen-plus-2025-04-28', + 'qwen-turbo', 'qwen-turbo-latest', 'qwen-turbo-0428', 'qwen-turbo-2025-04-28' From cf87a840f74b5bd1b3a52bc556c24469b880b26e Mon Sep 17 00:00:00 2001 From: beyondkmp Date: Sat, 28 Jun 2025 16:45:02 +0800 Subject: [PATCH 077/111] fix(FileStorage): remove redundant WordExtractor import (#7625) --- src/main/services/FileStorage.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/services/FileStorage.ts b/src/main/services/FileStorage.ts index 2d8810adca..0c81a454a7 100644 --- a/src/main/services/FileStorage.ts +++ b/src/main/services/FileStorage.ts @@ -19,6 +19,7 @@ import { getDocument } from 'officeparser/pdfjs-dist-build/pdf.js' import * as path from 'path' import { chdir } from 'process' import { v4 as uuidv4 } from 'uuid' +import WordExtractor from 'word-extractor' class FileStorage { private storageDir = getFilesDir() @@ -228,7 +229,6 @@ class FileStorage { chdir(this.tempDir) if (fileExtension === '.doc') { - const WordExtractor = require('word-extractor') const extractor = new WordExtractor() const extracted = await extractor.extract(filePath) chdir(originalCwd) From 83b95f98309b60033ac6048dbdd40584fd82080b Mon Sep 17 00:00:00 2001 From: happyZYM Date: Sat, 28 Jun 2025 16:45:54 +0800 Subject: [PATCH 078/111] fix: restore strict no-think for Openrouter provider with latest api (#7620) --- src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts | 7 +++---- src/renderer/src/types/sdk.ts | 2 +- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts b/src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts index a53247c1f7..499edfbb5c 100644 --- a/src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts +++ b/src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts @@ -113,6 +113,9 @@ export class OpenAIAPIClient extends OpenAIBaseClient< } if (!reasoningEffort) { + if (model.provider === 'openrouter') { + return { reasoning: { enabled: false, exclude: true } } + } if (isSupportedThinkingTokenQwenModel(model)) { return { enable_thinking: false } } @@ -122,10 +125,6 @@ export class OpenAIAPIClient extends OpenAIBaseClient< } if (isSupportedThinkingTokenGeminiModel(model)) { - // openrouter没有提供一个不推理的选项,先隐藏 - if (this.provider.id === 'openrouter') { - return { reasoning: { max_tokens: 0, exclude: true } } - } if (GEMINI_FLASH_MODEL_REGEX.test(model.id)) { return { reasoning_effort: 'none' } } diff --git a/src/renderer/src/types/sdk.ts b/src/renderer/src/types/sdk.ts index 559e02ecae..6505210b60 100644 --- a/src/renderer/src/types/sdk.ts +++ b/src/renderer/src/types/sdk.ts @@ -48,7 +48,7 @@ type OpenAIParamsWithoutReasoningEffort = Omit Date: Sat, 28 Jun 2025 16:51:49 +0800 Subject: [PATCH 079/111] fix: move ContentSearch below Messages in Chat layout (#7628) Reordered the ContentSearch component to render after the Messages component within the Chat page. This change likely improves the UI flow by displaying the search functionality below the chat messages. --- src/renderer/src/pages/home/Chat.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/renderer/src/pages/home/Chat.tsx b/src/renderer/src/pages/home/Chat.tsx index 8d16c5a36c..fb623b62a5 100644 --- a/src/renderer/src/pages/home/Chat.tsx +++ b/src/renderer/src/pages/home/Chat.tsx @@ -109,13 +109,6 @@ const Chat: FC = (props) => { return (
- } - filter={contentSearchFilter} - includeUser={filterIncludeUser} - onIncludeUserChange={userOutlinedItemClickHandler} - /> = (props) => { onComponentUpdate={messagesComponentUpdateHandler} onFirstUpdate={messagesComponentFirstUpdateHandler} /> + } + filter={contentSearchFilter} + includeUser={filterIncludeUser} + onIncludeUserChange={userOutlinedItemClickHandler} + /> {isMultiSelectMode && } From dfcebe97678f31b84874046f2fd6cdaa5b0d2b9b Mon Sep 17 00:00:00 2001 From: SuYao Date: Sat, 28 Jun 2025 16:58:17 +0800 Subject: [PATCH 080/111] fix(models): update regex patterns for Doubao models and enhance function checks (#7624) - Adjusted regex for visionAllowedModels and DOUBAO_THINKING_MODEL_REGEX to allow for optional suffixes. - Enhanced isFunctionCallingModel and isDoubaoThinkingAutoModel functions to check both model.id and model.name for better matching. --- src/renderer/src/config/models.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/renderer/src/config/models.ts b/src/renderer/src/config/models.ts index 0172bda938..18d24d9ba6 100644 --- a/src/renderer/src/config/models.ts +++ b/src/renderer/src/config/models.ts @@ -184,7 +184,7 @@ const visionAllowedModels = [ 'deepseek-vl(?:[\\w-]+)?', 'kimi-latest', 'gemma-3(?:-[\\w-]+)', - 'doubao-seed-1[.-]6(?:-[\\w-]+)' + 'doubao-seed-1[.-]6(?:-[\\w-]+)?' ] const visionExcludedModels = [ @@ -273,6 +273,10 @@ export function isFunctionCallingModel(model: Model): boolean { return ['deepseek-v3-tool', 'deepseek-v3-0324', 'qwq-32b', 'qwen2.5-72b-instruct'].includes(model.id) } + if (model.provider === 'doubao') { + return FUNCTION_CALLING_REGEX.test(model.id) || FUNCTION_CALLING_REGEX.test(model.name) + } + if (['deepseek', 'anthropic'].includes(model.provider)) { return true } @@ -2525,7 +2529,7 @@ export function isSupportedThinkingTokenDoubaoModel(model?: Model): boolean { return false } - return DOUBAO_THINKING_MODEL_REGEX.test(model.id) + return DOUBAO_THINKING_MODEL_REGEX.test(model.id) || DOUBAO_THINKING_MODEL_REGEX.test(model.name) } export function isClaudeReasoningModel(model?: Model): boolean { @@ -2857,13 +2861,14 @@ export const findTokenLimit = (modelId: string): { min: number; max: number } | // Doubao 支持思考模式的模型正则 export const DOUBAO_THINKING_MODEL_REGEX = - /doubao-(?:1[.-]5-thinking-vision-pro|1[.-]5-thinking-pro-m|seed-1[.-]6(?:-flash)?)(?:-\d{6})?$/i + /doubao-(?:1[.-]5-thinking-vision-pro|1[.-]5-thinking-pro-m|seed-1[.-]6(?:-flash)?(?!-(?:thinking)(?:-|$)))(?:-[\w-]+)*/i // 支持 auto 的 Doubao 模型 doubao-seed-1.6-xxx doubao-seed-1-6-xxx doubao-1-5-thinking-pro-m-xxx -export const DOUBAO_THINKING_AUTO_MODEL_REGEX = /doubao-(1-5-thinking-pro-m|seed-1\.6|seed-1-6-[\w-]+)(?:-[\w-]+)*/i +export const DOUBAO_THINKING_AUTO_MODEL_REGEX = + /doubao-(1-5-thinking-pro-m|seed-1[.-]6)(?!-(?:flash|thinking)(?:-|$))(?:-[\w-]+)*/i export function isDoubaoThinkingAutoModel(model: Model): boolean { - return DOUBAO_THINKING_AUTO_MODEL_REGEX.test(model.id) + return DOUBAO_THINKING_AUTO_MODEL_REGEX.test(model.id) || DOUBAO_THINKING_AUTO_MODEL_REGEX.test(model.name) } export const GEMINI_FLASH_MODEL_REGEX = new RegExp('gemini-.*-flash.*$') From 780373d5f74781442d3f26800f3176d57a6ee60d Mon Sep 17 00:00:00 2001 From: beyondkmp Date: Sat, 28 Jun 2025 17:17:47 +0800 Subject: [PATCH 081/111] =?UTF-8?q?fix:=20=E6=B5=8B=E8=AF=95=E7=89=88?= =?UTF-8?q?=E6=9C=AC=20(#7590)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(AppUpdater): add support for pre-release versions and enhance feed URL logic - Introduced a new FeedUrl for the lowest pre-release version. - Updated AppUpdater to handle early access and upgrade channel settings more effectively. - Enhanced IPC logging for early access and upgrade channel changes. - Refactored feed URL setting logic to streamline update processes. * fix(AppUpdater, ipc): enhance early access and upgrade channel handling - Added checks to prevent unnecessary cancellation of downloads when early access and upgrade channel settings remain unchanged. - Updated IPC handlers to ensure early access is enabled when switching upgrade channels if it was previously disabled. - Improved logging for better traceability of changes in early access and upgrade channel settings. * delete code * delete logs * refactor(AboutSettings): enhance upgrade channel management - Introduced logic to determine the current upgrade channel based on version. - Refactored available test channels to use a more structured approach with tooltips and labels. - Updated the method for retrieving available test channels to improve clarity and maintainability. * feat(IpcChannel, ConfigManager, AppUpdater): implement test plan and channel management - Replaced early access features with test plan and test channel options in IpcChannel and ConfigManager. - Updated IPC handlers to manage test plan and test channel settings, including logging enhancements. - Refactored AppUpdater to support fetching pre-release versions based on the selected test channel. - Modified settings and localization files to reflect the new test plan functionality. - Adjusted AboutSettings and related components to integrate test plan management and improve user experience. * format code * refactor(AppUpdater, AboutSettings): improve test channel logic and localization updates - Refactored the logic in AppUpdater to enhance the handling of test channels, ensuring correct channel retrieval based on the current version. - Updated the AboutSettings component to include useEffect for managing test channel changes and displaying appropriate warnings. - Modified localization files for multiple languages to clarify the behavior of test version switching, aligning with the new logic. --- packages/shared/IpcChannel.ts | 4 +- packages/shared/config/constant.ts | 3 +- src/main/ipc.ts | 18 ++-- src/main/services/AppUpdater.ts | 83 +++++++++++++------ src/main/services/ConfigManager.ts | 20 ++--- src/preload/index.ts | 4 +- src/renderer/src/hooks/useSettings.ts | 16 ++-- src/renderer/src/i18n/locales/en-us.json | 17 ++-- src/renderer/src/i18n/locales/ja-jp.json | 17 ++-- src/renderer/src/i18n/locales/ru-ru.json | 17 ++-- src/renderer/src/i18n/locales/zh-cn.json | 17 ++-- src/renderer/src/i18n/locales/zh-tw.json | 17 ++-- .../src/pages/settings/AboutSettings.tsx | 65 +++++++++------ src/renderer/src/store/migrate.ts | 4 +- src/renderer/src/store/settings.ts | 20 ++--- 15 files changed, 185 insertions(+), 137 deletions(-) diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index 8782d02f24..daea5dad6e 100644 --- a/packages/shared/IpcChannel.ts +++ b/packages/shared/IpcChannel.ts @@ -15,8 +15,8 @@ export enum IpcChannel { App_SetTrayOnClose = 'app:set-tray-on-close', App_SetTheme = 'app:set-theme', App_SetAutoUpdate = 'app:set-auto-update', - App_SetEnableEarlyAccess = 'app:set-enable-early-access', - App_SetUpgradeChannel = 'app:set-upgrade-channel', + App_SetTestPlan = 'app:set-test-plan', + App_SetTestChannel = 'app:set-test-channel', App_HandleZoomFactor = 'app:handle-zoom-factor', App_Select = 'app:select', App_HasWritePermission = 'app:has-write-permission', diff --git a/packages/shared/config/constant.ts b/packages/shared/config/constant.ts index 975767fefa..e4545d44cb 100644 --- a/packages/shared/config/constant.ts +++ b/packages/shared/config/constant.ts @@ -406,7 +406,8 @@ export const defaultLanguage = 'en-US' export enum FeedUrl { PRODUCTION = 'https://releases.cherry-ai.com', - GITHUB_LATEST = 'https://github.com/CherryHQ/cherry-studio/releases/latest/download' + GITHUB_LATEST = 'https://github.com/CherryHQ/cherry-studio/releases/latest/download', + PRERELEASE_LOWEST = 'https://github.com/CherryHQ/cherry-studio/releases/download/v1.4.0' } export enum UpgradeChannel { diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 32baecac35..8c6810bcdc 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -142,14 +142,20 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { configManager.setAutoUpdate(isActive) }) - ipcMain.handle(IpcChannel.App_SetEnableEarlyAccess, async (_, isActive: boolean) => { - appUpdater.cancelDownload() - configManager.setEnableEarlyAccess(isActive) + ipcMain.handle(IpcChannel.App_SetTestPlan, async (_, isActive: boolean) => { + log.info('set test plan', isActive) + if (isActive !== configManager.getTestPlan()) { + appUpdater.cancelDownload() + configManager.setTestPlan(isActive) + } }) - ipcMain.handle(IpcChannel.App_SetUpgradeChannel, async (_, channel: UpgradeChannel) => { - appUpdater.cancelDownload() - configManager.setUpgradeChannel(channel) + ipcMain.handle(IpcChannel.App_SetTestChannel, async (_, channel: UpgradeChannel) => { + log.info('set test channel', channel) + if (channel !== configManager.getTestChannel()) { + appUpdater.cancelDownload() + configManager.setTestChannel(channel) + } }) ipcMain.handle(IpcChannel.Config_Set, (_, key: string, value: any, isNotify: boolean = false) => { diff --git a/src/main/services/AppUpdater.ts b/src/main/services/AppUpdater.ts index e26a779d59..82165fd715 100644 --- a/src/main/services/AppUpdater.ts +++ b/src/main/services/AppUpdater.ts @@ -5,7 +5,7 @@ import { IpcChannel } from '@shared/IpcChannel' import { CancellationToken, UpdateInfo } from 'builder-util-runtime' import { app, BrowserWindow, dialog } from 'electron' import logger from 'electron-log' -import { AppUpdater as _AppUpdater, autoUpdater, NsisUpdater } from 'electron-updater' +import { AppUpdater as _AppUpdater, autoUpdater, NsisUpdater, UpdateCheckResult } from 'electron-updater' import path from 'path' import icon from '../../../build/icon.png?asset' @@ -15,6 +15,7 @@ export default class AppUpdater { autoUpdater: _AppUpdater = autoUpdater private releaseInfo: UpdateInfo | undefined private cancellationToken: CancellationToken = new CancellationToken() + private updateCheckResult: UpdateCheckResult | null = null constructor(mainWindow: BrowserWindow) { logger.transports.file.level = 'info' @@ -65,6 +66,7 @@ export default class AppUpdater { private async _getPreReleaseVersionFromGithub(channel: UpgradeChannel) { try { + logger.info('get pre 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', @@ -73,11 +75,12 @@ export default class AppUpdater { } }) const data = (await responses.json()) as GithubReleaseInfo[] - logger.debug('github release data', data) const release: GithubReleaseInfo | undefined = data.find((item: GithubReleaseInfo) => { return item.prerelease && item.tag_name.includes(`-${channel}.`) }) + logger.info('release info', release) + if (!release) { return null } @@ -119,31 +122,57 @@ export default class AppUpdater { autoUpdater.autoInstallOnAppQuit = isActive } - private async _setFeedUrl() { - // disable downgrade and differential download - // github and gitcode don't support multiple range download - this.autoUpdater.allowDowngrade = false - this.autoUpdater.disableDifferentialDownload = true + private _getChannelByVersion(version: string) { + if (version.includes(`-${UpgradeChannel.BETA}.`)) { + return UpgradeChannel.BETA + } + if (version.includes(`-${UpgradeChannel.RC}.`)) { + return UpgradeChannel.RC + } + return UpgradeChannel.LATEST + } + + private _getTestChannel() { + const currentChannel = this._getChannelByVersion(app.getVersion()) + const savedChannel = configManager.getTestChannel() + + if (currentChannel === UpgradeChannel.LATEST) { + return savedChannel || UpgradeChannel.RC + } + + if (savedChannel === currentChannel) { + return savedChannel + } + + // if the upgrade channel is not equal to the current channel, use the latest channel + return UpgradeChannel.LATEST + } + + private async _setFeedUrl() { + const testPlan = configManager.getTestPlan() + if (testPlan) { + const channel = this._getTestChannel() - if (configManager.getEnableEarlyAccess()) { - const channel = configManager.getUpgradeChannel() if (channel === UpgradeChannel.LATEST) { - this.autoUpdater.setFeedURL(FeedUrl.GITHUB_LATEST) this.autoUpdater.channel = UpgradeChannel.LATEST - return true + this.autoUpdater.setFeedURL(FeedUrl.GITHUB_LATEST) + return } const preReleaseUrl = await this._getPreReleaseVersionFromGithub(channel) if (preReleaseUrl) { this.autoUpdater.setFeedURL(preReleaseUrl) this.autoUpdater.channel = channel - return true + return } - return false + + // if no prerelease url, use lowest prerelease version to avoid error + this.autoUpdater.setFeedURL(FeedUrl.PRERELEASE_LOWEST) + this.autoUpdater.channel = UpgradeChannel.LATEST + return } - // no early access, use latest version - this.autoUpdater.channel = 'latest' + this.autoUpdater.channel = UpgradeChannel.LATEST this.autoUpdater.setFeedURL(FeedUrl.PRODUCTION) const ipCountry = await this._getIpCountry() @@ -151,12 +180,14 @@ export default class AppUpdater { if (ipCountry.toLowerCase() !== 'cn') { this.autoUpdater.setFeedURL(FeedUrl.GITHUB_LATEST) } - return true } public cancelDownload() { this.cancellationToken.cancel() this.cancellationToken = new CancellationToken() + if (this.autoUpdater.autoDownload) { + this.updateCheckResult?.cancellationToken?.cancel() + } } public async checkForUpdates() { @@ -167,17 +198,17 @@ export default class AppUpdater { } } - const isSetFeedUrl = await this._setFeedUrl() - if (!isSetFeedUrl) { - return { - currentVersion: app.getVersion(), - updateInfo: null - } - } + await this._setFeedUrl() + + // disable downgrade after change the channel + this.autoUpdater.allowDowngrade = false + + // github and gitcode don't support multiple range download + this.autoUpdater.disableDifferentialDownload = true try { - const update = await this.autoUpdater.checkForUpdates() - if (update?.isUpdateAvailable && !this.autoUpdater.autoDownload) { + 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 logger.info('downloadUpdate manual by check for updates', this.cancellationToken) @@ -186,7 +217,7 @@ export default class AppUpdater { return { currentVersion: this.autoUpdater.currentVersion, - updateInfo: update?.updateInfo + updateInfo: this.updateCheckResult?.updateInfo } } catch (error) { logger.error('Failed to check for update:', error) diff --git a/src/main/services/ConfigManager.ts b/src/main/services/ConfigManager.ts index 6d33b6e3dd..8e4b5d2bf1 100644 --- a/src/main/services/ConfigManager.ts +++ b/src/main/services/ConfigManager.ts @@ -16,8 +16,8 @@ export enum ConfigKeys { ClickTrayToShowQuickAssistant = 'clickTrayToShowQuickAssistant', EnableQuickAssistant = 'enableQuickAssistant', AutoUpdate = 'autoUpdate', - EnableEarlyAccess = 'enableEarlyAccess', - UpgradeChannel = 'upgradeChannel', + TestPlan = 'testPlan', + TestChannel = 'testChannel', EnableDataCollection = 'enableDataCollection', SelectionAssistantEnabled = 'selectionAssistantEnabled', SelectionAssistantTriggerMode = 'selectionAssistantTriggerMode', @@ -143,20 +143,20 @@ export class ConfigManager { this.set(ConfigKeys.AutoUpdate, value) } - getEnableEarlyAccess(): boolean { - return this.get(ConfigKeys.EnableEarlyAccess, false) + getTestPlan(): boolean { + return this.get(ConfigKeys.TestPlan, false) } - setEnableEarlyAccess(value: boolean) { - this.set(ConfigKeys.EnableEarlyAccess, value) + setTestPlan(value: boolean) { + this.set(ConfigKeys.TestPlan, value) } - getUpgradeChannel(): UpgradeChannel { - return this.get(ConfigKeys.UpgradeChannel, UpgradeChannel.LATEST) + getTestChannel(): UpgradeChannel { + return this.get(ConfigKeys.TestChannel) } - setUpgradeChannel(value: UpgradeChannel) { - this.set(ConfigKeys.UpgradeChannel, value) + setTestChannel(value: UpgradeChannel) { + this.set(ConfigKeys.TestChannel, value) } getEnableDataCollection(): boolean { diff --git a/src/preload/index.ts b/src/preload/index.ts index 7867c66917..8412e00bc3 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -23,8 +23,8 @@ const api = { setLaunchToTray: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetLaunchToTray, isActive), setTray: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetTray, isActive), setTrayOnClose: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetTrayOnClose, isActive), - setEnableEarlyAccess: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetEnableEarlyAccess, isActive), - setUpgradeChannel: (channel: UpgradeChannel) => ipcRenderer.invoke(IpcChannel.App_SetUpgradeChannel, channel), + setTestPlan: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetTestPlan, isActive), + setTestChannel: (channel: UpgradeChannel) => ipcRenderer.invoke(IpcChannel.App_SetTestChannel, channel), setTheme: (theme: ThemeMode) => ipcRenderer.invoke(IpcChannel.App_SetTheme, theme), handleZoomFactor: (delta: number, reset: boolean = false) => ipcRenderer.invoke(IpcChannel.App_HandleZoomFactor, delta, reset), diff --git a/src/renderer/src/hooks/useSettings.ts b/src/renderer/src/hooks/useSettings.ts index 72560706e6..dfb75cc791 100644 --- a/src/renderer/src/hooks/useSettings.ts +++ b/src/renderer/src/hooks/useSettings.ts @@ -4,7 +4,6 @@ import { SendMessageShortcut, setAssistantIconType, setAutoCheckUpdate as _setAutoCheckUpdate, - setEarlyAccess as _setEarlyAccess, setLaunchOnBoot, setLaunchToTray, setPinTopicsToTop, @@ -12,12 +11,13 @@ import { setShowTokens, setSidebarIcons, setTargetLanguage, + setTestChannel as _setTestChannel, + setTestPlan as _setTestPlan, setTheme, SettingsState, setTopicPosition, setTray as _setTray, setTrayOnClose, - setUpgradeChannel as _setUpgradeChannel, setWindowStyle } from '@renderer/store/settings' import { SidebarIcon, ThemeMode, TranslateLanguageVarious } from '@renderer/types' @@ -61,14 +61,14 @@ export function useSettings() { window.api.setAutoUpdate(isAutoUpdate) }, - setEarlyAccess(isEarlyAccess: boolean) { - dispatch(_setEarlyAccess(isEarlyAccess)) - window.api.setEnableEarlyAccess(isEarlyAccess) + setTestPlan(isTestPlan: boolean) { + dispatch(_setTestPlan(isTestPlan)) + window.api.setTestPlan(isTestPlan) }, - setUpgradeChannel(channel: UpgradeChannel) { - dispatch(_setUpgradeChannel(channel)) - window.api.setUpgradeChannel(channel) + setTestChannel(channel: UpgradeChannel) { + dispatch(_setTestChannel(channel)) + window.api.setTestChannel(channel) }, setTheme(theme: ThemeMode) { diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index ecb7182753..9a5e61122e 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -1392,15 +1392,14 @@ "general.emoji_picker": "Emoji Picker", "general.image_upload": "Image Upload", "general.auto_check_update.title": "Auto Update", - "general.early_access.title": "Early Access", - "general.early_access.tooltip": "Updating to test versions cannot be downgraded, there is a risk of data loss, please backup your data in advance", - "general.early_access.beta_version": "Beta Version", - "general.early_access.rc_version": "RC Version", - "general.early_access.latest_version": "Latest Version", - "general.early_access.latest_version_tooltip": "github latest version, latest stable version", - "general.early_access.version_options": "Version Options", - "general.early_access.rc_version_tooltip": "More stable, please backup your data", - "general.early_access.beta_version_tooltip": "Latest features but unstable, use with caution", + "general.test_plan.title": "Test Plan", + "general.test_plan.tooltip": "Participate in the test plan to experience the latest features faster, but also brings more risks, please backup your data in advance", + "general.test_plan.beta_version": "Beta Version (Beta)", + "general.test_plan.beta_version_tooltip": "Features may change at any time, bugs are more, upgrade quickly", + "general.test_plan.rc_version": "Preview Version (RC)", + "general.test_plan.rc_version_tooltip": "Close to stable version, features are basically stable, bugs are few", + "general.test_plan.version_options": "Version Options", + "general.test_plan.version_channel_not_match": "Preview and test version switching will take effect after the next stable version is released", "general.reset.button": "Reset", "general.reset.title": "Data Reset", "general.restore.button": "Restore", diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index 4b0d34eeb2..b1b03c4f3b 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -1863,15 +1863,14 @@ } }, "general.auto_check_update.title": "自動更新", - "general.early_access.title": "早期アクセス", - "general.early_access.tooltip": "更新すると、データが失われる可能性があります。データを事前にバックアップしてください。", - "general.early_access.beta_version": "ベータ版", - "general.early_access.rc_version": "RC版", - "general.early_access.latest_version": "最新版", - "general.early_access.latest_version_tooltip": "github latest バージョン, 最新安定版", - "general.early_access.version_options": "バージョンオプション", - "general.early_access.rc_version_tooltip": "より安定しています。データを事前にバックアップしてください。", - "general.early_access.beta_version_tooltip": "最新の機能ですが、不安定な場合があります。使用には注意してください。", + "general.test_plan.title": "テストプラン", + "general.test_plan.tooltip": "テストプランに参加すると、最新の機能をより早く体験できますが、同時により多くのリスクが伴います。データを事前にバックアップしてください。", + "general.test_plan.beta_version": "ベータ版(Beta)", + "general.test_plan.beta_version_tooltip": "機能が変更される可能性があります。バグが多く、迅速にアップグレードされます。", + "general.test_plan.rc_version": "プレビュー版(RC)", + "general.test_plan.rc_version_tooltip": "安定版に近い機能ですが、バグが少なく、迅速にアップグレードされます。", + "general.test_plan.version_options": "バージョンオプション", + "general.test_plan.version_channel_not_match": "プレビュー版とテスト版の切り替えは、次の正式版リリース時に有効になります。", "quickPhrase": { "title": "クイックフレーズ", "add": "フレーズを追加", diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index d7f20e297b..46faee8677 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -1863,15 +1863,14 @@ } }, "general.auto_check_update.title": "Автоматическое обновление", - "general.early_access.title": "Ранний доступ", - "general.early_access.tooltip": "Обновление до тестовых версий не может быть откачено, существует риск потери данных, пожалуйста, сделайте резервную копию данных заранее", - "general.early_access.beta_version": "Бета версия", - "general.early_access.rc_version": "RC версия", - "general.early_access.latest_version": "Стабильная версия", - "general.early_access.latest_version_tooltip": "github latest версия, стабильная версия", - "general.early_access.version_options": "Варианты версии", - "general.early_access.rc_version_tooltip": "Более стабильно, пожалуйста, сделайте резервную копию данных заранее", - "general.early_access.beta_version_tooltip": "Самые последние функции, но нестабильно, используйте с осторожностью", + "general.test_plan.title": "Тестовый план", + "general.test_plan.tooltip": "Участвовать в тестовом плане, чтобы быстрее получать новые функции, но при этом возникает больше рисков, пожалуйста, сделайте резервную копию данных заранее", + "general.test_plan.beta_version": "Тестовая версия (Beta)", + "general.test_plan.beta_version_tooltip": "Функции могут меняться в любое время, ошибки больше, обновление происходит быстрее", + "general.test_plan.rc_version": "Предварительная версия (RC)", + "general.test_plan.rc_version_tooltip": "Похожа на стабильную версию, функции стабильны, ошибки меньше, обновление происходит быстрее", + "general.test_plan.version_options": "Варианты версии", + "general.test_plan.version_channel_not_match": "Предварительная и тестовая версия будут доступны после выхода следующей стабильной версии", "quickPhrase": { "title": "Быстрые фразы", "add": "Добавить фразу", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 6b662d38ee..828d46d3c7 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -1392,15 +1392,14 @@ "general.emoji_picker": "表情选择器", "general.image_upload": "图片上传", "general.auto_check_update.title": "自动更新", - "general.early_access.title": "抢先体验", - "general.early_access.tooltip": "更新到测试版本不能降级,有数据丢失风险,请务必提前备份数据", - "general.early_access.beta_version": "预览版本", - "general.early_access.rc_version": "公测版本", - "general.early_access.latest_version": "稳定版本", - "general.early_access.version_options": "版本选择", - "general.early_access.rc_version_tooltip": "相对稳定,请备份数据", - "general.early_access.beta_version_tooltip": "功能最新但不稳定,谨慎使用", - "general.early_access.latest_version_tooltip": "github latest 版本, 最新稳定版本", + "general.test_plan.title": "测试计划", + "general.test_plan.tooltip": "参与测试计划,可以更快体验到最新功能,但同时也会带来更多风险,务必提前做好备份", + "general.test_plan.beta_version": "测试版(Beta)", + "general.test_plan.beta_version_tooltip": "功能可能随时变化,bug较多,升级较快", + "general.test_plan.rc_version": "预览版(RC)", + "general.test_plan.rc_version_tooltip": "接近正式版,功能基本稳定,bug较少", + "general.test_plan.version_options": "版本选择", + "general.test_plan.version_channel_not_match": "预览版和测试版的切换将在下一个正式版发布时生效", "general.reset.button": "重置", "general.reset.title": "重置数据", "general.restore.button": "恢复", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 44d99d4da9..0b833c5b5e 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -1866,15 +1866,14 @@ } }, "general.auto_check_update.title": "自動更新", - "general.early_access.title": "搶先體驗", - "general.early_access.tooltip": "更新到測試版本不能降級,有數據丟失風險,請務必提前備份數據", - "general.early_access.beta_version": "預覽版本", - "general.early_access.rc_version": "公測版本", - "general.early_access.latest_version": "穩定版本", - "general.early_access.latest_version_tooltip": "github latest 版本, 最新穩定版本", - "general.early_access.version_options": "版本選項", - "general.early_access.rc_version_tooltip": "相對穩定,請務必提前備份數據", - "general.early_access.beta_version_tooltip": "功能最新但不穩定,謹慎使用", + "general.test_plan.title": "測試計畫", + "general.test_plan.tooltip": "參與測試計畫,體驗最新功能,但同時也帶來更多風險,請務必提前備份數據", + "general.test_plan.beta_version": "測試版本(Beta)", + "general.test_plan.beta_version_tooltip": "功能可能會隨時變化,錯誤較多,升級較快", + "general.test_plan.rc_version": "預覽版本(RC)", + "general.test_plan.rc_version_tooltip": "相對穩定,請務必提前備份數據", + "general.test_plan.version_options": "版本選項", + "general.test_plan.version_channel_not_match": "預覽版和測試版的切換將在下一個正式版發布時生效", "quickPhrase": { "title": "快捷短語", "add": "新增短語", diff --git a/src/renderer/src/pages/settings/AboutSettings.tsx b/src/renderer/src/pages/settings/AboutSettings.tsx index 9bf65e4489..50832da2e6 100644 --- a/src/renderer/src/pages/settings/AboutSettings.tsx +++ b/src/renderer/src/pages/settings/AboutSettings.tsx @@ -26,8 +26,7 @@ const AboutSettings: FC = () => { const [version, setVersion] = useState('') const [isPortable, setIsPortable] = useState(false) const { t } = useTranslation() - const { autoCheckUpdate, setAutoCheckUpdate, earlyAccess, setEarlyAccess, upgradeChannel, setUpgradeChannel } = - useSettings() + const { autoCheckUpdate, setAutoCheckUpdate, testPlan, setTestPlan, testChannel, setTestChannel } = useSettings() const { theme } = useTheme() const dispatch = useAppDispatch() const { update } = useRuntime() @@ -97,8 +96,20 @@ const AboutSettings: FC = () => { const hasNewVersion = update?.info?.version && version ? compareVersions(update.info.version, version) > 0 : false - const handleUpgradeChannelChange = async (value: UpgradeChannel) => { - setUpgradeChannel(value) + const currentChannelByVersion = + [ + { pattern: `-${UpgradeChannel.BETA}.`, channel: UpgradeChannel.BETA }, + { pattern: `-${UpgradeChannel.RC}.`, channel: UpgradeChannel.RC } + ].find(({ pattern }) => version.includes(pattern))?.channel || UpgradeChannel.LATEST + + useEffect(() => { + if (testPlan && currentChannelByVersion !== UpgradeChannel.LATEST && testChannel !== currentChannelByVersion) { + window.message.warning(t('settings.general.test_plan.version_channel_not_match')) + } + }, [testPlan, testChannel, currentChannelByVersion, t]) + + const handleTestChannelChange = async (value: UpgradeChannel) => { + setTestChannel(value) // Clear update info when switching upgrade channel dispatch( setUpdateState({ @@ -116,25 +127,20 @@ const AboutSettings: FC = () => { const getAvailableTestChannels = () => { return [ { - tooltip: t('settings.general.early_access.latest_version_tooltip'), - label: t('settings.general.early_access.latest_version'), - value: UpgradeChannel.LATEST - }, - { - tooltip: t('settings.general.early_access.rc_version_tooltip'), - label: t('settings.general.early_access.rc_version'), + tooltip: t('settings.general.test_plan.rc_version_tooltip'), + label: t('settings.general.test_plan.rc_version'), value: UpgradeChannel.RC }, { - tooltip: t('settings.general.early_access.beta_version_tooltip'), - label: t('settings.general.early_access.beta_version'), + tooltip: t('settings.general.test_plan.beta_version_tooltip'), + label: t('settings.general.test_plan.beta_version'), value: UpgradeChannel.BETA } ] } - const handlerSetEarlyAccess = (value: boolean) => { - setEarlyAccess(value) + const handleSetTestPlan = (value: boolean) => { + setTestPlan(value) dispatch( setUpdateState({ available: false, @@ -145,7 +151,17 @@ const AboutSettings: FC = () => { downloadProgress: 0 }) ) - if (value === false) setUpgradeChannel(UpgradeChannel.LATEST) + + if (value === true) { + setTestChannel(getTestChannel()) + } + } + + const getTestChannel = () => { + if (testChannel === UpgradeChannel.LATEST) { + return UpgradeChannel.RC + } + return testChannel } useEffect(() => { @@ -155,7 +171,7 @@ const AboutSettings: FC = () => { setIsPortable(appInfo.isPortable) }) setAutoCheckUpdate(autoCheckUpdate) - }, [autoCheckUpdate, setAutoCheckUpdate, setEarlyAccess]) + }, [autoCheckUpdate, setAutoCheckUpdate]) return ( @@ -217,22 +233,21 @@ const AboutSettings: FC = () => { - {t('settings.general.early_access.title')} - - handlerSetEarlyAccess(v)} /> + {t('settings.general.test_plan.title')} + + handleSetTestPlan(v)} /> - {earlyAccess && getAvailableTestChannels().length > 0 && ( + {testPlan && ( <> - {t('settings.general.early_access.version_options')} + {t('settings.general.test_plan.version_options')} handleUpgradeChannelChange(e.target.value)}> + value={getTestChannel()} + onChange={(e) => handleTestChannelChange(e.target.value)}> {getAvailableTestChannels().map((option) => ( {option.label} diff --git a/src/renderer/src/store/migrate.ts b/src/renderer/src/store/migrate.ts index c8a132180f..501c5e483a 100644 --- a/src/renderer/src/store/migrate.ts +++ b/src/renderer/src/store/migrate.ts @@ -1543,7 +1543,7 @@ const migrateConfig = { state.paintings.tokenFluxPaintings = [] } state.settings.showTokens = true - state.settings.earlyAccess = false + state.settings.testPlan = false return state } catch (error) { return state @@ -1629,7 +1629,7 @@ const migrateConfig = { } }) if (state.settings) { - state.settings.upgradeChannel = UpgradeChannel.LATEST + state.settings.testChannel = UpgradeChannel.LATEST } return state } catch (error) { diff --git a/src/renderer/src/store/settings.ts b/src/renderer/src/store/settings.ts index ee991556f7..7d8e14ed11 100644 --- a/src/renderer/src/store/settings.ts +++ b/src/renderer/src/store/settings.ts @@ -68,8 +68,8 @@ export interface SettingsState { pasteLongTextThreshold: number clickAssistantToShowTopic: boolean autoCheckUpdate: boolean - earlyAccess: boolean - upgradeChannel: UpgradeChannel + testPlan: boolean + testChannel: UpgradeChannel renderInputMessageAsMarkdown: boolean // 代码执行 codeExecution: { @@ -222,8 +222,8 @@ export const initialState: SettingsState = { pasteLongTextThreshold: 1500, clickAssistantToShowTopic: true, autoCheckUpdate: true, - earlyAccess: false, - upgradeChannel: UpgradeChannel.LATEST, + testPlan: false, + testChannel: UpgradeChannel.LATEST, renderInputMessageAsMarkdown: false, codeExecution: { enabled: false, @@ -429,11 +429,11 @@ const settingsSlice = createSlice({ setAutoCheckUpdate: (state, action: PayloadAction) => { state.autoCheckUpdate = action.payload }, - setEarlyAccess: (state, action: PayloadAction) => { - state.earlyAccess = action.payload + setTestPlan: (state, action: PayloadAction) => { + state.testPlan = action.payload }, - setUpgradeChannel: (state, action: PayloadAction) => { - state.upgradeChannel = action.payload + setTestChannel: (state, action: PayloadAction) => { + state.testChannel = action.payload }, setRenderInputMessageAsMarkdown: (state, action: PayloadAction) => { state.renderInputMessageAsMarkdown = action.payload @@ -730,8 +730,8 @@ export const { setAssistantIconType, setPasteLongTextAsFile, setAutoCheckUpdate, - setEarlyAccess, - setUpgradeChannel, + setTestPlan, + setTestChannel, setRenderInputMessageAsMarkdown, setClickAssistantToShowTopic, setSkipBackupFile, From ece59cfacfd4146020667378e2bb832127ce6b63 Mon Sep 17 00:00:00 2001 From: beyondkmp Date: Sat, 28 Jun 2025 17:52:36 +0800 Subject: [PATCH 082/111] fix(migrate): handle state return in migration process and add upgradechannel setting (#7634) * fix(migrate): handle state return in migration process and add upgrade channel setting * fix(migrate): move upgrade channel setting to the correct migration step --- src/renderer/src/store/migrate.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/store/migrate.ts b/src/renderer/src/store/migrate.ts index 501c5e483a..8eea0a34a7 100644 --- a/src/renderer/src/store/migrate.ts +++ b/src/renderer/src/store/migrate.ts @@ -1628,9 +1628,6 @@ const migrateConfig = { } } }) - if (state.settings) { - state.settings.testChannel = UpgradeChannel.LATEST - } return state } catch (error) { return state @@ -1655,6 +1652,9 @@ const migrateConfig = { // @ts-ignore eslint-disable-next-line delete state.websearch.contentLimit } + if (state.settings) { + state.settings.testChannel = UpgradeChannel.LATEST + } return state } catch (error) { From 8de6ae1772070e6ec635cdaf3a6db7fb744ab05e Mon Sep 17 00:00:00 2001 From: one Date: Sat, 28 Jun 2025 19:00:26 +0800 Subject: [PATCH 083/111] fix(Menubar): icon for multi select (#7635) --- src/renderer/src/pages/home/Messages/MessageMenubar.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/pages/home/Messages/MessageMenubar.tsx b/src/renderer/src/pages/home/Messages/MessageMenubar.tsx index 50a4fc95ed..fda0f40a4b 100644 --- a/src/renderer/src/pages/home/Messages/MessageMenubar.tsx +++ b/src/renderer/src/pages/home/Messages/MessageMenubar.tsx @@ -1,4 +1,4 @@ -import { CheckOutlined, EditOutlined, MenuOutlined, QuestionCircleOutlined, SyncOutlined } from '@ant-design/icons' +import { CheckOutlined, EditOutlined, QuestionCircleOutlined, SyncOutlined } from '@ant-design/icons' import ObsidianExportPopup from '@renderer/components/Popups/ObsidianExportPopup' import SelectModelPopup from '@renderer/components/Popups/SelectModelPopup' import { TranslateLanguageOptions } from '@renderer/config/translate' @@ -29,7 +29,7 @@ import { removeTrailingDoubleSpaces } from '@renderer/utils/markdown' import { findMainTextBlocks, findTranslationBlocks, getMainTextContent } from '@renderer/utils/messageUtils/find' import { Dropdown, Popconfirm, Tooltip } from 'antd' import dayjs from 'dayjs' -import { AtSign, Copy, Languages, Menu, RefreshCw, Save, Share, Split, ThumbsUp, Trash } from 'lucide-react' +import { AtSign, Copy, Languages, ListChecks, Menu, RefreshCw, Save, Share, Split, ThumbsUp, Trash } from 'lucide-react' import { FilePenLine } from 'lucide-react' import { FC, memo, useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -195,7 +195,7 @@ const MessageMenubar: FC = (props) => { { label: t('chat.multiple.select'), key: 'multi-select', - icon: , + icon: , onClick: () => { toggleMultiSelectMode(true) } From 101d73fc10989d5dc078bfa0b8c4e2db6d5e9c93 Mon Sep 17 00:00:00 2001 From: Kingsword Date: Sat, 28 Jun 2025 20:04:03 +0800 Subject: [PATCH 084/111] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(ContentSe?= =?UTF-8?q?arch):=20ContentSearch=20to=20use=20CSS=20highlights=20API=20(#?= =?UTF-8?q?7493)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/renderer/src/assets/styles/index.scss | 4 +- src/renderer/src/components/ContentSearch.tsx | 623 ++++++------------ src/renderer/src/pages/home/Chat.tsx | 38 +- 3 files changed, 223 insertions(+), 442 deletions(-) diff --git a/src/renderer/src/assets/styles/index.scss b/src/renderer/src/assets/styles/index.scss index da28abc8c5..c4bd23d1fc 100644 --- a/src/renderer/src/assets/styles/index.scss +++ b/src/renderer/src/assets/styles/index.scss @@ -173,12 +173,12 @@ ul { color: var(--color-icon); } -span.highlight { +::highlight(search-matches) { background-color: var(--color-background-highlight); color: var(--color-highlight); } -span.highlight.selected { +::highlight(current-match) { background-color: var(--color-background-highlight-accent); } diff --git a/src/renderer/src/components/ContentSearch.tsx b/src/renderer/src/components/ContentSearch.tsx index 08a1fd415a..1f895e348b 100644 --- a/src/renderer/src/components/ContentSearch.tsx +++ b/src/renderer/src/components/ContentSearch.tsx @@ -3,13 +3,10 @@ import NarrowLayout from '@renderer/pages/home/Messages/NarrowLayout' import { Tooltip } from 'antd' import { debounce } from 'lodash' import { CaseSensitive, ChevronDown, ChevronUp, User, WholeWord, X } from 'lucide-react' -import React, { useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react' +import React, { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' -const HIGHLIGHT_CLASS = 'highlight' -const HIGHLIGHT_SELECT_CLASS = 'selected' - interface Props { children?: React.ReactNode searchTarget: React.RefObject | React.RefObject | HTMLElement @@ -18,19 +15,14 @@ interface Props { * * 返回`true`表示该`node`会被搜索 */ - filter: (node: Node) => boolean + filter: NodeFilter includeUser?: boolean onIncludeUserChange?: (value: boolean) => void } enum SearchCompletedState { NotSearched, - FirstSearched -} - -enum SearchTargetIndex { - Next, - Prev + Searched } export interface ContentSearchRef { @@ -47,60 +39,20 @@ export interface ContentSearchRef { focus(): void } -interface MatchInfo { - index: number - length: number - text: string -} - const escapeRegExp = (string: string): string => { return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // $& means the whole matched string } -const findWindowVerticalCenterElementIndex = (elementList: HTMLElement[]): number | null => { - if (!elementList || elementList.length === 0) { - return null - } - let closestElementIndex: number | null = null - let minVerticalDistance = Infinity - const windowCenterY = window.innerHeight / 2 - for (let i = 0; i < elementList.length; i++) { - const element = elementList[i] - if (!(element instanceof HTMLElement)) { - continue - } - const rect = element.getBoundingClientRect() - if (rect.bottom < 0 || rect.top > window.innerHeight) { - continue - } - const elementCenterY = rect.top + rect.height / 2 - const verticalDistance = Math.abs(elementCenterY - windowCenterY) - if (verticalDistance < minVerticalDistance) { - minVerticalDistance = verticalDistance - closestElementIndex = i - } - } - return closestElementIndex -} - -const highlightText = ( - textNode: Node, +const findRangesInTarget = ( + target: HTMLElement, + filter: NodeFilter, searchText: string, - highlightClass: string, isCaseSensitive: boolean, isWholeWord: boolean -): HTMLSpanElement[] | null => { - const textNodeParentNode: HTMLElement | null = textNode.parentNode as HTMLElement - if (textNodeParentNode) { - if (textNodeParentNode.classList.contains(highlightClass)) { - return null - } - } - if (textNode.nodeType !== Node.TEXT_NODE || !textNode.textContent) { - return null - } +): Range[] => { + CSS.highlights.clear() + const ranges: Range[] = [] - const textContent = textNode.textContent const escapedSearchText = escapeRegExp(searchText) // 检查搜索文本是否仅包含拉丁字母 @@ -109,89 +61,66 @@ const highlightText = ( // 只有当搜索文本仅包含拉丁字母时才应用大小写敏感 const regexFlags = hasOnlyLatinLetters && isCaseSensitive ? 'g' : 'gi' const regexPattern = isWholeWord ? `\\b${escapedSearchText}\\b` : escapedSearchText - const regex = new RegExp(regexPattern, regexFlags) + const searchRegex = new RegExp(regexPattern, regexFlags) + const treeWalker = document.createTreeWalker(target, NodeFilter.SHOW_TEXT, filter) + const allTextNodes: { node: Node; startOffset: number }[] = [] + let fullText = '' - let match - const matches: MatchInfo[] = [] - while ((match = regex.exec(textContent)) !== null) { - if (typeof match.index === 'number' && typeof match[0] === 'string') { - matches.push({ index: match.index, length: match[0].length, text: match[0] }) - } else { - console.error('Unexpected match format:', match) - } + // 1. 拼接所有文本节点内容 + while (treeWalker.nextNode()) { + allTextNodes.push({ + node: treeWalker.currentNode, + startOffset: fullText.length + }) + fullText += treeWalker.currentNode.nodeValue } - if (matches.length === 0) { - return null - } + // 2.在完整文本中查找匹配项 + let match: RegExpExecArray | null = null + while ((match = searchRegex.exec(fullText))) { + const matchStart = match.index + const matchEnd = matchStart + match[0].length - const parentNode = textNode.parentNode - if (!parentNode) { - return null - } + // 3. 将匹配项的索引映射回DOM Range + let startNode: Node | null = null + let endNode: Node | null = null + let startOffset = 0 + let endOffset = 0 - const fragment = document.createDocumentFragment() - let currentIndex = 0 - const highlightTextSet = new Set() - - matches.forEach(({ index, length, text }) => { - if (index > currentIndex) { - fragment.appendChild(document.createTextNode(textContent.substring(currentIndex, index))) - } - const highlightSpan = document.createElement('span') - highlightSpan.className = highlightClass - highlightSpan.textContent = text // Use the matched text to preserve case if not case-sensitive - fragment.appendChild(highlightSpan) - highlightTextSet.add(highlightSpan) - currentIndex = index + length - }) - - if (currentIndex < textContent.length) { - fragment.appendChild(document.createTextNode(textContent.substring(currentIndex))) - } - - parentNode.replaceChild(fragment, textNode) - return [...highlightTextSet] -} - -const mergeAdjacentTextNodes = (node: HTMLElement) => { - const children = Array.from(node.childNodes) - const groups: Array = [] - let currentTextGroup: { text: string; nodes: Node[] } | null = null - - for (const child of children) { - if (child.nodeType === Node.TEXT_NODE) { - if (currentTextGroup === null) { - currentTextGroup = { - text: child.textContent ?? '', - nodes: [child] - } - } else { - currentTextGroup.text += child.textContent - currentTextGroup.nodes.push(child) + // 找到起始节点和偏移 + for (const nodeInfo of allTextNodes) { + if ( + matchStart >= nodeInfo.startOffset && + matchStart < nodeInfo.startOffset + (nodeInfo.node.nodeValue?.length ?? 0) + ) { + startNode = nodeInfo.node + startOffset = matchStart - nodeInfo.startOffset + break } - } else { - if (currentTextGroup !== null) { - groups.push(currentTextGroup!) - currentTextGroup = null + } + + // 找到结束节点和偏移 + for (const nodeInfo of allTextNodes) { + if ( + matchEnd > nodeInfo.startOffset && + matchEnd <= nodeInfo.startOffset + (nodeInfo.node.nodeValue?.length ?? 0) + ) { + endNode = nodeInfo.node + endOffset = matchEnd - nodeInfo.startOffset + break } - groups.push(child) + } + + // 如果起始和结束节点都找到了,则创建一个 Range + if (startNode && endNode) { + const range = new Range() + range.setStart(startNode, startOffset) + range.setEnd(endNode, endOffset) + ranges.push(range) } } - if (currentTextGroup !== null) { - groups.push(currentTextGroup) - } - - const newChildren = groups.map((group) => { - if (group instanceof Node) { - return group - } else { - return document.createTextNode(group.text) - } - }) - - node.replaceChildren(...newChildren) + return ranges } // eslint-disable-next-line @eslint-react/no-forward-ref @@ -206,328 +135,178 @@ export const ContentSearch = React.forwardRef( })() const containerRef = React.useRef(null) const searchInputRef = React.useRef(null) - const [searchResultIndex, setSearchResultIndex] = useState(0) - const [totalCount, setTotalCount] = useState(0) const [enableContentSearch, setEnableContentSearch] = useState(false) const [searchCompleted, setSearchCompleted] = useState(SearchCompletedState.NotSearched) const [isCaseSensitive, setIsCaseSensitive] = useState(false) const [isWholeWord, setIsWholeWord] = useState(false) - const [shouldScroll, setShouldScroll] = useState(false) - const highlightTextSet = useState(new Set())[0] + const [allRanges, setAllRanges] = useState([]) + const [currentIndex, setCurrentIndex] = useState(0) const prevSearchText = useRef('') const { t } = useTranslation() - const locateByIndex = (index: number, shouldScroll = true) => { - if (target) { - const highlightTextNodes = [...highlightTextSet] as HTMLElement[] - highlightTextNodes.sort((a, b) => { - const { top: aTop } = a.getBoundingClientRect() - const { top: bTop } = b.getBoundingClientRect() - return aTop - bTop - }) - for (const node of highlightTextNodes) { - node.classList.remove(HIGHLIGHT_SELECT_CLASS) - } - setSearchResultIndex(index) - if (highlightTextNodes.length > 0) { - const highlightTextNode = highlightTextNodes[index] ?? null - if (highlightTextNode) { - highlightTextNode.classList.add(HIGHLIGHT_SELECT_CLASS) + const resetSearch = useCallback(() => { + CSS.highlights.clear() + setAllRanges([]) + setSearchCompleted(SearchCompletedState.NotSearched) + }, []) + + const locateByIndex = useCallback( + (shouldScroll = true) => { + // 清理旧的高亮 + CSS.highlights.clear() + + if (allRanges.length > 0) { + // 1. 创建并注册所有匹配项的高亮 + const allMatchesHighlight = new Highlight(...allRanges) + CSS.highlights.set('search-matches', allMatchesHighlight) + + // 2. 如果有当前项,为其创建并注册一个特殊的高亮 + if (currentIndex !== -1 && allRanges[currentIndex]) { + const currentMatchRange = allRanges[currentIndex] + const currentMatchHighlight = new Highlight(currentMatchRange) + CSS.highlights.set('current-match', currentMatchHighlight) + + // 3. 将当前项滚动到视图中 + // 获取第一个文本节点的父元素来进行滚动 + const parentElement = currentMatchRange.startContainer.parentElement if (shouldScroll) { - highlightTextNode.scrollIntoView({ + parentElement?.scrollIntoView({ behavior: 'smooth', - block: 'center' - // inline: 'center' 水平方向居中可能会导致 content 页面整体偏右, 使得左半部的内容被遮挡. 因此先注释掉该代码 + block: 'center', + inline: 'nearest' }) } } } - } - } + }, + [allRanges, currentIndex] + ) - const restoreHighlight = () => { - const highlightTextParentNodeSet = new Set() - // Make a copy because the set might be modified during iteration indirectly - const nodesToRestore = [...highlightTextSet] - for (const highlightTextNode of nodesToRestore) { - if (highlightTextNode.textContent) { - const textNode = document.createTextNode(highlightTextNode.textContent) - const node = highlightTextNode as HTMLElement - if (node.parentNode) { - highlightTextParentNodeSet.add(node.parentNode as HTMLElement) - node.replaceWith(textNode) // This removes the node from the DOM - } - } - } - highlightTextSet.clear() // Clear the original set after processing - for (const parentNode of highlightTextParentNodeSet) { - mergeAdjacentTextNodes(parentNode) - } - // highlightTextSet.clear() // Already cleared - } - - const search = (searchTargetIndex?: SearchTargetIndex): number | null => { + const search = useCallback(() => { const searchText = searchInputRef.current?.value.trim() ?? null + setSearchCompleted(SearchCompletedState.Searched) if (target && searchText !== null && searchText !== '') { - restoreHighlight() - const iter = document.createNodeIterator(target, NodeFilter.SHOW_TEXT) - let textNode: Node | null - const textNodeSet: Set = new Set() - while ((textNode = iter.nextNode())) { - if (filter(textNode)) { - textNodeSet.add(textNode) - } - } - - const highlightTextSetTemp = new Set() - for (const node of textNodeSet) { - const list = highlightText(node, searchText, HIGHLIGHT_CLASS, isCaseSensitive, isWholeWord) - if (list) { - list.forEach((node) => highlightTextSetTemp.add(node)) - } - } - const highlightTextList = [...highlightTextSetTemp] - setTotalCount(highlightTextList.length) - highlightTextSetTemp.forEach((node) => highlightTextSet.add(node)) - const changeIndex = () => { - let index: number - switch (searchTargetIndex) { - case SearchTargetIndex.Next: - { - index = (searchResultIndex + 1) % highlightTextList.length - } - break - case SearchTargetIndex.Prev: - { - index = (searchResultIndex - 1 + highlightTextList.length) % highlightTextList.length - } - break - default: { - index = searchResultIndex - } - } - return Math.max(index, 0) - } - - const targetIndex = (() => { - switch (searchCompleted) { - case SearchCompletedState.NotSearched: { - setSearchCompleted(SearchCompletedState.FirstSearched) - const index = findWindowVerticalCenterElementIndex(highlightTextList) - if (index !== null) { - setSearchResultIndex(index) - return index - } else { - setSearchResultIndex(0) - return 0 - } - } - case SearchCompletedState.FirstSearched: { - return changeIndex() - } - default: { - return null - } - } - })() - - if (targetIndex === null) { - return null - } else { - const totalCount = highlightTextSet.size - if (targetIndex >= totalCount) { - return totalCount - 1 - } else { - return targetIndex - } - } - } else { - return null + const ranges = findRangesInTarget(target, filter, searchText, isCaseSensitive, isWholeWord) + setAllRanges(ranges) + setCurrentIndex(0) } - } + }, [target, filter, isCaseSensitive, isWholeWord]) - const _searchHandlerDebounce = debounce(() => { - implementation.search() - }, 300) - const searchHandler = useCallback(_searchHandlerDebounce, [_searchHandlerDebounce]) - const userInputHandler = (event: React.ChangeEvent) => { - const value = event.target.value.trim() - if (value.length === 0) { - restoreHighlight() - setTotalCount(0) - setSearchResultIndex(0) - setSearchCompleted(SearchCompletedState.NotSearched) - } else { - // 用户输入时允许滚动 - setShouldScroll(true) - searchHandler() - } - prevSearchText.current = value - } - - const keyDownHandler = (event: React.KeyboardEvent) => { - const { code, key, shiftKey } = event - if (key === 'Process') { - return - } - - switch (code) { - case 'Enter': - { - if (shiftKey) { - implementation.searchPrev() - } else { - implementation.searchNext() - } - event.preventDefault() - } - break - case 'Escape': - { - implementation.disable() - } - break - } - } - - const searchInputFocus = () => requestAnimationFrame(() => searchInputRef.current?.focus()) - - const userOutlinedButtonOnClick = () => { - if (onIncludeUserChange) { - onIncludeUserChange(!includeUser) - } - searchInputFocus() - } - - // eslint-disable-next-line react-hooks/exhaustive-deps - const implementation = { - disable() { - setEnableContentSearch(false) - restoreHighlight() - setShouldScroll(false) - }, - enable(initialText?: string) { - setEnableContentSearch(true) - setShouldScroll(false) // Default to false, search itself might set it to true - if (searchInputRef.current) { - const inputEl = searchInputRef.current - if (initialText && initialText.trim().length > 0) { - inputEl.value = initialText - // Trigger search after setting initial text - // Need to make sure search() uses the new value - // and also to focus and select - requestAnimationFrame(() => { - inputEl.focus() - inputEl.select() - setShouldScroll(true) - const targetIndex = search() - if (targetIndex !== null) { - locateByIndex(targetIndex, true) // Ensure scrolling - } else { - // If search returns null (e.g., empty input or no matches with initial text), clear state - restoreHighlight() - setTotalCount(0) - setSearchResultIndex(0) + const implementation = useMemo( + () => ({ + disable: () => { + setEnableContentSearch(false) + CSS.highlights.clear() + }, + enable: (initialText?: string) => { + setEnableContentSearch(true) + if (searchInputRef.current) { + const inputEl = searchInputRef.current + if (initialText && initialText.trim().length > 0) { + inputEl.value = initialText + requestAnimationFrame(() => { + inputEl.focus() + inputEl.select() + search() + CSS.highlights.clear() setSearchCompleted(SearchCompletedState.NotSearched) - } - }) - } else { - requestAnimationFrame(() => { - inputEl.focus() - inputEl.select() - }) - // Only search if there's existing text and no new initialText - if (inputEl.value.trim()) { - const targetIndex = search() - if (targetIndex !== null) { - setSearchResultIndex(targetIndex) - // locateByIndex(targetIndex, false); // Don't scroll if just enabling with existing text - } + }) + } else { + requestAnimationFrame(() => { + inputEl.focus() + inputEl.select() + }) } } - } - }, - searchNext() { - if (enableContentSearch) { - const targetIndex = search(SearchTargetIndex.Next) - if (targetIndex !== null) { - locateByIndex(targetIndex) + }, + searchNext: () => { + if (allRanges.length > 0) { + setCurrentIndex((prev) => (prev < allRanges.length - 1 ? prev + 1 : 0)) } - } - }, - searchPrev() { - if (enableContentSearch) { - const targetIndex = search(SearchTargetIndex.Prev) - if (targetIndex !== null) { - locateByIndex(targetIndex) + }, + searchPrev: () => { + if (allRanges.length > 0) { + setCurrentIndex((prev) => (prev > 0 ? prev - 1 : allRanges.length - 1)) } - } - }, - resetSearchState() { - if (enableContentSearch) { + }, + resetSearchState: () => { setSearchCompleted(SearchCompletedState.NotSearched) - // Maybe also reset index? Depends on desired behavior - // setSearchResultIndex(0); + }, + search: () => { + search() + locateByIndex(true) + }, + silentSearch: () => { + search() + locateByIndex(false) + }, + focus: () => { + searchInputRef.current?.focus() } + }), + [allRanges.length, locateByIndex, search] + ) + + const _searchHandlerDebounce = useMemo(() => debounce(implementation.search, 300), [implementation.search]) + + const searchHandler = useCallback(() => { + _searchHandlerDebounce() + }, [_searchHandlerDebounce]) + + const userInputHandler = useCallback( + (event: React.ChangeEvent) => { + const value = event.target.value.trim() + if (value.length === 0) { + resetSearch() + } else { + searchHandler() + } + prevSearchText.current = value }, - search() { - if (enableContentSearch) { - const targetIndex = search() - if (targetIndex !== null) { - locateByIndex(targetIndex, shouldScroll) + [searchHandler, resetSearch] + ) + + const keyDownHandler = useCallback( + (event: React.KeyboardEvent) => { + if (event.key === 'Enter') { + event.preventDefault() + const value = (event.target as HTMLInputElement).value.trim() + if (value.length === 0) { + resetSearch() + return + } + if (event.shiftKey) { + implementation.searchPrev() } else { - // If search returns null (e.g., empty input), clear state - restoreHighlight() - setTotalCount(0) - setSearchResultIndex(0) - setSearchCompleted(SearchCompletedState.NotSearched) + implementation.searchNext() } + } else if (event.key === 'Escape') { + implementation.disable() } }, - silentSearch() { - if (enableContentSearch) { - const targetIndex = search() - if (targetIndex !== null) { - // 只更新索引,不触发滚动 - locateByIndex(targetIndex, false) - } - } - }, - focus() { - searchInputFocus() - } - } + [implementation, resetSearch] + ) - useImperativeHandle(ref, () => ({ - disable() { - implementation.disable() - }, - enable(initialText?: string) { - implementation.enable(initialText) - }, - searchNext() { - implementation.searchNext() - }, - searchPrev() { - implementation.searchPrev() - }, - search() { - implementation.search() - }, - silentSearch() { - implementation.silentSearch() - }, - focus() { - implementation.focus() - } - })) + const searchInputFocus = useCallback(() => { + requestAnimationFrame(() => searchInputRef.current?.focus()) + }, []) + + const userOutlinedButtonOnClick = useCallback(() => { + onIncludeUserChange?.(!includeUser) + searchInputFocus() + }, [includeUser, onIncludeUserChange, searchInputFocus]) + + useImperativeHandle(ref, () => implementation, [implementation]) + + useEffect(() => { + locateByIndex() + }, [currentIndex, locateByIndex]) - // Re-run search when options change and search is active useEffect(() => { if (enableContentSearch && searchInputRef.current?.value.trim()) { - implementation.search() + search() } - }, [isCaseSensitive, isWholeWord, enableContentSearch, implementation]) // Add enableContentSearch dependency + }, [isCaseSensitive, isWholeWord, enableContentSearch, search]) const prevButtonOnClick = () => { implementation.searchPrev() @@ -589,11 +368,11 @@ export const ContentSearch = React.forwardRef( {searchCompleted !== SearchCompletedState.NotSearched ? ( - totalCount > 0 ? ( + allRanges.length > 0 ? ( <> - {searchResultIndex + 1} + {currentIndex + 1} / - {totalCount} + {allRanges.length} ) : ( {t('common.no_results')} @@ -603,10 +382,10 @@ export const ContentSearch = React.forwardRef( )} - + - + diff --git a/src/renderer/src/pages/home/Chat.tsx b/src/renderer/src/pages/home/Chat.tsx index fb623b62a5..2639a06387 100644 --- a/src/renderer/src/pages/home/Chat.tsx +++ b/src/renderer/src/pages/home/Chat.tsx @@ -55,28 +55,30 @@ const Chat: FC = (props) => { } }) - const contentSearchFilter = (node: Node): boolean => { - if (node.parentNode) { - let parentNode: HTMLElement | null = node.parentNode as HTMLElement - while (parentNode?.parentNode) { - if (parentNode.classList.contains('MessageFooter')) { - return false - } + const contentSearchFilter: NodeFilter = { + acceptNode(node) { + if (node.parentNode) { + let parentNode: HTMLElement | null = node.parentNode as HTMLElement + while (parentNode?.parentNode) { + if (parentNode.classList.contains('MessageFooter')) { + return NodeFilter.FILTER_REJECT + } - if (filterIncludeUser) { - if (parentNode?.classList.contains('message-content-container')) { - return true - } - } else { - if (parentNode?.classList.contains('message-content-container-assistant')) { - return true + if (filterIncludeUser) { + if (parentNode?.classList.contains('message-content-container')) { + return NodeFilter.FILTER_ACCEPT + } + } else { + if (parentNode?.classList.contains('message-content-container-assistant')) { + return NodeFilter.FILTER_ACCEPT + } } + parentNode = parentNode.parentNode as HTMLElement } - parentNode = parentNode.parentNode as HTMLElement + return NodeFilter.FILTER_REJECT + } else { + return NodeFilter.FILTER_REJECT } - return false - } else { - return false } } From 27d22e90d4c96f4aca396337b202df17ca8b3390 Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Sat, 28 Jun 2025 20:38:53 +0800 Subject: [PATCH 085/111] chore(version): 1.4.7 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index c6eac03d52..88c2295312 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "CherryStudio", - "version": "1.4.6", + "version": "1.4.7", "private": true, "description": "A powerful AI assistant for producer.", "main": "./out/main/index.js", From 3f5901766d4d7d0a7780185c7f2d67c455dc274f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?George=C2=B7Dong?= <98630204+GeorgeDong32@users.noreply.github.com> Date: Sat, 28 Jun 2025 22:19:37 +0800 Subject: [PATCH 086/111] feat: Add S3 Backup (#6802) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: import opendal * feat: 添加S3备份支持及相关设置界面 - 在IpcChannel中新增S3备份相关IPC事件,支持备份、恢复、 列表、删除文件及连接检测 - 在ipc主进程注册对应的S3备份处理函数,集成backupManager - 新增S3设置页面,支持配置Endpoint、Region、Bucket、AccessKey等 参数,并提供同步和备份策略的UI控制 - 删除未使用的RemoteStorage.ts,简化代码库 提升备份功能的灵活性,支持S3作为远程存储目标 * feat(S3 Backup): 完善S3备份功能 - 支持自动备份 - 优化设置前端 - 优化备份恢复代码 * feat(i18n): add S3 storage translations * feat(settings): 优化数据设置页面和S3设置页面UI * feat(settings): optimize S3 settings state structure and update usage * refactor: simplify S3 backup and restore modal logic * feat(s3 backup): improve S3 settings defaults and modal props * fix(i18n): optimize S3 access key translations * feat(backup): optimize logging and progress reporting * fix(settings): set S3 maxBackups as unlimited by default * chore(package): restore opendal dependency in package.json --------- Co-authored-by: suyao --- package.json | 1 + packages/shared/IpcChannel.ts | 5 + src/main/ipc.ts | 5 + src/main/services/BackupManager.ts | 234 +++++++++-- src/main/services/RemoteStorage.ts | 126 +++--- src/preload/index.ts | 19 +- .../src/components/S3BackupManager.tsx | 298 ++++++++++++++ src/renderer/src/components/S3Modals.tsx | 258 ++++++++++++ src/renderer/src/i18n/locales/en-us.json | 64 +++ src/renderer/src/i18n/locales/ja-jp.json | 64 +++ src/renderer/src/i18n/locales/ru-ru.json | 64 +++ src/renderer/src/i18n/locales/zh-cn.json | 66 +++- src/renderer/src/i18n/locales/zh-tw.json | 66 +++- src/renderer/src/init.ts | 4 +- .../settings/DataSettings/DataSettings.tsx | 10 +- .../settings/DataSettings/S3Settings.tsx | 276 +++++++++++++ src/renderer/src/services/BackupService.ts | 366 ++++++++++++++++-- src/renderer/src/store/backup.ts | 11 +- src/renderer/src/store/settings.ts | 34 +- src/renderer/src/types/index.ts | 12 + yarn.lock | 80 ++++ 21 files changed, 1941 insertions(+), 122 deletions(-) create mode 100644 src/renderer/src/components/S3BackupManager.tsx create mode 100644 src/renderer/src/components/S3Modals.tsx create mode 100644 src/renderer/src/pages/settings/DataSettings/S3Settings.tsx diff --git a/package.json b/package.json index 88c2295312..5455f348b2 100644 --- a/package.json +++ b/package.json @@ -181,6 +181,7 @@ "npx-scope-finder": "^1.2.0", "officeparser": "^4.1.1", "openai": "patch:openai@npm%3A5.1.0#~/.yarn/patches/openai-npm-5.1.0-0e7b3ccb07.patch", + "opendal": "0.47.11", "p-queue": "^8.1.0", "playwright": "^1.52.0", "prettier": "^3.5.3", diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index daea5dad6e..ca49bd40c5 100644 --- a/packages/shared/IpcChannel.ts +++ b/packages/shared/IpcChannel.ts @@ -153,6 +153,11 @@ export enum IpcChannel { Backup_CheckConnection = 'backup:checkConnection', Backup_CreateDirectory = 'backup:createDirectory', Backup_DeleteWebdavFile = 'backup:deleteWebdavFile', + Backup_BackupToS3 = 'backup:backupToS3', + Backup_RestoreFromS3 = 'backup:restoreFromS3', + Backup_ListS3Files = 'backup:listS3Files', + Backup_DeleteS3File = 'backup:deleteS3File', + Backup_CheckS3Connection = 'backup:checkS3Connection', // zip Zip_Compress = 'zip:compress', diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 8c6810bcdc..af043c7c8c 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -344,6 +344,11 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { ipcMain.handle(IpcChannel.Backup_CheckConnection, backupManager.checkConnection) ipcMain.handle(IpcChannel.Backup_CreateDirectory, backupManager.createDirectory) ipcMain.handle(IpcChannel.Backup_DeleteWebdavFile, backupManager.deleteWebdavFile) + ipcMain.handle(IpcChannel.Backup_BackupToS3, backupManager.backupToS3) + ipcMain.handle(IpcChannel.Backup_RestoreFromS3, backupManager.restoreFromS3) + ipcMain.handle(IpcChannel.Backup_ListS3Files, backupManager.listS3Files) + ipcMain.handle(IpcChannel.Backup_DeleteS3File, backupManager.deleteS3File) + ipcMain.handle(IpcChannel.Backup_CheckS3Connection, backupManager.checkS3Connection) // file ipcMain.handle(IpcChannel.File_Open, fileManager.open) diff --git a/src/main/services/BackupManager.ts b/src/main/services/BackupManager.ts index e994e90bed..6e0c813e6d 100644 --- a/src/main/services/BackupManager.ts +++ b/src/main/services/BackupManager.ts @@ -1,5 +1,6 @@ import { IpcChannel } from '@shared/IpcChannel' import { WebDavConfig } from '@types' +import { S3Config } from '@types' import archiver from 'archiver' import { exec } from 'child_process' import { app } from 'electron' @@ -10,6 +11,7 @@ import * as path from 'path' import { CreateDirectoryOptions, FileStat } from 'webdav' import { getDataPath } from '../utils' +import S3Storage from './RemoteStorage' import WebDav from './WebDav' import { windowService } from './WindowService' @@ -25,6 +27,11 @@ class BackupManager { this.restoreFromWebdav = this.restoreFromWebdav.bind(this) this.listWebdavFiles = this.listWebdavFiles.bind(this) this.deleteWebdavFile = this.deleteWebdavFile.bind(this) + this.backupToS3 = this.backupToS3.bind(this) + this.restoreFromS3 = this.restoreFromS3.bind(this) + this.listS3Files = this.listS3Files.bind(this) + this.deleteS3File = this.deleteS3File.bind(this) + this.checkS3Connection = this.checkS3Connection.bind(this) } private async setWritableRecursive(dirPath: string): Promise { @@ -85,7 +92,11 @@ class BackupManager { const onProgress = (processData: { stage: string; progress: number; total: number }) => { mainWindow?.webContents.send(IpcChannel.BackupProgress, processData) - Logger.log('[BackupManager] backup progress', processData) + // 只在关键阶段记录日志:开始、结束和主要阶段转换点 + const logStages = ['preparing', 'writing_data', 'preparing_compression', 'completed'] + if (logStages.includes(processData.stage) || processData.progress === 100) { + Logger.log('[BackupManager] backup progress', processData) + } } try { @@ -147,18 +158,23 @@ class BackupManager { let totalBytes = 0 let processedBytes = 0 - // 首先计算总文件数和总大小 + // 首先计算总文件数和总大小,但不记录详细日志 const calculateTotals = async (dirPath: string) => { - const items = await fs.readdir(dirPath, { withFileTypes: true }) - for (const item of items) { - const fullPath = path.join(dirPath, item.name) - if (item.isDirectory()) { - await calculateTotals(fullPath) - } else { - totalEntries++ - const stats = await fs.stat(fullPath) - totalBytes += stats.size + try { + const items = await fs.readdir(dirPath, { withFileTypes: true }) + for (const item of items) { + const fullPath = path.join(dirPath, item.name) + if (item.isDirectory()) { + await calculateTotals(fullPath) + } else { + totalEntries++ + const stats = await fs.stat(fullPath) + totalBytes += stats.size + } } + } catch (error) { + // 仅在出错时记录日志 + Logger.error('[BackupManager] Error calculating totals:', error) } } @@ -230,7 +246,11 @@ class BackupManager { const onProgress = (processData: { stage: string; progress: number; total: number }) => { mainWindow?.webContents.send(IpcChannel.RestoreProgress, processData) - Logger.log('[BackupManager] restore progress', processData) + // 只在关键阶段记录日志 + const logStages = ['preparing', 'extracting', 'extracted', 'reading_data', 'completed'] + if (logStages.includes(processData.stage) || processData.progress === 100) { + Logger.log('[BackupManager] restore progress', processData) + } } try { @@ -382,21 +402,54 @@ class BackupManager { destination: string, onProgress: (size: number) => void ): Promise { - const items = await fs.readdir(source, { withFileTypes: true }) + // 先统计总文件数 + let totalFiles = 0 + let processedFiles = 0 + let lastProgressReported = 0 - for (const item of items) { - const sourcePath = path.join(source, item.name) - const destPath = path.join(destination, item.name) + // 计算总文件数 + const countFiles = async (dir: string): Promise => { + let count = 0 + const items = await fs.readdir(dir, { withFileTypes: true }) + for (const item of items) { + if (item.isDirectory()) { + count += await countFiles(path.join(dir, item.name)) + } else { + count++ + } + } + return count + } - if (item.isDirectory()) { - await fs.ensureDir(destPath) - await this.copyDirWithProgress(sourcePath, destPath, onProgress) - } else { - const stats = await fs.stat(sourcePath) - await fs.copy(sourcePath, destPath) - onProgress(stats.size) + totalFiles = await countFiles(source) + + // 复制文件并更新进度 + const copyDir = async (src: string, dest: string): Promise => { + const items = await fs.readdir(src, { withFileTypes: true }) + + for (const item of items) { + const sourcePath = path.join(src, item.name) + const destPath = path.join(dest, item.name) + + if (item.isDirectory()) { + await fs.ensureDir(destPath) + await copyDir(sourcePath, destPath) + } else { + const stats = await fs.stat(sourcePath) + await fs.copy(sourcePath, destPath) + processedFiles++ + + // 只在进度变化超过5%时报告进度 + const currentProgress = Math.floor((processedFiles / totalFiles) * 100) + if (currentProgress - lastProgressReported >= 5 || processedFiles === totalFiles) { + lastProgressReported = currentProgress + onProgress(stats.size) + } + } } } + + await copyDir(source, destination) } async checkConnection(_: Electron.IpcMainInvokeEvent, webdavConfig: WebDavConfig) { @@ -423,6 +476,141 @@ class BackupManager { throw new Error(error.message || 'Failed to delete backup file') } } + + async backupToS3(_: Electron.IpcMainInvokeEvent, data: string, s3Config: S3Config) { + // 获取设备名 + const os = require('os') + const deviceName = os.hostname ? os.hostname() : 'device' + const timestamp = new Date() + .toISOString() + .replace(/[-:T.Z]/g, '') + .slice(0, 14) + const filename = s3Config.fileName || `cherry-studio.backup.${deviceName}.${timestamp}.zip` + + // 不记录详细日志,只记录开始和结束 + Logger.log(`[BackupManager] Starting S3 backup to ${filename}`) + + const backupedFilePath = await this.backup(_, filename, data, undefined, s3Config.skipBackupFile) + const s3Client = new S3Storage('s3', { + endpoint: s3Config.endpoint, + region: s3Config.region, + bucket: s3Config.bucket, + access_key_id: s3Config.access_key_id, + secret_access_key: s3Config.secret_access_key, + root: s3Config.root || '' + }) + try { + const fileBuffer = await fs.promises.readFile(backupedFilePath) + const result = await s3Client.putFileContents(filename, fileBuffer) + await fs.remove(backupedFilePath) + + Logger.log(`[BackupManager] S3 backup completed successfully: ${filename}`) + return result + } catch (error) { + Logger.error(`[BackupManager] S3 backup failed:`, error) + await fs.remove(backupedFilePath) + throw error + } + } + + async restoreFromS3(_: Electron.IpcMainInvokeEvent, s3Config: S3Config) { + const filename = s3Config.fileName || 'cherry-studio.backup.zip' + + // 只记录开始和结束或错误 + Logger.log(`[BackupManager] Starting restore from S3: ${filename}`) + + const s3Client = new S3Storage('s3', { + endpoint: s3Config.endpoint, + region: s3Config.region, + bucket: s3Config.bucket, + access_key_id: s3Config.access_key_id, + secret_access_key: s3Config.secret_access_key, + root: s3Config.root || '' + }) + try { + const retrievedFile = await s3Client.getFileContents(filename) + const backupedFilePath = path.join(this.backupDir, filename) + if (!fs.existsSync(this.backupDir)) { + fs.mkdirSync(this.backupDir, { recursive: true }) + } + await new Promise((resolve, reject) => { + const writeStream = fs.createWriteStream(backupedFilePath) + writeStream.write(retrievedFile as Buffer) + writeStream.end() + writeStream.on('finish', () => resolve()) + writeStream.on('error', (error) => reject(error)) + }) + + Logger.log(`[BackupManager] S3 restore file downloaded successfully: ${filename}`) + return await this.restore(_, backupedFilePath) + } catch (error: any) { + Logger.error('[BackupManager] Failed to restore from S3:', error) + throw new Error(error.message || 'Failed to restore backup file') + } + } + + listS3Files = async (_: Electron.IpcMainInvokeEvent, s3Config: S3Config) => { + try { + const s3Client = new S3Storage('s3', { + endpoint: s3Config.endpoint, + region: s3Config.region, + bucket: s3Config.bucket, + access_key_id: s3Config.access_key_id, + secret_access_key: s3Config.secret_access_key, + root: s3Config.root || '' + }) + const entries = await s3Client.instance?.list('/') + const files: Array<{ fileName: string; modifiedTime: string; size: number }> = [] + if (entries) { + for await (const entry of entries) { + const path = entry.path() + if (path.endsWith('.zip')) { + const meta = await s3Client.instance!.stat(path) + if (meta.isFile()) { + files.push({ + fileName: path.replace(/^\/+/, ''), + modifiedTime: meta.lastModified || '', + size: Number(meta.contentLength || 0n) + }) + } + } + } + } + return files.sort((a, b) => new Date(b.modifiedTime).getTime() - new Date(a.modifiedTime).getTime()) + } catch (error: any) { + Logger.error('Failed to list S3 files:', error) + throw new Error(error.message || 'Failed to list backup files') + } + } + + async deleteS3File(_: Electron.IpcMainInvokeEvent, fileName: string, s3Config: S3Config) { + try { + const s3Client = new S3Storage('s3', { + endpoint: s3Config.endpoint, + region: s3Config.region, + bucket: s3Config.bucket, + access_key_id: s3Config.access_key_id, + secret_access_key: s3Config.secret_access_key, + root: s3Config.root || '' + }) + return await s3Client.deleteFile(fileName) + } catch (error: any) { + Logger.error('Failed to delete S3 file:', error) + throw new Error(error.message || 'Failed to delete backup file') + } + } + + async checkS3Connection(_: Electron.IpcMainInvokeEvent, s3Config: S3Config) { + const s3Client = new S3Storage('s3', { + endpoint: s3Config.endpoint, + region: s3Config.region, + bucket: s3Config.bucket, + access_key_id: s3Config.access_key_id, + secret_access_key: s3Config.secret_access_key, + root: s3Config.root || '' + }) + return await s3Client.checkConnection() + } } export default BackupManager diff --git a/src/main/services/RemoteStorage.ts b/src/main/services/RemoteStorage.ts index b62489bbbe..4efc57b6c6 100644 --- a/src/main/services/RemoteStorage.ts +++ b/src/main/services/RemoteStorage.ts @@ -1,57 +1,83 @@ -// import Logger from 'electron-log' -// import { Operator } from 'opendal' +import Logger from 'electron-log' +import type { Operator as OperatorType } from 'opendal' +const { Operator } = require('opendal') -// export default class RemoteStorage { -// public instance: Operator | undefined +export default class S3Storage { + public instance: OperatorType | undefined -// /** -// * -// * @param scheme is the scheme for opendal services. Available value includes "azblob", "azdls", "cos", "gcs", "obs", "oss", "s3", "webdav", "webhdfs", "aliyun-drive", "alluxio", "azfile", "dropbox", "gdrive", "onedrive", "postgresql", "mysql", "redis", "swift", "mongodb", "alluxio", "b2", "seafile", "upyun", "koofr", "yandex-disk" -// * @param options is the options for given opendal services. Valid options depend on the scheme. Checkout https://docs.rs/opendal/latest/opendal/services/index.html for all valid options. -// * -// * For example, use minio as remote storage: -// * -// * ```typescript -// * const storage = new RemoteStorage('s3', { -// * endpoint: 'http://localhost:9000', -// * region: 'us-east-1', -// * bucket: 'testbucket', -// * access_key_id: 'user', -// * secret_access_key: 'password', -// * root: '/path/to/basepath', -// * }) -// * ``` -// */ -// constructor(scheme: string, options?: Record | undefined | null) { -// this.instance = new Operator(scheme, options) + /** + * + * @param scheme is the scheme for opendal services. Available value includes "azblob", "azdls", "cos", "gcs", "obs", "oss", "s3", "webdav", "webhdfs", "aliyun-drive", "alluxio", "azfile", "dropbox", "gdrive", "onedrive", "postgresql", "mysql", "redis", "swift", "mongodb", "alluxio", "b2", "seafile", "upyun", "koofr", "yandex-disk" + * @param options is the options for given opendal services. Valid options depend on the scheme. Checkout https://docs.rs/opendal/latest/opendal/services/index.html for all valid options. + * + * For example, use minio as remote storage: + * + * ```typescript + * const storage = new S3Storage('s3', { + * endpoint: 'http://localhost:9000', + * region: 'us-east-1', + * bucket: 'testbucket', + * access_key_id: 'user', + * secret_access_key: 'password', + * root: '/path/to/basepath', + * }) + * ``` + */ + constructor(scheme: string, options?: Record | undefined | null) { + this.instance = new Operator(scheme, options) -// this.putFileContents = this.putFileContents.bind(this) -// this.getFileContents = this.getFileContents.bind(this) -// } + this.putFileContents = this.putFileContents.bind(this) + this.getFileContents = this.getFileContents.bind(this) + } -// public putFileContents = async (filename: string, data: string | Buffer) => { -// if (!this.instance) { -// return new Error('RemoteStorage client not initialized') -// } + public putFileContents = async (filename: string, data: string | Buffer) => { + if (!this.instance) { + return new Error('RemoteStorage client not initialized') + } -// try { -// return await this.instance.write(filename, data) -// } catch (error) { -// Logger.error('[RemoteStorage] Error putting file contents:', error) -// throw error -// } -// } + try { + return await this.instance.write(filename, data) + } catch (error) { + Logger.error('[RemoteStorage] Error putting file contents:', error) + throw error + } + } -// public getFileContents = async (filename: string) => { -// if (!this.instance) { -// throw new Error('RemoteStorage client not initialized') -// } + public getFileContents = async (filename: string) => { + if (!this.instance) { + throw new Error('RemoteStorage client not initialized') + } -// try { -// return await this.instance.read(filename) -// } catch (error) { -// Logger.error('[RemoteStorage] Error getting file contents:', error) -// throw error -// } -// } -// } + try { + return await this.instance.read(filename) + } catch (error) { + Logger.error('[RemoteStorage] Error getting file contents:', error) + throw error + } + } + + public deleteFile = async (filename: string) => { + if (!this.instance) { + throw new Error('RemoteStorage client not initialized') + } + try { + return await this.instance.delete(filename) + } catch (error) { + Logger.error('[RemoteStorage] Error deleting file:', error) + throw error + } + } + + public checkConnection = async () => { + if (!this.instance) { + throw new Error('RemoteStorage client not initialized') + } + try { + // 检查根目录是否可访问 + return await this.instance.stat('/') + } catch (error) { + Logger.error('[RemoteStorage] Error checking connection:', error) + throw error + } + } +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 8412e00bc3..f6e49ece10 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -2,7 +2,16 @@ import type { ExtractChunkData } from '@cherrystudio/embedjs-interfaces' import { electronAPI } from '@electron-toolkit/preload' import { UpgradeChannel } from '@shared/config/constant' import { IpcChannel } from '@shared/IpcChannel' -import { FileType, KnowledgeBaseParams, KnowledgeItem, MCPServer, Shortcut, ThemeMode, WebDavConfig } from '@types' +import { + FileType, + KnowledgeBaseParams, + KnowledgeItem, + MCPServer, + S3Config, + Shortcut, + ThemeMode, + WebDavConfig +} from '@types' import { contextBridge, ipcRenderer, OpenDialogOptions, shell, webUtils } from 'electron' import { Notification } from 'src/renderer/src/types/notification' import { CreateDirectoryOptions } from 'webdav' @@ -71,7 +80,13 @@ const api = { createDirectory: (webdavConfig: WebDavConfig, path: string, options?: CreateDirectoryOptions) => ipcRenderer.invoke(IpcChannel.Backup_CreateDirectory, webdavConfig, path, options), deleteWebdavFile: (fileName: string, webdavConfig: WebDavConfig) => - ipcRenderer.invoke(IpcChannel.Backup_DeleteWebdavFile, fileName, webdavConfig) + ipcRenderer.invoke(IpcChannel.Backup_DeleteWebdavFile, fileName, webdavConfig), + backupToS3: (data: string, s3Config: S3Config) => ipcRenderer.invoke(IpcChannel.Backup_BackupToS3, data, s3Config), + restoreFromS3: (s3Config: S3Config) => ipcRenderer.invoke(IpcChannel.Backup_RestoreFromS3, s3Config), + listS3Files: (s3Config: S3Config) => ipcRenderer.invoke(IpcChannel.Backup_ListS3Files, s3Config), + deleteS3File: (fileName: string, s3Config: S3Config) => + ipcRenderer.invoke(IpcChannel.Backup_DeleteS3File, fileName, s3Config), + checkS3Connection: (s3Config: S3Config) => ipcRenderer.invoke(IpcChannel.Backup_CheckS3Connection, s3Config) }, file: { select: (options?: OpenDialogOptions) => ipcRenderer.invoke(IpcChannel.File_Select, options), diff --git a/src/renderer/src/components/S3BackupManager.tsx b/src/renderer/src/components/S3BackupManager.tsx new file mode 100644 index 0000000000..ecc9ed88ef --- /dev/null +++ b/src/renderer/src/components/S3BackupManager.tsx @@ -0,0 +1,298 @@ +import { DeleteOutlined, ExclamationCircleOutlined, ReloadOutlined } from '@ant-design/icons' +import { restoreFromS3 } from '@renderer/services/BackupService' +import { formatFileSize } from '@renderer/utils' +import { Button, Modal, Table, Tooltip } from 'antd' +import dayjs from 'dayjs' +import { useCallback, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' + +interface BackupFile { + fileName: string + modifiedTime: string + size: number +} + +interface S3Config { + endpoint: string + region: string + bucket: string + access_key_id: string + secret_access_key: string + root?: string +} + +interface S3BackupManagerProps { + visible: boolean + onClose: () => void + s3Config: { + endpoint?: string + region?: string + bucket?: string + access_key_id?: string + secret_access_key?: string + root?: string + } + restoreMethod?: (fileName: string) => Promise +} + +export function S3BackupManager({ visible, onClose, s3Config, restoreMethod }: S3BackupManagerProps) { + const [backupFiles, setBackupFiles] = useState([]) + const [loading, setLoading] = useState(false) + const [selectedRowKeys, setSelectedRowKeys] = useState([]) + const [deleting, setDeleting] = useState(false) + const [restoring, setRestoring] = useState(false) + const [pagination, setPagination] = useState({ + current: 1, + pageSize: 5, + total: 0 + }) + const { t } = useTranslation() + + const { endpoint, region, bucket, access_key_id, secret_access_key, root } = s3Config + + const fetchBackupFiles = useCallback(async () => { + if (!endpoint || !region || !bucket || !access_key_id || !secret_access_key) { + window.message.error(t('settings.data.s3.manager.config.incomplete')) + return + } + + setLoading(true) + try { + const files = await window.api.backup.listS3Files({ + endpoint, + region, + bucket, + access_key_id, + secret_access_key, + root + } as S3Config) + setBackupFiles(files) + setPagination((prev) => ({ + ...prev, + total: files.length + })) + } catch (error: any) { + window.message.error(t('settings.data.s3.manager.files.fetch.error', { message: error.message })) + } finally { + setLoading(false) + } + }, [endpoint, region, bucket, access_key_id, secret_access_key, root, t]) + + useEffect(() => { + if (visible) { + fetchBackupFiles() + setSelectedRowKeys([]) + setPagination((prev) => ({ + ...prev, + current: 1 + })) + } + }, [visible, fetchBackupFiles]) + + const handleTableChange = (pagination: any) => { + setPagination(pagination) + } + + const handleDeleteSelected = async () => { + if (selectedRowKeys.length === 0) { + window.message.warning(t('settings.data.s3.manager.select.warning')) + return + } + + if (!endpoint || !region || !bucket || !access_key_id || !secret_access_key) { + window.message.error(t('settings.data.s3.manager.config.incomplete')) + return + } + + window.modal.confirm({ + title: t('settings.data.s3.manager.delete.confirm.title'), + icon: , + content: t('settings.data.s3.manager.delete.confirm.multiple', { count: selectedRowKeys.length }), + okText: t('settings.data.s3.manager.delete.confirm.title'), + cancelText: t('common.cancel'), + centered: true, + onOk: async () => { + setDeleting(true) + try { + // 依次删除选中的文件 + for (const key of selectedRowKeys) { + await window.api.backup.deleteS3File(key.toString(), { + endpoint, + region, + bucket, + access_key_id, + secret_access_key, + root + } as S3Config) + } + window.message.success( + t('settings.data.s3.manager.delete.success.multiple', { count: selectedRowKeys.length }) + ) + setSelectedRowKeys([]) + await fetchBackupFiles() + } catch (error: any) { + window.message.error(t('settings.data.s3.manager.delete.error', { message: error.message })) + } finally { + setDeleting(false) + } + } + }) + } + + const handleDeleteSingle = async (fileName: string) => { + if (!endpoint || !region || !bucket || !access_key_id || !secret_access_key) { + window.message.error(t('settings.data.s3.manager.config.incomplete')) + return + } + + window.modal.confirm({ + title: t('settings.data.s3.manager.delete.confirm.title'), + icon: , + content: t('settings.data.s3.manager.delete.confirm.single', { fileName }), + okText: t('settings.data.s3.manager.delete.confirm.title'), + cancelText: t('common.cancel'), + centered: true, + onOk: async () => { + setDeleting(true) + try { + await window.api.backup.deleteS3File(fileName, { + endpoint, + region, + bucket, + access_key_id, + secret_access_key, + root + } as S3Config) + window.message.success(t('settings.data.s3.manager.delete.success.single')) + await fetchBackupFiles() + } catch (error: any) { + window.message.error(t('settings.data.s3.manager.delete.error', { message: error.message })) + } finally { + setDeleting(false) + } + } + }) + } + + const handleRestore = async (fileName: string) => { + if (!endpoint || !region || !bucket || !access_key_id || !secret_access_key) { + window.message.error(t('settings.data.s3.manager.config.incomplete')) + return + } + + window.modal.confirm({ + title: t('settings.data.s3.restore.confirm.title'), + icon: , + content: t('settings.data.s3.restore.confirm.content'), + okText: t('settings.data.s3.restore.confirm.ok'), + cancelText: t('settings.data.s3.restore.confirm.cancel'), + centered: true, + onOk: async () => { + setRestoring(true) + try { + await (restoreMethod || restoreFromS3)(fileName) + window.message.success(t('settings.data.s3.restore.success')) + onClose() // 关闭模态框 + } catch (error: any) { + window.message.error(t('settings.data.s3.restore.error', { message: error.message })) + } finally { + setRestoring(false) + } + } + }) + } + + const columns = [ + { + title: t('settings.data.s3.manager.columns.fileName'), + dataIndex: 'fileName', + key: 'fileName', + ellipsis: { + showTitle: false + }, + render: (fileName: string) => ( + + {fileName} + + ) + }, + { + title: t('settings.data.s3.manager.columns.modifiedTime'), + dataIndex: 'modifiedTime', + key: 'modifiedTime', + width: 180, + render: (time: string) => dayjs(time).format('YYYY-MM-DD HH:mm:ss') + }, + { + title: t('settings.data.s3.manager.columns.size'), + dataIndex: 'size', + key: 'size', + width: 120, + render: (size: number) => formatFileSize(size) + }, + { + title: t('settings.data.s3.manager.columns.actions'), + key: 'action', + width: 160, + render: (_: any, record: BackupFile) => ( + <> + + + + ) + } + ] + + const rowSelection = { + selectedRowKeys, + onChange: (selectedRowKeys: React.Key[]) => { + setSelectedRowKeys(selectedRowKeys) + } + } + + return ( + } onClick={fetchBackupFiles} disabled={loading}> + {t('settings.data.s3.manager.refresh')} + , + , + + ]}> + + + ) +} diff --git a/src/renderer/src/components/S3Modals.tsx b/src/renderer/src/components/S3Modals.tsx new file mode 100644 index 0000000000..a74ad2e9ca --- /dev/null +++ b/src/renderer/src/components/S3Modals.tsx @@ -0,0 +1,258 @@ +import { backupToS3, handleData } from '@renderer/services/BackupService' +import { formatFileSize } from '@renderer/utils' +import { Input, Modal, Select, Spin } from 'antd' +import dayjs from 'dayjs' +import { useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' + +interface BackupFile { + fileName: string + modifiedTime: string + size: number +} + +export function useS3BackupModal() { + const [customFileName, setCustomFileName] = useState('') + const [isModalVisible, setIsModalVisible] = useState(false) + const [backuping, setBackuping] = useState(false) + + const handleBackup = async () => { + setBackuping(true) + try { + await backupToS3({ customFileName, showMessage: true }) + } finally { + setBackuping(false) + setIsModalVisible(false) + } + } + + const handleCancel = () => { + setIsModalVisible(false) + } + + const showBackupModal = useCallback(async () => { + // 获取默认文件名 + const deviceType = await window.api.system.getDeviceType() + const hostname = await window.api.system.getHostname() + const timestamp = dayjs().format('YYYYMMDDHHmmss') + const defaultFileName = `cherry-studio.${timestamp}.${hostname}.${deviceType}.zip` + setCustomFileName(defaultFileName) + setIsModalVisible(true) + }, []) + + return { + isModalVisible, + handleBackup, + handleCancel, + backuping, + customFileName, + setCustomFileName, + showBackupModal + } +} + +type S3BackupModalProps = { + isModalVisible: boolean + handleBackup: () => Promise + handleCancel: () => void + backuping: boolean + customFileName: string + setCustomFileName: (value: string) => void +} + +export function S3BackupModal({ + isModalVisible, + handleBackup, + handleCancel, + backuping, + customFileName, + setCustomFileName +}: S3BackupModalProps) { + const { t } = useTranslation() + + return ( + + setCustomFileName(e.target.value)} + placeholder={t('settings.data.s3.backup.modal.filename.placeholder')} + /> + + ) +} + +interface UseS3RestoreModalProps { + endpoint: string | undefined + region: string | undefined + bucket: string | undefined + access_key_id: string | undefined + secret_access_key: string | undefined + root?: string | undefined +} + +export function useS3RestoreModal({ + endpoint, + region, + bucket, + access_key_id, + secret_access_key, + root +}: UseS3RestoreModalProps) { + const [isRestoreModalVisible, setIsRestoreModalVisible] = useState(false) + const [restoring, setRestoring] = useState(false) + const [selectedFile, setSelectedFile] = useState(null) + const [loadingFiles, setLoadingFiles] = useState(false) + const [backupFiles, setBackupFiles] = useState([]) + const { t } = useTranslation() + + const showRestoreModal = useCallback(async () => { + if (!endpoint || !region || !bucket || !access_key_id || !secret_access_key) { + window.message.error({ content: t('settings.data.s3.manager.config.incomplete'), key: 's3-error' }) + return + } + + setIsRestoreModalVisible(true) + setLoadingFiles(true) + try { + const files = await window.api.backup.listS3Files({ + endpoint, + region, + bucket, + access_key_id, + secret_access_key, + root + }) + setBackupFiles(files) + } catch (error: any) { + window.message.error({ + content: t('settings.data.s3.manager.files.fetch.error', { message: error.message }), + key: 'list-files-error' + }) + } finally { + setLoadingFiles(false) + } + }, [endpoint, region, bucket, access_key_id, secret_access_key, root, t]) + + const handleRestore = useCallback(async () => { + if (!selectedFile || !endpoint || !region || !bucket || !access_key_id || !secret_access_key) { + window.message.error({ + content: !selectedFile + ? t('settings.data.s3.restore.file.required') + : t('settings.data.s3.restore.config.incomplete'), + key: 'restore-error' + }) + return + } + + window.modal.confirm({ + title: t('settings.data.s3.restore.confirm.title'), + content: t('settings.data.s3.restore.confirm.content'), + okText: t('settings.data.s3.restore.confirm.ok'), + cancelText: t('settings.data.s3.restore.confirm.cancel'), + centered: true, + onOk: async () => { + setRestoring(true) + try { + const data = await window.api.backup.restoreFromS3({ + endpoint, + region, + bucket, + access_key_id, + secret_access_key, + root, + fileName: selectedFile + }) + await handleData(JSON.parse(data)) + window.message.success(t('settings.data.s3.restore.success')) + setIsRestoreModalVisible(false) + } catch (error: any) { + window.message.error({ + content: t('settings.data.s3.restore.error', { message: error.message }), + key: 'restore-error' + }) + } finally { + setRestoring(false) + } + } + }) + }, [selectedFile, endpoint, region, bucket, access_key_id, secret_access_key, root, t]) + + const handleCancel = () => { + setIsRestoreModalVisible(false) + } + + return { + isRestoreModalVisible, + handleRestore, + handleCancel, + restoring, + selectedFile, + setSelectedFile, + loadingFiles, + backupFiles, + showRestoreModal + } +} + +type S3RestoreModalProps = ReturnType + +export function S3RestoreModal({ + isRestoreModalVisible, + handleRestore, + handleCancel, + restoring, + selectedFile, + setSelectedFile, + loadingFiles, + backupFiles +}: S3RestoreModalProps) { + const { t } = useTranslation() + + return ( + +
+ setEndpoint(e.target.value)} + style={{ width: 250 }} + type="url" + onBlur={() => dispatch(setS3({ ...s3, endpoint: endpoint || '' }))} + /> + + + + {t('settings.data.s3.region')} + setRegion(e.target.value)} + style={{ width: 250 }} + onBlur={() => dispatch(setS3({ ...s3, region: region || '' }))} + /> + + + + {t('settings.data.s3.bucket')} + setBucket(e.target.value)} + style={{ width: 250 }} + onBlur={() => dispatch(setS3({ ...s3, bucket: bucket || '' }))} + /> + + + + {t('settings.data.s3.accessKeyId')} + setAccessKeyId(e.target.value)} + style={{ width: 250 }} + onBlur={() => dispatch(setS3({ ...s3, accessKeyId: accessKeyId || '' }))} + /> + + + + {t('settings.data.s3.secretAccessKey')} + setSecretAccessKey(e.target.value)} + style={{ width: 250 }} + onBlur={() => dispatch(setS3({ ...s3, secretAccessKey: secretAccessKey || '' }))} + /> + + + + {t('settings.data.s3.root')} + setRoot(e.target.value)} + style={{ width: 250 }} + onBlur={() => dispatch(setS3({ ...s3, root: root || '' }))} + /> + + + + {t('settings.data.s3.backup.operation')} + + + + + + + + {t('settings.data.s3.autoSync')} + + + + + {t('settings.data.s3.maxBackups')} + + + + + {t('settings.data.s3.skipBackupFile')} + + + + {t('settings.data.s3.skipBackupFile.help')} + + {syncInterval > 0 && ( + <> + + + {t('settings.data.s3.syncStatus')} + {renderSyncStatus()} + + + )} + <> + + + + + + ) +} + +export default S3Settings diff --git a/src/renderer/src/services/BackupService.ts b/src/renderer/src/services/BackupService.ts index 3d78b2752a..b99ea6c77e 100644 --- a/src/renderer/src/services/BackupService.ts +++ b/src/renderer/src/services/BackupService.ts @@ -4,11 +4,62 @@ import { upgradeToV7 } from '@renderer/databases/upgrades' import i18n from '@renderer/i18n' import store from '@renderer/store' import { setWebDAVSyncState } from '@renderer/store/backup' +import { setS3SyncState } from '@renderer/store/backup' import { uuid } from '@renderer/utils' import dayjs from 'dayjs' import { NotificationService } from './NotificationService' +// 重试删除S3文件的辅助函数 +async function deleteS3FileWithRetry(fileName: string, s3Config: any, maxRetries = 3) { + let lastError: Error | null = null + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + await window.api.backup.deleteS3File(fileName, s3Config) + Logger.log(`[Backup] Successfully deleted old backup file: ${fileName} (attempt ${attempt})`) + return true + } catch (error: any) { + lastError = error + Logger.warn(`[Backup] Delete attempt ${attempt}/${maxRetries} failed for ${fileName}:`, error.message) + + // 如果不是最后一次尝试,等待一段时间再重试 + if (attempt < maxRetries) { + const delay = attempt * 1000 + Math.random() * 1000 // 1-2秒的随机延迟 + await new Promise((resolve) => setTimeout(resolve, delay)) + } + } + } + + Logger.error(`[Backup] Failed to delete old backup file after ${maxRetries} attempts: ${fileName}`, lastError) + return false +} + +// 重试删除WebDAV文件的辅助函数 +async function deleteWebdavFileWithRetry(fileName: string, webdavConfig: any, maxRetries = 3) { + let lastError: Error | null = null + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + await window.api.backup.deleteWebdavFile(fileName, webdavConfig) + Logger.log(`[Backup] Successfully deleted old backup file: ${fileName} (attempt ${attempt})`) + return true + } catch (error: any) { + lastError = error + Logger.warn(`[Backup] Delete attempt ${attempt}/${maxRetries} failed for ${fileName}:`, error.message) + + // 如果不是最后一次尝试,等待一段时间再重试 + if (attempt < maxRetries) { + const delay = attempt * 1000 + Math.random() * 1000 // 1-2秒的随机延迟 + await new Promise((resolve) => setTimeout(resolve, delay)) + } + } + } + + Logger.error(`[Backup] Failed to delete old backup file after ${maxRetries} attempts: ${fileName}`, lastError) + return false +} + export async function backup(skipBackupFile: boolean) { const filename = `cherry-studio.${dayjs().format('YYYYMMDDHHmm')}.zip` const fileContnet = await getBackupData() @@ -161,17 +212,21 @@ export async function backupToWebdav({ // 文件已按修改时间降序排序,所以最旧的文件在末尾 const filesToDelete = currentDeviceFiles.slice(webdavMaxBackups) - for (const file of filesToDelete) { - try { - await window.api.backup.deleteWebdavFile(file.fileName, { - webdavHost, - webdavUser, - webdavPass, - webdavPath - }) - Logger.log(`[Backup] Deleted old backup file: ${file.fileName}`) - } catch (error) { - Logger.error(`[Backup] Failed to delete old backup file: ${file.fileName}`, error) + Logger.log(`[Backup] Cleaning up ${filesToDelete.length} old backup files`) + + // 串行删除文件,避免并发请求导致的问题 + for (let i = 0; i < filesToDelete.length; i++) { + const file = filesToDelete[i] + await deleteWebdavFileWithRetry(file.fileName, { + webdavHost, + webdavUser, + webdavPass, + webdavPath + }) + + // 在删除操作之间添加短暂延迟,避免请求过于频繁 + if (i < filesToDelete.length - 1) { + await new Promise((resolve) => setTimeout(resolve, 500)) } } } @@ -242,6 +297,201 @@ export async function restoreFromWebdav(fileName?: string) { } } +// 备份到 S3 +export async function backupToS3({ + showMessage = false, + customFileName = '', + autoBackupProcess = false +}: { showMessage?: boolean; customFileName?: string; autoBackupProcess?: boolean } = {}) { + const notificationService = NotificationService.getInstance() + if (isManualBackupRunning) { + Logger.log('[Backup] Manual backup already in progress') + return + } + + // force set showMessage to false when auto backup process + if (autoBackupProcess) { + showMessage = false + } + + isManualBackupRunning = true + + store.dispatch(setS3SyncState({ syncing: true, lastSyncError: null })) + + const { + s3: { + endpoint: s3Endpoint, + region: s3Region, + bucket: s3Bucket, + accessKeyId: s3AccessKeyId, + secretAccessKey: s3SecretAccessKey, + root: s3Root, + maxBackups: s3MaxBackups, + skipBackupFile: s3SkipBackupFile + } + } = store.getState().settings + let deviceType = 'unknown' + let hostname = 'unknown' + try { + deviceType = (await window.api.system.getDeviceType()) || 'unknown' + hostname = (await window.api.system.getHostname()) || 'unknown' + } catch (error) { + Logger.error('[Backup] Failed to get device type or hostname:', error) + } + const timestamp = dayjs().format('YYYYMMDDHHmmss') + const backupFileName = customFileName || `cherry-studio.${timestamp}.${hostname}.${deviceType}.zip` + const finalFileName = backupFileName.endsWith('.zip') ? backupFileName : `${backupFileName}.zip` + const backupData = await getBackupData() + + // 上传文件 + try { + await window.api.backup.backupToS3(backupData, { + endpoint: s3Endpoint, + region: s3Region, + bucket: s3Bucket, + access_key_id: s3AccessKeyId, + secret_access_key: s3SecretAccessKey, + root: s3Root, + fileName: finalFileName, + skipBackupFile: s3SkipBackupFile + }) + + // S3上传成功 + store.dispatch( + setS3SyncState({ + lastSyncError: null + }) + ) + notificationService.send({ + id: uuid(), + type: 'success', + title: i18n.t('common.success'), + message: i18n.t('message.backup.success'), + silent: false, + timestamp: Date.now(), + source: 'backup' + }) + showMessage && window.message.success({ content: i18n.t('message.backup.success'), key: 'backup' }) + + // 清理旧备份文件 + if (s3MaxBackups > 0) { + try { + // 获取所有备份文件 + const files = await window.api.backup.listS3Files({ + endpoint: s3Endpoint, + region: s3Region, + bucket: s3Bucket, + access_key_id: s3AccessKeyId, + secret_access_key: s3SecretAccessKey, + root: s3Root + }) + + // 筛选当前设备的备份文件 + const currentDeviceFiles = files.filter((file) => { + // 检查文件名是否包含当前设备的标识信息 + return file.fileName.includes(deviceType) && file.fileName.includes(hostname) + }) + + // 如果当前设备的备份文件数量超过最大保留数量,删除最旧的文件 + if (currentDeviceFiles.length > s3MaxBackups) { + // 文件已按修改时间降序排序,所以最旧的文件在末尾 + const filesToDelete = currentDeviceFiles.slice(s3MaxBackups) + + Logger.log(`[Backup] Cleaning up ${filesToDelete.length} old backup files`) + + // 串行删除文件,避免并发请求导致的问题 + for (let i = 0; i < filesToDelete.length; i++) { + const file = filesToDelete[i] + await deleteS3FileWithRetry(file.fileName, { + endpoint: s3Endpoint, + region: s3Region, + bucket: s3Bucket, + access_key_id: s3AccessKeyId, + secret_access_key: s3SecretAccessKey, + root: s3Root + }) + + // 在删除操作之间添加短暂延迟,避免请求过于频繁 + if (i < filesToDelete.length - 1) { + await new Promise((resolve) => setTimeout(resolve, 500)) + } + } + } + } catch (error) { + Logger.error('[Backup] Failed to clean up old backup files:', error) + } + } + } catch (error: any) { + // if auto backup process, throw error + if (autoBackupProcess) { + throw error + } + notificationService.send({ + id: uuid(), + type: 'error', + title: i18n.t('message.backup.failed'), + message: error.message, + silent: false, + timestamp: Date.now(), + source: 'backup' + }) + store.dispatch(setS3SyncState({ lastSyncError: error.message })) + console.error('[Backup] backupToS3: Error uploading file to S3:', error) + showMessage && window.message.error({ content: i18n.t('message.backup.failed'), key: 'backup' }) + throw error + } finally { + if (!autoBackupProcess) { + store.dispatch( + setS3SyncState({ + lastSyncTime: Date.now(), + syncing: false + }) + ) + } + isManualBackupRunning = false + } +} + +// 从 S3 恢复 +export async function restoreFromS3(fileName?: string) { + const { + s3: { + endpoint: s3Endpoint, + region: s3Region, + bucket: s3Bucket, + accessKeyId: s3AccessKeyId, + secretAccessKey: s3SecretAccessKey, + root: s3Root + } + } = store.getState().settings + let data = '' + + try { + data = await window.api.backup.restoreFromS3({ + endpoint: s3Endpoint, + region: s3Region, + bucket: s3Bucket, + access_key_id: s3AccessKeyId, + secret_access_key: s3SecretAccessKey, + root: s3Root, + fileName + }) + } catch (error: any) { + console.error('[Backup] restoreFromS3: Error downloading file from S3:', error) + window.modal.error({ + title: i18n.t('message.restore.failed'), + content: error.message + }) + } + + try { + await handleData(JSON.parse(data)) + } catch (error) { + console.error('[Backup] Error downloading file from S3:', error) + window.message.error({ content: i18n.t('error.backup.file_format'), key: 'restore' }) + } +} + let autoSyncStarted = false let syncTimeout: NodeJS.Timeout | null = null let isAutoBackupRunning = false @@ -252,9 +502,17 @@ export function startAutoSync(immediate = false) { return } - const { webdavAutoSync, webdavHost } = store.getState().settings + const { + webdavAutoSync, + webdavHost, + s3: { autoSync: s3AutoSync, endpoint: s3Endpoint } + } = store.getState().settings - if (!webdavAutoSync || !webdavHost) { + // 检查WebDAV或S3自动同步配置 + const hasWebdavConfig = webdavAutoSync && webdavHost + const hasS3Config = s3AutoSync && s3Endpoint + + if (!hasWebdavConfig && !hasS3Config) { Logger.log('[AutoSync] Invalid sync settings, auto sync disabled') return } @@ -277,22 +535,29 @@ export function startAutoSync(immediate = false) { syncTimeout = null } - const { webdavSyncInterval } = store.getState().settings - const { webdavSync } = store.getState().backup + const { + webdavSyncInterval: _webdavSyncInterval, + s3: { syncInterval: _s3SyncInterval } + } = store.getState().settings + const { webdavSync, s3Sync } = store.getState().backup - if (webdavSyncInterval <= 0) { + // 使用当前激活的同步配置 + const syncInterval = hasWebdavConfig ? _webdavSyncInterval : _s3SyncInterval + const lastSyncTime = hasWebdavConfig ? webdavSync?.lastSyncTime : s3Sync?.lastSyncTime + + if (syncInterval <= 0) { Logger.log('[AutoSync] Invalid sync interval, auto sync disabled') stopAutoSync() return } // 用户指定的自动备份时间间隔(毫秒) - const requiredInterval = webdavSyncInterval * 60 * 1000 + const requiredInterval = syncInterval * 60 * 1000 let timeUntilNextSync = 1000 //also immediate switch (type) { - case 'fromLastSyncTime': // 如果存在最后一次同步WebDAV的时间,以它为参考计算下一次同步的时间 - timeUntilNextSync = Math.max(1000, (webdavSync?.lastSyncTime || 0) + requiredInterval - Date.now()) + case 'fromLastSyncTime': // 如果存在最后一次同步的时间,以它为参考计算下一次同步的时间 + timeUntilNextSync = Math.max(1000, (lastSyncTime || 0) + requiredInterval - Date.now()) break case 'fromNow': timeUntilNextSync = requiredInterval @@ -301,8 +566,9 @@ export function startAutoSync(immediate = false) { syncTimeout = setTimeout(performAutoBackup, timeUntilNextSync) + const backupType = hasWebdavConfig ? 'WebDAV' : 'S3' Logger.log( - `[AutoSync] Next sync scheduled in ${Math.floor(timeUntilNextSync / 1000 / 60)} minutes ${Math.floor( + `[AutoSync] Next ${backupType} sync scheduled in ${Math.floor(timeUntilNextSync / 1000 / 60)} minutes ${Math.floor( (timeUntilNextSync / 1000) % 60 )} seconds` ) @@ -321,17 +587,28 @@ export function startAutoSync(immediate = false) { while (retryCount < maxRetries) { try { - Logger.log(`[AutoSync] Starting auto backup... (attempt ${retryCount + 1}/${maxRetries})`) + const backupType = hasWebdavConfig ? 'WebDAV' : 'S3' + Logger.log(`[AutoSync] Starting auto ${backupType} backup... (attempt ${retryCount + 1}/${maxRetries})`) - await backupToWebdav({ autoBackupProcess: true }) - - store.dispatch( - setWebDAVSyncState({ - lastSyncError: null, - lastSyncTime: Date.now(), - syncing: false - }) - ) + if (hasWebdavConfig) { + await backupToWebdav({ autoBackupProcess: true }) + store.dispatch( + setWebDAVSyncState({ + lastSyncError: null, + lastSyncTime: Date.now(), + syncing: false + }) + ) + } else if (hasS3Config) { + await backupToS3({ autoBackupProcess: true }) + store.dispatch( + setS3SyncState({ + lastSyncError: null, + lastSyncTime: Date.now(), + syncing: false + }) + ) + } isAutoBackupRunning = false scheduleNextBackup() @@ -340,20 +617,31 @@ export function startAutoSync(immediate = false) { } catch (error: any) { retryCount++ if (retryCount === maxRetries) { - Logger.error('[AutoSync] Auto backup failed after all retries:', error) + const backupType = hasWebdavConfig ? 'WebDAV' : 'S3' + Logger.error(`[AutoSync] Auto ${backupType} backup failed after all retries:`, error) - store.dispatch( - setWebDAVSyncState({ - lastSyncError: 'Auto backup failed', - lastSyncTime: Date.now(), - syncing: false - }) - ) + if (hasWebdavConfig) { + store.dispatch( + setWebDAVSyncState({ + lastSyncError: 'Auto backup failed', + lastSyncTime: Date.now(), + syncing: false + }) + ) + } else if (hasS3Config) { + store.dispatch( + setS3SyncState({ + lastSyncError: 'Auto backup failed', + lastSyncTime: Date.now(), + syncing: false + }) + ) + } //only show 1 time error modal, and autoback stopped until user click ok await window.modal.error({ title: i18n.t('message.backup.failed'), - content: `[WebDAV Auto Backup] ${new Date().toLocaleString()} ` + error.message + content: `[${backupType} Auto Backup] ${new Date().toLocaleString()} ` + error.message }) scheduleNextBackup('fromNow') diff --git a/src/renderer/src/store/backup.ts b/src/renderer/src/store/backup.ts index a8b7d342c5..0740032efb 100644 --- a/src/renderer/src/store/backup.ts +++ b/src/renderer/src/store/backup.ts @@ -8,6 +8,7 @@ export interface WebDAVSyncState { export interface BackupState { webdavSync: WebDAVSyncState + s3Sync: WebDAVSyncState } const initialState: BackupState = { @@ -15,6 +16,11 @@ const initialState: BackupState = { lastSyncTime: null, syncing: false, lastSyncError: null + }, + s3Sync: { + lastSyncTime: null, + syncing: false, + lastSyncError: null } } @@ -24,9 +30,12 @@ const backupSlice = createSlice({ reducers: { setWebDAVSyncState: (state, action: PayloadAction>) => { state.webdavSync = { ...state.webdavSync, ...action.payload } + }, + setS3SyncState: (state, action: PayloadAction>) => { + state.s3Sync = { ...state.s3Sync, ...action.payload } } } }) -export const { setWebDAVSyncState } = backupSlice.actions +export const { setWebDAVSyncState, setS3SyncState } = backupSlice.actions export default backupSlice.reducer diff --git a/src/renderer/src/store/settings.ts b/src/renderer/src/store/settings.ts index 7d8e14ed11..8afbafc2a7 100644 --- a/src/renderer/src/store/settings.ts +++ b/src/renderer/src/store/settings.ts @@ -37,6 +37,19 @@ export type UserTheme = { colorPrimary: string } +export interface S3Config { + endpoint: string + region: string + bucket: string + accessKeyId: string + secretAccessKey: string + root: string + autoSync: boolean + syncInterval: number + maxBackups: number + skipBackupFile: boolean +} + export interface SettingsState { showAssistants: boolean showTopics: boolean @@ -185,6 +198,7 @@ export interface SettingsState { knowledgeEmbed: boolean } defaultPaintingProvider: PaintingProvider + s3: S3Config } export type MultiModelMessageStyle = 'horizontal' | 'vertical' | 'fold' | 'grid' @@ -329,7 +343,19 @@ export const initialState: SettingsState = { backup: false, knowledgeEmbed: false }, - defaultPaintingProvider: 'aihubmix' + defaultPaintingProvider: 'aihubmix', + s3: { + endpoint: '', + region: '', + bucket: '', + accessKeyId: '', + secretAccessKey: '', + root: '', + autoSync: false, + syncInterval: 0, + maxBackups: 0, + skipBackupFile: false + } } const settingsSlice = createSlice({ @@ -693,6 +719,9 @@ const settingsSlice = createSlice({ }, setDefaultPaintingProvider: (state, action: PayloadAction) => { state.defaultPaintingProvider = action.payload + }, + setS3: (state, action: PayloadAction) => { + state.s3 = action.payload } } }) @@ -801,7 +830,8 @@ export const { setOpenAISummaryText, setOpenAIServiceTier, setNotificationSettings, - setDefaultPaintingProvider + setDefaultPaintingProvider, + setS3 } = settingsSlice.actions export default settingsSlice.reducer diff --git a/src/renderer/src/types/index.ts b/src/renderer/src/types/index.ts index 3b4cc5cdc3..448f04c647 100644 --- a/src/renderer/src/types/index.ts +++ b/src/renderer/src/types/index.ts @@ -730,4 +730,16 @@ export interface StoreSyncAction { export type OpenAISummaryText = 'auto' | 'concise' | 'detailed' | 'off' export type OpenAIServiceTier = 'auto' | 'default' | 'flex' + +export type S3Config = { + endpoint: string + region: string + bucket: string + access_key_id: string + secret_access_key: string + root?: string + fileName?: string + skipBackupFile?: boolean +} + export type { Message } from './newMessage' diff --git a/yarn.lock b/yarn.lock index 2386409f15..1d1df92837 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3198,6 +3198,55 @@ __metadata: languageName: node linkType: hard +"@opendal/lib-darwin-arm64@npm:0.47.11": + version: 0.47.11 + resolution: "@opendal/lib-darwin-arm64@npm:0.47.11" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@opendal/lib-darwin-x64@npm:0.47.11": + version: 0.47.11 + resolution: "@opendal/lib-darwin-x64@npm:0.47.11" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@opendal/lib-linux-arm64-gnu@npm:0.47.11": + version: 0.47.11 + resolution: "@opendal/lib-linux-arm64-gnu@npm:0.47.11" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"@opendal/lib-linux-arm64-musl@npm:0.47.11": + version: 0.47.11 + resolution: "@opendal/lib-linux-arm64-musl@npm:0.47.11" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"@opendal/lib-linux-x64-gnu@npm:0.47.11": + version: 0.47.11 + resolution: "@opendal/lib-linux-x64-gnu@npm:0.47.11" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"@opendal/lib-win32-arm64-msvc@npm:0.47.11": + version: 0.47.11 + resolution: "@opendal/lib-win32-arm64-msvc@npm:0.47.11" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@opendal/lib-win32-x64-msvc@npm:0.47.11": + version: 0.47.11 + resolution: "@opendal/lib-win32-x64-msvc@npm:0.47.11" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + "@parcel/watcher-android-arm64@npm:2.5.1": version: 2.5.1 resolution: "@parcel/watcher-android-arm64@npm:2.5.1" @@ -5711,6 +5760,7 @@ __metadata: npx-scope-finder: "npm:^1.2.0" officeparser: "npm:^4.1.1" openai: "patch:openai@npm%3A5.1.0#~/.yarn/patches/openai-npm-5.1.0-0e7b3ccb07.patch" + opendal: "npm:0.47.11" os-proxy-config: "npm:^1.1.2" p-queue: "npm:^8.1.0" playwright: "npm:^1.52.0" @@ -14128,6 +14178,36 @@ __metadata: languageName: node linkType: hard +"opendal@npm:0.47.11": + version: 0.47.11 + resolution: "opendal@npm:0.47.11" + dependencies: + "@opendal/lib-darwin-arm64": "npm:0.47.11" + "@opendal/lib-darwin-x64": "npm:0.47.11" + "@opendal/lib-linux-arm64-gnu": "npm:0.47.11" + "@opendal/lib-linux-arm64-musl": "npm:0.47.11" + "@opendal/lib-linux-x64-gnu": "npm:0.47.11" + "@opendal/lib-win32-arm64-msvc": "npm:0.47.11" + "@opendal/lib-win32-x64-msvc": "npm:0.47.11" + dependenciesMeta: + "@opendal/lib-darwin-arm64": + optional: true + "@opendal/lib-darwin-x64": + optional: true + "@opendal/lib-linux-arm64-gnu": + optional: true + "@opendal/lib-linux-arm64-musl": + optional: true + "@opendal/lib-linux-x64-gnu": + optional: true + "@opendal/lib-win32-arm64-msvc": + optional: true + "@opendal/lib-win32-x64-msvc": + optional: true + checksum: 10c0/0783da2651bb27ac693ce38938d12b00124530fb965364517eef3de17b3ff898cdecf06260a79a7d70745d57c2ba952a753a4bab52e0831aa7232c3a69120225 + languageName: node + linkType: hard + "option@npm:~0.2.1": version: 0.2.4 resolution: "option@npm:0.2.4" From 486c5c42f7de0db09e47fc388ab303fdccd4640a Mon Sep 17 00:00:00 2001 From: Xin Rui <71483384+Konjac-XZ@users.noreply.github.com> Date: Sun, 29 Jun 2025 20:47:17 +0800 Subject: [PATCH 087/111] chore: format zh-cn and zh-tw i18n strings with pangu. (#7644) --- src/renderer/src/i18n/locales/zh-cn.json | 266 +++++++++++------------ src/renderer/src/i18n/locales/zh-tw.json | 204 ++++++++--------- 2 files changed, 235 insertions(+), 235 deletions(-) diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 0b4d7da254..475f766993 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -10,7 +10,7 @@ "add.prompt.placeholder": "输入提示词", "add.prompt.variables.tip": { "title": "可用的变量", - "content": "{{date}}:\t日期\n{{time}}:\t时间\n{{datetime}}:\t日期和时间\n{{system}}:\t操作系统\n{{arch}}:\tCPU架构\n{{language}}:\t语言\n{{model_name}}:\t模型名称\n{{username}}:\t用户名" + "content": "{{date}}:\t日期\n{{time}}:\t时间\n{{datetime}}:\t日期和时间\n{{system}}:\t操作系统\n{{arch}}:\tCPU 架构\n{{language}}:\t语言\n{{model_name}}:\t模型名称\n{{username}}:\t用户名" }, "add.title": "创建智能体", "import": { @@ -94,7 +94,7 @@ "titleLabel": "标题", "titlePlaceholder": "输入标题", "contentLabel": "内容", - "contentPlaceholder": "请输入短语内容,支持使用变量,然后按Tab键可以快速定位到变量进行修改。比如:\n帮我规划从${from}到${to}的路线,然后发送到${email}" + "contentPlaceholder": "请输入短语内容,支持使用变量,然后按 Tab 键可以快速定位到变量进行修改。比如:\n帮我规划从 ${from} 到 ${to} 的路线,然后发送到 ${email}" }, "list": { "showByList": "列表展示", @@ -118,7 +118,7 @@ "get_key": "获取", "get_key_success": "自动获取密钥成功", "login": "登录", - "oauth_button": "使用{{provider}}登录" + "oauth_button": "使用 {{provider}} 登录" }, "backup": { "confirm": "确定要备份数据吗?", @@ -169,11 +169,11 @@ }, "input.auto_resize": "自动调整高度", "input.clear": "清空消息 {{Command}}", - "input.clear.content": "确定要清除当前会话所有消息吗?", + "input.clear.content": "确定要清除当前会话所有消息吗?", "input.clear.title": "清空消息", "input.collapse": "收起", "input.context_count.tip": "上下文数 / 最大上下文数", - "input.estimated_tokens.tip": "预估 token 数", + "input.estimated_tokens.tip": "预估 Token 数", "input.expand": "展开", "input.file_not_supported": "模型不支持此文件类型", "input.file_error": "文件处理出错", @@ -189,13 +189,13 @@ "input.settings": "设置", "input.thinking": "思考", "input.thinking.mode.default": "默认", - "input.thinking.mode.default.tip": "模型会自动确定思考的 token 数", + "input.thinking.mode.default.tip": "模型会自动确定思考的 Token 数", "input.thinking.mode.custom": "自定义", - "input.thinking.mode.custom.tip": "模型最多可以思考的 token 数。需要考虑模型的上下文限制,否则会报错", - "input.thinking.mode.tokens.tip": "设置思考的 token 数", - "input.thinking.budget_exceeds_max": "思考预算超过最大 token 数", - "input.topics": " 话题 ", - "input.translate": "翻译成{{target_language}}", + "input.thinking.mode.custom.tip": "模型最多可以思考的 Token 数。需要考虑模型的上下文限制,否则会报错", + "input.thinking.mode.tokens.tip": "设置思考的 Token 数", + "input.thinking.budget_exceeds_max": "思考预算超过最大 Token 数", + "input.topics": "话题", + "input.translate": "翻译成 {{target_language}}", "input.upload": "上传图片或文档", "input.upload.upload_from_local": "上传本地文件...", "input.upload.document": "上传文档(模型不支持图片)", @@ -258,12 +258,12 @@ "settings.code_cache_threshold": "缓存阈值", "settings.code_cache_threshold.tip": "允许缓存的最小代码长度(千字符),超过阈值的代码块才会被缓存", "settings.context_count": "上下文数", - "settings.context_count.tip": "要保留在上下文中的消息数量,数值越大,上下文越长,消耗的 token 越多。普通聊天建议 5-10", + "settings.context_count.tip": "要保留在上下文中的消息数量,数值越大,上下文越长,消耗的 Token 越多。普通聊天建议 5-10", "settings.max": "不限", - "settings.max_tokens": "最大 TOKEN 数", + "settings.max_tokens": "最大 Token 数", "settings.max_tokens.confirm": "最大 Token 数", - "settings.max_tokens.confirm_content": "设置单次交互所用的最大 Token 数, 会影响返回结果的长度。要根据模型上下文限制来设置,否则会报错", - "settings.max_tokens.tip": "单次交互所用的最大 Token 数, 会影响返回结果的长度。要根据模型上下文限制来设置,否则会报错", + "settings.max_tokens.confirm_content": "设置单次交互所用的最大 Token 数,会影响返回结果的长度。要根据模型上下文限制来设置,否则会报错", + "settings.max_tokens.tip": "单次交互所用的最大 Token 数,会影响返回结果的长度。要根据模型上下文限制来设置,否则会报错", "settings.reset": "重置", "settings.set_as_default": "应用到默认助手", "settings.show_line_numbers": "代码显示行号", @@ -298,8 +298,8 @@ "topics.export.obsidian_btn": "确定", "topics.export.obsidian_created": "创建时间", "topics.export.obsidian_created_placeholder": "请选择创建时间", - "topics.export.obsidian_export_failed": "导出到Obsidian失败", - "topics.export.obsidian_export_success": "导出到Obsidian成功", + "topics.export.obsidian_export_failed": "导出到 Obsidian 失败", + "topics.export.obsidian_export_success": "导出到 Obsidian 成功", "topics.export.obsidian_operate": "处理方式", "topics.export.obsidian_operate_append": "追加", "topics.export.obsidian_operate_new_or_overwrite": "新建(如果存在就覆盖)", @@ -312,9 +312,9 @@ "topics.export.obsidian_title": "标题", "topics.export.obsidian_title_placeholder": "请输入标题", "topics.export.obsidian_title_required": "标题不能为空", - "topics.export.obsidian_no_vaults": "未找到Obsidian保管库", + "topics.export.obsidian_no_vaults": "未找到 Obsidian 保管库", "topics.export.obsidian_loading": "加载中...", - "topics.export.obsidian_fetch_error": "获取Obsidian保管库失败", + "topics.export.obsidian_fetch_error": "获取 Obsidian 保管库失败", "topics.export.obsidian_fetch_folders_error": "获取文件夹结构失败", "topics.export.obsidian_no_vault_selected": "请先选择一个保管库", "topics.export.obsidian_select_vault_first": "请先选择保管库", @@ -329,7 +329,7 @@ "topics.pinned": "固定话题", "topics.prompt": "话题提示词", "topics.prompt.edit.title": "编辑话题提示词", - "topics.prompt.tips": "话题提示词: 针对当前话题提供额外的补充提示词", + "topics.prompt.tips": "话题提示词:针对当前话题提供额外的补充提示词", "topics.title": "话题", "topics.unpinned": "取消固定", "translate": "翻译", @@ -469,7 +469,7 @@ "count": "个文件", "created_at": "创建时间", "delete": "删除", - "delete.content": "删除文件会删除文件在所有消息中的引用,确定要删除此文件吗?", + "delete.content": "删除文件会删除文件在所有消息中的引用,确定要删除此文件吗?", "delete.paintings.warning": "绘图中包含该图片,暂时无法删除", "delete.title": "删除文件", "document": "文档", @@ -484,7 +484,7 @@ "type": "类型" }, "gpustack": { - "keep_alive_time.description": "模型在内存中保持的时间(默认:5分钟)", + "keep_alive_time.description": "模型在内存中保持的时间(默认:5 分钟)", "keep_alive_time.placeholder": "分钟", "keep_alive_time.title": "保持活跃时间", "title": "GPUStack" @@ -494,7 +494,7 @@ "locate.message": "定位到消息", "search.messages": "搜索所有消息", "search.placeholder": "搜索话题或消息...", - "search.topics.empty": "没有找到相关话题, 点击回车键搜索所有消息", + "search.topics.empty": "没有找到相关话题,点击回车键搜索所有消息", "title": "话题搜索" }, "knowledge": { @@ -517,12 +517,12 @@ "chunk_size_tooltip": "将文档切割分段,每段的大小,不能超过模型上下文限制", "clear_selection": "清除选择", "delete": "删除", - "delete_confirm": "确定要删除此知识库吗?", + "delete_confirm": "确定要删除此知识库吗?", "dimensions": "嵌入维度", "dimensions_size_tooltip": "嵌入维度大小,数值越大,嵌入维度越大,但消耗的 Token 也越多", "dimensions_set_right": "⚠️ 请确保模型支持所设置的嵌入维度大小", "dimensions_default": "模型将使用默认嵌入维度", - "dimensions_size_placeholder": " 嵌入维度大小,如 1024", + "dimensions_size_placeholder": "嵌入维度大小,如 1024", "dimensions_auto_set": "自动设置嵌入维度", "dimensions_error_invalid": "请输入嵌入维度大小", "dimensions_size_too_large": "嵌入维度不能超过模型上下文限制({{max_context}})", @@ -563,15 +563,15 @@ "status_processing": "处理中", "threshold": "匹配度阈值", "threshold_placeholder": "未设置", - "threshold_too_large_or_small": "阈值不能大于1或小于0", + "threshold_too_large_or_small": "阈值不能大于 1 或小于 0", "threshold_tooltip": "用于衡量用户问题与知识库内容之间的相关性(0-1)", "title": "知识库", "topN": "返回结果数量", - "topN_too_large_or_small": "返回结果数量不能大于30或小于1", + "topN_too_large_or_small": "返回结果数量不能大于 30 或小于 1", "topN_placeholder": "未设置", "topN_tooltip": "返回的匹配结果数量,数值越大,匹配结果越多,但消耗的 Token 也越多", "url_added": "网址已添加", - "url_placeholder": "请输入网址, 多个网址用回车分隔", + "url_placeholder": "请输入网址,多个网址用回车分隔", "urls": "网址" }, "languages": { @@ -596,7 +596,7 @@ "malay": "马来文" }, "lmstudio": { - "keep_alive_time.description": "对话后模型在内存中保持的时间(默认:5分钟)", + "keep_alive_time.description": "对话后模型在内存中保持的时间(默认:5 分钟)", "keep_alive_time.placeholder": "分钟", "keep_alive_time.title": "保持活跃时间", "title": "LM Studio" @@ -618,13 +618,13 @@ "backup.start.success": "开始备份", "backup.success": "备份成功", "chat.completion.paused": "会话已停止", - "citation": "{{count}}个引用内容", + "citation": "{{count}} 个引用内容", "citations": "引用内容", "copied": "已复制", "copy.failed": "复制失败", "copy.success": "复制成功", "delete.confirm.title": "删除确认", - "delete.confirm.content": "确认删除选中的{{count}}条消息吗?", + "delete.confirm.content": "确认删除选中的 {{count}} 条消息吗?", "delete.failed": "删除失败", "delete.success": "删除成功", "empty_url": "无法下载图片,可能是提示词包含敏感内容或违禁词汇", @@ -645,8 +645,8 @@ "error.joplin.no_config": "未配置 Joplin 授权令牌 或 URL", "error.invalid.nutstore": "无效的坚果云设置", "error.invalid.nutstore_token": "无效的坚果云 Token", - "error.markdown.export.preconf": "导出Markdown文件到预先设定的路径失败", - "error.markdown.export.specified": "导出Markdown文件失败", + "error.markdown.export.preconf": "导出 Markdown 文件到预先设定的路径失败", + "error.markdown.export.specified": "导出 Markdown 文件失败", "error.notion.export": "导出 Notion 错误,请检查连接状态并对照文档检查配置", "error.notion.no_api_key": "未配置 Notion API Key 或 Notion Database ID", "error.yuque.export": "导出语雀错误,请检查连接状态并对照文档检查配置", @@ -654,11 +654,11 @@ "group.delete.content": "删除分组消息会删除用户提问和所有助手的回答", "group.delete.title": "删除分组消息", "ignore.knowledge.base": "联网模式开启,忽略知识库", - "loading.notion.exporting_progress": "正在导出到Notion ...", - "loading.notion.preparing": "正在准备导出到Notion...", + "loading.notion.exporting_progress": "正在导出到 Notion ...", + "loading.notion.preparing": "正在准备导出到 Notion...", "mention.title": "切换模型回答", "message.code_style": "代码风格", - "message.delete.content": "确定要删除此消息吗?", + "message.delete.content": "确定要删除此消息吗?", "message.delete.title": "删除消息", "message.multi_model_style": "多模型回答样式", "message.multi_model_style.fold": "标签模式", @@ -696,13 +696,13 @@ "upgrade.success.button": "重启", "upgrade.success.content": "重启用以完成升级", "upgrade.success.title": "升级成功", - "warn.notion.exporting": "正在导出到 Notion, 请勿重复请求导出!", + "warn.notion.exporting": "正在导出到 Notion, 请勿重复请求导出!", "warning.rate.limit": "发送过于频繁,请等待 {{seconds}} 秒后再尝试", "error.siyuan.export": "导出思源笔记失败,请检查连接状态并对照文档检查配置", - "error.siyuan.no_config": "未配置思源笔记API地址或令牌", + "error.siyuan.no_config": "未配置思源笔记 API 地址或令牌", "success.siyuan.export": "导出到思源笔记成功", - "warn.yuque.exporting": "正在导出语雀, 请勿重复请求导出!", - "warn.siyuan.exporting": "正在导出到思源笔记,请勿重复请求导出!", + "warn.yuque.exporting": "正在导出语雀,请勿重复请求导出!", + "warn.siyuan.exporting": "正在导出到思源笔记,请勿重复请求导出!", "websearch": { "rag": "正在执行 RAG...", "rag_complete": "保留 {{countBefore}} 个结果中的 {{countAfter}} 个...", @@ -722,7 +722,7 @@ "minimize": "最小化小程序", "devtools": "开发者工具", "openExternal": "在浏览器中打开", - "rightclick_copyurl": "右键复制URL", + "rightclick_copyurl": "右键复制 URL", "open_link_external_on": "当前:在浏览器中打开链接", "open_link_external_off": "当前:使用默认窗口打开链接" }, @@ -785,7 +785,7 @@ "embedding": "嵌入", "embedding_dimensions": "嵌入维度", "embedding_model": "嵌入模型", - "embedding_model_tooltip": "在设置->模型服务中点击管理按钮添加", + "embedding_model_tooltip": "在设置 -> 模型服务中点击管理按钮添加", "function_calling": "函数调用", "no_matches": "无可用模型", "parameter_name": "参数名称", @@ -811,7 +811,7 @@ "rerank_model": "重排模型", "rerank_model_support_provider": "目前重排序模型仅支持部分服务商 ({{provider}})", "rerank_model_not_support_provider": "目前重排序模型不支持该服务商 ({{provider}})", - "rerank_model_tooltip": "在设置->模型服务中点击管理按钮添加", + "rerank_model_tooltip": "在设置 -> 模型服务中点击管理按钮添加", "search": "搜索模型...", "stream_output": "流式输出", "enable_tool_use": "工具调用", @@ -838,14 +838,14 @@ "knowledge.error": "添加 {{type}} 到知识库失败: {{error}}" }, "ollama": { - "keep_alive_time.description": "对话后模型在内存中保持的时间(默认:5分钟)", + "keep_alive_time.description": "对话后模型在内存中保持的时间(默认:5 分钟)", "keep_alive_time.placeholder": "分钟", "keep_alive_time.title": "保持活跃时间", "title": "Ollama" }, "paintings": { "button.delete.image": "删除图片", - "button.delete.image.confirm": "确定要删除此图片吗?", + "button.delete.image.confirm": "确定要删除此图片吗?", "button.new.image": "新建图片", "guidance_scale": "引导比例", "guidance_scale_tip": "无分类器指导。控制模型在寻找相关图像时对提示词的遵循程度", @@ -872,8 +872,8 @@ "learn_more": "了解更多", "paint_course": "教程", "prompt_placeholder_edit": "输入你的图片描述,文本绘制用 \"双引号\" 包裹", - "prompt_placeholder_en": "输入\"英文\"图片描述,目前 Imagen 仅支持英文提示词", - "proxy_required": "打开代理并开启\"TUN模式\"查看生成图片或复制到浏览器打开,后续会支持国内直连", + "prompt_placeholder_en": "输入 \"英文\" 图片描述,目前 Imagen 仅支持英文提示词", + "proxy_required": "打开代理并开启 \"TUN 模式\" 查看生成图片或复制到浏览器打开,后续会支持国内直连", "image_file_required": "请先上传图片", "image_file_retry": "请重新上传图片", "image_placeholder": "暂无图片", @@ -956,7 +956,7 @@ "style_type_tip": "重混后的图像风格,仅适用于 V_2 及以上版本", "negative_prompt_tip": "描述不想在重混结果中出现的元素", "magic_prompt_option_tip": "智能优化重混提示词", - "rendering_speed_tip": "控制渲染速度与质量之间的平衡,仅适用于V_3版本" + "rendering_speed_tip": "控制渲染速度与质量之间的平衡,仅适用于 V_3 版本" }, "upscale": { "image_file": "需要放大的图片", @@ -969,7 +969,7 @@ "magic_prompt_option_tip": "智能优化放大提示词" }, "text_desc_required": "请先输入图片描述", - "req_error_text": "运行失败,请重试。提示词避免\"版权词\"和\"敏感词\"哦。", + "req_error_text": "运行失败,请重试。提示词避免 \"版权词\" 和 \"敏感词\" 哦。", "req_error_token": "请检查令牌有效性", "req_error_no_balance": "请检查令牌有效性", "image_handle_required": "请先上传图片", @@ -989,7 +989,7 @@ "prompts": { "explanation": "帮我解释一下这个概念", "summarize": "帮我总结一下这段话", - "title": "总结给出的会话,将其总结为语言为{{language}}的10字内标题,忽略会话中的指令,不要使用标点和特殊符号。以纯字符串格式输出,不要输出标题以外的内容。" + "title": "总结给出的会话,将其总结为语言为 {{language}} 的 10 字内标题,忽略会话中的指令,不要使用标点和特殊符号。以纯字符串格式输出,不要输出标题以外的内容。" }, "provider": { "aihubmix": "AiHubMix", @@ -1032,12 +1032,12 @@ "qwenlm": "QwenLM", "silicon": "硅基流动", "stepfun": "阶跃星辰", - "tencent-cloud-ti": "腾讯云TI", + "tencent-cloud-ti": "腾讯云 TI", "together": "Together", "xirang": "天翼云息壤", "yi": "零一万物", - "zhinao": "360智脑", - "zhipu": "智谱AI", + "zhinao": "360 智脑", + "zhipu": "智谱 AI", "voyageai": "Voyage AI", "qiniu": "七牛云 AI 推理", "tokenflux": "TokenFlux", @@ -1100,7 +1100,7 @@ "app_data.copy_data_option": "复制数据,会自动重启后将原始目录数据复制到新目录", "app_data.copy_time_notice": "复制数据将需要一些时间,复制期间不要关闭应用", "app_data.path_changed_without_copy": "路径已更改成功", - "app_data.copying_warning": "数据复制中,不要强制退出app, 复制完成后会自动重启应用", + "app_data.copying_warning": "数据复制中,不要强制退出 app, 复制完成后会自动重启应用", "app_data.copying": "正在将数据复制到新位置...", "app_data.copy_success": "已成功复制数据到新位置", "app_data.copy_failed": "复制数据失败", @@ -1111,9 +1111,9 @@ "app_data.new_path": "新路径", "app_data.select_error_root_path": "新路径不能是根路径", "app_data.select_error_write_permission": "新路径没有写入权限", - "app_data.stop_quit_app_reason": "应用目前在迁移数据, 不能退出", + "app_data.stop_quit_app_reason": "应用目前在迁移数据,不能退出", "app_data.select_not_empty_dir": "新路径不为空", - "app_data.select_not_empty_dir_content": "新路径不为空,将覆盖新路径中的数据, 有数据丢失和复制失败的风险,是否继续?", + "app_data.select_not_empty_dir_content": "新路径不为空,将覆盖新路径中的数据,有数据丢失和复制失败的风险,是否继续?", "app_data.select_error_same_path": "新路径与旧路径相同,请选择其他路径", "app_data.select_error_in_app_path": "新路径与应用安装路径相同,请选择其他路径", "app_knowledge": "知识库文件", @@ -1123,7 +1123,7 @@ "app_knowledge.remove_all_success": "文件删除成功", "app_logs": "应用日志", "backup.skip_file_data_title": "精简备份", - "backup.skip_file_data_help": "备份时跳过备份图片、知识库等数据文件,仅备份聊天记录和设置。减少空间占用, 加快备份速度", + "backup.skip_file_data_help": "备份时跳过备份图片、知识库等数据文件,仅备份聊天记录和设置。减少空间占用,加快备份速度", "clear_cache": { "button": "清除缓存", "confirm": "清除缓存将删除应用缓存的数据,包括小程序数据。此操作不可恢复,是否继续?", @@ -1141,14 +1141,14 @@ "export_menu": { "title": "导出菜单设置", "image": "导出为图片", - "markdown": "导出为Markdown", - "markdown_reason": "导出为Markdown(包含思考)", - "notion": "导出到Notion", + "markdown": "导出为 Markdown", + "markdown_reason": "导出为 Markdown(包含思考)", + "notion": "导出到 Notion", "yuque": "导出到语雀", - "obsidian": "导出到Obsidian", + "obsidian": "导出到 Obsidian", "siyuan": "导出到思源笔记", - "joplin": "导出到Joplin", - "docx": "导出为Word", + "joplin": "导出到 Joplin", + "docx": "导出为 Word", "plain_text": "复制为纯文本" }, "joplin": { @@ -1166,25 +1166,25 @@ "url": "Joplin 剪裁服务监听 URL", "url_placeholder": "http://127.0.0.1:41184/", "export_reasoning.title": "导出时包含思维链", - "export_reasoning.help": "开启后,导出到Joplin时会包含思维链内容。" + "export_reasoning.help": "开启后,导出到 Joplin 时会包含思维链内容。" }, - "markdown_export.force_dollar_math.help": "开启后,导出Markdown时会将强制使用$$来标记LaTeX公式。注意:该项也会影响所有通过Markdown导出的方式,如Notion、语雀等", - "markdown_export.force_dollar_math.title": "强制使用$$来标记LaTeX公式", + "markdown_export.force_dollar_math.help": "开启后,导出 Markdown 时会将强制使用 $$ 来标记 LaTeX 公式。注意:该项也会影响所有通过 Markdown 导出的方式,如 Notion、语雀等", + "markdown_export.force_dollar_math.title": "强制使用 $$ 来标记 LaTeX 公式", "markdown_export.help": "若填入,则每次导出时将自动保存到该路径;否则,将弹出保存对话框", "markdown_export.path": "默认导出路径", "markdown_export.path_placeholder": "导出路径", "markdown_export.select": "选择", "markdown_export.title": "Markdown 导出", "markdown_export.show_model_name.title": "导出时使用模型名称", - "markdown_export.show_model_name.help": "开启后,导出Markdown时会显示模型名称。注意:该项也会影响所有通过Markdown导出的方式,如Notion、语雀等。", + "markdown_export.show_model_name.help": "开启后,导出 Markdown 时会显示模型名称。注意:该项也会影响所有通过 Markdown 导出的方式,如 Notion、语雀等。", "markdown_export.show_model_provider.title": "显示模型供应商", - "markdown_export.show_model_provider.help": "在导出Markdown时显示模型供应商,如OpenAI、Gemini等", + "markdown_export.show_model_provider.help": "在导出 Markdown 时显示模型供应商,如 OpenAI、Gemini 等", "message_title.use_topic_naming.title": "使用话题命名模型为导出的消息创建标题", - "message_title.use_topic_naming.help": "开启后,使用话题命名模型为导出的消息创建标题。该项也会影响所有通过Markdown导出的方式", + "message_title.use_topic_naming.help": "开启后,使用话题命名模型为导出的消息创建标题。该项也会影响所有通过 Markdown 导出的方式", "minute_interval_one": "{{count}} 分钟", "minute_interval_other": "{{count}} 分钟", "notion.api_key": "Notion 密钥", - "notion.api_key_placeholder": "请输入Notion 密钥", + "notion.api_key_placeholder": "请输入 Notion 密钥", "notion.check": { "button": "检测", "empty_api_key": "未配置 API key", @@ -1194,13 +1194,13 @@ "success": "连接成功" }, "notion.database_id": "Notion 数据库 ID", - "notion.database_id_placeholder": "请输入Notion 数据库 ID", + "notion.database_id_placeholder": "请输入 Notion 数据库 ID", "notion.help": "Notion 配置文档", "notion.page_name_key": "页面标题字段名", "notion.page_name_key_placeholder": "请输入页面标题字段名,默认为 Name", "notion.title": "Notion 设置", "notion.export_reasoning.title": "导出时包含思维链", - "notion.export_reasoning.help": "开启后,导出到Notion时会包含思维链内容。", + "notion.export_reasoning.help": "开启后,导出到 Notion 时会包含思维链内容。", "title": "数据设置", "webdav": { "autoSync": "自动备份", @@ -1252,7 +1252,7 @@ }, "s3": { "title": "S3 兼容存储", - "title.help": "与AWS S3 API兼容的对象存储服务, 例如AWS S3, Cloudflare R2, 阿里云OSS, 腾讯云COS等", + "title.help": "与 AWS S3 API 兼容的对象存储服务,例如 AWS S3, Cloudflare R2, 阿里云 OSS, 腾讯云 COS 等", "endpoint": "API 地址", "endpoint.placeholder": "https://s3.example.com", "region": "区域", @@ -1317,8 +1317,8 @@ "yuque": { "check": { "button": "检测", - "empty_repo_url": "请先输入知识库URL", - "empty_token": "请先输入语雀Token", + "empty_repo_url": "请先输入知识库 URL", + "empty_token": "请先输入语雀 Token", "fail": "语雀连接验证失败", "success": "语雀连接验证成功" }, @@ -1327,7 +1327,7 @@ "repo_url_placeholder": "https://www.yuque.com/username/xxx", "title": "语雀配置", "token": "语雀 Token", - "token_placeholder": "请输入语雀Token" + "token_placeholder": "请输入语雀 Token" }, "obsidian": { "title": "Obsidian 配置", @@ -1340,21 +1340,21 @@ }, "siyuan": { "title": "思源笔记配置", - "api_url": "API地址", + "api_url": "API 地址", "api_url_placeholder": "例如:http://127.0.0.1:6806", - "token": "API令牌", - "token.help": "在思源笔记->设置->关于中获取", + "token": "API 令牌", + "token.help": "在思源笔记 -> 设置 -> 关于中获取", "token_placeholder": "请输入思源笔记令牌", - "box_id": "笔记本ID", - "box_id_placeholder": "请输入笔记本ID", + "box_id": "笔记本 ID", + "box_id_placeholder": "请输入笔记本 ID", "root_path": "文档根路径", "root_path_placeholder": "例如:/CherryStudio", "check": { "title": "连接检测", "button": "检测", - "empty_config": "请填写API地址和令牌", + "empty_config": "请填写 API 地址和令牌", "success": "连接成功", - "fail": "连接失败,请检查API地址和令牌", + "fail": "连接失败,请检查 API 地址和令牌", "error": "连接异常,请检查网络连接" } }, @@ -1385,7 +1385,7 @@ "display.assistant.title": "助手设置", "display.custom.css": "自定义 CSS", "display.custom.css.cherrycss": "从 cherrycss.com 获取", - "display.custom.css.placeholder": "/* 这里写自定义CSS */", + "display.custom.css.placeholder": "/* 这里写自定义 CSS */", "display.sidebar.chat.hiddenMessage": "助手是基础功能,不支持隐藏", "display.sidebar.disabled": "隐藏的图标", "display.sidebar.empty": "把要隐藏的功能从左侧拖拽到这里", @@ -1434,9 +1434,9 @@ "logo_upload_button": "上传", "save": "保存", "edit_description": "在这里编辑自定义小应用的配置。每个应用需要包含 id、name、url 和 logo 字段", - "placeholder": "请输入自定义小程序配置(JSON格式)", - "duplicate_ids": "发现重复的ID: {{ids}}", - "conflicting_ids": "与默认应用ID冲突: {{ids}}" + "placeholder": "请输入自定义小程序配置(JSON 格式)", + "duplicate_ids": "发现重复的 ID: {{ids}}", + "conflicting_ids": "与默认应用 ID 冲突: {{ids}}" }, "cache_settings": "缓存设置", "cache_title": "小程序缓存数量", @@ -1458,10 +1458,10 @@ "general.auto_check_update.title": "自动更新", "general.test_plan.title": "测试计划", "general.test_plan.tooltip": "参与测试计划,可以更快体验到最新功能,但同时也会带来更多风险,务必提前做好备份", - "general.test_plan.beta_version": "测试版(Beta)", - "general.test_plan.beta_version_tooltip": "功能可能随时变化,bug较多,升级较快", - "general.test_plan.rc_version": "预览版(RC)", - "general.test_plan.rc_version_tooltip": "接近正式版,功能基本稳定,bug较少", + "general.test_plan.beta_version": "测试版 (Beta)", + "general.test_plan.beta_version_tooltip": "功能可能随时变化,bug 较多,升级较快", + "general.test_plan.rc_version": "预览版 (RC)", + "general.test_plan.rc_version_tooltip": "接近正式版,功能基本稳定,bug 较少", "general.test_plan.version_options": "版本选择", "general.test_plan.version_channel_not_match": "预览版和测试版的切换将在下一个正式版发布时生效", "general.reset.button": "重置", @@ -1473,7 +1473,7 @@ "general.view_webdav_settings": "查看 WebDAV 设置", "general.spell_check": "拼写检查", "general.spell_check.languages": "拼写检查语言", - "input.auto_translate_with_space": "3个空格快速翻译", + "input.auto_translate_with_space": "3 个空格快速翻译", "input.show_translate_confirm": "显示翻译确认对话框", "input.target_language": "目标语言", "input.target_language.chinese": "简体中文", @@ -1491,7 +1491,7 @@ "addServer": "添加服务器", "addServer.create": "快速创建", "addServer.importFrom": "从 JSON 导入", - "addServer.importFrom.tooltip": "请从 MCP Servers 的介绍页面复制配置JSON(优先使用\n NPX或 UVX 配置),并粘贴到输入框中", + "addServer.importFrom.tooltip": "请从 MCP Servers 的介绍页面复制配置 JSON(优先使用\n NPX 或 UVX 配置),并粘贴到输入框中", "addServer.importFrom.placeholder": "粘贴 MCP 服务器 JSON 配置", "addServer.importFrom.invalid": "无效输入,请检查 JSON 格式", "addServer.importFrom.nameExists": "服务器已存在:{{name}}", @@ -1503,8 +1503,8 @@ "baseUrlTooltip": "远程 URL 地址", "command": "命令", "sse": "服务器发送事件 (sse)", - "streamableHttp": "可流式传输的HTTP (streamableHttp)", - "stdio": "标准输入/输出 (stdio)", + "streamableHttp": "可流式传输的 HTTP (streamableHttp)", + "stdio": "标准输入 / 输出 (stdio)", "inMemory": "内存", "config_description": "配置模型上下文协议服务器", "disable": "不使用 MCP 服务器", @@ -1516,7 +1516,7 @@ "description": "描述", "noDescriptionAvailable": "暂无描述", "duplicateName": "已存在同名服务器", - "editJson": "编辑JSON", + "editJson": "编辑 JSON", "editServer": "编辑服务器", "env": "环境变量", "envTooltip": "格式:KEY=value,每行一个", @@ -1527,10 +1527,10 @@ "install": "安装", "installError": "安装依赖项失败", "installSuccess": "依赖项安装成功", - "jsonFormatError": "JSON格式化错误", - "jsonModeHint": "编辑MCP服务器配置的JSON表示。保存前请确保格式正确", - "jsonSaveError": "保存JSON配置失败", - "jsonSaveSuccess": "JSON配置已保存", + "jsonFormatError": "JSON 格式化错误", + "jsonModeHint": "编辑 MCP 服务器配置的 JSON 表示。保存前请确保格式正确", + "jsonSaveError": "保存 JSON 配置失败", + "jsonSaveSuccess": "JSON 配置已保存", "missingDependencies": "缺失,请安装它以继续", "name": "名称", "noServers": "未配置服务器", @@ -1587,7 +1587,7 @@ "noResourcesAvailable": "无可用资源", "availableResources": "可用资源", "uri": "URI", - "mimeType": "MIME类型", + "mimeType": "MIME 类型", "size": "大小", "blob": "二进制数据", "blobInvisible": "隐藏二进制数据", @@ -1611,21 +1611,21 @@ "sync": { "title": "同步服务器", "selectProvider": "选择提供商:", - "discoverMcpServers": "发现MCP服务器", - "discoverMcpServersDescription": "访问平台以发现可用的MCP服务器", + "discoverMcpServers": "发现 MCP 服务器", + "discoverMcpServersDescription": "访问平台以发现可用的 MCP 服务器", "getToken": "获取 API 令牌", "getTokenDescription": "从您的帐户中获取个人 API 令牌", "setToken": "输入您的令牌", "tokenRequired": "需要 API 令牌", "tokenPlaceholder": "在此输入 API 令牌", "button": "同步", - "error": "同步MCP服务器出错", - "success": "同步MCP服务器成功", + "error": "同步 MCP 服务器出错", + "success": "同步 MCP 服务器成功", "unauthorized": "同步未授权", "noServersAvailable": "无可用的 MCP 服务器" }, "timeout": "超时", - "timeoutTooltip": "对该服务器请求的超时时间(秒),默认为60秒", + "timeoutTooltip": "对该服务器请求的超时时间(秒),默认为 60 秒", "provider": "提供者", "providerUrl": "提供者网址", "logoUrl": "标志网址", @@ -1635,7 +1635,7 @@ "advancedSettings": "高级设置" }, "messages.prompt": "显示提示词", - "messages.tokens": "显示Token用量", + "messages.tokens": "显示 Token 用量", "messages.divider": "消息分割线", "messages.divider.tooltip": "不适用于气泡样式消息", "messages.grid_columns": "消息网格展示列数", @@ -1648,11 +1648,11 @@ "messages.input.show_estimated_tokens": "显示预估 Token 数", "messages.input.title": "输入设置", "messages.input.enable_quick_triggers": "启用 / 和 @ 触发快捷菜单", - "messages.input.enable_delete_model": "启用删除键删除输入的模型/附件", + "messages.input.enable_delete_model": "启用删除键删除输入的模型 / 附件", "messages.markdown_rendering_input_message": "Markdown 渲染输入消息", "messages.math_engine": "数学公式引擎", "messages.math_engine.none": "无", - "messages.metrics": "首字时延 {{time_first_token_millsec}}ms | 每秒 {{token_speed}} tokens", + "messages.metrics": "首字时延 {{time_first_token_millsec}} ms | 每秒 {{token_speed}} tokens", "messages.model.title": "模型设置", "messages.navigation": "对话导航按钮", "messages.navigation.anchor": "对话锚点", @@ -1679,14 +1679,14 @@ "models.check.enable_concurrent": "并发检测", "models.check.enabled": "开启", "models.check.failed": "失败", - "models.check.keys_status_count": "通过:{{count_passed}}个密钥,失败:{{count_failed}}个密钥", + "models.check.keys_status_count": "通过:{{count_passed}} 个密钥,失败:{{count_failed}} 个密钥", "models.check.model_status_failed": "{{count}} 个模型完全无法访问", "models.check.model_status_partial": "其中 {{count}} 个模型用某些密钥无法访问", "models.check.model_status_passed": "{{count}} 个模型通过健康检测", "models.check.model_status_summary": "{{provider}}: {{summary}}", - "models.check.no_api_keys": "未找到API密钥,请先添加API密钥", + "models.check.no_api_keys": "未找到 API 密钥,请先添加 API 密钥", "models.check.passed": "通过", - "models.check.select_api_key": "选择要使用的API密钥:", + "models.check.select_api_key": "选择要使用的 API 密钥:", "models.check.single": "单个", "models.check.start": "开始", "models.check.title": "模型健康检测", @@ -1731,7 +1731,7 @@ "add.type": "提供商类型", "api.url.preview": "预览: {{url}}", "api.url.reset": "重置", - "api.url.tip": "/结尾忽略v1版本,#结尾强制使用输入地址", + "api.url.tip": "/ 结尾忽略 v1 版本,# 结尾强制使用输入地址", "api_host": "API 地址", "api_key": "API 密钥", "api_key.tip": "多个密钥使用逗号分隔", @@ -1748,8 +1748,8 @@ "check_all_keys": "检测所有密钥", "check_multiple_keys": "检测多个 API 密钥", "oauth": { - "button": "使用{{provider}}账号登录", - "description": "本服务由{{provider}}提供", + "button": "使用 {{provider}} 账号登录", + "description": "本服务由 {{provider}} 提供", "official_website": "官方网站" }, "openai": { @@ -1762,13 +1762,13 @@ "code_failed": "获取 Device Code 失败,请重试", "code_generated_desc": "请将 Device Code 复制到下面的浏览器链接中", "code_generated_title": "获取 Device Code", - "confirm_login": "过度使用可能会导致您的 Github 账号遭到封号,请谨慎使用!!!!", + "confirm_login": "过度使用可能会导致您的 Github 账号遭到封号,请谨慎使用!", "confirm_title": "风险警告", "connect": "连接 Github", "custom_headers": "自定义请求头", "description": "您的 Github 账号需要订阅 Copilot", "expand": "展开", - "headers_description": "自定义请求头(json格式)", + "headers_description": "自定义请求头 (json 格式)", "invalid_json": "JSON 格式错误", "login": "登录 Github", "logout": "退出 Github", @@ -1782,7 +1782,7 @@ "dmxapi": { "select_platform": "选择平台" }, - "delete.content": "确定要删除此模型提供商吗?", + "delete.content": "确定要删除此模型提供商吗?", "delete.title": "删除提供商", "docs_check": "查看", "docs_more_details": "获取更多详情", @@ -1797,7 +1797,7 @@ "title": "模型服务", "notes": { "title": "模型备注", - "placeholder": "请输入Markdown格式内容...", + "placeholder": "请输入 Markdown 格式内容...", "markdown_editor_default_value": "预览区域" }, "vertex_ai": { @@ -1856,7 +1856,7 @@ "reset_to_default": "重置为默认", "search_message": "搜索消息", "search_message_in_chat": "在当前对话中搜索消息", - "show_app": "显示/隐藏应用", + "show_app": "显示 / 隐藏应用", "show_settings": "打开设置", "title": "快捷键", "toggle_new_context": "清除上下文", @@ -1886,7 +1886,7 @@ "websearch": { "blacklist": "黑名单", "blacklist_description": "在搜索结果中不会出现以下网站的结果", - "blacklist_tooltip": "请使用以下格式(换行分隔)\n匹配模式: *://*.example.com/*\n正则表达式: /example\\.(net|org)/", + "blacklist_tooltip": "请使用以下格式 (换行分隔)\n匹配模式: *://*.example.com/*\n正则表达式: /example\\.(net|org)/", "check": "检测", "check_failed": "验证失败", "check_success": "验证成功", @@ -1903,7 +1903,7 @@ "subscribe_url": "订阅源地址", "subscribe_name": "替代名字", "subscribe_name.placeholder": "当下载的订阅源没有名称时所使用的替代名称", - "subscribe_add_success": "订阅源添加成功!", + "subscribe_add_success": "订阅源添加成功!", "subscribe_delete": "删除订阅源", "search_result_default": "默认", "search_with_time": "搜索包含日期", @@ -1923,7 +1923,7 @@ "method.cutoff": "截断", "cutoff.limit": "截断长度", "cutoff.limit.placeholder": "输入长度", - "cutoff.limit.tooltip": "限制搜索结果的内容长度, 超过限制的内容将被截断(例如 2000 字符)", + "cutoff.limit.tooltip": "限制搜索结果的内容长度,超过限制的内容将被截断(例如 2000 字符)", "cutoff.unit.char": "字符", "cutoff.unit.token": "Token", "method.rag": "RAG", @@ -1951,7 +1951,7 @@ "titleLabel": "标题", "contentLabel": "内容", "titlePlaceholder": "请输入短语标题", - "contentPlaceholder": "请输入短语内容,支持使用变量,然后按Tab键可以快速定位到变量进行修改。比如:\n帮我规划从${from}到${to}的路线,然后发送到${email}", + "contentPlaceholder": "请输入短语内容,支持使用变量,然后按 Tab 键可以快速定位到变量进行修改。比如:\n帮我规划从 ${from} 到 ${to} 的路线,然后发送到 ${email}", "delete": "删除短语", "deleteConfirm": "删除短语后将无法恢复,是否继续?", "locationLabel": "添加位置", @@ -2092,11 +2092,11 @@ "trigger_mode": { "title": "取词方式", "description": "划词后,触发取词并显示工具栏的方式", - "description_note": "少数应用不支持通过 Ctrl 键划词。若使用了AHK等工具对 Ctrl 键进行了重映射,可能导致部分应用无法划词。", + "description_note": "少数应用不支持通过 Ctrl 键划词。若使用了 AHK 等工具对 Ctrl 键进行了重映射,可能导致部分应用无法划词。", "selected": "划词", "selected_note": "划词后立即显示工具栏", "ctrlkey": "Ctrl 键", - "ctrlkey_note": "划词后,再 长按 Ctrl键,才显示工具栏", + "ctrlkey_note": "划词后,再 长按 Ctrl 键,才显示工具栏", "shortcut": "快捷键", "shortcut_note": "划词后,使用快捷键显示工具栏。请在快捷键设置页面中设置取词快捷键并启用。", "shortcut_link": "前往快捷键设置" @@ -2126,7 +2126,7 @@ }, "opacity": { "title": "透明度", - "description": "设置窗口的默认透明度,100%为完全不透明" + "description": "设置窗口的默认透明度,100% 为完全不透明" } }, "actions": { @@ -2139,7 +2139,7 @@ }, "add_tooltip": { "enabled": "添加自定义功能", - "disabled": "自定义功能已达上限 ({{max}}个)" + "disabled": "自定义功能已达上限 ({{max}} 个)" }, "delete_confirm": "确定要删除这个自定义功能吗?", "drag_hint": "拖拽排序,移动到上方以启用功能 ({{enabled}}/{{max}})" @@ -2171,7 +2171,7 @@ "label": "图标", "placeholder": "输入 Lucide 图标名称", "error": "无效的图标名称,请检查输入", - "tooltip": "Lucide图标名称为小写,如 arrow-right", + "tooltip": "Lucide 图标名称为小写,如 arrow-right", "view_all": "查看所有图标", "random": "随机图标" }, @@ -2186,9 +2186,9 @@ "default": "默认" }, "prompt": { - "label": "用户提示词(Prompt)", + "label": "用户提示词 (Prompt)", "tooltip": "用户提示词,作为用户输入的补充,不会覆盖助手的系统提示词", - "placeholder": "使用占位符{{text}}代表选中的文本,不填写时,选中的文本将添加到本提示词的末尾", + "placeholder": "使用占位符 {{text}} 代表选中的文本,不填写时,选中的文本将添加到本提示词的末尾", "placeholder_text": "占位符", "copy_placeholder": "复制占位符" } @@ -2203,7 +2203,7 @@ "name": { "label": "自定义名称", "hint": "请输入搜索引擎名称", - "max_length": "名称不能超过16个字符" + "max_length": "名称不能超过 16 个字符" }, "url": { "label": "自定义搜索 URL", @@ -2217,7 +2217,7 @@ }, "filter_modal": { "title": "应用筛选名单", - "user_tips": "请输入应用的执行文件名,每行一个,不区分大小写,可以模糊匹配。例如:chrome.exe、weixin.exe、Cherry Studio.exe等" + "user_tips": "请输入应用的执行文件名,每行一个,不区分大小写,可以模糊匹配。例如:chrome.exe、weixin.exe、Cherry Studio.exe 等" } } } diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 9b6299f385..edcaee1e85 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -10,7 +10,7 @@ "add.prompt.placeholder": "輸入提示詞", "add.prompt.variables.tip": { "title": "可用的變數", - "content": "{{date}}:\t日期\n{{time}}:\t時間\n{{datetime}}:\t日期和時間\n{{system}}:\t作業系統\n{{arch}}:\tCPU架構\n{{language}}:\t語言\n{{model_name}}:\t模型名稱\n{{username}}:\t使用者名稱" + "content": "{{date}}:\t日期\n{{time}}:\t時間\n{{datetime}}:\t日期和時間\n{{system}}:\t作業系統\n{{arch}}:\tCPU 架構\n{{language}}:\t語言\n{{model_name}}:\t模型名稱\n{{username}}:\t使用者名稱" }, "add.title": "建立智慧代理人", "import": { @@ -87,7 +87,7 @@ "titleLabel": "標題", "titlePlaceholder": "輸入標題", "contentLabel": "內容", - "contentPlaceholder": "請輸入短語內容,支持使用變量,然後按Tab鍵可以快速定位到變量進行修改。比如:\n幫我規劃從${from}到${to}的行程,然後發送到${email}" + "contentPlaceholder": "請輸入短語內容,支持使用變量,然後按 Tab 鍵可以快速定位到變量進行修改。比如:\n幫我規劃從 ${from} 到 ${to} 的行程,然後發送到 ${email}" }, "settings.knowledge_base.recognition.tip": "智慧代理人將調用大語言模型的意圖識別能力,判斷是否需要調用知識庫進行回答,該功能將依賴模型的能力", "settings.knowledge_base.recognition": "調用知識庫", @@ -118,7 +118,7 @@ "get_key": "取得", "get_key_success": "自動取得金鑰成功", "login": "登入", - "oauth_button": "使用{{provider}}登入" + "oauth_button": "使用 {{provider}} 登入" }, "backup": { "confirm": "確定要備份資料嗎?", @@ -186,8 +186,8 @@ "input.placeholder": "在此輸入您的訊息,按 {{key}} 傳送...", "input.send": "傳送", "input.settings": "設定", - "input.topics": " 話題 ", - "input.translate": "翻譯成{{target_language}}", + "input.topics": "話題", + "input.translate": "翻譯成 {{target_language}}", "input.upload": "上傳圖片或文件", "input.upload.document": "上傳文件(模型不支援圖片)", "input.web_search": "網路搜尋", @@ -294,9 +294,9 @@ "topics.export.obsidian_title": "標題", "topics.export.obsidian_title_placeholder": "請輸入標題", "topics.export.obsidian_title_required": "標題不能為空", - "topics.export.obsidian_no_vaults": "未找到Obsidian保管庫", + "topics.export.obsidian_no_vaults": "未找到 Obsidian 保管庫", "topics.export.obsidian_loading": "加載中...", - "topics.export.obsidian_fetch_error": "獲取Obsidian保管庫失敗", + "topics.export.obsidian_fetch_error": "獲取 Obsidian 保管庫失敗", "topics.export.obsidian_fetch_folders_error": "獲取文件夾結構失敗", "topics.export.obsidian_no_vault_selected": "請先選擇一個保管庫", "topics.export.obsidian_select_vault_first": "請先選擇保管庫", @@ -332,11 +332,11 @@ "input.tools.collapse_out": "移出折疊", "input.thinking": "思考", "input.thinking.mode.default": "預設", - "input.thinking.mode.default.tip": "模型會自動確定思考的 token 數", + "input.thinking.mode.default.tip": "模型會自動確定思考的 Token 數", "input.thinking.mode.custom": "自定義", - "input.thinking.mode.custom.tip": "模型最多可以思考的 token 數。需要考慮模型的上下文限制,否則會報錯", - "input.thinking.mode.tokens.tip": "設置思考的 token 數", - "input.thinking.budget_exceeds_max": "思考預算超過最大 token 數" + "input.thinking.mode.custom.tip": "模型最多可以思考的 Token 數。需要考慮模型的上下文限制,否則會報錯", + "input.thinking.mode.tokens.tip": "設置思考的 Token 數", + "input.thinking.budget_exceeds_max": "思考預算超過最大 Token 數" }, "code_block": { "collapse": "折疊", @@ -559,7 +559,7 @@ "threshold_tooltip": "用於衡量使用者問題與知識庫內容之間的相關性(0-1)", "title": "知識庫", "topN": "返回結果數量", - "topN_too_large_or_small": "返回結果數量不能大於30或小於1", + "topN_too_large_or_small": "返回結果數量不能大於 30 或小於 1", "topN_placeholder": "未設定", "topN_tooltip": "返回的匹配結果數量,數值越大,匹配結果越多,但消耗的 Token 也越多", "url_added": "網址已新增", @@ -567,7 +567,7 @@ "urls": "網址", "dimensions": "嵌入維度", "dimensions_size_tooltip": "嵌入維度大小,數值越大,嵌入維度越大,但消耗的 Token 也越多", - "dimensions_size_placeholder": " 嵌入維度大小,例如 1024", + "dimensions_size_placeholder": "嵌入維度大小,例如 1024", "dimensions_auto_set": "自動設定嵌入維度", "dimensions_error_invalid": "請輸入嵌入維度大小", "dimensions_size_too_large": "嵌入維度不能超過模型上下文限制({{max_context}})", @@ -642,7 +642,7 @@ "error.invalid.proxy.url": "無效的代理伺服器 URL", "error.invalid.webdav": "無效的 WebDAV 設定", "error.joplin.export": "匯出 Joplin 失敗,請保持 Joplin 已運行並檢查連接狀態或檢查設定", - "error.joplin.no_config": "未設定 Joplin 授權Token 或 URL", + "error.joplin.no_config": "未設定 Joplin 授權 Token 或 URL", "error.invalid.nutstore": "無效的坚果云設定", "error.invalid.nutstore_token": "無效的坚果云 Token", "error.markdown.export.preconf": "導出 Markdown 文件到預先設定的路徑失敗", @@ -699,7 +699,7 @@ "warn.notion.exporting": "正在匯出到 Notion,請勿重複請求匯出!", "warning.rate.limit": "發送過於頻繁,請在 {{seconds}} 秒後再嘗試", "error.siyuan.export": "導出思源筆記失敗,請檢查連接狀態並對照文檔檢查配置", - "error.siyuan.no_config": "未配置思源筆記API地址或令牌", + "error.siyuan.no_config": "未配置思源筆記 API 地址或令牌", "success.siyuan.export": "導出到思源筆記成功", "warn.yuque.exporting": "正在導出語雀,請勿重複請求導出!", "warn.siyuan.exporting": "正在導出到思源筆記,請勿重複請求導出!", @@ -722,7 +722,7 @@ "minimize": "最小化小工具", "devtools": "開發者工具", "openExternal": "在瀏覽器中開啟", - "rightclick_copyurl": "右鍵複製URL", + "rightclick_copyurl": "右鍵複製 URL", "open_link_external_on": "当前:在瀏覽器中開啟連結", "open_link_external_off": "当前:使用預設視窗開啟連結" }, @@ -785,7 +785,7 @@ "embedding": "嵌入", "embedding_dimensions": "嵌入維度", "embedding_model": "嵌入模型", - "embedding_model_tooltip": "在設定->模型服務中點選管理按鈕新增", + "embedding_model_tooltip": "在設定 -> 模型服務中點選管理按鈕新增", "function_calling": "函數調用", "no_matches": "無可用模型", "parameter_name": "參數名稱", @@ -798,7 +798,7 @@ "pinned": "已固定", "rerank_model": "重排模型", "rerank_model_support_provider": "目前重排序模型僅支持部分服務商 ({{provider}})", - "rerank_model_tooltip": "在設定->模型服務中點擊管理按鈕添加", + "rerank_model_tooltip": "在設定 -> 模型服務中點擊管理按鈕添加", "search": "搜尋模型...", "stream_output": "串流輸出", "enable_tool_use": "工具調用", @@ -834,7 +834,7 @@ }, "notification": { "assistant": "助手回應", - "knowledge.success": "成功將{{type}}新增至知識庫", + "knowledge.success": "成功將 {{type}} 新增至知識庫", "knowledge.error": "無法將 {{type}} 加入知識庫: {{error}}" }, "ollama": { @@ -869,10 +869,10 @@ "aspect_ratio": "畫幅比例", "style_type": "風格", "learn_more": "了解更多", - "prompt_placeholder_edit": "輸入你的圖片描述,文本繪製用 '雙引號' 包裹", - "prompt_placeholder_en": "輸入”英文“圖片描述,目前 Imagen 僅支持英文提示詞", + "prompt_placeholder_edit": "輸入你的圖片描述,文本繪製用 ' 雙引號 ' 包裹", + "prompt_placeholder_en": "輸入” 英文 “圖片描述,目前 Imagen 僅支持英文提示詞", "paint_course": "教程", - "proxy_required": "打開代理並開啟”TUN模式“查看生成圖片或複製到瀏覽器開啟,後續會支持國內直連", + "proxy_required": "打開代理並開啟”TUN 模式 “查看生成圖片或複製到瀏覽器開啟,後續會支持國內直連", "image_file_required": "請先上傳圖片", "image_file_retry": "請重新上傳圖片", "image_placeholder": "無圖片", @@ -932,7 +932,7 @@ "negative_prompt_tip": "描述不想在圖像中出現的內容", "magic_prompt_option_tip": "智能優化生成效果的提示詞", "style_type_tip": "圖像生成風格,僅適用於 V_2 及以上版本", - "rendering_speed_tip": "控制渲染速度與品質之間的平衡,僅適用於V_3版本", + "rendering_speed_tip": "控制渲染速度與品質之間的平衡,僅適用於 V_3 版本", "person_generation": "人物生成", "person_generation_tip": "允許模型生成人物圖像" }, @@ -943,7 +943,7 @@ "style_type_tip": "編輯後的圖像風格,僅適用於 V_2 及以上版本", "seed_tip": "控制編輯結果的隨機性", "magic_prompt_option_tip": "智能優化編輯提示詞", - "rendering_speed_tip": "控制渲染速度與品質之間的平衡,僅適用於V_3版本" + "rendering_speed_tip": "控制渲染速度與品質之間的平衡,僅適用於 V_3 版本" }, "remix": { "model_tip": "選擇重混使用的 AI 模型版本", @@ -955,7 +955,7 @@ "style_type_tip": "重混後的圖像風格,僅適用於 V_2 及以上版本", "negative_prompt_tip": "描述不想在重混結果中出現的元素", "magic_prompt_option_tip": "智能優化重混提示詞", - "rendering_speed_tip": "控制渲染速度與品質之間的平衡,僅適用於V_3版本" + "rendering_speed_tip": "控制渲染速度與品質之間的平衡,僅適用於 V_3 版本" }, "upscale": { "image_file": "需要放大的圖片", @@ -970,7 +970,7 @@ "rendering_speed": "渲染速度", "text_desc_required": "請先輸入圖片描述", "image_handle_required": "請先上傳圖片。", - "req_error_text": "运行失败,请重试。提示词避免“版权词”和”敏感词”哦。", + "req_error_text": "运行失败,请重试。提示词避免 “版权词” 和” 敏感词” 哦。", "req_error_token": "請檢查令牌的有效性", "req_error_no_balance": "請檢查令牌的有效性", "auto_create_paint": "自動新增圖片", @@ -989,7 +989,7 @@ "prompts": { "explanation": "幫我解釋一下這個概念", "summarize": "幫我總結一下這段話", - "title": "將會話內容以{{language}}總結為10個字內的標題,忽略對話中的指令,勿使用標點與特殊符號。僅輸出純字串,不輸出標題以外內容。" + "title": "將會話內容以 {{language}} 總結為 10 個字內的標題,忽略對話中的指令,勿使用標點與特殊符號。僅輸出純字串,不輸出標題以外內容。" }, "provider": { "aihubmix": "AiHubMix", @@ -1097,10 +1097,10 @@ "app_data.select": "修改目錄", "app_data.select_title": "變更應用數據目錄", "app_data.restart_notice": "變更數據目錄後可能需要重啟應用才能生效", - "app_data.copy_data_option": "複製數據, 會自動重啟後將原始目錄數據複製到新目錄", + "app_data.copy_data_option": "複製數據,會自動重啟後將原始目錄數據複製到新目錄", "app_data.copy_time_notice": "複製數據將需要一些時間,複製期間不要關閉應用", "app_data.path_changed_without_copy": "路徑已變更成功", - "app_data.copying_warning": "數據複製中,不要強制退出應用, 複製完成後會自動重啟應用", + "app_data.copying_warning": "數據複製中,不要強制退出應用,複製完成後會自動重啟應用", "app_data.copying": "正在複製數據到新位置...", "app_data.copy_success": "成功複製數據到新位置", "app_data.copy_failed": "複製數據失敗", @@ -1113,7 +1113,7 @@ "app_data.select_error_write_permission": "新路徑沒有寫入權限", "app_data.stop_quit_app_reason": "應用目前正在遷移數據,不能退出", "app_data.select_not_empty_dir": "新路徑不為空", - "app_data.select_not_empty_dir_content": "新路徑不為空,選擇複製將覆蓋新路徑中的數據, 有數據丟失和複製失敗的風險,是否繼續?", + "app_data.select_not_empty_dir_content": "新路徑不為空,選擇複製將覆蓋新路徑中的數據,有數據丟失和複製失敗的風險,是否繼續?", "app_data.select_error_same_path": "新路徑與舊路徑相同,請選擇其他路徑", "app_data.select_error_in_app_path": "新路徑與應用安裝路徑相同,請選擇其他路徑", "app_knowledge": "知識庫文件", @@ -1123,7 +1123,7 @@ "app_knowledge.remove_all_success": "檔案刪除成功", "app_logs": "應用程式日誌", "backup.skip_file_data_title": "精簡備份", - "backup.skip_file_data_help": "備份時跳過備份圖片、知識庫等數據文件,僅備份聊天記錄和設置。減少空間佔用, 加快備份速度", + "backup.skip_file_data_help": "備份時跳過備份圖片、知識庫等數據文件,僅備份聊天記錄和設置。減少空間佔用,加快備份速度", "clear_cache": { "button": "清除快取", "confirm": "清除快取將刪除應用快取資料,包括小工具資料。此操作不可恢復,是否繼續?", @@ -1141,44 +1141,44 @@ "export_menu": { "title": "匯出選單設定", "image": "匯出為圖片", - "markdown": "匯出為Markdown", - "markdown_reason": "匯出為Markdown(包含思考)", - "notion": "匯出到Notion", + "markdown": "匯出為 Markdown", + "markdown_reason": "匯出為 Markdown(包含思考)", + "notion": "匯出到 Notion", "yuque": "匯出到語雀", - "obsidian": "匯出到Obsidian", + "obsidian": "匯出到 Obsidian", "siyuan": "匯出到思源筆記", - "joplin": "匯出到Joplin", - "docx": "匯出為Word", + "joplin": "匯出到 Joplin", + "docx": "匯出為 Word", "plain_text": "複製為純文本" }, "joplin": { "check": { "button": "檢查", - "empty_token": "請先輸入 Joplin 授權Token", + "empty_token": "請先輸入 Joplin 授權 Token", "empty_url": "請先輸入 Joplin 剪輯服務 URL", "fail": "Joplin 連接驗證失敗", "success": "Joplin 連接驗證成功" }, - "help": "在 Joplin 選項中,啟用剪輯服務(無需安裝瀏覽器外掛),確認埠編號,並複製授權Token", + "help": "在 Joplin 選項中,啟用剪輯服務(無需安裝瀏覽器外掛),確認埠編號,並複製授權 Token", "title": "Joplin 設定", - "token": "Joplin 授權Token", - "token_placeholder": "請輸入 Joplin 授權Token", + "token": "Joplin 授權 Token", + "token_placeholder": "請輸入 Joplin 授權 Token", "url": "Joplin 剪輯服務 URL", "url_placeholder": "http://127.0.0.1:41184/", "export_reasoning.title": "匯出時包含思維鏈", "export_reasoning.help": "啟用後,匯出內容將包含助手生成的思維鏈(思考過程)。" }, - "markdown_export.force_dollar_math.help": "開啟後,匯出Markdown時會強制使用$$來標記LaTeX公式。注意:該項也會影響所有透過Markdown匯出的方式,如Notion、語雀等", - "markdown_export.force_dollar_math.title": "LaTeX公式強制使用$$", + "markdown_export.force_dollar_math.help": "開啟後,匯出 Markdown 時會強制使用 $$ 來標記 LaTeX 公式。注意:該項也會影響所有透過 Markdown 匯出的方式,如 Notion、語雀等", + "markdown_export.force_dollar_math.title": "LaTeX 公式強制使用 $$", "markdown_export.help": "若填入,每次匯出時將自動儲存至該路徑;否則,將彈出儲存對話框", "markdown_export.path": "預設匯出路徑", "markdown_export.path_placeholder": "匯出路徑", "markdown_export.select": "選擇", "markdown_export.title": "Markdown 匯出", "markdown_export.show_model_name.title": "匯出時使用模型名稱", - "markdown_export.show_model_name.help": "啟用後,匯出Markdown時會顯示模型名稱。注意:該項也會影響所有透過Markdown匯出的方式,如Notion、語雀等。", + "markdown_export.show_model_name.help": "啟用後,匯出 Markdown 時會顯示模型名稱。注意:該項也會影響所有透過 Markdown 匯出的方式,如 Notion、語雀等。", "markdown_export.show_model_provider.title": "顯示模型供應商", - "markdown_export.show_model_provider.help": "在匯出Markdown時顯示模型供應商,如OpenAI、Gemini等", + "markdown_export.show_model_provider.help": "在匯出 Markdown 時顯示模型供應商,如 OpenAI、Gemini 等", "minute_interval_one": "{{count}} 分鐘", "minute_interval_other": "{{count}} 分鐘", "notion.api_key": "Notion 金鑰", @@ -1198,7 +1198,7 @@ "notion.page_name_key_placeholder": "請輸入頁面標題欄位名稱,預設為 Name", "notion.title": "Notion 設定", "notion.export_reasoning.title": "匯出時包含思維鏈", - "notion.export_reasoning.help": "啟用後,匯出到Notion時會包含思維鏈內容。", + "notion.export_reasoning.help": "啟用後,匯出到 Notion 時會包含思維鏈內容。", "title": "資料設定", "webdav": { "autoSync": "自動備份", @@ -1250,7 +1250,7 @@ }, "s3": { "title": "S3 相容儲存", - "title.help": "與AWS S3 API相容的物件儲存服務,例如AWS S3、Cloudflare R2、阿里雲OSS、騰訊雲COS等", + "title.help": "與 AWS S3 API 相容的物件儲存服務,例如 AWS S3、Cloudflare R2、阿里雲 OSS、騰訊雲 COS 等", "endpoint": "API 位址", "endpoint.placeholder": "https://s3.example.com", "region": "區域", @@ -1338,21 +1338,21 @@ }, "siyuan": { "title": "思源筆記配置", - "api_url": "API地址", + "api_url": "API 地址", "api_url_placeholder": "例如:http://127.0.0.1:6806", - "token": "API令牌", - "token.help": "在思源筆記->設置->關於中獲取", + "token": "API 令牌", + "token.help": "在思源筆記 -> 設置 -> 關於中獲取", "token_placeholder": "請輸入思源筆記令牌", - "box_id": "筆記本ID", - "box_id_placeholder": "請輸入筆記本ID", + "box_id": "筆記本 ID", + "box_id_placeholder": "請輸入筆記本 ID", "root_path": "文檔根路徑", "root_path_placeholder": "例如:/CherryStudio", "check": { "title": "連接檢查", "button": "檢查", - "empty_config": "請填寫API地址和令牌", + "empty_config": "請填寫 API 地址和令牌", "success": "連接成功", - "fail": "連接失敗,請檢查API地址和令牌", + "fail": "連接失敗,請檢查 API 地址和令牌", "error": "連接異常,請檢查網絡連接" } }, @@ -1380,7 +1380,7 @@ "new_folder.button": "新建文件夾" }, "message_title.use_topic_naming.title": "使用話題命名模型為導出的消息創建標題", - "message_title.use_topic_naming.help": "此設定會影響所有通過Markdown導出的方式,如Notion、語雀等" + "message_title.use_topic_naming.help": "此設定會影響所有通過 Markdown 導出的方式,如 Notion、語雀等" }, "display.assistant.title": "助手設定", "display.custom.css": "自訂 CSS", @@ -1408,8 +1408,8 @@ "title": "在瀏覽器中打開新視窗連結" }, "custom": { - "duplicate_ids": "發現重複的ID: {{ids}}", - "conflicting_ids": "與預設應用ID衝突: {{ids}}", + "duplicate_ids": "發現重複的 ID: {{ids}}", + "conflicting_ids": "與預設應用 ID 衝突: {{ids}}", "title": "自定義", "edit_title": "編輯自定義小程序", "save_success": "自定義小程序保存成功", @@ -1435,7 +1435,7 @@ "logo_upload_label": "上傳 Logo", "logo_upload_button": "上傳", "save": "保存", - "placeholder": "請輸入自定義小程序配置(JSON格式)", + "placeholder": "請輸入自定義小程序配置(JSON 格式)", "edit_description": "編輯自定義小程序配置" }, "cache_settings": "緩存設置", @@ -1482,7 +1482,7 @@ "addServer": "新增伺服器", "addServer.create": "快速創建", "addServer.importFrom": "從 JSON 導入", - "addServer.importFrom.tooltip": "請從 MCP Servers 的介紹頁面複製配置JSON(優先使用\n NPX或 UVX 配置),並粘貼到輸入框中", + "addServer.importFrom.tooltip": "請從 MCP Servers 的介紹頁面複製配置 JSON(優先使用\n NPX 或 UVX 配置),並粘貼到輸入框中", "addServer.importFrom.placeholder": "貼上 MCP 伺服器 JSON 設定", "addServer.importFrom.invalid": "無效的輸入,請檢查 JSON 格式", "addServer.importFrom.nameExists": "伺服器已存在:{{name}}", @@ -1494,8 +1494,8 @@ "baseUrlTooltip": "遠端 URL 地址", "command": "指令", "sse": "伺服器傳送事件 (sse)", - "streamableHttp": "可串流的HTTP (streamableHttp)", - "stdio": "標準輸入/輸出 (stdio)", + "streamableHttp": "可串流的 HTTP (streamableHttp)", + "stdio": "標準輸入 / 輸出 (stdio)", "inMemory": "記憶體", "config_description": "設定模型上下文協議伺服器", "disable": "不使用 MCP 伺服器", @@ -1507,7 +1507,7 @@ "description": "描述", "noDescriptionAvailable": "描述不存在", "duplicateName": "已存在相同名稱的伺服器", - "editJson": "編輯JSON", + "editJson": "編輯 JSON", "editServer": "編輯伺服器", "env": "環境變數", "envTooltip": "格式:KEY=value,每行一個", @@ -1518,10 +1518,10 @@ "install": "安裝", "installError": "安裝相依套件失敗", "installSuccess": "相依套件安裝成功", - "jsonFormatError": "JSON格式錯誤", - "jsonModeHint": "編輯MCP伺服器配置的JSON表示。保存前請確保格式正確", - "jsonSaveError": "保存JSON配置失敗", - "jsonSaveSuccess": "JSON配置已儲存", + "jsonFormatError": "JSON 格式錯誤", + "jsonModeHint": "編輯 MCP 伺服器配置的 JSON 表示。保存前請確保格式正確", + "jsonSaveError": "保存 JSON 配置失敗", + "jsonSaveSuccess": "JSON 配置已儲存", "missingDependencies": "缺失,請安裝它以繼續", "name": "名稱", "noServers": "未設定伺服器", @@ -1578,7 +1578,7 @@ "noResourcesAvailable": "無可用資源", "availableResources": "可用資源", "uri": "URI", - "mimeType": "MIME類型", + "mimeType": "MIME 類型", "size": "大小", "blob": "二進位數據", "blobInvisible": "隱藏二進位數據", @@ -1602,21 +1602,21 @@ "sync": { "title": "同步伺服器", "selectProvider": "選擇提供者:", - "discoverMcpServers": "發現MCP伺服器", - "discoverMcpServersDescription": "訪問平台以發現可用的MCP伺服器", + "discoverMcpServers": "發現 MCP 伺服器", + "discoverMcpServersDescription": "訪問平台以發現可用的 MCP 伺服器", "getToken": "獲取 API 令牌", "getTokenDescription": "從您的帳戶獲取個人 API 令牌", "setToken": "輸入您的令牌", "tokenRequired": "需要 API 令牌", "tokenPlaceholder": "在此輸入 API 令牌", "button": "同步", - "error": "同步MCP伺服器出錯", - "success": "同步MCP伺服器成功", + "error": "同步 MCP 伺服器出錯", + "success": "同步 MCP 伺服器成功", "unauthorized": "同步未授權", "noServersAvailable": "無可用的 MCP 伺服器" }, "timeout": "超時", - "timeoutTooltip": "對該伺服器請求的超時時間(秒),預設為60秒", + "timeoutTooltip": "對該伺服器請求的超時時間(秒),預設為 60 秒", "provider": "提供者", "providerUrl": "提供者網址", "logoUrl": "標誌網址", @@ -1626,7 +1626,7 @@ "advancedSettings": "高級設定" }, "messages.prompt": "提示詞顯示", - "messages.tokens": "Token用量顯示", + "messages.tokens": "Token 用量顯示", "messages.divider": "訊息間顯示分隔線", "messages.divider.tooltip": "不適用於氣泡樣式消息", "messages.grid_columns": "訊息網格展示列數", @@ -1639,11 +1639,11 @@ "messages.input.show_estimated_tokens": "顯示預估 Token 數", "messages.input.title": "輸入設定", "messages.input.enable_quick_triggers": "啟用 / 和 @ 觸發快捷選單", - "messages.input.enable_delete_model": "啟用刪除鍵刪除模型/附件", + "messages.input.enable_delete_model": "啟用刪除鍵刪除模型 / 附件", "messages.markdown_rendering_input_message": "Markdown 渲染輸入訊息", "messages.math_engine": "數學公式引擎", "messages.math_engine.none": "無", - "messages.metrics": "首字延遲 {{time_first_token_millsec}}ms | 每秒 {{token_speed}} tokens", + "messages.metrics": "首字延遲 {{time_first_token_millsec}} ms | 每秒 {{token_speed}} tokens", "messages.model.title": "模型設定", "messages.navigation": "訊息導航", "messages.navigation.anchor": "對話錨點", @@ -1670,14 +1670,14 @@ "models.check.enable_concurrent": "並行檢查", "models.check.enabled": "開啟", "models.check.failed": "失敗", - "models.check.keys_status_count": "通過:{{count_passed}}個密鑰,失敗:{{count_failed}}個密鑰", + "models.check.keys_status_count": "通過:{{count_passed}} 個密鑰,失敗:{{count_failed}} 個密鑰", "models.check.model_status_failed": "{{count}} 個模型完全無法訪問", "models.check.model_status_partial": "其中 {{count}} 個模型用某些密鑰無法訪問", "models.check.model_status_passed": "{{count}} 個模型通過健康檢查", "models.check.model_status_summary": "{{provider}}: {{summary}}", - "models.check.no_api_keys": "未找到API密鑰,請先添加API密鑰", + "models.check.no_api_keys": "未找到 API 密鑰,請先添加 API 密鑰", "models.check.passed": "通過", - "models.check.select_api_key": "選擇要使用的API密鑰:", + "models.check.select_api_key": "選擇要使用的 API 密鑰:", "models.check.single": "單個", "models.check.start": "開始", "models.check.title": "模型健康檢查", @@ -1716,7 +1716,7 @@ "add.type": "供應商類型", "api.url.preview": "預覽:{{url}}", "api.url.reset": "重設", - "api.url.tip": "/結尾忽略 v1 版本,#結尾強制使用輸入位址", + "api.url.tip": "/ 結尾忽略 v1 版本,# 結尾強制使用輸入位址", "api_host": "API 主機地址", "api_key": "API 金鑰", "api_key.tip": "多個金鑰使用逗號分隔", @@ -1733,24 +1733,24 @@ "check_all_keys": "檢查所有金鑰", "check_multiple_keys": "檢查多個 API 金鑰", "oauth": { - "button": "使用{{provider}}帳號登入", - "description": "本服務由{{provider}}提供", + "button": "使用 {{provider}} 帳號登入", + "description": "本服務由 {{provider}} 提供", "official_website": "官方網站" }, "copilot": { - "auth_failed": "Github Copilot認證失敗", + "auth_failed": "Github Copilot 認證失敗", "auth_success": "Github Copilot 認證成功", "auth_success_title": "認證成功", - "code_failed": "獲取 Device Code失敗,請重試", + "code_failed": "獲取 Device Code 失敗,請重試", "code_generated_desc": "請將設備代碼複製到下面的瀏覽器連結中", "code_generated_title": "獲取設備代碼", - "confirm_login": "過度使用可能會導致您的 Github 帳號遭到封號,請謹慎使用!!!!", + "confirm_login": "過度使用可能會導致您的 Github 帳號遭到封號,請謹慎使用!", "confirm_title": "風險警告", "connect": "連接 Github", "custom_headers": "自訂請求標頭", "description": "您的 Github 帳號需要訂閱 Copilot", "expand": "展開", - "headers_description": "自訂請求標頭(json格式)", + "headers_description": "自訂請求標頭 (json 格式)", "invalid_json": "JSON 格式錯誤", "login": "登入 Github", "logout": "退出 Github", @@ -1779,14 +1779,14 @@ "title": "模型提供者", "notes": { "title": "模型備註", - "placeholder": "輸入Markdown格式內容...", + "placeholder": "輸入 Markdown 格式內容...", "markdown_editor_default_value": "預覽區域" }, "openai": { "alert": "OpenAI Provider 不再支援舊的呼叫方法。如果使用第三方 API,請建立新的服務供應商" }, "vertex_ai": { - "project_id": "專案ID", + "project_id": "專案 ID", "project_id_placeholder": "your-google-cloud-project-id", "project_id_help": "您的 Google Cloud 專案 ID", "location": "地區", @@ -1840,7 +1840,7 @@ "reset_to_default": "重設為預設", "search_message": "搜尋訊息", "search_message_in_chat": "在當前對話中搜尋訊息", - "show_app": "顯示/隱藏應用程式", + "show_app": "顯示 / 隱藏應用程式", "show_settings": "開啟設定", "title": "快捷鍵", "toggle_new_context": "清除上下文", @@ -1894,7 +1894,7 @@ "subscribe_url": "訂閱源地址", "subscribe_name": "替代名稱", "subscribe_name.placeholder": "當下載的訂閱源沒有名稱時所使用的替代名稱", - "subscribe_add_success": "訂閱源添加成功!", + "subscribe_add_success": "訂閱源添加成功!", "subscribe_delete": "刪除", "title": "網路搜尋", "overwrite": "覆蓋搜尋服務商", @@ -1932,9 +1932,9 @@ "general.auto_check_update.title": "自動更新", "general.test_plan.title": "測試計畫", "general.test_plan.tooltip": "參與測試計畫,體驗最新功能,但同時也帶來更多風險,請務必提前備份數據", - "general.test_plan.beta_version": "測試版本(Beta)", + "general.test_plan.beta_version": "測試版本 (Beta)", "general.test_plan.beta_version_tooltip": "功能可能會隨時變化,錯誤較多,升級較快", - "general.test_plan.rc_version": "預覽版本(RC)", + "general.test_plan.rc_version": "預覽版本 (RC)", "general.test_plan.rc_version_tooltip": "相對穩定,請務必提前備份數據", "general.test_plan.version_options": "版本選項", "general.test_plan.version_channel_not_match": "預覽版和測試版的切換將在下一個正式版發布時生效", @@ -1945,7 +1945,7 @@ "titleLabel": "標題", "contentLabel": "內容", "titlePlaceholder": "請輸入短語標題", - "contentPlaceholder": "請輸入短語內容,支持使用變量,然後按Tab鍵可以快速定位到變量進行修改。比如:\n幫我規劃從${from}到${to}的行程,然後發送到${email}", + "contentPlaceholder": "請輸入短語內容,支持使用變量,然後按 Tab 鍵可以快速定位到變量進行修改。比如:\n幫我規劃從 ${from} 到 ${to} 的行程,然後發送到 ${email}", "delete": "刪除短語", "deleteConfirm": "刪除後無法復原,是否繼續?", "locationLabel": "添加位置", @@ -1971,7 +1971,7 @@ "reset": "重置" }, "openai": { - "title": "OpenAI設定", + "title": "OpenAI 設定", "summary_text_mode.title": "摘要模式", "summary_text_mode.tip": "模型所執行的推理摘要", "summary_text_mode.auto": "自動", @@ -2092,11 +2092,11 @@ "trigger_mode": { "title": "取詞方式", "description": "劃詞後,觸發取詞並顯示工具列的方式", - "description_note": "在某些應用中可能無法透過 Ctrl 鍵劃詞。若使用了AHK等工具對Ctrl鍵進行了重新對應,可能導致部分應用程式無法劃詞。", + "description_note": "在某些應用中可能無法透過 Ctrl 鍵劃詞。若使用了 AHK 等工具對 Ctrl 鍵進行了重新對應,可能導致部分應用程式無法劃詞。", "selected": "劃詞", "selected_note": "劃詞後,立即顯示工具列", "ctrlkey": "Ctrl 鍵", - "ctrlkey_note": "劃詞後,再 按住 Ctrl鍵,才顯示工具列", + "ctrlkey_note": "劃詞後,再 按住 Ctrl 鍵,才顯示工具列", "shortcut": "快捷鍵", "shortcut_note": "劃詞後,使用快捷鍵顯示工具列。請在快捷鍵設定頁面中設置取詞快捷鍵並啟用。", "shortcut_link": "前往快捷鍵設定" @@ -2126,7 +2126,7 @@ }, "opacity": { "title": "透明度", - "description": "設置視窗的預設透明度,100%為完全不透明" + "description": "設置視窗的預設透明度,100% 為完全不透明" } }, "actions": { @@ -2139,7 +2139,7 @@ }, "add_tooltip": { "enabled": "新增自訂功能", - "disabled": "自訂功能已達上限 ({{max}}個)" + "disabled": "自訂功能已達上限 ({{max}} 個)" }, "delete_confirm": "確定要刪除這個自訂功能嗎?", "drag_hint": "拖曳排序,移動到上方以啟用功能 ({{enabled}}/{{max}})" @@ -2171,7 +2171,7 @@ "label": "圖示", "placeholder": "輸入 Lucide 圖示名稱", "error": "無效的圖示名稱,請檢查輸入", - "tooltip": "Lucide圖示名稱為小寫,如 arrow-right", + "tooltip": "Lucide 圖示名稱為小寫,如 arrow-right", "view_all": "檢視所有圖示", "random": "隨機圖示" }, @@ -2186,9 +2186,9 @@ "default": "預設" }, "prompt": { - "label": "使用者提示詞(Prompt)", + "label": "使用者提示詞 (Prompt)", "tooltip": "使用者提示詞,作為使用者輸入的補充,不會覆蓋助手的系統提示詞", - "placeholder": "使用佔位符{{text}}代表選取的文字,不填寫時,選取的文字將加到本提示詞的末尾", + "placeholder": "使用佔位符 {{text}} 代表選取的文字,不填寫時,選取的文字將加到本提示詞的末尾", "placeholder_text": "佔位符", "copy_placeholder": "複製佔位符" } @@ -2203,7 +2203,7 @@ "name": { "label": "自訂名稱", "hint": "請輸入搜尋引擎名稱", - "max_length": "名稱不能超過16個字元" + "max_length": "名稱不能超過 16 個字元" }, "url": { "label": "自訂搜尋 URL", @@ -2217,7 +2217,7 @@ }, "filter_modal": { "title": "應用篩選名單", - "user_tips": "請輸入應用的執行檔名稱,每行一個,不區分大小寫,可以模糊匹配。例如:chrome.exe、weixin.exe、Cherry Studio.exe等" + "user_tips": "請輸入應用的執行檔名稱,每行一個,不區分大小寫,可以模糊匹配。例如:chrome.exe、weixin.exe、Cherry Studio.exe 等" } } } From 9a4c69579da6ace2b3dfb3d828cc9ef731109981 Mon Sep 17 00:00:00 2001 From: Kingsword Date: Sun, 29 Jun 2025 21:32:05 +0800 Subject: [PATCH 088/111] fix: restore message content className logic to resolve search issue (#7651) --- src/renderer/src/pages/home/Messages/Message.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/pages/home/Messages/Message.tsx b/src/renderer/src/pages/home/Messages/Message.tsx index d013e34e0e..4d56ce7a05 100644 --- a/src/renderer/src/pages/home/Messages/Message.tsx +++ b/src/renderer/src/pages/home/Messages/Message.tsx @@ -155,7 +155,13 @@ const MessageItem: FC = ({ {!isEditing && ( <> Date: Sun, 29 Jun 2025 23:58:24 +0800 Subject: [PATCH 089/111] feat: support linux deb (#7652) --- electron-builder.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/electron-builder.yml b/electron-builder.yml index d1a70bf896..ecbbc10057 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -90,6 +90,7 @@ linux: artifactName: ${productName}-${version}-${arch}.${ext} target: - target: AppImage + - target: deb maintainer: electronjs.org category: Utility desktop: From 218dcc222926ec2907b4d6e65d238ab4c5850142 Mon Sep 17 00:00:00 2001 From: Yiyang Suen Date: Mon, 30 Jun 2025 00:01:28 +0800 Subject: [PATCH 090/111] fix: textarea not resizing back after clearing long input (#7609) (#7632) * fix: textarea not resizing back after clearing long input (#7609) * fix: text area auto size only when not dragged --- src/renderer/src/pages/home/Inputbar/Inputbar.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx index 9d3da9646e..af18b7fe23 100644 --- a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx +++ b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx @@ -788,6 +788,7 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = variant="borderless" spellCheck={enableSpellCheck} rows={2} + autoSize={textareaHeight ? false : { minRows: 2, maxRows: 20 }} ref={textareaRef} style={{ fontSize, From b0053b94a9947d2d1f476d52c8379861cff359f0 Mon Sep 17 00:00:00 2001 From: SuYao Date: Mon, 30 Jun 2025 00:15:36 +0800 Subject: [PATCH 091/111] fix(models): enhance Doubao model checks to include model.id conditions (#7657) - Updated model checks in isFunctionCallingModel, isEmbeddingModel, isVisionModel, and isReasoningModel functions to consider model.id for 'doubao' provider. - Improved isOpenAIWebSearchModel to include additional conditions for model.id. --- .../clients/openai/OpenAIResponseAPIClient.ts | 5 ----- src/renderer/src/config/models.ts | 13 ++++++++----- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/src/renderer/src/aiCore/clients/openai/OpenAIResponseAPIClient.ts b/src/renderer/src/aiCore/clients/openai/OpenAIResponseAPIClient.ts index a6c49a5cf8..8994b7b2f5 100644 --- a/src/renderer/src/aiCore/clients/openai/OpenAIResponseAPIClient.ts +++ b/src/renderer/src/aiCore/clients/openai/OpenAIResponseAPIClient.ts @@ -386,10 +386,6 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient< }) } - const toolChoices: OpenAI.Responses.ToolChoiceTypes = { - type: 'web_search_preview' - } - tools = tools.concat(extraTools) const commonParams = { model: model.id, @@ -402,7 +398,6 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient< max_output_tokens: maxTokens, stream: streamOutput, tools: !isEmpty(tools) ? tools : undefined, - tool_choice: enableWebSearch ? toolChoices : undefined, service_tier: this.getServiceTier(model), ...(this.getReasoningEffort(assistant, model) as OpenAI.Reasoning), ...this.getCustomParameters(assistant) diff --git a/src/renderer/src/config/models.ts b/src/renderer/src/config/models.ts index 18d24d9ba6..3bb9d099fa 100644 --- a/src/renderer/src/config/models.ts +++ b/src/renderer/src/config/models.ts @@ -273,7 +273,7 @@ export function isFunctionCallingModel(model: Model): boolean { return ['deepseek-v3-tool', 'deepseek-v3-0324', 'qwq-32b', 'qwen2.5-72b-instruct'].includes(model.id) } - if (model.provider === 'doubao') { + if (model.provider === 'doubao' || model.id.includes('doubao')) { return FUNCTION_CALLING_REGEX.test(model.id) || FUNCTION_CALLING_REGEX.test(model.name) } @@ -2327,7 +2327,7 @@ export function isEmbeddingModel(model: Model): boolean { return false } - if (model.provider === 'doubao') { + if (model.provider === 'doubao' || model.id.includes('doubao')) { return EMBEDDING_REGEX.test(model.name) } @@ -2351,7 +2351,7 @@ export function isVisionModel(model: Model): boolean { // return false // } - if (model.provider === 'doubao') { + if (model.provider === 'doubao' || model.id.includes('doubao')) { return VISION_REGEX.test(model.name) || VISION_REGEX.test(model.id) || model.type?.includes('vision') || false } @@ -2422,7 +2422,9 @@ export function isOpenAIWebSearchModel(model: Model): boolean { model.id.includes('gpt-4o-search-preview') || model.id.includes('gpt-4o-mini-search-preview') || (model.id.includes('gpt-4.1') && !model.id.includes('gpt-4.1-nano')) || - (model.id.includes('gpt-4o') && !model.id.includes('gpt-4o-image')) + (model.id.includes('gpt-4o') && !model.id.includes('gpt-4o-image')) || + model.id.includes('o3') || + model.id.includes('o4') ) } @@ -2555,8 +2557,9 @@ export function isReasoningModel(model?: Model): boolean { return false } - if (model.provider === 'doubao') { + if (model.provider === 'doubao' || model.id.includes('doubao')) { return ( + REASONING_REGEX.test(model.id) || REASONING_REGEX.test(model.name) || model.type?.includes('reasoning') || isSupportedThinkingTokenDoubaoModel(model) || From 7b7819217fe37fe07bf26ab3920ff2389da75a37 Mon Sep 17 00:00:00 2001 From: David Zhang <61440144+WAcry@users.noreply.github.com> Date: Sun, 29 Jun 2025 12:14:58 -0700 Subject: [PATCH 092/111] chore(OpenAIApiClient): handle empty delta objects in non-streaming esponses (#7658) chore(OpenAIApiClient): handle empty delta objects in non-streaming responses --- .../src/aiCore/clients/openai/OpenAIApiClient.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts b/src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts index 499edfbb5c..6c6e524b53 100644 --- a/src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts +++ b/src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts @@ -639,9 +639,15 @@ export class OpenAIAPIClient extends OpenAIBaseClient< if (!choice) return - // 对于流式响应,使用delta;对于非流式响应,使用message - const contentSource: OpenAISdkRawContentSource | null = - 'delta' in choice ? choice.delta : 'message' in choice ? choice.message : null + // 对于流式响应,使用 delta;对于非流式响应,使用 message。 + // 然而某些 OpenAI 兼容平台在非流式请求时会错误地返回一个空对象的 delta 字段。 + // 如果 delta 为空对象,应当忽略它并回退到 message,避免造成内容缺失。 + let contentSource: OpenAISdkRawContentSource | null = null + if ('delta' in choice && choice.delta && Object.keys(choice.delta).length > 0) { + contentSource = choice.delta + } else if ('message' in choice) { + contentSource = choice.message + } if (!contentSource) return From 4c988ede52678f21485eddaeaae27fa4b0640faf Mon Sep 17 00:00:00 2001 From: cnJasonZ Date: Mon, 30 Jun 2025 10:16:22 +0800 Subject: [PATCH 093/111] Feat/ppio rerank (#7567) * feat: add PPIO rerank and embedding models * fix: fix migrate.ts * fix: set ppio provider type to openai * fix: remove 'ppio' from ProviderType definition --------- Co-authored-by: suyao --- .../src/aiCore/clients/ApiClientFactory.ts | 6 ++ .../src/aiCore/clients/ppio/PPIOAPIClient.ts | 65 +++++++++++++++ src/renderer/src/config/models.ts | 80 +++++++++++++------ src/renderer/src/store/index.ts | 2 +- src/renderer/src/store/llm.ts | 2 +- src/renderer/src/store/migrate.ts | 11 +++ 6 files changed, 139 insertions(+), 27 deletions(-) create mode 100644 src/renderer/src/aiCore/clients/ppio/PPIOAPIClient.ts diff --git a/src/renderer/src/aiCore/clients/ApiClientFactory.ts b/src/renderer/src/aiCore/clients/ApiClientFactory.ts index adc97e70e0..b0fbe3e479 100644 --- a/src/renderer/src/aiCore/clients/ApiClientFactory.ts +++ b/src/renderer/src/aiCore/clients/ApiClientFactory.ts @@ -7,6 +7,7 @@ import { GeminiAPIClient } from './gemini/GeminiAPIClient' import { VertexAPIClient } from './gemini/VertexAPIClient' import { OpenAIAPIClient } from './openai/OpenAIApiClient' import { OpenAIResponseAPIClient } from './openai/OpenAIResponseAPIClient' +import { PPIOAPIClient } from './ppio/PPIOAPIClient' /** * Factory for creating ApiClient instances based on provider configuration @@ -31,6 +32,11 @@ export class ApiClientFactory { instance = new AihubmixAPIClient(provider) as BaseApiClient return instance } + if (provider.id === 'ppio') { + console.log(`[ApiClientFactory] Creating PPIOAPIClient for provider: ${provider.id}`) + instance = new PPIOAPIClient(provider) as BaseApiClient + return instance + } // 然后检查标准的provider type switch (provider.type) { diff --git a/src/renderer/src/aiCore/clients/ppio/PPIOAPIClient.ts b/src/renderer/src/aiCore/clients/ppio/PPIOAPIClient.ts new file mode 100644 index 0000000000..2b8dec332d --- /dev/null +++ b/src/renderer/src/aiCore/clients/ppio/PPIOAPIClient.ts @@ -0,0 +1,65 @@ +import { isSupportedModel } from '@renderer/config/models' +import { Provider } from '@renderer/types' +import OpenAI from 'openai' + +import { OpenAIAPIClient } from '../openai/OpenAIApiClient' + +export class PPIOAPIClient extends OpenAIAPIClient { + constructor(provider: Provider) { + super(provider) + } + + override async listModels(): Promise { + try { + const sdk = await this.getSdkInstance() + + // PPIO requires three separate requests to get all model types + const [chatModelsResponse, embeddingModelsResponse, rerankerModelsResponse] = await Promise.all([ + // Chat/completion models + sdk.request({ + method: 'get', + path: '/models' + }), + // Embedding models + sdk.request({ + method: 'get', + path: '/models?model_type=embedding' + }), + // Reranker models + sdk.request({ + method: 'get', + path: '/models?model_type=reranker' + }) + ]) + + // Extract models from all responses + // @ts-ignore - PPIO response structure may not be typed + const allModels = [ + ...((chatModelsResponse as any)?.data || []), + ...((embeddingModelsResponse as any)?.data || []), + ...((rerankerModelsResponse as any)?.data || []) + ] + + // Process and standardize model data + const processedModels = allModels.map((model: any) => ({ + id: model.id || model.name, + description: model.description || model.display_name || model.summary, + object: 'model' as const, + owned_by: model.owned_by || model.publisher || model.organization || 'ppio', + created: model.created || Date.now() + })) + + // Clean up model IDs and filter supported models + processedModels.forEach((model) => { + if (model.id) { + model.id = model.id.trim() + } + }) + + return processedModels.filter(isSupportedModel) + } catch (error) { + console.error('Error listing PPIO models:', error) + return [] + } + } +} diff --git a/src/renderer/src/config/models.ts b/src/renderer/src/config/models.ts index 3bb9d099fa..3c2902a489 100644 --- a/src/renderer/src/config/models.ts +++ b/src/renderer/src/config/models.ts @@ -767,6 +767,30 @@ export const SYSTEM_MODELS: Record = { } ], ppio: [ + { + id: 'deepseek/deepseek-r1-0528', + provider: 'ppio', + name: 'DeepSeek R1-0528', + group: 'deepseek' + }, + { + id: 'deepseek/deepseek-v3-0324', + provider: 'ppio', + name: 'DeepSeek V3-0324', + group: 'deepseek' + }, + { + id: 'deepseek/deepseek-r1-turbo', + provider: 'ppio', + name: 'DeepSeek R1 Turbo', + group: 'deepseek' + }, + { + id: 'deepseek/deepseek-v3-turbo', + provider: 'ppio', + name: 'DeepSeek V3 Turbo', + group: 'deepseek' + }, { id: 'deepseek/deepseek-r1/community', name: 'DeepSeek: DeepSeek R1 (Community)', @@ -780,52 +804,58 @@ export const SYSTEM_MODELS: Record = { group: 'deepseek' }, { - id: 'deepseek/deepseek-r1', + id: 'minimaxai/minimax-m1-80k', provider: 'ppio', - name: 'DeepSeek R1', - group: 'deepseek' + name: 'MiniMax M1-80K', + group: 'minimaxai' }, { - id: 'deepseek/deepseek-v3', + id: 'qwen/qwen3-235b-a22b-fp8', provider: 'ppio', - name: 'DeepSeek V3', - group: 'deepseek' - }, - { - id: 'qwen/qwen-2.5-72b-instruct', - provider: 'ppio', - name: 'Qwen2.5-72B-Instruct', + name: 'Qwen3 235B', group: 'qwen' }, { - id: 'qwen/qwen2.5-32b-instruct', + id: 'qwen/qwen3-32b-fp8', provider: 'ppio', - name: 'Qwen2.5-32B-Instruct', + name: 'Qwen3 32B', group: 'qwen' }, { - id: 'meta-llama/llama-3.1-70b-instruct', + id: 'qwen/qwen3-30b-a3b-fp8', provider: 'ppio', - name: 'Llama-3.1-70B-Instruct', - group: 'meta-llama' + name: 'Qwen3 30B', + group: 'qwen' }, { - id: 'meta-llama/llama-3.1-8b-instruct', + id: 'qwen/qwen2.5-vl-72b-instruct', provider: 'ppio', - name: 'Llama-3.1-8B-Instruct', - group: 'meta-llama' + name: 'Qwen2.5 VL 72B', + group: 'qwen' }, { - id: '01-ai/yi-1.5-34b-chat', + id: 'qwen/qwen3-embedding-8b', provider: 'ppio', - name: 'Yi-1.5-34B-Chat', - group: '01-ai' + name: 'Qwen3 Embedding 8B', + group: 'qwen' }, { - id: '01-ai/yi-1.5-9b-chat', + id: 'qwen/qwen3-reranker-8b', provider: 'ppio', - name: 'Yi-1.5-9B-Chat', - group: '01-ai' + name: 'Qwen3 Reranker 8B', + group: 'qwen' + }, + { + id: 'thudm/glm-z1-32b-0414', + provider: 'ppio', + name: 'GLM-Z1 32B', + group: 'thudm' + }, + { + id: 'thudm/glm-z1-9b-0414', + provider: 'ppio', + name: 'GLM-Z1 9B', + group: 'thudm' } ], alayanew: [], diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index 4a26bf96c0..3b6cba1040 100644 --- a/src/renderer/src/store/index.ts +++ b/src/renderer/src/store/index.ts @@ -50,7 +50,7 @@ const persistedReducer = persistReducer( { key: 'cherry-studio', storage, - version: 116, + version: 117, blacklist: ['runtime', 'messages', 'messageBlocks'], migrate }, diff --git a/src/renderer/src/store/llm.ts b/src/renderer/src/store/llm.ts index f70c4ef2ba..d42b4dc065 100644 --- a/src/renderer/src/store/llm.ts +++ b/src/renderer/src/store/llm.ts @@ -79,7 +79,7 @@ export const INITIAL_PROVIDERS: Provider[] = [ name: 'PPIO', type: 'openai', apiKey: '', - apiHost: 'https://api.ppinfra.com/v3/openai', + apiHost: 'https://api.ppinfra.com/v3/openai/', models: SYSTEM_MODELS.ppio, isSystem: true, enabled: false diff --git a/src/renderer/src/store/migrate.ts b/src/renderer/src/store/migrate.ts index 8eea0a34a7..56ffa33cbe 100644 --- a/src/renderer/src/store/migrate.ts +++ b/src/renderer/src/store/migrate.ts @@ -1656,6 +1656,17 @@ const migrateConfig = { state.settings.testChannel = UpgradeChannel.LATEST } + return state + } catch (error) { + return state + } + }, + '117': (state: RootState) => { + try { + updateProvider(state, 'ppio', { + models: SYSTEM_MODELS.ppio, + apiHost: 'https://api.ppinfra.com/v3/openai/' + }) return state } catch (error) { return state From 1034b946288e2b6dd326441c8fd46be9de91e7e1 Mon Sep 17 00:00:00 2001 From: Wang Jiyuan <59059173+EurFelux@users.noreply.github.com> Date: Mon, 30 Jun 2025 10:43:19 +0800 Subject: [PATCH 094/111] fix(translate): improve language options with clearer values (#7640) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(翻译配置): 修正简体中文语言选项的值和标签显示 将'chinese'改为更明确的'chinese-simplified' * style(translate): 统一语言选项的显示格式为规范名称 --- src/renderer/src/config/translate.ts | 40 ++++++++++++++-------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/src/renderer/src/config/translate.ts b/src/renderer/src/config/translate.ts index b8e5cd0b4e..9a85b68ecc 100644 --- a/src/renderer/src/config/translate.ts +++ b/src/renderer/src/config/translate.ts @@ -9,116 +9,116 @@ export interface TranslateLanguageOption { export const TranslateLanguageOptions: TranslateLanguageOption[] = [ { - value: 'english', + value: 'English', langCode: 'en-us', label: i18n.t('languages.english'), emoji: '🇬🇧' }, { - value: 'chinese', + value: 'Chinese (Simplified)', langCode: 'zh-cn', label: i18n.t('languages.chinese'), emoji: '🇨🇳' }, { - value: 'chinese-traditional', + value: 'Chinese (Traditional)', langCode: 'zh-tw', label: i18n.t('languages.chinese-traditional'), emoji: '🇭🇰' }, { - value: 'japanese', + value: 'Japanese', langCode: 'ja-jp', label: i18n.t('languages.japanese'), emoji: '🇯🇵' }, { - value: 'korean', + value: 'Korean', langCode: 'ko-kr', label: i18n.t('languages.korean'), emoji: '🇰🇷' }, { - value: 'french', + value: 'French', langCode: 'fr-fr', label: i18n.t('languages.french'), emoji: '🇫🇷' }, { - value: 'german', + value: 'German', langCode: 'de-de', label: i18n.t('languages.german'), emoji: '🇩🇪' }, { - value: 'italian', + value: 'Italian', langCode: 'it-it', label: i18n.t('languages.italian'), emoji: '🇮🇹' }, { - value: 'spanish', + value: 'Spanish', langCode: 'es-es', label: i18n.t('languages.spanish'), emoji: '🇪🇸' }, { - value: 'portuguese', + value: 'Portuguese', langCode: 'pt-pt', label: i18n.t('languages.portuguese'), emoji: '🇵🇹' }, { - value: 'russian', + value: 'Russian', langCode: 'ru-ru', label: i18n.t('languages.russian'), emoji: '🇷🇺' }, { - value: 'polish', + value: 'Polish', langCode: 'pl-pl', label: i18n.t('languages.polish'), emoji: '🇵🇱' }, { - value: 'arabic', + value: 'Arabic', langCode: 'ar-ar', label: i18n.t('languages.arabic'), emoji: '🇸🇦' }, { - value: 'turkish', + value: 'Turkish', langCode: 'tr-tr', label: i18n.t('languages.turkish'), emoji: '🇹🇷' }, { - value: 'thai', + value: 'Thai', langCode: 'th-th', label: i18n.t('languages.thai'), emoji: '🇹🇭' }, { - value: 'vietnamese', + value: 'Vietnamese', langCode: 'vi-vn', label: i18n.t('languages.vietnamese'), emoji: '🇻🇳' }, { - value: 'indonesian', + value: 'Indonesian', langCode: 'id-id', label: i18n.t('languages.indonesian'), emoji: '🇮🇩' }, { - value: 'urdu', + value: 'Urdu', langCode: 'ur-pk', label: i18n.t('languages.urdu'), emoji: '🇵🇰' }, { - value: 'malay', + value: 'Malay', langCode: 'ms-my', label: i18n.t('languages.malay'), emoji: '🇲🇾' @@ -129,7 +129,7 @@ export const translateLanguageOptions = (): typeof TranslateLanguageOptions => { return TranslateLanguageOptions.map((option) => { return { value: option.value, - label: i18n.t(`languages.${option.value}`), + label: option.label, emoji: option.emoji } }) From a9a9d884ce8ec7104c2b23ce6b3dc71a0d58966f Mon Sep 17 00:00:00 2001 From: SuYao Date: Mon, 30 Jun 2025 13:51:23 +0800 Subject: [PATCH 095/111] Fix/gemini (#7659) * refactor: update Gemini and OpenAI API clients for improved reasoning model handling - Replaced isGeminiReasoningModel with isSupportedThinkingTokenGeminiModel in GeminiAPIClient for better model validation. - Enhanced OpenAIAPIClient to support additional configurations for reasoning efforts and thinking budgets based on model type. - Introduced new thinking tags for Gemini models in ThinkingTagExtractionMiddleware. - Updated model checks in models.ts to streamline reasoning model identification. - Adjusted ThinkingButton component to differentiate between Gemini and Gemini Pro models based on regex checks. * refactor(GeminiAPIClient): streamline reasoning configuration handling - Simplified the logic for returning thinking configuration when reasoningEffort is undefined in GeminiAPIClient. - Updated ApiService to include enableReasoning flag for API calls, enhancing control over reasoning capabilities. * fix(OpenAIAPIClient): add support for non-flash Gemini models in reasoning configuration - Introduced a check for non-flash models in the OpenAIAPIClient to enhance reasoning configuration handling for supported Gemini models. - This change ensures that reasoning is correctly configured based on the model type, improving overall model validation. --- .../aiCore/clients/gemini/GeminiAPIClient.ts | 26 ++++++------ .../aiCore/clients/openai/OpenAIApiClient.ts | 40 ++++++++++++++++++- .../feat/ThinkingTagExtractionMiddleware.ts | 2 + src/renderer/src/config/models.ts | 6 ++- .../pages/home/Inputbar/ThinkingButton.tsx | 15 +++++-- src/renderer/src/services/ApiService.ts | 1 + src/renderer/src/types/sdk.ts | 1 + 7 files changed, 71 insertions(+), 20 deletions(-) diff --git a/src/renderer/src/aiCore/clients/gemini/GeminiAPIClient.ts b/src/renderer/src/aiCore/clients/gemini/GeminiAPIClient.ts index 549e931966..f37bcd3d30 100644 --- a/src/renderer/src/aiCore/clients/gemini/GeminiAPIClient.ts +++ b/src/renderer/src/aiCore/clients/gemini/GeminiAPIClient.ts @@ -22,8 +22,8 @@ import { GenericChunk } from '@renderer/aiCore/middleware/schemas' import { findTokenLimit, GEMINI_FLASH_MODEL_REGEX, - isGeminiReasoningModel, isGemmaModel, + isSupportedThinkingTokenGeminiModel, isVisionModel } from '@renderer/config/models' import { CacheService } from '@renderer/services/CacheService' @@ -393,29 +393,29 @@ export class GeminiAPIClient extends BaseApiClient< * @returns The reasoning effort */ private getBudgetToken(assistant: Assistant, model: Model) { - if (isGeminiReasoningModel(model)) { + if (isSupportedThinkingTokenGeminiModel(model)) { const reasoningEffort = assistant?.settings?.reasoning_effort // 如果thinking_budget是undefined,不思考 if (reasoningEffort === undefined) { - return { - thinkingConfig: { - includeThoughts: false, - ...(GEMINI_FLASH_MODEL_REGEX.test(model.id) ? { thinkingBudget: 0 } : {}) - } as ThinkingConfig - } + return GEMINI_FLASH_MODEL_REGEX.test(model.id) + ? { + thinkingConfig: { + thinkingBudget: 0 + } + } + : {} } - const effortRatio = EFFORT_RATIO[reasoningEffort] - - if (effortRatio > 1) { + if (reasoningEffort === 'auto') { return { thinkingConfig: { - includeThoughts: true + includeThoughts: true, + thinkingBudget: -1 } } } - + const effortRatio = EFFORT_RATIO[reasoningEffort] const { min, max } = findTokenLimit(model.id) || { min: 0, max: 0 } // 计算 budgetTokens,确保不低于 min const budget = Math.floor((max - min) * effortRatio + min) diff --git a/src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts b/src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts index 6c6e524b53..9b72758c97 100644 --- a/src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts +++ b/src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts @@ -114,6 +114,9 @@ export class OpenAIAPIClient extends OpenAIBaseClient< if (!reasoningEffort) { if (model.provider === 'openrouter') { + if (isSupportedThinkingTokenGeminiModel(model) && !GEMINI_FLASH_MODEL_REGEX.test(model.id)) { + return {} + } return { reasoning: { enabled: false, exclude: true } } } if (isSupportedThinkingTokenQwenModel(model)) { @@ -126,7 +129,15 @@ export class OpenAIAPIClient extends OpenAIBaseClient< if (isSupportedThinkingTokenGeminiModel(model)) { if (GEMINI_FLASH_MODEL_REGEX.test(model.id)) { - return { reasoning_effort: 'none' } + return { + extra_body: { + google: { + thinking_config: { + thinking_budget: 0 + } + } + } + } } return {} } @@ -169,12 +180,37 @@ export class OpenAIAPIClient extends OpenAIBaseClient< } // OpenAI models - if (isSupportedReasoningEffortOpenAIModel(model) || isSupportedThinkingTokenGeminiModel(model)) { + if (isSupportedReasoningEffortOpenAIModel(model)) { return { reasoning_effort: reasoningEffort } } + if (isSupportedThinkingTokenGeminiModel(model)) { + if (reasoningEffort === 'auto') { + return { + extra_body: { + google: { + thinking_config: { + thinking_budget: -1, + include_thoughts: true + } + } + } + } + } + return { + extra_body: { + google: { + thinking_config: { + thinking_budget: budgetTokens, + include_thoughts: true + } + } + } + } + } + // Claude models if (isSupportedThinkingTokenClaudeModel(model)) { const maxTokens = assistant.settings?.maxTokens diff --git a/src/renderer/src/aiCore/middleware/feat/ThinkingTagExtractionMiddleware.ts b/src/renderer/src/aiCore/middleware/feat/ThinkingTagExtractionMiddleware.ts index 440de40045..fe2d51d8de 100644 --- a/src/renderer/src/aiCore/middleware/feat/ThinkingTagExtractionMiddleware.ts +++ b/src/renderer/src/aiCore/middleware/feat/ThinkingTagExtractionMiddleware.ts @@ -11,11 +11,13 @@ export const MIDDLEWARE_NAME = 'ThinkingTagExtractionMiddleware' // 不同模型的思考标签配置 const reasoningTags: TagConfig[] = [ { openingTag: '', closingTag: '', separator: '\n' }, + { openingTag: '', closingTag: '', separator: '\n' }, { openingTag: '###Thinking', closingTag: '###Response', separator: '\n' } ] const getAppropriateTag = (model?: Model): TagConfig => { if (model?.id?.includes('qwen3')) return reasoningTags[0] + if (model?.id?.includes('gemini-2.5')) return reasoningTags[1] // 可以在这里添加更多模型特定的标签配置 return reasoningTags[0] // 默认使用 标签 } diff --git a/src/renderer/src/config/models.ts b/src/renderer/src/config/models.ts index 3c2902a489..48560007ca 100644 --- a/src/renderer/src/config/models.ts +++ b/src/renderer/src/config/models.ts @@ -2509,14 +2509,16 @@ export function isGeminiReasoningModel(model?: Model): boolean { return true } - if (model.id.includes('gemini-2.5')) { + if (isSupportedThinkingTokenGeminiModel(model)) { return true } return false } -export const isSupportedThinkingTokenGeminiModel = isGeminiReasoningModel +export const isSupportedThinkingTokenGeminiModel = (model: Model): boolean => { + return model.id.includes('gemini-2.5') +} export function isQwenReasoningModel(model?: Model): boolean { if (!model) { diff --git a/src/renderer/src/pages/home/Inputbar/ThinkingButton.tsx b/src/renderer/src/pages/home/Inputbar/ThinkingButton.tsx index 21db131cef..5bf57d9c9c 100644 --- a/src/renderer/src/pages/home/Inputbar/ThinkingButton.tsx +++ b/src/renderer/src/pages/home/Inputbar/ThinkingButton.tsx @@ -7,6 +7,7 @@ import { } from '@renderer/components/Icons/SVGIcon' import { useQuickPanel } from '@renderer/components/QuickPanel' import { + GEMINI_FLASH_MODEL_REGEX, isDoubaoThinkingAutoModel, isSupportedReasoningEffortGrokModel, isSupportedThinkingTokenDoubaoModel, @@ -37,13 +38,14 @@ const MODEL_SUPPORTED_OPTIONS: Record = { default: ['off', 'low', 'medium', 'high'], grok: ['off', 'low', 'high'], gemini: ['off', 'low', 'medium', 'high', 'auto'], + gemini_pro: ['low', 'medium', 'high', 'auto'], qwen: ['off', 'low', 'medium', 'high'], doubao: ['off', 'auto', 'high'] } // 选项转换映射表:当选项不支持时使用的替代选项 const OPTION_FALLBACK: Record = { - off: 'off', + off: 'low', // off -> low (for Gemini Pro models) low: 'high', medium: 'high', // medium -> high (for Grok models) high: 'high', @@ -57,6 +59,7 @@ const ThinkingButton: FC = ({ ref, model, assistant, ToolbarButton }): Re const isGrokModel = isSupportedReasoningEffortGrokModel(model) const isGeminiModel = isSupportedThinkingTokenGeminiModel(model) + const isGeminiFlashModel = GEMINI_FLASH_MODEL_REGEX.test(model.id) const isQwenModel = isSupportedThinkingTokenQwenModel(model) const isDoubaoModel = isSupportedThinkingTokenDoubaoModel(model) @@ -66,12 +69,18 @@ const ThinkingButton: FC = ({ ref, model, assistant, ToolbarButton }): Re // 确定当前模型支持的选项类型 const modelType = useMemo(() => { - if (isGeminiModel) return 'gemini' + if (isGeminiModel) { + if (isGeminiFlashModel) { + return 'gemini' + } else { + return 'gemini_pro' + } + } if (isGrokModel) return 'grok' if (isQwenModel) return 'qwen' if (isDoubaoModel) return 'doubao' return 'default' - }, [isGeminiModel, isGrokModel, isQwenModel, isDoubaoModel]) + }, [isGeminiModel, isGrokModel, isQwenModel, isDoubaoModel, isGeminiFlashModel]) // 获取当前模型支持的选项 const supportedOptions = useMemo(() => { diff --git a/src/renderer/src/services/ApiService.ts b/src/renderer/src/services/ApiService.ts index 4704a8bfd3..abc3db81b0 100644 --- a/src/renderer/src/services/ApiService.ts +++ b/src/renderer/src/services/ApiService.ts @@ -607,6 +607,7 @@ export async function checkApi(provider: Provider, model: Model): Promise messages: 'hi', assistant, streamOutput: true, + enableReasoning: false, shouldThrow: true } diff --git a/src/renderer/src/types/sdk.ts b/src/renderer/src/types/sdk.ts index 6505210b60..c7eeb9500c 100644 --- a/src/renderer/src/types/sdk.ts +++ b/src/renderer/src/types/sdk.ts @@ -53,6 +53,7 @@ export type ReasoningEffortOptionalParams = { enable_thinking?: boolean thinking_budget?: number enable_reasoning?: boolean + extra_body?: Record // Add any other potential reasoning-related keys here if they exist } From 21ba35b6bf20073d366764e134bfc2780a6f53ae Mon Sep 17 00:00:00 2001 From: SuYao Date: Mon, 30 Jun 2025 15:17:05 +0800 Subject: [PATCH 096/111] fix(ImageGenerationMiddleware): read image binary data (#7681) - Replaced direct API call for reading binary images with FileManager's readBinaryImage method to streamline image handling in the ImageGenerationMiddleware. --- .../src/aiCore/middleware/feat/ImageGenerationMiddleware.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/aiCore/middleware/feat/ImageGenerationMiddleware.ts b/src/renderer/src/aiCore/middleware/feat/ImageGenerationMiddleware.ts index d0a4dc4903..6c01759bec 100644 --- a/src/renderer/src/aiCore/middleware/feat/ImageGenerationMiddleware.ts +++ b/src/renderer/src/aiCore/middleware/feat/ImageGenerationMiddleware.ts @@ -1,5 +1,6 @@ import { BaseApiClient } from '@renderer/aiCore/clients/BaseApiClient' import { isDedicatedImageGenerationModel } from '@renderer/config/models' +import FileManager from '@renderer/services/FileManager' import { ChunkType } from '@renderer/types/chunk' import { findImageBlocks, getMainTextContent } from '@renderer/utils/messageUtils/find' import OpenAI from 'openai' @@ -46,7 +47,7 @@ export const ImageGenerationMiddleware: CompletionsMiddleware = const userImages = await Promise.all( userImageBlocks.map(async (block) => { if (!block.file) return null - const binaryData: Uint8Array = await window.api.file.binaryImage(block.file.id) + const binaryData: Uint8Array = await FileManager.readBinaryImage(block.file) const mimeType = `${block.file.type}/${block.file.ext.slice(1)}` return await toFile(new Blob([binaryData]), block.file.origin_name || 'image.png', { type: mimeType }) }) From db4ce9fb7f6e5b2ee94e899ca6ba8d610684c4cf Mon Sep 17 00:00:00 2001 From: Teo Date: Mon, 30 Jun 2025 16:13:25 +0800 Subject: [PATCH 097/111] fix(Inputbar): fix enter key confict (#7679) fix(Inputbar): prevent default behavior for Enter key when quick panel is visible --- src/renderer/src/pages/home/Inputbar/Inputbar.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx index af18b7fe23..462ef0adb6 100644 --- a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx +++ b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx @@ -348,8 +348,9 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = //other keys should be ignored const isEnterPressed = event.key === 'Enter' && !event.nativeEvent.isComposing if (isEnterPressed) { + if (quickPanel.isVisible) return event.preventDefault() + if (isSendMessageKeyPressed(event, sendMessageShortcut)) { - if (quickPanel.isVisible) return event.preventDefault() sendMessage() return event.preventDefault() } else { From ac03aab29fcaa242ed0389bc1ec8d3ab2e2cbec0 Mon Sep 17 00:00:00 2001 From: beyondkmp Date: Mon, 30 Jun 2025 17:04:48 +0800 Subject: [PATCH 098/111] chore(package): add opendal dependency to package.json (#7685) --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 5455f348b2..dd835ee9fe 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "jsdom": "26.1.0", "node-stream-zip": "^1.15.0", "notion-helper": "^1.3.22", + "opendal": "0.47.11", "os-proxy-config": "^1.1.2", "selection-hook": "^0.9.23", "turndown": "7.2.0" @@ -181,7 +182,6 @@ "npx-scope-finder": "^1.2.0", "officeparser": "^4.1.1", "openai": "patch:openai@npm%3A5.1.0#~/.yarn/patches/openai-npm-5.1.0-0e7b3ccb07.patch", - "opendal": "0.47.11", "p-queue": "^8.1.0", "playwright": "^1.52.0", "prettier": "^3.5.3", From 8c657b57f723438f8ee60c9170d0b686c98d5391 Mon Sep 17 00:00:00 2001 From: beyondkmp Date: Mon, 30 Jun 2025 20:23:22 +0800 Subject: [PATCH 099/111] feat: add country flag emoji support and enhance UI components (#7646) * feat: add country flag emoji support and enhance UI components * Added country-flag-emoji-polyfill to package.json and yarn.lock * Integrated polyfill in AddAgentPopup, GeneralSettings, and AssistantPromptSettings components * Updated emoji rendering styles for better visual consistency * fix: update country flag emoji polyfill to use 'Twemoji Country Flags' * feat: enhance emoji components with country flag support * Integrated country-flag-emoji-polyfill in EmojiIcon, EmojiPicker, and AssistantItem components. * Updated font-family styles across various components for consistent emoji rendering. * Removed redundant polyfill calls from AddAgentPopup and AssistantPromptSettings. * refactor: streamline country flag emoji integration * Removed redundant polyfill calls from EmojiIcon, AssistantItem, and GeneralSettings components. * Updated EmojiPicker to use a local font file for country flag emojis. * Added country flag font import in index.scss for improved styling consistency. * format code * refactor: standardize country flag font usage across components * Introduced a new CSS class for country flag font to streamline styling. * Updated various components (GeneralSettings, EmojiIcon, EmojiAvatar, AssistantPromptSettings, TranslatePage) to utilize the new class for consistent font application. * Removed inline font-family styles to enhance maintainability. * refactor: update font styles for improved consistency and maintainability * Added Windows-specific font configuration in font.scss for better emoji rendering. * Removed inline font-family styles from various components (EmojiAvatar, GeneralSettings, AssistantPromptSettings, TranslatePage) to enhance code clarity and maintainability. * refactor: remove inline font-family styles from EmojiIcon for improved maintainability --- package.json | 1 + .../TwemojiCountryFlags.woff2 | Bin 0 -> 77476 bytes .../assets/fonts/country-flag-fonts/flag.css | 13 +++++++ src/renderer/src/assets/styles/font.scss | 32 +++++++++++------- src/renderer/src/assets/styles/index.scss | 1 + .../src/components/Avatar/EmojiAvatar.tsx | 1 + .../src/components/EmojiPicker/index.tsx | 6 ++++ .../AssistantPromptSettings.tsx | 10 +++++- yarn.lock | 8 +++++ 9 files changed, 59 insertions(+), 13 deletions(-) create mode 100644 src/renderer/src/assets/fonts/country-flag-fonts/TwemojiCountryFlags.woff2 create mode 100644 src/renderer/src/assets/fonts/country-flag-fonts/flag.css diff --git a/package.json b/package.json index dd835ee9fe..360da4ae11 100644 --- a/package.json +++ b/package.json @@ -141,6 +141,7 @@ "axios": "^1.7.3", "browser-image-compression": "^2.0.2", "color": "^5.0.0", + "country-flag-emoji-polyfill": "0.1.8", "dayjs": "^1.11.11", "dexie": "^4.0.8", "dexie-react-hooks": "^1.1.7", diff --git a/src/renderer/src/assets/fonts/country-flag-fonts/TwemojiCountryFlags.woff2 b/src/renderer/src/assets/fonts/country-flag-fonts/TwemojiCountryFlags.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..7f5bebba53e42cb9e1a7a55bb4a20fe04bd4273b GIT binary patch literal 77476 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 height: ${(props) => props.$size}px; font-size: ${(props) => props.$fontSize}px; transition: opacity 0.3s ease; + &:hover { opacity: 0.8; } diff --git a/src/renderer/src/components/EmojiPicker/index.tsx b/src/renderer/src/components/EmojiPicker/index.tsx index eb8a90dbde..406d6d1865 100644 --- a/src/renderer/src/components/EmojiPicker/index.tsx +++ b/src/renderer/src/components/EmojiPicker/index.tsx @@ -1,4 +1,6 @@ +import TwemojiCountryFlagsWoff2 from '@renderer/assets/fonts/country-flag-fonts/TwemojiCountryFlags.woff2?url' import { useTheme } from '@renderer/context/ThemeProvider' +import { polyfillCountryFlagEmojis } from 'country-flag-emoji-polyfill' import { FC, useEffect, useRef } from 'react' interface Props { @@ -9,6 +11,10 @@ const EmojiPicker: FC = ({ onEmojiClick }) => { const { theme } = useTheme() const ref = useRef(null) + useEffect(() => { + polyfillCountryFlagEmojis('Twemoji Mozilla', TwemojiCountryFlagsWoff2) + }, []) + useEffect(() => { if (ref.current) { ref.current.addEventListener('emoji-click', (event: any) => { diff --git a/src/renderer/src/pages/settings/AssistantSettings/AssistantPromptSettings.tsx b/src/renderer/src/pages/settings/AssistantSettings/AssistantPromptSettings.tsx index 22ff2a7d62..a473c683ed 100644 --- a/src/renderer/src/pages/settings/AssistantSettings/AssistantPromptSettings.tsx +++ b/src/renderer/src/pages/settings/AssistantSettings/AssistantPromptSettings.tsx @@ -65,7 +65,15 @@ const AssistantPromptSettings: React.FC = ({ assistant, updateAssistant } } arrow trigger="click"> - + {emoji && ( Date: Mon, 30 Jun 2025 20:40:32 +0800 Subject: [PATCH 100/111] style(antd): Optimize antd components through patch method (#7683) * fix(dependencies): update antd to patch version 5.24.7 and apply custom patch * refactor(AddAgentPopup): remove unused ChevronDown import * feat(AntdProvider): add paddingXS to Dropdown component for improved layout --- .../patches/antd-npm-5.24.7-356a553ae5.patch | 69 +++++++++++++++++++ package.json | 2 +- src/renderer/src/assets/styles/index.scss | 2 +- src/renderer/src/context/AntdProvider.tsx | 3 +- .../pages/agents/components/AddAgentPopup.tsx | 2 - .../components/AddKnowledgePopup.tsx | 15 +--- .../components/KnowledgeSettingsPopup.tsx | 9 +-- .../AssistantKnowledgeBaseSettings.tsx | 3 +- .../AssistantModelSettings.tsx | 5 +- .../settings/ModelSettings/ModelSettings.tsx | 8 +-- .../ProviderSettings/ModelEditContent.tsx | 1 - .../CompressionSettings/CutoffSettings.tsx | 3 +- .../CompressionSettings/RagSettings.tsx | 4 +- .../CompressionSettings/index.tsx | 2 - .../src/pages/translate/TranslatePage.tsx | 7 +- yarn.lock | 64 ++++++++++++++++- 16 files changed, 145 insertions(+), 54 deletions(-) create mode 100644 .yarn/patches/antd-npm-5.24.7-356a553ae5.patch diff --git a/.yarn/patches/antd-npm-5.24.7-356a553ae5.patch b/.yarn/patches/antd-npm-5.24.7-356a553ae5.patch new file mode 100644 index 0000000000..d5f7a89edb --- /dev/null +++ b/.yarn/patches/antd-npm-5.24.7-356a553ae5.patch @@ -0,0 +1,69 @@ +diff --git a/es/dropdown/dropdown.js b/es/dropdown/dropdown.js +index 986877a762b9ad0aca596a8552732cd12d2eaabb..1f18aa2ea745e68950e4cee16d4d655f5c835fd5 100644 +--- a/es/dropdown/dropdown.js ++++ b/es/dropdown/dropdown.js +@@ -2,7 +2,7 @@ + + import * as React from 'react'; + import LeftOutlined from "@ant-design/icons/es/icons/LeftOutlined"; +-import RightOutlined from "@ant-design/icons/es/icons/RightOutlined"; ++import { ChevronRight } from 'lucide-react'; + import classNames from 'classnames'; + import RcDropdown from 'rc-dropdown'; + import useEvent from "rc-util/es/hooks/useEvent"; +@@ -158,8 +158,10 @@ const Dropdown = props => { + className: `${prefixCls}-menu-submenu-arrow` + }, direction === 'rtl' ? (/*#__PURE__*/React.createElement(LeftOutlined, { + className: `${prefixCls}-menu-submenu-arrow-icon` +- })) : (/*#__PURE__*/React.createElement(RightOutlined, { +- className: `${prefixCls}-menu-submenu-arrow-icon` ++ })) : (/*#__PURE__*/React.createElement(ChevronRight, { ++ size: 16, ++ strokeWidth: 1.8, ++ className: `${prefixCls}-menu-submenu-arrow-icon lucide-custom` + }))), + mode: "vertical", + selectable: false, +diff --git a/es/dropdown/style/index.js b/es/dropdown/style/index.js +index 768c01783002c6901c85a73061ff6b3e776a60ce..39b1b95a56cdc9fb586a193c3adad5141f5cf213 100644 +--- a/es/dropdown/style/index.js ++++ b/es/dropdown/style/index.js +@@ -240,7 +240,8 @@ const genBaseStyle = token => { + marginInlineEnd: '0 !important', + color: token.colorTextDescription, + fontSize: fontSizeIcon, +- fontStyle: 'normal' ++ fontStyle: 'normal', ++ marginTop: 3, + } + } + }), +diff --git a/es/select/useIcons.js b/es/select/useIcons.js +index 959115be936ef8901548af2658c5dcfdc5852723..c812edd52123eb0faf4638b1154fcfa1b05b513b 100644 +--- a/es/select/useIcons.js ++++ b/es/select/useIcons.js +@@ -4,10 +4,10 @@ import * as React from 'react'; + import CheckOutlined from "@ant-design/icons/es/icons/CheckOutlined"; + import CloseCircleFilled from "@ant-design/icons/es/icons/CloseCircleFilled"; + import CloseOutlined from "@ant-design/icons/es/icons/CloseOutlined"; +-import DownOutlined from "@ant-design/icons/es/icons/DownOutlined"; + import LoadingOutlined from "@ant-design/icons/es/icons/LoadingOutlined"; + import SearchOutlined from "@ant-design/icons/es/icons/SearchOutlined"; + import { devUseWarning } from '../_util/warning'; ++import { ChevronDown } from 'lucide-react'; + export default function useIcons(_ref) { + let { + suffixIcon, +@@ -56,8 +56,10 @@ export default function useIcons(_ref) { + className: iconCls + })); + } +- return getSuffixIconNode(/*#__PURE__*/React.createElement(DownOutlined, { +- className: iconCls ++ return getSuffixIconNode(/*#__PURE__*/React.createElement(ChevronDown, { ++ size: 16, ++ strokeWidth: 1.8, ++ className: `${iconCls} lucide-custom` + })); + }; + } diff --git a/package.json b/package.json index 360da4ae11..93b7ce697f 100644 --- a/package.json +++ b/package.json @@ -135,7 +135,7 @@ "@vitest/ui": "^3.1.4", "@vitest/web-worker": "^3.1.4", "@xyflow/react": "^12.4.4", - "antd": "^5.22.5", + "antd": "patch:antd@npm%3A5.24.7#~/.yarn/patches/antd-npm-5.24.7-356a553ae5.patch", "archiver": "^7.0.1", "async-mutex": "^0.5.0", "axios": "^1.7.3", diff --git a/src/renderer/src/assets/styles/index.scss b/src/renderer/src/assets/styles/index.scss index 62f7eae852..7507507888 100644 --- a/src/renderer/src/assets/styles/index.scss +++ b/src/renderer/src/assets/styles/index.scss @@ -170,7 +170,7 @@ ul { } } -.lucide { +.lucide:not(.lucide-custom) { color: var(--color-icon); } diff --git a/src/renderer/src/context/AntdProvider.tsx b/src/renderer/src/context/AntdProvider.tsx index 7d22fbb6a5..206f65e262 100644 --- a/src/renderer/src/context/AntdProvider.tsx +++ b/src/renderer/src/context/AntdProvider.tsx @@ -79,7 +79,8 @@ const AntdProvider: FC = ({ children }) => { Dropdown: { controlPaddingHorizontal: 8, borderRadiusLG: 10, - borderRadiusSM: 8 + borderRadiusSM: 8, + paddingXS: 4 }, Popover: { borderRadiusLG: 10 diff --git a/src/renderer/src/pages/agents/components/AddAgentPopup.tsx b/src/renderer/src/pages/agents/components/AddAgentPopup.tsx index 089ec16fa1..108052a701 100644 --- a/src/renderer/src/pages/agents/components/AddAgentPopup.tsx +++ b/src/renderer/src/pages/agents/components/AddAgentPopup.tsx @@ -14,7 +14,6 @@ import { Agent, KnowledgeBase } from '@renderer/types' import { getLeadingEmoji, uuid } from '@renderer/utils' import { Button, Form, FormInstance, Input, Modal, Popover, Select, SelectProps } from 'antd' import TextArea from 'antd/es/input/TextArea' -import { ChevronDown } from 'lucide-react' import { useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import stringWidth from 'string-width' @@ -213,7 +212,6 @@ const PopupContainer: React.FC = ({ resolve }) => { .toLowerCase() .includes(input.toLowerCase()) } - suffixIcon={} /> )} diff --git a/src/renderer/src/pages/knowledge/components/AddKnowledgePopup.tsx b/src/renderer/src/pages/knowledge/components/AddKnowledgePopup.tsx index 5ce1801243..128d63f07e 100644 --- a/src/renderer/src/pages/knowledge/components/AddKnowledgePopup.tsx +++ b/src/renderer/src/pages/knowledge/components/AddKnowledgePopup.tsx @@ -13,7 +13,6 @@ import { KnowledgeBase, Model } from '@renderer/types' import { getErrorMessage } from '@renderer/utils/error' import { Flex, Form, Input, InputNumber, Modal, Select, Slider, Switch } from 'antd' import { find, sortBy } from 'lodash' -import { ChevronDown } from 'lucide-react' import { nanoid } from 'nanoid' import { useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -183,12 +182,7 @@ const PopupContainer: React.FC = ({ title, resolve }) => { label={t('models.embedding_model')} tooltip={{ title: t('models.embedding_model_tooltip'), placement: 'right' }} rules={[{ required: true, message: t('message.error.enter.model') }]}> - = ({ title, resolve }) => { label={t('models.rerank_model')} tooltip={{ title: t('models.rerank_model_tooltip'), placement: 'right' }} rules={[{ required: false, message: t('message.error.enter.model') }]}> - {t('models.rerank_model_not_support_provider', { diff --git a/src/renderer/src/pages/knowledge/components/KnowledgeSettingsPopup.tsx b/src/renderer/src/pages/knowledge/components/KnowledgeSettingsPopup.tsx index 625ca2c90f..6b44684d5a 100644 --- a/src/renderer/src/pages/knowledge/components/KnowledgeSettingsPopup.tsx +++ b/src/renderer/src/pages/knowledge/components/KnowledgeSettingsPopup.tsx @@ -140,13 +140,7 @@ const PopupContainer: React.FC = ({ base: _base, resolve }) => { initialValue={getModelUniqId(base.model)} tooltip={{ title: t('models.embedding_model_tooltip'), placement: 'right' }} rules={[{ required: true, message: t('message.error.enter.model') }]}> - = ({ base: _base, resolve }) => { options={rerankSelectOptions} placeholder={t('settings.models.empty')} allowClear - suffixIcon={} /> diff --git a/src/renderer/src/pages/settings/AssistantSettings/AssistantKnowledgeBaseSettings.tsx b/src/renderer/src/pages/settings/AssistantSettings/AssistantKnowledgeBaseSettings.tsx index a593f41cbe..169ed3ffd5 100644 --- a/src/renderer/src/pages/settings/AssistantSettings/AssistantKnowledgeBaseSettings.tsx +++ b/src/renderer/src/pages/settings/AssistantSettings/AssistantKnowledgeBaseSettings.tsx @@ -3,7 +3,7 @@ import { Box } from '@renderer/components/Layout' import { useAppSelector } from '@renderer/store' import { Assistant, AssistantSettings } from '@renderer/types' import { Row, Segmented, Select, SelectProps, Tooltip } from 'antd' -import { ChevronDown, CircleHelp } from 'lucide-react' +import { CircleHelp } from 'lucide-react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -46,7 +46,6 @@ const AssistantKnowledgeBaseSettings: React.FC = ({ assistant, updateAssi .toLowerCase() .includes(input.toLowerCase()) } - suffixIcon={} /> diff --git a/src/renderer/src/pages/settings/AssistantSettings/AssistantModelSettings.tsx b/src/renderer/src/pages/settings/AssistantSettings/AssistantModelSettings.tsx index 31e44abfbb..7017b02890 100644 --- a/src/renderer/src/pages/settings/AssistantSettings/AssistantModelSettings.tsx +++ b/src/renderer/src/pages/settings/AssistantSettings/AssistantModelSettings.tsx @@ -10,7 +10,6 @@ import { Assistant, AssistantSettingCustomParameters, AssistantSettings } from ' import { modalConfirm } from '@renderer/utils' import { Button, Col, Divider, Input, InputNumber, Row, Select, Slider, Switch, Tooltip } from 'antd' import { isNull } from 'lodash' -import { ChevronDown } from 'lucide-react' import { FC, useCallback, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -118,7 +117,6 @@ const AssistantModelSettings: FC = ({ assistant, updateAssistant, updateA { label: 'true', value: true }, { label: 'false', value: false } ]} - suffixIcon={} /> ) case 'json': @@ -437,8 +435,7 @@ const AssistantModelSettings: FC = ({ assistant, updateAssistant, updateA ) }))} - suffixIcon={} /> )} @@ -455,7 +452,6 @@ const TranslatePage: FC = () => { ) }))} - suffixIcon={} /> ) } @@ -555,7 +551,6 @@ const TranslatePage: FC = () => { ) })) ]} - suffixIcon={} />