From 2fad7c0ff671b913fb158a698836f20277b7c39c Mon Sep 17 00:00:00 2001 From: MyPrototypeWhat <43230886+MyPrototypeWhat@users.noreply.github.com> Date: Fri, 4 Jul 2025 16:07:13 +0800 Subject: [PATCH 01/54] refactor(messageThunk): streamline loading state management for topics (#7809) * refactor(messageThunk): streamline loading state management for topics - Reintroduced the handleChangeLoadingOfTopic function to manage loading states more effectively. - Updated thunk implementations to ensure loading state is correctly set after message processing. - Removed commented-out code for clarity and maintainability. * fix(messageThunk): ensure loading state is managed correctly after message sending - Added a finally block to guarantee that the loading state is updated after the sendMessage thunk execution. - Removed commented-out code for improved clarity and maintainability. --- src/renderer/src/store/thunk/messageThunk.ts | 31 +++++++++----------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/src/renderer/src/store/thunk/messageThunk.ts b/src/renderer/src/store/thunk/messageThunk.ts index 0360022a54..5537ed0b13 100644 --- a/src/renderer/src/store/thunk/messageThunk.ts +++ b/src/renderer/src/store/thunk/messageThunk.ts @@ -35,6 +35,7 @@ import { } from '@renderer/utils/messageUtils/create' import { getMainTextContent } from '@renderer/utils/messageUtils/find' import { getTopicQueue } from '@renderer/utils/queue' +import { waitForTopicQueue } from '@renderer/utils/queue' import { isOnHomePage } from '@renderer/utils/window' import { t } from 'i18next' import { isEmpty, throttle } from 'lodash' @@ -44,10 +45,10 @@ import type { AppDispatch, RootState } from '../index' import { removeManyBlocks, updateOneBlock, upsertManyBlocks, upsertOneBlock } from '../messageBlock' import { newMessagesActions, selectMessagesForTopic } from '../newMessage' -// const handleChangeLoadingOfTopic = async (topicId: string) => { -// await waitForTopicQueue(topicId) -// store.dispatch(newMessagesActions.setTopicLoading({ topicId, loading: false })) -// } +const handleChangeLoadingOfTopic = async (topicId: string) => { + await waitForTopicQueue(topicId) + store.dispatch(newMessagesActions.setTopicLoading({ topicId, loading: false })) +} // TODO: 后续可以将db操作移到Listener Middleware中 export const saveMessageAndBlocksToDB = async (message: Message, blocks: MessageBlock[], messageIndex: number = -1) => { try { @@ -801,7 +802,7 @@ const fetchAndProcessAssistantResponseImpl = async ( const usage = await estimateMessagesUsage({ assistant, messages: finalContextWithAssistant }) response.usage = usage } - dispatch(newMessagesActions.setTopicLoading({ topicId, loading: false })) + // dispatch(newMessagesActions.setTopicLoading({ topicId, loading: false })) } if (response && response.metrics) { if (response.metrics.completion_tokens === 0 && response.usage?.completion_tokens) { @@ -887,10 +888,9 @@ export const sendMessage = } } catch (error) { console.error('Error in sendMessage thunk:', error) + } finally { + handleChangeLoadingOfTopic(topicId) } - // finally { - // handleChangeLoadingOfTopic(topicId) - // } } /** @@ -1136,10 +1136,9 @@ export const resendMessageThunk = } } catch (error) { console.error(`[resendMessageThunk] Error resending user message ${userMessageToResend.id}:`, error) + } finally { + handleChangeLoadingOfTopic(topicId) } - // finally { - // handleChangeLoadingOfTopic(topicId) - // } } /** @@ -1271,10 +1270,9 @@ export const regenerateAssistantResponseThunk = error ) // dispatch(newMessagesActions.setTopicLoading({ topicId, loading: false })) + } finally { + handleChangeLoadingOfTopic(topicId) } - // finally { - // handleChangeLoadingOfTopic(topicId) - // } } // --- Thunk to initiate translation and create the initial block --- @@ -1447,10 +1445,9 @@ export const appendAssistantResponseThunk = console.error(`[appendAssistantResponseThunk] Error appending assistant response:`, error) // Optionally dispatch an error action or notification // Resetting loading state should be handled by the underlying fetchAndProcessAssistantResponseImpl + } finally { + handleChangeLoadingOfTopic(topicId) } - // finally { - // handleChangeLoadingOfTopic(topicId) - // } } /** From 134ea51b0f0fdac14e9688f948b914a50297c84c Mon Sep 17 00:00:00 2001 From: SuYao Date: Fri, 4 Jul 2025 17:03:45 +0800 Subject: [PATCH 02/54] fix: websearch block and citation formatting (#7776) * feat: enhance citation handling for Perplexity web search results - Implemented formatting for Perplexity citations in MainTextBlock, including data-citation attributes. - Updated citation processing in message store and thunk to support new citation structure. - Added utility functions for link completion based on web search results. - Enhanced tests to verify correct handling of Perplexity citations and links. * refactor: streamline chunk processing in OpenAIApiClient - Replaced single choice handling with a loop to process all choices in the chunk. - Improved handling of content sources, ensuring fallback mechanisms are in place for delta and message fields. - Enhanced tool call processing to accommodate missing function names and arguments. - Maintained existing functionality for web search data and reasoning content processing. * fix: improve citation handling and web search integration - Enhanced citation formatting to support legacy data compatibility in messageBlock.ts. - Updated messageThunk.ts to manage main text block references and citation updates more effectively. - Removed unnecessary web search flag and streamlined block processing logic. * fix: improve citation transforms to skip code blocks - Add withCitationTags for better code structure - Add tests - Remove outdated code - The Citation type in @renderer/types/index.ts is not referenced anywhere, so removed - Move the actual Citation type from @renderer/pages/home/Messages/CitationsList.tsx to @renderer/types/index.ts - Allow text selecting in tooltip * test: update tests * refactor(messageThunk): streamline citation handling in response processing - Removed redundant citation block source retrieval during text chunk processing. - Updated citation references handling to ensure proper inclusion only when available. - Simplified the logic for managing citation references in both streaming and final text updates. * refactor: simplify determineCitationSource for backward compatibility --------- Co-authored-by: one --- .../clients/anthropic/AnthropicAPIClient.ts | 26 +- .../aiCore/clients/openai/OpenAIApiClient.ts | 136 ++--- .../clients/openai/OpenAIResponseAPIClient.ts | 4 + .../middleware/core/TextChunkMiddleware.ts | 56 +- .../middleware/core/ThinkChunkMiddleware.ts | 10 + .../middleware/core/WebSearchMiddleware.ts | 7 +- .../pages/home/Markdown/CitationTooltip.tsx | 2 +- .../CitationTooltip.test.tsx.snap | 4 +- .../home/Messages/Blocks/MainTextBlock.tsx | 114 +--- .../Blocks/__tests__/MainTextBlock.test.tsx | 162 ++--- .../src/pages/home/Messages/CitationsList.tsx | 12 +- src/renderer/src/services/ApiService.ts | 3 - .../src/services/StreamProcessingService.ts | 1 + src/renderer/src/store/messageBlock.ts | 15 +- src/renderer/src/store/thunk/messageThunk.ts | 76 ++- src/renderer/src/types/chunk.ts | 1 - src/renderer/src/types/index.ts | 5 +- .../src/utils/__tests__/citation.test.ts | 562 ++++++++++++++++++ .../src/utils/__tests__/linkConverter.test.ts | 17 + src/renderer/src/utils/citation.ts | 210 +++++++ src/renderer/src/utils/formats.ts | 28 - src/renderer/src/utils/linkConverter.ts | 43 +- 22 files changed, 1156 insertions(+), 338 deletions(-) create mode 100644 src/renderer/src/utils/__tests__/citation.test.ts create mode 100644 src/renderer/src/utils/citation.ts diff --git a/src/renderer/src/aiCore/clients/anthropic/AnthropicAPIClient.ts b/src/renderer/src/aiCore/clients/anthropic/AnthropicAPIClient.ts index ebe76d8152..c946f114fe 100644 --- a/src/renderer/src/aiCore/clients/anthropic/AnthropicAPIClient.ts +++ b/src/renderer/src/aiCore/clients/anthropic/AnthropicAPIClient.ts @@ -49,7 +49,9 @@ import { LLMWebSearchCompleteChunk, LLMWebSearchInProgressChunk, MCPToolCreatedChunk, + TextCompleteChunk, TextDeltaChunk, + ThinkingCompleteChunk, ThinkingDeltaChunk } from '@renderer/types/chunk' import { type Message } from '@renderer/types/newMessage' @@ -517,7 +519,7 @@ export class AnthropicAPIClient extends BaseApiClient< return () => { let accumulatedJson = '' const toolCalls: Record = {} - + const ChunkIdTypeMap: Record = {} return { async transform(rawChunk: AnthropicSdkRawChunk, controller: TransformStreamDefaultController) { switch (rawChunk.type) { @@ -612,6 +614,19 @@ export class AnthropicAPIClient extends BaseApiClient< toolCalls[rawChunk.index] = contentBlock break } + case 'text': { + if (!ChunkIdTypeMap[rawChunk.index]) { + ChunkIdTypeMap[rawChunk.index] = ChunkType.TEXT_DELTA // 用textdelta代表文本块 + } + break + } + case 'thinking': + case 'redacted_thinking': { + if (!ChunkIdTypeMap[rawChunk.index]) { + ChunkIdTypeMap[rawChunk.index] = ChunkType.THINKING_DELTA // 用thinkingdelta代表思考块 + } + break + } } break } @@ -646,6 +661,15 @@ export class AnthropicAPIClient extends BaseApiClient< break } case 'content_block_stop': { + if (ChunkIdTypeMap[rawChunk.index] === ChunkType.TEXT_DELTA) { + controller.enqueue({ + type: ChunkType.TEXT_COMPLETE + } as TextCompleteChunk) + } else if (ChunkIdTypeMap[rawChunk.index] === ChunkType.THINKING_DELTA) { + controller.enqueue({ + type: ChunkType.THINKING_COMPLETE + } as ThinkingCompleteChunk) + } const toolCall = toolCalls[rawChunk.index] if (toolCall) { try { diff --git a/src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts b/src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts index 65e9cc67c4..c1994dcb95 100644 --- a/src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts +++ b/src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts @@ -564,11 +564,11 @@ export class OpenAIAPIClient extends OpenAIBaseClient< // Perplexity citations // @ts-ignore - citations may not be in standard type definitions - if (context.provider?.id === 'perplexity' && chunk.citations && chunk.citations.length > 0) { + if (context.provider?.id === 'perplexity' && chunk.search_results && chunk.search_results.length > 0) { hasBeenCollectedWebSearch = true return { // @ts-ignore - citations may not be in standard type definitions - results: chunk.citations, + results: chunk.search_results, source: WebSearchSource.PERPLEXITY } } @@ -672,74 +672,21 @@ export class OpenAIAPIClient extends OpenAIBaseClient< // 处理chunk if ('choices' in chunk && chunk.choices && chunk.choices.length > 0) { - const choice = chunk.choices[0] + for (const choice of chunk.choices) { + if (!choice) continue - if (!choice) return - - // 对于流式响应,使用 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 - - const webSearchData = collectWebSearchData(chunk, contentSource, context) - if (webSearchData) { - controller.enqueue({ - type: ChunkType.LLM_WEB_SEARCH_COMPLETE, - llm_web_search: webSearchData - }) - } - - // 处理推理内容 (e.g. from OpenRouter DeepSeek-R1) - // @ts-ignore - reasoning_content is not in standard OpenAI types but some providers use it - const reasoningText = contentSource.reasoning_content || contentSource.reasoning - if (reasoningText) { - controller.enqueue({ - type: ChunkType.THINKING_DELTA, - text: reasoningText - }) - } - - // 处理文本内容 - if (contentSource.content) { - controller.enqueue({ - type: ChunkType.TEXT_DELTA, - text: contentSource.content - }) - } - - // 处理工具调用 - if (contentSource.tool_calls) { - for (const toolCall of contentSource.tool_calls) { - if ('index' in toolCall) { - const { id, index, function: fun } = toolCall - if (fun?.name) { - toolCalls[index] = { - id: id || '', - function: { - name: fun.name, - arguments: fun.arguments || '' - }, - type: 'function' - } - } else if (fun?.arguments) { - toolCalls[index].function.arguments += fun.arguments - } - } else { - toolCalls.push(toolCall) - } + // 对于流式响应,使用 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 } - } - // 处理finish_reason,发送流结束信号 - if ('finish_reason' in choice && choice.finish_reason) { - Logger.debug(`[OpenAIApiClient] Stream finished with reason: ${choice.finish_reason}`) + if (!contentSource) continue + const webSearchData = collectWebSearchData(chunk, contentSource, context) if (webSearchData) { controller.enqueue({ @@ -747,7 +694,60 @@ export class OpenAIAPIClient extends OpenAIBaseClient< llm_web_search: webSearchData }) } - emitCompletionSignals(controller) + + // 处理推理内容 (e.g. from OpenRouter DeepSeek-R1) + // @ts-ignore - reasoning_content is not in standard OpenAI types but some providers use it + const reasoningText = contentSource.reasoning_content || contentSource.reasoning + if (reasoningText) { + controller.enqueue({ + type: ChunkType.THINKING_DELTA, + text: reasoningText + }) + } + + // 处理文本内容 + if (contentSource.content) { + controller.enqueue({ + type: ChunkType.TEXT_DELTA, + text: contentSource.content + }) + } + + // 处理工具调用 + if (contentSource.tool_calls) { + for (const toolCall of contentSource.tool_calls) { + if ('index' in toolCall) { + const { id, index, function: fun } = toolCall + if (fun?.name) { + toolCalls[index] = { + id: id || '', + function: { + name: fun.name, + arguments: fun.arguments || '' + }, + type: 'function' + } + } else if (fun?.arguments) { + toolCalls[index].function.arguments += fun.arguments + } + } else { + toolCalls.push(toolCall) + } + } + } + + // 处理finish_reason,发送流结束信号 + if ('finish_reason' in choice && choice.finish_reason) { + Logger.debug(`[OpenAIApiClient] Stream finished with reason: ${choice.finish_reason}`) + const webSearchData = collectWebSearchData(chunk, contentSource, context) + if (webSearchData) { + controller.enqueue({ + type: ChunkType.LLM_WEB_SEARCH_COMPLETE, + llm_web_search: webSearchData + }) + } + emitCompletionSignals(controller) + } } } }, diff --git a/src/renderer/src/aiCore/clients/openai/OpenAIResponseAPIClient.ts b/src/renderer/src/aiCore/clients/openai/OpenAIResponseAPIClient.ts index 99e40ed818..a5bfff8263 100644 --- a/src/renderer/src/aiCore/clients/openai/OpenAIResponseAPIClient.ts +++ b/src/renderer/src/aiCore/clients/openai/OpenAIResponseAPIClient.ts @@ -492,6 +492,10 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient< case 'response.output_item.added': if (chunk.item.type === 'function_call') { outputItems.push(chunk.item) + } else if (chunk.item.type === 'web_search_call') { + controller.enqueue({ + type: ChunkType.LLM_WEB_SEARCH_IN_PROGRESS + }) } break case 'response.reasoning_summary_part.added': diff --git a/src/renderer/src/aiCore/middleware/core/TextChunkMiddleware.ts b/src/renderer/src/aiCore/middleware/core/TextChunkMiddleware.ts index 2a3255356f..3905d52058 100644 --- a/src/renderer/src/aiCore/middleware/core/TextChunkMiddleware.ts +++ b/src/renderer/src/aiCore/middleware/core/TextChunkMiddleware.ts @@ -1,5 +1,5 @@ import Logger from '@renderer/config/logger' -import { ChunkType, TextDeltaChunk } from '@renderer/types/chunk' +import { ChunkType, TextCompleteChunk, TextDeltaChunk } from '@renderer/types/chunk' import { CompletionsParams, CompletionsResult, GenericChunk } from '../schemas' import { CompletionsContext, CompletionsMiddleware } from '../types' @@ -38,7 +38,7 @@ export const TextChunkMiddleware: CompletionsMiddleware = // 用于跨chunk的状态管理 let accumulatedTextContent = '' - let hasEnqueue = false + let hasTextCompleteEventEnqueue = false const enhancedTextStream = resultFromUpstream.pipeThrough( new TransformStream({ transform(chunk: GenericChunk, controller) { @@ -53,30 +53,44 @@ export const TextChunkMiddleware: CompletionsMiddleware = // 创建新的chunk,包含处理后的文本 controller.enqueue(chunk) - } else if (accumulatedTextContent) { - if (chunk.type !== ChunkType.LLM_RESPONSE_COMPLETE) { - controller.enqueue(chunk) - hasEnqueue = true - } - const finalText = accumulatedTextContent - ctx._internal.customState!.accumulatedText = finalText - if (ctx._internal.toolProcessingState && !ctx._internal.toolProcessingState?.output) { - ctx._internal.toolProcessingState.output = finalText - } - - // 处理 onResponse 回调 - 发送最终完整文本 - if (params.onResponse) { - params.onResponse(finalText, true) - } - + } else if (chunk.type === ChunkType.TEXT_COMPLETE) { + const textChunk = chunk as TextCompleteChunk controller.enqueue({ - type: ChunkType.TEXT_COMPLETE, - text: finalText + ...textChunk, + text: accumulatedTextContent }) + if (params.onResponse) { + params.onResponse(accumulatedTextContent, true) + } + hasTextCompleteEventEnqueue = true accumulatedTextContent = '' - if (!hasEnqueue) { + } else if (accumulatedTextContent && !hasTextCompleteEventEnqueue) { + if (chunk.type === ChunkType.LLM_RESPONSE_COMPLETE) { + const finalText = accumulatedTextContent + ctx._internal.customState!.accumulatedText = finalText + if (ctx._internal.toolProcessingState && !ctx._internal.toolProcessingState?.output) { + ctx._internal.toolProcessingState.output = finalText + } + + // 处理 onResponse 回调 - 发送最终完整文本 + if (params.onResponse) { + params.onResponse(finalText, true) + } + + controller.enqueue({ + type: ChunkType.TEXT_COMPLETE, + text: finalText + }) + controller.enqueue(chunk) + } else { + controller.enqueue({ + type: ChunkType.TEXT_COMPLETE, + text: accumulatedTextContent + }) controller.enqueue(chunk) } + hasTextCompleteEventEnqueue = true + accumulatedTextContent = '' } else { // 其他类型的chunk直接传递 controller.enqueue(chunk) diff --git a/src/renderer/src/aiCore/middleware/core/ThinkChunkMiddleware.ts b/src/renderer/src/aiCore/middleware/core/ThinkChunkMiddleware.ts index b0df8313a5..dccdde7f10 100644 --- a/src/renderer/src/aiCore/middleware/core/ThinkChunkMiddleware.ts +++ b/src/renderer/src/aiCore/middleware/core/ThinkChunkMiddleware.ts @@ -65,6 +65,16 @@ export const ThinkChunkMiddleware: CompletionsMiddleware = thinking_millsec: thinkingStartTime > 0 ? Date.now() - thinkingStartTime : 0 } controller.enqueue(enhancedChunk) + } else if (chunk.type === ChunkType.THINKING_COMPLETE) { + const thinkingCompleteChunk = chunk as ThinkingCompleteChunk + controller.enqueue({ + ...thinkingCompleteChunk, + text: accumulatedThinkingContent, + thinking_millsec: thinkingStartTime > 0 ? Date.now() - thinkingStartTime : 0 + }) + hasThinkingContent = false + accumulatedThinkingContent = '' + thinkingStartTime = 0 } else if (hasThinkingContent && thinkingStartTime > 0) { // 收到任何非THINKING_DELTA的chunk时,如果有累积的思考内容,生成THINKING_COMPLETE const thinkingCompleteChunk: ThinkingCompleteChunk = { diff --git a/src/renderer/src/aiCore/middleware/core/WebSearchMiddleware.ts b/src/renderer/src/aiCore/middleware/core/WebSearchMiddleware.ts index 70915abffa..d4c8f71eff 100644 --- a/src/renderer/src/aiCore/middleware/core/WebSearchMiddleware.ts +++ b/src/renderer/src/aiCore/middleware/core/WebSearchMiddleware.ts @@ -42,7 +42,12 @@ export const WebSearchMiddleware: CompletionsMiddleware = const providerType = model.provider || 'openai' // 使用当前可用的Web搜索结果进行链接转换 const text = chunk.text - const result = smartLinkConverter(text, providerType, isFirstChunk) + const result = smartLinkConverter( + text, + providerType, + isFirstChunk, + ctx._internal.webSearchState!.results + ) if (isFirstChunk) { isFirstChunk = false } diff --git a/src/renderer/src/pages/home/Markdown/CitationTooltip.tsx b/src/renderer/src/pages/home/Markdown/CitationTooltip.tsx index 6041b562af..febbd3264f 100644 --- a/src/renderer/src/pages/home/Markdown/CitationTooltip.tsx +++ b/src/renderer/src/pages/home/Markdown/CitationTooltip.tsx @@ -32,7 +32,7 @@ const CitationTooltip: React.FC = ({ children, citation }) // 自定义悬浮卡片内容 const tooltipContent = useMemo( () => ( -
+
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 e9c6def351..f11d52a833 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 @@ -58,7 +58,9 @@ exports[`CitationTooltip > basic rendering > should match snapshot 1`] = `
-
+
= ({ block, citationBlockId, role, mentions const rawCitations = useSelector((state: RootState) => selectFormattedCitationsByBlockId(state, citationBlockId)) - const formattedCitations = useMemo(() => { - return rawCitations.map((citation) => ({ - ...citation, - content: citation.content ? cleanMarkdownContent(citation.content) : citation.content - })) - }, [rawCitations]) - const processedContent = useMemo(() => { - let content = block.content - // Update condition to use citationBlockId - if (!block.citationReferences?.length || !citationBlockId || formattedCitations.length === 0) { - return content + if (!block.citationReferences?.length || !citationBlockId || rawCitations.length === 0) { + return block.content } - switch (block.citationReferences[0].citationBlockSource) { - case WebSearchSource.OPENAI: - case WebSearchSource.OPENAI_RESPONSE: { - formattedCitations.forEach((citation) => { - const citationNum = citation.number - const supData = { - id: citationNum, - url: citation.url, - title: citation.title || citation.hostname || '', - content: citation.content?.substring(0, 200) - } - const citationJson = encodeHTML(JSON.stringify(supData)) + // 确定最适合的 source + const sourceType = determineCitationSource(block.citationReferences) - // Handle[N](url) - const preFormattedRegex = new RegExp(`\\[${citationNum}\\]\\(.*?\\)`, 'g') - - const citationTag = `[${citationNum}](${citation.url})` - - content = content.replace(preFormattedRegex, citationTag) - }) - break - } - case WebSearchSource.GEMINI: { - // First pass: Add basic citation marks using metadata - let processedContent = content - const firstCitation = formattedCitations[0] - if (firstCitation?.metadata) { - firstCitation.metadata.forEach((support: GroundingSupport) => { - const citationNums = support.groundingChunkIndices! - - if (support.segment) { - const text = support.segment.text! - // 生成引用标记 - const basicTag = citationNums - .map((citationNum) => { - const citation = formattedCitations.find((c) => c.number === citationNum + 1) - return citation ? `[${citationNum + 1}](${citation.url})` : '' - }) - .join('') - - // 在文本后面添加引用标记,而不是替换 - if (text && basicTag) { - processedContent = processedContent.replace(text, `${text}${basicTag}`) - } - } - }) - content = processedContent - } - // Second pass: Replace basic citations with full citation data - formattedCitations.forEach((citation) => { - const citationNum = citation.number - const supData = { - id: citationNum, - url: citation.url, - title: citation.title || citation.hostname || '', - content: citation.content?.substring(0, 200) - } - const citationJson = encodeHTML(JSON.stringify(supData)) - - // Replace basic citation with full citation including data - const basicCitationRegex = new RegExp(`\\[${citationNum}\\]\\(${citation.url}\\)`, 'g') - const fullCitationTag = `[${citationNum}](${citation.url})` - content = content.replace(basicCitationRegex, fullCitationTag) - }) - break - } - default: { - // FIXME:性能问题,需要优化 - // Replace all citation numbers and pre-formatted links with formatted citations - formattedCitations.forEach((citation) => { - const citationNum = citation.number - const supData = { - id: citationNum, - url: citation.url, - title: citation.title || citation.hostname || '', - content: citation.content?.substring(0, 200) - } - const isLink = citation.url.startsWith('http') - const citationJson = encodeHTML(JSON.stringify(supData)) - - // Handle both plain references [N] and pre-formatted links [N](url) - const plainRefRegex = new RegExp(`\\[${citationNum}\\]`, 'g') - - const supTag = `${citationNum}` - const citationTag = isLink ? `[${supTag}](${citation.url})` : supTag - - content = content.replace(plainRefRegex, citationTag) - }) - } - } - - return content - }, [block.content, block.citationReferences, citationBlockId, formattedCitations]) + return withCitationTags(block.content, rawCitations, sourceType) + }, [block.content, block.citationReferences, citationBlockId, rawCitations]) const ignoreToolUse = useMemo(() => { return processedContent.replace(toolUseRegex, '') diff --git a/src/renderer/src/pages/home/Messages/Blocks/__tests__/MainTextBlock.test.tsx b/src/renderer/src/pages/home/Messages/Blocks/__tests__/MainTextBlock.test.tsx index e2badf156c..551e0d9371 100644 --- a/src/renderer/src/pages/home/Messages/Blocks/__tests__/MainTextBlock.test.tsx +++ b/src/renderer/src/pages/home/Messages/Blocks/__tests__/MainTextBlock.test.tsx @@ -48,6 +48,28 @@ vi.mock('@renderer/utils/formats', () => ({ encodeHTML: vi.fn((content: string) => content.replace(/"/g, '"')) })) +// Mock citation utilities +vi.mock('@renderer/utils/citation', () => ({ + withCitationTags: vi.fn((content: string, citations: any[]) => { + // Simple mock implementation that simulates citation processing + if (citations.length > 0) { + return `${content} [processed-citations]` + } + return content + }), + determineCitationSource: vi.fn((citationReferences: any[], citationBlock?: any) => { + // Mock implementation that returns the first valid source from citationReferences + if (citationBlock?.response?.source) { + return citationBlock.response.source + } + if (citationReferences?.length) { + const validReference = citationReferences.find((ref) => ref.citationBlockSource) + return validReference?.citationBlockSource + } + return undefined + }) +})) + // Mock services vi.mock('@renderer/services/ModelService', () => ({ getModelUniqId: vi.fn() @@ -66,7 +88,8 @@ vi.mock('@renderer/pages/home/Markdown/Markdown', () => ({ describe('MainTextBlock', () => { // Get references to mocked modules let mockGetModelUniqId: any - let mockCleanMarkdownContent: any + let mockWithCitationTags: any + let mockDetermineCitationSource: any // Create a mock store for Provider const mockStore = configureStore({ @@ -80,9 +103,10 @@ describe('MainTextBlock', () => { // Get the mocked functions const { getModelUniqId } = await import('@renderer/services/ModelService') - const { cleanMarkdownContent } = await import('@renderer/utils/formats') + const { withCitationTags, determineCitationSource } = await import('@renderer/utils/citation') mockGetModelUniqId = getModelUniqId as any - mockCleanMarkdownContent = cleanMarkdownContent as any + mockWithCitationTags = withCitationTags as any + mockDetermineCitationSource = determineCitationSource as any // Default mock implementations mockUseSettings.mockReturnValue({ renderInputMessageAsMarkdown: false }) @@ -283,8 +307,16 @@ text after`, }) it('should process content through format utilities', () => { - const block = createMainTextBlock({ content: 'Content to process' }) - mockUseSelector.mockReturnValue([{ id: '1', content: 'Citation content', number: 1 }]) + const block = createMainTextBlock({ + content: 'Content to process', + citationReferences: [{ citationBlockSource: 'DEFAULT' as any }] + }) + const mockCitations = [{ id: '1', content: 'Citation content', number: 1 }] + + // Mock the useSelector calls - first call for citations, second call for citationBlock + mockUseSelector + .mockReturnValueOnce(mockCitations) // selectFormattedCitationsByBlockId + .mockReturnValueOnce(undefined) // messageBlocksSelectors.selectById renderMainTextBlock({ block, @@ -292,8 +324,14 @@ text after`, citationBlockId: 'test-citations' }) - // Verify utility functions are called - expect(mockCleanMarkdownContent).toHaveBeenCalled() + // Verify determineCitationSource was called with correct parameters + expect(mockDetermineCitationSource).toHaveBeenCalledWith(block.citationReferences) + + // Verify citation processing was called with correct parameters + expect(mockWithCitationTags).toHaveBeenCalledWith('Content to process', mockCitations, 'DEFAULT') + + // Verify the processed content is rendered + expect(screen.getByText('Markdown: Content to process [processed-citations]')).toBeInTheDocument() }) }) @@ -308,7 +346,7 @@ text after`, expect(mockUseSelector).toHaveBeenCalled() }) - it('should integrate with citation system when citations exist', () => { + it('should integrate with citation processing when all conditions are met', () => { const block = createMainTextBlock({ content: 'Content with citation [1]', citationReferences: [{ citationBlockSource: WebSearchSource.OPENAI }] @@ -324,7 +362,11 @@ text after`, } ] - mockUseSelector.mockReturnValue(mockCitations) + // Mock the useSelector calls - first call for citations, second call for citationBlock + mockUseSelector + .mockReturnValueOnce(mockCitations) // selectFormattedCitationsByBlockId + .mockReturnValueOnce(undefined) // messageBlocksSelectors.selectById + renderMainTextBlock({ block, role: 'assistant', @@ -335,28 +377,58 @@ text after`, expect(mockUseSelector).toHaveBeenCalled() expect(getRenderedMarkdown()).toBeInTheDocument() - // Verify content processing occurred - expect(mockCleanMarkdownContent).toHaveBeenCalledWith('Citation content') + // Verify determineCitationSource was called + expect(mockDetermineCitationSource).toHaveBeenCalledWith(block.citationReferences) + + // Verify withCitationTags was called with correct parameters + expect(mockWithCitationTags).toHaveBeenCalledWith( + 'Content with citation [1]', + mockCitations, + WebSearchSource.OPENAI + ) + + // Verify the processed content is rendered + expect(screen.getByText('Markdown: Content with citation [1] [processed-citations]')).toBeInTheDocument() }) - it('should handle different citation sources correctly', () => { - const testSources = [WebSearchSource.OPENAI, 'DEFAULT' as any, 'CUSTOM' as any] + it('should skip citation processing when conditions are not met', () => { + const testCases = [ + { + name: 'no citationReferences', + block: createMainTextBlock({ content: 'Content [1]' }), + citationBlockId: 'test' + }, + { + name: 'no citationBlockId', + block: createMainTextBlock({ + content: 'Content [1]', + citationReferences: [{ citationBlockSource: 'DEFAULT' as any }] + }), + citationBlockId: undefined + }, + { + name: 'no citations data', + block: createMainTextBlock({ + content: 'Content [1]', + citationReferences: [{ citationBlockSource: 'DEFAULT' as any }] + }), + citationBlockId: 'test' + } + ] - testSources.forEach((source) => { - const block = createMainTextBlock({ - content: `Citation test for ${source}`, - citationReferences: [{ citationBlockSource: source }] - }) - - mockUseSelector.mockReturnValue([{ id: '1', number: 1, url: 'https://test.com', title: 'Test' }]) + testCases.forEach(({ block, citationBlockId }) => { + mockUseSelector.mockReturnValue([]) // No citations const { unmount } = renderMainTextBlock({ block, role: 'assistant', - citationBlockId: `test-${source}` + citationBlockId }) expect(getRenderedMarkdown()).toBeInTheDocument() + // Should render original content without citation processing + expect(screen.getByText(`Markdown: ${block.content}`)).toBeInTheDocument() + unmount() }) }) @@ -400,51 +472,7 @@ text after`, }) }) - describe('edge cases and robustness', () => { - it('should handle large content without performance issues', () => { - const largeContent = 'A'.repeat(1000) + ' with citations [1]' - const block = createMainTextBlock({ content: largeContent }) - - const largeCitations = [ - { - id: '1', - number: 1, - url: 'https://large.com', - title: 'Large', - content: 'B'.repeat(500) - } - ] - - mockUseSelector.mockReturnValue(largeCitations) - - expect(() => { - renderMainTextBlock({ - block, - role: 'assistant', - citationBlockId: 'large-test' - }) - }).not.toThrow() - - expect(getRenderedMarkdown()).toBeInTheDocument() - }) - - it('should handle special characters and Unicode gracefully', () => { - const specialContent = '测试内容 🚀 📝 ✨ <>&"\'` [1]' - const block = createMainTextBlock({ content: specialContent }) - - mockUseSelector.mockReturnValue([{ id: '1', number: 1, title: '特殊字符测试', content: '内容 with 🎉' }]) - - expect(() => { - renderMainTextBlock({ - block, - role: 'assistant', - citationBlockId: 'unicode-test' - }) - }).not.toThrow() - - expect(getRenderedMarkdown()).toBeInTheDocument() - }) - + describe('integration and robustness', () => { it('should handle null and undefined values gracefully', () => { const block = createMainTextBlock({ content: 'Null safety test' }) @@ -460,7 +488,7 @@ text after`, expect(getRenderedMarkdown()).toBeInTheDocument() }) - it('should integrate properly with Redux store', () => { + it('should integrate properly with Redux store for citations', () => { const block = createMainTextBlock({ content: 'Redux integration test', citationReferences: [{ citationBlockSource: 'DEFAULT' as any }] diff --git a/src/renderer/src/pages/home/Messages/CitationsList.tsx b/src/renderer/src/pages/home/Messages/CitationsList.tsx index 5a61fecb72..ec51379784 100644 --- a/src/renderer/src/pages/home/Messages/CitationsList.tsx +++ b/src/renderer/src/pages/home/Messages/CitationsList.tsx @@ -1,5 +1,6 @@ import ContextMenu from '@renderer/components/ContextMenu' import Favicon from '@renderer/components/Icons/FallbackFavicon' +import { Citation } from '@renderer/types' import { fetchWebContent } from '@renderer/utils/fetch' import { cleanMarkdownContent } from '@renderer/utils/formats' import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query' @@ -9,17 +10,6 @@ import React, { useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' -export interface Citation { - number: number - url: string - title?: string - hostname?: string - content?: string - showFavicon?: boolean - type?: string - metadata?: Record -} - interface CitationsListProps { citations: Citation[] } diff --git a/src/renderer/src/services/ApiService.ts b/src/renderer/src/services/ApiService.ts index abc3db81b0..7c4861f403 100644 --- a/src/renderer/src/services/ApiService.ts +++ b/src/renderer/src/services/ApiService.ts @@ -359,9 +359,6 @@ export async function fetchChatCompletion({ // --- Call AI Completions --- onChunkReceived({ type: ChunkType.LLM_RESPONSE_CREATED }) - if (enableWebSearch) { - onChunkReceived({ type: ChunkType.LLM_WEB_SEARCH_IN_PROGRESS }) - } await AI.completions( { callType: 'chat', diff --git a/src/renderer/src/services/StreamProcessingService.ts b/src/renderer/src/services/StreamProcessingService.ts index 527cc2242f..78007e85e2 100644 --- a/src/renderer/src/services/StreamProcessingService.ts +++ b/src/renderer/src/services/StreamProcessingService.ts @@ -43,6 +43,7 @@ export function createStreamProcessor(callbacks: StreamProcessorCallbacks = {}) return (chunk: Chunk) => { try { const data = chunk + // console.log('data: ', chunk) switch (data.type) { case ChunkType.BLOCK_COMPLETE: { if (callbacks.onComplete) callbacks.onComplete(AssistantMessageStatus.SUCCESS, data?.response) diff --git a/src/renderer/src/store/messageBlock.ts b/src/renderer/src/store/messageBlock.ts index f9b8c34cd5..50d94af827 100644 --- a/src/renderer/src/store/messageBlock.ts +++ b/src/renderer/src/store/messageBlock.ts @@ -1,8 +1,7 @@ import { WebSearchResultBlock } from '@anthropic-ai/sdk/resources' import type { GroundingMetadata } from '@google/genai' import { createEntityAdapter, createSelector, createSlice, type PayloadAction } from '@reduxjs/toolkit' -import type { Citation } from '@renderer/pages/home/Messages/CitationsList' -import { WebSearchProviderResponse, WebSearchSource } from '@renderer/types' +import { Citation, WebSearchProviderResponse, WebSearchSource } from '@renderer/types' import type { CitationMessageBlock, MessageBlock } from '@renderer/types/newMessage' import { MessageBlockType } from '@renderer/types/newMessage' import type OpenAI from 'openai' @@ -160,9 +159,19 @@ export const formatCitationsFromBlock = (block: CitationMessageBlock | undefined } }) || [] break + case WebSearchSource.PERPLEXITY: { + formattedCitations = + (block.response.results as any[])?.map((result, index) => ({ + number: index + 1, + url: result.url || result, // 兼容旧数据 + title: result.title || new URL(result).hostname, // 兼容旧数据 + showFavicon: true, + type: 'websearch' + })) || [] + break + } case WebSearchSource.GROK: case WebSearchSource.OPENROUTER: - case WebSearchSource.PERPLEXITY: formattedCitations = (block.response.results as any[])?.map((url, index) => { try { diff --git a/src/renderer/src/store/thunk/messageThunk.ts b/src/renderer/src/store/thunk/messageThunk.ts index 5537ed0b13..5cb2836e82 100644 --- a/src/renderer/src/store/thunk/messageThunk.ts +++ b/src/renderer/src/store/thunk/messageThunk.ts @@ -8,7 +8,15 @@ import { createStreamProcessor, type StreamProcessorCallbacks } from '@renderer/ import { estimateMessagesUsage } from '@renderer/services/TokenService' import store from '@renderer/store' import { updateTopicUpdatedAt } from '@renderer/store/assistants' -import type { Assistant, ExternalToolResult, FileMetadata, MCPToolResponse, Model, Topic } from '@renderer/types' +import { + type Assistant, + type ExternalToolResult, + type FileMetadata, + type MCPToolResponse, + type Model, + type Topic, + WebSearchSource +} from '@renderer/types' import type { CitationMessageBlock, FileMessageBlock, @@ -353,7 +361,7 @@ const fetchAndProcessAssistantResponseImpl = async ( let thinkingBlockId: string | null = null let imageBlockId: string | null = null let toolBlockId: string | null = null - let hasWebSearch = false + const toolCallIdToBlockIdMap = new Map() const notificationService = NotificationService.getInstance() @@ -433,8 +441,7 @@ const fetchAndProcessAssistantResponseImpl = async ( const initialChanges: Partial = { type: MessageBlockType.MAIN_TEXT, content: accumulatedContent, - status: MessageBlockStatus.STREAMING, - citationReferences: citationBlockId ? [{ citationBlockId }] : [] + status: MessageBlockStatus.STREAMING } mainTextBlockId = initialPlaceholderBlockId // 清理占位块 @@ -444,8 +451,7 @@ const fetchAndProcessAssistantResponseImpl = async ( saveUpdatedBlockToDB(mainTextBlockId, assistantMsgId, topicId, getState) } else { const newBlock = createMainTextBlock(assistantMsgId, accumulatedContent, { - status: MessageBlockStatus.STREAMING, - citationReferences: citationBlockId ? [{ citationBlockId }] : [] + status: MessageBlockStatus.STREAMING }) mainTextBlockId = newBlock.id // 立即设置ID,防止竞态条件 await handleBlockTransition(newBlock, MessageBlockType.MAIN_TEXT) @@ -453,27 +459,27 @@ const fetchAndProcessAssistantResponseImpl = async ( }, onTextComplete: async (finalText) => { if (mainTextBlockId) { + let citationBlockSource: WebSearchSource | undefined + if (citationBlockId) { + const citationBlock = getState().messageBlocks.entities[citationBlockId] as CitationMessageBlock + citationBlockSource = citationBlock.response?.source + } const changes = { content: finalText, - status: MessageBlockStatus.SUCCESS + status: MessageBlockStatus.SUCCESS, + citationReferences: citationBlockSource ? [{ citationBlockId, citationBlockSource }] : [] } cancelThrottledBlockUpdate(mainTextBlockId) dispatch(updateOneBlock({ id: mainTextBlockId, changes })) saveUpdatedBlockToDB(mainTextBlockId, assistantMsgId, topicId, getState) - mainTextBlockId = null + if (!assistant.enableWebSearch) { + mainTextBlockId = null + } } else { console.warn( `[onTextComplete] Received text.complete but last block was not MAIN_TEXT (was ${lastBlockType}) or lastBlockId is null.` ) } - if (citationBlockId && !hasWebSearch) { - const changes: Partial = { - status: MessageBlockStatus.SUCCESS - } - dispatch(updateOneBlock({ id: citationBlockId, changes })) - saveUpdatedBlockToDB(citationBlockId, assistantMsgId, topicId, getState) - citationBlockId = null - } }, onThinkingChunk: async (text, thinking_millsec) => { accumulatedThinking += text @@ -616,15 +622,44 @@ const fetchAndProcessAssistantResponseImpl = async ( } }, onLLMWebSearchComplete: async (llmWebSearchResult) => { - if (citationBlockId) { - hasWebSearch = true + const blockId = citationBlockId || initialPlaceholderBlockId + if (blockId) { const changes: Partial = { + type: MessageBlockType.CITATION, response: llmWebSearchResult, status: MessageBlockStatus.SUCCESS } - dispatch(updateOneBlock({ id: citationBlockId, changes })) - saveUpdatedBlockToDB(citationBlockId, assistantMsgId, topicId, getState) + dispatch(updateOneBlock({ id: blockId, changes })) + saveUpdatedBlockToDB(blockId, assistantMsgId, topicId, getState) + if (mainTextBlockId) { + const state = getState() + const existingMainTextBlock = state.messageBlocks.entities[mainTextBlockId] + if (existingMainTextBlock && existingMainTextBlock.type === MessageBlockType.MAIN_TEXT) { + const currentRefs = existingMainTextBlock.citationReferences || [] + const mainTextChanges = { + citationReferences: [...currentRefs, { blockId, citationBlockSource: llmWebSearchResult.source }] + } + dispatch(updateOneBlock({ id: mainTextBlockId, changes: mainTextChanges })) + saveUpdatedBlockToDB(mainTextBlockId, assistantMsgId, topicId, getState) + } + mainTextBlockId = null + } + if (initialPlaceholderBlockId) { + citationBlockId = initialPlaceholderBlockId + initialPlaceholderBlockId = null + } + } else { + const citationBlock = createCitationBlock( + assistantMsgId, + { + response: llmWebSearchResult + }, + { + status: MessageBlockStatus.SUCCESS + } + ) + citationBlockId = citationBlock.id if (mainTextBlockId) { const state = getState() const existingMainTextBlock = state.messageBlocks.entities[mainTextBlockId] @@ -641,6 +676,7 @@ const fetchAndProcessAssistantResponseImpl = async ( } mainTextBlockId = null } + await handleBlockTransition(citationBlock, MessageBlockType.CITATION) } }, onImageCreated: async () => { diff --git a/src/renderer/src/types/chunk.ts b/src/renderer/src/types/chunk.ts index 746c8999cb..f079677a1d 100644 --- a/src/renderer/src/types/chunk.ts +++ b/src/renderer/src/types/chunk.ts @@ -55,7 +55,6 @@ export interface LLMResponseInProgressChunk { response?: Response type: ChunkType.LLM_RESPONSE_IN_PROGRESS } - export interface TextDeltaChunk { /** * The text content of the chunk diff --git a/src/renderer/src/types/index.ts b/src/renderer/src/types/index.ts index b5cd425f03..084cc130e2 100644 --- a/src/renderer/src/types/index.ts +++ b/src/renderer/src/types/index.ts @@ -728,9 +728,12 @@ export interface QuickPhrase { export interface Citation { number: number url: string - hostname: string title?: string + hostname?: string content?: string + showFavicon?: boolean + type?: string + metadata?: Record } export type MathEngine = 'KaTeX' | 'MathJax' | 'none' diff --git a/src/renderer/src/utils/__tests__/citation.test.ts b/src/renderer/src/utils/__tests__/citation.test.ts new file mode 100644 index 0000000000..4c877e285a --- /dev/null +++ b/src/renderer/src/utils/__tests__/citation.test.ts @@ -0,0 +1,562 @@ +import { GroundingSupport } from '@google/genai' +import { Citation, WebSearchSource } from '@renderer/types' +import { describe, expect, it, vi } from 'vitest' + +import { + determineCitationSource, + generateCitationTag, + mapCitationMarksToTags, + normalizeCitationMarks, + withCitationTags +} from '../citation' + +// Mock dependencies +vi.mock('@renderer/utils/formats', () => ({ + cleanMarkdownContent: vi.fn((content: string) => content.replace(/[*_~`]/g, '')), + encodeHTML: vi.fn((str: string) => + str.replace(/[&<>"']/g, (match) => { + const entities: { [key: string]: string } = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + } + return entities[match] + }) + ) +})) + +describe('citation', () => { + const createCitationMap = (citations: Citation[]) => new Map(citations.map((c) => [c.number, c])) + + describe('determineCitationSource', () => { + it('should find the the citation source', () => { + const citationReferences = [{ citationBlockId: 'block1', citationBlockSource: WebSearchSource.OPENAI }] + + const result = determineCitationSource(citationReferences) + expect(result).toBe(WebSearchSource.OPENAI) + }) + + it('should find first valid source in citation references', () => { + const citationReferences = [ + { citationBlockId: 'block1' }, // no source + { citationBlockId: 'block2', citationBlockSource: WebSearchSource.GEMINI }, + { citationBlockId: 'block3', citationBlockSource: WebSearchSource.GEMINI } + ] + + const result = determineCitationSource(citationReferences) + expect(result).toBe(WebSearchSource.GEMINI) + }) + + it('should return undefined when no sources available', () => { + const citationReferences = [ + { citationBlockId: 'block1' }, // no source + { citationBlockId: 'block2' } // no source + ] + + const result = determineCitationSource(citationReferences) + expect(result).toBeUndefined() + }) + + it('should return undefined for empty citation references', () => { + const result = determineCitationSource([]) + expect(result).toBeUndefined() + }) + + it('should return undefined for undefined citation references', () => { + const result = determineCitationSource(undefined) + expect(result).toBeUndefined() + }) + }) + + describe('withCitationTags', () => { + it('should process citations with default source type', () => { + const content = 'Test content [1] with citation' + const citations: Citation[] = [ + { + number: 1, + url: 'https://example.com', + title: 'Example' + } + ] + + const result = withCitationTags(content, citations) + + expect(result).toContain('[](https://example.com)') + }) + + it('should process citations with OpenAI source type', () => { + const content = 'Test content [1](https://example.com)' + const citations: Citation[] = [ + { + number: 1, + url: 'https://example.com', + title: 'Example', + content: 'Some **content**' + } + ] + + const result = withCitationTags(content, citations, WebSearchSource.OPENAI) + + expect(result).toContain('[](https://example.com)') + }) + + it('should process citations with Gemini source type', () => { + const content = 'Test content from Gemini' + const metadata: GroundingSupport[] = [ + { + segment: { text: 'Test content' }, + groundingChunkIndices: [0] + } + ] + const citations: Citation[] = [ + { + number: 1, + url: 'https://example.com', + title: 'Example', + metadata + } + ] + + const result = withCitationTags(content, citations, WebSearchSource.GEMINI) + + expect(result).toContain('Test content[](https://example.com)') + }) + + it('should handle empty citations array', () => { + const content = 'This is test content [1]' + const result = withCitationTags(content, []) + expect(result).toBe(content) + }) + }) + + describe('normalizeCitationMarks with markdown', () => { + const citations: Citation[] = [ + { number: 1, url: 'https://example1.com', title: 'Example 1' }, + { number: 2, url: 'https://example2.com', title: 'Example 2' }, + { number: 3, url: 'https://example3.com', title: 'Example 3' } + ] + const citationMap = createCitationMap(citations) + + it('should not process citations in inline code', () => { + const content = 'Here is `code with [1] citation` and normal [2] citation' + const result = normalizeCitationMarks(content, citationMap) + + // 内联代码中的 [1] 应该保持不变 + expect(result).toContain('`code with [1] citation`') + // 普通文本中的 [2] 应该被处理 + expect(result).toContain('[cite:2]') + }) + + it('should not process citations in code blocks', () => { + const content = `Text with citation [1] + +\`\`\`python +# Python code with [2] reference +def func(): + data = [3, 4, 5] # Array with [1] element reference + return data +\`\`\` + +\`\`\`bash +echo "Command with [2] parameter" +\`\`\` + + // Indented code block is not skipped + echo "Indented code block [3]" + +Normal text with [3] citation` + + const result = normalizeCitationMarks(content, citationMap) + + // 代码块内的内容应该保持原样 + expect(result).toContain('# Python code with [2] reference') + expect(result).toContain('data = [3, 4, 5] # Array with [1] element reference') + expect(result).toContain('echo "Command with [2] parameter"') + + // 代码块外的引用应该被处理 + expect(result).toContain('Text with citation [cite:1]') + expect(result).toContain('Indented code block [cite:3]') + expect(result).toContain('Normal text with [cite:3]') + }) + + it('should handle malformed code blocks', () => { + const content = `Text with [1] + +\`\`\`unclosed +Code block without closing +With [2] citation + +Normal text with [3] continues` + + const result = normalizeCitationMarks(content, citationMap) + + expect(result).toContain('[cite:1]') + expect(result).toContain('[cite:2]') + expect(result).toContain('[cite:3]') + }) + + it('should handle citations in various markdown structures', () => { + const content = `Normal citation [1] + +> This is a blockquote with [2] citation +> And another line with [3] + +Back to normal **with [1] again** + +# Heading with [3] citation +## Subheading with [2] citation + +List: +- list item with citation [1] + +Numbered list: +1. item with [2]` + + const result = normalizeCitationMarks(content, citationMap) + console.log(result) + + expect(result).toContain('citation [cite:1]') + expect(result).toContain('blockquote with [cite:2]') + expect(result).toContain('another line with [cite:3]') + expect(result).toContain('with [cite:1] again') + expect(result).toContain('Heading with [cite:3]') + expect(result).toContain('Subheading with [cite:2]') + expect(result).toContain('list item with citation [cite:1]') + expect(result).toContain('item with [cite:2]') + }) + }) + + describe('normalizeCitationMarks simple', () => { + describe('OpenAI format citations', () => { + it('should normalize OpenAI format citations', () => { + const content = 'Text with [1](https://example.com) citation' + const citations: Citation[] = [{ number: 1, url: 'https://example.com', title: 'Test' }] + const citationMap = createCitationMap(citations) + + for (const sourceType of [WebSearchSource.OPENAI, WebSearchSource.OPENAI_RESPONSE]) { + const result = normalizeCitationMarks(content, citationMap, sourceType) + expect(result).toBe('Text with [cite:1] citation') + } + }) + + it('should preserve non-matching OpenAI citations', () => { + const content = 'Text with [3](https://missing.com) citation' + const citations: Citation[] = [{ number: 1, url: 'https://example.com', title: 'Test' }] + const citationMap = createCitationMap(citations) + + for (const sourceType of [WebSearchSource.OPENAI, WebSearchSource.OPENAI_RESPONSE]) { + const result = normalizeCitationMarks(content, citationMap, sourceType) + expect(result).toBe('Text with [3](https://missing.com) citation') + } + }) + }) + + describe('Perplexity format citations', () => { + it('should normalize Perplexity format citations', () => { + const content = 'Perplexity citations [1](https://example.com)' + const citations: Citation[] = [ + { number: 1, url: 'https://example.com', title: 'Example Citation', content: 'Citation content' } + ] + const citationMap = new Map(citations.map((c) => [c.number, c])) + + const normalized = normalizeCitationMarks(content, citationMap, WebSearchSource.PERPLEXITY) + expect(normalized).toBe('Perplexity citations [cite:1]') + }) + + it('should preserve unmatched Perplexity citations', () => { + const content = 'Text with [2](https://notfound.com) citation' + const citations: Citation[] = [{ number: 1, url: 'https://example.com', title: 'Example Citation' }] + const citationMap = new Map(citations.map((c) => [c.number, c])) + + // 2号引用不存在,应该保持原样 + const normalized = normalizeCitationMarks(content, citationMap, WebSearchSource.PERPLEXITY) + expect(normalized).toBe('Text with [2](https://notfound.com) citation') + }) + }) + + describe('Gemini format citations', () => { + it('should normalize Gemini format citations', () => { + const content = 'This is test content from Gemini' + const metadata: GroundingSupport[] = [ + { + segment: { text: 'test content' }, + groundingChunkIndices: [0, 1] + } + ] + const citations: Citation[] = [ + { number: 1, url: 'https://example1.com', title: 'Test 1', metadata }, + { number: 2, url: 'https://example2.com', title: 'Test 2' } + ] + const citationMap = createCitationMap(citations) + + const result = normalizeCitationMarks(content, citationMap, WebSearchSource.GEMINI) + + expect(result).toBe('This is test content[cite:1][cite:2] from Gemini') + }) + + it('should handle Gemini citations without metadata', () => { + const content = 'Content without metadata' + const citations: Citation[] = [{ number: 1, url: 'https://example.com', title: 'Test' }] + const citationMap = createCitationMap(citations) + + const result = normalizeCitationMarks(content, citationMap, WebSearchSource.GEMINI) + + expect(result).toBe('Content without metadata') + }) + }) + + describe('default format citations', () => { + it('should normalize default format citations', () => { + const content = 'Text with [1][2] and [3] citations' + const citations: Citation[] = [ + { number: 1, url: 'https://example1.com', title: 'Test 1' }, + { number: 2, url: 'https://example2.com', title: 'Test 2' }, + { number: 3, url: 'https://example3.com', title: 'Test 3' } + ] + const citationMap = createCitationMap(citations) + + const result = normalizeCitationMarks(content, citationMap) + + expect(result).toBe('Text with [cite:1][cite:2] and [cite:3] citations') + }) + + it('should preserve non-matching default format citations', () => { + const content = 'Text with [1] and [3] citations' + const citations: Citation[] = [{ number: 1, url: 'https://example1.com', title: 'Test 1' }] + const citationMap = createCitationMap(citations) + + const result = normalizeCitationMarks(content, citationMap) + + expect(result).toBe('Text with [cite:1] and [3] citations') + }) + + it('should handle nested citation patterns', () => { + const content = 'Text with [[1]] and [cite:[2]] patterns' + const citations: Citation[] = [ + { number: 1, url: 'https://example1.com', title: 'Test 1' }, + { number: 2, url: 'https://example2.com', title: 'Test 2' } + ] + const citationMap = new Map(citations.map((c) => [c.number, c])) + + const result = normalizeCitationMarks(content, citationMap) + + // 最里面的会被处理 + expect(result).toBe('Text with [[cite:1]] and [cite:[cite:2]] patterns') + }) + + it('should handle mixed citation formats', () => { + const content = 'Text with [1] and [2](url) and other [3] formats' + const citations: Citation[] = [ + { number: 1, url: 'https://example1.com', title: 'Test 1' }, + { number: 2, url: 'https://example2.com', title: 'Test 2' } + ] + const citationMap = createCitationMap(citations) + + const result = normalizeCitationMarks(content, citationMap, WebSearchSource.OPENAI) + + expect(result).toBe('Text with [1] and [cite:2] and other [3] formats') + }) + }) + }) + + describe('mapCitationMarksToTags', () => { + const createCitationMap = (citations: Citation[]) => new Map(citations.map((c) => [c.number, c])) + + it('should convert cite marks to tags', () => { + const content = 'Text with [cite:1] citation' + const citations: Citation[] = [{ number: 1, url: 'https://example.com', title: 'Test' }] + const citationMap = createCitationMap(citations) + + const result = mapCitationMarksToTags(content, citationMap) + + expect(result).toContain('with [](https://example.com) citation') + }) + + it('should handle multiple cite marks', () => { + const content = 'Text with [cite:1][cite:2] and [cite:3] citations' + const citations: Citation[] = [ + { number: 1, url: 'https://example1.com', title: 'Test 1' }, + { number: 2, url: 'https://example2.com', title: 'Test 2' }, + { number: 3, url: 'https://example3.com', title: 'Test 3' } + ] + const citationMap = createCitationMap(citations) + + const result = mapCitationMarksToTags(content, citationMap) + + expect(result).toContain('with [](https://example1.com)[](https://example2.com) and') + expect(result).toContain('3](https://example3.com) citations') + }) + + it('should preserve non-matching cite marks', () => { + const content = 'Text with [cite:1] and [cite:3] citations' + const citations: Citation[] = [{ number: 1, url: 'https://example1.com', title: 'Test 1' }] + const citationMap = createCitationMap(citations) + + const result = mapCitationMarksToTags(content, citationMap) + + expect(result).toContain('1](https://example1.com)') + expect(result).toContain('[cite:3]') // Should remain unchanged + }) + + it('should handle nested cite marks', () => { + const content = 'Text with [cite:[cite:1]] and [cite:2] citations' + const citations: Citation[] = [ + { number: 1, url: 'https://example1.com', title: 'Test 1' }, + { number: 2, url: 'https://example2.com', title: 'Test 2' } + ] + const citationMap = createCitationMap(citations) + + const result = mapCitationMarksToTags(content, citationMap) + + expect(result).toContain('[cite:[](https://example1.com)]') + expect(result).toContain('2](https://example2.com)') + }) + + it('should handle content without cite marks', () => { + const content = 'Text without citations' + const citationMap = new Map() + + const result = mapCitationMarksToTags(content, citationMap) + + expect(result).toBe('Text without citations') + }) + + it('should handle malformed citation numbers', () => { + const content = 'Text with [cite:abc] and [cite:] marks' + const citationMap = new Map() + + const result = mapCitationMarksToTags(content, citationMap) + + expect(result).toBe('Text with [cite:abc] and [cite:] marks') + }) + }) + + describe('generateCitationTag', () => { + it('should generate citation tag with valid URL', () => { + const citation: Citation = { + number: 1, + url: 'https://example.com', + title: 'Example Title', + content: 'Some content here' + } + + const result = generateCitationTag(citation) + + expect(result).toContain('[](https://example.com)') + expect(result).toContain('Example Title') + }) + + it('should generate citation tag without URL when invalid', () => { + const citation: Citation = { + number: 2, + url: 'invalid-url', + title: 'Test Title' + } + + const result = generateCitationTag(citation) + + expect(result).toContain('[]()') + expect(result).not.toContain('](invalid-url)') + }) + + it('should handle citation without URL', () => { + const citation: Citation = { + number: 3, + url: '', + title: 'No URL Title' + } + + const result = generateCitationTag(citation) + + expect(result).toContain('[]()') + }) + + it('should use hostname when title is missing', () => { + const citation: Citation = { + number: 4, + url: 'https://example.com', + hostname: 'example.com' + } + + const result = generateCitationTag(citation) + + expect(result).toContain('example.com') + }) + + it('should handle citation with all empty values', () => { + const citation: Citation = { + number: 6, + url: '', + title: '', + hostname: '', + content: '' + } + + const result = generateCitationTag(citation) + + expect(result).toContain('[]()') + }) + + it('should truncate content to 200 characters in data-citation', () => { + const longContent = 'a'.repeat(300) + const citation: Citation = { + number: 1, + url: 'https://example.com', + title: 'Test', + content: longContent + } + + const result = generateCitationTag(citation) + const match = result.match(/data-citation='([^']+)'/) + expect(match).not.toBeNull() + if (match) { + const citationData = JSON.parse(match[1].replace(/"/g, '"')) + expect(citationData.content.length).toBe(200) + expect(citationData.content).toBe(longContent.substring(0, 200)) + } + }) + }) + + describe('performance', () => { + it('should handle large content efficiently', () => { + const largeContent = 'Test content '.repeat(10000) + '[1]' + const citations: Citation[] = [{ number: 1, url: 'https://example.com', title: 'Test' }] + + const start = Date.now() + const result = withCitationTags(largeContent, citations) + const end = Date.now() + + expect(result).toContain('[ { + const citations: Citation[] = Array.from({ length: 100 }, (_, i) => ({ + number: i + 1, + url: `https://example${i + 1}.com`, + title: `Test ${i + 1}` + })) + const content = citations.map((c) => `[${c.number}]`).join(' ') + + const start = Date.now() + const result = withCitationTags(content, citations) + const end = Date.now() + + expect(result).toContain('[ { }) describe('convertLinks', () => { + it('should convert number links to numbered links', () => { + const input = '参考 [1](https://example.com/1) 和 [2](https://example.com/2)' + const result = convertLinks(input, true) + expect(result.text).toBe('参考 [1](https://example.com/1) 和 [2](https://example.com/2)') + expect(result.hasBufferedContent).toBe(false) + }) + it('should convert links with domain-like text to numbered links', () => { const input = '查看这个网站 [example.com](https://example.com)' const result = convertLinks(input, true) @@ -375,4 +383,13 @@ describe('linkConverter', () => { expect(result).toBe('[链接1](https://example.com)[链接2](https://other.com)') }) }) + + describe('completionPerplexityLinks', () => { + it('should complete links with webSearch data', () => { + const webSearch = [{ url: 'https://example.com/1' }, { url: 'https://example.com/2' }] + const input = '参考 [1] 和 [2]' + const result = completionPerplexityLinks(input, webSearch) + expect(result).toBe('参考 [1](https://example.com/1) 和 [2](https://example.com/2)') + }) + }) }) diff --git a/src/renderer/src/utils/citation.ts b/src/renderer/src/utils/citation.ts new file mode 100644 index 0000000000..da2cee7706 --- /dev/null +++ b/src/renderer/src/utils/citation.ts @@ -0,0 +1,210 @@ +import { GroundingSupport } from '@google/genai' +import { Citation, WebSearchSource } from '@renderer/types' + +import { cleanMarkdownContent, encodeHTML } from './formats' + +/** + * 从多个 citationReference 中获取第一个有效的 source + * @returns WebSearchSource + */ +export function determineCitationSource( + citationReferences: Array<{ citationBlockId?: string; citationBlockSource?: WebSearchSource }> | undefined +): WebSearchSource | undefined { + // 从 citationReferences 获取第一个有效的 source + if (citationReferences?.length) { + const validReference = citationReferences.find((ref) => ref.citationBlockSource) + return validReference?.citationBlockSource + } + + return undefined +} + +/** + * 把文本内容中的引用标记转换为完整的引用标签 + * - 标准化引用标记 + * - 转换标记为用于渲染的标签 + * + * @param content 原始文本内容 + * @param citations 原始引用列表 + * @param sourceType 引用来源类型 + * @returns 处理后的文本内容 + */ +export function withCitationTags(content: string, citations: Citation[], sourceType?: WebSearchSource): string { + if (!content || citations.length === 0) return content + + const formattedCitations = citations.map((citation) => ({ + ...citation, + content: citation.content ? cleanMarkdownContent(citation.content) : citation.content + })) + + const citationMap = new Map(formattedCitations.map((c) => [c.number, c])) + + const normalizedContent = normalizeCitationMarks(content, citationMap, sourceType) + + return mapCitationMarksToTags(normalizedContent, citationMap) +} + +/** + * 标准化引用标记,统一转换为 [cite:N] 格式: + * - OpenAI 格式: [N](url) → [cite:N] + * - Gemini 格式: 根据metadata添加 [cite:N] + * - 其他格式: [N] → [cite:N] + * + * 算法: + * - one pass + 正则替换 + * - 跳过代码块等特殊上下文 + * + * @param content 原始文本内容 + * @param citationMap 引用映射表 + * @param sourceType 引用来源类型 + * @returns 标准化后的文本内容 + */ +export function normalizeCitationMarks( + content: string, + citationMap: Map, + sourceType?: WebSearchSource +): string { + // 识别需要跳过的代码区域,注意:indented code block已被禁用,不需要跳过 + const codeBlockRegex = /```[\s\S]*?```|`[^`\n]*`/gm + const skipRanges: Array<{ start: number; end: number }> = [] + + let match + while ((match = codeBlockRegex.exec(content)) !== null) { + skipRanges.push({ + start: match.index, + end: match.index + match[0].length + }) + } + + // 检查位置是否在代码块内 + const shouldSkip = (pos: number): boolean => { + for (const range of skipRanges) { + if (pos >= range.start && pos < range.end) return true + if (range.start > pos) break // 已排序,可以提前结束 + } + return false + } + + // 统一的替换函数 + const applyReplacements = (regex: RegExp, getReplacementFn: (match: RegExpExecArray) => string | null) => { + const replacements: Array<{ start: number; end: number; replacement: string }> = [] + + regex.lastIndex = 0 // 重置正则状态 + let match: RegExpExecArray | null + while ((match = regex.exec(content)) !== null) { + if (!shouldSkip(match.index)) { + const replacement = getReplacementFn(match) + if (replacement !== null) { + replacements.push({ + start: match.index, + end: match.index + match[0].length, + replacement + }) + } + } + } + + // 从后往前替换避免位置偏移 + replacements.reverse().forEach(({ start, end, replacement }) => { + content = content.slice(0, start) + replacement + content.slice(end) + }) + } + + switch (sourceType) { + case WebSearchSource.OPENAI: + case WebSearchSource.OPENAI_RESPONSE: + case WebSearchSource.PERPLEXITY: { + // OpenAI 格式: [N](url) → [cite:N] + applyReplacements(/\[(\d+)<\/sup>\]\([^)]*\)/g, (match) => { + const citationNum = parseInt(match[1], 10) + return citationMap.has(citationNum) ? `[cite:${citationNum}]` : null + }) + break + } + case WebSearchSource.GEMINI: { + // Gemini 格式: 根据metadata添加 [cite:N] + const firstCitation = Array.from(citationMap.values())[0] + if (firstCitation?.metadata) { + const textReplacements = new Map() + + // 收集所有需要替换的文本 + firstCitation.metadata.forEach((support: GroundingSupport) => { + if (!support.groundingChunkIndices || !support.segment?.text) return + + const citationNums = support.groundingChunkIndices + const text = support.segment.text + const basicTag = citationNums + .map((citationNum) => { + const citation = citationMap.get(citationNum + 1) + return citation ? `[cite:${citationNum + 1}]` : '' + }) + .filter(Boolean) + .join('') + + if (basicTag) { + textReplacements.set(text, `${text}${basicTag}`) + } + }) + + // 一次性应用所有替换 + textReplacements.forEach((replacement, originalText) => { + const escapedText = originalText.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + applyReplacements(new RegExp(escapedText, 'g'), () => replacement) + }) + } + break + } + default: { + // 简单数字格式: [N] → [cite:N] + applyReplacements(/\[(\d+)\]/g, (match) => { + const citationNum = parseInt(match[1], 10) + return citationMap.has(citationNum) ? `[cite:${citationNum}]` : null + }) + } + } + + return content +} + +/** + * 把文本内容中的 [cite:N] 标记转换为用于渲染的标签 + * @param content 原始文本内容 + * @param citationMap 引用映射表 + * @returns 处理后的文本内容 + */ +export function mapCitationMarksToTags(content: string, citationMap: Map): string { + // 统一替换所有 [cite:N] 标记 + return content.replace(/\[cite:(\d+)\]/g, (match, num) => { + const citationNum = parseInt(num, 10) + const citation = citationMap.get(citationNum) + + if (citation) { + return generateCitationTag(citation) + } + + // 如果没找到对应的引用数据,保持原样(应该不会发生) + return match + }) +} + +/** + * 生成单个用于渲染的引用标签 + * @param citation 引用数据 + * @returns 渲染后的引用标签 + */ +export function generateCitationTag(citation: Citation): string { + const supData = { + id: citation.number, + url: citation.url, + title: citation.title || citation.hostname || '', + content: citation.content?.substring(0, 200) + } + const citationJson = encodeHTML(JSON.stringify(supData)) + + // 判断是否为有效链接 + const isLink = citation.url && citation.url.startsWith('http') + + // 生成链接格式: [N](url) + // 或者生成空括号格式: [N]() + return `[${citation.number}]` + (isLink ? `(${citation.url})` : '()') +} diff --git a/src/renderer/src/utils/formats.ts b/src/renderer/src/utils/formats.ts index ee64efd443..5c8aea70ba 100644 --- a/src/renderer/src/utils/formats.ts +++ b/src/renderer/src/utils/formats.ts @@ -88,34 +88,6 @@ export function removeSvgEmptyLines(text: string): string { }) } -// export function withGeminiGrounding(block: MainTextMessageBlock | TranslationMessageBlock): string { -// // TODO -// // const citationBlock = findCitationBlockWithGrounding(block) -// // const groundingSupports = citationBlock?.groundingMetadata?.groundingSupports - -// const content = block.content - -// // if (!groundingSupports || groundingSupports.length === 0) { -// // return content -// // } - -// // groundingSupports.forEach((support) => { -// // const text = support?.segment?.text -// // const indices = support?.groundingChunkIndices - -// // if (!text || !indices) return - -// // const nodes = indices.reduce((acc, index) => { -// // acc.push(`${index + 1}`) -// // return acc -// // }, [] as string[]) - -// // content = content.replace(text, `${text} ${nodes.join(' ')}`) -// // }) - -// return content -// } - export function withGenerateImage(message: Message): { content: string; images?: string[] } { const originalContent = getMainTextContent(message) const imagePattern = new RegExp(`!\\[[^\\]]*\\]\\((.*?)\\s*("(?:.*[^"])")?\\s*\\)`) diff --git a/src/renderer/src/utils/linkConverter.ts b/src/renderer/src/utils/linkConverter.ts index 652c2f4283..220333f027 100644 --- a/src/renderer/src/utils/linkConverter.ts +++ b/src/renderer/src/utils/linkConverter.ts @@ -1,3 +1,5 @@ +import { WebSearchResponse, WebSearchSource } from '@renderer/types' + // Counter for numbering links let linkCounter = 1 // Buffer to hold incomplete link fragments across chunks @@ -236,11 +238,13 @@ export function convertLinks( } // Rule 3: If the link text is not a URL/host, keep the text and add the numbered link - if (!isHost(linkText)) { - result += `${linkText} [${counter}](${url})` - } else { - // Rule 2: If the link text is a URL/host, replace with numbered link + // 增加一个条件:如果 linkText 是纯数字,也直接替换 + if (isHost(linkText) || /^\d+$/.test(linkText)) { + // Rule 2: If the link text is a URL/host or purely digits, replace with numbered link result += `[${counter}](${url})` + } else { + // If the link text is neither a URL/host nor purely digits, keep the text and add the numbered link + result += `${linkText} [${counter}](${url})` } position += match[0].length @@ -337,6 +341,25 @@ export function completeLinks(text: string, webSearch: any[]): string { }) } +/** + * 根据webSearch结果补全链接,将[num]转换为[num](webSearch[num-1].url) + * @param {string} text 原始文本 + * @param {any[]} webSearch webSearch结果 + * @returns {string} 补全后的文本 + */ +export function completionPerplexityLinks(text: string, webSearch: any[]): string { + return text.replace(/\[(\d+)\]/g, (match, numStr) => { + const num = parseInt(numStr) + const index = num - 1 + // 检查 webSearch 数组中是否存在对应的 URL + if (index >= 0 && index < webSearch.length && webSearch[index].url) { + return `[${num}](${webSearch[index].url})` + } + // 如果没有找到对应的 URL,保持原样 + return match + }) +} + /** * 从Markdown文本中提取所有URL * 支持以下格式: @@ -463,8 +486,18 @@ export function extractWebSearchReferences(text: string): Array<{ export function smartLinkConverter( text: string, providerType: string = 'openai', - resetCounter: boolean = false + resetCounter: boolean = false, + webSearchResults?: WebSearchResponse ): { text: string; hasBufferedContent: boolean } { + if (webSearchResults) { + const webSearch = webSearchResults.results + switch (webSearchResults.source) { + case WebSearchSource.PERPLEXITY: { + text = completionPerplexityLinks(text, webSearch as any[]) + break + } + } + } // 检测文本中的引用模式 const references = extractWebSearchReferences(text) From 8cfe6a5848522df1d16bf2feac2ac0793a320572 Mon Sep 17 00:00:00 2001 From: beyondkmp Date: Fri, 4 Jul 2025 17:19:22 +0800 Subject: [PATCH 03/54] feat(settings): add option to disable hardware acceleration (#7811) * feat(settings): add option to disable hardware acceleration - Introduced a new setting to allow users to disable hardware acceleration. - Added corresponding IPC channel and configuration management methods. - Updated UI components to reflect the new setting and prompt for app restart. - Localized confirmation messages for hardware acceleration changes in multiple languages. * fix(settings): add delay before relaunching app after disabling hardware acceleration - Introduced a 500ms delay before the application relaunches to ensure settings are applied correctly. - This change improves user experience by allowing time for the setting to take effect before the app restarts. * fix lint * fix(settings): handle errors when disabling hardware acceleration - Wrapped the hardware acceleration disabling function in a try-catch block to manage potential errors. - Added user feedback through an error message if the operation fails, improving overall robustness. --- packages/shared/IpcChannel.ts | 1 + src/main/index.ts | 8 +++++ src/main/ipc.ts | 4 +++ src/main/services/ConfigManager.ts | 11 +++++- src/preload/index.ts | 4 ++- src/renderer/src/hooks/useSettings.ts | 5 +++ src/renderer/src/i18n/locales/en-us.json | 7 ++++ src/renderer/src/i18n/locales/ja-jp.json | 7 ++++ src/renderer/src/i18n/locales/ru-ru.json | 7 ++++ src/renderer/src/i18n/locales/zh-cn.json | 7 ++++ src/renderer/src/i18n/locales/zh-tw.json | 7 ++++ .../src/pages/settings/GeneralSettings.tsx | 35 ++++++++++++++++++- src/renderer/src/store/migrate.ts | 1 + src/renderer/src/store/settings.ts | 8 +++++ 14 files changed, 109 insertions(+), 3 deletions(-) diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index 7dd60bab06..38c6c2b516 100644 --- a/packages/shared/IpcChannel.ts +++ b/packages/shared/IpcChannel.ts @@ -36,6 +36,7 @@ export enum IpcChannel { App_MacRequestProcessTrust = 'app:mac-request-process-trust', App_QuoteToMain = 'app:quote-to-main', + App_SetDisableHardwareAcceleration = 'app:set-disable-hardware-acceleration', Notification_Send = 'notification:send', Notification_OnClick = 'notification:on-click', diff --git a/src/main/index.ts b/src/main/index.ts index 46ebd7c6e6..e022bb71a8 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -28,6 +28,14 @@ import { windowService } from './services/WindowService' Logger.initialize() +/** + * Disable hardware acceleration if setting is enabled + */ +const disableHardwareAcceleration = configManager.getDisableHardwareAcceleration() +if (disableHardwareAcceleration) { + app.disableHardwareAcceleration() +} + /** * Disable chromium's window animations * main purpose for this is to avoid the transparent window flashing when it is shown diff --git a/src/main/ipc.ts b/src/main/ipc.ts index a9c5169096..f97cb60ed9 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -561,4 +561,8 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { SelectionService.registerIpcHandler() ipcMain.handle(IpcChannel.App_QuoteToMain, (_, text: string) => windowService.quoteToMainWindow(text)) + + ipcMain.handle(IpcChannel.App_SetDisableHardwareAcceleration, (_, isDisable: boolean) => { + configManager.setDisableHardwareAcceleration(isDisable) + }) } diff --git a/src/main/services/ConfigManager.ts b/src/main/services/ConfigManager.ts index 8e4b5d2bf1..a10e7521eb 100644 --- a/src/main/services/ConfigManager.ts +++ b/src/main/services/ConfigManager.ts @@ -24,7 +24,8 @@ export enum ConfigKeys { SelectionAssistantFollowToolbar = 'selectionAssistantFollowToolbar', SelectionAssistantRemeberWinSize = 'selectionAssistantRemeberWinSize', SelectionAssistantFilterMode = 'selectionAssistantFilterMode', - SelectionAssistantFilterList = 'selectionAssistantFilterList' + SelectionAssistantFilterList = 'selectionAssistantFilterList', + DisableHardwareAcceleration = 'disableHardwareAcceleration' } export class ConfigManager { @@ -218,6 +219,14 @@ export class ConfigManager { this.setAndNotify(ConfigKeys.SelectionAssistantFilterList, value) } + getDisableHardwareAcceleration(): boolean { + return this.get(ConfigKeys.DisableHardwareAcceleration, false) + } + + setDisableHardwareAcceleration(value: boolean) { + this.set(ConfigKeys.DisableHardwareAcceleration, value) + } + setAndNotify(key: string, value: unknown) { this.set(key, value, true) } diff --git a/src/preload/index.ts b/src/preload/index.ts index 3120492dde..533263512d 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -290,7 +290,9 @@ const api = { minimizeActionWindow: () => ipcRenderer.invoke(IpcChannel.Selection_ActionWindowMinimize), pinActionWindow: (isPinned: boolean) => ipcRenderer.invoke(IpcChannel.Selection_ActionWindowPin, isPinned) }, - quoteToMainWindow: (text: string) => ipcRenderer.invoke(IpcChannel.App_QuoteToMain, text) + quoteToMainWindow: (text: string) => ipcRenderer.invoke(IpcChannel.App_QuoteToMain, text), + setDisableHardwareAcceleration: (isDisable: boolean) => + ipcRenderer.invoke(IpcChannel.App_SetDisableHardwareAcceleration, isDisable) } // Use `contextBridge` APIs to expose Electron APIs to diff --git a/src/renderer/src/hooks/useSettings.ts b/src/renderer/src/hooks/useSettings.ts index dfb75cc791..43f9e41135 100644 --- a/src/renderer/src/hooks/useSettings.ts +++ b/src/renderer/src/hooks/useSettings.ts @@ -4,6 +4,7 @@ import { SendMessageShortcut, setAssistantIconType, setAutoCheckUpdate as _setAutoCheckUpdate, + setDisableHardwareAcceleration, setLaunchOnBoot, setLaunchToTray, setPinTopicsToTop, @@ -100,6 +101,10 @@ export function useSettings() { }, setShowTokens(showTokens: boolean) { dispatch(setShowTokens(showTokens)) + }, + setDisableHardwareAcceleration(disableHardwareAcceleration: boolean) { + dispatch(setDisableHardwareAcceleration(disableHardwareAcceleration)) + window.api.setDisableHardwareAcceleration(disableHardwareAcceleration) } } } diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index e76ee35701..3ec5747c48 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -1805,6 +1805,13 @@ "title": "Proxy Settings" }, "proxy.title": "Proxy Address", + "hardware_acceleration": { + "title": "Disable Hardware Acceleration", + "confirm": { + "title": "Restart Required", + "content": "Disabling hardware acceleration requires restarting the app to take effect. Do you want to restart now?" + } + }, "quickAssistant": { "click_tray_to_show": "Click the tray icon to start", "enable_quick_assistant": "Enable Quick Assistant", diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index d735329335..c2947a57d8 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -1787,6 +1787,13 @@ "title": "プロキシ設定" }, "proxy.title": "プロキシアドレス", + "hardware_acceleration": { + "title": "ハードウェアアクセラレーションを無効にする", + "confirm": { + "title": "再起動が必要", + "content": "ハードウェアアクセラレーションを無効にするには、アプリを再起動する必要があります。再起動しますか?" + } + }, "quickAssistant": { "click_tray_to_show": "トレイアイコンをクリックして起動", "enable_quick_assistant": "クイックアシスタントを有効にする", diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index fd664fa871..ef329e7747 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -1787,6 +1787,13 @@ "title": "Настройки прокси" }, "proxy.title": "Адрес прокси", + "hardware_acceleration": { + "title": "Отключить аппаратное ускорение", + "confirm": { + "title": "Требуется перезапуск", + "content": "Отключение аппаратного ускорения требует перезапуска приложения для вступления в силу. Перезапустить приложение?" + } + }, "quickAssistant": { "click_tray_to_show": "Нажмите на иконку трея для запуска", "enable_quick_assistant": "Включить быстрый помощник", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index fee1a37288..9504068ced 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -1805,6 +1805,13 @@ "title": "代理设置" }, "proxy.title": "代理地址", + "hardware_acceleration": { + "title": "禁用硬件加速", + "confirm": { + "title": "需要重启应用", + "content": "禁用硬件加速需要重启应用才能生效,是否现在重启?" + } + }, "quickAssistant": { "click_tray_to_show": "点击托盘图标启动", "enable_quick_assistant": "启用快捷助手", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index d16130ef52..503c8b07b1 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -1790,6 +1790,13 @@ "title": "代理伺服器設定" }, "proxy.title": "代理伺服器地址", + "hardware_acceleration": { + "title": "禁用硬件加速", + "confirm": { + "title": "需要重新啟動", + "content": "禁用硬件加速需要重新啟動應用程序才能生效。是否立即重新啟動?" + } + }, "quickAssistant": { "click_tray_to_show": "點選工具列圖示啟動", "enable_quick_assistant": "啟用快捷助手", diff --git a/src/renderer/src/pages/settings/GeneralSettings.tsx b/src/renderer/src/pages/settings/GeneralSettings.tsx index 411fdd4768..9e47317a55 100644 --- a/src/renderer/src/pages/settings/GeneralSettings.tsx +++ b/src/renderer/src/pages/settings/GeneralSettings.tsx @@ -35,7 +35,9 @@ const GeneralSettings: FC = () => { tray, proxyMode: storeProxyMode, enableDataCollection, - enableSpellCheck + enableSpellCheck, + disableHardwareAcceleration, + setDisableHardwareAcceleration } = useSettings() const [proxyUrl, setProxyUrl] = useState(storeProxyUrl) const { theme } = useTheme() @@ -147,6 +149,32 @@ const GeneralSettings: FC = () => { window.api.setSpellCheckLanguages(selectedLanguages) } + const handleHardwareAccelerationChange = (checked: boolean) => { + window.modal.confirm({ + title: t('settings.hardware_acceleration.confirm.title'), + content: t('settings.hardware_acceleration.confirm.content'), + okText: t('common.confirm'), + cancelText: t('common.cancel'), + centered: true, + onOk() { + try { + setDisableHardwareAcceleration(checked) + } catch (error) { + window.message.error({ + content: (error as Error).message, + key: 'disable-hardware-acceleration-error' + }) + return + } + + // 重启应用 + setTimeout(() => { + window.api.relaunchApp() + }, 500) + } + }) + } + return ( @@ -223,6 +251,11 @@ const GeneralSettings: FC = () => { )} + + + {t('settings.hardware_acceleration.title')} + + {t('settings.notification.title')} diff --git a/src/renderer/src/store/migrate.ts b/src/renderer/src/store/migrate.ts index 935da99054..1e29aebd69 100644 --- a/src/renderer/src/store/migrate.ts +++ b/src/renderer/src/store/migrate.ts @@ -1721,6 +1721,7 @@ const migrateConfig = { try { addProvider(state, 'new-api') state.llm.providers = moveProvider(state.llm.providers, 'new-api', 16) + state.settings.disableHardwareAcceleration = false return state } catch (error) { return state diff --git a/src/renderer/src/store/settings.ts b/src/renderer/src/store/settings.ts index 01ca1757f4..778837e388 100644 --- a/src/renderer/src/store/settings.ts +++ b/src/renderer/src/store/settings.ts @@ -163,6 +163,8 @@ export interface SettingsState { spellCheckLanguages: string[] enableQuickPanelTriggers: boolean enableBackspaceDeleteModel: boolean + // 硬件加速设置 + disableHardwareAcceleration: boolean exportMenuOptions: { image: boolean markdown: boolean @@ -310,6 +312,8 @@ export const initialState: SettingsState = { spellCheckLanguages: [], enableQuickPanelTriggers: false, enableBackspaceDeleteModel: true, + // 硬件加速设置 + disableHardwareAcceleration: false, exportMenuOptions: { image: true, markdown: true, @@ -685,6 +689,9 @@ const settingsSlice = createSlice({ setEnableBackspaceDeleteModel: (state, action: PayloadAction) => { state.enableBackspaceDeleteModel = action.payload }, + setDisableHardwareAcceleration: (state, action: PayloadAction) => { + state.disableHardwareAcceleration = action.payload + }, setOpenAISummaryText: (state, action: PayloadAction) => { state.openAI.summaryText = action.payload }, @@ -801,6 +808,7 @@ export const { setExportMenuOptions, setEnableQuickPanelTriggers, setEnableBackspaceDeleteModel, + setDisableHardwareAcceleration, setOpenAISummaryText, setOpenAIServiceTier, setNotificationSettings, From e5d94d9a538ed13548aeee95339b75507bc20e45 Mon Sep 17 00:00:00 2001 From: Chen Tao <70054568+eeee0717@users.noreply.github.com> Date: Fri, 4 Jul 2025 17:47:52 +0800 Subject: [PATCH 04/54] fix(MinerU): remove check quota (#7804) fix: remove check quota --- src/main/preprocess/MineruPreprocessProvider.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/main/preprocess/MineruPreprocessProvider.ts b/src/main/preprocess/MineruPreprocessProvider.ts index a0a9c65417..58c0c00c23 100644 --- a/src/main/preprocess/MineruPreprocessProvider.ts +++ b/src/main/preprocess/MineruPreprocessProvider.ts @@ -111,7 +111,6 @@ export default class MineruPreprocessProvider extends BasePreprocessProvider { } private async validateFile(filePath: string): Promise { - const quota = await this.checkQuota() const pdfBuffer = await fs.promises.readFile(filePath) const doc = await this.readPdf(new Uint8Array(pdfBuffer)) @@ -125,10 +124,6 @@ export default class MineruPreprocessProvider extends BasePreprocessProvider { const fileSizeMB = Math.round(pdfBuffer.length / (1024 * 1024)) throw new Error(`PDF file size (${fileSizeMB}MB) exceeds the limit of 200MB`) } - // 检查配额 - if (quota <= 0 || quota - doc.numPages <= 0) { - throw new Error('MinerU解析配额不足,请申请企业账户或自行部署,剩余额度:' + quota) - } } private createProcessedFileInfo(file: FileMetadata, outputPath: string): FileMetadata { From 2a48babd50b53e0c64e7264807624534ccec6623 Mon Sep 17 00:00:00 2001 From: one Date: Fri, 4 Jul 2025 23:50:42 +0800 Subject: [PATCH 05/54] fix: update websearch i18n, allow more search results (#7797) --- src/renderer/src/i18n/locales/en-us.json | 3 +-- src/renderer/src/i18n/locales/ja-jp.json | 3 +-- src/renderer/src/i18n/locales/ru-ru.json | 3 +-- src/renderer/src/i18n/locales/zh-cn.json | 3 +-- src/renderer/src/i18n/locales/zh-tw.json | 3 +-- .../ProviderSettings/GithubCopilotSettings.tsx | 2 +- .../WebSearchSettings/BasicSettings.tsx | 16 ++++++++++++---- .../CompressionSettings/RagSettings.tsx | 6 +++--- src/renderer/src/services/WebSearchService.ts | 2 +- 9 files changed, 22 insertions(+), 19 deletions(-) diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 3ec5747c48..f2e9def398 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -1896,7 +1896,7 @@ "search_max_result": "Number of search results", "search_provider": "Search service provider", "search_provider_placeholder": "Choose a search service provider.", - "search_result_default": "Default", + "search_max_result.tooltip": "When search result compression is disabled, the number of results may be too large, which may lead to insufficient tokens", "search_with_time": "Search with dates included", "tavily": { "api_key": "Tavily API Key", @@ -1931,7 +1931,6 @@ "cutoff.unit.token": "Token", "method.rag": "RAG", "rag.document_count": "Document Count", - "rag.document_count.default": "Default", "rag.document_count.tooltip": "Expected number of documents to extract from each search result, the actual total number of extracted documents is this value multiplied by the number of search results.", "rag.embedding_dimensions.auto_get": "Auto Get Dimensions", "rag.embedding_dimensions.placeholder": "Leave empty", diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index c2947a57d8..e0776e5887 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -1851,7 +1851,7 @@ "search_max_result": "検索結果の数", "search_provider": "検索サービスプロバイダー", "search_provider_placeholder": "検索サービスプロバイダーを選択する", - "search_result_default": "デフォルト", + "search_max_result.tooltip": "検索結果の圧縮が無効な場合、結果の数が多すぎるとトークンが不足する可能性があります", "search_with_time": "日付を含む検索", "tavily": { "api_key": "Tavily API キー", @@ -1886,7 +1886,6 @@ "cutoff.unit.token": "トークン", "method.rag": "RAG", "rag.document_count": "文書数", - "rag.document_count.default": "デフォルト", "rag.document_count.tooltip": "単一の検索結果から抽出する文書数。実際に抽出される文書数は、この値に検索結果数を乗じたものです。", "rag.embedding_dimensions.auto_get": "次元を自動取得", "rag.embedding_dimensions.placeholder": "次元を設定しない", diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index ef329e7747..6a835f61b7 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -1849,9 +1849,9 @@ "get_api_key": "Получить ключ API", "no_provider_selected": "Пожалуйста, выберите поставщика поисковых услуг, затем проверьте.", "search_max_result": "Количество результатов поиска", + "search_max_result.tooltip": "При отключенном сжатии результатов поиска, количество результатов может быть слишком большим, что приведет к исчерпанию токенов", "search_provider": "поиск сервисного провайдера", "search_provider_placeholder": "Выберите поставщика поисковых услуг", - "search_result_default": "По умолчанию", "search_with_time": "Поиск, содержащий дату", "tavily": { "api_key": "Ключ API Tavily", @@ -1886,7 +1886,6 @@ "cutoff.unit.token": "Токены", "method.rag": "RAG", "rag.document_count": "Количество документов", - "rag.document_count.default": "По умолчанию", "rag.document_count.tooltip": "Ожидаемое количество документов, которые будут извлечены из каждого результата поиска. Фактическое количество извлеченных документов равно этому значению, умноженному на количество результатов поиска.", "rag.embedding_dimensions.auto_get": "Автоматически получить размерности", "rag.embedding_dimensions.placeholder": "Не устанавливать размерности", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 9504068ced..db5dd86d5f 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -1941,6 +1941,7 @@ "get_api_key": "点击这里获取密钥", "no_provider_selected": "请选择搜索服务商后再检测", "search_max_result": "搜索结果个数", + "search_max_result.tooltip": "未开启搜索结果压缩的情况下,数量过大可能会消耗过多 tokens", "search_provider": "搜索服务商", "search_provider_placeholder": "选择一个搜索服务商", "subscribe": "黑名单订阅", @@ -1951,7 +1952,6 @@ "subscribe_name.placeholder": "当下载的订阅源没有名称时所使用的替代名称", "subscribe_add_success": "订阅源添加成功!", "subscribe_delete": "删除订阅源", - "search_result_default": "默认", "search_with_time": "搜索包含日期", "tavily": { "api_key": "Tavily API 密钥", @@ -1976,7 +1976,6 @@ "cutoff.unit.token": "Token", "method.rag": "RAG", "rag.document_count": "文档数量", - "rag.document_count.default": "默认", "rag.document_count.tooltip": "预期从单个搜索结果中提取的文档数量,实际提取的总数量是这个值乘以搜索结果数量。", "rag.embedding_dimensions.auto_get": "自动获取维度", "rag.embedding_dimensions.placeholder": "不设置维度", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 503c8b07b1..743814c0cd 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -1852,9 +1852,9 @@ "get_api_key": "點選這裡取得金鑰", "no_provider_selected": "請選擇搜尋服務商後再檢查", "search_max_result": "搜尋結果個數", + "search_max_result.tooltip": "未開啟搜尋結果壓縮的情況下,數量過大可能會消耗過多 tokens", "search_provider": "搜尋服務商", "search_provider_placeholder": "選擇一個搜尋服務商", - "search_result_default": "預設", "search_with_time": "搜尋包含日期", "tavily": { "api_key": "Tavily API 金鑰", @@ -1889,7 +1889,6 @@ "cutoff.unit.token": "Token", "method.rag": "RAG", "rag.document_count": "文檔數量", - "rag.document_count.default": "預設", "rag.document_count.tooltip": "預期從單個搜尋結果中提取的文檔數量,實際提取的總數量是這個值乘以搜尋結果數量。", "rag.embedding_dimensions.auto_get": "自動獲取維度", "rag.embedding_dimensions.placeholder": "不設置維度", diff --git a/src/renderer/src/pages/settings/ProviderSettings/GithubCopilotSettings.tsx b/src/renderer/src/pages/settings/ProviderSettings/GithubCopilotSettings.tsx index 356ddfd2fb..f91f09848a 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/GithubCopilotSettings.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/GithubCopilotSettings.tsx @@ -252,7 +252,7 @@ const GithubCopilotSettings: FC = ({ provider: initi min={1} max={60} step={1} - marks={{ 1: '1', 10: t('settings.tool.websearch.search_result_default'), 60: '60' }} + marks={{ 1: '1', 10: t('common.default'), 60: '60' }} onChangeComplete={(value) => updateProvider({ ...provider, rateLimit: value })} /> diff --git a/src/renderer/src/pages/settings/ToolSettings/WebSearchSettings/BasicSettings.tsx b/src/renderer/src/pages/settings/ToolSettings/WebSearchSettings/BasicSettings.tsx index bd68db34fd..ea2690f789 100644 --- a/src/renderer/src/pages/settings/ToolSettings/WebSearchSettings/BasicSettings.tsx +++ b/src/renderer/src/pages/settings/ToolSettings/WebSearchSettings/BasicSettings.tsx @@ -2,8 +2,9 @@ import { useTheme } from '@renderer/context/ThemeProvider' import { useWebSearchSettings } from '@renderer/hooks/useWebSearchProviders' import { useAppDispatch } from '@renderer/store' import { setMaxResult, setSearchWithTime } from '@renderer/store/websearch' -import { Slider, Switch } from 'antd' +import { Slider, Switch, Tooltip } from 'antd' import { t } from 'i18next' +import { Info } from 'lucide-react' import { FC } from 'react' import { SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '../..' @@ -25,14 +26,21 @@ const BasicSettings: FC = () => { - {t('settings.tool.websearch.search_max_result')} + + {t('settings.tool.websearch.search_max_result')} + {maxResults > 20 && ( + + + + )} + dispatch(setMaxResult(value))} /> diff --git a/src/renderer/src/pages/settings/ToolSettings/WebSearchSettings/CompressionSettings/RagSettings.tsx b/src/renderer/src/pages/settings/ToolSettings/WebSearchSettings/CompressionSettings/RagSettings.tsx index 8df4f14a5a..f5169889b8 100644 --- a/src/renderer/src/pages/settings/ToolSettings/WebSearchSettings/CompressionSettings/RagSettings.tsx +++ b/src/renderer/src/pages/settings/ToolSettings/WebSearchSettings/CompressionSettings/RagSettings.tsx @@ -138,7 +138,7 @@ const RagSettings = () => { {t('models.embedding_dimensions')} - + @@ -179,7 +179,7 @@ const RagSettings = () => { {t('settings.tool.websearch.compression.rag.document_count')} - + @@ -191,7 +191,7 @@ const RagSettings = () => { step={1} onChange={handleDocumentCountChange} marks={{ - 1: t('settings.tool.websearch.compression.rag.document_count.default'), + 1: t('common.default'), 3: '3', 10: '10' }} diff --git a/src/renderer/src/services/WebSearchService.ts b/src/renderer/src/services/WebSearchService.ts index efb726a0aa..66157a062f 100644 --- a/src/renderer/src/services/WebSearchService.ts +++ b/src/renderer/src/services/WebSearchService.ts @@ -510,7 +510,7 @@ class WebSearchService { window.message.error({ key: 'websearch-rag-failed', duration: 10, - content: `${i18n.t('settings.websearch.compression.error.rag_failed')}: ${formatErrorMessage(error)}` + content: `${i18n.t('settings.tool.websearch.compression.error.rag_failed')}: ${formatErrorMessage(error)}` }) finalResults = [] From f8c221f51a69ed25018d6dad233bff92ee1210ba Mon Sep 17 00:00:00 2001 From: one Date: Fri, 4 Jul 2025 23:55:31 +0800 Subject: [PATCH 06/54] fix(CodePreview): line height rounding (#7835) --- src/renderer/src/assets/styles/markdown.scss | 2 ++ .../src/components/CodeBlockView/CodePreview.tsx | 9 +++++---- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/assets/styles/markdown.scss b/src/renderer/src/assets/styles/markdown.scss index 2cb60e6bb4..d497c6e793 100644 --- a/src/renderer/src/assets/styles/markdown.scss +++ b/src/renderer/src/assets/styles/markdown.scss @@ -326,6 +326,8 @@ mjx-container { /* Shiki 相关样式 */ .shiki { font-family: var(--code-font-family); + // 保持行高为初始值,在 shiki 代码块中处理 + line-height: initial; } /* CodeMirror 相关样式 */ diff --git a/src/renderer/src/components/CodeBlockView/CodePreview.tsx b/src/renderer/src/components/CodeBlockView/CodePreview.tsx index 106d03d21f..3a95a68cd8 100644 --- a/src/renderer/src/components/CodeBlockView/CodePreview.tsx +++ b/src/renderer/src/components/CodeBlockView/CodePreview.tsx @@ -107,7 +107,8 @@ const CodePreview = ({ children, language, setTools }: CodePreviewProps) => { // Virtualizer 配置 const getScrollElement = useCallback(() => scrollerRef.current, []) const getItemKey = useCallback((index: number) => `${callerId}-${index}`, [callerId]) - const estimateSize = useCallback(() => (fontSize - 1) * 1.6, [fontSize]) // 同步全局样式 + // `line-height: 1.6` 为全局样式,但是为了避免测量误差在这里取整 + const estimateSize = useCallback(() => Math.round((fontSize - 1) * 1.6), [fontSize]) // 创建 virtualizer 实例 const virtualizer = useVirtualizer({ @@ -144,6 +145,7 @@ const CodePreview = ({ children, language, setTools }: CodePreviewProps) => { ref={scrollerRef} className="shiki-scroller" $wrap={shouldWrap} + $lineHeight={estimateSize()} style={ { '--gutter-width': `${gutterDigits}ch`, @@ -229,18 +231,19 @@ VirtualizedRow.displayName = 'VirtualizedRow' const ScrollContainer = styled.div<{ $wrap?: boolean + $lineHeight?: number }>` display: block; overflow: auto; position: relative; border-radius: inherit; - height: auto; padding: 0.5em 1em; .line { display: flex; align-items: flex-start; width: 100%; + line-height: ${(props) => props.$lineHeight}px; .line-number { width: var(--gutter-width, 1.2ch); @@ -250,14 +253,12 @@ const ScrollContainer = styled.div<{ user-select: none; flex-shrink: 0; overflow: hidden; - line-height: inherit; font-family: inherit; font-variant-numeric: tabular-nums; } .line-content { flex: 1; - line-height: inherit; * { white-space: ${(props) => (props.$wrap ? 'pre-wrap' : 'pre')}; overflow-wrap: ${(props) => (props.$wrap ? 'break-word' : 'normal')}; From ee4c4b16ec492a2339dd1c779754c4b21e69d6e9 Mon Sep 17 00:00:00 2001 From: Konv Suu <2583695112@qq.com> Date: Fri, 4 Jul 2025 23:56:22 +0800 Subject: [PATCH 07/54] fix(message-group): revert grid layout to use min-width (#7830) --- src/renderer/src/pages/home/Messages/MessageGroup.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/src/pages/home/Messages/MessageGroup.tsx b/src/renderer/src/pages/home/Messages/MessageGroup.tsx index 374b40d463..13cc692b93 100644 --- a/src/renderer/src/pages/home/Messages/MessageGroup.tsx +++ b/src/renderer/src/pages/home/Messages/MessageGroup.tsx @@ -265,7 +265,7 @@ const GridContainer = styled(Scrollbar)<{ $count: number; $gridColumns: number } gap: 16px; &.horizontal { padding-bottom: 4px; - grid-template-columns: repeat(${({ $count }) => $count}, minmax(0, 1fr)); + grid-template-columns: repeat(${({ $count }) => $count}, minmax(420px, 1fr)); overflow-x: auto; } &.fold, From a924da10c20e9948824746d1405e9937451cbe68 Mon Sep 17 00:00:00 2001 From: beyondkmp Date: Sat, 5 Jul 2025 00:13:22 +0800 Subject: [PATCH 08/54] fix(WindowService): update default window dimensions to improve user experience (#7789) - Changed the default width from 1080 to 960 and height from 670 to 600 for the main window. - Adjusted minimum width and height settings to match the new defaults, enhancing compatibility with various screen sizes. --- src/main/services/WindowService.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/services/WindowService.ts b/src/main/services/WindowService.ts index 65132eb54f..7c4f13a254 100644 --- a/src/main/services/WindowService.ts +++ b/src/main/services/WindowService.ts @@ -41,8 +41,8 @@ export class WindowService { } const mainWindowState = windowStateKeeper({ - defaultWidth: 1080, - defaultHeight: 670, + defaultWidth: 960, + defaultHeight: 600, fullScreen: false, maximize: false }) @@ -52,7 +52,7 @@ export class WindowService { y: mainWindowState.y, width: mainWindowState.width, height: mainWindowState.height, - minWidth: 1080, + minWidth: 960, minHeight: 600, show: false, autoHideMenuBar: true, From 619aadce41c4711cb1b5336d34fbf9c4afdca47b Mon Sep 17 00:00:00 2001 From: SuYao Date: Sat, 5 Jul 2025 13:25:19 +0800 Subject: [PATCH 09/54] fix(models): update glm-4 model regex for improved matching (#7793) - Changed the glm-4 model entry to use a regex pattern for better flexibility in version matching, allowing for optional version numbers and suffixes. --- 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 e185072e6b..bebc6cc0c7 100644 --- a/src/renderer/src/config/models.ts +++ b/src/renderer/src/config/models.ts @@ -164,7 +164,7 @@ const visionAllowedModels = [ 'claude-sonnet-4', 'claude-opus-4', 'vision', - 'glm-4v', + 'glm-4(?:\\.\\d+)?v(?:-[\\w-]+)?', 'qwen-vl', 'qwen2-vl', 'qwen2.5-vl', From 19e9ba773f216d1ed6e5b82062f2d36ff7a57042 Mon Sep 17 00:00:00 2001 From: Jason Young <44939412+farion1231@users.noreply.github.com> Date: Sat, 5 Jul 2025 13:28:33 +0800 Subject: [PATCH 10/54] test: add comprehensive tests for CopyIcon and MinAppIcon components (#7833) * test: add comprehensive tests for CopyIcon and MinAppIcon components - Add tests for CopyIcon covering default rendering, className merging, and prop passing - Add tests for MinAppIcon covering default props, custom size, sidebar mode, styles, and edge cases - Include snapshot tests for both components * fix: update test snapshots after component styling changes Update snapshots for CopyIcon and MinAppIcon components to match current styled-components implementation (replaces inline styles with generated classes). * refactor: simplify icon component tests based on PR review feedback - CopyIcon: replace multiple redundant tests with single snapshot test - MinAppIcon: remove duplicate test that overlaps with snapshot test - Keep essential business logic tests for MinAppIcon (sidebar behavior, null return) - Update test snapshots accordingly --- .../Icons/__tests__/CopyIcon.test.tsx | 15 +++++ .../Icons/__tests__/MinAppIcon.test.tsx | 65 +++++++++++++++++++ .../__snapshots__/CopyIcon.test.tsx.snap | 9 +++ .../__snapshots__/MinAppIcon.test.tsx.snap | 15 +++++ 4 files changed, 104 insertions(+) create mode 100644 src/renderer/src/components/Icons/__tests__/CopyIcon.test.tsx create mode 100644 src/renderer/src/components/Icons/__tests__/MinAppIcon.test.tsx create mode 100644 src/renderer/src/components/Icons/__tests__/__snapshots__/CopyIcon.test.tsx.snap create mode 100644 src/renderer/src/components/Icons/__tests__/__snapshots__/MinAppIcon.test.tsx.snap diff --git a/src/renderer/src/components/Icons/__tests__/CopyIcon.test.tsx b/src/renderer/src/components/Icons/__tests__/CopyIcon.test.tsx new file mode 100644 index 0000000000..e94967a12a --- /dev/null +++ b/src/renderer/src/components/Icons/__tests__/CopyIcon.test.tsx @@ -0,0 +1,15 @@ +import { render } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' + +import CopyIcon from '../CopyIcon' + +describe('CopyIcon', () => { + it('should match snapshot with props and className', () => { + const onClick = vi.fn() + const { container } = render( + + ) + + expect(container.firstChild).toMatchSnapshot() + }) +}) diff --git a/src/renderer/src/components/Icons/__tests__/MinAppIcon.test.tsx b/src/renderer/src/components/Icons/__tests__/MinAppIcon.test.tsx new file mode 100644 index 0000000000..a06c19a840 --- /dev/null +++ b/src/renderer/src/components/Icons/__tests__/MinAppIcon.test.tsx @@ -0,0 +1,65 @@ +import { render } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' + +import MinAppIcon from '../MinAppIcon' + +vi.mock('@renderer/config/minapps', () => ({ + DEFAULT_MIN_APPS: [ + { + id: 'test-app-1', + name: 'Test App 1', + logo: '/test-logo-1.png', + url: 'https://test1.com', + bodered: true, + background: '#f0f0f0' + }, + { + id: 'test-app-2', + name: 'Test App 2', + logo: '/test-logo-2.png', + url: 'https://test2.com', + bodered: false, + background: undefined + } + ] +})) + +describe('MinAppIcon', () => { + const mockApp = { + id: 'test-app-1', + name: 'Test App', + url: 'https://test.com', + style: { + opacity: 0.8, + transform: 'scale(1.1)' + } + } + + it('should render correctly with various props', () => { + const customStyle = { marginTop: '10px' } + const { container } = render() + + expect(container.firstChild).toMatchSnapshot() + }) + + it('should not apply app.style when sidebar is true', () => { + const { container } = render() + const img = container.querySelector('img') + + expect(img).not.toHaveStyle({ + opacity: '0.8', + transform: 'scale(1.1)' + }) + }) + + it('should return null when app is not found in DEFAULT_MIN_APPS', () => { + const unknownApp = { + id: 'unknown-app', + name: 'Unknown App', + url: 'https://unknown.com' + } + const { container } = render() + + expect(container.firstChild).toBeNull() + }) +}) diff --git a/src/renderer/src/components/Icons/__tests__/__snapshots__/CopyIcon.test.tsx.snap b/src/renderer/src/components/Icons/__tests__/__snapshots__/CopyIcon.test.tsx.snap new file mode 100644 index 0000000000..d333ca4578 --- /dev/null +++ b/src/renderer/src/components/Icons/__tests__/__snapshots__/CopyIcon.test.tsx.snap @@ -0,0 +1,9 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`CopyIcon > should match snapshot with props and className 1`] = ` + +`; diff --git a/src/renderer/src/components/Icons/__tests__/__snapshots__/MinAppIcon.test.tsx.snap b/src/renderer/src/components/Icons/__tests__/__snapshots__/MinAppIcon.test.tsx.snap new file mode 100644 index 0000000000..e41515fed6 --- /dev/null +++ b/src/renderer/src/components/Icons/__tests__/__snapshots__/MinAppIcon.test.tsx.snap @@ -0,0 +1,15 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`MinAppIcon > should render correctly with various props 1`] = ` +.c0 { + border-radius: 16px; + user-select: none; + -webkit-user-drag: none; +} + + +`; From 1ebf546b70655e5fd70d823aef0074d63d51a265 Mon Sep 17 00:00:00 2001 From: one Date: Sat, 5 Jul 2025 15:08:02 +0800 Subject: [PATCH 11/54] chore: fix vite warning on dynamic imports (#7852) --- src/renderer/src/components/CodeEditor/hook.ts | 14 ++------------ src/renderer/src/hooks/useMinappPopup.ts | 11 +++++------ src/renderer/src/hooks/useTopic.ts | 3 +-- src/renderer/src/services/WebSearchService.ts | 4 +--- 4 files changed, 9 insertions(+), 23 deletions(-) diff --git a/src/renderer/src/components/CodeEditor/hook.ts b/src/renderer/src/components/CodeEditor/hook.ts index c5bbab2d0d..7e3bd28327 100644 --- a/src/renderer/src/components/CodeEditor/hook.ts +++ b/src/renderer/src/components/CodeEditor/hook.ts @@ -1,22 +1,12 @@ +import { linter } from '@codemirror/lint' // statically imported by @uiw/codemirror-extensions-basic-setup import { useCodeStyle } from '@renderer/context/CodeStyleProvider' import { Extension } from '@uiw/react-codemirror' import { useEffect, useState } from 'react' -let linterPromise: Promise | null = null -function importLintPackage() { - if (!linterPromise) { - linterPromise = import('@codemirror/lint').then((mod) => mod.linter) - } - return linterPromise -} - // 语言对应的 linter 加载器 const linterLoaders: Record Promise> = { json: async () => { - const [linter, jsonParseLinter] = await Promise.all([ - importLintPackage(), - import('@codemirror/lang-json').then((mod) => mod.jsonParseLinter) - ]) + const jsonParseLinter = await import('@codemirror/lang-json').then((mod) => mod.jsonParseLinter) return linter(jsonParseLinter()) } } diff --git a/src/renderer/src/hooks/useMinappPopup.ts b/src/renderer/src/hooks/useMinappPopup.ts index 578246cc06..010136ae2a 100644 --- a/src/renderer/src/hooks/useMinappPopup.ts +++ b/src/renderer/src/hooks/useMinappPopup.ts @@ -1,3 +1,4 @@ +import { DEFAULT_MIN_APPS } from '@renderer/config/minapps' import { useRuntime } from '@renderer/hooks/useRuntime' import { useSettings } from '@renderer/hooks/useSettings' // 使用设置中的值 import { useAppDispatch } from '@renderer/store' @@ -74,12 +75,10 @@ export const useMinappPopup = () => { /** Open a minapp by id (look up the minapp in DEFAULT_MIN_APPS) */ const openMinappById = useCallback( (id: string, keepAlive: boolean = false) => { - import('@renderer/config/minapps').then(({ DEFAULT_MIN_APPS }) => { - const app = DEFAULT_MIN_APPS.find((app) => app?.id === id) - if (app) { - openMinapp(app, keepAlive) - } - }) + const app = DEFAULT_MIN_APPS.find((app) => app?.id === id) + if (app) { + openMinapp(app, keepAlive) + } }, [openMinapp] ) diff --git a/src/renderer/src/hooks/useTopic.ts b/src/renderer/src/hooks/useTopic.ts index c6913bd7eb..e6fea6f69a 100644 --- a/src/renderer/src/hooks/useTopic.ts +++ b/src/renderer/src/hooks/useTopic.ts @@ -1,5 +1,6 @@ import db from '@renderer/databases' import i18n from '@renderer/i18n' +import { fetchMessagesSummary } from '@renderer/services/ApiService' import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' import { deleteMessageFiles } from '@renderer/services/MessagesService' import store from '@renderer/store' @@ -133,8 +134,6 @@ export const autoRenameTopic = async (assistant: Assistant, topicId: string) => if (topic && topic.name === i18n.t('chat.default.topic.name') && topic.messages.length >= 2) { try { startTopicRenaming(topicId) - - const { fetchMessagesSummary } = await import('@renderer/services/ApiService') const summaryText = await fetchMessagesSummary({ messages: topic.messages, assistant }) if (summaryText) { const data = { ...topic, name: summaryText } diff --git a/src/renderer/src/services/WebSearchService.ts b/src/renderer/src/services/WebSearchService.ts index 66157a062f..9f6e367ddc 100644 --- a/src/renderer/src/services/WebSearchService.ts +++ b/src/renderer/src/services/WebSearchService.ts @@ -22,6 +22,7 @@ import { fetchWebContents } from '@renderer/utils/fetch' import { consolidateReferencesByUrl, selectReferences } from '@renderer/utils/websearch' import dayjs from 'dayjs' import { LRUCache } from 'lru-cache' +import { sliceByTokens } from 'tokenx' import { getKnowledgeBaseParams } from './KnowledgeService' import { getKnowledgeSourceUrl, searchKnowledgeBase } from './KnowledgeService' @@ -384,9 +385,6 @@ class WebSearchService { 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 截断 From a567666c79346457c7a8c66b96f2d44cc8b4550d Mon Sep 17 00:00:00 2001 From: fullex <106392080+0xfullex@users.noreply.github.com> Date: Sat, 5 Jul 2025 17:19:25 +0800 Subject: [PATCH 12/54] docs: add `testplan` md (#7854) --- CONTRIBUTING.md | 6 ++- README.md | 2 +- docs/CONTRIBUTING.zh.md | 12 +++-- docs/README.zh.md | 2 +- docs/branching-strategy-en.md | 2 + docs/branching-strategy-zh.md | 2 + docs/testplan-en.md | 99 +++++++++++++++++++++++++++++++++++ docs/testplan-zh.md | 99 +++++++++++++++++++++++++++++++++++ 8 files changed, 217 insertions(+), 7 deletions(-) create mode 100644 docs/testplan-en.md create mode 100644 docs/testplan-zh.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9338bc035d..408057252b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,4 +1,4 @@ -[中文](./docs/CONTRIBUTING.zh.md) | [English](./CONTRIBUTING.md) +[中文](docs/CONTRIBUTING.zh.md) | [English](CONTRIBUTING.md) # Cherry Studio Contributor Guide @@ -58,6 +58,10 @@ git commit --signoff -m "Your commit message" Maintainers are here to help you implement your use case within a reasonable timeframe. They will do their best to review your code and provide constructive feedback promptly. However, if you get stuck during the review process or feel your Pull Request is not receiving the attention it deserves, please contact us via comments in the Issue or through the [Community](README.md#-community). +### Participating in the Test Plan + +The Test Plan aims to provide users with a more stable application experience and faster iteration speed. For details, please refer to the [Test Plan](docs/testplan-en.md). + ### Other Suggestions - **Contact Developers**: Before submitting a PR, you can contact the developers first to discuss or get help. diff --git a/README.md b/README.md index 097f0b5756..ee33521ede 100644 --- a/README.md +++ b/README.md @@ -182,7 +182,7 @@ Refer to the [Branching Strategy](docs/branching-strategy-en.md) for contributio 3. **Submit Changes**: Commit and push your changes. 4. **Open a Pull Request**: Describe your changes and reasons. -For more detailed guidelines, please refer to our [Contributing Guide](./CONTRIBUTING.md). +For more detailed guidelines, please refer to our [Contributing Guide](CONTRIBUTING.md). Thank you for your support and contributions! diff --git a/docs/CONTRIBUTING.zh.md b/docs/CONTRIBUTING.zh.md index 30b1983d18..7574990cd4 100644 --- a/docs/CONTRIBUTING.zh.md +++ b/docs/CONTRIBUTING.zh.md @@ -1,6 +1,6 @@ # Cherry Studio 贡献者指南 -[**English**](../CONTRIBUTING.md) | [**中文**](./CONTRIBUTING.zh.md) +[**English**](../CONTRIBUTING.md) | [**中文**](CONTRIBUTING.zh.md) 欢迎来到 Cherry Studio 的贡献者社区!我们致力于将 Cherry Studio 打造成一个长期提供价值的项目,并希望邀请更多的开发者加入我们的行列。无论您是经验丰富的开发者还是刚刚起步的初学者,您的贡献都将帮助我们更好地服务用户,提升软件质量。 @@ -24,7 +24,7 @@ ## 开始之前 -请确保阅读了[行为准则](CODE_OF_CONDUCT.md)和[LICENSE](LICENSE)。 +请确保阅读了[行为准则](../CODE_OF_CONDUCT.md)和[LICENSE](../LICENSE)。 ## 开始贡献 @@ -32,7 +32,7 @@ ### 测试 -未经测试的功能等同于不存在。为确保代码真正有效,应通过单元测试和功能测试覆盖相关流程。因此,在考虑贡献时,也请考虑可测试性。所有测试均可本地运行,无需依赖 CI。请参阅[开发者指南](docs/dev.md#test)中的“Test”部分。 +未经测试的功能等同于不存在。为确保代码真正有效,应通过单元测试和功能测试覆盖相关流程。因此,在考虑贡献时,也请考虑可测试性。所有测试均可本地运行,无需依赖 CI。请参阅[开发者指南](dev.md#test)中的“Test”部分。 ### 拉取请求的自动化测试 @@ -60,7 +60,11 @@ git commit --signoff -m "Your commit message" ### 获取代码审查/合并 -维护者在此帮助您在合理时间内实现您的用例。他们会尽力在合理时间内审查您的代码并提供建设性反馈。但如果您在审查过程中受阻,或认为您的 Pull Request 未得到应有的关注,请通过 Issue 中的评论或者[社群](README.md#-community)联系我们 +维护者在此帮助您在合理时间内实现您的用例。他们会尽力在合理时间内审查您的代码并提供建设性反馈。但如果您在审查过程中受阻,或认为您的 Pull Request 未得到应有的关注,请通过 Issue 中的评论或者[社群](README.zh.md#-community)联系我们 + +### 参与测试计划 + +测试计划旨在为用户提供更稳定的应用体验和更快的迭代速度,详细情况请参阅[测试计划](testplan-zh.md)。 ### 其他建议 diff --git a/docs/README.zh.md b/docs/README.zh.md index 7c1cccaa1f..774db66627 100644 --- a/docs/README.zh.md +++ b/docs/README.zh.md @@ -190,7 +190,7 @@ https://docs.cherry-ai.com 3. **提交更改**:提交并推送您的更改 4. **打开 Pull Request**:描述您的更改和原因 -有关更详细的指南,请参阅我们的 [贡献指南](./CONTRIBUTING.zh.md) +有关更详细的指南,请参阅我们的 [贡献指南](CONTRIBUTING.zh.md) 感谢您的支持和贡献! diff --git a/docs/branching-strategy-en.md b/docs/branching-strategy-en.md index f3b7ddf508..8e646249ad 100644 --- a/docs/branching-strategy-en.md +++ b/docs/branching-strategy-en.md @@ -16,6 +16,8 @@ Cherry Studio implements a structured branching strategy to maintain code qualit - Only accepts documentation updates and bug fixes - Thoroughly tested before production deployment +For details about the `testplan` branch used in the Test Plan, please refer to the [Test Plan](testplan-en.md). + ## Contributing Branches When contributing to Cherry Studio, please follow these guidelines: diff --git a/docs/branching-strategy-zh.md b/docs/branching-strategy-zh.md index b1379537a5..36b7ca263d 100644 --- a/docs/branching-strategy-zh.md +++ b/docs/branching-strategy-zh.md @@ -16,6 +16,8 @@ Cherry Studio 采用结构化的分支策略来维护代码质量并简化开发 - 只接受文档更新和 bug 修复 - 经过完整测试后可以发布到生产环境 +关于测试计划所使用的`testplan`分支,请查阅[测试计划](testplan-zh.md)。 + ## 贡献分支 在为 Cherry Studio 贡献代码时,请遵循以下准则: diff --git a/docs/testplan-en.md b/docs/testplan-en.md new file mode 100644 index 0000000000..0f7cd41473 --- /dev/null +++ b/docs/testplan-en.md @@ -0,0 +1,99 @@ +# Test Plan + +To provide users with a more stable application experience and faster iteration speed, Cherry Studio has launched the "Test Plan". + +## User Guide + +The Test Plan is divided into the RC channel and the Beta channel, with the following differences: + +- **RC (Release Candidate)**: The features are stable, with fewer bugs, and it is close to the official release. +- **Beta**: Features may change at any time, and there may be more bugs, but users can experience future features earlier. + +Users can enable the "Test Plan" and select the version channel in the software's `Settings` > `About`. Please note that the versions in the "Test Plan" cannot guarantee data consistency, so be sure to back up your data before using them. + +Users are welcome to submit issues or provide feedback through other channels for any bugs encountered during testing. Your feedback is very important to us. + +## Developer Guide + +### Participating in the Test Plan + +Developers should submit `PRs` according to the [Contributor Guide](../CONTRIBUTING.md) (and ensure the target branch is `main`). The repository maintainers will evaluate whether the `PR` should be included in the Test Plan based on factors such as the impact of the feature on the application, its importance, and whether broader testing is needed. + +If the `PR` is added to the Test Plan, the repository maintainers will: + +- Notify the `PR` submitter. +- Set the PR to `draft` status (to avoid accidental merging into `main` before testing is complete). +- Set the `milestone` to the specific Test Plan version. +- Modify the `PR` title. + +During participation in the Test Plan, `PR` submitters should: + +- Keep the `PR` branch synchronized with the latest `main` (i.e., the `PR` branch should always be based on the latest `main` code). +- Ensure the `PR` branch is conflict-free. +- Actively respond to comments & reviews and fix bugs. +- Enable maintainers to modify the `PR` branch to allow for bug fixes at any time. + +Inclusion in the Test Plan does not guarantee the final merging of the `PR`. It may be shelved due to immature features or poor testing feedback. + +### Test Plan Lead + +A maintainer will be assigned as the lead for a specific version (e.g., `1.5.0-rc`). The responsibilities of the Test Plan lead include: + +- Determining whether a `PR` meets the Test Plan requirements and deciding whether it should be included in the current Test Plan. +- Modifying the status of `PRs` added to the Test Plan and communicating relevant matters with the `PR` submitter. +- Before the Test Plan release, merging the branches of `PRs` added to the Test Plan (using squash merge) into the corresponding version branch of `testplan` and resolving conflicts. +- Ensuring the `testplan` branch is synchronized with the latest `main`. +- Overseeing the Test Plan release. + +## In-Depth Understanding + +### About `PRs` + +A `PR` is a collection of a specific branch (and commits), comments, reviews, and other information, and it is the **smallest management unit** of the Test Plan. + +Compared to submitting all features to a single branch, the Test Plan manages features through `PRs`, which offers greater flexibility and efficiency: + +- Features can be added or removed between different versions of the Test Plan without cumbersome `revert` operations. +- Clear feature boundaries and responsibilities are established. Bug fixes are completed within their respective `PRs`, isolating cross-impact and better tracking progress. +- The `PR` submitter is responsible for resolving conflicts with the latest `main`. The Test Plan lead is responsible for resolving conflicts between `PR` branches. However, since features added to the Test Plan are relatively independent (in other words, if a feature has broad implications, it should be independently included in the Test Plan), conflicts are generally few or simple. + +### The `testplan` Branch + +The `testplan` branch is a **temporary** branch used for Test Plan releases. + +Note: + +- **Do not develop based on this branch**. It may change or even be deleted at any time, and there is no guarantee of commit completeness or order. +- **Do not submit `commits` or `PRs` to this branch**, as they will not be retained. +- The `testplan` branch is always based on the latest `main` branch (not on a released version), with features added on top. + +#### RC Branch + +Branch name: `testplan/rc/x.y.z` + +Used for RC releases, where `x.y.z` is the target version number. Note that whether it is rc.1 or rc.5, as long as the major version number is `x.y.z`, it is completed in this branch. + +Generally, the version number for releases from this branch is named `x.y.z-rc.n`. + +#### Beta Branch + +Branch name: `testplan/beta/x.y.z` + +Used for Beta releases, where `x.y.z` is the target version number. Note that whether it is beta.1 or beta.5, as long as the major version number is `x.y.z`, it is completed in this branch. + +Generally, the version number for releases from this branch is named `x.y.z-beta.n`. + +### Version Rules + +The application version number for the Test Plan is: `x.y.z-CHA.n`, where: + +- `x.y.z` is the conventional version number, referred to here as the **target version number**. +- `CHA` is the channel code (Channel), currently divided into `rc` and `beta`. +- `n` is the release number, starting from `1`. + +Examples of complete version numbers: `1.5.0-rc.3`, `1.5.1-beta.1`, `1.6.0-beta.6`. + +The **target version number** of the Test Plan points to the official version number where these features are expected to be added. For example: + +- `1.5.0-rc.3` means this is a preview of the `1.5.0` official release (the current latest official release is `1.4.9`, and `1.5.0` has not yet been officially released). +- `1.5.1-beta.1` means this is a beta version of the `1.5.1` official release (the current latest official release is `1.5.0`, and `1.5.1` has not yet been officially released). diff --git a/docs/testplan-zh.md b/docs/testplan-zh.md new file mode 100644 index 0000000000..ed4913d4a4 --- /dev/null +++ b/docs/testplan-zh.md @@ -0,0 +1,99 @@ +# 测试计划 + +为了给用户提供更稳定的应用体验,并提供更快的迭代速度,Cherry Studio推出“测试计划”。 + +## 用户指南 + +测试计划分为RC版通道和Beta版通道吗,区别在于: + +- **RC版(预览版)**:RC即Release Candidate,功能已经稳定,BUG较少,接近正式版 +- **Beta版(测试版)**:功能可能随时变化,BUG较多,可以较早体验未来功能 + +用户可以在软件的`设置`-`关于`中,开启“测试计划”并选择版本通道。请注意“测试计划”的版本无法保证数据的一致性,请使用前一定要备份数据。 + +用户在测试过程中发现的BUG,欢迎提交issue或通过其他渠道反馈。用户的反馈对我们非常重要。 + +## 开发者指南 + +### 参与测试计划 + +开发者按照[贡献者指南](CONTRIBUTING.zh.md)要求正常提交`PR`(并注意提交target为`main`)。仓库维护者会综合考虑(例如该功能对应用的影响程度,功能的重要性,是否需要更广泛的测试等),决定该`PR`是否应加入测试计划。 + +若该`PR`加入测试计划,仓库维护者会做如下操作: + +- 通知`PR`提交人 +- 设置PR为`draft`状态(避免在测试完成前意外并入`main`) +- `milestone`设置为具体测试计划版本 +- 修改`PR`标题 + +`PR`提交人在参与测试计划过程中,应做到: + +- 保持`PR`分支与最新`main`同步(即`PR`分支总是应基于最新`main`代码) +- 保持`PR`分支为无冲突状态 +- 积极响应 comments & reviews,修复bug +- 开启维护者可以修改`PR`分支的权限,以便维护者能随时修改BUG + +加入测试计划并不保证`PR`的最终合并,也有可能由于功能不成熟或测试反馈不佳而搁置 + +### 测试计划负责人 + +某个维护者会被指定为某个版本期间(例如`1.5.0-rc`)的测试计划负责人。测试计划负责人的工作为: + +- 判断某个`PR`是否符合测试计划要求,并决定是否应合入当期测试计划 +- 修改加入测试计划的`PR`状态,并与`PR`提交人沟通相关事宜 +- 在测试计划发版前,将加入测试计划的`PR`分支逐一合并(采用squash merge)至`testplan`对应版本分支,并解决冲突 +- 保证`testplan`分支与最新`main`同步 +- 负责测试计划发版 + +## 深入理解 + +### 关于`PR` + +`PR`是特定分支(及commits)、comments、reviews等各种信息的集合,也是测试计划的**最小管理单元**。 + +相比将所有功能都提交到某个分支,测试计划通过`PR`来管理功能,这可以带来极大的灵活度和效率: + +- 测试计划的各个版本间,可以随意增减功能,而无需繁琐的`revert`操作 +- 明确了功能边界和负责人,bug修复在各自`PR`中完成,隔离了交叉影响,也能更好观察进度 +- `PR`提交人负责与最新`main`之间的冲突;测试计划负责人负责各`PR`分支之间的冲突,但因加入测试计划的各功能相对比较独立(话句话说,如果功能牵涉较广,则应独立上测试计划),冲突一般比较少或简单。 + +### `testplan`分支 + +`testplan`分支是用于测试计划发版所用的**临时**分支。 + +注意: + +- **请勿基于该分支开发**。该分支随时会变化甚至删除,且并不保证commit的完整和顺序。 +- **请勿向该分支提交`commit`及`PR`**,将不会得到保留 +- `testplan`分支总是基于最新`main`分支(而不是基于已发布版本),在其之上添加功能 + +#### RC版分支 + +分支名称:`testplan/rc/x.y.z` + +用于RC版的发版,x.y.z为目标版本号,注意无论是rc.1还是rc.5,只要主版本号为x.y.z,都在该分支完成。 + +一般而言,该分支发版的版本号命名为`x.y.z-rc.n` + +#### Beta版分支 + +分支名称:`testplan/beta/x.y.z` + +用于Beta版的发版,x.y.z为目标版本号,注意无论是beta.1还是beta.5,只要主版本号为x.y.z,都在该分支完成。 + +一般而言,该分支发版的版本号命名为`x.y.z-beta.n` + +### 版本规则 + +测试计划的应用版本号为:`x.y.z-CHA.n`,其中: + +- `x.y.z`为一般意义上的版本号,在这里称为**目标版本号** +- `CHA`为通道号(Channel),现在分为`rc`和`beta` +- `n`为发版编号,从`1`计数 + +完整的版本号举例:`1.5.0-rc.3`、`1.5.1-beta.1`、`1.6.0-beta.6` + +测试计划的**目标版本号**指向希望添加这些功能的正式版版本号。例如: + +- `1.5.0-rc.3`是指,这是`1.5.0`正式版的预览版(当前最新正式版是`1.4.9`,而`1.5.0`正式版还未发布) +- `1.5.1-beta.1`是指,这是`1.5.1`正式版的测试版(当前最新正式版是`1.5.0`,而`1.5.1`正式版还未发布) From a1304054ce08ba3dce3cd1066b5ccbc441d32abe Mon Sep 17 00:00:00 2001 From: Jason Young <44939412+farion1231@users.noreply.github.com> Date: Sun, 6 Jul 2025 04:51:41 +0800 Subject: [PATCH 13/54] test: add comprehensive unit tests for asyncInitializer and copy utilities (#7858) * test: add unit tests for asyncInitializer and copy utilities - Add tests for asyncInitializer class functionality - Add tests for clipboard copy operations * refactor(test): improve copy.test.ts structure and maintainability - Remove complex shared testCopyFunction in favor of individual test cases - Simplify mock cleanup by removing redundant afterEach - Split test scenarios into focused, independent test cases - Improve test readability with clear Chinese comments - Maintain full test coverage while following TEST_UTILS.md guidelines - Fix minor formatting in asyncInitializer.test.ts * test: remove unnecessary test cases - Remove AsyncInitializer type support test - Remove maintain separate instances test - These tests verify language features rather than business logic * refactor(test): reorganize copy and export test structure Restructure test organization based on PR review feedback: - Move export functionality tests from copy.test.ts to export.test.ts - Remove unnecessary "clipboard API not available" test - Merge duplicate empty content tests for better coverage - Add boundary tests for special characters and Markdown formatting - Fix ESLint formatting issues Test responsibilities are now clearer: - copy.test.ts: Focus on clipboard operations (8 tests) - export.test.ts: Focus on content conversion and edge cases * fix(test): correct markdown formatting test for list items Fix the regex pattern to properly handle markdown list items. Replace with separate patterns to avoid removing the dash from list items incorrectly. * fix(test): format prettier style for markdown test --- .../utils/__tests__/asyncInitializer.test.ts | 90 ++++++++ src/renderer/src/utils/__tests__/copy.test.ts | 196 ++++++++++++++++++ .../src/utils/__tests__/export.test.ts | 107 +++++++++- 3 files changed, 382 insertions(+), 11 deletions(-) create mode 100644 src/renderer/src/utils/__tests__/asyncInitializer.test.ts create mode 100644 src/renderer/src/utils/__tests__/copy.test.ts diff --git a/src/renderer/src/utils/__tests__/asyncInitializer.test.ts b/src/renderer/src/utils/__tests__/asyncInitializer.test.ts new file mode 100644 index 0000000000..8ff6d2ced6 --- /dev/null +++ b/src/renderer/src/utils/__tests__/asyncInitializer.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it, vi } from 'vitest' + +import { AsyncInitializer } from '../asyncInitializer' + +describe('AsyncInitializer', () => { + it('should initialize value lazily on first get', async () => { + const mockFactory = vi.fn().mockResolvedValue('test-value') + const initializer = new AsyncInitializer(mockFactory) + + // factory 不应该在构造时调用 + expect(mockFactory).not.toHaveBeenCalled() + + // 第一次调用 get + const result = await initializer.get() + + expect(mockFactory).toHaveBeenCalledTimes(1) + expect(result).toBe('test-value') + }) + + it('should cache value and return same instance on subsequent calls', async () => { + const mockFactory = vi.fn().mockResolvedValue('test-value') + const initializer = new AsyncInitializer(mockFactory) + + // 多次调用 get + const result1 = await initializer.get() + const result2 = await initializer.get() + const result3 = await initializer.get() + + // factory 只应该被调用一次 + expect(mockFactory).toHaveBeenCalledTimes(1) + + // 所有结果应该相同 + expect(result1).toBe('test-value') + expect(result2).toBe('test-value') + expect(result3).toBe('test-value') + }) + + it('should handle concurrent calls properly', async () => { + let resolveFactory: (value: string) => void + const factoryPromise = new Promise((resolve) => { + resolveFactory = resolve + }) + const mockFactory = vi.fn().mockReturnValue(factoryPromise) + + const initializer = new AsyncInitializer(mockFactory) + + // 同时调用多次 get + const promise1 = initializer.get() + const promise2 = initializer.get() + const promise3 = initializer.get() + + // factory 只应该被调用一次 + expect(mockFactory).toHaveBeenCalledTimes(1) + + // 解析 promise + resolveFactory!('concurrent-value') + + const results = await Promise.all([promise1, promise2, promise3]) + expect(results).toEqual(['concurrent-value', 'concurrent-value', 'concurrent-value']) + }) + + it('should handle and cache errors', async () => { + const error = new Error('Factory error') + const mockFactory = vi.fn().mockRejectedValue(error) + const initializer = new AsyncInitializer(mockFactory) + + // 多次调用都应该返回相同的错误 + await expect(initializer.get()).rejects.toThrow('Factory error') + await expect(initializer.get()).rejects.toThrow('Factory error') + + // factory 只应该被调用一次 + expect(mockFactory).toHaveBeenCalledTimes(1) + }) + + it('should not retry after failure', async () => { + // 确认错误被缓存,不会重试 + const error = new Error('Initialization failed') + const mockFactory = vi.fn().mockRejectedValue(error) + const initializer = new AsyncInitializer(mockFactory) + + // 第一次失败 + await expect(initializer.get()).rejects.toThrow('Initialization failed') + + // 第二次调用不应该重试 + await expect(initializer.get()).rejects.toThrow('Initialization failed') + + // factory 只被调用一次 + expect(mockFactory).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/utils/__tests__/copy.test.ts b/src/renderer/src/utils/__tests__/copy.test.ts new file mode 100644 index 0000000000..85480a686e --- /dev/null +++ b/src/renderer/src/utils/__tests__/copy.test.ts @@ -0,0 +1,196 @@ +import { Message, Topic } from '@renderer/types' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { copyMessageAsPlainText, copyTopicAsMarkdown, copyTopicAsPlainText } from '../copy' + +// Mock dependencies +vi.mock('@renderer/utils/export', () => ({ + topicToMarkdown: vi.fn(), + topicToPlainText: vi.fn(), + messageToPlainText: vi.fn() +})) + +vi.mock('i18next', () => ({ + default: { + t: vi.fn((key) => key) + } +})) + +// Mock navigator.clipboard +const mockClipboard = { + writeText: vi.fn() +} + +// Mock window.message +const mockMessage = { + success: vi.fn() +} + +// 创建测试数据辅助函数 +function createTestTopic(partial: Partial = {}): Topic { + return { + id: 'test-topic-id', + assistantId: 'test-assistant-id', + name: 'Test Topic', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + messages: [], + ...partial + } +} + +function createTestMessage(partial: Partial = {}): Message { + return { + id: 'test-message-id', + role: 'user', + assistantId: 'test-assistant-id', + topicId: 'test-topic-id', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + status: 'success', + blocks: [], + ...partial + } as Message +} + +describe('copy', () => { + beforeEach(() => { + // 设置全局 mocks + Object.defineProperty(global.navigator, 'clipboard', { + value: mockClipboard, + writable: true + }) + + Object.defineProperty(global.window, 'message', { + value: mockMessage, + writable: true + }) + + // 清理所有 mock 调用 + vi.clearAllMocks() + }) + + describe('copyTopicAsMarkdown', () => { + it('should copy topic as markdown successfully', async () => { + // 准备测试数据 + const topic = createTestTopic() + const markdownContent = '# Test Topic\n\nContent here...' + + const { topicToMarkdown } = await import('@renderer/utils/export') + vi.mocked(topicToMarkdown).mockResolvedValue(markdownContent) + mockClipboard.writeText.mockResolvedValue(undefined) + + // 执行测试 + await copyTopicAsMarkdown(topic) + + // 验证结果 + expect(topicToMarkdown).toHaveBeenCalledWith(topic) + expect(mockClipboard.writeText).toHaveBeenCalledWith(markdownContent) + expect(mockMessage.success).toHaveBeenCalledWith('message.copy.success') + }) + + it('should handle export function errors', async () => { + // 测试导出函数错误 + const topic = createTestTopic() + const { topicToMarkdown } = await import('@renderer/utils/export') + vi.mocked(topicToMarkdown).mockRejectedValue(new Error('Export error')) + + await expect(copyTopicAsMarkdown(topic)).rejects.toThrow('Export error') + expect(mockClipboard.writeText).not.toHaveBeenCalled() + expect(mockMessage.success).not.toHaveBeenCalled() + }) + + it('should handle clipboard write errors', async () => { + // 测试剪贴板写入错误 + const topic = createTestTopic() + const markdownContent = '# Test Topic' + + const { topicToMarkdown } = await import('@renderer/utils/export') + vi.mocked(topicToMarkdown).mockResolvedValue(markdownContent) + mockClipboard.writeText.mockRejectedValue(new Error('Clipboard error')) + + await expect(copyTopicAsMarkdown(topic)).rejects.toThrow('Clipboard error') + expect(mockMessage.success).not.toHaveBeenCalled() + }) + }) + + describe('copyTopicAsPlainText', () => { + it('should copy topic as plain text successfully', async () => { + // 测试成功复制纯文本 + const topic = createTestTopic() + const plainTextContent = 'Test Topic\n\nPlain text content...' + + const { topicToPlainText } = await import('@renderer/utils/export') + vi.mocked(topicToPlainText).mockResolvedValue(plainTextContent) + mockClipboard.writeText.mockResolvedValue(undefined) + + await copyTopicAsPlainText(topic) + + expect(topicToPlainText).toHaveBeenCalledWith(topic) + expect(mockClipboard.writeText).toHaveBeenCalledWith(plainTextContent) + expect(mockMessage.success).toHaveBeenCalledWith('message.copy.success') + }) + + it('should handle export function errors', async () => { + // 测试导出函数错误 + const topic = createTestTopic() + const { topicToPlainText } = await import('@renderer/utils/export') + vi.mocked(topicToPlainText).mockRejectedValue(new Error('Export error')) + + await expect(copyTopicAsPlainText(topic)).rejects.toThrow('Export error') + expect(mockClipboard.writeText).not.toHaveBeenCalled() + expect(mockMessage.success).not.toHaveBeenCalled() + }) + }) + + describe('copyMessageAsPlainText', () => { + it('should copy message as plain text successfully', async () => { + // 测试成功复制消息纯文本 + const message = createTestMessage() + const plainTextContent = 'This is the plain text content of the message' + + const { messageToPlainText } = await import('@renderer/utils/export') + vi.mocked(messageToPlainText).mockReturnValue(plainTextContent) + mockClipboard.writeText.mockResolvedValue(undefined) + + await copyMessageAsPlainText(message) + + expect(messageToPlainText).toHaveBeenCalledWith(message) + expect(mockClipboard.writeText).toHaveBeenCalledWith(plainTextContent) + expect(mockMessage.success).toHaveBeenCalledWith('message.copy.success') + }) + + it('should handle messageToPlainText errors', async () => { + // 测试消息转换错误 + const message = createTestMessage() + const { messageToPlainText } = await import('@renderer/utils/export') + vi.mocked(messageToPlainText).mockImplementation(() => { + throw new Error('Message conversion error') + }) + + await expect(copyMessageAsPlainText(message)).rejects.toThrow('Message conversion error') + expect(mockClipboard.writeText).not.toHaveBeenCalled() + expect(mockMessage.success).not.toHaveBeenCalled() + }) + }) + + describe('edge cases', () => { + it('should handle null or undefined inputs gracefully', async () => { + // 测试null/undefined输入的错误处理 + const { topicToMarkdown, topicToPlainText, messageToPlainText } = await import('@renderer/utils/export') + + vi.mocked(topicToMarkdown).mockRejectedValue(new Error('Cannot read properties of null')) + vi.mocked(topicToPlainText).mockRejectedValue(new Error('Cannot read properties of undefined')) + vi.mocked(messageToPlainText).mockImplementation(() => { + throw new Error('Cannot read properties of null') + }) + + // @ts-expect-error 测试类型错误 + await expect(copyTopicAsMarkdown(null)).rejects.toThrow('Cannot read properties of null') + // @ts-expect-error 测试类型错误 + await expect(copyTopicAsPlainText(undefined)).rejects.toThrow('Cannot read properties of undefined') + // @ts-expect-error 测试类型错误 + await expect(copyMessageAsPlainText(null)).rejects.toThrow('Cannot read properties of null') + }) + }) +}) diff --git a/src/renderer/src/utils/__tests__/export.test.ts b/src/renderer/src/utils/__tests__/export.test.ts index aa25fbb679..463313078b 100644 --- a/src/renderer/src/utils/__tests__/export.test.ts +++ b/src/renderer/src/utils/__tests__/export.test.ts @@ -258,6 +258,17 @@ describe('export', () => { mockedMessages = [userMsg, assistantMsg] }) + it('should handle empty content in message blocks', () => { + const msgWithEmptyContent = createMessage({ role: 'user', id: 'empty_block' }, [ + { type: MessageBlockType.MAIN_TEXT, content: '' } + ]) + const markdown = messageToMarkdown(msgWithEmptyContent) + expect(markdown).toContain('### 🧑‍💻 User') + // Should handle empty content gracefully + expect(markdown).toBeDefined() + expect(markdown.split('\n\n').filter((s) => s.trim()).length).toBeGreaterThanOrEqual(1) + }) + it('should format user message using main text block', () => { const msg = mockedMessages.find((m) => m.id === 'u1') expect(msg).toBeDefined() @@ -421,7 +432,7 @@ describe('export', () => { } ;(db.topics.get as any).mockResolvedValue({ messages: [userMsg, assistantMsg] }) // Specific mock for this test to check formatting - ;(markdownToPlainText as any).mockImplementation((str) => str.replace(/[#*]/g, '')) + ;(markdownToPlainText as any).mockImplementation((str: string) => str.replace(/[#*]/g, '')) const plainText = await topicToPlainText(testTopic) @@ -438,20 +449,54 @@ describe('export', () => { const testMessage = createMessage({ role: 'user', id: 'single_msg_plain' }, [ { type: MessageBlockType.MAIN_TEXT, content: '### Single Message Content' } ]) - ;(markdownToPlainText as any).mockImplementation((str) => str.replace(/[#*_]/g, '')) + ;(markdownToPlainText as any).mockImplementation((str: string) => str.replace(/[#*_]/g, '')) const result = messageToPlainText(testMessage) expect(result).toBe('Single Message Content') expect(markdownToPlainText).toHaveBeenCalledWith('### Single Message Content') }) - it('should return empty string for message with no main text', () => { - const testMessage = createMessage({ role: 'user', id: 'empty_msg_plain' }, []) - ;(markdownToPlainText as any).mockReturnValue('') // Mock to return empty for empty input + it('should return empty string for message with no main text or empty content', () => { + // Test case 1: No blocks at all + const testMessageNoBlocks = createMessage({ role: 'user', id: 'empty_msg_plain' }, []) + ;(markdownToPlainText as any).mockReturnValue('') + + const result1 = messageToPlainText(testMessageNoBlocks) + expect(result1).toBe('') + expect(markdownToPlainText).toHaveBeenCalledWith('') + + // Test case 2: Block exists but content is empty + const testMessageEmptyContent = createMessage({ role: 'user', id: 'empty_content_msg' }, [ + { type: MessageBlockType.MAIN_TEXT, content: '' } + ]) + + const result2 = messageToPlainText(testMessageEmptyContent) + expect(result2).toBe('') + expect(markdownToPlainText).toHaveBeenCalledWith('') + }) + + it('should handle special characters in message content', () => { + const testMessage = createMessage({ role: 'user', id: 'special_chars_msg' }, [ + { type: MessageBlockType.MAIN_TEXT, content: 'Text with "quotes" & and &entities;' } + ]) + ;(markdownToPlainText as any).mockImplementation((str: string) => str) const result = messageToPlainText(testMessage) - expect(result).toBe('') - expect(markdownToPlainText).toHaveBeenCalledWith('') + expect(result).toBe('Text with "quotes" & and &entities;') + expect(markdownToPlainText).toHaveBeenCalledWith('Text with "quotes" & and &entities;') + }) + + it('should handle messages with markdown formatting', () => { + const testMessage = createMessage({ role: 'user', id: 'markdown_msg' }, [ + { type: MessageBlockType.MAIN_TEXT, content: '# Header\n**Bold** and *italic* text\n- List item' } + ]) + ;(markdownToPlainText as any).mockImplementation((str: string) => + str.replace(/[#*_]/g, '').replace(/^- /gm, '').replace(/\n+/g, '\n').trim() + ) + + const result = messageToPlainText(testMessage) + expect(result).toBe('Header\nBold and italic text\nList item') + expect(markdownToPlainText).toHaveBeenCalledWith('# Header\n**Bold** and *italic* text\n- List item') }) }) @@ -472,7 +517,7 @@ describe('export', () => { updatedAt: '' } ;(db.topics.get as any).mockResolvedValue({ messages: [msg1, msg2] }) - ;(markdownToPlainText as any).mockImplementation((str) => str) // Pass-through + ;(markdownToPlainText as any).mockImplementation((str: string) => str) // Pass-through const plainText = await topicToPlainText(testTopic) expect(plainText).toBe('Multi Plain Formatted\n\nUser:\nMsg1 Formatted\n\nAssistant:\nMsg2 Formatted') @@ -492,6 +537,46 @@ describe('export', () => { })) }) + it('should handle empty content in topic messages', async () => { + const msgWithEmpty = createMessage({ role: 'user', id: 'empty_content' }, [ + { type: MessageBlockType.MAIN_TEXT, content: '' } + ]) + const testTopic: Topic = { + id: 'topic_empty_content', + name: 'Topic with empty content', + assistantId: 'asst_test', + messages: [msgWithEmpty] as any, + createdAt: '', + updatedAt: '' + } + ;(db.topics.get as any).mockResolvedValue({ messages: [msgWithEmpty] }) + ;(markdownToPlainText as any).mockImplementation((str: string) => str) + + const result = await topicToPlainText(testTopic) + expect(result).toBe('Topic with empty content\n\nUser:\n') + }) + + it('should handle special characters in topic content', async () => { + const msgWithSpecial = createMessage({ role: 'user', id: 'special_chars' }, [ + { type: MessageBlockType.MAIN_TEXT, content: 'Content with "quotes" & and &entities;' } + ]) + const testTopic: Topic = { + id: 'topic_special_chars', + name: 'Topic with "quotes" & symbols', + assistantId: 'asst_test', + messages: [msgWithSpecial] as any, + createdAt: '', + updatedAt: '' + } + ;(db.topics.get as any).mockResolvedValue({ messages: [msgWithSpecial] }) + ;(markdownToPlainText as any).mockImplementation((str: string) => str) + + const result = await topicToPlainText(testTopic) + expect(markdownToPlainText).toHaveBeenCalledWith('Topic with "quotes" & symbols') + expect(markdownToPlainText).toHaveBeenCalledWith('Content with "quotes" & and &entities;') + expect(result).toContain('Content with "quotes" & and &entities;') + }) + it('should return plain text for a topic with messages', async () => { const msg1 = createMessage({ role: 'user', id: 'tp_u1' }, [ { type: MessageBlockType.MAIN_TEXT, content: '**Hello**' } @@ -508,7 +593,7 @@ describe('export', () => { updatedAt: '' } ;(db.topics.get as any).mockResolvedValue({ messages: [msg1, msg2] }) - ;(markdownToPlainText as any).mockImplementation((str) => str.replace(/[#*_]/g, '')) + ;(markdownToPlainText as any).mockImplementation((str: string) => str.replace(/[#*_]/g, '')) const result = await topicToPlainText(testTopic) expect(db.topics.get).toHaveBeenCalledWith('topic1_plain') @@ -528,7 +613,7 @@ describe('export', () => { updatedAt: '' } ;(db.topics.get as any).mockResolvedValue({ messages: [] }) - ;(markdownToPlainText as any).mockImplementation((str) => str.replace(/[#*_]/g, '')) + ;(markdownToPlainText as any).mockImplementation((str: string) => str.replace(/[#*_]/g, '')) const result = await topicToPlainText(testTopic) expect(result).toBe('Empty Topic') @@ -580,7 +665,7 @@ describe('export', () => { writeTextMock.mockReset() // Ensure markdownToPlainText mock is set - ;(markdownToPlainText as any).mockImplementation((str) => str.replace(/[#*_]/g, '')) + ;(markdownToPlainText as any).mockImplementation((str: string) => str.replace(/[#*_]/g, '')) }) afterEach(() => { From 60a3cac80df6ed80717a4ef45f9b750083447c0a Mon Sep 17 00:00:00 2001 From: Jason Young <44939412+farion1231@users.noreply.github.com> Date: Sun, 6 Jul 2025 14:18:03 +0800 Subject: [PATCH 14/54] fix: improve abortController robustness with defensive programming (#7856) --- .../utils/__tests__/abortController.test.ts | 213 ++++++++++++++++++ src/renderer/src/utils/abortController.ts | 11 +- 2 files changed, 221 insertions(+), 3 deletions(-) create mode 100644 src/renderer/src/utils/__tests__/abortController.test.ts diff --git a/src/renderer/src/utils/__tests__/abortController.test.ts b/src/renderer/src/utils/__tests__/abortController.test.ts new file mode 100644 index 0000000000..8b2bdd4cb4 --- /dev/null +++ b/src/renderer/src/utils/__tests__/abortController.test.ts @@ -0,0 +1,213 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { + abortCompletion, + abortMap, + addAbortController, + createAbortPromise, + removeAbortController +} from '../abortController' + +// Mock logger +vi.mock('@renderer/config/logger', () => ({ + default: { + log: vi.fn() + } +})) + +describe('abortController', () => { + beforeEach(() => { + // 清理全局 Map + abortMap.clear() + }) + + describe('addAbortController', () => { + it('should add abort function to map', () => { + const abortFn = vi.fn() + addAbortController('test-id', abortFn) + + expect(abortMap.get('test-id')).toContain(abortFn) + }) + + it('should handle multiple abort functions for same id', () => { + const fn1 = vi.fn() + const fn2 = vi.fn() + addAbortController('test-id', fn1) + addAbortController('test-id', fn2) + + const fns = abortMap.get('test-id') + expect(fns).toHaveLength(2) + expect(fns).toEqual([fn1, fn2]) + }) + + it('should handle duplicate functions for same id', () => { + // 测试重复添加相同函数 + const fn = vi.fn() + addAbortController('test-id', fn) + addAbortController('test-id', fn) + + const fns = abortMap.get('test-id') + expect(fns).toHaveLength(2) + expect(fns).toEqual([fn, fn]) + }) + + it('should handle empty string id', () => { + // 测试空字符串 id + const fn = vi.fn() + addAbortController('', fn) + + expect(abortMap.get('')).toContain(fn) + }) + }) + + describe('removeAbortController', () => { + it('should remove specific abort function', () => { + const fn1 = vi.fn() + const fn2 = vi.fn() + addAbortController('test-id', fn1) + addAbortController('test-id', fn2) + + removeAbortController('test-id', fn1) + + expect(abortMap.get('test-id')).toEqual([fn2]) + }) + + it('should handle non-existent function gracefully', () => { + const fn1 = vi.fn() + const fn2 = vi.fn() + addAbortController('test-id', fn1) + + // 删除不存在的函数,原函数应该保持 + removeAbortController('test-id', fn2) + expect(abortMap.get('test-id')).toEqual([fn1]) + }) + + it('should handle empty string id', () => { + // 测试空字符串 id + const fn = vi.fn() + addAbortController('', fn) + removeAbortController('', fn) + expect(abortMap.get('')).toEqual([]) + }) + + it('should handle non-existent id gracefully', () => { + // 测试不存在的 id + const fn = vi.fn() + expect(() => removeAbortController('non-existent-id', fn)).not.toThrow() + }) + }) + + describe('abortCompletion', () => { + it('should call all abort functions and clean up', () => { + const fn1 = vi.fn() + const fn2 = vi.fn() + addAbortController('test-id', fn1) + addAbortController('test-id', fn2) + + abortCompletion('test-id') + + // 验证所有函数被调用 + expect(fn1).toHaveBeenCalledTimes(1) + expect(fn2).toHaveBeenCalledTimes(1) + // 验证清理完成 - 数组变为空但条目仍存在 + expect(abortMap.get('test-id')).toEqual([]) + }) + + it('should handle non-existent id gracefully', () => { + expect(() => abortCompletion('non-existent')).not.toThrow() + }) + + it('should handle empty string id', () => { + // 测试空字符串 id + expect(() => abortCompletion('')).not.toThrow() + }) + + it('should handle empty function array', () => { + // 测试空函数数组 + abortMap.set('test-id', []) + expect(() => abortCompletion('test-id')).not.toThrow() + expect(abortMap.has('test-id')).toBe(true) // 空数组不会被处理 + }) + }) + + describe('createAbortPromise', () => { + it('should reject immediately if signal already aborted', async () => { + const controller = new AbortController() + controller.abort() + + const promise = createAbortPromise(controller.signal, Promise.resolve('success')) + + await expect(promise).rejects.toMatchObject({ + name: 'AbortError', + message: 'Operation aborted' + }) + }) + + it('should reject when signal is aborted later', async () => { + const controller = new AbortController() + const finallyPromise = new Promise(() => {}) // 永不解析的 Promise + + const promise = createAbortPromise(controller.signal, finallyPromise) + + // 稍后中止 + setTimeout(() => controller.abort(), 10) + + await expect(promise).rejects.toThrow('Operation aborted') + }) + + it('should cleanup event listener when finallyPromise completes', async () => { + const controller = new AbortController() + const finallyPromise = Promise.resolve('completed') + + const removeEventListenerSpy = vi.spyOn(controller.signal, 'removeEventListener') + + createAbortPromise(controller.signal, finallyPromise) + + // 等待 finallyPromise 完成 + await finallyPromise + + // 给一点时间让 finally 回调执行 + await new Promise((resolve) => setTimeout(resolve, 0)) + + // 验证清理工作 + expect(removeEventListenerSpy).toHaveBeenCalledWith('abort', expect.any(Function)) + }) + + it('should not reject when finallyPromise resolves normally', async () => { + // 测试正常完成情况 + const controller = new AbortController() + const finallyPromise = Promise.resolve('success') + + // createAbortPromise 返回的是一个永远 pending 的 Promise(除非被 abort) + const abortPromise = createAbortPromise(controller.signal, finallyPromise) + + // 让 finallyPromise 完成 + await finallyPromise + + // abortPromise 应该保持 pending 状态(因为没有被 abort) + // 我们不能直接测试 pending 状态,但可以确保它不会立即 reject + let rejected = false + abortPromise.catch(() => { + rejected = true + }) + + await new Promise((resolve) => setTimeout(resolve, 10)) + expect(rejected).toBe(false) + }) + + it('should handle signal that becomes aborted before Promise creation', () => { + // 测试在创建 Promise 前就已经 aborted 的信号 + const controller = new AbortController() + controller.abort() + + const finallyPromise = new Promise(() => {}) // 永不解析 + + const promise = createAbortPromise(controller.signal, finallyPromise) + + return expect(promise).rejects.toMatchObject({ + name: 'AbortError', + message: 'Operation aborted' + }) + }) + }) +}) diff --git a/src/renderer/src/utils/abortController.ts b/src/renderer/src/utils/abortController.ts index 561dbb0099..973e30a36a 100644 --- a/src/renderer/src/utils/abortController.ts +++ b/src/renderer/src/utils/abortController.ts @@ -8,9 +8,14 @@ export const addAbortController = (id: string, abortFn: () => void) => { export const removeAbortController = (id: string, abortFn: () => void) => { const callbackArr = abortMap.get(id) - if (abortFn) { - callbackArr?.splice(callbackArr?.indexOf(abortFn), 1) - } else abortMap.delete(id) + if (abortFn && callbackArr) { + const index = callbackArr.indexOf(abortFn) + if (index !== -1) { + callbackArr.splice(index, 1) + } + } else { + abortMap.delete(id) + } } export const abortCompletion = (id: string) => { From c25f1f856a1d854be3edf6d0e21cece27d85df68 Mon Sep 17 00:00:00 2001 From: Phantom <59059173+EurFelux@users.noreply.github.com> Date: Sun, 6 Jul 2025 14:22:06 +0800 Subject: [PATCH 15/54] fix(QuickPhrasesButton): resolve QuickPhrases database error (#7872) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix(QuickPhrasesButton): 修复依赖assistant导致的频繁更新报错问题 --- src/renderer/src/pages/home/Inputbar/QuickPhrasesButton.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/src/pages/home/Inputbar/QuickPhrasesButton.tsx b/src/renderer/src/pages/home/Inputbar/QuickPhrasesButton.tsx index ed2e751108..d15c982a87 100644 --- a/src/renderer/src/pages/home/Inputbar/QuickPhrasesButton.tsx +++ b/src/renderer/src/pages/home/Inputbar/QuickPhrasesButton.tsx @@ -45,7 +45,7 @@ const QuickPhrasesButton = ({ ref, setInputValue, resizeTextArea, ToolbarButton, const assistantPrompts = assistant.regularPhrases || [] setQuickPhrasesList([...assistantPrompts, ...phrases]) }, - [assistant] + [assistant.regularPhrases] ) useEffect(() => { From bf7e713eecd98093a60a5634384fd29281a0ed9c Mon Sep 17 00:00:00 2001 From: SuYao Date: Sun, 6 Jul 2025 14:40:55 +0800 Subject: [PATCH 16/54] fix: qwen3 empty think block (#7873) --- .../feat/ThinkingTagExtractionMiddleware.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/renderer/src/aiCore/middleware/feat/ThinkingTagExtractionMiddleware.ts b/src/renderer/src/aiCore/middleware/feat/ThinkingTagExtractionMiddleware.ts index fe2d51d8de..2fcefc1b34 100644 --- a/src/renderer/src/aiCore/middleware/feat/ThinkingTagExtractionMiddleware.ts +++ b/src/renderer/src/aiCore/middleware/feat/ThinkingTagExtractionMiddleware.ts @@ -69,7 +69,7 @@ export const ThinkingTagExtractionMiddleware: CompletionsMiddleware = const extractionResults = tagExtractor.processText(textChunk.text) for (const extractionResult of extractionResults) { - if (extractionResult.complete && extractionResult.tagContentExtracted) { + if (extractionResult.complete && extractionResult.tagContentExtracted?.trim()) { // 生成 THINKING_COMPLETE 事件 const thinkingCompleteChunk: ThinkingCompleteChunk = { type: ChunkType.THINKING_COMPLETE, @@ -89,12 +89,14 @@ export const ThinkingTagExtractionMiddleware: CompletionsMiddleware = thinkingStartTime = Date.now() } - const thinkingDeltaChunk: ThinkingDeltaChunk = { - type: ChunkType.THINKING_DELTA, - text: extractionResult.content, - thinking_millsec: thinkingStartTime > 0 ? Date.now() - thinkingStartTime : 0 + if (extractionResult.content?.trim()) { + const thinkingDeltaChunk: ThinkingDeltaChunk = { + type: ChunkType.THINKING_DELTA, + text: extractionResult.content, + thinking_millsec: thinkingStartTime > 0 ? Date.now() - thinkingStartTime : 0 + } + controller.enqueue(thinkingDeltaChunk) } - controller.enqueue(thinkingDeltaChunk) } else { // 发送清理后的文本内容 const cleanTextChunk: TextDeltaChunk = { From a4620f8c68e97305bb730bd08ee20cd785b67584 Mon Sep 17 00:00:00 2001 From: one Date: Sun, 6 Jul 2025 15:10:44 +0800 Subject: [PATCH 17/54] refactor(ApiKeyList): add a popup for api key list (#7491) * refactor(ApiKeyList): add a popup for api key list - ApiKeyList for key management - ApiKeyListPopup triggerred by a button - Move formatApiKeys to utils for better reuse - Simplify apikey related states in ProviderSettings for better integration with ApiKeyList - Modify `updateProvider` to accept partial updates - Update api key placeholder * fix: strict type * refactor: support websearch provider * refactor: remove ApiCheckPopup * refactor: simplify interfaces for ProviderSetting and WebSearchProviderSetting * fix: sync input api key between sub-pages, futher simplification * fix: bold title * refactor: extract status icon colors * refactor: add a status indicator to input box on error, update type definitions * refactor: further simplification, make data flow clearer * feat: support api key list for preprocess settings * refactor: better naming, less confusion --- src/renderer/src/assets/styles/color.scss | 4 + src/renderer/src/components/Icons/SVGIcon.tsx | 27 +- .../components/Popups/ApiKeyListPopup/hook.ts | 307 ++++++++++++++++++ .../Popups/ApiKeyListPopup/index.ts | 2 + .../Popups/ApiKeyListPopup/item.tsx | 213 ++++++++++++ .../Popups/ApiKeyListPopup/list.tsx | 224 +++++++++++++ .../Popups/ApiKeyListPopup/popup.tsx | 88 +++++ .../Popups/ApiKeyListPopup/types.ts | 31 ++ src/renderer/src/hooks/usePreprocess.ts | 7 +- src/renderer/src/hooks/useProvider.ts | 4 +- .../src/hooks/useWebSearchProviders.ts | 7 +- src/renderer/src/i18n/locales/en-us.json | 10 +- src/renderer/src/i18n/locales/ja-jp.json | 10 +- src/renderer/src/i18n/locales/ru-ru.json | 10 +- src/renderer/src/i18n/locales/zh-cn.json | 10 +- src/renderer/src/i18n/locales/zh-tw.json | 10 +- .../pages/knowledge/components/QuotaTag.tsx | 6 +- .../ProviderSettings/ApiCheckPopup.tsx | 231 ------------- .../ProviderSettings/DMXAPISettings.tsx | 8 +- .../GithubCopilotSettings.tsx | 15 +- .../settings/ProviderSettings/ModelList.tsx | 19 +- .../ProviderSettings/ProviderOAuth.tsx | 12 +- .../ProviderSettings/ProviderSetting.tsx | 250 +++++++------- .../SelectProviderModelPopup.tsx | 2 + .../pages/settings/ProviderSettings/index.tsx | 2 +- .../ToolSettings/OcrSettings/OcrSettings.tsx | 3 +- .../PreprocessSettings/PreprocessSettings.tsx | 38 ++- .../WebSearchProviderSetting.tsx | 69 ++-- .../ToolSettings/WebSearchSettings/index.tsx | 2 +- src/renderer/src/services/ApiService.ts | 4 - src/renderer/src/store/llm.ts | 7 +- src/renderer/src/store/preprocess.ts | 4 +- src/renderer/src/store/websearch.ts | 4 +- src/renderer/src/utils/api.ts | 10 + src/renderer/src/utils/index.ts | 1 + src/renderer/src/utils/naming.ts | 12 + 36 files changed, 1205 insertions(+), 458 deletions(-) create mode 100644 src/renderer/src/components/Popups/ApiKeyListPopup/hook.ts create mode 100644 src/renderer/src/components/Popups/ApiKeyListPopup/index.ts create mode 100644 src/renderer/src/components/Popups/ApiKeyListPopup/item.tsx create mode 100644 src/renderer/src/components/Popups/ApiKeyListPopup/list.tsx create mode 100644 src/renderer/src/components/Popups/ApiKeyListPopup/popup.tsx create mode 100644 src/renderer/src/components/Popups/ApiKeyListPopup/types.ts delete mode 100644 src/renderer/src/pages/settings/ProviderSettings/ApiCheckPopup.tsx diff --git a/src/renderer/src/assets/styles/color.scss b/src/renderer/src/assets/styles/color.scss index ce7e9cefe9..3f23425afc 100644 --- a/src/renderer/src/assets/styles/color.scss +++ b/src/renderer/src/assets/styles/color.scss @@ -72,6 +72,10 @@ --chat-text-user: var(--color-black); --list-item-border-radius: 20px; + + --color-status-success: #52c41a; + --color-status-error: #ff4d4f; + --color-status-warning: #faad14; } [theme-mode='light'] { diff --git a/src/renderer/src/components/Icons/SVGIcon.tsx b/src/renderer/src/components/Icons/SVGIcon.tsx index d58eab7ee5..988d9657c1 100644 --- a/src/renderer/src/components/Icons/SVGIcon.tsx +++ b/src/renderer/src/components/Icons/SVGIcon.tsx @@ -1,14 +1,25 @@ +import { lightbulbVariants } from '@renderer/utils/motionVariants' +import { motion } from 'framer-motion' import { SVGProps } from 'react' -export const StreamlineGoodHealthAndWellBeing = (props: SVGProps) => { +export const StreamlineGoodHealthAndWellBeing = ( + props: SVGProps & { + size?: number | string + isActive?: boolean + } +) => { + const { size = '1em', isActive, ...svgProps } = props + return ( - - {/* Icon from Streamline by Streamline - https://creativecommons.org/licenses/by/4.0/ */} - - - - - + + + {/* Icon from Streamline by Streamline - https://creativecommons.org/licenses/by/4.0/ */} + + + + + + ) } diff --git a/src/renderer/src/components/Popups/ApiKeyListPopup/hook.ts b/src/renderer/src/components/Popups/ApiKeyListPopup/hook.ts new file mode 100644 index 0000000000..5bd9072d12 --- /dev/null +++ b/src/renderer/src/components/Popups/ApiKeyListPopup/hook.ts @@ -0,0 +1,307 @@ +import Logger from '@renderer/config/logger' +import { isEmbeddingModel, isRerankModel } from '@renderer/config/models' +import SelectProviderModelPopup from '@renderer/pages/settings/ProviderSettings/SelectProviderModelPopup' +import { checkApi } from '@renderer/services/ApiService' +import WebSearchService from '@renderer/services/WebSearchService' +import { Model, PreprocessProvider, Provider, WebSearchProvider } from '@renderer/types' +import { formatApiKeys, splitApiKeyString } from '@renderer/utils/api' +import { formatErrorMessage } from '@renderer/utils/error' +import { TFunction } from 'i18next' +import { isEmpty } from 'lodash' +import { useCallback, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' + +import { ApiKeyConnectivity, ApiKeyValidity, ApiKeyWithStatus, ApiProviderKind, ApiProviderUnion } from './types' + +interface UseApiKeysProps { + provider: ApiProviderUnion + updateProvider: (provider: Partial) => void + providerKind: ApiProviderKind +} + +/** + * API Keys 管理 hook + */ +export function useApiKeys({ provider, updateProvider, providerKind }: UseApiKeysProps) { + const { t } = useTranslation() + + // 连通性检查的 UI 状态管理 + const [connectivityStates, setConnectivityStates] = useState>(new Map()) + + // 保存 apiKey 到 provider + const updateProviderWithKey = useCallback( + (newKeys: string[]) => { + const validKeys = newKeys.filter((k) => k.trim()) + const formattedKeyString = formatApiKeys(validKeys.join(',')) + updateProvider({ apiKey: formattedKeyString }) + }, + [updateProvider] + ) + + // 解析 keyString 为数组 + const keys = useMemo(() => { + if (!provider.apiKey) return [] + const formattedApiKeys = formatApiKeys(provider.apiKey) + const keys = splitApiKeyString(formattedApiKeys) + return Array.from(new Set(keys)) + }, [provider.apiKey]) + + // 合并基本数据和连通性状态 + const keysWithStatus = useMemo((): ApiKeyWithStatus[] => { + return keys.map((key) => { + const connectivityState = connectivityStates.get(key) || { + status: 'not_checked' as const, + checking: false, + error: undefined, + model: undefined, + latency: undefined + } + return { + key, + ...connectivityState + } + }) + }, [keys, connectivityStates]) + + // 更新单个 key 的连通性状态 + const updateConnectivityState = useCallback((key: string, state: Partial) => { + setConnectivityStates((prev) => { + const newMap = new Map(prev) + const currentState = prev.get(key) || { + status: 'not_checked' as const, + checking: false, + error: undefined, + model: undefined, + latency: undefined + } + newMap.set(key, { ...currentState, ...state }) + return newMap + }) + }, []) + + // 验证 API key 格式 + const validateApiKey = useCallback( + (key: string, existingKeys: string[] = []): ApiKeyValidity => { + const trimmedKey = key.trim() + + if (!trimmedKey) { + return { isValid: false, error: t('settings.provider.api.key.error.empty') } + } + + if (existingKeys.includes(trimmedKey)) { + return { isValid: false, error: t('settings.provider.api.key.error.duplicate') } + } + + return { isValid: true } + }, + [t] + ) + + // 添加新 key + const addKey = useCallback( + (key: string): ApiKeyValidity => { + const validation = validateApiKey(key, keys) + + if (!validation.isValid) { + return validation + } + + updateProviderWithKey([...keys, key.trim()]) + return { isValid: true } + }, + [validateApiKey, keys, updateProviderWithKey] + ) + + // 更新 key + const updateKey = useCallback( + (index: number, key: string): ApiKeyValidity => { + if (index < 0 || index >= keys.length) { + Logger.error('[ApiKeyList] invalid key index', { index }) + return { isValid: false, error: 'Invalid index' } + } + + const otherKeys = keys.filter((_, i) => i !== index) + const validation = validateApiKey(key, otherKeys) + + if (!validation.isValid) { + return validation + } + + // 清除旧 key 的连通性状态 + const oldKey = keys[index] + if (oldKey !== key.trim()) { + setConnectivityStates((prev) => { + const newMap = new Map(prev) + newMap.delete(oldKey) + return newMap + }) + } + + const newKeys = [...keys] + newKeys[index] = key.trim() + updateProviderWithKey(newKeys) + + return { isValid: true } + }, + [keys, validateApiKey, updateProviderWithKey] + ) + + // 移除 key + const removeKey = useCallback( + (index: number) => { + if (index < 0 || index >= keys.length) return + + const keyToRemove = keys[index] + const newKeys = keys.filter((_, i) => i !== index) + + // 清除对应的连通性状态 + setConnectivityStates((prev) => { + const newMap = new Map(prev) + newMap.delete(keyToRemove) + return newMap + }) + + updateProviderWithKey(newKeys) + }, + [keys, updateProviderWithKey] + ) + + // 移除连通性检查失败的 keys + const removeInvalidKeys = useCallback(() => { + const validKeys = keysWithStatus.filter((keyStatus) => keyStatus.status !== 'error').map((k) => k.key) + + // 清除被删除的 keys 的连通性状态 + const keysToRemove = keysWithStatus.filter((keyStatus) => keyStatus.status === 'error').map((k) => k.key) + + setConnectivityStates((prev) => { + const newMap = new Map(prev) + keysToRemove.forEach((key) => newMap.delete(key)) + return newMap + }) + + updateProviderWithKey(validKeys) + }, [keysWithStatus, updateProviderWithKey]) + + // 检查单个 key 的连通性,不负责选择和验证模型 + const runConnectivityCheck = useCallback( + async (index: number, model?: Model): Promise => { + const keyToCheck = keys[index] + const currentState = connectivityStates.get(keyToCheck) + if (currentState?.checking) return + + // 设置检查状态 + updateConnectivityState(keyToCheck, { checking: true }) + + try { + const startTime = Date.now() + if (isLlmProvider(provider, providerKind) && model) { + await checkApi({ ...provider, apiKey: keyToCheck }, model) + } else { + const result = await WebSearchService.checkSearch({ ...provider, apiKey: keyToCheck }) + if (!result.valid) throw new Error(result.error) + } + const latency = Date.now() - startTime + + // 连通性检查成功 + updateConnectivityState(keyToCheck, { + checking: false, + status: 'success', + model, + latency, + error: undefined + }) + } catch (error: any) { + // 连通性检查失败 + updateConnectivityState(keyToCheck, { + checking: false, + status: 'error', + error: formatErrorMessage(error), + model: undefined, + latency: undefined + }) + + Logger.error('[ApiKeyList] failed to validate the connectivity of the api key', error) + } + }, + [keys, connectivityStates, updateConnectivityState, provider, providerKind] + ) + + // 检查单个 key 的连通性 + const checkKeyConnectivity = useCallback( + async (index: number): Promise => { + if (!provider || index < 0 || index >= keys.length) return + + const keyToCheck = keys[index] + const currentState = connectivityStates.get(keyToCheck) + if (currentState?.checking) return + + const model = isLlmProvider(provider, providerKind) ? await getModelForCheck(provider, t) : undefined + if (model === null) return + + await runConnectivityCheck(index, model) + }, + [provider, keys, connectivityStates, providerKind, t, runConnectivityCheck] + ) + + // 检查所有 keys 的连通性 + const checkAllKeysConnectivity = useCallback(async () => { + if (!provider || keys.length === 0) return + + const model = isLlmProvider(provider, providerKind) ? await getModelForCheck(provider, t) : undefined + if (model === null) return + + await Promise.allSettled(keys.map((_, index) => runConnectivityCheck(index, model))) + }, [provider, keys, providerKind, t, runConnectivityCheck]) + + // 计算是否有 key 正在检查 + const isChecking = useMemo(() => { + return Array.from(connectivityStates.values()).some((state) => state.checking) + }, [connectivityStates]) + + return { + keys: keysWithStatus, + addKey, + updateKey, + removeKey, + removeInvalidKeys, + checkKeyConnectivity, + checkAllKeysConnectivity, + isChecking + } +} + +export function isLlmProvider(obj: any, kind: ApiProviderKind): obj is Provider { + return kind === 'llm' && 'type' in obj && 'models' in obj +} + +export function isWebSearchProvider(obj: any, kind: ApiProviderKind): obj is WebSearchProvider { + return kind === 'websearch' && ('url' in obj || 'engines' in obj) +} + +export function isPreprocessProvider(obj: any, kind: ApiProviderKind): obj is PreprocessProvider { + return kind === 'doc-preprocess' && ('quota' in obj || 'options' in obj) +} + +// 获取模型用于检查 +async function getModelForCheck(provider: Provider, t: TFunction): Promise { + const modelsToCheck = 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 { + const selectedModel = await SelectProviderModelPopup.show({ provider }) + if (!selectedModel) return null + return selectedModel + } catch (error) { + Logger.error('[ApiKeyList] failed to select model', error) + return null + } +} diff --git a/src/renderer/src/components/Popups/ApiKeyListPopup/index.ts b/src/renderer/src/components/Popups/ApiKeyListPopup/index.ts new file mode 100644 index 0000000000..b599832456 --- /dev/null +++ b/src/renderer/src/components/Popups/ApiKeyListPopup/index.ts @@ -0,0 +1,2 @@ +export { default as ApiKeyListPopup } from './popup' +export * from './types' diff --git a/src/renderer/src/components/Popups/ApiKeyListPopup/item.tsx b/src/renderer/src/components/Popups/ApiKeyListPopup/item.tsx new file mode 100644 index 0000000000..cf830897cb --- /dev/null +++ b/src/renderer/src/components/Popups/ApiKeyListPopup/item.tsx @@ -0,0 +1,213 @@ +import { CheckCircleFilled, CloseCircleFilled, MinusOutlined } from '@ant-design/icons' +import { StreamlineGoodHealthAndWellBeing } from '@renderer/components/Icons/SVGIcon' +import { maskApiKey } from '@renderer/utils/api' +import { Button, Flex, Input, InputRef, List, Popconfirm, Tooltip, Typography } from 'antd' +import { Check, PenLine, X } from 'lucide-react' +import { FC, memo, useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import styled from 'styled-components' + +import { ApiKeyValidity, ApiKeyWithStatus } from './types' + +export interface ApiKeyItemProps { + keyStatus: ApiKeyWithStatus + onUpdate: (newKey: string) => ApiKeyValidity + onRemove: () => void + onCheck: () => Promise + disabled?: boolean + showHealthCheck?: boolean + isNew?: boolean +} + +/** + * API Key 项组件 + * 支持编辑、删除、连接检查等操作 + */ +const ApiKeyItem: FC = ({ + keyStatus, + onUpdate, + onRemove, + onCheck, + disabled: _disabled = false, + showHealthCheck = true, + isNew = false +}) => { + const { t } = useTranslation() + const [isEditing, setIsEditing] = useState(isNew || !keyStatus.key.trim()) + const [editValue, setEditValue] = useState(keyStatus.key) + const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false) + const inputRef = useRef(null) + + const disabled = keyStatus.checking || _disabled + const isNotChecked = keyStatus.status === 'not_checked' + const isSuccess = keyStatus.status === 'success' + const statusColor = isSuccess ? 'var(--color-status-success)' : 'var(--color-status-error)' + + useEffect(() => { + if (isEditing && inputRef.current) { + inputRef.current.focus() + } + }, [isEditing]) + + useEffect(() => { + setHasUnsavedChanges(editValue.trim() !== keyStatus.key.trim()) + }, [editValue, keyStatus.key]) + + const handleEdit = () => { + if (disabled) return + setIsEditing(true) + setEditValue(keyStatus.key) + } + + const handleSave = () => { + const result = onUpdate(editValue) + if (!result.isValid) { + window.message.warning({ + key: 'api-key-error', + content: result.error + }) + return + } + + setIsEditing(false) + } + + const handleCancelEdit = () => { + if (isNew || !keyStatus.key.trim()) { + // 临时项取消时直接移除 + onRemove() + } else { + // 现有项取消时恢复原值 + setEditValue(keyStatus.key) + setIsEditing(false) + } + } + + const renderStatusIcon = () => { + if (keyStatus.checking || isNotChecked) return null + + const StatusIcon = isSuccess ? CheckCircleFilled : CloseCircleFilled + return + } + + const renderKeyCheckResultTooltip = () => { + if (keyStatus.checking) { + return t('settings.models.check.checking') + } + + if (isNotChecked) { + return '' + } + + const statusTitle = isSuccess ? t('settings.models.check.passed') : t('settings.models.check.failed') + + return ( +
+ {statusTitle} + {keyStatus.model && ( +
+ {t('common.model')}: {keyStatus.model.name} +
+ )} + {keyStatus.latency && isSuccess && ( +
+ {t('settings.provider.api.key.check.latency')}: {(keyStatus.latency / 1000).toFixed(2)}s +
+ )} + {keyStatus.error &&
{keyStatus.error}
} +
+ ) + } + + return ( + + {isEditing ? ( + + setEditValue(e.target.value)} + onPressEnter={handleSave} + placeholder={t('settings.provider.api.key.new_key.placeholder')} + style={{ flex: 1, fontSize: '14px', marginLeft: '-10px' }} + spellCheck={false} + disabled={disabled} + /> + + + + + + + ) +} + +interface SpecificApiKeyListProps { + providerId: string + providerKind: ApiProviderKind + showHealthCheck?: boolean +} + +export const LlmApiKeyList: FC = ({ providerId, providerKind, showHealthCheck = true }) => { + const { provider, updateProvider } = useProvider(providerId) + + return ( + + ) +} + +export const WebSearchApiKeyList: FC = ({ + providerId, + providerKind, + showHealthCheck = true +}) => { + const { provider, updateProvider } = useWebSearchProvider(providerId) + + return ( + + ) +} + +export const DocPreprocessApiKeyList: FC = ({ + providerId, + providerKind, + showHealthCheck = true +}) => { + const { provider, updateProvider } = usePreprocessProvider(providerId) + + return ( + + ) +} diff --git a/src/renderer/src/components/Popups/ApiKeyListPopup/popup.tsx b/src/renderer/src/components/Popups/ApiKeyListPopup/popup.tsx new file mode 100644 index 0000000000..096e00ca58 --- /dev/null +++ b/src/renderer/src/components/Popups/ApiKeyListPopup/popup.tsx @@ -0,0 +1,88 @@ +import { TopView } from '@renderer/components/TopView' +import { Modal } from 'antd' +import { useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' + +import { DocPreprocessApiKeyList, LlmApiKeyList, WebSearchApiKeyList } from './list' +import { ApiProviderKind } from './types' + +interface ShowParams { + providerId: string + providerKind: ApiProviderKind + title?: string + showHealthCheck?: boolean +} + +interface Props extends ShowParams { + resolve: (value: any) => void +} + +/** + * API Key 列表弹窗容器组件 + */ +const PopupContainer: React.FC = ({ providerId, providerKind, title, resolve, showHealthCheck = true }) => { + const [open, setOpen] = useState(true) + const { t } = useTranslation() + + const onCancel = () => { + setOpen(false) + } + + const onClose = () => { + resolve(null) + } + + const ListComponent = useMemo(() => { + switch (providerKind) { + case 'llm': + return LlmApiKeyList + case 'websearch': + return WebSearchApiKeyList + case 'doc-preprocess': + return DocPreprocessApiKeyList + default: + return null + } + }, [providerKind]) + + return ( + + {ListComponent && ( + + )} + + ) +} + +const TopViewKey = 'ApiKeyListPopup' + +export default class ApiKeyListPopup { + static topviewId = 0 + + static hide() { + TopView.hide(TopViewKey) + } + + static show(props: ShowParams) { + return new Promise((resolve) => { + TopView.show( + { + resolve(v) + TopView.hide(TopViewKey) + }} + />, + TopViewKey + ) + }) + } +} diff --git a/src/renderer/src/components/Popups/ApiKeyListPopup/types.ts b/src/renderer/src/components/Popups/ApiKeyListPopup/types.ts new file mode 100644 index 0000000000..f5ed1c62d1 --- /dev/null +++ b/src/renderer/src/components/Popups/ApiKeyListPopup/types.ts @@ -0,0 +1,31 @@ +import { Model, PreprocessProvider, Provider, WebSearchProvider } from '@renderer/types' + +/** + * API Key 连通性检查的状态 + */ +export type ApiKeyConnectivity = { + status: 'success' | 'error' | 'not_checked' + checking?: boolean + error?: string + model?: Model + latency?: number +} + +/** + * API key 及其连通性检查的状态 + */ +export type ApiKeyWithStatus = { + key: string +} & ApiKeyConnectivity + +/** + * API key 格式有效性 + */ +export type ApiKeyValidity = { + isValid: boolean + error?: string +} + +export type ApiProviderUnion = Provider | WebSearchProvider | PreprocessProvider + +export type ApiProviderKind = 'llm' | 'websearch' | 'doc-preprocess' diff --git a/src/renderer/src/hooks/usePreprocess.ts b/src/renderer/src/hooks/usePreprocess.ts index 5a4c6649b5..41463227ad 100644 --- a/src/renderer/src/hooks/usePreprocess.ts +++ b/src/renderer/src/hooks/usePreprocess.ts @@ -14,10 +14,11 @@ export const usePreprocessProvider = (id: string) => { if (!provider) { throw new Error(`preprocess provider with id ${id} not found`) } - const updatePreprocessProvider = (preprocessProvider: PreprocessProvider) => { - dispatch(_updatePreprocessProvider(preprocessProvider)) + + return { + provider, + updateProvider: (updates: Partial) => dispatch(_updatePreprocessProvider({ id, ...updates })) } - return { provider, updatePreprocessProvider } } export const usePreprocessProviders = () => { diff --git a/src/renderer/src/hooks/useProvider.ts b/src/renderer/src/hooks/useProvider.ts index 95a8a8fa0b..3f1d0223ec 100644 --- a/src/renderer/src/hooks/useProvider.ts +++ b/src/renderer/src/hooks/useProvider.ts @@ -26,7 +26,7 @@ export function useProviders() { providers: providers || {}, addProvider: (provider: Provider) => dispatch(addProvider(provider)), removeProvider: (provider: Provider) => dispatch(removeProvider(provider)), - updateProvider: (provider: Provider) => dispatch(updateProvider(provider)), + updateProvider: (updates: Partial & { id: string }) => dispatch(updateProvider(updates)), updateProviders: (providers: Provider[]) => dispatch(updateProviders(providers)) } } @@ -50,7 +50,7 @@ export function useProvider(id: string) { return { provider, models: provider?.models || [], - updateProvider: (provider: Provider) => dispatch(updateProvider(provider)), + updateProvider: (updates: Partial) => dispatch(updateProvider({ id, ...updates })), addModel: (model: Model) => dispatch(addModel({ providerId: id, model })), removeModel: (model: Model) => dispatch(removeModel({ providerId: id, model })), updateModel: (model: Model) => dispatch(updateModel({ providerId: id, model })) diff --git a/src/renderer/src/hooks/useWebSearchProviders.ts b/src/renderer/src/hooks/useWebSearchProviders.ts index f5c1dda78c..32f9238abf 100644 --- a/src/renderer/src/hooks/useWebSearchProviders.ts +++ b/src/renderer/src/hooks/useWebSearchProviders.ts @@ -58,11 +58,10 @@ export const useWebSearchProvider = (id: string) => { throw new Error(`Web search provider with id ${id} not found`) } - const updateProvider = (provider: WebSearchProvider) => { - dispatch(updateWebSearchProvider(provider)) + return { + provider, + updateProvider: (updates: Partial) => dispatch(updateWebSearchProvider({ id, ...updates })) } - - return { provider, updateProvider } } export const useBlacklist = () => { diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index f2e9def398..d0574c2028 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -385,6 +385,7 @@ "cut": "Cut", "default": "Default", "delete": "Delete", + "delete_confirm": "Are you sure you want to delete?", "description": "Description", "docs": "Docs", "download": "Download", @@ -1707,8 +1708,14 @@ "api.url.tip": "Ending with / ignores v1, ending with # forces use of input address", "api_host": "API Host", "api_key": "API Key", - "api_key.tip": "Multiple keys separated by commas", + "api_key.tip": "Multiple keys separated by commas or spaces", "api_version": "API Version", + "api.key.new_key.placeholder": "Enter one or more keys", + "api.key.error.duplicate": "API key already exists", + "api.key.error.empty": "API key cannot be empty", + "api.key.check.latency": "Latency", + "api.key.list.open": "Open Management Interface", + "api.key.list.title": "API Key Management", "basic_auth": "HTTP authentication", "basic_auth.tip": "Applicable to instances deployed remotely (see the documentation). Currently, only the Basic scheme (RFC 7617) is supported.", "basic_auth.user_name": "Username", @@ -1891,7 +1898,6 @@ "check": "Check", "check_failed": "Verification failed", "check_success": "Verification successful", - "get_api_key": "Get API Key", "no_provider_selected": "Please select a search service provider before checking.", "search_max_result": "Number of search results", "search_provider": "Search service provider", diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index e0776e5887..8f9d621b89 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -385,6 +385,7 @@ "cut": "切り取り", "default": "デフォルト", "delete": "削除", + "delete_confirm": "削除してもよろしいですか?", "description": "説明", "docs": "ドキュメント", "download": "ダウンロード", @@ -1689,8 +1690,14 @@ "api.url.tip": "/で終わる場合、v1を無視します。#で終わる場合、入力されたアドレスを強制的に使用します", "api_host": "APIホスト", "api_key": "APIキー", - "api_key.tip": "複数のキーはカンマで区切ります", + "api_key.tip": "複数のキーはカンマまたはスペースで区切ります", "api_version": "APIバージョン", + "api.key.new_key.placeholder": "1つ以上のキーを入力してください", + "api.key.error.duplicate": "APIキーはすでに存在します", + "api.key.error.empty": "APIキーは空にできません", + "api.key.check.latency": "遅延", + "api.key.list.open": "管理インターフェースを開く", + "api.key.list.title": "APIキー管理", "basic_auth": "HTTP 認証", "basic_auth.tip": "サーバー展開によるインスタンスに適用されます(ドキュメントを参照)。現在はBasicスキーム(RFC7617)のみをサポートしています。", "basic_auth.user_name": "ユーザー名", @@ -1846,7 +1853,6 @@ "check": "チェック", "check_failed": "検証に失敗しました", "check_success": "検証に成功しました", - "get_api_key": "APIキーを取得", "no_provider_selected": "検索サービスプロバイダーを選択してから再確認してください。", "search_max_result": "検索結果の数", "search_provider": "検索サービスプロバイダー", diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index 6a835f61b7..902e2b51df 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -385,6 +385,7 @@ "cut": "Вырезать", "default": "По умолчанию", "delete": "Удалить", + "delete_confirm": "Вы уверены, что хотите удалить?", "description": "Описание", "docs": "Документы", "download": "Скачать", @@ -1689,8 +1690,14 @@ "api.url.tip": "Заканчивая на / игнорирует v1, заканчивая на # принудительно использует введенный адрес", "api_host": "Хост API", "api_key": "Ключ API", - "api_key.tip": "Несколько ключей, разделенных запятыми", + "api_key.tip": "Несколько ключей, разделенных запятыми или пробелами", "api_version": "Версия API", + "api.key.new_key.placeholder": "Введите один или несколько ключей", + "api.key.error.duplicate": "API ключ уже существует", + "api.key.error.empty": "API ключ не может быть пустым", + "api.key.check.latency": "Задержка", + "api.key.list.open": "Открыть интерфейс управления", + "api.key.list.title": "Управление ключами API", "basic_auth": "HTTP аутентификация", "basic_auth.tip": "Применимо к экземплярам, развернутым через сервер (см. документацию). В настоящее время поддерживается только схема Basic (RFC7617).", "basic_auth.user_name": "Имя пользователя", @@ -1846,7 +1853,6 @@ "check": "проверка", "check_failed": "Проверка не прошла", "check_success": "Проверка успешна", - "get_api_key": "Получить ключ API", "no_provider_selected": "Пожалуйста, выберите поставщика поисковых услуг, затем проверьте.", "search_max_result": "Количество результатов поиска", "search_max_result.tooltip": "При отключенном сжатии результатов поиска, количество результатов может быть слишком большим, что приведет к исчерпанию токенов", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index db5dd86d5f..2d603470e0 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -385,6 +385,7 @@ "cut": "剪切", "default": "默认", "delete": "删除", + "delete_confirm": "确定要删除吗?", "description": "描述", "docs": "文档", "download": "下载", @@ -1707,8 +1708,14 @@ "api.url.tip": "/ 结尾忽略 v1 版本,# 结尾强制使用输入地址", "api_host": "API 地址", "api_key": "API 密钥", - "api_key.tip": "多个密钥使用逗号分隔", + "api_key.tip": "多个密钥使用逗号或空格分隔", "api_version": "API 版本", + "api.key.new_key.placeholder": "输入一个或多个密钥", + "api.key.error.duplicate": "API 密钥已存在", + "api.key.error.empty": "API 密钥不能为空", + "api.key.check.latency": "耗时", + "api.key.list.open": "打开管理界面", + "api.key.list.title": "API 密钥管理", "basic_auth": "HTTP 认证", "basic_auth.tip": "适用于通过服务器部署的实例(参见文档)。目前仅支持 Basic 方案(RFC7617)", "basic_auth.user_name": "用户名", @@ -1938,7 +1945,6 @@ "check_success": "验证成功", "overwrite": "覆盖服务商搜索", "overwrite_tooltip": "强制使用搜索服务商而不是大语言模型进行搜索", - "get_api_key": "点击这里获取密钥", "no_provider_selected": "请选择搜索服务商后再检测", "search_max_result": "搜索结果个数", "search_max_result.tooltip": "未开启搜索结果压缩的情况下,数量过大可能会消耗过多 tokens", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 743814c0cd..0b8e0b7e2d 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -385,6 +385,7 @@ "cut": "剪下", "default": "預設", "delete": "刪除", + "delete_confirm": "確定要刪除嗎?", "description": "描述", "docs": "文件", "download": "下載", @@ -1692,8 +1693,14 @@ "api.url.tip": "/ 結尾忽略 v1 版本,# 結尾強制使用輸入位址", "api_host": "API 主機地址", "api_key": "API 金鑰", - "api_key.tip": "多個金鑰使用逗號分隔", + "api_key.tip": "多個金鑰使用逗號或空格分隔", "api_version": "API 版本", + "api.key.new_key.placeholder": "輸入一個或多個密鑰", + "api.key.error.duplicate": "API 密鑰已存在", + "api.key.error.empty": "API 密鑰不能為空", + "api.key.check.latency": "耗時", + "api.key.list.open": "打開管理界面", + "api.key.list.title": "API 密鑰管理", "basic_auth": "HTTP 認證", "basic_auth.tip": "適用於透過伺服器部署的實例(請參閱文檔)。目前僅支援 Basic 方案(RFC7617)", "basic_auth.user_name": "用戶", @@ -1849,7 +1856,6 @@ "check": "檢查", "check_failed": "驗證失敗", "check_success": "驗證成功", - "get_api_key": "點選這裡取得金鑰", "no_provider_selected": "請選擇搜尋服務商後再檢查", "search_max_result": "搜尋結果個數", "search_max_result.tooltip": "未開啟搜尋結果壓縮的情況下,數量過大可能會消耗過多 tokens", diff --git a/src/renderer/src/pages/knowledge/components/QuotaTag.tsx b/src/renderer/src/pages/knowledge/components/QuotaTag.tsx index fe088f0ac4..2f811d363c 100644 --- a/src/renderer/src/pages/knowledge/components/QuotaTag.tsx +++ b/src/renderer/src/pages/knowledge/components/QuotaTag.tsx @@ -12,7 +12,7 @@ const QuotaTag: FC<{ base: KnowledgeBase; providerId: string; quota?: number }> quota: _quota }) => { const { t } = useTranslation() - const { provider, updatePreprocessProvider } = usePreprocessProvider(providerId) + const { provider, updateProvider } = usePreprocessProvider(providerId) const [quota, setQuota] = useState(_quota) useEffect(() => { @@ -21,7 +21,7 @@ const QuotaTag: FC<{ base: KnowledgeBase; providerId: string; quota?: number }> // 使用用户的key时quota为无限 if (provider.apiKey) { setQuota(-9999) - updatePreprocessProvider({ ...provider, quota: -9999 }) + updateProvider({ quota: -9999 }) return } if (quota === undefined) { @@ -39,7 +39,7 @@ const QuotaTag: FC<{ base: KnowledgeBase; providerId: string; quota?: number }> } } if (_quota !== undefined) { - updatePreprocessProvider({ ...provider, quota: _quota }) + updateProvider({ quota: _quota }) return } checkQuota() diff --git a/src/renderer/src/pages/settings/ProviderSettings/ApiCheckPopup.tsx b/src/renderer/src/pages/settings/ProviderSettings/ApiCheckPopup.tsx deleted file mode 100644 index afd6ba576e..0000000000 --- a/src/renderer/src/pages/settings/ProviderSettings/ApiCheckPopup.tsx +++ /dev/null @@ -1,231 +0,0 @@ -import { CheckCircleFilled, CloseCircleFilled, LoadingOutlined, MinusCircleOutlined } from '@ant-design/icons' -import Scrollbar from '@renderer/components/Scrollbar' -import { TopView } from '@renderer/components/TopView' -import { checkApi } from '@renderer/services/ApiService' -import WebSearchService from '@renderer/services/WebSearchService' -import { Model, Provider, WebSearchProvider } from '@renderer/types' -import { maskApiKey } from '@renderer/utils/api' -import { Button, List, Modal, Space, Spin, Typography } from 'antd' -import { useState } from 'react' -import { useTranslation } from 'react-i18next' -import styled from 'styled-components' - -interface ShowParams { - title: string - provider: Provider | WebSearchProvider - model?: Model - apiKeys: string[] - type: 'provider' | 'websearch' -} - -interface Props extends ShowParams { - resolve: (data: any) => void -} - -interface KeyStatus { - key: string - isValid?: boolean - checking?: boolean -} - -const PopupContainer: React.FC = ({ title, provider, model, apiKeys, type, resolve }) => { - const [open, setOpen] = useState(true) - const [keyStatuses, setKeyStatuses] = useState(() => { - const uniqueKeys = new Set(apiKeys) - return Array.from(uniqueKeys).map((key) => ({ key })) - }) - const { t } = useTranslation() - const [isChecking, setIsChecking] = useState(false) - const [isCheckingSingle, setIsCheckingSingle] = useState(false) - - const checkAllKeys = async () => { - setIsChecking(true) - const newStatuses = [...keyStatuses] - - try { - // 使用Promise.all并行处理所有API验证请求 - const checkPromises = newStatuses.map(async (status, i) => { - // 先更新当前密钥为检查中状态 - setKeyStatuses((prev) => prev.map((status, idx) => (idx === i ? { ...status, checking: true } : status))) - - try { - let valid = false - if (type === 'provider' && model) { - await checkApi({ ...(provider as Provider), apiKey: status.key }, model) - valid = true - } else { - const result = await WebSearchService.checkSearch({ - ...(provider as WebSearchProvider), - apiKey: status.key - }) - valid = result.valid - } - - // 更新验证结果 - setKeyStatuses((prev) => prev.map((s, idx) => (idx === i ? { ...s, checking: false, isValid: valid } : s))) - - return { index: i, valid } - } catch (error: unknown) { - // 处理错误情况 - setKeyStatuses((prev) => prev.map((s, idx) => (idx === i ? { ...s, checking: false, isValid: false } : s))) - return { index: i, valid: false } - } - }) - - // 等待所有请求完成 - await Promise.all(checkPromises) - } finally { - setIsChecking(false) - } - } - - const checkSingleKey = async (keyIndex: number) => { - if (isChecking || keyStatuses[keyIndex].checking) { - return - } - - setIsCheckingSingle(true) - setKeyStatuses((prev) => prev.map((status, idx) => (idx === keyIndex ? { ...status, checking: true } : status))) - - try { - let valid = false - if (type === 'provider' && model) { - await checkApi({ ...(provider as Provider), apiKey: keyStatuses[keyIndex].key }, model) - valid = true - } else { - const result = await WebSearchService.checkSearch({ - ...(provider as WebSearchProvider), - apiKey: keyStatuses[keyIndex].key - }) - valid = result.valid - } - - setKeyStatuses((prev) => - prev.map((status, idx) => (idx === keyIndex ? { ...status, checking: false, isValid: valid } : status)) - ) - } catch (error: unknown) { - setKeyStatuses((prev) => - prev.map((status, idx) => (idx === keyIndex ? { ...status, checking: false, isValid: false } : status)) - ) - } finally { - setIsCheckingSingle(false) - } - } - - const removeInvalidKeys = () => { - setKeyStatuses((prev) => prev.filter((status) => status.isValid !== false)) - } - - const removeKey = (keyIndex: number) => { - setKeyStatuses((prev) => prev.filter((_, idx) => idx !== keyIndex)) - } - - const onOk = () => { - const allKeys = keyStatuses.map((status) => status.key) - resolve({ validKeys: allKeys }) - setOpen(false) - } - - const onCancel = () => { - setOpen(false) - } - - const onClose = () => { - resolve({}) - } - - return ( - - - - - - - - - - }> - - ( - - - {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/DMXAPISettings.tsx b/src/renderer/src/pages/settings/ProviderSettings/DMXAPISettings.tsx index b9d54fd8b9..495c629429 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/DMXAPISettings.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/DMXAPISettings.tsx @@ -2,7 +2,6 @@ import DmxapiLogo from '@renderer/assets/images/providers/dmxapi-logo.webp' import DmxapiLogoDark from '@renderer/assets/images/providers/dmxapi-logo-dark.webp' import { useTheme } from '@renderer/context/ThemeProvider' import { useProvider } from '@renderer/hooks/useProvider' -import { Provider } from '@renderer/types' import { Radio, RadioChangeEvent, Space } from 'antd' import { FC, useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -11,8 +10,7 @@ import styled from 'styled-components' import { SettingSubtitle } from '..' interface DMXAPISettingsProps { - provider: Provider - setApiKey: (apiKey: string) => void + providerId: string } // DMXAPI平台选项 @@ -40,8 +38,8 @@ const PlatformOptions = [ } ] -const DMXAPISettings: FC = ({ provider: initialProvider }) => { - const { provider, updateProvider } = useProvider(initialProvider.id) +const DMXAPISettings: FC = ({ providerId }) => { + const { provider, updateProvider } = useProvider(providerId) const { theme } = useTheme() const { t } = useTranslation() diff --git a/src/renderer/src/pages/settings/ProviderSettings/GithubCopilotSettings.tsx b/src/renderer/src/pages/settings/ProviderSettings/GithubCopilotSettings.tsx index f91f09848a..55bb643653 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/GithubCopilotSettings.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/GithubCopilotSettings.tsx @@ -1,7 +1,6 @@ import { CheckCircleOutlined, CopyOutlined, ExclamationCircleOutlined } from '@ant-design/icons' import { useCopilot } from '@renderer/hooks/useCopilot' import { useProvider } from '@renderer/hooks/useProvider' -import { Provider } from '@renderer/types' import { Alert, Button, Input, message, Popconfirm, Slider, Space, Tooltip, Typography } from 'antd' import { FC, useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -10,8 +9,7 @@ import styled from 'styled-components' import { SettingDivider, SettingGroup, SettingHelpText, SettingRow, SettingTitle } from '..' interface GithubCopilotSettingsProps { - provider: Provider - setApiKey: (apiKey: string) => void + providerId: string } enum AuthStatus { @@ -20,9 +18,9 @@ enum AuthStatus { AUTHENTICATED } -const GithubCopilotSettings: FC = ({ provider: initialProvider, setApiKey }) => { +const GithubCopilotSettings: FC = ({ providerId }) => { const { t } = useTranslation() - const { provider, updateProvider } = useProvider(initialProvider.id) + const { provider, updateProvider } = useProvider(providerId) const { username, avatar, defaultHeaders, updateState, updateDefaultHeaders } = useCopilot() // 状态管理 const [authStatus, setAuthStatus] = useState(AuthStatus.NOT_STARTED) @@ -79,7 +77,6 @@ const GithubCopilotSettings: FC = ({ provider: initi setAuthStatus(AuthStatus.AUTHENTICATED) updateState({ username: login, avatar: avatar }) updateProvider({ ...provider, apiKey: token, isAuthed: true }) - setApiKey(token) message.success(t('settings.provider.copilot.auth_success')) } } catch (error) { @@ -88,7 +85,7 @@ const GithubCopilotSettings: FC = ({ provider: initi } finally { setLoading(false) } - }, [deviceCode, t, updateProvider, provider, setApiKey, updateState, defaultHeaders]) + }, [deviceCode, t, updateProvider, provider, updateState, defaultHeaders]) // 登出 const handleLogout = useCallback(async () => { @@ -97,7 +94,6 @@ const GithubCopilotSettings: FC = ({ provider: initi // 1. 保存登出状态到本地 updateProvider({ ...provider, apiKey: '', isAuthed: false }) - setApiKey('') // 3. 清除本地存储的token await window.api.copilot.logout() @@ -114,11 +110,10 @@ const GithubCopilotSettings: FC = ({ provider: initi message.error(t('settings.provider.copilot.logout_failed')) // 如果登出失败,重置登出状态 updateProvider({ ...provider, apiKey: '', isAuthed: false }) - setApiKey('') } finally { setLoading(false) } - }, [t, updateProvider, provider, setApiKey]) + }, [t, updateProvider, provider]) // 复制用户代码 const handleCopyUserCode = useCallback(() => { diff --git a/src/renderer/src/pages/settings/ProviderSettings/ModelList.tsx b/src/renderer/src/pages/settings/ProviderSettings/ModelList.tsx index 12f1e50516..c032467a66 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/ModelList.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/ModelList.tsx @@ -31,12 +31,6 @@ import AddModelPopup from './AddModelPopup' import EditModelsPopup from './EditModelsPopup' import ModelEditContent from './ModelEditContent' -const STATUS_COLORS = { - success: '#52c41a', - error: '#ff4d4f', - warning: '#faad14' -} - export interface ModelStatus { model: Model status?: ModelCheckStatus @@ -74,7 +68,7 @@ function useModelStatusRendering() { return (
{statusTitle} - {status.error &&
{status.error}
} + {status.error &&
{status.error}
}
) } @@ -93,7 +87,10 @@ function useModelStatusRendering() { return (
  • + style={{ + marginBottom: '5px', + color: kr.isValid ? 'var(--color-status-success)' : 'var(--color-status-error)' + }}> {maskedKey}: {kr.isValid ? t('settings.models.check.passed') : t('settings.models.check.failed')} {kr.error && !kr.isValid && ` (${kr.error})`} {kr.latency && kr.isValid && ` (${formatLatency(kr.latency)})`} @@ -383,11 +380,11 @@ const StatusIndicator = styled.div<{ $type: string }>` color: ${(props) => { switch (props.$type) { case 'success': - return STATUS_COLORS.success + return 'var(--color-status-success)' case 'error': - return STATUS_COLORS.error + return 'var(--color-status-error)' case 'partial': - return STATUS_COLORS.warning + return 'var(--color-status-warning)' default: return 'var(--color-text)' } diff --git a/src/renderer/src/pages/settings/ProviderSettings/ProviderOAuth.tsx b/src/renderer/src/pages/settings/ProviderSettings/ProviderOAuth.tsx index e5d70d4fee..6cc757a450 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/ProviderOAuth.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/ProviderOAuth.tsx @@ -5,7 +5,7 @@ import TokenFluxProviderLogo from '@renderer/assets/images/providers/tokenflux.p import { HStack } from '@renderer/components/Layout' import OAuthButton from '@renderer/components/OAuth/OAuthButton' import { PROVIDER_CONFIG } from '@renderer/config/providers' -import { Provider } from '@renderer/types' +import { useProvider } from '@renderer/hooks/useProvider' import { providerBills, providerCharge } from '@renderer/utils/oauth' import { Button } from 'antd' import { isEmpty } from 'lodash' @@ -15,8 +15,7 @@ import { Trans, useTranslation } from 'react-i18next' import styled from 'styled-components' interface Props { - provider: Provider - setApiKey: (apiKey: string) => void + providerId: string } const PROVIDER_LOGO_MAP = { @@ -26,8 +25,13 @@ const PROVIDER_LOGO_MAP = { tokenflux: TokenFluxProviderLogo } -const ProviderOAuth: FC = ({ provider, setApiKey }) => { +const ProviderOAuth: FC = ({ providerId }) => { const { t } = useTranslation() + const { provider, updateProvider } = useProvider(providerId) + + const setApiKey = (newKey: string) => { + updateProvider({ apiKey: newKey }) + } let providerWebsite = PROVIDER_CONFIG[provider.id]?.api?.url.replace('https://', '').replace('api.', '') || provider.name diff --git a/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx b/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx index 74a414d81c..4f693aaacb 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx @@ -1,25 +1,26 @@ -import { CheckOutlined, LoadingOutlined } from '@ant-design/icons' +import { CheckOutlined, CloseCircleFilled, 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 { ApiKeyConnectivity, ApiKeyListPopup } from '@renderer/components/Popups/ApiKeyListPopup' 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 { checkApi } from '@renderer/services/ApiService' import { checkModelsHealth, getModelCheckSummary } from '@renderer/services/HealthCheckService' import { isProviderSupportAuth } from '@renderer/services/ProviderService' -import { Provider } from '@renderer/types' -import { formatApiHost, splitApiKeyString } from '@renderer/utils/api' +import { formatApiHost, formatApiKeys, getFancyProviderName, splitApiKeyString } from '@renderer/utils' +import { formatErrorMessage } from '@renderer/utils/error' 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 { Settings2, SquareArrowOutUpRight } from 'lucide-react' +import { List, Settings2, SquareArrowOutUpRight } from 'lucide-react' import { motion } from 'motion/react' -import { FC, useCallback, useDeferredValue, useEffect, useState } from 'react' +import { FC, useCallback, useDeferredValue, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -31,7 +32,6 @@ import { SettingSubtitle, SettingTitle } from '..' -import ApiCheckPopup from './ApiCheckPopup' import DMXAPISettings from './DMXAPISettings' import GithubCopilotSettings from './GithubCopilotSettings' import GPUStackSettings from './GPUStackSettings' @@ -45,24 +45,19 @@ import SelectProviderModelPopup from './SelectProviderModelPopup' import VertexAISettings from './VertexAISettings' interface Props { - provider: Provider + providerId: string } -const ProviderSetting: FC = ({ provider: _provider }) => { - const { provider } = useProvider(_provider.id) +const ProviderSetting: FC = ({ providerId }) => { + const { provider, updateProvider, models } = useProvider(providerId) const allProviders = useAllProviders() const { updateProviders } = useProviders() - 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 +71,43 @@ const ProviderSetting: FC = ({ provider: _provider }) => { const [modelStatuses, setModelStatuses] = useState([]) const [isHealthChecking, setIsHealthChecking] = useState(false) + const fancyProviderName = getFancyProviderName(provider) + + const [localApiKey, setLocalApiKey] = useState(provider.apiKey) + const [apiKeyConnectivity, setApiKeyConnectivity] = useState({ + status: 'not_checked', + checking: false + }) + // eslint-disable-next-line react-hooks/exhaustive-deps - const debouncedSetApiKey = useCallback( + const debouncedUpdateApiKey = useCallback( debounce((value) => { - setApiKey(formatApiKeys(value)) - }, 100), + updateProvider({ apiKey: formatApiKeys(value) }) + }, 150), [] ) + // 同步 provider.apiKey 到 localApiKey + // 重置连通性检查状态 + useEffect(() => { + setLocalApiKey(provider.apiKey) + setApiKeyConnectivity({ status: 'not_checked' }) + }, [provider.apiKey]) + + // 同步 localApiKey 到 provider.apiKey(防抖) + useEffect(() => { + if (localApiKey !== provider.apiKey) { + debouncedUpdateApiKey(localApiKey) + } + + // 卸载时取消任何待执行的更新 + return () => debouncedUpdateApiKey.cancel() + }, [localApiKey, provider.apiKey, debouncedUpdateApiKey]) + + const isApiKeyConnectable = useMemo(() => { + return apiKeyConnectivity.status === 'success' + }, [apiKeyConnectivity]) + const moveProviderToTop = useCallback( (providerId: string) => { const reorderedProviders = [...allProviders] @@ -99,21 +123,23 @@ const ProviderSetting: FC = ({ provider: _provider }) => { [allProviders, updateProviders] ) - const onUpdateApiKey = () => { - if (apiKey !== provider.apiKey) { - updateProvider({ ...provider, apiKey }) - } - } - const onUpdateApiHost = () => { if (apiHost.trim()) { - updateProvider({ ...provider, apiHost }) + updateProvider({ apiHost }) } else { setApiHost(provider.apiHost) } } - const onUpdateApiVersion = () => updateProvider({ ...provider, apiVersion }) + const onUpdateApiVersion = () => updateProvider({ apiVersion }) + + const openApiKeyList = async () => { + await ApiKeyListPopup.show({ + providerId: provider.id, + providerKind: 'llm', + title: `${fancyProviderName} ${t('settings.provider.api.key.list.title')}` + }) + } const onHealthCheck = async () => { const modelsToCheck = models.filter((model) => !isRerankModel(model)) @@ -128,7 +154,7 @@ const ProviderSetting: FC = ({ provider: _provider }) => { return } - const keys = splitApiKeyString(apiKey) + const keys = splitApiKeyString(provider.apiKey) // Add an empty key to enable health checks for local models. // Error messages will be shown for each model if a valid key is needed. @@ -193,6 +219,12 @@ const ProviderSetting: FC = ({ provider: _provider }) => { } const onCheckApi = async () => { + // 如果存在多个密钥,直接打开管理窗口 + if (provider.apiKey.includes(',')) { + await openApiKeyList() + return + } + const modelsToCheck = models.filter((model) => !isEmbeddingModel(model) && !isRerankModel(model)) if (isEmpty(modelsToCheck)) { @@ -212,58 +244,38 @@ const ProviderSetting: FC = ({ provider: _provider }) => { return } - if (apiKey.includes(',')) { - const keys = splitApiKeyString(apiKey) + try { + setApiKeyConnectivity((prev) => ({ ...prev, checking: true, status: 'not_checked' })) + await checkApi({ ...provider, apiHost }, model) - const result = await ApiCheckPopup.show({ - title: t('settings.provider.check_multiple_keys'), - provider: { ...provider, apiHost }, - model, - apiKeys: keys, - type: 'provider' + window.message.success({ + key: 'api-check', + style: { marginTop: '3vh' }, + duration: 2, + content: i18n.t('message.api.connection.success') }) - if (result?.validKeys) { - const newApiKey = result.validKeys.join(',') - setInputValue(newApiKey) - setApiKey(newApiKey) - updateProvider({ ...provider, apiKey: newApiKey }) - } - } else { - setApiChecking(true) + setApiKeyConnectivity((prev) => ({ ...prev, status: 'success' })) + setTimeout(() => { + setApiKeyConnectivity((prev) => ({ ...prev, status: 'not_checked' })) + }, 3000) + } catch (error: any) { + window.message.error({ + key: 'api-check', + style: { marginTop: '3vh' }, + duration: 8, + content: i18n.t('message.api.connection.failed') + }) - 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) - } + setApiKeyConnectivity((prev) => ({ ...prev, status: 'error', error: formatErrorMessage(error) })) + } finally { + setApiKeyConnectivity((prev) => ({ ...prev, checking: false })) } } const onReset = () => { setApiHost(configedApiHost) - updateProvider({ ...provider, apiHost: configedApiHost }) + updateProvider({ apiHost: configedApiHost }) } const hostPreview = () => { @@ -276,28 +288,31 @@ const ProviderSetting: FC = ({ provider: _provider }) => { return formatApiHost(apiHost) + 'responses' } + // API key 连通性检查状态指示器,目前仅在失败时显示 + const renderStatusIndicator = () => { + if (apiKeyConnectivity.checking || apiKeyConnectivity.status !== 'error') { + return null + } + + return ( + {apiKeyConnectivity.error}}> + + + ) + } + useEffect(() => { if (provider.id === 'copilot') { return } - setApiKey(provider.apiKey) setApiHost(provider.apiHost) - }, [provider.apiKey, provider.apiHost, provider.id]) - - // Save apiKey to provider when unmount - useEffect(() => { - return () => { - if (apiKey.trim() && apiKey !== provider.apiKey) { - updateProvider({ ...provider, apiKey }) - } - } - }, [apiKey, provider, updateProvider]) + }, [provider.apiHost, provider.id]) return ( - {provider.isSystem ? t(`provider.${provider.id}`) : provider.name} + {fancyProviderName} {officialWebsite && ( {apiKeyWebsite && ( @@ -423,7 +441,7 @@ const ProviderSetting: FC = ({ provider: _provider }) => { )} {provider.id === 'lmstudio' && } {provider.id === 'gpustack' && } - {provider.id === 'copilot' && } + {provider.id === 'copilot' && } {provider.id === 'vertexai' && } @@ -461,4 +479,12 @@ const ProviderName = styled.span` margin-right: -2px; ` +const ErrorOverlay = styled.div` + max-height: 200px; + overflow-y: auto; + max-width: 300px; + word-wrap: break-word; + user-select: text; +` + export default ProviderSetting diff --git a/src/renderer/src/pages/settings/ProviderSettings/SelectProviderModelPopup.tsx b/src/renderer/src/pages/settings/ProviderSettings/SelectProviderModelPopup.tsx index 9e5a8161b4..d6cc3e3f8f 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/SelectProviderModelPopup.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/SelectProviderModelPopup.tsx @@ -52,8 +52,10 @@ const PopupContainer: React.FC = ({ provider, resolve, reject }) => { centered> diff --git a/src/renderer/src/pages/settings/DataSettings/JoplinSettings.tsx b/src/renderer/src/pages/settings/DataSettings/JoplinSettings.tsx index 32b3148541..664b65a60c 100644 --- a/src/renderer/src/pages/settings/DataSettings/JoplinSettings.tsx +++ b/src/renderer/src/pages/settings/DataSettings/JoplinSettings.tsx @@ -5,7 +5,7 @@ import { useMinappPopup } from '@renderer/hooks/useMinappPopup' import { RootState, useAppDispatch } from '@renderer/store' import { setJoplinExportReasoning, setJoplinToken, setJoplinUrl } from '@renderer/store/settings' import { Button, Space, Switch, Tooltip } from 'antd' -import Input from 'antd/es/input/Input' +import { Input } from 'antd' import { FC } from 'react' import { useTranslation } from 'react-i18next' import { useSelector } from 'react-redux' @@ -107,11 +107,12 @@ const JoplinSettings: FC = () => { - diff --git a/src/renderer/src/pages/settings/DataSettings/NotionSettings.tsx b/src/renderer/src/pages/settings/DataSettings/NotionSettings.tsx index 26e8d0872a..3b97b5cbcc 100644 --- a/src/renderer/src/pages/settings/DataSettings/NotionSettings.tsx +++ b/src/renderer/src/pages/settings/DataSettings/NotionSettings.tsx @@ -11,7 +11,7 @@ import { setNotionPageNameKey } from '@renderer/store/settings' import { Button, Space, Switch, Tooltip } from 'antd' -import Input from 'antd/es/input/Input' +import { Input } from 'antd' import { FC } from 'react' import { useTranslation } from 'react-i18next' import { useSelector } from 'react-redux' @@ -122,12 +122,12 @@ const NotionSettings: FC = () => { {t('settings.data.notion.api_key')} - diff --git a/src/renderer/src/pages/settings/DataSettings/SiyuanSettings.tsx b/src/renderer/src/pages/settings/DataSettings/SiyuanSettings.tsx index 2681f13053..7a51edc70f 100644 --- a/src/renderer/src/pages/settings/DataSettings/SiyuanSettings.tsx +++ b/src/renderer/src/pages/settings/DataSettings/SiyuanSettings.tsx @@ -5,7 +5,7 @@ import { useMinappPopup } from '@renderer/hooks/useMinappPopup' import { RootState, useAppDispatch } from '@renderer/store' import { setSiyuanApiUrl, setSiyuanBoxId, setSiyuanRootPath, setSiyuanToken } from '@renderer/store/settings' import { Button, Space, Tooltip } from 'antd' -import Input from 'antd/es/input/Input' +import { Input } from 'antd' import { FC } from 'react' import { useTranslation } from 'react-i18next' import { useSelector } from 'react-redux' @@ -109,11 +109,12 @@ const SiyuanSettings: FC = () => { - diff --git a/src/renderer/src/pages/settings/DataSettings/YuqueSettings.tsx b/src/renderer/src/pages/settings/DataSettings/YuqueSettings.tsx index 60a8d6ef7c..1f013130c1 100644 --- a/src/renderer/src/pages/settings/DataSettings/YuqueSettings.tsx +++ b/src/renderer/src/pages/settings/DataSettings/YuqueSettings.tsx @@ -5,7 +5,7 @@ import { useMinappPopup } from '@renderer/hooks/useMinappPopup' import { RootState, useAppDispatch } from '@renderer/store' import { setYuqueRepoId, setYuqueToken, setYuqueUrl } from '@renderer/store/settings' import { Button, Space, Tooltip } from 'antd' -import Input from 'antd/es/input/Input' +import { Input } from 'antd' import { FC } from 'react' import { useTranslation } from 'react-i18next' import { useSelector } from 'react-redux' @@ -101,11 +101,12 @@ const YuqueSettings: FC = () => { - From 40519b48c55d508ebba579a3af691f2c1ff46f1d Mon Sep 17 00:00:00 2001 From: fullex <106392080+0xfullex@users.noreply.github.com> Date: Sun, 6 Jul 2025 23:41:20 +0800 Subject: [PATCH 25/54] fix(SelectionAssistant): overall bug fix from v1.4.8 (#7834) * feat(SelectionService): enable toolbar visibility on all workspaces * feat: update selection-hook to v1.0.5 * fix: show toolbar over fullscreen apps * fix(SelectionService): adjust macOS window type handling for fullscreen apps --- package.json | 2 +- src/main/services/SelectionService.ts | 136 ++++++++++++++++---------- yarn.lock | 10 +- 3 files changed, 90 insertions(+), 58 deletions(-) diff --git a/package.json b/package.json index d5cbc6e707..ee48a5733f 100644 --- a/package.json +++ b/package.json @@ -68,7 +68,7 @@ "notion-helper": "^1.3.22", "os-proxy-config": "^1.1.2", "pdfjs-dist": "4.10.38", - "selection-hook": "^1.0.4", + "selection-hook": "^1.0.5", "turndown": "7.2.0" }, "devDependencies": { diff --git a/src/main/services/SelectionService.ts b/src/main/services/SelectionService.ts index 23578b75e0..3be2d5a95a 100644 --- a/src/main/services/SelectionService.ts +++ b/src/main/services/SelectionService.ts @@ -141,7 +141,7 @@ export class SelectionService { * Initialize zoom factor from config and subscribe to changes * Ensures UI elements scale properly with system DPI settings */ - private initZoomFactor() { + private initZoomFactor(): void { const zoomFactor = configManager.getZoomFactor() if (zoomFactor) { this.setZoomFactor(zoomFactor) @@ -154,7 +154,7 @@ export class SelectionService { this.zoomFactor = zoomFactor } - private initConfig() { + private initConfig(): void { this.triggerMode = configManager.getSelectionAssistantTriggerMode() as TriggerMode this.isFollowToolbar = configManager.getSelectionAssistantFollowToolbar() this.isRemeberWinSize = configManager.getSelectionAssistantRemeberWinSize() @@ -207,7 +207,7 @@ export class SelectionService { * @param mode - The mode to set, either 'default', 'whitelist', or 'blacklist' * @param list - An array of strings representing the list of items to include or exclude */ - private setHookGlobalFilterMode(mode: string, list: string[]) { + private setHookGlobalFilterMode(mode: string, list: string[]): void { if (!this.selectionHook) return const modeMap = { @@ -245,7 +245,7 @@ export class SelectionService { } } - private setHookFineTunedList() { + private setHookFineTunedList(): void { if (!this.selectionHook) return const excludeClipboardCursorDetectList = isWin @@ -271,6 +271,11 @@ export class SelectionService { * @returns {boolean} Success status of service start */ public start(): boolean { + if (!isSupportedOS) { + this.logError(new Error('SelectionService start(): not supported on this OS')) + return false + } + if (!this.selectionHook) { this.logError(new Error('SelectionService start(): instance is null')) return false @@ -373,7 +378,7 @@ export class SelectionService { * Toggle the enabled state of the selection service * Will sync the new enabled store to all renderer windows */ - public toggleEnabled(enabled: boolean | undefined = undefined) { + public toggleEnabled(enabled: boolean | undefined = undefined): void { if (!this.selectionHook) return const newEnabled = enabled === undefined ? !configManager.getSelectionAssistantEnabled() : enabled @@ -389,7 +394,7 @@ export class SelectionService { * Sets up window properties, event handlers, and loads the toolbar UI * @param readyCallback Optional callback when window is ready to show */ - private createToolbarWindow(readyCallback?: () => void) { + private createToolbarWindow(readyCallback?: () => void): void { if (this.isToolbarAlive()) return const { toolbarWidth, toolbarHeight } = this.getToolbarRealSize() @@ -414,9 +419,11 @@ export class SelectionService { backgroundMaterial: 'none', // Platform specific settings - // [macOS] DO NOT set type to 'panel', it will not work because it conflicts with other settings // [macOS] DO NOT set focusable to false, it will make other windows bring to front together - ...(isWin ? { type: 'toolbar', focusable: false } : {}), + // [macOS] `panel` conflicts with other settings , + // and log will show `NSWindow does not support nonactivating panel styleMask 0x80` + // but it seems still work on fullscreen apps, so we set this anyway + ...(isWin ? { type: 'toolbar', focusable: false } : { type: 'panel' }), hiddenInMissionControl: true, // [macOS only] acceptFirstMouse: true, // [macOS only] @@ -447,13 +454,6 @@ export class SelectionService { // Add show/hide event listeners this.toolbarWindow.on('show', () => { this.toolbarWindow?.webContents.send(IpcChannel.Selection_ToolbarVisibilityChange, true) - - // [macOS] force the toolbar window to be visible on current desktop - // but it will make docker icon flash. And we found that it's not necessary now. - // will remove after testing - // if (isMac) { - // this.toolbarWindow!.setVisibleOnAllWorkspaces(false) - // } }) this.toolbarWindow.on('hide', () => { @@ -485,10 +485,10 @@ export class SelectionService { * @param point Reference point for positioning, logical coordinates * @param orientation Preferred position relative to reference point */ - private showToolbarAtPosition(point: Point, orientation: RelativeOrientation) { + private showToolbarAtPosition(point: Point, orientation: RelativeOrientation, programName: string): void { if (!this.isToolbarAlive()) { this.createToolbarWindow(() => { - this.showToolbarAtPosition(point, orientation) + this.showToolbarAtPosition(point, orientation, programName) }) return } @@ -509,16 +509,45 @@ export class SelectionService { //should set every time the window is shown this.toolbarWindow!.setAlwaysOnTop(true, 'screen-saver') - // [macOS] force the toolbar window to be visible on current desktop - // but it will make docker icon flash. And we found that it's not necessary now. - // will remove after testing - // if (isMac) { - // this.toolbarWindow!.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }) - // } + // [macOS] a series of hacky ways only for macOS + if (isMac) { + // [macOS] a hacky way + // when set `skipTransformProcessType: true`, if the selection is in self app, it will make the selection canceled after toolbar showing + // so we just don't set `skipTransformProcessType: true` when in self app + const isSelf = ['com.github.Electron', 'com.kangfenmao.CherryStudio'].includes(programName) - // [macOS] MUST use `showInactive()` to prevent other windows bring to front together - // [Windows] is OK for both `show()` and `showInactive()` because of `focusable: false` - this.toolbarWindow!.showInactive() + if (!isSelf) { + // [macOS] an ugly hacky way + // `focusable: true` will make mainWindow disappeared when `setVisibleOnAllWorkspaces` + // so we set `focusable: true` before showing, and then set false after showing + this.toolbarWindow!.setFocusable(false) + + // [macOS] + // force `setVisibleOnAllWorkspaces: true` to let toolbar show in all workspaces. And we MUST not set it to false again + // set `skipTransformProcessType: true` to avoid dock icon spinning when `setVisibleOnAllWorkspaces` + this.toolbarWindow!.setVisibleOnAllWorkspaces(true, { + visibleOnFullScreen: true, + skipTransformProcessType: true + }) + } + + // [macOS] MUST use `showInactive()` to prevent other windows bring to front together + // [Windows] is OK for both `show()` and `showInactive()` because of `focusable: false` + this.toolbarWindow!.showInactive() + + // [macOS] restore the focusable status + this.toolbarWindow!.setFocusable(true) + + this.startHideByMouseKeyListener() + + return + } + + /** + * The following is for Windows + */ + + this.toolbarWindow!.show() /** * [Windows] @@ -588,8 +617,8 @@ export class SelectionService { * Check if toolbar window exists and is not destroyed * @returns {boolean} Toolbar window status */ - private isToolbarAlive() { - return this.toolbarWindow && !this.toolbarWindow.isDestroyed() + private isToolbarAlive(): boolean { + return !!(this.toolbarWindow && !this.toolbarWindow.isDestroyed()) } /** @@ -598,7 +627,7 @@ export class SelectionService { * @param width New toolbar width * @param height New toolbar height */ - public determineToolbarSize(width: number, height: number) { + public determineToolbarSize(width: number, height: number): void { const toolbarWidth = Math.ceil(width) // only update toolbar width if it's changed @@ -611,7 +640,7 @@ export class SelectionService { * Get actual toolbar dimensions accounting for zoom factor * @returns Object containing toolbar width and height */ - private getToolbarRealSize() { + private getToolbarRealSize(): { toolbarWidth: number; toolbarHeight: number } { return { toolbarWidth: this.TOOLBAR_WIDTH * this.zoomFactor, toolbarHeight: this.TOOLBAR_HEIGHT * this.zoomFactor @@ -882,8 +911,8 @@ export class SelectionService { refPoint = { x: Math.round(refPoint.x), y: Math.round(refPoint.y) } } - this.showToolbarAtPosition(refPoint, refOrientation) - this.toolbarWindow?.webContents.send(IpcChannel.Selection_TextSelected, selectionData) + this.showToolbarAtPosition(refPoint, refOrientation, selectionData.programName) + this.toolbarWindow!.webContents.send(IpcChannel.Selection_TextSelected, selectionData) } /** @@ -891,7 +920,7 @@ export class SelectionService { */ // Start monitoring global mouse clicks - private startHideByMouseKeyListener() { + private startHideByMouseKeyListener(): void { try { // Register event handlers this.selectionHook!.on('mouse-down', this.handleMouseDownHide) @@ -904,7 +933,7 @@ export class SelectionService { } // Stop monitoring global mouse clicks - private stopHideByMouseKeyListener() { + private stopHideByMouseKeyListener(): void { if (!this.isHideByMouseKeyListenerActive) return try { @@ -1098,7 +1127,7 @@ export class SelectionService { * Initialize preloaded action windows * Creates a pool of windows at startup for faster response */ - private async initPreloadedActionWindows() { + private async initPreloadedActionWindows(): Promise { try { // Create initial pool of preloaded windows for (let i = 0; i < this.PRELOAD_ACTION_WINDOW_COUNT; i++) { @@ -1112,7 +1141,7 @@ export class SelectionService { /** * Close all preloaded action windows */ - private closePreloadedActionWindows() { + private closePreloadedActionWindows(): void { for (const actionWindow of this.preloadedActionWindows) { if (!actionWindow.isDestroyed()) { actionWindow.destroy() @@ -1124,7 +1153,7 @@ export class SelectionService { * Preload a new action window asynchronously * This method is called after popping a window to ensure we always have windows ready */ - private async pushNewActionWindow() { + private async pushNewActionWindow(): Promise { try { const actionWindow = this.createPreloadedActionWindow() this.preloadedActionWindows.push(actionWindow) @@ -1138,7 +1167,7 @@ export class SelectionService { * Immediately returns a window and asynchronously creates a new one * @returns {BrowserWindow} The action window */ - private popActionWindow() { + private popActionWindow(): BrowserWindow { // Get a window from the preloaded queue or create a new one if empty const actionWindow = this.preloadedActionWindows.pop() || this.createPreloadedActionWindow() @@ -1202,7 +1231,7 @@ export class SelectionService { * Ensures window stays within screen boundaries * @param actionWindow Window to position and show */ - private showActionWindow(actionWindow: BrowserWindow) { + private showActionWindow(actionWindow: BrowserWindow): void { let actionWindowWidth = this.ACTION_WINDOW_WIDTH let actionWindowHeight = this.ACTION_WINDOW_HEIGHT @@ -1228,6 +1257,7 @@ export class SelectionService { }) actionWindow.show() + return } @@ -1292,38 +1322,40 @@ export class SelectionService { * Switches between selection-based and alt-key based triggering * Manages appropriate event listeners for each mode */ - private processTriggerMode() { + private processTriggerMode(): void { + if (!this.selectionHook) return + switch (this.triggerMode) { case TriggerMode.Selected: if (this.isCtrlkeyListenerActive) { - this.selectionHook!.off('key-down', this.handleKeyDownCtrlkeyMode) - this.selectionHook!.off('key-up', this.handleKeyUpCtrlkeyMode) + this.selectionHook.off('key-down', this.handleKeyDownCtrlkeyMode) + this.selectionHook.off('key-up', this.handleKeyUpCtrlkeyMode) this.isCtrlkeyListenerActive = false } - this.selectionHook!.setSelectionPassiveMode(false) + this.selectionHook.setSelectionPassiveMode(false) break case TriggerMode.Ctrlkey: if (!this.isCtrlkeyListenerActive) { - this.selectionHook!.on('key-down', this.handleKeyDownCtrlkeyMode) - this.selectionHook!.on('key-up', this.handleKeyUpCtrlkeyMode) + this.selectionHook.on('key-down', this.handleKeyDownCtrlkeyMode) + this.selectionHook.on('key-up', this.handleKeyUpCtrlkeyMode) this.isCtrlkeyListenerActive = true } - this.selectionHook!.setSelectionPassiveMode(true) + this.selectionHook.setSelectionPassiveMode(true) break case TriggerMode.Shortcut: //remove the ctrlkey listener, don't need any key listener for shortcut mode if (this.isCtrlkeyListenerActive) { - this.selectionHook!.off('key-down', this.handleKeyDownCtrlkeyMode) - this.selectionHook!.off('key-up', this.handleKeyUpCtrlkeyMode) + this.selectionHook.off('key-down', this.handleKeyDownCtrlkeyMode) + this.selectionHook.off('key-up', this.handleKeyUpCtrlkeyMode) this.isCtrlkeyListenerActive = false } - this.selectionHook!.setSelectionPassiveMode(true) + this.selectionHook.setSelectionPassiveMode(true) break } } @@ -1404,13 +1436,13 @@ export class SelectionService { this.isIpcHandlerRegistered = true } - private logInfo(message: string, forceShow: boolean = false) { + private logInfo(message: string, forceShow: boolean = false): void { if (isDev || forceShow) { Logger.info('[SelectionService] Info: ', message) } } - private logError(...args: [...string[], Error]) { + private logError(...args: [...string[], Error]): void { Logger.error('[SelectionService] Error: ', ...args) } } @@ -1423,7 +1455,7 @@ export class SelectionService { export function initSelectionService(): boolean { if (!isSupportedOS) return false - configManager.subscribe(ConfigKeys.SelectionAssistantEnabled, (enabled: boolean) => { + configManager.subscribe(ConfigKeys.SelectionAssistantEnabled, (enabled: boolean): void => { //avoid closure const ss = SelectionService.getInstance() if (!ss) { diff --git a/yarn.lock b/yarn.lock index 0711dc386e..5496f84dee 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5960,7 +5960,7 @@ __metadata: remove-markdown: "npm:^0.6.2" rollup-plugin-visualizer: "npm:^5.12.0" sass: "npm:^1.88.0" - selection-hook: "npm:^1.0.4" + selection-hook: "npm:^1.0.5" shiki: "npm:^3.7.0" string-width: "npm:^7.2.0" styled-components: "npm:^6.1.11" @@ -16928,14 +16928,14 @@ __metadata: languageName: node linkType: hard -"selection-hook@npm:^1.0.4": - version: 1.0.4 - resolution: "selection-hook@npm:1.0.4" +"selection-hook@npm:^1.0.5": + version: 1.0.5 + resolution: "selection-hook@npm:1.0.5" dependencies: node-addon-api: "npm:^8.4.0" node-gyp: "npm:latest" node-gyp-build: "npm:^4.8.4" - checksum: 10c0/8c694cf7bb82159ec8aa2079e9fe74149d8cf5679720e67203b7b56a591f67d9d25e43bb5ed4242c5d6fa8be61ba64863d3b81f6a2fc5dbad7861a5273f65061 + checksum: 10c0/d188e2bafa6d820779e57a721bd2480dc1fde3f9daa2e3f92f1b69712637079e5fd9443575bc8624c98a057608f867d82fb2abf2d0796777db1f18ea50ea0028 languageName: node linkType: hard From 2c5bb5b69941f82d9b42693ec3ed17efd2eade64 Mon Sep 17 00:00:00 2001 From: one Date: Mon, 7 Jul 2025 01:46:11 +0800 Subject: [PATCH 26/54] chore: remove useless classnames (#7795) * chore: remove useless classnames * fix: respect filterIncludeUser --- src/renderer/src/pages/home/Chat.tsx | 31 +++++++------------ .../src/pages/home/Messages/Message.tsx | 8 +---- 2 files changed, 12 insertions(+), 27 deletions(-) diff --git a/src/renderer/src/pages/home/Chat.tsx b/src/renderer/src/pages/home/Chat.tsx index 2639a06387..ac8483fc73 100644 --- a/src/renderer/src/pages/home/Chat.tsx +++ b/src/renderer/src/pages/home/Chat.tsx @@ -57,28 +57,19 @@ const Chat: FC = (props) => { 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 - } + const container = node.parentElement?.closest('.message-content-container') + if (!container) return NodeFilter.FILTER_REJECT - 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 - } - return NodeFilter.FILTER_REJECT - } else { - return NodeFilter.FILTER_REJECT + const message = container.closest('.message') + if (!message) return NodeFilter.FILTER_REJECT + + if (filterIncludeUser) { + return NodeFilter.FILTER_ACCEPT } + if (message.classList.contains('message-assistant')) { + return NodeFilter.FILTER_ACCEPT + } + return NodeFilter.FILTER_REJECT } } diff --git a/src/renderer/src/pages/home/Messages/Message.tsx b/src/renderer/src/pages/home/Messages/Message.tsx index f5de966f0d..b171e65bf7 100644 --- a/src/renderer/src/pages/home/Messages/Message.tsx +++ b/src/renderer/src/pages/home/Messages/Message.tsx @@ -156,13 +156,7 @@ const MessageItem: FC = ({ {!isEditing && ( <> Date: Mon, 7 Jul 2025 07:29:15 +0800 Subject: [PATCH 27/54] chore: move ocr and preprocess into knowledge folder (#7896) chore: move ocr and preprocess into knowledge file --- src/main/{ => knowledage}/ocr/BaseOcrProvider.ts | 0 src/main/{ => knowledage}/ocr/DefaultOcrProvider.ts | 0 src/main/{ => knowledage}/ocr/MacSysOcrProvider.ts | 0 src/main/{ => knowledage}/ocr/OcrProvider.ts | 0 src/main/{ => knowledage}/ocr/OcrProviderFactory.ts | 0 .../{ => knowledage}/preprocess/BasePreprocessProvider.ts | 0 .../{ => knowledage}/preprocess/DefaultPreprocessProvider.ts | 0 .../{ => knowledage}/preprocess/Doc2xPreprocessProvider.ts | 0 .../{ => knowledage}/preprocess/MineruPreprocessProvider.ts | 0 .../{ => knowledage}/preprocess/MistralPreprocessProvider.ts | 0 src/main/{ => knowledage}/preprocess/PreprocessProvider.ts | 0 .../{ => knowledage}/preprocess/PreprocessProviderFactory.ts | 0 src/main/services/KnowledgeService.ts | 4 ++-- 13 files changed, 2 insertions(+), 2 deletions(-) rename src/main/{ => knowledage}/ocr/BaseOcrProvider.ts (100%) rename src/main/{ => knowledage}/ocr/DefaultOcrProvider.ts (100%) rename src/main/{ => knowledage}/ocr/MacSysOcrProvider.ts (100%) rename src/main/{ => knowledage}/ocr/OcrProvider.ts (100%) rename src/main/{ => knowledage}/ocr/OcrProviderFactory.ts (100%) rename src/main/{ => knowledage}/preprocess/BasePreprocessProvider.ts (100%) rename src/main/{ => knowledage}/preprocess/DefaultPreprocessProvider.ts (100%) rename src/main/{ => knowledage}/preprocess/Doc2xPreprocessProvider.ts (100%) rename src/main/{ => knowledage}/preprocess/MineruPreprocessProvider.ts (100%) rename src/main/{ => knowledage}/preprocess/MistralPreprocessProvider.ts (100%) rename src/main/{ => knowledage}/preprocess/PreprocessProvider.ts (100%) rename src/main/{ => knowledage}/preprocess/PreprocessProviderFactory.ts (100%) diff --git a/src/main/ocr/BaseOcrProvider.ts b/src/main/knowledage/ocr/BaseOcrProvider.ts similarity index 100% rename from src/main/ocr/BaseOcrProvider.ts rename to src/main/knowledage/ocr/BaseOcrProvider.ts diff --git a/src/main/ocr/DefaultOcrProvider.ts b/src/main/knowledage/ocr/DefaultOcrProvider.ts similarity index 100% rename from src/main/ocr/DefaultOcrProvider.ts rename to src/main/knowledage/ocr/DefaultOcrProvider.ts diff --git a/src/main/ocr/MacSysOcrProvider.ts b/src/main/knowledage/ocr/MacSysOcrProvider.ts similarity index 100% rename from src/main/ocr/MacSysOcrProvider.ts rename to src/main/knowledage/ocr/MacSysOcrProvider.ts diff --git a/src/main/ocr/OcrProvider.ts b/src/main/knowledage/ocr/OcrProvider.ts similarity index 100% rename from src/main/ocr/OcrProvider.ts rename to src/main/knowledage/ocr/OcrProvider.ts diff --git a/src/main/ocr/OcrProviderFactory.ts b/src/main/knowledage/ocr/OcrProviderFactory.ts similarity index 100% rename from src/main/ocr/OcrProviderFactory.ts rename to src/main/knowledage/ocr/OcrProviderFactory.ts diff --git a/src/main/preprocess/BasePreprocessProvider.ts b/src/main/knowledage/preprocess/BasePreprocessProvider.ts similarity index 100% rename from src/main/preprocess/BasePreprocessProvider.ts rename to src/main/knowledage/preprocess/BasePreprocessProvider.ts diff --git a/src/main/preprocess/DefaultPreprocessProvider.ts b/src/main/knowledage/preprocess/DefaultPreprocessProvider.ts similarity index 100% rename from src/main/preprocess/DefaultPreprocessProvider.ts rename to src/main/knowledage/preprocess/DefaultPreprocessProvider.ts diff --git a/src/main/preprocess/Doc2xPreprocessProvider.ts b/src/main/knowledage/preprocess/Doc2xPreprocessProvider.ts similarity index 100% rename from src/main/preprocess/Doc2xPreprocessProvider.ts rename to src/main/knowledage/preprocess/Doc2xPreprocessProvider.ts diff --git a/src/main/preprocess/MineruPreprocessProvider.ts b/src/main/knowledage/preprocess/MineruPreprocessProvider.ts similarity index 100% rename from src/main/preprocess/MineruPreprocessProvider.ts rename to src/main/knowledage/preprocess/MineruPreprocessProvider.ts diff --git a/src/main/preprocess/MistralPreprocessProvider.ts b/src/main/knowledage/preprocess/MistralPreprocessProvider.ts similarity index 100% rename from src/main/preprocess/MistralPreprocessProvider.ts rename to src/main/knowledage/preprocess/MistralPreprocessProvider.ts diff --git a/src/main/preprocess/PreprocessProvider.ts b/src/main/knowledage/preprocess/PreprocessProvider.ts similarity index 100% rename from src/main/preprocess/PreprocessProvider.ts rename to src/main/knowledage/preprocess/PreprocessProvider.ts diff --git a/src/main/preprocess/PreprocessProviderFactory.ts b/src/main/knowledage/preprocess/PreprocessProviderFactory.ts similarity index 100% rename from src/main/preprocess/PreprocessProviderFactory.ts rename to src/main/knowledage/preprocess/PreprocessProviderFactory.ts diff --git a/src/main/services/KnowledgeService.ts b/src/main/services/KnowledgeService.ts index c57c0eb104..2e5f3a44d0 100644 --- a/src/main/services/KnowledgeService.ts +++ b/src/main/services/KnowledgeService.ts @@ -24,9 +24,9 @@ import { WebLoader } from '@cherrystudio/embedjs-loader-web' import Embeddings from '@main/knowledage/embeddings/Embeddings' import { addFileLoader } from '@main/knowledage/loader' import { NoteLoader } from '@main/knowledage/loader/noteLoader' +import OcrProvider from '@main/knowledage/ocr/OcrProvider' +import PreprocessProvider from '@main/knowledage/preprocess/PreprocessProvider' import Reranker from '@main/knowledage/reranker/Reranker' -import OcrProvider from '@main/ocr/OcrProvider' -import PreprocessProvider from '@main/preprocess/PreprocessProvider' import { windowService } from '@main/services/WindowService' import { getDataPath } from '@main/utils' import { getAllFiles } from '@main/utils/file' From 9fd2583fd5229da007cb662f533a1079f16517d2 Mon Sep 17 00:00:00 2001 From: one Date: Mon, 7 Jul 2025 10:51:56 +0800 Subject: [PATCH 28/54] refactor(CodeEditor): add blur extension, move some extensions to hooks (#7882) --- .../CodeEditor/{hook.ts => hooks.ts} | 57 ++++++++++++++++++- .../src/components/CodeEditor/index.tsx | 28 ++++----- 2 files changed, 65 insertions(+), 20 deletions(-) rename src/renderer/src/components/CodeEditor/{hook.ts => hooks.ts} (56%) diff --git a/src/renderer/src/components/CodeEditor/hook.ts b/src/renderer/src/components/CodeEditor/hooks.ts similarity index 56% rename from src/renderer/src/components/CodeEditor/hook.ts rename to src/renderer/src/components/CodeEditor/hooks.ts index 7e3bd28327..71d74ca3a5 100644 --- a/src/renderer/src/components/CodeEditor/hook.ts +++ b/src/renderer/src/components/CodeEditor/hooks.ts @@ -1,7 +1,8 @@ import { linter } from '@codemirror/lint' // statically imported by @uiw/codemirror-extensions-basic-setup +import { EditorView } from '@codemirror/view' import { useCodeStyle } from '@renderer/context/CodeStyleProvider' -import { Extension } from '@uiw/react-codemirror' -import { useEffect, useState } from 'react' +import { Extension, keymap } from '@uiw/react-codemirror' +import { useEffect, useMemo, useState } from 'react' // 语言对应的 linter 加载器 const linterLoaders: Record Promise> = { @@ -53,3 +54,55 @@ export const useLanguageExtensions = (language: string, lint?: boolean) => { return extensions } + +interface UseSaveKeymapProps { + onSave?: (content: string) => void + enabled?: boolean +} + +/** + * CodeMirror 扩展,用于处理保存快捷键 (Cmd/Ctrl + S) + * @param onSave 保存时触发的回调函数 + * @param enabled 是否启用此快捷键 + * @returns 扩展或空数组 + */ +export function useSaveKeymap({ onSave, enabled = true }: UseSaveKeymapProps) { + return useMemo(() => { + if (!enabled || !onSave) { + return [] + } + + return keymap.of([ + { + key: 'Mod-s', + run: (view: EditorView) => { + onSave(view.state.doc.toString()) + return true + }, + preventDefault: true + } + ]) + }, [onSave, enabled]) +} + +interface UseBlurHandlerProps { + onBlur?: (content: string) => void +} + +/** + * CodeMirror 扩展,用于处理编辑器的 blur 事件 + * @param onBlur blur 事件触发时的回调函数 + * @returns 扩展或空数组 + */ +export function useBlurHandler({ onBlur }: UseBlurHandlerProps) { + return useMemo(() => { + if (!onBlur) { + return [] + } + return EditorView.domEventHandlers({ + blur: (_event, view) => { + onBlur(view.state.doc.toString()) + } + }) + }, [onBlur]) +} diff --git a/src/renderer/src/components/CodeEditor/index.tsx b/src/renderer/src/components/CodeEditor/index.tsx index db699fa030..db7dd5f1ba 100644 --- a/src/renderer/src/components/CodeEditor/index.tsx +++ b/src/renderer/src/components/CodeEditor/index.tsx @@ -1,7 +1,7 @@ import { CodeTool, TOOL_SPECS, useCodeTool } from '@renderer/components/CodeToolbar' import { useCodeStyle } from '@renderer/context/CodeStyleProvider' import { useSettings } from '@renderer/hooks/useSettings' -import CodeMirror, { Annotation, BasicSetupOptions, EditorView, Extension, keymap } from '@uiw/react-codemirror' +import CodeMirror, { Annotation, BasicSetupOptions, EditorView, Extension } from '@uiw/react-codemirror' import diff from 'fast-diff' import { ChevronsDownUp, @@ -14,7 +14,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { memo } from 'react' import { useTranslation } from 'react-i18next' -import { useLanguageExtensions } from './hook' +import { useBlurHandler, useLanguageExtensions, useSaveKeymap } from './hooks' // 标记非用户编辑的变更 const External = Annotation.define() @@ -25,6 +25,7 @@ interface Props { language: string onSave?: (newContent: string) => void onChange?: (newContent: string) => void + onBlur?: (newContent: string) => void setTools?: (value: React.SetStateAction) => void height?: string minHeight?: string @@ -54,6 +55,7 @@ const CodeEditor = ({ language, onSave, onChange, + onBlur, setTools, height, minHeight, @@ -166,28 +168,18 @@ const CodeEditor = ({ setIsUnwrapped(!wrappable) }, [wrappable]) - // 保存功能的快捷键 - const saveKeymap = useMemo(() => { - return keymap.of([ - { - key: 'Mod-s', - run: () => { - handleSave() - return true - }, - preventDefault: true - } - ]) - }, [handleSave]) + const saveKeymapExtension = useSaveKeymap({ onSave, enabled: enableKeymap }) + const blurExtension = useBlurHandler({ onBlur }) const customExtensions = useMemo(() => { return [ ...(extensions ?? []), ...langExtensions, ...(isUnwrapped ? [] : [EditorView.lineWrapping]), - ...(enableKeymap ? [saveKeymap] : []) - ] - }, [extensions, langExtensions, isUnwrapped, enableKeymap, saveKeymap]) + saveKeymapExtension, + blurExtension + ].flat() + }, [extensions, langExtensions, isUnwrapped, saveKeymapExtension, blurExtension]) return ( Date: Mon, 7 Jul 2025 10:53:19 +0800 Subject: [PATCH 29/54] fix(MessageMenubar): use classNames function to handle className (#7903) --- src/renderer/src/pages/home/Messages/MessageMenubar.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/pages/home/Messages/MessageMenubar.tsx b/src/renderer/src/pages/home/Messages/MessageMenubar.tsx index ea5e043c33..dcd86288e1 100644 --- a/src/renderer/src/pages/home/Messages/MessageMenubar.tsx +++ b/src/renderer/src/pages/home/Messages/MessageMenubar.tsx @@ -15,7 +15,7 @@ import { messageBlocksSelectors } from '@renderer/store/messageBlock' import { selectMessagesForTopic } from '@renderer/store/newMessage' import type { Assistant, Model, Topic } from '@renderer/types' import { type Message, MessageBlockType } from '@renderer/types/newMessage' -import { captureScrollableDivAsBlob, captureScrollableDivAsDataURL } from '@renderer/utils' +import { captureScrollableDivAsBlob, captureScrollableDivAsDataURL, classNames } from '@renderer/utils' import { copyMessageAsPlainText } from '@renderer/utils/copy' import { exportMarkdownToJoplin, @@ -399,7 +399,7 @@ const MessageMenubar: FC = (props) => { const softHoverBg = isBubbleStyle && !isLastMessage return ( - + {message.role === 'user' && ( Date: Mon, 7 Jul 2025 11:05:47 +0800 Subject: [PATCH 30/54] feat(mcp): Add default args for built-in file system MCP server (#7865) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat(mcp): 为内置文件系统MCP服务器添加允许目录参数 --- src/renderer/src/store/mcp.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/renderer/src/store/mcp.ts b/src/renderer/src/store/mcp.ts index 05caf291c4..f267c546e9 100644 --- a/src/renderer/src/store/mcp.ts +++ b/src/renderer/src/store/mcp.ts @@ -124,6 +124,7 @@ export const builtinMCPServers: MCPServer[] = [ name: '@cherry/filesystem', type: 'inMemory', description: '实现文件系统操作的模型上下文协议(MCP)的 Node.js 服务器', + args: ['/Users/username/Desktop', '/path/to/other/allowed/dir'], isActive: false, provider: 'CherryAI' }, From 278fd931fb65e70ae19c09c7d869f215573805c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?George=C2=B7Dong?= <98630204+GeorgeDong32@users.noreply.github.com> Date: Mon, 7 Jul 2025 21:00:51 +0800 Subject: [PATCH 31/54] feat: object storage backup (#7791) 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 * feat(backup): migrate S3 Backup dependency from opendal to aws-sdk * refactor(backup): simplify S3 config handling and partial updates * refactor(backup): update Nutstore sync state to use RemoteSyncState * feat(store): add migration 120 to initialize missing s3 settings * feat(settings): add tooltip and help link for S3 storage * fix(s3settings): disable backup button until all fields are set --------- Co-authored-by: suyao --- package.json | 1 + packages/shared/IpcChannel.ts | 5 + src/main/ipc.ts | 5 + src/main/services/BackupManager.ts | 193 ++- src/main/services/RemoteStorage.ts | 57 - src/main/services/S3Storage.ts | 183 +++ src/preload/index.ts | 18 +- .../src/components/S3BackupManager.tsx | 295 ++++ src/renderer/src/components/S3Modals.tsx | 265 ++++ src/renderer/src/i18n/locales/en-us.json | 65 + src/renderer/src/i18n/locales/ja-jp.json | 65 + src/renderer/src/i18n/locales/ru-ru.json | 65 + src/renderer/src/i18n/locales/zh-cn.json | 67 +- src/renderer/src/i18n/locales/zh-tw.json | 67 +- src/renderer/src/init.ts | 4 +- .../settings/DataSettings/DataSettings.tsx | 10 +- .../settings/DataSettings/S3Settings.tsx | 292 ++++ src/renderer/src/services/BackupService.ts | 326 ++++- src/renderer/src/services/NutstoreService.ts | 2 +- src/renderer/src/store/backup.ts | 17 +- src/renderer/src/store/migrate.ts | 10 + src/renderer/src/store/nutstore.ts | 6 +- src/renderer/src/store/settings.ts | 30 +- src/renderer/src/types/index.ts | 15 + yarn.lock | 1272 ++++++++++++++++- 25 files changed, 3193 insertions(+), 142 deletions(-) delete mode 100644 src/main/services/RemoteStorage.ts create mode 100644 src/main/services/S3Storage.ts 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 ee48a5733f..06e656cbc1 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "prepare": "husky" }, "dependencies": { + "@aws-sdk/client-s3": "^3.840.0", "@cherrystudio/pdf-to-img-napi": "^0.0.1", "@libsql/client": "0.14.0", "@libsql/win32-x64-msvc": "^0.4.7", diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index 38c6c2b516..66475c50fa 100644 --- a/packages/shared/IpcChannel.ts +++ b/packages/shared/IpcChannel.ts @@ -165,6 +165,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 f97cb60ed9..4a5433f67f 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -368,6 +368,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..576f004188 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 './S3Storage' 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,100 @@ 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(s3Config) + 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(s3Config) + 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(s3Config) + + const objects = await s3Client.listFiles() + const files = objects + .filter((obj) => obj.key.endsWith('.zip')) + .map((obj) => { + const segments = obj.key.split('/') + const fileName = segments[segments.length - 1] + return { + fileName, + modifiedTime: obj.lastModified || '', + size: obj.size + } + }) + + 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(s3Config) + 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(s3Config) + return await s3Client.checkConnection() + } } export default BackupManager diff --git a/src/main/services/RemoteStorage.ts b/src/main/services/RemoteStorage.ts deleted file mode 100644 index b62489bbbe..0000000000 --- a/src/main/services/RemoteStorage.ts +++ /dev/null @@ -1,57 +0,0 @@ -// import Logger from 'electron-log' -// import { Operator } from 'opendal' - -// export default class RemoteStorage { -// public instance: Operator | 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) - -// 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') -// } - -// 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') -// } - -// try { -// return await this.instance.read(filename) -// } catch (error) { -// Logger.error('[RemoteStorage] Error getting file contents:', error) -// throw error -// } -// } -// } diff --git a/src/main/services/S3Storage.ts b/src/main/services/S3Storage.ts new file mode 100644 index 0000000000..0b45bb0387 --- /dev/null +++ b/src/main/services/S3Storage.ts @@ -0,0 +1,183 @@ +import { + DeleteObjectCommand, + GetObjectCommand, + HeadBucketCommand, + ListObjectsV2Command, + PutObjectCommand, + S3Client +} from '@aws-sdk/client-s3' +import type { S3Config } from '@types' +import Logger from 'electron-log' +import * as net from 'net' +import { Readable } from 'stream' + +/** + * 将可读流转换为 Buffer + */ +function streamToBuffer(stream: Readable): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = [] + stream.on('data', (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))) + stream.on('error', reject) + stream.on('end', () => resolve(Buffer.concat(chunks))) + }) +} + +// 需要使用 Virtual Host-Style 的服务商域名后缀白名单 +const VIRTUAL_HOST_SUFFIXES = ['aliyuncs.com', 'myqcloud.com'] + +/** + * 使用 AWS SDK v3 的简单 S3 封装,兼容之前 RemoteStorage 的最常用接口。 + */ +export default class S3Storage { + private client: S3Client + private bucket: string + private root: string + + constructor(config: S3Config) { + const { endpoint, region, accessKeyId, secretAccessKey, bucket, root } = config + + const usePathStyle = (() => { + if (!endpoint) return false + + try { + const { hostname } = new URL(endpoint) + + if (hostname === 'localhost' || net.isIP(hostname) !== 0) { + return true + } + + const isInWhiteList = VIRTUAL_HOST_SUFFIXES.some((suffix) => hostname.endsWith(suffix)) + return !isInWhiteList + } catch (e) { + Logger.warn('[S3Storage] Failed to parse endpoint, fallback to Path-Style:', endpoint, e) + return true + } + })() + + this.client = new S3Client({ + region, + endpoint: endpoint || undefined, + credentials: { + accessKeyId: accessKeyId, + secretAccessKey: secretAccessKey + }, + forcePathStyle: usePathStyle + }) + + this.bucket = bucket + this.root = root?.replace(/^\/+/g, '').replace(/\/+$/g, '') || '' + + this.putFileContents = this.putFileContents.bind(this) + this.getFileContents = this.getFileContents.bind(this) + this.deleteFile = this.deleteFile.bind(this) + this.listFiles = this.listFiles.bind(this) + this.checkConnection = this.checkConnection.bind(this) + } + + /** + * 内部辅助方法,用来拼接带 root 的对象 key + */ + private buildKey(key: string): string { + if (!this.root) return key + return key.startsWith(`${this.root}/`) ? key : `${this.root}/${key}` + } + + async putFileContents(key: string, data: Buffer | string) { + try { + const contentType = key.endsWith('.zip') ? 'application/zip' : 'application/octet-stream' + + return await this.client.send( + new PutObjectCommand({ + Bucket: this.bucket, + Key: this.buildKey(key), + Body: data, + ContentType: contentType + }) + ) + } catch (error) { + Logger.error('[S3Storage] Error putting object:', error) + throw error + } + } + + async getFileContents(key: string): Promise { + try { + const res = await this.client.send(new GetObjectCommand({ Bucket: this.bucket, Key: this.buildKey(key) })) + if (!res.Body || !(res.Body instanceof Readable)) { + throw new Error('Empty body received from S3') + } + return await streamToBuffer(res.Body as Readable) + } catch (error) { + Logger.error('[S3Storage] Error getting object:', error) + throw error + } + } + + async deleteFile(key: string) { + try { + const keyWithRoot = this.buildKey(key) + const variations = new Set([keyWithRoot, key.replace(/^\//, '')]) + for (const k of variations) { + try { + await this.client.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: k })) + } catch { + // 忽略删除失败 + } + } + } catch (error) { + Logger.error('[S3Storage] Error deleting object:', error) + throw error + } + } + + /** + * 列举指定前缀下的对象,默认列举全部。 + */ + async listFiles(prefix = ''): Promise> { + const files: Array<{ key: string; lastModified?: string; size: number }> = [] + let continuationToken: string | undefined + const fullPrefix = this.buildKey(prefix) + + try { + do { + const res = await this.client.send( + new ListObjectsV2Command({ + Bucket: this.bucket, + Prefix: fullPrefix === '' ? undefined : fullPrefix, + ContinuationToken: continuationToken + }) + ) + + res.Contents?.forEach((obj) => { + if (!obj.Key) return + files.push({ + key: obj.Key, + lastModified: obj.LastModified?.toISOString(), + size: obj.Size ?? 0 + }) + }) + + continuationToken = res.IsTruncated ? res.NextContinuationToken : undefined + } while (continuationToken) + + return files + } catch (error) { + Logger.error('[S3Storage] Error listing objects:', error) + throw error + } + } + + /** + * 尝试调用 HeadBucket 判断凭证/网络是否可用 + */ + async checkConnection() { + try { + await this.client.send(new HeadBucketCommand({ Bucket: this.bucket })) + return true + } catch (error) { + Logger.error('[S3Storage] Error checking connection:', error) + throw error + } + } +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 533263512d..ea081645b2 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -10,6 +10,7 @@ import { KnowledgeItem, MCPServer, Provider, + S3Config, Shortcut, ThemeMode, WebDavConfig @@ -72,9 +73,9 @@ const api = { decompress: (text: Buffer) => ipcRenderer.invoke(IpcChannel.Zip_Decompress, text) }, backup: { - backup: (fileName: string, data: string, destinationPath?: string, skipBackupFile?: boolean) => - ipcRenderer.invoke(IpcChannel.Backup_Backup, fileName, data, destinationPath, skipBackupFile), - restore: (backupPath: string) => ipcRenderer.invoke(IpcChannel.Backup_Restore, backupPath), + backup: (filename: string, content: string, path: string, skipBackupFile: boolean) => + ipcRenderer.invoke(IpcChannel.Backup_Backup, filename, content, path, skipBackupFile), + restore: (path: string) => ipcRenderer.invoke(IpcChannel.Backup_Restore, path), backupToWebdav: (data: string, webdavConfig: WebDavConfig) => ipcRenderer.invoke(IpcChannel.Backup_BackupToWebdav, data, webdavConfig), restoreFromWebdav: (webdavConfig: WebDavConfig) => @@ -86,7 +87,16 @@ 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), + checkWebdavConnection: (webdavConfig: WebDavConfig) => + ipcRenderer.invoke(IpcChannel.Backup_CheckConnection, 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..f644d2dce6 --- /dev/null +++ b/src/renderer/src/components/S3BackupManager.tsx @@ -0,0 +1,295 @@ +import { DeleteOutlined, ExclamationCircleOutlined, ReloadOutlined } from '@ant-design/icons' +import { restoreFromS3 } from '@renderer/services/BackupService' +import type { S3Config } from '@renderer/types' +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 S3BackupManagerProps { + visible: boolean + onClose: () => void + s3Config: Partial + 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, accessKeyId, secretAccessKey } = s3Config + + const fetchBackupFiles = useCallback(async () => { + if (!endpoint || !region || !bucket || !accessKeyId || !secretAccessKey) { + window.message.error(t('settings.data.s3.manager.config.incomplete')) + return + } + + setLoading(true) + try { + const files = await window.api.backup.listS3Files({ + ...s3Config, + endpoint, + region, + bucket, + accessKeyId, + secretAccessKey, + skipBackupFile: false, + autoSync: false, + syncInterval: 0, + maxBackups: 0 + }) + 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, accessKeyId, secretAccessKey, t, s3Config]) + + 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 || !accessKeyId || !secretAccessKey) { + 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(), { + ...s3Config, + endpoint, + region, + bucket, + accessKeyId, + secretAccessKey, + skipBackupFile: false, + autoSync: false, + syncInterval: 0, + maxBackups: 0 + }) + } + 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 || !accessKeyId || !secretAccessKey) { + 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, { + ...s3Config, + endpoint, + region, + bucket, + accessKeyId, + secretAccessKey, + skipBackupFile: false, + autoSync: false, + syncInterval: 0, + maxBackups: 0 + }) + 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 || !accessKeyId || !secretAccessKey) { + 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..75c8b31b3a --- /dev/null +++ b/src/renderer/src/components/S3Modals.tsx @@ -0,0 +1,265 @@ +import { backupToS3 } 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 + accessKeyId: string | undefined + secretAccessKey: string | undefined + root?: string | undefined +} + +export function useS3RestoreModal({ + endpoint, + region, + bucket, + accessKeyId, + secretAccessKey, + 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 || !accessKeyId || !secretAccessKey) { + 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, + accessKeyId, + secretAccessKey, + root, + autoSync: false, + syncInterval: 0, + maxBackups: 0, + skipBackupFile: false + }) + 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, accessKeyId, secretAccessKey, root, t]) + + const handleRestore = useCallback(async () => { + if (!selectedFile || !endpoint || !region || !bucket || !accessKeyId || !secretAccessKey) { + 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', { fileName: selectedFile }), + okText: t('settings.data.s3.restore.confirm.ok'), + cancelText: t('settings.data.s3.restore.confirm.cancel'), + centered: true, + onOk: async () => { + setRestoring(true) + try { + await window.api.backup.restoreFromS3({ + endpoint, + region, + bucket, + accessKeyId, + secretAccessKey, + root, + fileName: selectedFile, + autoSync: false, + syncInterval: 0, + maxBackups: 0, + skipBackupFile: false + }) + window.message.success({ content: t('message.restore.success'), key: 's3-restore' }) + 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, accessKeyId, secretAccessKey, 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(setS3Partial({ endpoint: endpoint || '' }))} + /> + + + + {t('settings.data.s3.region')} + setRegion(e.target.value)} + style={{ width: 250 }} + onBlur={() => dispatch(setS3Partial({ region: region || '' }))} + /> + + + + {t('settings.data.s3.bucket')} + setBucket(e.target.value)} + style={{ width: 250 }} + onBlur={() => dispatch(setS3Partial({ bucket: bucket || '' }))} + /> + + + + {t('settings.data.s3.accessKeyId')} + setAccessKeyId(e.target.value)} + style={{ width: 250 }} + onBlur={() => dispatch(setS3Partial({ accessKeyId: accessKeyId || '' }))} + /> + + + + {t('settings.data.s3.secretAccessKey')} + setSecretAccessKey(e.target.value)} + style={{ width: 250 }} + onBlur={() => dispatch(setS3Partial({ secretAccessKey: secretAccessKey || '' }))} + /> + + + + {t('settings.data.s3.root')} + setRoot(e.target.value)} + style={{ width: 250 }} + onBlur={() => dispatch(setS3Partial({ 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..00a09acf54 100644 --- a/src/renderer/src/services/BackupService.ts +++ b/src/renderer/src/services/BackupService.ts @@ -4,11 +4,63 @@ 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 { S3Config, WebDavConfig } from '@renderer/types' import { uuid } from '@renderer/utils' import dayjs from 'dayjs' import { NotificationService } from './NotificationService' +// 重试删除S3文件的辅助函数 +async function deleteS3FileWithRetry(fileName: string, s3Config: S3Config, 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: WebDavConfig, 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 +213,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 +298,160 @@ export async function restoreFromWebdav(fileName?: string) { } } +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 + } + + if (autoBackupProcess) { + showMessage = false + } + + isManualBackupRunning = true + + store.dispatch(setS3SyncState({ syncing: true, lastSyncError: null })) + + const s3Config = store.getState().settings.s3 + 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 { + const success = await window.api.backup.backupToS3(backupData, { + ...s3Config, + fileName: finalFileName + }) + + if (success) { + store.dispatch( + setS3SyncState({ + lastSyncError: null, + syncing: false, + lastSyncTime: Date.now() + }) + ) + 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 (s3Config.maxBackups > 0) { + try { + // 获取所有备份文件 + const files = await window.api.backup.listS3Files(s3Config) + + // 筛选当前设备的备份文件 + const currentDeviceFiles = files.filter((file) => { + return file.fileName.includes(deviceType) && file.fileName.includes(hostname) + }) + + // 如果当前设备的备份文件数量超过最大保留数量,删除最旧的文件 + if (currentDeviceFiles.length > s3Config.maxBackups) { + const filesToDelete = currentDeviceFiles.slice(s3Config.maxBackups) + + 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, s3Config) + + 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) + } + } + } else { + if (autoBackupProcess) { + throw new Error(i18n.t('message.backup.failed')) + } + + store.dispatch(setS3SyncState({ lastSyncError: 'Backup failed' })) + showMessage && window.message.error({ content: i18n.t('message.backup.failed'), key: 'backup' }) + } + } catch (error: any) { + 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 s3Config = store.getState().settings.s3 + + if (!fileName) { + const files = await window.api.backup.listS3Files(s3Config) + if (files.length > 0) { + fileName = files[0].fileName + } + } + + if (fileName) { + const restoreData = await window.api.backup.restoreFromS3({ + ...s3Config, + fileName + }) + const data = JSON.parse(restoreData) + await handleData(data) + store.dispatch( + setS3SyncState({ + lastSyncTime: Date.now(), + syncing: false, + lastSyncError: null + }) + ) + } +} + let autoSyncStarted = false let syncTimeout: NodeJS.Timeout | null = null let isAutoBackupRunning = false @@ -252,9 +462,18 @@ export function startAutoSync(immediate = false) { return } - const { webdavAutoSync, webdavHost } = store.getState().settings + const settings = store.getState().settings + const { webdavAutoSync, webdavHost } = settings + const s3Settings = settings.s3 - if (!webdavAutoSync || !webdavHost) { + const s3AutoSync = s3Settings?.autoSync + const s3Endpoint = s3Settings?.endpoint + + // 检查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 +496,28 @@ export function startAutoSync(immediate = false) { syncTimeout = null } - const { webdavSyncInterval } = store.getState().settings - const { webdavSync } = store.getState().backup + const settings = store.getState().settings + const _webdavSyncInterval = settings.webdavSyncInterval + const _s3SyncInterval = settings.s3?.syncInterval + const { webdavSync, s3Sync } = store.getState().backup - if (webdavSyncInterval <= 0) { + // 使用当前激活的同步配置 + const syncInterval = hasWebdavConfig ? _webdavSyncInterval : _s3SyncInterval + const lastSyncTime = hasWebdavConfig ? webdavSync?.lastSyncTime : s3Sync?.lastSyncTime + + if (!syncInterval || 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 +526,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 +547,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 +577,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/services/NutstoreService.ts b/src/renderer/src/services/NutstoreService.ts index c52e6b8030..6eb727abc1 100644 --- a/src/renderer/src/services/NutstoreService.ts +++ b/src/renderer/src/services/NutstoreService.ts @@ -48,7 +48,7 @@ export async function checkConnection() { return false } - const isSuccess = await window.api.backup.checkConnection({ + const isSuccess = await window.api.backup.checkWebdavConnection({ ...config, webdavPath: '/' }) diff --git a/src/renderer/src/store/backup.ts b/src/renderer/src/store/backup.ts index a8b7d342c5..0418e5ab96 100644 --- a/src/renderer/src/store/backup.ts +++ b/src/renderer/src/store/backup.ts @@ -1,13 +1,14 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit' -export interface WebDAVSyncState { +export interface RemoteSyncState { lastSyncTime: number | null syncing: boolean lastSyncError: string | null } export interface BackupState { - webdavSync: WebDAVSyncState + webdavSync: RemoteSyncState + s3Sync: RemoteSyncState } const initialState: BackupState = { @@ -15,6 +16,11 @@ const initialState: BackupState = { lastSyncTime: null, syncing: false, lastSyncError: null + }, + s3Sync: { + lastSyncTime: null, + syncing: false, + lastSyncError: null } } @@ -22,11 +28,14 @@ const backupSlice = createSlice({ name: 'backup', initialState, reducers: { - setWebDAVSyncState: (state, action: PayloadAction>) => { + 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/migrate.ts b/src/renderer/src/store/migrate.ts index 1e29aebd69..a305d03735 100644 --- a/src/renderer/src/store/migrate.ts +++ b/src/renderer/src/store/migrate.ts @@ -1726,6 +1726,16 @@ const migrateConfig = { } catch (error) { return state } + }, + '120': (state: RootState) => { + try { + if (!state.settings.s3) { + state.settings.s3 = settingsInitialState.s3 + } + return state + } catch (error) { + return state + } } } diff --git a/src/renderer/src/store/nutstore.ts b/src/renderer/src/store/nutstore.ts index 354a93bd39..cd4721b6df 100644 --- a/src/renderer/src/store/nutstore.ts +++ b/src/renderer/src/store/nutstore.ts @@ -1,8 +1,8 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit' -import { WebDAVSyncState } from './backup' +import { RemoteSyncState } from './backup' -export interface NutstoreSyncState extends WebDAVSyncState {} +export interface NutstoreSyncState extends RemoteSyncState {} export interface NutstoreState { nutstoreToken: string | null @@ -42,7 +42,7 @@ const nutstoreSlice = createSlice({ setNutstoreSyncInterval: (state, action: PayloadAction) => { state.nutstoreSyncInterval = action.payload }, - setNutstoreSyncState: (state, action: PayloadAction>) => { + setNutstoreSyncState: (state, action: PayloadAction>) => { state.nutstoreSyncState = { ...state.nutstoreSyncState, ...action.payload } }, setNutstoreSkipBackupFile: (state, action: PayloadAction) => { diff --git a/src/renderer/src/store/settings.ts b/src/renderer/src/store/settings.ts index 778837e388..771e7cf349 100644 --- a/src/renderer/src/store/settings.ts +++ b/src/renderer/src/store/settings.ts @@ -8,13 +8,14 @@ import { OpenAIServiceTier, OpenAISummaryText, PaintingProvider, + S3Config, ThemeMode, TranslateLanguageVarious } from '@renderer/types' import { uuid } from '@renderer/utils' import { UpgradeChannel } from '@shared/config/constant' -import { WebDAVSyncState } from './backup' +import { RemoteSyncState } from './backup' export type SendMessageShortcut = 'Enter' | 'Shift+Enter' | 'Ctrl+Enter' | 'Command+Enter' | 'Alt+Enter' @@ -30,7 +31,7 @@ export const DEFAULT_SIDEBAR_ICONS: SidebarIcon[] = [ 'files' ] -export interface NutstoreSyncRuntime extends WebDAVSyncState {} +export interface NutstoreSyncRuntime extends RemoteSyncState {} export type AssistantIconType = 'model' | 'emoji' | 'none' @@ -189,6 +190,7 @@ export interface SettingsState { knowledge: boolean } defaultPaintingProvider: PaintingProvider + s3: S3Config } export type MultiModelMessageStyle = 'horizontal' | 'vertical' | 'fold' | 'grid' @@ -336,7 +338,19 @@ export const initialState: SettingsState = { backup: false, knowledge: false }, - defaultPaintingProvider: 'aihubmix' + defaultPaintingProvider: 'aihubmix', + s3: { + endpoint: '', + region: '', + bucket: '', + accessKeyId: '', + secretAccessKey: '', + root: '', + autoSync: false, + syncInterval: 0, + maxBackups: 0, + skipBackupFile: false + } } const settingsSlice = createSlice({ @@ -703,6 +717,12 @@ const settingsSlice = createSlice({ }, setDefaultPaintingProvider: (state, action: PayloadAction) => { state.defaultPaintingProvider = action.payload + }, + setS3: (state, action: PayloadAction) => { + state.s3 = action.payload + }, + setS3Partial: (state, action: PayloadAction>) => { + state.s3 = { ...state.s3, ...action.payload } } } }) @@ -812,7 +832,9 @@ export const { setOpenAISummaryText, setOpenAIServiceTier, setNotificationSettings, - setDefaultPaintingProvider + setDefaultPaintingProvider, + setS3, + setS3Partial } = settingsSlice.actions export default settingsSlice.reducer diff --git a/src/renderer/src/types/index.ts b/src/renderer/src/types/index.ts index 084cc130e2..21eb4bbc99 100644 --- a/src/renderer/src/types/index.ts +++ b/src/renderer/src/types/index.ts @@ -749,4 +749,19 @@ 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 + accessKeyId: string + secretAccessKey: string + root?: string + fileName?: string + skipBackupFile: boolean + autoSync: boolean + syncInterval: number + maxBackups: number +} + export type { Message } from './newMessage' diff --git a/yarn.lock b/yarn.lock index 5496f84dee..7afa0defc2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -228,6 +228,650 @@ __metadata: languageName: node linkType: hard +"@aws-crypto/crc32@npm:5.2.0": + version: 5.2.0 + resolution: "@aws-crypto/crc32@npm:5.2.0" + dependencies: + "@aws-crypto/util": "npm:^5.2.0" + "@aws-sdk/types": "npm:^3.222.0" + tslib: "npm:^2.6.2" + checksum: 10c0/eab9581d3363af5ea498ae0e72de792f54d8890360e14a9d8261b7b5c55ebe080279fb2556e07994d785341cdaa99ab0b1ccf137832b53b5904cd6928f2b094b + languageName: node + linkType: hard + +"@aws-crypto/crc32c@npm:5.2.0": + version: 5.2.0 + resolution: "@aws-crypto/crc32c@npm:5.2.0" + dependencies: + "@aws-crypto/util": "npm:^5.2.0" + "@aws-sdk/types": "npm:^3.222.0" + tslib: "npm:^2.6.2" + checksum: 10c0/223efac396cdebaf5645568fa9a38cd0c322c960ae1f4276bedfe2e1031d0112e49d7d39225d386354680ecefae29f39af469a84b2ddfa77cb6692036188af77 + languageName: node + linkType: hard + +"@aws-crypto/sha1-browser@npm:5.2.0": + version: 5.2.0 + resolution: "@aws-crypto/sha1-browser@npm:5.2.0" + dependencies: + "@aws-crypto/supports-web-crypto": "npm:^5.2.0" + "@aws-crypto/util": "npm:^5.2.0" + "@aws-sdk/types": "npm:^3.222.0" + "@aws-sdk/util-locate-window": "npm:^3.0.0" + "@smithy/util-utf8": "npm:^2.0.0" + tslib: "npm:^2.6.2" + checksum: 10c0/51fed0bf078c10322d910af179871b7d299dde5b5897873ffbeeb036f427e5d11d23db9794439226544b73901920fd19f4d86bbc103ed73cc0cfdea47a83c6ac + languageName: node + linkType: hard + +"@aws-crypto/sha256-browser@npm:5.2.0": + version: 5.2.0 + resolution: "@aws-crypto/sha256-browser@npm:5.2.0" + dependencies: + "@aws-crypto/sha256-js": "npm:^5.2.0" + "@aws-crypto/supports-web-crypto": "npm:^5.2.0" + "@aws-crypto/util": "npm:^5.2.0" + "@aws-sdk/types": "npm:^3.222.0" + "@aws-sdk/util-locate-window": "npm:^3.0.0" + "@smithy/util-utf8": "npm:^2.0.0" + tslib: "npm:^2.6.2" + checksum: 10c0/05f6d256794df800fe9aef5f52f2ac7415f7f3117d461f85a6aecaa4e29e91527b6fd503681a17136fa89e9dd3d916e9c7e4cfb5eba222875cb6c077bdc1d00d + languageName: node + linkType: hard + +"@aws-crypto/sha256-js@npm:5.2.0, @aws-crypto/sha256-js@npm:^5.2.0": + version: 5.2.0 + resolution: "@aws-crypto/sha256-js@npm:5.2.0" + dependencies: + "@aws-crypto/util": "npm:^5.2.0" + "@aws-sdk/types": "npm:^3.222.0" + tslib: "npm:^2.6.2" + checksum: 10c0/6c48701f8336341bb104dfde3d0050c89c288051f6b5e9bdfeb8091cf3ffc86efcd5c9e6ff2a4a134406b019c07aca9db608128f8d9267c952578a3108db9fd1 + languageName: node + linkType: hard + +"@aws-crypto/supports-web-crypto@npm:^5.2.0": + version: 5.2.0 + resolution: "@aws-crypto/supports-web-crypto@npm:5.2.0" + dependencies: + tslib: "npm:^2.6.2" + checksum: 10c0/4d2118e29d68ca3f5947f1e37ce1fbb3239a0c569cc938cdc8ab8390d595609b5caf51a07c9e0535105b17bf5c52ea256fed705a07e9681118120ab64ee73af2 + languageName: node + linkType: hard + +"@aws-crypto/util@npm:5.2.0, @aws-crypto/util@npm:^5.2.0": + version: 5.2.0 + resolution: "@aws-crypto/util@npm:5.2.0" + dependencies: + "@aws-sdk/types": "npm:^3.222.0" + "@smithy/util-utf8": "npm:^2.0.0" + tslib: "npm:^2.6.2" + checksum: 10c0/0362d4c197b1fd64b423966945130207d1fe23e1bb2878a18e361f7743c8d339dad3f8729895a29aa34fff6a86c65f281cf5167c4bf253f21627ae80b6dd2951 + languageName: node + linkType: hard + +"@aws-sdk/client-s3@npm:^3.840.0": + version: 3.840.0 + resolution: "@aws-sdk/client-s3@npm:3.840.0" + dependencies: + "@aws-crypto/sha1-browser": "npm:5.2.0" + "@aws-crypto/sha256-browser": "npm:5.2.0" + "@aws-crypto/sha256-js": "npm:5.2.0" + "@aws-sdk/core": "npm:3.840.0" + "@aws-sdk/credential-provider-node": "npm:3.840.0" + "@aws-sdk/middleware-bucket-endpoint": "npm:3.840.0" + "@aws-sdk/middleware-expect-continue": "npm:3.840.0" + "@aws-sdk/middleware-flexible-checksums": "npm:3.840.0" + "@aws-sdk/middleware-host-header": "npm:3.840.0" + "@aws-sdk/middleware-location-constraint": "npm:3.840.0" + "@aws-sdk/middleware-logger": "npm:3.840.0" + "@aws-sdk/middleware-recursion-detection": "npm:3.840.0" + "@aws-sdk/middleware-sdk-s3": "npm:3.840.0" + "@aws-sdk/middleware-ssec": "npm:3.840.0" + "@aws-sdk/middleware-user-agent": "npm:3.840.0" + "@aws-sdk/region-config-resolver": "npm:3.840.0" + "@aws-sdk/signature-v4-multi-region": "npm:3.840.0" + "@aws-sdk/types": "npm:3.840.0" + "@aws-sdk/util-endpoints": "npm:3.840.0" + "@aws-sdk/util-user-agent-browser": "npm:3.840.0" + "@aws-sdk/util-user-agent-node": "npm:3.840.0" + "@aws-sdk/xml-builder": "npm:3.821.0" + "@smithy/config-resolver": "npm:^4.1.4" + "@smithy/core": "npm:^3.6.0" + "@smithy/eventstream-serde-browser": "npm:^4.0.4" + "@smithy/eventstream-serde-config-resolver": "npm:^4.1.2" + "@smithy/eventstream-serde-node": "npm:^4.0.4" + "@smithy/fetch-http-handler": "npm:^5.0.4" + "@smithy/hash-blob-browser": "npm:^4.0.4" + "@smithy/hash-node": "npm:^4.0.4" + "@smithy/hash-stream-node": "npm:^4.0.4" + "@smithy/invalid-dependency": "npm:^4.0.4" + "@smithy/md5-js": "npm:^4.0.4" + "@smithy/middleware-content-length": "npm:^4.0.4" + "@smithy/middleware-endpoint": "npm:^4.1.13" + "@smithy/middleware-retry": "npm:^4.1.14" + "@smithy/middleware-serde": "npm:^4.0.8" + "@smithy/middleware-stack": "npm:^4.0.4" + "@smithy/node-config-provider": "npm:^4.1.3" + "@smithy/node-http-handler": "npm:^4.0.6" + "@smithy/protocol-http": "npm:^5.1.2" + "@smithy/smithy-client": "npm:^4.4.5" + "@smithy/types": "npm:^4.3.1" + "@smithy/url-parser": "npm:^4.0.4" + "@smithy/util-base64": "npm:^4.0.0" + "@smithy/util-body-length-browser": "npm:^4.0.0" + "@smithy/util-body-length-node": "npm:^4.0.0" + "@smithy/util-defaults-mode-browser": "npm:^4.0.21" + "@smithy/util-defaults-mode-node": "npm:^4.0.21" + "@smithy/util-endpoints": "npm:^3.0.6" + "@smithy/util-middleware": "npm:^4.0.4" + "@smithy/util-retry": "npm:^4.0.6" + "@smithy/util-stream": "npm:^4.2.2" + "@smithy/util-utf8": "npm:^4.0.0" + "@smithy/util-waiter": "npm:^4.0.6" + "@types/uuid": "npm:^9.0.1" + tslib: "npm:^2.6.2" + uuid: "npm:^9.0.1" + checksum: 10c0/c923c8a0b6743f81478758641190b7c1da8306e7f6bf81d7f9df722be183f7ad506ad47e1b9de0807961fffec6b36074385d4c611c0c2fb08c8e5b1d47948a48 + languageName: node + linkType: hard + +"@aws-sdk/client-sso@npm:3.840.0": + version: 3.840.0 + resolution: "@aws-sdk/client-sso@npm:3.840.0" + dependencies: + "@aws-crypto/sha256-browser": "npm:5.2.0" + "@aws-crypto/sha256-js": "npm:5.2.0" + "@aws-sdk/core": "npm:3.840.0" + "@aws-sdk/middleware-host-header": "npm:3.840.0" + "@aws-sdk/middleware-logger": "npm:3.840.0" + "@aws-sdk/middleware-recursion-detection": "npm:3.840.0" + "@aws-sdk/middleware-user-agent": "npm:3.840.0" + "@aws-sdk/region-config-resolver": "npm:3.840.0" + "@aws-sdk/types": "npm:3.840.0" + "@aws-sdk/util-endpoints": "npm:3.840.0" + "@aws-sdk/util-user-agent-browser": "npm:3.840.0" + "@aws-sdk/util-user-agent-node": "npm:3.840.0" + "@smithy/config-resolver": "npm:^4.1.4" + "@smithy/core": "npm:^3.6.0" + "@smithy/fetch-http-handler": "npm:^5.0.4" + "@smithy/hash-node": "npm:^4.0.4" + "@smithy/invalid-dependency": "npm:^4.0.4" + "@smithy/middleware-content-length": "npm:^4.0.4" + "@smithy/middleware-endpoint": "npm:^4.1.13" + "@smithy/middleware-retry": "npm:^4.1.14" + "@smithy/middleware-serde": "npm:^4.0.8" + "@smithy/middleware-stack": "npm:^4.0.4" + "@smithy/node-config-provider": "npm:^4.1.3" + "@smithy/node-http-handler": "npm:^4.0.6" + "@smithy/protocol-http": "npm:^5.1.2" + "@smithy/smithy-client": "npm:^4.4.5" + "@smithy/types": "npm:^4.3.1" + "@smithy/url-parser": "npm:^4.0.4" + "@smithy/util-base64": "npm:^4.0.0" + "@smithy/util-body-length-browser": "npm:^4.0.0" + "@smithy/util-body-length-node": "npm:^4.0.0" + "@smithy/util-defaults-mode-browser": "npm:^4.0.21" + "@smithy/util-defaults-mode-node": "npm:^4.0.21" + "@smithy/util-endpoints": "npm:^3.0.6" + "@smithy/util-middleware": "npm:^4.0.4" + "@smithy/util-retry": "npm:^4.0.6" + "@smithy/util-utf8": "npm:^4.0.0" + tslib: "npm:^2.6.2" + checksum: 10c0/6d83d3dfefaab731818eade68f08f563906e9bee37c0836da262f47b15be8b1885e813a67927dd2549b1a043dffb551a2ec39a963ef335b9df54e8b9faf534e5 + languageName: node + linkType: hard + +"@aws-sdk/core@npm:3.840.0": + version: 3.840.0 + resolution: "@aws-sdk/core@npm:3.840.0" + dependencies: + "@aws-sdk/types": "npm:3.840.0" + "@aws-sdk/xml-builder": "npm:3.821.0" + "@smithy/core": "npm:^3.6.0" + "@smithy/node-config-provider": "npm:^4.1.3" + "@smithy/property-provider": "npm:^4.0.4" + "@smithy/protocol-http": "npm:^5.1.2" + "@smithy/signature-v4": "npm:^5.1.2" + "@smithy/smithy-client": "npm:^4.4.5" + "@smithy/types": "npm:^4.3.1" + "@smithy/util-base64": "npm:^4.0.0" + "@smithy/util-body-length-browser": "npm:^4.0.0" + "@smithy/util-middleware": "npm:^4.0.4" + "@smithy/util-utf8": "npm:^4.0.0" + fast-xml-parser: "npm:4.4.1" + tslib: "npm:^2.6.2" + checksum: 10c0/6bd10d86a85c2f52d1a6ca3fe4e45fb8b8ba43abb0f52d2cd14b8d3fb9908f2e1ec0cd9dcf7980df847cfb3dbcd329679a6fe7d029fbc57840d716d1120bc445 + languageName: node + linkType: hard + +"@aws-sdk/credential-provider-env@npm:3.840.0": + version: 3.840.0 + resolution: "@aws-sdk/credential-provider-env@npm:3.840.0" + dependencies: + "@aws-sdk/core": "npm:3.840.0" + "@aws-sdk/types": "npm:3.840.0" + "@smithy/property-provider": "npm:^4.0.4" + "@smithy/types": "npm:^4.3.1" + tslib: "npm:^2.6.2" + checksum: 10c0/ed12ee47f67980b2a434a168de12401312d995428f33e487ea64420a670fdfec59324318eb02e630ef779336723499ca13533cec2b64f1f9d9f48fe9c7e138ef + languageName: node + linkType: hard + +"@aws-sdk/credential-provider-http@npm:3.840.0": + version: 3.840.0 + resolution: "@aws-sdk/credential-provider-http@npm:3.840.0" + dependencies: + "@aws-sdk/core": "npm:3.840.0" + "@aws-sdk/types": "npm:3.840.0" + "@smithy/fetch-http-handler": "npm:^5.0.4" + "@smithy/node-http-handler": "npm:^4.0.6" + "@smithy/property-provider": "npm:^4.0.4" + "@smithy/protocol-http": "npm:^5.1.2" + "@smithy/smithy-client": "npm:^4.4.5" + "@smithy/types": "npm:^4.3.1" + "@smithy/util-stream": "npm:^4.2.2" + tslib: "npm:^2.6.2" + checksum: 10c0/21892b9252b4f7692f9a3e9999a5991e476a8ef7541674c230e94d6a5a1fa7381e643e69d1f7e77dd3bbcee952fa9f4bf45793abf8e5a9c60c0ecb407f10ad4f + languageName: node + linkType: hard + +"@aws-sdk/credential-provider-ini@npm:3.840.0": + version: 3.840.0 + resolution: "@aws-sdk/credential-provider-ini@npm:3.840.0" + dependencies: + "@aws-sdk/core": "npm:3.840.0" + "@aws-sdk/credential-provider-env": "npm:3.840.0" + "@aws-sdk/credential-provider-http": "npm:3.840.0" + "@aws-sdk/credential-provider-process": "npm:3.840.0" + "@aws-sdk/credential-provider-sso": "npm:3.840.0" + "@aws-sdk/credential-provider-web-identity": "npm:3.840.0" + "@aws-sdk/nested-clients": "npm:3.840.0" + "@aws-sdk/types": "npm:3.840.0" + "@smithy/credential-provider-imds": "npm:^4.0.6" + "@smithy/property-provider": "npm:^4.0.4" + "@smithy/shared-ini-file-loader": "npm:^4.0.4" + "@smithy/types": "npm:^4.3.1" + tslib: "npm:^2.6.2" + checksum: 10c0/963c9a675b327f70c7123c392ce0e96ee9e451e118b3af7ba1ea65921965718f96896c29992448c4d5f7739c499e66007aed03be28e094fab0728b8b2bb19731 + languageName: node + linkType: hard + +"@aws-sdk/credential-provider-node@npm:3.840.0": + version: 3.840.0 + resolution: "@aws-sdk/credential-provider-node@npm:3.840.0" + dependencies: + "@aws-sdk/credential-provider-env": "npm:3.840.0" + "@aws-sdk/credential-provider-http": "npm:3.840.0" + "@aws-sdk/credential-provider-ini": "npm:3.840.0" + "@aws-sdk/credential-provider-process": "npm:3.840.0" + "@aws-sdk/credential-provider-sso": "npm:3.840.0" + "@aws-sdk/credential-provider-web-identity": "npm:3.840.0" + "@aws-sdk/types": "npm:3.840.0" + "@smithy/credential-provider-imds": "npm:^4.0.6" + "@smithy/property-provider": "npm:^4.0.4" + "@smithy/shared-ini-file-loader": "npm:^4.0.4" + "@smithy/types": "npm:^4.3.1" + tslib: "npm:^2.6.2" + checksum: 10c0/cef45e1d12aee1e05aae0498a03eafe6b0f18aa612cb7b49965dcb535bb7bc91339f33de299afb235d20e557a9a2ce16ab1ff2ddf9babec3860cc217437106b7 + languageName: node + linkType: hard + +"@aws-sdk/credential-provider-process@npm:3.840.0": + version: 3.840.0 + resolution: "@aws-sdk/credential-provider-process@npm:3.840.0" + dependencies: + "@aws-sdk/core": "npm:3.840.0" + "@aws-sdk/types": "npm:3.840.0" + "@smithy/property-provider": "npm:^4.0.4" + "@smithy/shared-ini-file-loader": "npm:^4.0.4" + "@smithy/types": "npm:^4.3.1" + tslib: "npm:^2.6.2" + checksum: 10c0/c4278d64dd3a4c3072b30483fb723c6fabf811989f4f434f6573c729fed94e6851ff339275fe207e6aeab83a672d57dca70b1385c8c2dca731cae87fcec59319 + languageName: node + linkType: hard + +"@aws-sdk/credential-provider-sso@npm:3.840.0": + version: 3.840.0 + resolution: "@aws-sdk/credential-provider-sso@npm:3.840.0" + dependencies: + "@aws-sdk/client-sso": "npm:3.840.0" + "@aws-sdk/core": "npm:3.840.0" + "@aws-sdk/token-providers": "npm:3.840.0" + "@aws-sdk/types": "npm:3.840.0" + "@smithy/property-provider": "npm:^4.0.4" + "@smithy/shared-ini-file-loader": "npm:^4.0.4" + "@smithy/types": "npm:^4.3.1" + tslib: "npm:^2.6.2" + checksum: 10c0/4b0398be1d148bcab6e228016fead4c14d0fa6c6d0a7bc59b1b3e937534070f9a99c2147a897a24e83de4601e406d47d8a1a5b19fa59a5d35beb2474b1b41087 + languageName: node + linkType: hard + +"@aws-sdk/credential-provider-web-identity@npm:3.840.0": + version: 3.840.0 + resolution: "@aws-sdk/credential-provider-web-identity@npm:3.840.0" + dependencies: + "@aws-sdk/core": "npm:3.840.0" + "@aws-sdk/nested-clients": "npm:3.840.0" + "@aws-sdk/types": "npm:3.840.0" + "@smithy/property-provider": "npm:^4.0.4" + "@smithy/types": "npm:^4.3.1" + tslib: "npm:^2.6.2" + checksum: 10c0/a68d4b09d9c1869383372c105ed78c5b2c5442e783f8a2fa5f8ca3e9f84e4041d7eaf854a74f867b9f4bfa9f7288093b71e2789494e77ae04e8f77ef280ffdab + languageName: node + linkType: hard + +"@aws-sdk/middleware-bucket-endpoint@npm:3.840.0": + version: 3.840.0 + resolution: "@aws-sdk/middleware-bucket-endpoint@npm:3.840.0" + dependencies: + "@aws-sdk/types": "npm:3.840.0" + "@aws-sdk/util-arn-parser": "npm:3.804.0" + "@smithy/node-config-provider": "npm:^4.1.3" + "@smithy/protocol-http": "npm:^5.1.2" + "@smithy/types": "npm:^4.3.1" + "@smithy/util-config-provider": "npm:^4.0.0" + tslib: "npm:^2.6.2" + checksum: 10c0/371f6e30b16821e1a9c17efcbe6436616eb2bcbfe1757d5f70c56d5eca8452d8dddd42f26f53635b87f927b4da541dc36156e4d3529bb0eb0705969365dce8fc + languageName: node + linkType: hard + +"@aws-sdk/middleware-expect-continue@npm:3.840.0": + version: 3.840.0 + resolution: "@aws-sdk/middleware-expect-continue@npm:3.840.0" + dependencies: + "@aws-sdk/types": "npm:3.840.0" + "@smithy/protocol-http": "npm:^5.1.2" + "@smithy/types": "npm:^4.3.1" + tslib: "npm:^2.6.2" + checksum: 10c0/73099d06d044f5d82cf172398939c8776c966bf88466288270d80a4e93f451c9e620c92252b0b5c8086b22429f6a69137a21d81bbac66e573c36241859f0739b + languageName: node + linkType: hard + +"@aws-sdk/middleware-flexible-checksums@npm:3.840.0": + version: 3.840.0 + resolution: "@aws-sdk/middleware-flexible-checksums@npm:3.840.0" + dependencies: + "@aws-crypto/crc32": "npm:5.2.0" + "@aws-crypto/crc32c": "npm:5.2.0" + "@aws-crypto/util": "npm:5.2.0" + "@aws-sdk/core": "npm:3.840.0" + "@aws-sdk/types": "npm:3.840.0" + "@smithy/is-array-buffer": "npm:^4.0.0" + "@smithy/node-config-provider": "npm:^4.1.3" + "@smithy/protocol-http": "npm:^5.1.2" + "@smithy/types": "npm:^4.3.1" + "@smithy/util-middleware": "npm:^4.0.4" + "@smithy/util-stream": "npm:^4.2.2" + "@smithy/util-utf8": "npm:^4.0.0" + tslib: "npm:^2.6.2" + checksum: 10c0/55f31563a9811cc0b49c00d3c24e719416f51be31ac3d2af87425850d1c4ea2abb9a2dfc2f853ca6c3e10b837640e189c5cd37369476951dd0eab286e5abacbf + languageName: node + linkType: hard + +"@aws-sdk/middleware-host-header@npm:3.840.0": + version: 3.840.0 + resolution: "@aws-sdk/middleware-host-header@npm:3.840.0" + dependencies: + "@aws-sdk/types": "npm:3.840.0" + "@smithy/protocol-http": "npm:^5.1.2" + "@smithy/types": "npm:^4.3.1" + tslib: "npm:^2.6.2" + checksum: 10c0/aae5964c39118815293f3f1d42c6b5131ff44862d33af9c8d44eb98fb5b8db0e6191cceba59c487a2b89b70b2e7ad710b174a14506bc6d99d333af42fd6b3d07 + languageName: node + linkType: hard + +"@aws-sdk/middleware-location-constraint@npm:3.840.0": + version: 3.840.0 + resolution: "@aws-sdk/middleware-location-constraint@npm:3.840.0" + dependencies: + "@aws-sdk/types": "npm:3.840.0" + "@smithy/types": "npm:^4.3.1" + tslib: "npm:^2.6.2" + checksum: 10c0/4520274c5b350881df39e28b1732b482ee8023801e8cc6fe1da4b11856ea9660af5036dc6144cefce20338ed0cf5622cc03d10dddf67f95354447d3d0448d987 + languageName: node + linkType: hard + +"@aws-sdk/middleware-logger@npm:3.840.0": + version: 3.840.0 + resolution: "@aws-sdk/middleware-logger@npm:3.840.0" + dependencies: + "@aws-sdk/types": "npm:3.840.0" + "@smithy/types": "npm:^4.3.1" + tslib: "npm:^2.6.2" + checksum: 10c0/5cc4eec656ec9811b64e504a96812f05f1b57e3542ea1dae6710505f81f8dfb36119709538b736a55792f02565818ab71f803e91b00bc4f0652ab198fce153fd + languageName: node + linkType: hard + +"@aws-sdk/middleware-recursion-detection@npm:3.840.0": + version: 3.840.0 + resolution: "@aws-sdk/middleware-recursion-detection@npm:3.840.0" + dependencies: + "@aws-sdk/types": "npm:3.840.0" + "@smithy/protocol-http": "npm:^5.1.2" + "@smithy/types": "npm:^4.3.1" + tslib: "npm:^2.6.2" + checksum: 10c0/88b1dfbf487d86b2aa26761b08e3de2fd1edd8d09abffd88f5d31b77215fd0852c74deba38802a15cc7015a716d990c2925523af88577890311958f53ef739e7 + languageName: node + linkType: hard + +"@aws-sdk/middleware-sdk-s3@npm:3.840.0": + version: 3.840.0 + resolution: "@aws-sdk/middleware-sdk-s3@npm:3.840.0" + dependencies: + "@aws-sdk/core": "npm:3.840.0" + "@aws-sdk/types": "npm:3.840.0" + "@aws-sdk/util-arn-parser": "npm:3.804.0" + "@smithy/core": "npm:^3.6.0" + "@smithy/node-config-provider": "npm:^4.1.3" + "@smithy/protocol-http": "npm:^5.1.2" + "@smithy/signature-v4": "npm:^5.1.2" + "@smithy/smithy-client": "npm:^4.4.5" + "@smithy/types": "npm:^4.3.1" + "@smithy/util-config-provider": "npm:^4.0.0" + "@smithy/util-middleware": "npm:^4.0.4" + "@smithy/util-stream": "npm:^4.2.2" + "@smithy/util-utf8": "npm:^4.0.0" + tslib: "npm:^2.6.2" + checksum: 10c0/8ef8413028e710a5cee96af80b545d578c3c385dbcb87d2e2b61772b81813f700d7ca503305305af9819462c354e131e8aef692f58eeb08164279701ca1e67ef + languageName: node + linkType: hard + +"@aws-sdk/middleware-ssec@npm:3.840.0": + version: 3.840.0 + resolution: "@aws-sdk/middleware-ssec@npm:3.840.0" + dependencies: + "@aws-sdk/types": "npm:3.840.0" + "@smithy/types": "npm:^4.3.1" + tslib: "npm:^2.6.2" + checksum: 10c0/22cdded72582d15adb266e5f65b5756c129b7104535765ff5c67eedc24609bface9eebb1fa3b74ed41e7b8fade57940195810bbbe2e44b8283104849894ec658 + languageName: node + linkType: hard + +"@aws-sdk/middleware-user-agent@npm:3.840.0": + version: 3.840.0 + resolution: "@aws-sdk/middleware-user-agent@npm:3.840.0" + dependencies: + "@aws-sdk/core": "npm:3.840.0" + "@aws-sdk/types": "npm:3.840.0" + "@aws-sdk/util-endpoints": "npm:3.840.0" + "@smithy/core": "npm:^3.6.0" + "@smithy/protocol-http": "npm:^5.1.2" + "@smithy/types": "npm:^4.3.1" + tslib: "npm:^2.6.2" + checksum: 10c0/68822bc24d1311ba47a1e3b2ff194376f3923b39379aa29e6be658ee7e1b809bfea5ea07335c696ca581b42665f30899e25bbe8d9b3216003f602622b4326140 + languageName: node + linkType: hard + +"@aws-sdk/nested-clients@npm:3.840.0": + version: 3.840.0 + resolution: "@aws-sdk/nested-clients@npm:3.840.0" + dependencies: + "@aws-crypto/sha256-browser": "npm:5.2.0" + "@aws-crypto/sha256-js": "npm:5.2.0" + "@aws-sdk/core": "npm:3.840.0" + "@aws-sdk/middleware-host-header": "npm:3.840.0" + "@aws-sdk/middleware-logger": "npm:3.840.0" + "@aws-sdk/middleware-recursion-detection": "npm:3.840.0" + "@aws-sdk/middleware-user-agent": "npm:3.840.0" + "@aws-sdk/region-config-resolver": "npm:3.840.0" + "@aws-sdk/types": "npm:3.840.0" + "@aws-sdk/util-endpoints": "npm:3.840.0" + "@aws-sdk/util-user-agent-browser": "npm:3.840.0" + "@aws-sdk/util-user-agent-node": "npm:3.840.0" + "@smithy/config-resolver": "npm:^4.1.4" + "@smithy/core": "npm:^3.6.0" + "@smithy/fetch-http-handler": "npm:^5.0.4" + "@smithy/hash-node": "npm:^4.0.4" + "@smithy/invalid-dependency": "npm:^4.0.4" + "@smithy/middleware-content-length": "npm:^4.0.4" + "@smithy/middleware-endpoint": "npm:^4.1.13" + "@smithy/middleware-retry": "npm:^4.1.14" + "@smithy/middleware-serde": "npm:^4.0.8" + "@smithy/middleware-stack": "npm:^4.0.4" + "@smithy/node-config-provider": "npm:^4.1.3" + "@smithy/node-http-handler": "npm:^4.0.6" + "@smithy/protocol-http": "npm:^5.1.2" + "@smithy/smithy-client": "npm:^4.4.5" + "@smithy/types": "npm:^4.3.1" + "@smithy/url-parser": "npm:^4.0.4" + "@smithy/util-base64": "npm:^4.0.0" + "@smithy/util-body-length-browser": "npm:^4.0.0" + "@smithy/util-body-length-node": "npm:^4.0.0" + "@smithy/util-defaults-mode-browser": "npm:^4.0.21" + "@smithy/util-defaults-mode-node": "npm:^4.0.21" + "@smithy/util-endpoints": "npm:^3.0.6" + "@smithy/util-middleware": "npm:^4.0.4" + "@smithy/util-retry": "npm:^4.0.6" + "@smithy/util-utf8": "npm:^4.0.0" + tslib: "npm:^2.6.2" + checksum: 10c0/1b9ee866f37f433723e472ed194629155de2b1fb7d464bf772727c5140bcb6ad5fbc5d4ae911a19b319f55614239bb1935304fa3ec5a881038a577c32a96b238 + languageName: node + linkType: hard + +"@aws-sdk/region-config-resolver@npm:3.840.0": + version: 3.840.0 + resolution: "@aws-sdk/region-config-resolver@npm:3.840.0" + dependencies: + "@aws-sdk/types": "npm:3.840.0" + "@smithy/node-config-provider": "npm:^4.1.3" + "@smithy/types": "npm:^4.3.1" + "@smithy/util-config-provider": "npm:^4.0.0" + "@smithy/util-middleware": "npm:^4.0.4" + tslib: "npm:^2.6.2" + checksum: 10c0/27d72bb9657efd79637a4c4aa895004d29c66eefce083fa84050f092f68bcba8cb9bf0e4c16c11c132a5fa01f1841e878fa903bc837c4e1e6904d1b2d2c3dd37 + languageName: node + linkType: hard + +"@aws-sdk/signature-v4-multi-region@npm:3.840.0": + version: 3.840.0 + resolution: "@aws-sdk/signature-v4-multi-region@npm:3.840.0" + dependencies: + "@aws-sdk/middleware-sdk-s3": "npm:3.840.0" + "@aws-sdk/types": "npm:3.840.0" + "@smithy/protocol-http": "npm:^5.1.2" + "@smithy/signature-v4": "npm:^5.1.2" + "@smithy/types": "npm:^4.3.1" + tslib: "npm:^2.6.2" + checksum: 10c0/224e17e624925ba5972f698d92e92289912f9e1ca1fd0525bbc62e6965a9e0585abb309fdb6b7e304fddeb4301e5c832d4370b324c55cbfd42922e73c1abc70c + languageName: node + linkType: hard + +"@aws-sdk/token-providers@npm:3.840.0": + version: 3.840.0 + resolution: "@aws-sdk/token-providers@npm:3.840.0" + dependencies: + "@aws-sdk/core": "npm:3.840.0" + "@aws-sdk/nested-clients": "npm:3.840.0" + "@aws-sdk/types": "npm:3.840.0" + "@smithy/property-provider": "npm:^4.0.4" + "@smithy/shared-ini-file-loader": "npm:^4.0.4" + "@smithy/types": "npm:^4.3.1" + tslib: "npm:^2.6.2" + checksum: 10c0/a172666169fd8164ce48a3a0ea242405d8437119c8fbcf259223badf8ad04cf68a1ebba54c09c22cbee5c16775e885733788978aa99c9a27241036e967ea2fa5 + languageName: node + linkType: hard + +"@aws-sdk/types@npm:3.840.0, @aws-sdk/types@npm:^3.222.0": + version: 3.840.0 + resolution: "@aws-sdk/types@npm:3.840.0" + dependencies: + "@smithy/types": "npm:^4.3.1" + tslib: "npm:^2.6.2" + checksum: 10c0/292d38f5087c3aa925addd890f8ae2bf650282c2cf4997d971a341dc0249dfca7ce02d69a4af09da2562b78a4232232d2a3b88105f34f66aee608d52aac238d1 + languageName: node + linkType: hard + +"@aws-sdk/util-arn-parser@npm:3.804.0": + version: 3.804.0 + resolution: "@aws-sdk/util-arn-parser@npm:3.804.0" + dependencies: + tslib: "npm:^2.6.2" + checksum: 10c0/b6d4c883ec2949fa40552fe8573c9c32af07c92c1bd94a27d978aa14d37b005be95392069d6b882ba977484f4dd0371792296fb2516f5d7601be5102888ee9ee + languageName: node + linkType: hard + +"@aws-sdk/util-endpoints@npm:3.840.0": + version: 3.840.0 + resolution: "@aws-sdk/util-endpoints@npm:3.840.0" + dependencies: + "@aws-sdk/types": "npm:3.840.0" + "@smithy/types": "npm:^4.3.1" + "@smithy/util-endpoints": "npm:^3.0.6" + tslib: "npm:^2.6.2" + checksum: 10c0/822fe59c003b433c955756daf47736a17c42c25f449b9ca96c2c2bb79964866ee0a0a657824da6289588d689e76712a7058d70e42c3fad2b78bfb23f905643d9 + languageName: node + linkType: hard + +"@aws-sdk/util-locate-window@npm:^3.0.0": + version: 3.804.0 + resolution: "@aws-sdk/util-locate-window@npm:3.804.0" + dependencies: + tslib: "npm:^2.6.2" + checksum: 10c0/a0ceaf6531f188751fea7e829b730650689fa2196e0b3f870dde3888bcb840fe0852e10488699d4d9683db0765cd7f7060ca8ac216348991996b6d794f9957ab + languageName: node + linkType: hard + +"@aws-sdk/util-user-agent-browser@npm:3.840.0": + version: 3.840.0 + resolution: "@aws-sdk/util-user-agent-browser@npm:3.840.0" + dependencies: + "@aws-sdk/types": "npm:3.840.0" + "@smithy/types": "npm:^4.3.1" + bowser: "npm:^2.11.0" + tslib: "npm:^2.6.2" + checksum: 10c0/873d5e3218958aa935127b05dad5a1d8cf26c9b7726584eb424a5958e7e205786dd99e4fa053b65f3b956261a7f8a3746e48e9b7dc47c3149792ff525da97631 + languageName: node + linkType: hard + +"@aws-sdk/util-user-agent-node@npm:3.840.0": + version: 3.840.0 + resolution: "@aws-sdk/util-user-agent-node@npm:3.840.0" + dependencies: + "@aws-sdk/middleware-user-agent": "npm:3.840.0" + "@aws-sdk/types": "npm:3.840.0" + "@smithy/node-config-provider": "npm:^4.1.3" + "@smithy/types": "npm:^4.3.1" + tslib: "npm:^2.6.2" + peerDependencies: + aws-crt: ">=1.0.0" + peerDependenciesMeta: + aws-crt: + optional: true + checksum: 10c0/862fc435d8a25f3e299e5c92c5ba51ef287a75f18cb0a529797a42a72de1481e3c92458a5569eeeab09fddfb5a75db1c59aa766d95b0e832c32c6c1bd7745644 + languageName: node + linkType: hard + +"@aws-sdk/xml-builder@npm:3.821.0": + version: 3.821.0 + resolution: "@aws-sdk/xml-builder@npm:3.821.0" + dependencies: + "@smithy/types": "npm:^4.3.1" + tslib: "npm:^2.6.2" + checksum: 10c0/316e0eb04bcec0bb0897f67718629deab29adb9664ce78743ad854df772472c02332ab12627d74b96ebe2205adc51b1cb7fb01fcb4251e80a7af405e56cfa135 + languageName: node + linkType: hard + "@babel/code-frame@npm:^7.10.4": version: 7.27.1 resolution: "@babel/code-frame@npm:7.27.1" @@ -3993,6 +4637,604 @@ __metadata: languageName: node linkType: hard +"@smithy/abort-controller@npm:^4.0.4": + version: 4.0.4 + resolution: "@smithy/abort-controller@npm:4.0.4" + dependencies: + "@smithy/types": "npm:^4.3.1" + tslib: "npm:^2.6.2" + checksum: 10c0/eb172b002fb92406c69b83460f949ace73247e6abd85d0d3714de2765c5db7b98070b9abfb630e2c591dd7b2ff770cc24f7737c1c207581f716c402b16bf46f9 + languageName: node + linkType: hard + +"@smithy/chunked-blob-reader-native@npm:^4.0.0": + version: 4.0.0 + resolution: "@smithy/chunked-blob-reader-native@npm:4.0.0" + dependencies: + "@smithy/util-base64": "npm:^4.0.0" + tslib: "npm:^2.6.2" + checksum: 10c0/4387f4e8841f20c1c4e689078141de7e6f239e7883be3a02810a023aa30939b15576ee00227b991972d2c5a2f3b6152bcaeca0975c9fa8d3669354c647bd532a + languageName: node + linkType: hard + +"@smithy/chunked-blob-reader@npm:^5.0.0": + version: 5.0.0 + resolution: "@smithy/chunked-blob-reader@npm:5.0.0" + dependencies: + tslib: "npm:^2.6.2" + checksum: 10c0/55ba0fe366ddaa3f93e1faf8a70df0b67efedbd0008922295efe215df09b68df0ba3043293e65b17e7d1be71448d074c2bfc54e5eb6bd18f59b425822c2b9e9a + languageName: node + linkType: hard + +"@smithy/config-resolver@npm:^4.1.4": + version: 4.1.4 + resolution: "@smithy/config-resolver@npm:4.1.4" + dependencies: + "@smithy/node-config-provider": "npm:^4.1.3" + "@smithy/types": "npm:^4.3.1" + "@smithy/util-config-provider": "npm:^4.0.0" + "@smithy/util-middleware": "npm:^4.0.4" + tslib: "npm:^2.6.2" + checksum: 10c0/41832a42f8da7143732c71098b410f4ddcb096066126f7e8f45bae8d9aeb95681bd0d0d54886f46244c945c63829ca5d23373d4de31a038487aa07159722ef4e + languageName: node + linkType: hard + +"@smithy/core@npm:^3.6.0": + version: 3.6.0 + resolution: "@smithy/core@npm:3.6.0" + dependencies: + "@smithy/middleware-serde": "npm:^4.0.8" + "@smithy/protocol-http": "npm:^5.1.2" + "@smithy/types": "npm:^4.3.1" + "@smithy/util-base64": "npm:^4.0.0" + "@smithy/util-body-length-browser": "npm:^4.0.0" + "@smithy/util-middleware": "npm:^4.0.4" + "@smithy/util-stream": "npm:^4.2.2" + "@smithy/util-utf8": "npm:^4.0.0" + tslib: "npm:^2.6.2" + checksum: 10c0/015874e1c44815b6e50594f2983a1a88e3c4f777760d062b2e31b402e8d145ce5c64b33065eaa97fd37867ef6c95493ddc62f3775cd7102e6fd41c9808be219a + languageName: node + linkType: hard + +"@smithy/credential-provider-imds@npm:^4.0.6": + version: 4.0.6 + resolution: "@smithy/credential-provider-imds@npm:4.0.6" + dependencies: + "@smithy/node-config-provider": "npm:^4.1.3" + "@smithy/property-provider": "npm:^4.0.4" + "@smithy/types": "npm:^4.3.1" + "@smithy/url-parser": "npm:^4.0.4" + tslib: "npm:^2.6.2" + checksum: 10c0/b1f3157d0a7b9f9155ac80aeac70d7db896d23d0322a6b38f0e848f1e53864ba1bca6d3dc5dd9af86446c371ebc5bffe01f0712ad562e7635e7d13e532622aa4 + languageName: node + linkType: hard + +"@smithy/eventstream-codec@npm:^4.0.4": + version: 4.0.4 + resolution: "@smithy/eventstream-codec@npm:4.0.4" + dependencies: + "@aws-crypto/crc32": "npm:5.2.0" + "@smithy/types": "npm:^4.3.1" + "@smithy/util-hex-encoding": "npm:^4.0.0" + tslib: "npm:^2.6.2" + checksum: 10c0/89b76826d4d3bf97317e3539ece105b9a03552144ad816a687b0b2cbca60e2b3513062c04b6cfacaffb270d616ffc8ac8bf549afc4aa676a6d7465df5a3215ba + languageName: node + linkType: hard + +"@smithy/eventstream-serde-browser@npm:^4.0.4": + version: 4.0.4 + resolution: "@smithy/eventstream-serde-browser@npm:4.0.4" + dependencies: + "@smithy/eventstream-serde-universal": "npm:^4.0.4" + "@smithy/types": "npm:^4.3.1" + tslib: "npm:^2.6.2" + checksum: 10c0/b2444538555c54ac96d4049b0be3a65d959914bcd5198a8059edc838c7ffac5a1db225290194f85ea8805c47c1edc95484dfeb415cb2004ec3e572880f4fc8c5 + languageName: node + linkType: hard + +"@smithy/eventstream-serde-config-resolver@npm:^4.1.2": + version: 4.1.2 + resolution: "@smithy/eventstream-serde-config-resolver@npm:4.1.2" + dependencies: + "@smithy/types": "npm:^4.3.1" + tslib: "npm:^2.6.2" + checksum: 10c0/54184a29d1e42f1b972292efc3a5cbbe3ca237cd9ab76132bad40e8426fa62d0b7f6fdac01f23e3a9cac69919107ddfd9d2f2873f83ae1f65470d3052c67cefc + languageName: node + linkType: hard + +"@smithy/eventstream-serde-node@npm:^4.0.4": + version: 4.0.4 + resolution: "@smithy/eventstream-serde-node@npm:4.0.4" + dependencies: + "@smithy/eventstream-serde-universal": "npm:^4.0.4" + "@smithy/types": "npm:^4.3.1" + tslib: "npm:^2.6.2" + checksum: 10c0/e6d0765a73332c79b69531ed20c27e49475173da09ce21e4c011a64d8a61d7c5c328c9bc1cab991e145fc969b16071ffd6a33ab11291c0fa2a46e8dae28da23b + languageName: node + linkType: hard + +"@smithy/eventstream-serde-universal@npm:^4.0.4": + version: 4.0.4 + resolution: "@smithy/eventstream-serde-universal@npm:4.0.4" + dependencies: + "@smithy/eventstream-codec": "npm:^4.0.4" + "@smithy/types": "npm:^4.3.1" + tslib: "npm:^2.6.2" + checksum: 10c0/f0c18efa6cafa111ed20c8c53b4a7b6a0f8e25ccb0d2cafdf83282ebc6f96e47f26daf24b5b810ea83a02e03c994c35419d94fad76871f2cc6cb01d2aed277d9 + languageName: node + linkType: hard + +"@smithy/fetch-http-handler@npm:^5.0.4": + version: 5.0.4 + resolution: "@smithy/fetch-http-handler@npm:5.0.4" + dependencies: + "@smithy/protocol-http": "npm:^5.1.2" + "@smithy/querystring-builder": "npm:^4.0.4" + "@smithy/types": "npm:^4.3.1" + "@smithy/util-base64": "npm:^4.0.0" + tslib: "npm:^2.6.2" + checksum: 10c0/ce57acfcd40a6ff3965c5f14b432c5ab87f0b0766766960224d4af79af85e37d61da2db6dc5cfa16bf4b8f2d8966a2838d2ee6eef8d5cd5a837aacbc01517851 + languageName: node + linkType: hard + +"@smithy/hash-blob-browser@npm:^4.0.4": + version: 4.0.4 + resolution: "@smithy/hash-blob-browser@npm:4.0.4" + dependencies: + "@smithy/chunked-blob-reader": "npm:^5.0.0" + "@smithy/chunked-blob-reader-native": "npm:^4.0.0" + "@smithy/types": "npm:^4.3.1" + tslib: "npm:^2.6.2" + checksum: 10c0/f970058c2e04e86427e1474355199027fc84dc1d96d9a2278ed37904458d37b020472541390558bd3fb071bbd177b2850b18ceb1beb39d387fead06a2912f974 + languageName: node + linkType: hard + +"@smithy/hash-node@npm:^4.0.4": + version: 4.0.4 + resolution: "@smithy/hash-node@npm:4.0.4" + dependencies: + "@smithy/types": "npm:^4.3.1" + "@smithy/util-buffer-from": "npm:^4.0.0" + "@smithy/util-utf8": "npm:^4.0.0" + tslib: "npm:^2.6.2" + checksum: 10c0/07beb38643990f6c055457765d65af2aedd5944d819025df90d1f2f59596d1a1394cd8c9035ac6d343bc55e3afeb186b51b0ac91938024da8687120fc0b436dc + languageName: node + linkType: hard + +"@smithy/hash-stream-node@npm:^4.0.4": + version: 4.0.4 + resolution: "@smithy/hash-stream-node@npm:4.0.4" + dependencies: + "@smithy/types": "npm:^4.3.1" + "@smithy/util-utf8": "npm:^4.0.0" + tslib: "npm:^2.6.2" + checksum: 10c0/4899132433f520e45972bbacb6a999da8d7ccf4c813f2fb28b1af65eaf268ba549b2c37dd54a586cd7bcd82f6e4cec914651a6446b3fb3e1f226ca1864051535 + languageName: node + linkType: hard + +"@smithy/invalid-dependency@npm:^4.0.4": + version: 4.0.4 + resolution: "@smithy/invalid-dependency@npm:4.0.4" + dependencies: + "@smithy/types": "npm:^4.3.1" + tslib: "npm:^2.6.2" + checksum: 10c0/5e5a6282c17a7310f8e866c7e34fa07479d42c650cf3c1875bdb0ec38d5280eeac82a269605a3521b8fa455b92673d8fd5e97eb997acf81a80da82d6f501d651 + languageName: node + linkType: hard + +"@smithy/is-array-buffer@npm:^2.2.0": + version: 2.2.0 + resolution: "@smithy/is-array-buffer@npm:2.2.0" + dependencies: + tslib: "npm:^2.6.2" + checksum: 10c0/2f2523cd8cc4538131e408eb31664983fecb0c8724956788b015aaf3ab85a0c976b50f4f09b176f1ed7bbe79f3edf80743be7a80a11f22cd9ce1285d77161aaf + languageName: node + linkType: hard + +"@smithy/is-array-buffer@npm:^4.0.0": + version: 4.0.0 + resolution: "@smithy/is-array-buffer@npm:4.0.0" + dependencies: + tslib: "npm:^2.6.2" + checksum: 10c0/ae393fbd5944d710443cd5dd225d1178ef7fb5d6259c14f3e1316ec75e401bda6cf86f7eb98bfd38e5ed76e664b810426a5756b916702cbd418f0933e15e7a3b + languageName: node + linkType: hard + +"@smithy/md5-js@npm:^4.0.4": + version: 4.0.4 + resolution: "@smithy/md5-js@npm:4.0.4" + dependencies: + "@smithy/types": "npm:^4.3.1" + "@smithy/util-utf8": "npm:^4.0.0" + tslib: "npm:^2.6.2" + checksum: 10c0/7c66405dca5d7df6694367dbb4a3d9f13fdfe2589abc81f85d5fb7bf876e1382578d9c477d2256d4b5bc59951c3c534e51eb65c53c2fb3251080f16d1d7ea82c + languageName: node + linkType: hard + +"@smithy/middleware-content-length@npm:^4.0.4": + version: 4.0.4 + resolution: "@smithy/middleware-content-length@npm:4.0.4" + dependencies: + "@smithy/protocol-http": "npm:^5.1.2" + "@smithy/types": "npm:^4.3.1" + tslib: "npm:^2.6.2" + checksum: 10c0/fde43ff13f0830c4608b83cf6e2bd3ae142aa6eb3df6f6c190c2564dd00c2c98f4f95da9146c69bc09115ad87ffc9dc24935d1a3d6d3b2383a9c8558d9177dd6 + languageName: node + linkType: hard + +"@smithy/middleware-endpoint@npm:^4.1.13": + version: 4.1.13 + resolution: "@smithy/middleware-endpoint@npm:4.1.13" + dependencies: + "@smithy/core": "npm:^3.6.0" + "@smithy/middleware-serde": "npm:^4.0.8" + "@smithy/node-config-provider": "npm:^4.1.3" + "@smithy/shared-ini-file-loader": "npm:^4.0.4" + "@smithy/types": "npm:^4.3.1" + "@smithy/url-parser": "npm:^4.0.4" + "@smithy/util-middleware": "npm:^4.0.4" + tslib: "npm:^2.6.2" + checksum: 10c0/a4f605ba95d59e5afbad326ed0a1417fb33cb1c6085a9c13f765520d3732e223ab501033457eab72ed223d41ce0a079d6895ebb3954935b2a6d25b223c4ef72c + languageName: node + linkType: hard + +"@smithy/middleware-retry@npm:^4.1.14": + version: 4.1.14 + resolution: "@smithy/middleware-retry@npm:4.1.14" + dependencies: + "@smithy/node-config-provider": "npm:^4.1.3" + "@smithy/protocol-http": "npm:^5.1.2" + "@smithy/service-error-classification": "npm:^4.0.6" + "@smithy/smithy-client": "npm:^4.4.5" + "@smithy/types": "npm:^4.3.1" + "@smithy/util-middleware": "npm:^4.0.4" + "@smithy/util-retry": "npm:^4.0.6" + tslib: "npm:^2.6.2" + uuid: "npm:^9.0.1" + checksum: 10c0/a720f366f3c8b5ea9d35bf38718d3885492fe896288623f9e5b3c293bfea14bc530b9107da100abdac3ff45bebbe1335f6da928c005fc78dbdefab2d65f1269d + languageName: node + linkType: hard + +"@smithy/middleware-serde@npm:^4.0.8": + version: 4.0.8 + resolution: "@smithy/middleware-serde@npm:4.0.8" + dependencies: + "@smithy/protocol-http": "npm:^5.1.2" + "@smithy/types": "npm:^4.3.1" + tslib: "npm:^2.6.2" + checksum: 10c0/11414e584780716b2b0487fe748da9927943d4d810b5b0161e73df6ab24a4d17f675773287f95868c57a71013385f7b027eb2afbab1eed3dbaafef754b482b27 + languageName: node + linkType: hard + +"@smithy/middleware-stack@npm:^4.0.4": + version: 4.0.4 + resolution: "@smithy/middleware-stack@npm:4.0.4" + dependencies: + "@smithy/types": "npm:^4.3.1" + tslib: "npm:^2.6.2" + checksum: 10c0/b29b6430e31f11683f0ce0e06d21a4bfe6cb791ce1eb5686533559baa81698f617bfbfdac06f569e13f077ce177cb70e55f4db20701906b3e344d9294817f382 + languageName: node + linkType: hard + +"@smithy/node-config-provider@npm:^4.1.3": + version: 4.1.3 + resolution: "@smithy/node-config-provider@npm:4.1.3" + dependencies: + "@smithy/property-provider": "npm:^4.0.4" + "@smithy/shared-ini-file-loader": "npm:^4.0.4" + "@smithy/types": "npm:^4.3.1" + tslib: "npm:^2.6.2" + checksum: 10c0/bea20b3f92290fbefa32d30c4ac7632f94d4e89b5432dfe5a2d0c6261bfd90e882d62dd02e0a4e65f3bc89f815b19e44d7bb103a78b6c77941cc186450ad79f1 + languageName: node + linkType: hard + +"@smithy/node-http-handler@npm:^4.0.6": + version: 4.0.6 + resolution: "@smithy/node-http-handler@npm:4.0.6" + dependencies: + "@smithy/abort-controller": "npm:^4.0.4" + "@smithy/protocol-http": "npm:^5.1.2" + "@smithy/querystring-builder": "npm:^4.0.4" + "@smithy/types": "npm:^4.3.1" + tslib: "npm:^2.6.2" + checksum: 10c0/bde23701b6166b76958cbc194d551a139e3dcc1d05a6c7de3d5b14f54934ca5a49a28d13d8ec4b012716aae816cd0c8c4735c959d5ef697a7a1932fbcfc5d7f2 + languageName: node + linkType: hard + +"@smithy/property-provider@npm:^4.0.4": + version: 4.0.4 + resolution: "@smithy/property-provider@npm:4.0.4" + dependencies: + "@smithy/types": "npm:^4.3.1" + tslib: "npm:^2.6.2" + checksum: 10c0/c370efbb43ab01fb6050fbf4c231bbe2fb7d660256adeee40c0c4c14b7af1b9b75c36f6924aeacdd2885fad1aaf0655047cafe5f0d22f5e371cbd25ff2f04b27 + languageName: node + linkType: hard + +"@smithy/protocol-http@npm:^5.1.2": + version: 5.1.2 + resolution: "@smithy/protocol-http@npm:5.1.2" + dependencies: + "@smithy/types": "npm:^4.3.1" + tslib: "npm:^2.6.2" + checksum: 10c0/50fb026efa321e65a77f9747312eeb428ff2196095c15ed5937efe807a4734c47746759ccf2dbc84a45719effcbc81221662289be6d4d5ec122afb0e3cd66fd9 + languageName: node + linkType: hard + +"@smithy/querystring-builder@npm:^4.0.4": + version: 4.0.4 + resolution: "@smithy/querystring-builder@npm:4.0.4" + dependencies: + "@smithy/types": "npm:^4.3.1" + "@smithy/util-uri-escape": "npm:^4.0.0" + tslib: "npm:^2.6.2" + checksum: 10c0/30ec0301fbc2212101391841000a3117ab6c3ae2b6b2a1db230cc1dfcf97738f527b23f859f0a5e843f2a983793b58cdcd21a0ce11ef93fcdf5d8a1ee0d70fbc + languageName: node + linkType: hard + +"@smithy/querystring-parser@npm:^4.0.4": + version: 4.0.4 + resolution: "@smithy/querystring-parser@npm:4.0.4" + dependencies: + "@smithy/types": "npm:^4.3.1" + tslib: "npm:^2.6.2" + checksum: 10c0/36bc93732a1628be5dd53748f6f36237bad26de2da810195213541dd35b20eee0b0264160a0de734b9333ca747e0229253d6729d1a8ddc26d176c0b1cce309e0 + languageName: node + linkType: hard + +"@smithy/service-error-classification@npm:^4.0.6": + version: 4.0.6 + resolution: "@smithy/service-error-classification@npm:4.0.6" + dependencies: + "@smithy/types": "npm:^4.3.1" + checksum: 10c0/b67f5ef633fa803f6b9f81f53dcc361253f33e01ffefbcb1beaf29c578834b1381e5f979e25b38985d351142e1ab4ee638cf132a2ba9f6f7a0a806a35da76d86 + languageName: node + linkType: hard + +"@smithy/shared-ini-file-loader@npm:^4.0.4": + version: 4.0.4 + resolution: "@smithy/shared-ini-file-loader@npm:4.0.4" + dependencies: + "@smithy/types": "npm:^4.3.1" + tslib: "npm:^2.6.2" + checksum: 10c0/a3ecabadda13ff6fca99585e7e0086a04c4d2350b8c783b3a23493c2ae0a599f397d3cb80a7e171b7123889340995cada866d320726fa6a03f3063d60d5d0207 + languageName: node + linkType: hard + +"@smithy/signature-v4@npm:^5.1.2": + version: 5.1.2 + resolution: "@smithy/signature-v4@npm:5.1.2" + dependencies: + "@smithy/is-array-buffer": "npm:^4.0.0" + "@smithy/protocol-http": "npm:^5.1.2" + "@smithy/types": "npm:^4.3.1" + "@smithy/util-hex-encoding": "npm:^4.0.0" + "@smithy/util-middleware": "npm:^4.0.4" + "@smithy/util-uri-escape": "npm:^4.0.0" + "@smithy/util-utf8": "npm:^4.0.0" + tslib: "npm:^2.6.2" + checksum: 10c0/83d3870668a6c080c1d0cbecf2e7d1a86c0298cc3a3df9fba21bd942e2a9bcae81eb50960c66bba00c6f9820ef9e5ab3e5ddba67b2d7914a09a82c7887621c0c + languageName: node + linkType: hard + +"@smithy/smithy-client@npm:^4.4.5": + version: 4.4.5 + resolution: "@smithy/smithy-client@npm:4.4.5" + dependencies: + "@smithy/core": "npm:^3.6.0" + "@smithy/middleware-endpoint": "npm:^4.1.13" + "@smithy/middleware-stack": "npm:^4.0.4" + "@smithy/protocol-http": "npm:^5.1.2" + "@smithy/types": "npm:^4.3.1" + "@smithy/util-stream": "npm:^4.2.2" + tslib: "npm:^2.6.2" + checksum: 10c0/180115cf186a0984db9110b3763db2f84451b65c353ae8e908cc6b6941ad4ad13de690192e7ee50281c83694ab09a7f282bcf4c81a2d839497f515c951d86b38 + languageName: node + linkType: hard + +"@smithy/types@npm:^4.3.1": + version: 4.3.1 + resolution: "@smithy/types@npm:4.3.1" + dependencies: + tslib: "npm:^2.6.2" + checksum: 10c0/8b350562b9ed4ff97465025b4ae77a34bb07b9d47fb6f9781755aac9401b0355a63c2fef307393e2dae3fa0277149dd7d83f5bc2a63d4ad3519ea32fd56b5cda + languageName: node + linkType: hard + +"@smithy/url-parser@npm:^4.0.4": + version: 4.0.4 + resolution: "@smithy/url-parser@npm:4.0.4" + dependencies: + "@smithy/querystring-parser": "npm:^4.0.4" + "@smithy/types": "npm:^4.3.1" + tslib: "npm:^2.6.2" + checksum: 10c0/5f4649d9ff618c683e339fa826b1d722419bf8e20d72726fc5fe3cd479ec8c161d4b09b6e24e49b0143a6fb4f9a950d35410db1834e143c28e377b9c529a3657 + languageName: node + linkType: hard + +"@smithy/util-base64@npm:^4.0.0": + version: 4.0.0 + resolution: "@smithy/util-base64@npm:4.0.0" + dependencies: + "@smithy/util-buffer-from": "npm:^4.0.0" + "@smithy/util-utf8": "npm:^4.0.0" + tslib: "npm:^2.6.2" + checksum: 10c0/ad18ec66cc357c189eef358d96876b114faf7086b13e47e009b265d0ff80cec046052500489c183957b3a036768409acdd1a373e01074cc002ca6983f780cffc + languageName: node + linkType: hard + +"@smithy/util-body-length-browser@npm:^4.0.0": + version: 4.0.0 + resolution: "@smithy/util-body-length-browser@npm:4.0.0" + dependencies: + tslib: "npm:^2.6.2" + checksum: 10c0/574a10934024a86556e9dcde1a9776170284326c3dfcc034afa128cc5a33c1c8179fca9cfb622ef8be5f2004316cc3f427badccceb943e829105536ec26306d9 + languageName: node + linkType: hard + +"@smithy/util-body-length-node@npm:^4.0.0": + version: 4.0.0 + resolution: "@smithy/util-body-length-node@npm:4.0.0" + dependencies: + tslib: "npm:^2.6.2" + checksum: 10c0/e91fd3816767606c5f786166ada26440457fceb60f96653b3d624dcf762a8c650e513c275ff3f647cb081c63c283cc178853a7ed9aa224abc8ece4eeeef7a1dd + languageName: node + linkType: hard + +"@smithy/util-buffer-from@npm:^2.2.0": + version: 2.2.0 + resolution: "@smithy/util-buffer-from@npm:2.2.0" + dependencies: + "@smithy/is-array-buffer": "npm:^2.2.0" + tslib: "npm:^2.6.2" + checksum: 10c0/223d6a508b52ff236eea01cddc062b7652d859dd01d457a4e50365af3de1e24a05f756e19433f6ccf1538544076b4215469e21a4ea83dc1d58d829725b0dbc5a + languageName: node + linkType: hard + +"@smithy/util-buffer-from@npm:^4.0.0": + version: 4.0.0 + resolution: "@smithy/util-buffer-from@npm:4.0.0" + dependencies: + "@smithy/is-array-buffer": "npm:^4.0.0" + tslib: "npm:^2.6.2" + checksum: 10c0/be7cd33b6cb91503982b297716251e67cdca02819a15797632091cadab2dc0b4a147fff0709a0aa9bbc0b82a2644a7ed7c8afdd2194d5093cee2e9605b3a9f6f + languageName: node + linkType: hard + +"@smithy/util-config-provider@npm:^4.0.0": + version: 4.0.0 + resolution: "@smithy/util-config-provider@npm:4.0.0" + dependencies: + tslib: "npm:^2.6.2" + checksum: 10c0/cd9498d5f77a73aadd575084bcb22d2bb5945bac4605d605d36f2efe3f165f2b60f4dc88b7a62c2ed082ffa4b2c2f19621d0859f18399edbc2b5988d92e4649f + languageName: node + linkType: hard + +"@smithy/util-defaults-mode-browser@npm:^4.0.21": + version: 4.0.21 + resolution: "@smithy/util-defaults-mode-browser@npm:4.0.21" + dependencies: + "@smithy/property-provider": "npm:^4.0.4" + "@smithy/smithy-client": "npm:^4.4.5" + "@smithy/types": "npm:^4.3.1" + bowser: "npm:^2.11.0" + tslib: "npm:^2.6.2" + checksum: 10c0/401d5f83aa0c054755e18742a6f35de50268174d93ad05bd95123fe176870153da3bfc2344ebad23a2a159bd0668f2c0c758a94e3d5696dd59990d5e881c4c1b + languageName: node + linkType: hard + +"@smithy/util-defaults-mode-node@npm:^4.0.21": + version: 4.0.21 + resolution: "@smithy/util-defaults-mode-node@npm:4.0.21" + dependencies: + "@smithy/config-resolver": "npm:^4.1.4" + "@smithy/credential-provider-imds": "npm:^4.0.6" + "@smithy/node-config-provider": "npm:^4.1.3" + "@smithy/property-provider": "npm:^4.0.4" + "@smithy/smithy-client": "npm:^4.4.5" + "@smithy/types": "npm:^4.3.1" + tslib: "npm:^2.6.2" + checksum: 10c0/936399758fdecf68b14f7adfcb6a9dbc50b62edeabc6c146affe5f7dc40ccfc42df0c6af882748a8ccc32a54834bcf1d22fd42ec8589242dcabe5b983b67e40c + languageName: node + linkType: hard + +"@smithy/util-endpoints@npm:^3.0.6": + version: 3.0.6 + resolution: "@smithy/util-endpoints@npm:3.0.6" + dependencies: + "@smithy/node-config-provider": "npm:^4.1.3" + "@smithy/types": "npm:^4.3.1" + tslib: "npm:^2.6.2" + checksum: 10c0/d7d583c73a0c1ce38188569616cd4d7c95c36c0393516117043962b932f8c743e8cd672d2edd23ea8a9da0e30b84ee0f0ced0709cc8024b70ea8e5f17f505811 + languageName: node + linkType: hard + +"@smithy/util-hex-encoding@npm:^4.0.0": + version: 4.0.0 + resolution: "@smithy/util-hex-encoding@npm:4.0.0" + dependencies: + tslib: "npm:^2.6.2" + checksum: 10c0/70dbb3aa1a79aff3329d07a66411ff26398df338bdd8a6d077b438231afe3dc86d9a7022204baddecd8bc633f059d5c841fa916d81dd7447ea79b64148f386d2 + languageName: node + linkType: hard + +"@smithy/util-middleware@npm:^4.0.4": + version: 4.0.4 + resolution: "@smithy/util-middleware@npm:4.0.4" + dependencies: + "@smithy/types": "npm:^4.3.1" + tslib: "npm:^2.6.2" + checksum: 10c0/39530add63ec13dac555846c30e98128316136f7f57bfd8fe876a8c15a7677cb64d0a33fd1f08b671096d769ab3f025d4d8c785a9d7a7cdf42fd0188236b0f32 + languageName: node + linkType: hard + +"@smithy/util-retry@npm:^4.0.6": + version: 4.0.6 + resolution: "@smithy/util-retry@npm:4.0.6" + dependencies: + "@smithy/service-error-classification": "npm:^4.0.6" + "@smithy/types": "npm:^4.3.1" + tslib: "npm:^2.6.2" + checksum: 10c0/b1d3a5875769300bb74d63243868eba8a8f3567a9b22776cfb11700cfdd10bf10b2ed96bffdc9527d9130daf1be2482ea9217e1865a94efed01fe66e688768f4 + languageName: node + linkType: hard + +"@smithy/util-stream@npm:^4.2.2": + version: 4.2.2 + resolution: "@smithy/util-stream@npm:4.2.2" + dependencies: + "@smithy/fetch-http-handler": "npm:^5.0.4" + "@smithy/node-http-handler": "npm:^4.0.6" + "@smithy/types": "npm:^4.3.1" + "@smithy/util-base64": "npm:^4.0.0" + "@smithy/util-buffer-from": "npm:^4.0.0" + "@smithy/util-hex-encoding": "npm:^4.0.0" + "@smithy/util-utf8": "npm:^4.0.0" + tslib: "npm:^2.6.2" + checksum: 10c0/5e4ef783e41185d291a72e8503d02fd5a5f7bd23f3d30198f3d738c0f27dd6d7ea131fe6fbe36a6ac69b8bd4207f7dfc75a15329764e6aa52f62c45bc5442619 + languageName: node + linkType: hard + +"@smithy/util-uri-escape@npm:^4.0.0": + version: 4.0.0 + resolution: "@smithy/util-uri-escape@npm:4.0.0" + dependencies: + tslib: "npm:^2.6.2" + checksum: 10c0/23984624060756adba8aa4ab1693fe6b387ee5064d8ec4dfd39bb5908c4ee8b9c3f2dc755da9b07505d8e3ce1338c1867abfa74158931e4728bf3cfcf2c05c3d + languageName: node + linkType: hard + +"@smithy/util-utf8@npm:^2.0.0": + version: 2.3.0 + resolution: "@smithy/util-utf8@npm:2.3.0" + dependencies: + "@smithy/util-buffer-from": "npm:^2.2.0" + tslib: "npm:^2.6.2" + checksum: 10c0/e18840c58cc507ca57fdd624302aefd13337ee982754c9aa688463ffcae598c08461e8620e9852a424d662ffa948fc64919e852508028d09e89ced459bd506ab + languageName: node + linkType: hard + +"@smithy/util-utf8@npm:^4.0.0": + version: 4.0.0 + resolution: "@smithy/util-utf8@npm:4.0.0" + dependencies: + "@smithy/util-buffer-from": "npm:^4.0.0" + tslib: "npm:^2.6.2" + checksum: 10c0/28a5a5372cbf0b3d2e32dd16f79b04c2aec6f704cf13789db922e9686fde38dde0171491cfa4c2c201595d54752a319faaeeed3c325329610887694431e28c98 + languageName: node + linkType: hard + +"@smithy/util-waiter@npm:^4.0.6": + version: 4.0.6 + resolution: "@smithy/util-waiter@npm:4.0.6" + dependencies: + "@smithy/abort-controller": "npm:^4.0.4" + "@smithy/types": "npm:^4.3.1" + tslib: "npm:^2.6.2" + checksum: 10c0/4027aed03515dfb627c09e0d490f001b912def1142865d0ec8de1fc0422e7c71e96df3efc7b92c7fdfff9030105b2b4213120506d064ad346cc79124708c1b17 + languageName: node + linkType: hard + "@strongtz/win32-arm64-msvc@npm:^0.4.7": version: 0.4.7 resolution: "@strongtz/win32-arm64-msvc@npm:0.4.7" @@ -4930,6 +6172,13 @@ __metadata: languageName: node linkType: hard +"@types/uuid@npm:^9.0.1": + version: 9.0.8 + resolution: "@types/uuid@npm:9.0.8" + checksum: 10c0/b411b93054cb1d4361919579ef3508a1f12bf15b5fdd97337d3d351bece6c921b52b6daeef89b62340fd73fd60da407878432a1af777f40648cbe53a01723489 + languageName: node + linkType: hard + "@types/verror@npm:^1.10.3": version: 1.10.11 resolution: "@types/verror@npm:1.10.11" @@ -5811,6 +7060,7 @@ __metadata: "@agentic/tavily": "npm:^7.3.3" "@ant-design/v5-patch-for-react-19": "npm:^1.0.3" "@anthropic-ai/sdk": "npm:^0.41.0" + "@aws-sdk/client-s3": "npm:^3.840.0" "@cherrystudio/embedjs": "npm:^0.1.31" "@cherrystudio/embedjs-libsql": "npm:^0.1.31" "@cherrystudio/embedjs-loader-csv": "npm:^0.1.31" @@ -6691,6 +7941,13 @@ __metadata: languageName: node linkType: hard +"bowser@npm:^2.11.0": + version: 2.11.0 + resolution: "bowser@npm:2.11.0" + checksum: 10c0/04efeecc7927a9ec33c667fa0965dea19f4ac60b3fea60793c2e6cf06c1dcd2f7ae1dbc656f450c5f50783b1c75cf9dc173ba6f3b7db2feee01f8c4b793e1bd3 + languageName: node + linkType: hard + "brace-expansion@npm:^1.1.7": version: 1.1.11 resolution: "brace-expansion@npm:1.1.11" @@ -9955,6 +11212,17 @@ __metadata: languageName: node linkType: hard +"fast-xml-parser@npm:4.4.1": + version: 4.4.1 + resolution: "fast-xml-parser@npm:4.4.1" + dependencies: + strnum: "npm:^1.0.5" + bin: + fxparser: src/cli/cli.js + checksum: 10c0/7f334841fe41bfb0bf5d920904ccad09cefc4b5e61eaf4c225bf1e1bb69ee77ef2147d8942f783ee8249e154d1ca8a858e10bda78a5d78b8bed3f48dcee9bf33 + languageName: node + linkType: hard + "fast-xml-parser@npm:^4.5.0, fast-xml-parser@npm:^4.5.1": version: 4.5.3 resolution: "fast-xml-parser@npm:4.5.3" @@ -17573,7 +18841,7 @@ __metadata: languageName: node linkType: hard -"strnum@npm:^1.1.1": +"strnum@npm:^1.0.5, strnum@npm:^1.1.1": version: 1.1.2 resolution: "strnum@npm:1.1.2" checksum: 10c0/a0fce2498fa3c64ce64a40dada41beb91cabe3caefa910e467dc0518ef2ebd7e4d10f8c2202a6104f1410254cae245066c0e94e2521fb4061a5cb41831952392 @@ -18143,7 +19411,7 @@ __metadata: languageName: node linkType: hard -"tslib@npm:^2.0.1, tslib@npm:^2.1.0, tslib@npm:^2.4.0, tslib@npm:^2.8.1": +"tslib@npm:^2.0.1, tslib@npm:^2.1.0, tslib@npm:^2.4.0, tslib@npm:^2.6.2, tslib@npm:^2.8.1": version: 2.8.1 resolution: "tslib@npm:2.8.1" checksum: 10c0/9c4759110a19c53f992d9aae23aac5ced636e99887b51b9e61def52611732872ff7668757d4e4c61f19691e36f4da981cd9485e869b4a7408d689f6bf1f14e62 From a314a43f0f08b7f9dac4dc7314fcd49a66d241d1 Mon Sep 17 00:00:00 2001 From: Phantom <59059173+EurFelux@users.noreply.github.com> Date: Mon, 7 Jul 2025 22:08:56 +0800 Subject: [PATCH 32/54] refactor(translate): Language Type (#7727) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(translate): 重构翻译功能使用语言枚举类型 统一翻译功能中的语言表示方式,使用枚举类型替代字符串 更新相关组件和服务以适配新的语言类型定义 添加数据库迁移脚本处理语言类型变更 添加store迁移处理语言类型变更 * refactor(translate): 移除调试用的console.log语句 * refactor(translate): 移除冗余的类型检查逻辑 * fix(db): 添加对TranslateHistory的db迁移 * fix(databases): 捕获数据库升级时的语言映射错误 添加错误处理以防止语言映射失败时中断升级过程 * fix(翻译组件): 修复语言比较和选择逻辑错误 修复语言比较时直接比较对象而非langCode的问题 更新Select组件使用langCode作为值并正确处理语言切换 * refactor(translate): 将saveTranslateHistory参数类型从Language改为LanguageCode * refactor(hooks): 更新useMessageOperations中的语言代码类型 将targetLanguage和sourceLanguage参数类型从string更新为LanguageCode,提高类型安全性 * docs(translate): 更新JSDoc注释以使用TypeScript类型语法 * feat(备份服务): 升级数据库版本至v8并添加迁移逻辑 添加从v7到v8的数据库迁移支持 更新翻译历史记录中的语言代码映射 优化迁移过程中的日志记录和错误处理 * fix(store): 修复目标语言迁移时的默认值处理 确保在迁移配置时将旧版语言代码正确映射到新版格式,无法映射时使用默认英语 * refactor(translate): 将语言标签从字符串改为函数以支持动态翻译 * refactor(translate): 优化翻译窗口语言选择逻辑 重构翻译窗口的目标语言选择逻辑,使用语言代码获取完整语言信息 移除冗余的Space组件,简化Select选项渲染方式 * docs(技术文档): 新增数据库设置字段文档 添加数据库设置字段的说明文档,包含翻译相关字段的类型和用途 * refactor(translate): 修改db中biDirectionLangPair存储类型 将语言代码处理统一改为存储langCode而非Language对象 修改相关代码以使用getLanguageByLangcode进行转换 更新数据库升级逻辑以兼容新格式 * docs(translate): 为getLanguageByLangcode函数添加注释说明 * fix(数据库升级): 修复升级到V8时可能出现的空值访问问题 * refactor(databases): 优化语言映射错误处理逻辑 将不必要的try-catch块替换为if条件判断 * docs(technical): 修正数据库设置文档中的类型描述 * refactor: 优化语言代码处理和变量命名 * fix(ActionTranslate): 使用langCode存储双向翻译语言对 * fix(migrate): 修复错误的迁移过程 * refactor(translate): 重构语言选项从硬编码改为动态生成 将translateLanguageOptions从硬编码的数组改为通过LanguagesEnum动态生成,提高可维护性 * fix(store): 更新持久化存储版本并修复语言映射迁移问题 将持久化存储版本从119升级到120,并修复语言代码映射迁移问题。迁移过程中将旧的语言标识转换为新的标准语言代码格式。 --- docs/technical/db.settings.md | 11 + .../src/components/Popups/TextEditPopup.tsx | 3 +- .../src/components/TranslateButton.tsx | 5 +- src/renderer/src/config/translate.ts | 285 ++++++++++-------- src/renderer/src/databases/index.ts | 15 +- src/renderer/src/databases/upgrades.ts | 81 ++++- .../src/hooks/useMessageOperations.ts | 6 +- .../src/pages/home/Inputbar/Inputbar.tsx | 3 +- .../pages/home/Messages/MessageMenubar.tsx | 16 +- .../src/pages/home/Tabs/SettingsTab.tsx | 22 +- .../src/pages/paintings/AihubmixPage.tsx | 3 +- .../src/pages/paintings/SiliconPage.tsx | 3 +- .../src/pages/paintings/TokenFluxPage.tsx | 3 +- .../src/pages/translate/TranslatePage.tsx | 130 ++++---- src/renderer/src/services/AssistantService.ts | 6 +- src/renderer/src/services/BackupService.ts | 10 +- src/renderer/src/services/TranslateService.ts | 3 +- src/renderer/src/store/index.ts | 2 +- src/renderer/src/store/migrate.ts | 22 +- src/renderer/src/store/settings.ts | 2 +- src/renderer/src/types/index.ts | 44 ++- src/renderer/src/utils/translate.ts | 150 +++++---- .../mini/translate/TranslateWindow.tsx | 33 +- .../action/components/ActionTranslate.tsx | 56 ++-- 24 files changed, 557 insertions(+), 357 deletions(-) create mode 100644 docs/technical/db.settings.md diff --git a/docs/technical/db.settings.md b/docs/technical/db.settings.md new file mode 100644 index 0000000000..1d63098851 --- /dev/null +++ b/docs/technical/db.settings.md @@ -0,0 +1,11 @@ +# 数据库设置字段 + +此文档包含部分字段的数据类型说明。 + +## 字段 + +| 字段名 | 类型 | 说明 | +| ------------------------------ | ------------------------------ | ------------ | +| `translate:target:language` | `LanguageCode` | 翻译目标语言 | +| `translate:source:language` | `LanguageCode` | 翻译源语言 | +| `translate:bidirectional:pair` | `[LanguageCode, LanguageCode]` | 双向翻译对 | diff --git a/src/renderer/src/components/Popups/TextEditPopup.tsx b/src/renderer/src/components/Popups/TextEditPopup.tsx index 46bca109fc..ab7bf40cb6 100644 --- a/src/renderer/src/components/Popups/TextEditPopup.tsx +++ b/src/renderer/src/components/Popups/TextEditPopup.tsx @@ -3,6 +3,7 @@ import { useDefaultModel } from '@renderer/hooks/useAssistant' import { useSettings } from '@renderer/hooks/useSettings' import { fetchTranslate } from '@renderer/services/ApiService' import { getDefaultTranslateAssistant } from '@renderer/services/AssistantService' +import { getLanguageByLangcode } from '@renderer/utils/translate' import { Modal, ModalProps } from 'antd' import TextArea from 'antd/es/input/TextArea' import { TextAreaProps } from 'antd/lib/input' @@ -111,7 +112,7 @@ const PopupContainer: React.FC = ({ } try { - const assistant = getDefaultTranslateAssistant(targetLanguage, textValue) + const assistant = getDefaultTranslateAssistant(getLanguageByLangcode(targetLanguage), textValue) const translatedText = await fetchTranslate({ content: textValue, assistant }) if (isMounted.current) { setTextValue(translatedText) diff --git a/src/renderer/src/components/TranslateButton.tsx b/src/renderer/src/components/TranslateButton.tsx index 78dd1b7d34..d52448b488 100644 --- a/src/renderer/src/components/TranslateButton.tsx +++ b/src/renderer/src/components/TranslateButton.tsx @@ -3,6 +3,7 @@ import { useDefaultModel } from '@renderer/hooks/useAssistant' import { useSettings } from '@renderer/hooks/useSettings' import { fetchTranslate } from '@renderer/services/ApiService' import { getDefaultTranslateAssistant } from '@renderer/services/AssistantService' +import { getLanguageByLangcode } from '@renderer/utils/translate' import { Button, Tooltip } from 'antd' import { Languages } from 'lucide-react' import { FC, useEffect, useState } from 'react' @@ -54,7 +55,7 @@ const TranslateButton: FC = ({ text, onTranslated, disabled, style, isLoa setIsTranslating(true) try { - const assistant = getDefaultTranslateAssistant(targetLanguage, text) + const assistant = getDefaultTranslateAssistant(getLanguageByLangcode(targetLanguage), text) const translatedText = await fetchTranslate({ content: text, assistant }) onTranslated(translatedText) } catch (error) { @@ -75,7 +76,7 @@ const TranslateButton: FC = ({ text, onTranslated, disabled, style, isLoa return ( {isTranslating ? : } diff --git a/src/renderer/src/config/translate.ts b/src/renderer/src/config/translate.ts index 9a85b68ecc..cbac95aafa 100644 --- a/src/renderer/src/config/translate.ts +++ b/src/renderer/src/config/translate.ts @@ -1,136 +1,159 @@ import i18n from '@renderer/i18n' +import { Language } from '@renderer/types' -export interface TranslateLanguageOption { - value: string - langCode?: string - label: string - emoji: string +export const ENGLISH: Language = { + value: 'English', + langCode: 'en-us', + label: () => i18n.t('languages.english'), + emoji: '🇬🇧' } -export const TranslateLanguageOptions: TranslateLanguageOption[] = [ - { - value: 'English', - langCode: 'en-us', - label: i18n.t('languages.english'), - emoji: '🇬🇧' - }, - { - value: 'Chinese (Simplified)', - langCode: 'zh-cn', - label: i18n.t('languages.chinese'), - emoji: '🇨🇳' - }, - { - value: 'Chinese (Traditional)', - langCode: 'zh-tw', - label: i18n.t('languages.chinese-traditional'), - emoji: '🇭🇰' - }, - { - value: 'Japanese', - langCode: 'ja-jp', - label: i18n.t('languages.japanese'), - emoji: '🇯🇵' - }, - { - value: 'Korean', - langCode: 'ko-kr', - label: i18n.t('languages.korean'), - emoji: '🇰🇷' - }, - - { - value: 'French', - langCode: 'fr-fr', - label: i18n.t('languages.french'), - emoji: '🇫🇷' - }, - { - value: 'German', - langCode: 'de-de', - label: i18n.t('languages.german'), - emoji: '🇩🇪' - }, - { - value: 'Italian', - langCode: 'it-it', - label: i18n.t('languages.italian'), - emoji: '🇮🇹' - }, - { - value: 'Spanish', - langCode: 'es-es', - label: i18n.t('languages.spanish'), - emoji: '🇪🇸' - }, - { - value: 'Portuguese', - langCode: 'pt-pt', - label: i18n.t('languages.portuguese'), - emoji: '🇵🇹' - }, - { - value: 'Russian', - langCode: 'ru-ru', - label: i18n.t('languages.russian'), - emoji: '🇷🇺' - }, - { - value: 'Polish', - langCode: 'pl-pl', - label: i18n.t('languages.polish'), - emoji: '🇵🇱' - }, - { - value: 'Arabic', - langCode: 'ar-ar', - label: i18n.t('languages.arabic'), - emoji: '🇸🇦' - }, - { - value: 'Turkish', - langCode: 'tr-tr', - label: i18n.t('languages.turkish'), - emoji: '🇹🇷' - }, - { - value: 'Thai', - langCode: 'th-th', - label: i18n.t('languages.thai'), - emoji: '🇹🇭' - }, - { - value: 'Vietnamese', - langCode: 'vi-vn', - label: i18n.t('languages.vietnamese'), - emoji: '🇻🇳' - }, - { - value: 'Indonesian', - langCode: 'id-id', - label: i18n.t('languages.indonesian'), - emoji: '🇮🇩' - }, - { - value: 'Urdu', - langCode: 'ur-pk', - label: i18n.t('languages.urdu'), - emoji: '🇵🇰' - }, - { - value: 'Malay', - langCode: 'ms-my', - label: i18n.t('languages.malay'), - emoji: '🇲🇾' - } -] - -export const translateLanguageOptions = (): typeof TranslateLanguageOptions => { - return TranslateLanguageOptions.map((option) => { - return { - value: option.value, - label: option.label, - emoji: option.emoji - } - }) +export const CHINESE_SIMPLIFIED: Language = { + value: 'Chinese (Simplified)', + langCode: 'zh-cn', + label: () => i18n.t('languages.chinese'), + emoji: '🇨🇳' } + +export const CHINESE_TRADITIONAL: Language = { + value: 'Chinese (Traditional)', + langCode: 'zh-tw', + label: () => i18n.t('languages.chinese-traditional'), + emoji: '🇭🇰' +} + +export const JAPANESE: Language = { + value: 'Japanese', + langCode: 'ja-jp', + label: () => i18n.t('languages.japanese'), + emoji: '🇯🇵' +} + +export const KOREAN: Language = { + value: 'Korean', + langCode: 'ko-kr', + label: () => i18n.t('languages.korean'), + emoji: '🇰🇷' +} + +export const FRENCH: Language = { + value: 'French', + langCode: 'fr-fr', + label: () => i18n.t('languages.french'), + emoji: '🇫🇷' +} + +export const GERMAN: Language = { + value: 'German', + langCode: 'de-de', + label: () => i18n.t('languages.german'), + emoji: '🇩🇪' +} + +export const ITALIAN: Language = { + value: 'Italian', + langCode: 'it-it', + label: () => i18n.t('languages.italian'), + emoji: '🇮🇹' +} + +export const SPANISH: Language = { + value: 'Spanish', + langCode: 'es-es', + label: () => i18n.t('languages.spanish'), + emoji: '🇪🇸' +} + +export const PORTUGUESE: Language = { + value: 'Portuguese', + langCode: 'pt-pt', + label: () => i18n.t('languages.portuguese'), + emoji: '🇵🇹' +} + +export const RUSSIAN: Language = { + value: 'Russian', + langCode: 'ru-ru', + label: () => i18n.t('languages.russian'), + emoji: '🇷🇺' +} + +export const POLISH: Language = { + value: 'Polish', + langCode: 'pl-pl', + label: () => i18n.t('languages.polish'), + emoji: '🇵🇱' +} + +export const ARABIC: Language = { + value: 'Arabic', + langCode: 'ar-ar', + label: () => i18n.t('languages.arabic'), + emoji: '🇸🇦' +} + +export const TURKISH: Language = { + value: 'Turkish', + langCode: 'tr-tr', + label: () => i18n.t('languages.turkish'), + emoji: '🇹🇷' +} + +export const THAI: Language = { + value: 'Thai', + langCode: 'th-th', + label: () => i18n.t('languages.thai'), + emoji: '🇹🇭' +} + +export const VIETNAMESE: Language = { + value: 'Vietnamese', + langCode: 'vi-vn', + label: () => i18n.t('languages.vietnamese'), + emoji: '🇻🇳' +} + +export const INDONESIAN: Language = { + value: 'Indonesian', + langCode: 'id-id', + label: () => i18n.t('languages.indonesian'), + emoji: '🇮🇩' +} + +export const URDU: Language = { + value: 'Urdu', + langCode: 'ur-pk', + label: () => i18n.t('languages.urdu'), + emoji: '🇵🇰' +} + +export const MALAY: Language = { + value: 'Malay', + langCode: 'ms-my', + label: () => i18n.t('languages.malay'), + emoji: '🇲🇾' +} + +export const LanguagesEnum = { + enUS: ENGLISH, + zhCN: CHINESE_SIMPLIFIED, + zhTW: CHINESE_TRADITIONAL, + jaJP: JAPANESE, + koKR: KOREAN, + frFR: FRENCH, + deDE: GERMAN, + itIT: ITALIAN, + esES: SPANISH, + ptPT: PORTUGUESE, + ruRU: RUSSIAN, + plPL: POLISH, + arAR: ARABIC, + trTR: TURKISH, + thTH: THAI, + viVN: VIETNAMESE, + idID: INDONESIAN, + urPK: URDU, + msMY: MALAY +} as const + +export const translateLanguageOptions: Language[] = Object.values(LanguagesEnum) diff --git a/src/renderer/src/databases/index.ts b/src/renderer/src/databases/index.ts index aa765db05b..6c23a115a5 100644 --- a/src/renderer/src/databases/index.ts +++ b/src/renderer/src/databases/index.ts @@ -3,7 +3,7 @@ import { FileMetadata, KnowledgeItem, QuickPhrase, TranslateHistory } from '@ren import type { Message as NewMessage, MessageBlock } from '@renderer/types/newMessage' import { Dexie, type EntityTable } from 'dexie' -import { upgradeToV5, upgradeToV7 } from './upgrades' +import { upgradeToV5, upgradeToV7, upgradeToV8 } from './upgrades' // Database declaration (move this to its own module also) export const db = new Dexie('CherryStudio') as Dexie & { @@ -74,4 +74,17 @@ db.version(7) }) .upgrade((tx) => upgradeToV7(tx)) +db.version(8) + .stores({ + // Re-declare all tables for the new version + files: 'id, name, origin_name, path, size, ext, type, created_at, count', + topics: '&id', // Correct index for topics + settings: '&id, value', + knowledge_notes: '&id, baseId, type, content, created_at, updated_at', + translate_history: '&id, sourceText, targetText, sourceLanguage, targetLanguage, createdAt', + quick_phrases: 'id', + message_blocks: 'id, messageId, file.id' // Correct syntax with comma separator + }) + .upgrade((tx) => upgradeToV8(tx)) + export default db diff --git a/src/renderer/src/databases/upgrades.ts b/src/renderer/src/databases/upgrades.ts index cb1e770db0..5543dde4ab 100644 --- a/src/renderer/src/databases/upgrades.ts +++ b/src/renderer/src/databases/upgrades.ts @@ -1,7 +1,7 @@ import Logger from '@renderer/config/logger' -import type { LegacyMessage as OldMessage, Topic } from '@renderer/types' -import { FileTypes } from '@renderer/types' // Import FileTypes enum -import { WebSearchSource } from '@renderer/types' +import { LanguagesEnum } from '@renderer/config/translate' +import type { LanguageCode, LegacyMessage as OldMessage, Topic } from '@renderer/types' +import { FileTypes, WebSearchSource } from '@renderer/types' // Import FileTypes enum import type { BaseMessageBlock, CitationMessageBlock, @@ -308,3 +308,78 @@ export async function upgradeToV7(tx: Transaction): Promise { Logger.log('DB migration to version 7 finished successfully.') } + +export async function upgradeToV8(tx: Transaction): Promise { + Logger.log('DB migration to version 8 started') + + const langMap: Record = { + english: 'en-us', + chinese: 'zh-cn', + 'chinese-traditional': 'zh-tw', + japanese: 'ja-jp', + korean: 'ko-kr', + french: 'fr-fr', + german: 'de-de', + italian: 'it-it', + spanish: 'es-es', + portuguese: 'pt-pt', + russian: 'ru-ru', + polish: 'pl-pl', + arabic: 'ar-ar', + turkish: 'tr-tr', + thai: 'th-th', + vietnamese: 'vi-vn', + indonesian: 'id-id', + urdu: 'ur-pk', + malay: 'ms-my' + } + + const settingsTable = tx.table('settings') + const defaultPair: [LanguageCode, LanguageCode] = [LanguagesEnum.enUS.langCode, LanguagesEnum.zhCN.langCode] + const originSource = (await settingsTable.get('translate:source:language'))?.value + const originTarget = (await settingsTable.get('translate:target:language'))?.value + const originPair = (await settingsTable.get('translate:bidirectional:pair'))?.value + let newSource, newTarget, newPair + Logger.log('originSource: %o', originSource) + if (originSource === 'auto') { + newSource = 'auto' + } else { + newSource = langMap[originSource] + if (!newSource) { + newSource = LanguagesEnum.enUS.langCode + } + } + + Logger.log('originTarget: %o', originTarget) + newTarget = langMap[originTarget] + if (!newTarget) { + newTarget = LanguagesEnum.zhCN.langCode + } + + Logger.log('originPair: %o', originPair) + newPair = [langMap[originPair[0]], langMap[originPair[1]]] + if (!newPair[0] || !newPair[1]) { + newPair = defaultPair + } + + Logger.log('DB migration to version 8: %o', { newSource, newTarget, newPair }) + + await settingsTable.put({ id: 'translate:bidirectional:pair', value: newPair }) + await settingsTable.put({ id: 'translate:source:language', value: newSource }) + await settingsTable.put({ id: 'translate:target:language', value: newTarget }) + + const histories = tx.table('translate_history') + + for (const history of await histories.toArray()) { + try { + await tx.table('translate_history').put({ + ...history, + sourceLanguage: langMap[history.sourceLanguage], + targetLanguage: langMap[history.targetLanguage] + }) + } catch (error) { + console.error('Error upgrading history:', error) + } + } + Logger.log('DB migration to version 8 finished.') +} diff --git a/src/renderer/src/hooks/useMessageOperations.ts b/src/renderer/src/hooks/useMessageOperations.ts index 559b4ad879..d8ac8aac60 100644 --- a/src/renderer/src/hooks/useMessageOperations.ts +++ b/src/renderer/src/hooks/useMessageOperations.ts @@ -19,7 +19,7 @@ import { updateMessageAndBlocksThunk, updateTranslationBlockThunk } from '@renderer/store/thunk/messageThunk' -import type { Assistant, Model, Topic } from '@renderer/types' +import type { Assistant, LanguageCode, Model, Topic } from '@renderer/types' import type { Message, MessageBlock } from '@renderer/types/newMessage' import { MessageBlockStatus, MessageBlockType } from '@renderer/types/newMessage' import { abortCompletion } from '@renderer/utils/abortController' @@ -195,9 +195,9 @@ export function useMessageOperations(topic: Topic) { const getTranslationUpdater = useCallback( async ( messageId: string, - targetLanguage: string, + targetLanguage: LanguageCode, sourceBlockId?: string, - sourceLanguage?: string + sourceLanguage?: LanguageCode ): Promise<((accumulatedText: string, isComplete?: boolean) => void) | null> => { if (!topic.id) return null diff --git a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx index fe550510a0..98095a81e1 100644 --- a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx +++ b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx @@ -37,6 +37,7 @@ import type { MessageInputBaseParams } from '@renderer/types/newMessage' import { classNames, delay, formatFileSize, getFileExtension } from '@renderer/utils' import { formatQuotedText } from '@renderer/utils/formats' import { getFilesFromDropEvent, getSendMessageShortcutLabel, isSendMessageKeyPressed } from '@renderer/utils/input' +import { getLanguageByLangcode } from '@renderer/utils/translate' import { documentExts, imageExts, textExts } from '@shared/config/constant' import { IpcChannel } from '@shared/IpcChannel' import { Button, Tooltip } from 'antd' @@ -253,7 +254,7 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = try { setIsTranslating(true) - const translatedText = await translateText(text, targetLanguage) + const translatedText = await translateText(text, getLanguageByLangcode(targetLanguage)) translatedText && setText(translatedText) setTimeout(() => resizeTextArea(), 0) } catch (error) { diff --git a/src/renderer/src/pages/home/Messages/MessageMenubar.tsx b/src/renderer/src/pages/home/Messages/MessageMenubar.tsx index dcd86288e1..a8d55bbe80 100644 --- a/src/renderer/src/pages/home/Messages/MessageMenubar.tsx +++ b/src/renderer/src/pages/home/Messages/MessageMenubar.tsx @@ -2,7 +2,7 @@ import { CheckOutlined, EditOutlined, QuestionCircleOutlined, SyncOutlined } fro import ObsidianExportPopup from '@renderer/components/Popups/ObsidianExportPopup' import SelectModelPopup from '@renderer/components/Popups/SelectModelPopup' import { isVisionModel } from '@renderer/config/models' -import { TranslateLanguageOptions } from '@renderer/config/translate' +import { translateLanguageOptions } from '@renderer/config/translate' import { useMessageEditing } from '@renderer/context/MessageEditingContext' import { useChatContext } from '@renderer/hooks/useChatContext' import { useMessageOperations, useTopicLoading } from '@renderer/hooks/useMessageOperations' @@ -13,7 +13,7 @@ import { translateText } from '@renderer/services/TranslateService' import store, { RootState } from '@renderer/store' import { messageBlocksSelectors } from '@renderer/store/messageBlock' import { selectMessagesForTopic } from '@renderer/store/newMessage' -import type { Assistant, Model, Topic } from '@renderer/types' +import type { Assistant, Language, Model, Topic } from '@renderer/types' import { type Message, MessageBlockType } from '@renderer/types/newMessage' import { captureScrollableDivAsBlob, captureScrollableDivAsDataURL, classNames } from '@renderer/utils' import { copyMessageAsPlainText } from '@renderer/utils/copy' @@ -153,12 +153,12 @@ const MessageMenubar: FC = (props) => { }, [message.id, startEditing]) const handleTranslate = useCallback( - async (language: string) => { + async (language: Language) => { if (isTranslating) return setIsTranslating(true) const messageId = message.id - const translationUpdater = await getTranslationUpdater(messageId, language) + const translationUpdater = await getTranslationUpdater(messageId, language.langCode) if (!translationUpdater) return try { await translateText(mainTextContent, language, translationUpdater) @@ -457,10 +457,10 @@ const MessageMenubar: FC = (props) => { backgroundClip: 'border-box' }, items: [ - ...TranslateLanguageOptions.map((item) => ({ - label: item.emoji + ' ' + item.label, - key: item.value, - onClick: () => handleTranslate(item.value) + ...translateLanguageOptions.map((item) => ({ + label: item.emoji + ' ' + item.label(), + key: item.langCode, + onClick: () => handleTranslate(item) })), ...(hasTranslationBlocks ? [ diff --git a/src/renderer/src/pages/home/Tabs/SettingsTab.tsx b/src/renderer/src/pages/home/Tabs/SettingsTab.tsx index 67c3ba7b97..cc56f72c05 100644 --- a/src/renderer/src/pages/home/Tabs/SettingsTab.tsx +++ b/src/renderer/src/pages/home/Tabs/SettingsTab.tsx @@ -8,6 +8,7 @@ import { isSupportedFlexServiceTier, isSupportedReasoningEffortOpenAIModel } from '@renderer/config/models' +import { translateLanguageOptions } from '@renderer/config/translate' import { useCodeStyle } from '@renderer/context/CodeStyleProvider' import { useTheme } from '@renderer/context/ThemeProvider' import { useAssistant } from '@renderer/hooks/useAssistant' @@ -44,14 +45,7 @@ import { setShowTranslateConfirm, setThoughtAutoCollapse } from '@renderer/store/settings' -import { - Assistant, - AssistantSettings, - CodeStyleVarious, - MathEngine, - ThemeMode, - TranslateLanguageVarious -} from '@renderer/types' +import { Assistant, AssistantSettings, CodeStyleVarious, MathEngine, ThemeMode } from '@renderer/types' import { modalConfirm } from '@renderer/utils' import { getSendMessageShortcutLabel } from '@renderer/utils/input' import { Button, Col, InputNumber, Row, Slider, Switch, Tooltip } from 'antd' @@ -625,14 +619,10 @@ const SettingsTab: FC = (props) => { {t('settings.input.target_language')} 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') }, - { value: 'english', label: t('settings.input.target_language.english') }, - { value: 'japanese', label: t('settings.input.target_language.japanese') }, - { value: 'russian', label: t('settings.input.target_language.russian') } - ]} + onChange={(value) => setTargetLanguage(value)} + options={translateLanguageOptions.map((item) => { + return { value: item.langCode, label: item.emoji + ' ' + item.label() } + })} /> diff --git a/src/renderer/src/pages/paintings/AihubmixPage.tsx b/src/renderer/src/pages/paintings/AihubmixPage.tsx index 60d152a353..af57c21f47 100644 --- a/src/renderer/src/pages/paintings/AihubmixPage.tsx +++ b/src/renderer/src/pages/paintings/AihubmixPage.tsx @@ -7,6 +7,7 @@ import Scrollbar from '@renderer/components/Scrollbar' import TranslateButton from '@renderer/components/TranslateButton' import { isMac } from '@renderer/config/constant' import { getProviderLogo } from '@renderer/config/providers' +import { LanguagesEnum } from '@renderer/config/translate' import { useTheme } from '@renderer/context/ThemeProvider' import { usePaintings } from '@renderer/hooks/usePaintings' import { useAllProviders } from '@renderer/hooks/useProvider' @@ -543,7 +544,7 @@ const AihubmixPage: FC<{ Options: string[] }> = ({ Options }) => { try { setIsTranslating(true) - const translatedText = await translateText(painting.prompt, 'english') + const translatedText = await translateText(painting.prompt, LanguagesEnum.enUS) updatePaintingState({ prompt: translatedText }) } catch (error) { console.error('Translation failed:', error) diff --git a/src/renderer/src/pages/paintings/SiliconPage.tsx b/src/renderer/src/pages/paintings/SiliconPage.tsx index 51edb4244a..f595ef76de 100644 --- a/src/renderer/src/pages/paintings/SiliconPage.tsx +++ b/src/renderer/src/pages/paintings/SiliconPage.tsx @@ -12,6 +12,7 @@ import Scrollbar from '@renderer/components/Scrollbar' import TranslateButton from '@renderer/components/TranslateButton' import { isMac } from '@renderer/config/constant' import { TEXT_TO_IMAGES_MODELS } from '@renderer/config/models' +import { LanguagesEnum } from '@renderer/config/translate' import { useTheme } from '@renderer/context/ThemeProvider' import { usePaintings } from '@renderer/hooks/usePaintings' import { useAllProviders } from '@renderer/hooks/useProvider' @@ -302,7 +303,7 @@ const SiliconPage: FC<{ Options: string[] }> = ({ Options }) => { try { setIsTranslating(true) - const translatedText = await translateText(painting.prompt, 'english') + const translatedText = await translateText(painting.prompt, LanguagesEnum.enUS) updatePaintingState({ prompt: translatedText }) } catch (error) { console.error('Translation failed:', error) diff --git a/src/renderer/src/pages/paintings/TokenFluxPage.tsx b/src/renderer/src/pages/paintings/TokenFluxPage.tsx index 85a39df438..40e1c2f2c0 100644 --- a/src/renderer/src/pages/paintings/TokenFluxPage.tsx +++ b/src/renderer/src/pages/paintings/TokenFluxPage.tsx @@ -4,6 +4,7 @@ import Scrollbar from '@renderer/components/Scrollbar' import TranslateButton from '@renderer/components/TranslateButton' import { isMac } from '@renderer/config/constant' import { getProviderLogo } from '@renderer/config/providers' +import { LanguagesEnum } from '@renderer/config/translate' import { usePaintings } from '@renderer/hooks/usePaintings' import { useAllProviders } from '@renderer/hooks/useProvider' import { useRuntime } from '@renderer/hooks/useRuntime' @@ -255,7 +256,7 @@ const TokenFluxPage: FC<{ Options: string[] }> = ({ Options }) => { try { setIsTranslating(true) - const translatedText = await translateText(painting.prompt, 'english') + const translatedText = await translateText(painting.prompt, LanguagesEnum.enUS) updatePaintingState({ prompt: translatedText }) } catch (error) { console.error('Translation failed:', error) diff --git a/src/renderer/src/pages/translate/TranslatePage.tsx b/src/renderer/src/pages/translate/TranslatePage.tsx index 8d4e7116b6..ae1ee408a3 100644 --- a/src/renderer/src/pages/translate/TranslatePage.tsx +++ b/src/renderer/src/pages/translate/TranslatePage.tsx @@ -4,7 +4,7 @@ import CopyIcon from '@renderer/components/Icons/CopyIcon' import { HStack } from '@renderer/components/Layout' import { isEmbeddingModel } from '@renderer/config/models' import { TRANSLATE_PROMPT } from '@renderer/config/prompts' -import { translateLanguageOptions } from '@renderer/config/translate' +import { LanguagesEnum, translateLanguageOptions } from '@renderer/config/translate' import { useCodeStyle } from '@renderer/context/CodeStyleProvider' import db from '@renderer/databases' import { useDefaultModel } from '@renderer/hooks/useAssistant' @@ -15,13 +15,14 @@ import { getDefaultTranslateAssistant } from '@renderer/services/AssistantServic import { getModelUniqId, hasModel } from '@renderer/services/ModelService' import { useAppDispatch } from '@renderer/store' import { setTranslateModelPrompt } from '@renderer/store/settings' -import type { Model, TranslateHistory } from '@renderer/types' +import type { Language, LanguageCode, Model, TranslateHistory } from '@renderer/types' import { runAsyncFunction, uuid } from '@renderer/utils' import { createInputScrollHandler, createOutputScrollHandler, detectLanguage, - determineTargetLanguage + determineTargetLanguage, + getLanguageByLangcode } from '@renderer/utils/translate' import { Button, Dropdown, Empty, Flex, Modal, Popconfirm, Select, Space, Switch, Tooltip } from 'antd' import TextArea, { TextAreaRef } from 'antd/es/input/TextArea' @@ -35,7 +36,7 @@ import styled from 'styled-components' let _text = '' let _result = '' -let _targetLanguage = 'english' +let _targetLanguage = LanguagesEnum.enUS const TranslateSettings: FC<{ visible: boolean @@ -46,8 +47,8 @@ const TranslateSettings: FC<{ setIsBidirectional: (value: boolean) => void enableMarkdown: boolean setEnableMarkdown: (value: boolean) => void - bidirectionalPair: [string, string] - setBidirectionalPair: (value: [string, string]) => void + bidirectionalPair: [Language, Language] + setBidirectionalPair: (value: [Language, Language]) => void translateModel: Model | undefined onModelChange: (model: Model) => void allModels: Model[] @@ -71,7 +72,7 @@ const TranslateSettings: FC<{ const { t } = useTranslation() const { translateModelPrompt } = useSettings() const dispatch = useAppDispatch() - const [localPair, setLocalPair] = useState<[string, string]>(bidirectionalPair) + const [localPair, setLocalPair] = useState<[Language, Language]>(bidirectionalPair) const [showPrompt, setShowPrompt] = useState(false) const [localPrompt, setLocalPrompt] = useState(translateModelPrompt) @@ -94,7 +95,7 @@ const TranslateSettings: FC<{ return } setBidirectionalPair(localPair) - db.settings.put({ id: 'translate:bidirectional:pair', value: localPair }) + db.settings.put({ id: 'translate:bidirectional:pair', value: [localPair[0].langCode, localPair[1].langCode] }) db.settings.put({ id: 'translate:scroll:sync', value: isScrollSyncEnabled }) db.settings.put({ id: 'translate:markdown:enabled', value: enableMarkdown }) db.settings.put({ id: 'translate:model:prompt', value: localPrompt }) @@ -189,16 +190,16 @@ const TranslateSettings: FC<{ setLocalPair([localPair[0], value])} - options={translateLanguageOptions().map((lang) => ({ - value: lang.value, + value={localPair[1].langCode} + onChange={(value) => setLocalPair([localPair[0], getLanguageByLangcode(value)])} + options={translateLanguageOptions.map((lang) => ({ + value: lang.langCode, label: ( {lang.emoji} -
    {lang.label}
    +
    {lang.label()}
    ) }))} @@ -275,7 +276,6 @@ const TranslateSettings: FC<{ const TranslatePage: FC = () => { const { t } = useTranslation() const { shikiMarkdownIt } = useCodeStyle() - const [targetLanguage, setTargetLanguage] = useState(_targetLanguage) const [text, setText] = useState(_text) const [result, setResult] = useState(_result) const [renderedMarkdown, setRenderedMarkdown] = useState('') @@ -286,10 +286,14 @@ const TranslatePage: FC = () => { const [isScrollSyncEnabled, setIsScrollSyncEnabled] = useState(false) const [isBidirectional, setIsBidirectional] = useState(false) const [enableMarkdown, setEnableMarkdown] = useState(false) - const [bidirectionalPair, setBidirectionalPair] = useState<[string, string]>(['english', 'chinese']) + const [bidirectionalPair, setBidirectionalPair] = useState<[Language, Language]>([ + LanguagesEnum.enUS, + LanguagesEnum.zhCN + ]) const [settingsVisible, setSettingsVisible] = useState(false) - const [detectedLanguage, setDetectedLanguage] = useState(null) - const [sourceLanguage, setSourceLanguage] = useState('auto') + const [detectedLanguage, setDetectedLanguage] = useState(null) + const [sourceLanguage, setSourceLanguage] = useState('auto') + const [targetLanguage, setTargetLanguage] = useState(_targetLanguage) const contentContainerRef = useRef(null) const textAreaRef = useRef(null) const outputTextRef = useRef(null) @@ -329,8 +333,8 @@ const TranslatePage: FC = () => { const saveTranslateHistory = async ( sourceText: string, targetText: string, - sourceLanguage: string, - targetLanguage: string + sourceLanguage: LanguageCode, + targetLanguage: LanguageCode ) => { const history: TranslateHistory = { id: uuid(), @@ -364,7 +368,7 @@ const TranslatePage: FC = () => { setLoading(true) try { // 确定源语言:如果用户选择了特定语言,使用用户选择的;如果选择'auto',则自动检测 - let actualSourceLanguage: string + let actualSourceLanguage: Language if (sourceLanguage === 'auto') { actualSourceLanguage = await detectLanguage(text) setDetectedLanguage(actualSourceLanguage) @@ -389,7 +393,7 @@ const TranslatePage: FC = () => { return } - const actualTargetLanguage = result.language as string + const actualTargetLanguage = result.language as Language if (isBidirectional) { setTargetLanguage(actualTargetLanguage) } @@ -405,7 +409,7 @@ const TranslatePage: FC = () => { } }) - await saveTranslateHistory(text, translatedText, actualSourceLanguage, actualTargetLanguage) + await saveTranslateHistory(text, translatedText, actualSourceLanguage.langCode, actualTargetLanguage.langCode) setLoading(false) } catch (error) { console.error('Translation error:', error) @@ -432,7 +436,7 @@ const TranslatePage: FC = () => { const onHistoryItemClick = (history: TranslateHistory) => { setText(history.sourceText) setResult(history.targetText) - setTargetLanguage(history.targetLanguage) + setTargetLanguage(getLanguageByLangcode(history.targetLanguage)) } useEffect(() => { @@ -460,20 +464,32 @@ const TranslatePage: FC = () => { useEffect(() => { runAsyncFunction(async () => { const targetLang = await db.settings.get({ id: 'translate:target:language' }) - targetLang && setTargetLanguage(targetLang.value) + targetLang && setTargetLanguage(getLanguageByLangcode(targetLang.value)) const sourceLang = await db.settings.get({ id: 'translate:source:language' }) - sourceLang && setSourceLanguage(sourceLang.value) + sourceLang && + setSourceLanguage(sourceLang.value === 'auto' ? sourceLang.value : getLanguageByLangcode(sourceLang.value)) const bidirectionalPairSetting = await db.settings.get({ id: 'translate:bidirectional:pair' }) if (bidirectionalPairSetting) { const langPair = bidirectionalPairSetting.value + let source: undefined | Language + let target: undefined | Language + if (Array.isArray(langPair) && langPair.length === 2 && langPair[0] !== langPair[1]) { - setBidirectionalPair(langPair as [string, string]) + source = getLanguageByLangcode(langPair[0]) + target = getLanguageByLangcode(langPair[1]) + } + + if (source && target) { + setBidirectionalPair([source, target]) } else { - const defaultPair: [string, string] = ['english', 'chinese'] + const defaultPair: [Language, Language] = [LanguagesEnum.enUS, LanguagesEnum.zhCN] setBidirectionalPair(defaultPair) - db.settings.put({ id: 'translate:bidirectional:pair', value: defaultPair }) + db.settings.put({ + id: 'translate:bidirectional:pair', + value: [defaultPair[0].langCode, defaultPair[1].langCode] + }) } } @@ -489,7 +505,7 @@ const TranslatePage: FC = () => { }, []) const onKeyDown = (e: React.KeyboardEvent) => { - const isEnterPressed = e.keyCode == 13 + const isEnterPressed = e.key === 'Enter' if (isEnterPressed && !e.shiftKey && !e.ctrlKey && !e.metaKey) { e.preventDefault() onTranslate() @@ -501,32 +517,37 @@ const TranslatePage: FC = () => { // 获取当前语言状态显示 const getLanguageDisplay = () => { - if (isBidirectional) { - return ( - - - {`${t(`languages.${bidirectionalPair[0]}`)} ⇆ ${t(`languages.${bidirectionalPair[1]}`)}`} - - - ) + try { + if (isBidirectional) { + return ( + + + {`${bidirectionalPair[0].label()} ⇆ ${bidirectionalPair[1].label()}`} + + + ) + } + } catch (error) { + console.error('Error getting language display:', error) + setBidirectionalPair([LanguagesEnum.enUS, LanguagesEnum.zhCN]) } return ( { - setSourceLanguage(value) + onChange={(value: LanguageCode | 'auto') => { + if (value !== 'auto') setSourceLanguage(getLanguageByLangcode(value)) + else setSourceLanguage('auto') 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')} (${detectedLanguage.label()})` : t('translate.detected.language') }, - ...translateLanguageOptions().map((lang) => ({ - value: lang.value, + ...translateLanguageOptions.map((lang) => ({ + value: lang.langCode, label: ( {lang.emoji} - {lang.label} + {lang.label()} ) })) diff --git a/src/renderer/src/services/AssistantService.ts b/src/renderer/src/services/AssistantService.ts index e8ec416b1e..0d216aa3aa 100644 --- a/src/renderer/src/services/AssistantService.ts +++ b/src/renderer/src/services/AssistantService.ts @@ -2,7 +2,7 @@ import { DEFAULT_CONTEXTCOUNT, DEFAULT_MAX_TOKENS, DEFAULT_TEMPERATURE } from '@ import i18n from '@renderer/i18n' import store from '@renderer/store' import { addAssistant } from '@renderer/store/assistants' -import type { Agent, Assistant, AssistantSettings, Model, Provider, Topic } from '@renderer/types' +import type { Agent, Assistant, AssistantSettings, Language, Model, Provider, Topic } from '@renderer/types' import { uuid } from '@renderer/utils' export function getDefaultAssistant(): Assistant { @@ -28,7 +28,7 @@ export function getDefaultAssistant(): Assistant { } } -export function getDefaultTranslateAssistant(targetLanguage: string, text: string): Assistant { +export function getDefaultTranslateAssistant(targetLanguage: Language, text: string): Assistant { const translateModel = getTranslateModel() const assistant: Assistant = getDefaultAssistant() assistant.model = translateModel @@ -39,7 +39,7 @@ export function getDefaultTranslateAssistant(targetLanguage: string, text: strin assistant.prompt = store .getState() - .settings.translateModelPrompt.replaceAll('{{target_language}}', targetLanguage) + .settings.translateModelPrompt.replaceAll('{{target_language}}', targetLanguage.value) .replaceAll('{{text}}', text) return assistant } diff --git a/src/renderer/src/services/BackupService.ts b/src/renderer/src/services/BackupService.ts index 00a09acf54..4bb92f38b0 100644 --- a/src/renderer/src/services/BackupService.ts +++ b/src/renderer/src/services/BackupService.ts @@ -1,6 +1,6 @@ import Logger from '@renderer/config/logger' import db from '@renderer/databases' -import { upgradeToV7 } from '@renderer/databases/upgrades' +import { upgradeToV7, upgradeToV8 } from '@renderer/databases/upgrades' import i18n from '@renderer/i18n' import store from '@renderer/store' import { setWebDAVSyncState } from '@renderer/store/backup' @@ -637,7 +637,7 @@ export function stopAutoSync() { export async function getBackupData() { return JSON.stringify({ time: new Date().getTime(), - version: 4, + version: 5, localStorage, indexedDB: await backupDatabase() }) @@ -674,6 +674,12 @@ export async function handleData(data: Record) { }) } + if (data.version === 4) { + await db.transaction('rw', db.tables, async (tx) => { + await upgradeToV8(tx) + }) + } + window.message.success({ content: i18n.t('message.restore.success'), key: 'restore' }) setTimeout(() => window.api.reload(), 1000) return diff --git a/src/renderer/src/services/TranslateService.ts b/src/renderer/src/services/TranslateService.ts index 513fa3ef36..1fb25d1a86 100644 --- a/src/renderer/src/services/TranslateService.ts +++ b/src/renderer/src/services/TranslateService.ts @@ -1,12 +1,13 @@ import i18n from '@renderer/i18n' import store from '@renderer/store' +import { Language } from '@renderer/types' import { fetchTranslate } from './ApiService' import { getDefaultTranslateAssistant } from './AssistantService' export const translateText = async ( text: string, - targetLanguage: string, + targetLanguage: Language, onResponse?: (text: string, isComplete: boolean) => void ) => { const translateModel = store.getState().llm.translateModel diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index f0c0cb2680..5f1d84bfae 100644 --- a/src/renderer/src/store/index.ts +++ b/src/renderer/src/store/index.ts @@ -54,7 +54,7 @@ const persistedReducer = persistReducer( { key: 'cherry-studio', storage, - version: 119, + version: 120, blacklist: ['runtime', 'messages', 'messageBlocks'], migrate }, diff --git a/src/renderer/src/store/migrate.ts b/src/renderer/src/store/migrate.ts index a305d03735..53c28a84ba 100644 --- a/src/renderer/src/store/migrate.ts +++ b/src/renderer/src/store/migrate.ts @@ -5,7 +5,7 @@ import { SYSTEM_MODELS } from '@renderer/config/models' import { TRANSLATE_PROMPT } from '@renderer/config/prompts' import db from '@renderer/databases' import i18n from '@renderer/i18n' -import { Assistant, Provider, WebSearchProvider } from '@renderer/types' +import { Assistant, LanguageCode, Provider, WebSearchProvider } from '@renderer/types' import { getDefaultGroupName, getLeadingEmoji, runAsyncFunction, uuid } from '@renderer/utils' import { UpgradeChannel } from '@shared/config/constant' import { isEmpty } from 'lodash' @@ -897,6 +897,7 @@ const migrateConfig = { }, '65': (state: RootState) => { try { + // @ts-ignore expect error state.settings.targetLanguage = 'english' return state } catch (error) { @@ -1736,6 +1737,25 @@ const migrateConfig = { } catch (error) { return state } + }, + '120': (state: RootState) => { + try { + const langMap: Record = { + english: 'en-us', + chinese: 'zh-cn', + 'chinese-traditional': 'zh-tw', + japanese: 'ja-jp', + russian: 'ru-ru' + } + + const origin = state.settings.targetLanguage + const newLang = langMap[origin] + if (newLang) state.settings.targetLanguage = newLang + else state.settings.targetLanguage = 'en-us' + return state + } catch (error) { + return state + } } } diff --git a/src/renderer/src/store/settings.ts b/src/renderer/src/store/settings.ts index 771e7cf349..71b99b9463 100644 --- a/src/renderer/src/store/settings.ts +++ b/src/renderer/src/store/settings.ts @@ -201,7 +201,7 @@ export const initialState: SettingsState = { assistantsTabSortType: 'list', sendMessageShortcut: 'Enter', language: navigator.language as LanguageVarious, - targetLanguage: 'english' as TranslateLanguageVarious, + targetLanguage: 'en-us', proxyMode: 'system', proxyUrl: undefined, userName: '', diff --git a/src/renderer/src/types/index.ts b/src/renderer/src/types/index.ts index 21eb4bbc99..b90e41b79b 100644 --- a/src/renderer/src/types/index.ts +++ b/src/renderer/src/types/index.ts @@ -340,16 +340,7 @@ export enum ThemeMode { export type LanguageVarious = 'zh-CN' | 'zh-TW' | 'el-GR' | 'en-US' | 'es-ES' | 'fr-FR' | 'ja-JP' | 'pt-PT' | 'ru-RU' -export type TranslateLanguageVarious = - | 'chinese' - | 'chinese-traditional' - | 'greek' - | 'english' - | 'spanish' - | 'french' - | 'japanese' - | 'portuguese' - | 'russian' +export type TranslateLanguageVarious = LanguageCode export type CodeStyleVarious = 'auto' | string @@ -489,12 +480,41 @@ export type GenerateImageResponse = { images: string[] } +export type LanguageCode = + | 'en-us' + | 'zh-cn' + | 'zh-tw' + | 'ja-jp' + | 'ko-kr' + | 'fr-fr' + | 'de-de' + | 'it-it' + | 'es-es' + | 'pt-pt' + | 'ru-ru' + | 'pl-pl' + | 'ar-ar' + | 'tr-tr' + | 'th-th' + | 'vi-vn' + | 'id-id' + | 'ur-pk' + | 'ms-my' + +// langCode应当能够唯一确认一种语言 +export type Language = { + value: string + langCode: LanguageCode + label: () => string + emoji: string +} + export interface TranslateHistory { id: string sourceText: string targetText: string - sourceLanguage: string - targetLanguage: string + sourceLanguage: LanguageCode + targetLanguage: LanguageCode createdAt: string } diff --git a/src/renderer/src/utils/translate.ts b/src/renderer/src/utils/translate.ts index bd50733482..77a88928b9 100644 --- a/src/renderer/src/utils/translate.ts +++ b/src/renderer/src/utils/translate.ts @@ -1,13 +1,15 @@ +import { LanguagesEnum } from '@renderer/config/translate' +import { Language, LanguageCode } from '@renderer/types' import { franc } from 'franc-min' import React, { MutableRefObject } from 'react' /** * 使用Unicode字符范围检测语言 * 适用于较短文本的语言检测 - * @param {string} text 需要检测语言的文本 - * @returns {string} 检测到的语言代码 + * @param text 需要检测语言的文本 + * @returns 检测到的语言 */ -export const detectLanguageByUnicode = (text: string): string => { +export const detectLanguageByUnicode = (text: string): Language => { const counts = { zh: 0, ja: 0, @@ -40,8 +42,8 @@ export const detectLanguageByUnicode = (text: string): string => { } } - if (totalChars === 0) return 'en' - let maxLang = 'en' + if (totalChars === 0) return LanguagesEnum.enUS + let maxLang = '' let maxCount = 0 for (const [lang, count] of Object.entries(counts)) { @@ -52,73 +54,68 @@ export const detectLanguageByUnicode = (text: string): string => { } if (maxCount / totalChars < 0.3) { - return 'en' + return LanguagesEnum.enUS + } + + switch (maxLang) { + case 'zh': + return LanguagesEnum.zhCN + case 'ja': + return LanguagesEnum.jaJP + case 'ko': + return LanguagesEnum.koKR + case 'ru': + return LanguagesEnum.ruRU + case 'ar': + return LanguagesEnum.arAR + case 'en': + return LanguagesEnum.enUS + default: + console.error(`Unknown language: ${maxLang}`) + return LanguagesEnum.enUS } - return maxLang } /** * 检测输入文本的语言 - * @param {string} inputText 需要检测语言的文本 - * @returns {Promise} 检测到的语言代码 + * @param inputText 需要检测语言的文本 + * @returns 检测到的语言 */ -export const detectLanguage = async (inputText: string): Promise => { +export const detectLanguage = async (inputText: string): Promise => { const text = inputText.trim() - if (!text) return 'any' - let code: string + if (!text) return LanguagesEnum.zhCN + let lang: Language // 如果文本长度小于20个字符,使用Unicode范围检测 if (text.length < 20) { - code = detectLanguageByUnicode(text) + lang = detectLanguageByUnicode(text) } else { // franc 返回 ISO 639-3 代码 const iso3 = franc(text) - const isoMap: Record = { - cmn: 'zh', - jpn: 'ja', - kor: 'ko', - rus: 'ru', - ara: 'ar', - spa: 'es', - fra: 'fr', - deu: 'de', - ita: 'it', - por: 'pt', - eng: 'en', - pol: 'pl', - tur: 'tr', - tha: 'th', - vie: 'vi', - ind: 'id', - urd: 'ur', - zsm: 'ms' + const isoMap: Record = { + cmn: LanguagesEnum.zhCN, + jpn: LanguagesEnum.jaJP, + kor: LanguagesEnum.koKR, + rus: LanguagesEnum.ruRU, + ara: LanguagesEnum.arAR, + spa: LanguagesEnum.esES, + fra: LanguagesEnum.frFR, + deu: LanguagesEnum.deDE, + ita: LanguagesEnum.itIT, + por: LanguagesEnum.ptPT, + eng: LanguagesEnum.enUS, + pol: LanguagesEnum.plPL, + tur: LanguagesEnum.trTR, + tha: LanguagesEnum.thTH, + vie: LanguagesEnum.viVN, + ind: LanguagesEnum.idID, + urd: LanguagesEnum.urPK, + zsm: LanguagesEnum.msMY } - code = isoMap[iso3] || 'en' + lang = isoMap[iso3] || LanguagesEnum.enUS } - // 映射到应用使用的语言键 - const languageMap: Record = { - zh: 'chinese', - ja: 'japanese', - ko: 'korean', - ru: 'russian', - es: 'spanish', - fr: 'french', - de: 'german', - it: 'italian', - pt: 'portuguese', - ar: 'arabic', - en: 'english', - pl: 'polish', - tr: 'turkish', - th: 'thai', - vi: 'vietnamese', - id: 'indonesian', - ur: 'urdu', - ms: 'malay' - } - - return languageMap[code] || 'english' + return lang } /** @@ -127,10 +124,13 @@ export const detectLanguage = async (inputText: string): Promise => { * @param languagePair 配置的语言对 * @returns 目标语言 */ -export const getTargetLanguageForBidirectional = (sourceLanguage: string, languagePair: [string, string]): string => { - if (sourceLanguage === languagePair[0]) { +export const getTargetLanguageForBidirectional = ( + sourceLanguage: Language, + languagePair: [Language, Language] +): Language => { + if (sourceLanguage.langCode === languagePair[0].langCode) { return languagePair[1] - } else if (sourceLanguage === languagePair[1]) { + } else if (sourceLanguage.langCode === languagePair[1].langCode) { return languagePair[0] } return languagePair[0] !== sourceLanguage ? languagePair[0] : languagePair[1] @@ -142,8 +142,8 @@ export const getTargetLanguageForBidirectional = (sourceLanguage: string, langua * @param languagePair 配置的语言对 * @returns 是否在语言对中 */ -export const isLanguageInPair = (sourceLanguage: string, languagePair: [string, string]): boolean => { - return [languagePair[0], languagePair[1]].includes(sourceLanguage) +export const isLanguageInPair = (sourceLanguage: Language, languagePair: [Language, Language]): boolean => { + return [languagePair[0].langCode, languagePair[1].langCode].includes(sourceLanguage.langCode) } /** @@ -155,11 +155,11 @@ export const isLanguageInPair = (sourceLanguage: string, languagePair: [string, * @returns 处理结果对象 */ export const determineTargetLanguage = ( - sourceLanguage: string, - targetLanguage: string, + sourceLanguage: Language, + targetLanguage: Language, isBidirectional: boolean, - bidirectionalPair: [string, string] -): { success: boolean; language?: string; errorType?: 'same_language' | 'not_in_pair' } => { + bidirectionalPair: [Language, Language] +): { success: boolean; language?: Language; errorType?: 'same_language' | 'not_in_pair' } => { if (isBidirectional) { if (!isLanguageInPair(sourceLanguage, bidirectionalPair)) { return { success: false, errorType: 'not_in_pair' } @@ -169,7 +169,7 @@ export const determineTargetLanguage = ( language: getTargetLanguageForBidirectional(sourceLanguage, bidirectionalPair) } } else { - if (sourceLanguage === targetLanguage) { + if (sourceLanguage.langCode === targetLanguage.langCode) { return { success: false, errorType: 'same_language' } } return { success: true, language: targetLanguage } @@ -228,3 +228,21 @@ export const createOutputScrollHandler = ( handleScrollSync(e.currentTarget, inputEl, isProgrammaticScrollRef) } } + +/** + * 根据语言代码获取对应的语言对象 + * @param langcode - 语言代码 + * @returns 返回对应的语言对象,如果找不到则返回英语(enUS) + * @example + * ```typescript + * const language = getLanguageByLangcode('zh-cn') // 返回中文语言对象 + * ``` + */ +export const getLanguageByLangcode = (langcode: LanguageCode): Language => { + const result = Object.values(LanguagesEnum).find((item) => item.langCode === langcode) + if (!result) { + console.error(`Language not found for langcode: ${langcode}`) + return LanguagesEnum.enUS + } + return result +} diff --git a/src/renderer/src/windows/mini/translate/TranslateWindow.tsx b/src/renderer/src/windows/mini/translate/TranslateWindow.tsx index 26e83fcf17..9cfde74bd3 100644 --- a/src/renderer/src/windows/mini/translate/TranslateWindow.tsx +++ b/src/renderer/src/windows/mini/translate/TranslateWindow.tsx @@ -1,13 +1,14 @@ import { SwapOutlined } from '@ant-design/icons' import Scrollbar from '@renderer/components/Scrollbar' -import { TranslateLanguageOptions } from '@renderer/config/translate' +import { LanguagesEnum, translateLanguageOptions } from '@renderer/config/translate' import db from '@renderer/databases' import { useDefaultModel } from '@renderer/hooks/useAssistant' import { fetchTranslate } from '@renderer/services/ApiService' import { getDefaultTranslateAssistant } from '@renderer/services/AssistantService' -import { Assistant } from '@renderer/types' +import { Assistant, Language } from '@renderer/types' import { runAsyncFunction } from '@renderer/utils' -import { Select, Space } from 'antd' +import { getLanguageByLangcode } from '@renderer/utils/translate' +import { Select } from 'antd' import { isEmpty } from 'lodash' import { FC, useCallback, useEffect, useRef, useState } from 'react' import { useHotkeys } from 'react-hotkeys-hook' @@ -18,11 +19,11 @@ interface Props { text: string } -let _targetLanguage = 'chinese' +let _targetLanguage = (await db.settings.get({ id: 'translate:target:language' }))?.value || LanguagesEnum.zhCN const Translate: FC = ({ text }) => { const [result, setResult] = useState('') - const [targetLanguage, setTargetLanguage] = useState(_targetLanguage) + const [targetLanguage, setTargetLanguage] = useState(_targetLanguage) const { translateModel } = useDefaultModel() const { t } = useTranslation() const translatingRef = useRef(false) @@ -37,8 +38,7 @@ const Translate: FC = ({ text }) => { try { translatingRef.current = true - const targetLang = await db.settings.get({ id: 'translate:target:language' }) - const assistant: Assistant = getDefaultTranslateAssistant(targetLang?.value || targetLanguage, text) + const assistant: Assistant = getDefaultTranslateAssistant(targetLanguage, text) // const message: Message = { // id: uuid(), // role: 'user', @@ -64,7 +64,7 @@ const Translate: FC = ({ text }) => { useEffect(() => { runAsyncFunction(async () => { const targetLang = await db.settings.get({ id: 'translate:target:language' }) - targetLang && setTargetLanguage(targetLang.value) + targetLang && setTargetLanguage(getLanguageByLangcode(targetLang.value)) }) }, []) @@ -91,22 +91,17 @@ const Translate: FC = ({ text }) => { ({ - value: lang.value, + options={translateLanguageOptions.map((lang) => ({ + value: lang.langCode, label: ( {lang.emoji} - {lang.label} + {lang.label()} ) }))} - onChange={(value) => handleChangeLanguage(value, alterLanguage)} + onChange={(value) => handleChangeLanguage(getLanguageByLangcode(value), alterLanguage)} disabled={isLoading} />
    + + ) +} diff --git a/src/renderer/src/components/LocalBackupModals.tsx b/src/renderer/src/components/LocalBackupModals.tsx new file mode 100644 index 0000000000..9f2a700dd2 --- /dev/null +++ b/src/renderer/src/components/LocalBackupModals.tsx @@ -0,0 +1,98 @@ +import { backupToLocalDir } from '@renderer/services/BackupService' +import { Button, Input, Modal } from 'antd' +import dayjs from 'dayjs' +import { useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' + +interface LocalBackupModalProps { + isModalVisible: boolean + handleBackup: () => void + handleCancel: () => void + backuping: boolean + customFileName: string + setCustomFileName: (value: string) => void +} + +export function LocalBackupModal({ + isModalVisible, + handleBackup, + handleCancel, + backuping, + customFileName, + setCustomFileName +}: LocalBackupModalProps) { + const { t } = useTranslation() + + return ( + + {t('common.cancel')} + , + + ]}> + setCustomFileName(e.target.value)} + placeholder={t('settings.data.local.backup.modal.filename.placeholder')} + /> + + ) +} + +// Hook for backup modal +export function useLocalBackupModal(localBackupDir: string | undefined) { + const [isModalVisible, setIsModalVisible] = useState(false) + const [backuping, setBackuping] = useState(false) + const [customFileName, setCustomFileName] = useState('') + + 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) + }, []) + + const handleBackup = async () => { + if (!localBackupDir) { + setIsModalVisible(false) + return + } + + setBackuping(true) + try { + await backupToLocalDir({ + showMessage: true, + customFileName + }) + setIsModalVisible(false) + } catch (error) { + console.error('[LocalBackupModal] Backup failed:', error) + } finally { + setBackuping(false) + } + } + + return { + isModalVisible, + handleBackup, + handleCancel, + backuping, + customFileName, + setCustomFileName, + showBackupModal + } +} diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index a627ce3725..79a9b0236c 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -374,6 +374,7 @@ "assistant": "Assistant", "avatar": "Avatar", "back": "Back", + "browse": "Browse", "cancel": "Cancel", "chat": "Chat", "clear": "Clear", @@ -1266,7 +1267,52 @@ "syncStatus": "Backup Status", "title": "WebDAV", "user": "WebDAV User", - "maxBackups": "Maximum Backups", + "maxBackups": "Maximum Backups" + }, + "local": { + "autoSync": "Auto Backup", + "autoSync.off": "Off", + "backup.button": "Backup to Local", + "backup.modal.filename.placeholder": "Please enter backup filename", + "backup.modal.title": "Backup to Local Directory", + "backup.manager.title": "Local Backup Manager", + "backup.manager.refresh": "Refresh", + "backup.manager.delete.selected": "Delete Selected", + "backup.manager.delete.text": "Delete", + "backup.manager.restore.text": "Restore", + "backup.manager.restore.success": "Restore successful, application will refresh shortly", + "backup.manager.restore.error": "Restore failed", + "backup.manager.delete.confirm.title": "Confirm Delete", + "backup.manager.delete.confirm.single": "Are you sure you want to delete backup file \"{{fileName}}\"? This action cannot be undone.", + "backup.manager.delete.confirm.multiple": "Are you sure you want to delete {{count}} selected backup files? This action cannot be undone.", + "backup.manager.delete.success.single": "Deleted successfully", + "backup.manager.delete.success.multiple": "Successfully deleted {{count}} backup files", + "backup.manager.delete.error": "Delete failed", + "backup.manager.fetch.error": "Failed to get backup files", + "backup.manager.select.files.delete": "Please select backup files to delete", + "backup.manager.columns.fileName": "Filename", + "backup.manager.columns.modifiedTime": "Modified Time", + "backup.manager.columns.size": "Size", + "backup.manager.columns.actions": "Actions", + "directory.select_error_app_data_path": "New path cannot be the same as the application data path", + "directory.select_error_in_app_install_path": "New path cannot be the same as the application installation path", + "directory.select_error_write_permission": "New path does not have write permission", + "directory.select_title": "Select Backup Directory", + "directory": "Local Backup Directory", + "directory.placeholder": "Select a directory for local backups", + "hour_interval_one": "{{count}} hour", + "hour_interval_other": "{{count}} hours", + "lastSync": "Last Backup", + "minute_interval_one": "{{count}} minute", + "minute_interval_other": "{{count}} minutes", + "noSync": "Waiting for next backup", + "restore.button": "Restore from Local", + "restore.confirm.content": "Restoring from local backup will replace current data. Do you want to continue?", + "restore.confirm.title": "Confirm Restore", + "syncError": "Backup Error", + "syncStatus": "Backup Status", + "title": "Local Backup", + "maxBackups": "Maximum backups", "maxBackups.unlimited": "Unlimited" }, "s3": { diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index 46959d3ccd..e31ecb4a38 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -374,6 +374,7 @@ "assistant": "アシスタント", "avatar": "アバター", "back": "戻る", + "browse": "参照", "cancel": "キャンセル", "chat": "チャット", "clear": "クリア", @@ -1157,6 +1158,50 @@ "divider.third_party": "サードパーティー連携", "hour_interval_one": "{{count}} 時間", "hour_interval_other": "{{count}} 時間", + "local": { + "title": "ローカルバックアップ", + "directory": "バックアップディレクトリ", + "directory.placeholder": "バックアップディレクトリを選択してください", + "backup.button": "ローカルにバックアップ", + "backup.modal.title": "ローカルにバックアップ", + "backup.modal.filename.placeholder": "バックアップファイル名を入力してください", + "restore.button": "バックアップファイル管理", + "autoSync": "自動バックアップ", + "autoSync.off": "オフ", + "lastSync": "最終バックアップ", + "noSync": "次回のバックアップを待機中", + "syncError": "バックアップエラー", + "syncStatus": "バックアップ状態", + "minute_interval_one": "{{count}} 分", + "minute_interval_other": "{{count}} 分", + "hour_interval_one": "{{count}} 時間", + "hour_interval_other": "{{count}} 時間", + "maxBackups": "最大バックアップ数", + "maxBackups.unlimited": "無制限", + "backup.manager.title": "バックアップファイル管理", + "backup.manager.refresh": "更新", + "backup.manager.delete.selected": "選択したものを削除", + "backup.manager.delete.text": "削除", + "backup.manager.restore.text": "復元", + "backup.manager.restore.success": "復元が成功しました、アプリケーションは間もなく更新されます", + "backup.manager.restore.error": "復元に失敗しました", + "backup.manager.delete.confirm.title": "削除の確認", + "backup.manager.delete.confirm.single": "バックアップファイル \"{{fileName}}\" を削除してもよろしいですか?この操作は元に戻せません。", + "backup.manager.delete.confirm.multiple": "選択した {{count}} 個のバックアップファイルを削除してもよろしいですか?この操作は元に戻せません。", + "backup.manager.delete.success.single": "削除が成功しました", + "backup.manager.delete.success.multiple": "{{count}} 個のバックアップファイルを削除しました", + "backup.manager.delete.error": "削除に失敗しました", + "backup.manager.fetch.error": "バックアップファイルの取得に失敗しました", + "backup.manager.select.files.delete": "削除するバックアップファイルを選択してください", + "backup.manager.columns.fileName": "ファイル名", + "backup.manager.columns.modifiedTime": "更新日時", + "backup.manager.columns.size": "サイズ", + "backup.manager.columns.actions": "操作", + "directory.select_error_app_data_path": "新パスはアプリデータパスと同じです。別のパスを選択してください", + "directory.select_error_in_app_install_path": "新パスはアプリインストールパスと同じです。別のパスを選択してください", + "directory.select_error_write_permission": "新パスに書き込み権限がありません", + "directory.select_title": "バックアップディレクトリを選択" + }, "export_menu": { "title": "エクスポートメニュー設定", "image": "画像としてエクスポート", diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index 8cb7c3bc98..63a23000f3 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -374,6 +374,7 @@ "assistant": "Ассистент", "avatar": "Аватар", "back": "Назад", + "browse": "Обзор", "cancel": "Отмена", "chat": "Чат", "clear": "Очистить", @@ -1157,6 +1158,52 @@ "divider.third_party": "Сторонние подключения", "hour_interval_one": "{{count}} час", "hour_interval_other": "{{count}} часов", + "local": { + "title": "Локальное резервное копирование", + "directory": "Каталог резервных копий", + "directory.placeholder": "Выберите каталог для резервных копий", + "backup.button": "Создать резервную копию", + "backup.modal.title": "Локальное резервное копирование", + "backup.modal.filename.placeholder": "Введите имя файла резервной копии", + "restore.button": "Управление резервными копиями", + "autoSync": "Автоматическое резервное копирование", + "autoSync.off": "Выключено", + "lastSync": "Последнее копирование", + "noSync": "Ожидание следующего копирования", + "syncError": "Ошибка копирования", + "syncStatus": "Статус копирования", + "minute_interval_one": "{{count}} минута", + "minute_interval_few": "{{count}} минуты", + "minute_interval_many": "{{count}} минут", + "hour_interval_one": "{{count}} час", + "hour_interval_few": "{{count}} часа", + "hour_interval_many": "{{count}} часов", + "maxBackups": "Максимальное количество резервных копий", + "maxBackups.unlimited": "Без ограничений", + "backup.manager.title": "Управление резервными копиями", + "backup.manager.refresh": "Обновить", + "backup.manager.delete.selected": "Удалить выбранное", + "backup.manager.delete.text": "Удалить", + "backup.manager.restore.text": "Восстановить", + "backup.manager.restore.success": "Восстановление успешно, приложение скоро обновится", + "backup.manager.restore.error": "Ошибка восстановления", + "backup.manager.delete.confirm.title": "Подтверждение удаления", + "backup.manager.delete.confirm.single": "Вы действительно хотите удалить файл резервной копии \"{{fileName}}\"? Это действие нельзя отменить.", + "backup.manager.delete.confirm.multiple": "Вы действительно хотите удалить выбранные {{count}} файла(ов) резервных копий? Это действие нельзя отменить.", + "backup.manager.delete.success.single": "Успешно удалено", + "backup.manager.delete.success.multiple": "Удалено {{count}} файла(ов) резервных копий", + "backup.manager.delete.error": "Ошибка удаления", + "backup.manager.fetch.error": "Ошибка получения файлов резервных копий", + "backup.manager.select.files.delete": "Выберите файлы резервных копий для удаления", + "backup.manager.columns.fileName": "Имя файла", + "backup.manager.columns.modifiedTime": "Время изменения", + "backup.manager.columns.size": "Размер", + "backup.manager.columns.actions": "Действия", + "directory.select_error_app_data_path": "Новый путь не может совпадать с путем данных приложения", + "directory.select_error_in_app_install_path": "Новый путь не может совпадать с путем установки приложения", + "directory.select_error_write_permission": "Новый путь не имеет разрешения на запись", + "directory.select_title": "Выберите каталог для резервных копий" + }, "export_menu": { "title": "Настройки меню экспорта", "image": "Экспорт как изображение", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 82c16170ee..6b26c45588 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -374,6 +374,7 @@ "assistant": "智能体", "avatar": "头像", "back": "返回", + "browse": "浏览", "cancel": "取消", "chat": "聊天", "clear": "清除", @@ -1159,6 +1160,50 @@ "divider.third_party": "第三方连接", "hour_interval_one": "{{count}} 小时", "hour_interval_other": "{{count}} 小时", + "local": { + "title": "本地备份", + "directory": "备份目录", + "directory.placeholder": "请选择备份目录", + "backup.button": "本地备份", + "backup.modal.title": "本地备份", + "backup.modal.filename.placeholder": "请输入备份文件名", + "restore.button": "备份文件管理", + "autoSync": "自动备份", + "autoSync.off": "关闭", + "lastSync": "上次备份", + "noSync": "等待下次备份", + "syncError": "备份错误", + "syncStatus": "备份状态", + "minute_interval_one": "{{count}} 分钟", + "minute_interval_other": "{{count}} 分钟", + "hour_interval_one": "{{count}} 小时", + "hour_interval_other": "{{count}} 小时", + "maxBackups": "最大备份数", + "maxBackups.unlimited": "无限制", + "backup.manager.title": "备份文件管理", + "backup.manager.refresh": "刷新", + "backup.manager.delete.selected": "删除选中", + "backup.manager.delete.text": "删除", + "backup.manager.restore.text": "恢复", + "backup.manager.restore.success": "恢复成功,应用将很快刷新", + "backup.manager.restore.error": "恢复失败", + "backup.manager.delete.confirm.title": "确认删除", + "backup.manager.delete.confirm.single": "确定要删除备份文件 \"{{fileName}}\" 吗?此操作无法撤销。", + "backup.manager.delete.confirm.multiple": "确定要删除选中的 {{count}} 个备份文件吗?此操作无法撤销。", + "backup.manager.delete.success.single": "删除成功", + "backup.manager.delete.success.multiple": "已删除 {{count}} 个备份文件", + "backup.manager.delete.error": "删除失败", + "backup.manager.fetch.error": "获取备份文件失败", + "backup.manager.select.files.delete": "请选择要删除的备份文件", + "backup.manager.columns.fileName": "文件名", + "backup.manager.columns.modifiedTime": "修改时间", + "backup.manager.columns.size": "大小", + "backup.manager.columns.actions": "操作", + "directory.select_error_app_data_path": "新路径不能与应用数据路径相同", + "directory.select_error_in_app_install_path": "新路径不能与应用安装路径相同", + "directory.select_error_write_permission": "新路径没有写入权限", + "directory.select_title": "选择备份目录" + }, "export_menu": { "title": "导出菜单设置", "image": "导出为图片", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 0b5f272148..2346e15de3 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -374,6 +374,7 @@ "assistant": "智慧代理人", "avatar": "頭像", "back": "返回", + "browse": "瀏覽", "cancel": "取消", "chat": "聊天", "clear": "清除", @@ -1159,6 +1160,50 @@ "divider.third_party": "第三方連接", "hour_interval_one": "{{count}} 小時", "hour_interval_other": "{{count}} 小時", + "local": { + "title": "本地備份", + "directory": "備份目錄", + "directory.placeholder": "請選擇備份目錄", + "backup.button": "本地備份", + "backup.modal.title": "本地備份", + "backup.modal.filename.placeholder": "請輸入備份文件名", + "restore.button": "備份文件管理", + "autoSync": "自動備份", + "autoSync.off": "關閉", + "lastSync": "上次備份", + "noSync": "等待下次備份", + "syncError": "備份錯誤", + "syncStatus": "備份狀態", + "minute_interval_one": "{{count}} 分鐘", + "minute_interval_other": "{{count}} 分鐘", + "hour_interval_one": "{{count}} 小時", + "hour_interval_other": "{{count}} 小時", + "maxBackups": "最大備份數", + "maxBackups.unlimited": "無限制", + "backup.manager.title": "備份文件管理", + "backup.manager.refresh": "刷新", + "backup.manager.delete.selected": "刪除選中", + "backup.manager.delete.text": "刪除", + "backup.manager.restore.text": "恢復", + "backup.manager.restore.success": "恢復成功,應用將很快刷新", + "backup.manager.restore.error": "恢復失敗", + "backup.manager.delete.confirm.title": "確認刪除", + "backup.manager.delete.confirm.single": "確定要刪除備份文件 \"{{fileName}}\" 嗎?此操作無法撤銷。", + "backup.manager.delete.confirm.multiple": "確定要刪除選中的 {{count}} 個備份文件嗎?此操作無法撤銷。", + "backup.manager.delete.success.single": "刪除成功", + "backup.manager.delete.success.multiple": "已刪除 {{count}} 個備份文件", + "backup.manager.delete.error": "刪除失敗", + "backup.manager.fetch.error": "獲取備份文件失敗", + "backup.manager.select.files.delete": "請選擇要刪除的備份文件", + "backup.manager.columns.fileName": "文件名", + "backup.manager.columns.modifiedTime": "修改時間", + "backup.manager.columns.size": "大小", + "backup.manager.columns.actions": "操作", + "directory.select_error_app_data_path": "新路徑不能與應用數據路徑相同", + "directory.select_error_in_app_install_path": "新路徑不能與應用安裝路徑相同", + "directory.select_error_write_permission": "新路徑沒有寫入權限", + "directory.select_title": "選擇備份目錄" + }, "export_menu": { "title": "匯出選單設定", "image": "匯出為圖片", diff --git a/src/renderer/src/init.ts b/src/renderer/src/init.ts index c5d969b6fd..91e5e54c65 100644 --- a/src/renderer/src/init.ts +++ b/src/renderer/src/init.ts @@ -1,6 +1,6 @@ import KeyvStorage from '@kangfenmao/keyv-storage' -import { startAutoSync } from './services/BackupService' +import { startAutoSync, startLocalBackupAutoSync } from './services/BackupService' import { startNutstoreAutoSync } from './services/NutstoreService' import storeSyncService from './services/StoreSyncService' import store from './store' @@ -12,7 +12,7 @@ function initKeyv() { function initAutoSync() { setTimeout(() => { - const { webdavAutoSync, s3 } = store.getState().settings + const { webdavAutoSync, localBackupAutoSync, s3 } = store.getState().settings const { nutstoreAutoSync } = store.getState().nutstore if (webdavAutoSync || (s3 && s3.autoSync)) { startAutoSync() @@ -20,6 +20,9 @@ function initAutoSync() { if (nutstoreAutoSync) { startNutstoreAutoSync() } + if (localBackupAutoSync) { + startLocalBackupAutoSync() + } }, 8000) } diff --git a/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx b/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx index 1ef101157c..3ef10704b7 100644 --- a/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx +++ b/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx @@ -39,6 +39,7 @@ import { import AgentsSubscribeUrlSettings from './AgentsSubscribeUrlSettings' import ExportMenuOptions from './ExportMenuSettings' import JoplinSettings from './JoplinSettings' +import LocalBackupSettings from './LocalBackupSettings' import MarkdownExportSettings from './MarkdownExportSettings' import NotionSettings from './NotionSettings' import NutstoreSettings from './NutstoreSettings' @@ -88,6 +89,7 @@ const DataSettings: FC = () => { { key: 'divider_0', isDivider: true, text: t('settings.data.divider.basic') }, { key: 'data', title: 'settings.data.data.title', icon: }, { key: 'divider_1', isDivider: true, text: t('settings.data.divider.cloud_storage') }, + { key: 'local_backup', title: 'settings.data.local.title', icon: }, { key: 'webdav', title: 'settings.data.webdav.title', icon: }, { key: 'nutstore', title: 'settings.data.nutstore.title', icon: }, { key: 's3', title: 'settings.data.s3.title', icon: }, @@ -665,6 +667,7 @@ const DataSettings: FC = () => { {menu === 'obsidian' && } {menu === 'siyuan' && } {menu === 'agentssubscribe_url' && } + {menu === 'local_backup' && } ) diff --git a/src/renderer/src/pages/settings/DataSettings/LocalBackupSettings.tsx b/src/renderer/src/pages/settings/DataSettings/LocalBackupSettings.tsx new file mode 100644 index 0000000000..1defa7549a --- /dev/null +++ b/src/renderer/src/pages/settings/DataSettings/LocalBackupSettings.tsx @@ -0,0 +1,279 @@ +import { DeleteOutlined, FolderOpenOutlined, SaveOutlined, SyncOutlined, WarningOutlined } from '@ant-design/icons' +import { HStack } from '@renderer/components/Layout' +import { LocalBackupManager } from '@renderer/components/LocalBackupManager' +import { LocalBackupModal, useLocalBackupModal } from '@renderer/components/LocalBackupModals' +import { useTheme } from '@renderer/context/ThemeProvider' +import { useSettings } from '@renderer/hooks/useSettings' +import { startLocalBackupAutoSync, stopLocalBackupAutoSync } from '@renderer/services/BackupService' +import { useAppDispatch, useAppSelector } from '@renderer/store' +import { + setLocalBackupAutoSync, + setLocalBackupDir as _setLocalBackupDir, + setLocalBackupMaxBackups as _setLocalBackupMaxBackups, + setLocalBackupSkipBackupFile as _setLocalBackupSkipBackupFile, + setLocalBackupSyncInterval as _setLocalBackupSyncInterval +} from '@renderer/store/settings' +import { AppInfo } from '@renderer/types' +import { Button, Input, Select, Switch, Tooltip } from 'antd' +import dayjs from 'dayjs' +import { FC, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' + +import { SettingDivider, SettingGroup, SettingHelpText, SettingRow, SettingRowTitle, SettingTitle } from '..' + +const LocalBackupSettings: FC = () => { + const { + localBackupDir: localBackupDirSetting, + localBackupSyncInterval: localBackupSyncIntervalSetting, + localBackupMaxBackups: localBackupMaxBackupsSetting, + localBackupSkipBackupFile: localBackupSkipBackupFileSetting + } = useSettings() + + const [localBackupDir, setLocalBackupDir] = useState(localBackupDirSetting) + const [localBackupSkipBackupFile, setLocalBackupSkipBackupFile] = useState(localBackupSkipBackupFileSetting) + const [backupManagerVisible, setBackupManagerVisible] = useState(false) + + const [syncInterval, setSyncInterval] = useState(localBackupSyncIntervalSetting) + const [maxBackups, setMaxBackups] = useState(localBackupMaxBackupsSetting) + + const [appInfo, setAppInfo] = useState() + + useEffect(() => { + window.api.getAppInfo().then(setAppInfo) + }, []) + + const dispatch = useAppDispatch() + const { theme } = useTheme() + + const { t } = useTranslation() + + const { localBackupSync } = useAppSelector((state) => state.backup) + + const onSyncIntervalChange = (value: number) => { + setSyncInterval(value) + dispatch(_setLocalBackupSyncInterval(value)) + if (value === 0) { + dispatch(setLocalBackupAutoSync(false)) + stopLocalBackupAutoSync() + } else { + dispatch(setLocalBackupAutoSync(true)) + startLocalBackupAutoSync() + } + } + + const checkLocalBackupDirValid = async (dir: string) => { + if (dir === '') { + return false + } + + // check new local backup dir is not in app data path + // if is in app data path, show error + if (dir.startsWith(appInfo!.appDataPath)) { + window.message.error(t('settings.data.local.directory.select_error_app_data_path')) + return false + } + + // check new local backup dir is not in app install path + // if is in app install path, show error + if (dir.startsWith(appInfo!.installPath)) { + window.message.error(t('settings.data.local.directory.select_error_in_app_install_path')) + return false + } + + // check new app data path has write permission + const hasWritePermission = await window.api.hasWritePermission(dir) + if (!hasWritePermission) { + window.message.error(t('settings.data.local.directory.select_error_write_permission')) + return false + } + + return true + } + + const handleLocalBackupDirChange = async (value: string) => { + if (await checkLocalBackupDirValid(value)) { + setLocalBackupDir(value) + dispatch(_setLocalBackupDir(value)) + // Create directory if it doesn't exist and set it in the backend + await window.api.backup.setLocalBackupDir(value) + + dispatch(setLocalBackupAutoSync(true)) + startLocalBackupAutoSync(true) + return + } + + setLocalBackupDir('') + dispatch(_setLocalBackupDir('')) + dispatch(setLocalBackupAutoSync(false)) + stopLocalBackupAutoSync() + } + + const onMaxBackupsChange = (value: number) => { + setMaxBackups(value) + dispatch(_setLocalBackupMaxBackups(value)) + } + + const onSkipBackupFilesChange = (value: boolean) => { + setLocalBackupSkipBackupFile(value) + dispatch(_setLocalBackupSkipBackupFile(value)) + } + + const handleBrowseDirectory = async () => { + try { + const newLocalBackupDir = await window.api.select({ + properties: ['openDirectory', 'createDirectory'], + title: t('settings.data.local.directory.select_title') + }) + + if (!newLocalBackupDir) { + return + } + + handleLocalBackupDirChange(newLocalBackupDir) + } catch (error) { + console.error('Failed to select directory:', error) + } + } + + const handleClearDirectory = () => { + setLocalBackupDir('') + dispatch(_setLocalBackupDir('')) + dispatch(setLocalBackupAutoSync(false)) + stopLocalBackupAutoSync() + } + + const renderSyncStatus = () => { + if (!localBackupDir) return null + + if (!localBackupSync.lastSyncTime && !localBackupSync.syncing && !localBackupSync.lastSyncError) { + return {t('settings.data.local.noSync')} + } + + return ( + + {localBackupSync.syncing && } + {!localBackupSync.syncing && localBackupSync.lastSyncError && ( + + + + )} + {localBackupSync.lastSyncTime && ( + + {t('settings.data.local.lastSync')}: {dayjs(localBackupSync.lastSyncTime).format('HH:mm:ss')} + + )} + + ) + } + + const { isModalVisible, handleBackup, handleCancel, backuping, customFileName, setCustomFileName, showBackupModal } = + useLocalBackupModal(localBackupDir) + + const showBackupManager = () => { + setBackupManagerVisible(true) + } + + const closeBackupManager = () => { + setBackupManagerVisible(false) + } + + return ( + + {t('settings.data.local.title')} + + + {t('settings.data.local.directory')} + + + + + + + + + {t('settings.general.backup.title')} + + + + + + + + {t('settings.data.local.autoSync')} + + + + + {t('settings.data.local.maxBackups')} + + + + + {t('settings.data.backup.skip_file_data_title')} + + + + {t('settings.data.backup.skip_file_data_help')} + + {localBackupSync && syncInterval > 0 && ( + <> + + + {t('settings.data.local.syncStatus')} + {renderSyncStatus()} + + + )} + <> + + + + + + ) +} + +export default LocalBackupSettings diff --git a/src/renderer/src/services/BackupService.ts b/src/renderer/src/services/BackupService.ts index 4bb92f38b0..88bb8be394 100644 --- a/src/renderer/src/services/BackupService.ts +++ b/src/renderer/src/services/BackupService.ts @@ -3,8 +3,7 @@ import db from '@renderer/databases' import { upgradeToV7, upgradeToV8 } 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 { setLocalBackupSyncState, setS3SyncState, setWebDAVSyncState } from '@renderer/store/backup' import { S3Config, WebDavConfig } from '@renderer/types' import { uuid } from '@renderer/utils' import dayjs from 'dayjs' @@ -469,11 +468,15 @@ export function startAutoSync(immediate = false) { const s3AutoSync = s3Settings?.autoSync const s3Endpoint = s3Settings?.endpoint + const localBackupAutoSync = settings.localBackupAutoSync + const localBackupDir = settings.localBackupDir + // 检查WebDAV或S3自动同步配置 const hasWebdavConfig = webdavAutoSync && webdavHost const hasS3Config = s3AutoSync && s3Endpoint + const hasLocalConfig = localBackupAutoSync && localBackupDir - if (!hasWebdavConfig && !hasS3Config) { + if (!hasWebdavConfig && !hasS3Config && !hasLocalConfig) { Logger.log('[AutoSync] Invalid sync settings, auto sync disabled') return } @@ -717,3 +720,279 @@ async function clearDatabase() { } }) } + +/** + * Backup to local directory + */ +export async function backupToLocalDir({ + 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(setLocalBackupSyncState({ syncing: true, lastSyncError: null })) + + const { localBackupDir, localBackupMaxBackups, localBackupSkipBackupFile } = 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 { + const result = await window.api.backup.backupToLocalDir(backupData, finalFileName, { + localBackupDir, + skipBackupFile: localBackupSkipBackupFile + }) + + if (result) { + store.dispatch( + setLocalBackupSyncState({ + lastSyncError: null + }) + ) + + if (showMessage) { + notificationService.send({ + id: uuid(), + type: 'success', + title: i18n.t('common.success'), + message: i18n.t('message.backup.success'), + silent: false, + timestamp: Date.now(), + source: 'backup' + }) + } + + // Clean up old backups if maxBackups is set + if (localBackupMaxBackups > 0) { + try { + // Get all backup files + const files = await window.api.backup.listLocalBackupFiles(localBackupDir) + + // Filter backups for current device + const currentDeviceFiles = files.filter((file) => { + return file.fileName.includes(deviceType) && file.fileName.includes(hostname) + }) + + if (currentDeviceFiles.length > localBackupMaxBackups) { + // Sort by modified time (oldest first) + const filesToDelete = currentDeviceFiles + .sort((a, b) => new Date(a.modifiedTime).getTime() - new Date(b.modifiedTime).getTime()) + .slice(0, currentDeviceFiles.length - localBackupMaxBackups) + + // Delete older backups + for (const file of filesToDelete) { + Logger.log(`[LocalBackup] Deleting old backup: ${file.fileName}`) + await window.api.backup.deleteLocalBackupFile(file.fileName, localBackupDir) + } + } + } catch (error) { + Logger.error('[LocalBackup] Failed to clean up old backups:', error) + } + } + } + + return result + } catch (error: any) { + Logger.error('[LocalBackup] Backup failed:', error) + + store.dispatch( + setLocalBackupSyncState({ + lastSyncError: error.message || 'Unknown error' + }) + ) + + if (showMessage) { + window.modal.error({ + title: i18n.t('message.backup.failed'), + content: error.message || 'Unknown error' + }) + } + + throw error + } finally { + if (!autoBackupProcess) { + store.dispatch( + setLocalBackupSyncState({ + lastSyncTime: Date.now(), + syncing: false + }) + ) + } + isManualBackupRunning = false + } +} + +export async function restoreFromLocalBackup(fileName: string) { + try { + const { localBackupDir } = store.getState().settings + await window.api.backup.restoreFromLocalBackup(fileName, localBackupDir) + return true + } catch (error) { + Logger.error('[LocalBackup] Restore failed:', error) + throw error + } +} + +// Local backup auto sync +let localBackupAutoSyncStarted = false +let localBackupSyncTimeout: NodeJS.Timeout | null = null +let isLocalBackupAutoRunning = false + +export function startLocalBackupAutoSync(immediate = false) { + if (localBackupAutoSyncStarted) { + return + } + + const { localBackupAutoSync, localBackupDir } = store.getState().settings + + if (!localBackupAutoSync || !localBackupDir) { + Logger.log('[LocalBackupAutoSync] Invalid sync settings, auto sync disabled') + return + } + + localBackupAutoSyncStarted = true + + stopLocalBackupAutoSync() + + scheduleNextBackup(immediate ? 'immediate' : 'fromLastSyncTime') + + /** + * @param type 'immediate' | 'fromLastSyncTime' | 'fromNow' + * 'immediate', first backup right now + * 'fromLastSyncTime', schedule next backup from last sync time + * 'fromNow', schedule next backup from now + */ + function scheduleNextBackup(type: 'immediate' | 'fromLastSyncTime' | 'fromNow' = 'fromLastSyncTime') { + if (localBackupSyncTimeout) { + clearTimeout(localBackupSyncTimeout) + localBackupSyncTimeout = null + } + + const { localBackupSyncInterval } = store.getState().settings + const { localBackupSync } = store.getState().backup + + if (localBackupSyncInterval <= 0) { + Logger.log('[LocalBackupAutoSync] Invalid sync interval, auto sync disabled') + stopLocalBackupAutoSync() + return + } + + // User specified auto backup interval (milliseconds) + const requiredInterval = localBackupSyncInterval * 60 * 1000 + + let timeUntilNextSync = 1000 // immediate by default + switch (type) { + case 'fromLastSyncTime': // If last sync time exists, use it as reference + timeUntilNextSync = Math.max(1000, (localBackupSync?.lastSyncTime || 0) + requiredInterval - Date.now()) + break + case 'fromNow': + timeUntilNextSync = requiredInterval + break + } + + localBackupSyncTimeout = setTimeout(performAutoBackup, timeUntilNextSync) + + Logger.log( + `[LocalBackupAutoSync] Next sync scheduled in ${Math.floor(timeUntilNextSync / 1000 / 60)} minutes ${Math.floor( + (timeUntilNextSync / 1000) % 60 + )} seconds` + ) + } + + async function performAutoBackup() { + if (isLocalBackupAutoRunning || isManualBackupRunning) { + Logger.log('[LocalBackupAutoSync] Backup already in progress, rescheduling') + scheduleNextBackup() + return + } + + isLocalBackupAutoRunning = true + const maxRetries = 4 + let retryCount = 0 + + while (retryCount < maxRetries) { + try { + Logger.log(`[LocalBackupAutoSync] Starting auto backup... (attempt ${retryCount + 1}/${maxRetries})`) + + await backupToLocalDir({ autoBackupProcess: true }) + + store.dispatch( + setLocalBackupSyncState({ + lastSyncError: null, + lastSyncTime: Date.now(), + syncing: false + }) + ) + + isLocalBackupAutoRunning = false + scheduleNextBackup() + + break + } catch (error: any) { + retryCount++ + if (retryCount === maxRetries) { + Logger.error('[LocalBackupAutoSync] Auto backup failed after all retries:', error) + + store.dispatch( + setLocalBackupSyncState({ + lastSyncError: 'Auto backup failed', + lastSyncTime: Date.now(), + syncing: false + }) + ) + + // Only show error modal once and wait for user acknowledgment + await window.modal.error({ + title: i18n.t('message.backup.failed'), + content: `[Local Backup Auto Backup] ${new Date().toLocaleString()} ` + error.message + }) + + scheduleNextBackup('fromNow') + isLocalBackupAutoRunning = false + } else { + // Exponential Backoff with Base 2: 7s, 17s, 37s + const backoffDelay = Math.pow(2, retryCount - 1) * 10000 - 3000 + Logger.log(`[LocalBackupAutoSync] Failed, retry ${retryCount}/${maxRetries} after ${backoffDelay / 1000}s`) + + await new Promise((resolve) => setTimeout(resolve, backoffDelay)) + + // Check if auto backup was stopped by user + if (!isLocalBackupAutoRunning) { + Logger.log('[LocalBackupAutoSync] retry cancelled by user, exit') + break + } + } + } + } + } +} + +export function stopLocalBackupAutoSync() { + if (localBackupSyncTimeout) { + Logger.log('[LocalBackupAutoSync] Stopping auto sync') + clearTimeout(localBackupSyncTimeout) + localBackupSyncTimeout = null + } + isLocalBackupAutoRunning = false + localBackupAutoSyncStarted = false +} diff --git a/src/renderer/src/store/backup.ts b/src/renderer/src/store/backup.ts index 0418e5ab96..cf5d8e6e30 100644 --- a/src/renderer/src/store/backup.ts +++ b/src/renderer/src/store/backup.ts @@ -8,6 +8,7 @@ export interface RemoteSyncState { export interface BackupState { webdavSync: RemoteSyncState + localBackupSync: RemoteSyncState s3Sync: RemoteSyncState } @@ -17,6 +18,11 @@ const initialState: BackupState = { syncing: false, lastSyncError: null }, + localBackupSync: { + lastSyncTime: null, + syncing: false, + lastSyncError: null + }, s3Sync: { lastSyncTime: null, syncing: false, @@ -31,11 +37,14 @@ const backupSlice = createSlice({ setWebDAVSyncState: (state, action: PayloadAction>) => { state.webdavSync = { ...state.webdavSync, ...action.payload } }, + setLocalBackupSyncState: (state, action: PayloadAction>) => { + state.localBackupSync = { ...state.localBackupSync, ...action.payload } + }, setS3SyncState: (state, action: PayloadAction>) => { state.s3Sync = { ...state.s3Sync, ...action.payload } } } }) -export const { setWebDAVSyncState, setS3SyncState } = backupSlice.actions +export const { setWebDAVSyncState, setLocalBackupSyncState, setS3SyncState } = backupSlice.actions export default backupSlice.reducer diff --git a/src/renderer/src/store/migrate.ts b/src/renderer/src/store/migrate.ts index 2f35546f32..6c0900fd91 100644 --- a/src/renderer/src/store/migrate.ts +++ b/src/renderer/src/store/migrate.ts @@ -1723,6 +1723,7 @@ const migrateConfig = { addProvider(state, 'new-api') state.llm.providers = moveProvider(state.llm.providers, 'new-api', 16) state.settings.disableHardwareAcceleration = false + return state } catch (error) { return state @@ -1746,6 +1747,12 @@ const migrateConfig = { const newLang = langMap[origin] if (newLang) state.settings.targetLanguage = newLang else state.settings.targetLanguage = 'en-us' + + state.settings.localBackupMaxBackups = 0 + state.settings.localBackupSkipBackupFile = false + state.settings.localBackupDir = '' + state.settings.localBackupAutoSync = false + state.settings.localBackupSyncInterval = 0 return state } catch (error) { return state diff --git a/src/renderer/src/store/settings.ts b/src/renderer/src/store/settings.ts index 71b99b9463..be1fe8a794 100644 --- a/src/renderer/src/store/settings.ts +++ b/src/renderer/src/store/settings.ts @@ -189,6 +189,12 @@ export interface SettingsState { backup: boolean knowledge: boolean } + // Local backup settings + localBackupDir: string + localBackupAutoSync: boolean + localBackupSyncInterval: number + localBackupMaxBackups: number + localBackupSkipBackupFile: boolean defaultPaintingProvider: PaintingProvider s3: S3Config } @@ -338,6 +344,12 @@ export const initialState: SettingsState = { backup: false, knowledge: false }, + // Local backup settings + localBackupDir: '', + localBackupAutoSync: false, + localBackupSyncInterval: 0, + localBackupMaxBackups: 0, + localBackupSkipBackupFile: false, defaultPaintingProvider: 'aihubmix', s3: { endpoint: '', @@ -715,6 +727,22 @@ const settingsSlice = createSlice({ setNotificationSettings: (state, action: PayloadAction) => { state.notification = action.payload }, + // Local backup settings + setLocalBackupDir: (state, action: PayloadAction) => { + state.localBackupDir = action.payload + }, + setLocalBackupAutoSync: (state, action: PayloadAction) => { + state.localBackupAutoSync = action.payload + }, + setLocalBackupSyncInterval: (state, action: PayloadAction) => { + state.localBackupSyncInterval = action.payload + }, + setLocalBackupMaxBackups: (state, action: PayloadAction) => { + state.localBackupMaxBackups = action.payload + }, + setLocalBackupSkipBackupFile: (state, action: PayloadAction) => { + state.localBackupSkipBackupFile = action.payload + }, setDefaultPaintingProvider: (state, action: PayloadAction) => { state.defaultPaintingProvider = action.payload }, @@ -832,6 +860,12 @@ export const { setOpenAISummaryText, setOpenAIServiceTier, setNotificationSettings, + // Local backup settings + setLocalBackupDir, + setLocalBackupAutoSync, + setLocalBackupSyncInterval, + setLocalBackupMaxBackups, + setLocalBackupSkipBackupFile, setDefaultPaintingProvider, setS3, setS3Partial From 08b9e0788f4b0cc8688eda60cf6ba7726aa1c16f Mon Sep 17 00:00:00 2001 From: one Date: Tue, 8 Jul 2025 14:23:55 +0800 Subject: [PATCH 40/54] chore: git blame ignore (#7925) --- .git-blame-ignore-revs | 2 ++ package.json | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) create mode 100644 .git-blame-ignore-revs diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 0000000000..80532ea84b --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,2 @@ +# ignore #7923 eol change and code formatting +4ac8a388347ff35f34de42c3ef4a2f81f03fb3b1 diff --git a/package.json b/package.json index 7c193730d0..5da524deca 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,7 @@ "test:lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts", "format": "prettier --write .", "lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix", - "prepare": "husky" + "prepare": "git config blame.ignoreRevsFile .git-blame-ignore-revs && husky" }, "dependencies": { "@aws-sdk/client-s3": "^3.840.0", From 915291d78058fb6aa784cfc5d3ea876410624210 Mon Sep 17 00:00:00 2001 From: one Date: Tue, 8 Jul 2025 14:44:21 +0800 Subject: [PATCH 41/54] fix: content search count on enable (#7920) --- src/renderer/src/components/ContentSearch.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/renderer/src/components/ContentSearch.tsx b/src/renderer/src/components/ContentSearch.tsx index 1f895e348b..a172d40570 100644 --- a/src/renderer/src/components/ContentSearch.tsx +++ b/src/renderer/src/components/ContentSearch.tsx @@ -208,8 +208,6 @@ export const ContentSearch = React.forwardRef( inputEl.focus() inputEl.select() search() - CSS.highlights.clear() - setSearchCompleted(SearchCompletedState.NotSearched) }) } else { requestAnimationFrame(() => { From f506a9d7ac50d0f1bdf15766884c3821444643d9 Mon Sep 17 00:00:00 2001 From: SuYao Date: Tue, 8 Jul 2025 16:31:10 +0800 Subject: [PATCH 42/54] fix(provider): fix azure type (#7926) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(provider): fix azure type * fix: lint --------- Co-authored-by: George·Dong <98630204+GeorgeDong32@users.noreply.github.com> --- src/renderer/src/store/llm.ts | 2 +- src/renderer/src/store/migrate.ts | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/store/llm.ts b/src/renderer/src/store/llm.ts index 21701da59f..b5876bcb80 100644 --- a/src/renderer/src/store/llm.ts +++ b/src/renderer/src/store/llm.ts @@ -237,7 +237,7 @@ export const INITIAL_PROVIDERS: Provider[] = [ { id: 'azure-openai', name: 'Azure OpenAI', - type: 'openai', + type: 'azure-openai', apiKey: '', apiHost: '', apiVersion: '', diff --git a/src/renderer/src/store/migrate.ts b/src/renderer/src/store/migrate.ts index 6c0900fd91..a4a7e292ac 100644 --- a/src/renderer/src/store/migrate.ts +++ b/src/renderer/src/store/migrate.ts @@ -1748,6 +1748,12 @@ const migrateConfig = { if (newLang) state.settings.targetLanguage = newLang else state.settings.targetLanguage = 'en-us' + state.llm.providers.forEach((provider) => { + if (provider.id === 'azure-openai') { + provider.type = 'azure-openai' + } + }) + state.settings.localBackupMaxBackups = 0 state.settings.localBackupSkipBackupFile = false state.settings.localBackupDir = '' From 33da5d31cf47e15e2745e551dbacf0d8de21797b Mon Sep 17 00:00:00 2001 From: beyondkmp Date: Tue, 8 Jul 2025 16:49:28 +0800 Subject: [PATCH 43/54] fix: cannot show window in mini and hide status (#7943) * feat(ProtocolClient): show main window on protocol URL handling * refactor(ProtocolClient): remove main window display logic; update handleProviders to show window on macOS * fix lint --------- Co-authored-by: rcadmin --- src/main/services/urlschema/handle-providers.ts | 6 ++++++ src/renderer/src/pages/settings/ProviderSettings/index.tsx | 3 ++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/main/services/urlschema/handle-providers.ts b/src/main/services/urlschema/handle-providers.ts index d23f3749db..9a598fc459 100644 --- a/src/main/services/urlschema/handle-providers.ts +++ b/src/main/services/urlschema/handle-providers.ts @@ -1,3 +1,4 @@ +import { isMac } from '@main/constant' import Logger from 'electron-log' import { windowService } from '../WindowService' @@ -33,8 +34,13 @@ export async function handleProvidersProtocolUrl(url: URL) { (await mainWindow.webContents.executeJavaScript(`typeof window.navigate === 'function'`)) ) { mainWindow.webContents.executeJavaScript(`window.navigate('/settings/provider?addProviderData=${data}')`) + + if (isMac) { + windowService.showMainWindow() + } } else { setTimeout(() => { + Logger.info('handleProvidersProtocolUrl timeout', { data, version }) handleProvidersProtocolUrl(url) }, 1000) } diff --git a/src/renderer/src/pages/settings/ProviderSettings/index.tsx b/src/renderer/src/pages/settings/ProviderSettings/index.tsx index f35b6b72b9..a89719c178 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/index.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/index.tsx @@ -259,7 +259,8 @@ const ProvidersList: FC = () => { window.message.error(t('settings.models.provider_key_add_failed_by_invalid_data')) window.navigate('/settings/provider') } - }, [addProvider, providers, searchParams, t, updateProvider]) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchParams]) const onDragEnd = (result: DropResult) => { setDragging(false) From da5badc189ba699c176965513690ee7ca5d21001 Mon Sep 17 00:00:00 2001 From: one Date: Tue, 8 Jul 2025 17:05:40 +0800 Subject: [PATCH 44/54] perf: draggable virtual list (#7904) * perf(TopicsTab): use DraggableVirtualList for the topic list - Add a DraggableVirtualList implemented using react-virtual - Rename DragableList to DraggableList - Add tests * refactor: improve props, fix drag area --- .../src/components/DraggableList/index.tsx | 2 + .../index.tsx => DraggableList/list.tsx} | 4 +- .../components/DraggableList/virtual-list.tsx | 212 ++++++++++++++++++ .../src/components/Scrollbar/index.tsx | 2 +- ...leList.test.tsx => DraggableList.test.tsx} | 52 ++--- .../__tests__/DraggableVirtualList.test.tsx | 164 ++++++++++++++ ...t.tsx.snap => DraggableList.test.tsx.snap} | 2 +- .../DraggableVirtualList.test.tsx.snap | 91 ++++++++ src/renderer/src/components/app/Sidebar.tsx | 6 +- .../agents/components/ManageAgentsPopup.tsx | 6 +- .../src/pages/home/Tabs/AssistantsTab.tsx | 10 +- .../src/pages/home/Tabs/TopicsTab.tsx | 151 ++++++------- .../src/pages/knowledge/KnowledgePage.tsx | 6 +- .../paintings/components/PaintingsList.tsx | 6 +- .../AssistantRegularPromptsSettings.tsx | 6 +- .../settings/MCPSettings/McpServersList.tsx | 6 +- .../settings/QuickPhraseSettings/index.tsx | 6 +- 17 files changed, 597 insertions(+), 135 deletions(-) create mode 100644 src/renderer/src/components/DraggableList/index.tsx rename src/renderer/src/components/{DragableList/index.tsx => DraggableList/list.tsx} (97%) create mode 100644 src/renderer/src/components/DraggableList/virtual-list.tsx rename src/renderer/src/components/__tests__/{DragableList.test.tsx => DraggableList.test.tsx} (87%) create mode 100644 src/renderer/src/components/__tests__/DraggableVirtualList.test.tsx rename src/renderer/src/components/__tests__/__snapshots__/{DragableList.test.tsx.snap => DraggableList.test.tsx.snap} (95%) create mode 100644 src/renderer/src/components/__tests__/__snapshots__/DraggableVirtualList.test.tsx.snap diff --git a/src/renderer/src/components/DraggableList/index.tsx b/src/renderer/src/components/DraggableList/index.tsx new file mode 100644 index 0000000000..de98dd00d5 --- /dev/null +++ b/src/renderer/src/components/DraggableList/index.tsx @@ -0,0 +1,2 @@ +export { default as DraggableList } from './list' +export { default as DraggableVirtualList } from './virtual-list' diff --git a/src/renderer/src/components/DragableList/index.tsx b/src/renderer/src/components/DraggableList/list.tsx similarity index 97% rename from src/renderer/src/components/DragableList/index.tsx rename to src/renderer/src/components/DraggableList/list.tsx index cc281e7b01..0f23a69978 100644 --- a/src/renderer/src/components/DragableList/index.tsx +++ b/src/renderer/src/components/DraggableList/list.tsx @@ -23,7 +23,7 @@ interface Props { droppableProps?: Partial } -const DragableList: FC> = ({ +const DraggableList: FC> = ({ children, list, style, @@ -78,4 +78,4 @@ const DragableList: FC> = ({ ) } -export default DragableList +export default DraggableList diff --git a/src/renderer/src/components/DraggableList/virtual-list.tsx b/src/renderer/src/components/DraggableList/virtual-list.tsx new file mode 100644 index 0000000000..b8e51642e9 --- /dev/null +++ b/src/renderer/src/components/DraggableList/virtual-list.tsx @@ -0,0 +1,212 @@ +import { + DragDropContext, + Draggable, + Droppable, + DroppableProps, + DropResult, + OnDragEndResponder, + OnDragStartResponder, + ResponderProvided +} from '@hello-pangea/dnd' +import Scrollbar from '@renderer/components/Scrollbar' +import { droppableReorder } from '@renderer/utils' +import { useVirtualizer } from '@tanstack/react-virtual' +import { type Key, memo, useCallback, useRef } from 'react' + +/** + * 泛型 Props,用于配置 DraggableVirtualList。 + * + * @template T 列表元素的类型 + * @property {string} [className] 根节点附加 class + * @property {React.CSSProperties} [style] 根节点附加样式 + * @property {React.CSSProperties} [itemStyle] 元素内容区域的附加样式 + * @property {React.CSSProperties} [itemContainerStyle] 元素拖拽容器的附加样式 + * @property {Partial} [droppableProps] 透传给 Droppable 的额外配置 + * @property {(list: T[]) => void} onUpdate 拖拽排序完成后的回调,返回新的列表顺序 + * @property {OnDragStartResponder} [onDragStart] 开始拖拽时的回调 + * @property {OnDragEndResponder} [onDragEnd] 结束拖拽时的回调 + * @property {T[]} list 渲染的数据源 + * @property {(index: number) => Key} [itemKey] 提供给虚拟列表的行 key,若不提供默认使用 index + * @property {number} [overscan=5] 前后额外渲染的行数,提升快速滚动时的体验 + * @property {(item: T, index: number) => React.ReactNode} children 列表项渲染函数 + */ +interface DraggableVirtualListProps { + ref?: React.Ref + className?: string + style?: React.CSSProperties + itemStyle?: React.CSSProperties + itemContainerStyle?: React.CSSProperties + droppableProps?: Partial + onUpdate: (list: T[]) => void + onDragStart?: OnDragStartResponder + onDragEnd?: OnDragEndResponder + list: T[] + itemKey?: (index: number) => Key + overscan?: number + children: (item: T, index: number) => React.ReactNode +} + +/** + * 带虚拟滚动与拖拽排序能力的(垂直)列表组件。 + * - 滚动容器由该组件内部管理。 + * @template T 列表元素的类型 + * @param {DraggableVirtualListProps} props 组件参数 + * @returns {React.ReactElement} + */ +function DraggableVirtualList({ + ref, + className, + style, + itemStyle, + itemContainerStyle, + droppableProps, + onDragStart, + onUpdate, + onDragEnd, + list, + itemKey, + overscan = 5, + children +}: DraggableVirtualListProps): React.ReactElement { + const _onDragEnd = (result: DropResult, provided: ResponderProvided) => { + onDragEnd?.(result, provided) + if (result.destination) { + const sourceIndex = result.source.index + const destIndex = result.destination.index + const reorderAgents = droppableReorder(list, sourceIndex, destIndex) + onUpdate(reorderAgents) + } + } + + // 虚拟列表滚动容器的 ref + const parentRef = useRef(null) + + const virtualizer = useVirtualizer({ + count: list.length, + getScrollElement: useCallback(() => parentRef.current, []), + getItemKey: itemKey, + estimateSize: useCallback(() => 50, []), + overscan + }) + + return ( +
    + + { + const item = list[rubric.source.index] + return ( +
    + {item && children(item, rubric.source.index)} +
    + ) + }} + {...droppableProps}> + {(provided) => { + // 让 dnd 和虚拟列表共享同一个滚动容器 + const setRefs = (el: HTMLDivElement | null) => { + provided.innerRef(el) + parentRef.current = el + } + + return ( + +
    + {virtualizer.getVirtualItems().map((virtualItem) => ( + + ))} +
    +
    + ) + }} +
    +
    +
    + ) +} + +/** + * 渲染单个可拖拽的虚拟列表项,高度为动态测量 + */ +const VirtualRow = memo(({ virtualItem, list, children, itemStyle, itemContainerStyle, virtualizer }: any) => { + const item = list[virtualItem.index] + const draggableId = String(virtualItem.key) + return ( + + {(provided) => { + const setDragRefs = (el: HTMLElement | null) => { + provided.innerRef(el) + virtualizer.measureElement(el) + } + + const dndStyle = provided.draggableProps.style + const virtualizerTransform = `translateY(${virtualItem.start}px)` + + // dnd 的 transform 负责拖拽时的位移和让位动画, + // virtualizer 的 translateY 负责将项定位到虚拟列表的正确位置, + // 它们拼接起来可以同时实现拖拽视觉效果和虚拟化定位。 + const combinedTransform = dndStyle?.transform + ? `${dndStyle.transform} ${virtualizerTransform}` + : virtualizerTransform + + return ( +
    +
    + {item && children(item, virtualItem.index)} +
    +
    + ) + }} +
    + ) +}) + +export default DraggableVirtualList diff --git a/src/renderer/src/components/Scrollbar/index.tsx b/src/renderer/src/components/Scrollbar/index.tsx index c3bd1b2d0c..60258d8c8b 100644 --- a/src/renderer/src/components/Scrollbar/index.tsx +++ b/src/renderer/src/components/Scrollbar/index.tsx @@ -3,7 +3,7 @@ import { FC, useCallback, useEffect, useRef, useState } from 'react' import styled from 'styled-components' interface Props extends Omit, 'onScroll'> { - ref?: React.RefObject + ref?: React.Ref onScroll?: () => void // Custom onScroll prop for useScrollPosition's handleScroll } diff --git a/src/renderer/src/components/__tests__/DragableList.test.tsx b/src/renderer/src/components/__tests__/DraggableList.test.tsx similarity index 87% rename from src/renderer/src/components/__tests__/DragableList.test.tsx rename to src/renderer/src/components/__tests__/DraggableList.test.tsx index d40849fff8..4878fd4838 100644 --- a/src/renderer/src/components/__tests__/DragableList.test.tsx +++ b/src/renderer/src/components/__tests__/DraggableList.test.tsx @@ -3,7 +3,7 @@ import { render, screen } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' -import DragableList from '../DragableList' +import { DraggableList } from '../DraggableList' // mock @hello-pangea/dnd 组件 vi.mock('@hello-pangea/dnd', () => { @@ -49,7 +49,7 @@ declare global { } } -describe('DragableList', () => { +describe('DraggableList', () => { describe('rendering', () => { it('should render all list items', () => { const list = [ @@ -58,9 +58,9 @@ describe('DragableList', () => { { id: 'c', name: 'C' } ] render( - {}}> + {}}> {(item) =>
    {item.name}
    } -
    + ) const items = screen.getAllByTestId('item') expect(items.length).toBe(3) @@ -74,9 +74,9 @@ describe('DragableList', () => { const style = { background: 'red' } const listStyle = { color: 'blue' } render( - {}}> + {}}> {(item) =>
    {item.name}
    } -
    + ) // 检查 style 是否传递到外层容器 const virtualList = screen.getByTestId('virtual-list') @@ -85,9 +85,9 @@ describe('DragableList', () => { it('should render nothing when list is empty', () => { render( - {}}> + {}}> {(item) =>
    {item.name}
    } -
    + ) // 虚拟列表存在但无内容 const items = screen.queryAllByTestId('item') @@ -106,9 +106,9 @@ describe('DragableList', () => { const onUpdate = vi.fn() render( - + {(item) =>
    {item.name}
    } -
    + ) // 直接调用 window.triggerOnDragEnd 模拟拖拽结束 @@ -128,9 +128,9 @@ describe('DragableList', () => { const onDragEnd = vi.fn() render( - {}} onDragStart={onDragStart} onDragEnd={onDragEnd}> + {}} onDragStart={onDragStart} onDragEnd={onDragEnd}> {(item) =>
    {item.name}
    } -
    + ) // 先手动调用 onDragStart @@ -150,9 +150,9 @@ describe('DragableList', () => { const onUpdate = vi.fn() render( - + {(item) =>
    {item.name}
    } -
    + ) // 模拟拖拽到自身 @@ -168,9 +168,9 @@ describe('DragableList', () => { const onUpdate = vi.fn() render( - + {(item) =>
    {item.name}
    } -
    + ) // 拖拽自身 @@ -188,9 +188,9 @@ describe('DragableList', () => { // 不传 onDragStart/onDragEnd expect(() => { render( - {}}> + {}}> {(item) =>
    {item.name}
    } -
    + ) window.triggerOnDragEnd({ source: { index: 0 }, destination: { index: 1 } }, {}) }).not.toThrow() @@ -201,9 +201,9 @@ describe('DragableList', () => { const onUpdate = vi.fn() render( - + {(item) =>
    {item}
    } -
    + ) // 拖拽第0项到第2项 @@ -222,9 +222,9 @@ describe('DragableList', () => { ] render( - {}}> + {}}> {(item) =>
    {item.name}
    } -
    + ) // placeholder 应该在初始渲染时就存在 @@ -240,9 +240,9 @@ describe('DragableList', () => { ] const onUpdate = vi.fn() render( - + {(item) =>
    {item.name}
    } -
    + ) // 拖拽第2项到第0项 @@ -272,9 +272,9 @@ describe('DragableList', () => { { id: 'c', name: 'C' } ] const { container } = render( - {}}> + {}}> {(item) =>
    {item.name}
    } -
    + ) expect(container).toMatchSnapshot() }) diff --git a/src/renderer/src/components/__tests__/DraggableVirtualList.test.tsx b/src/renderer/src/components/__tests__/DraggableVirtualList.test.tsx new file mode 100644 index 0000000000..b82181ef42 --- /dev/null +++ b/src/renderer/src/components/__tests__/DraggableVirtualList.test.tsx @@ -0,0 +1,164 @@ +/// + +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' + +import DraggableVirtualList from '../DraggableList/virtual-list' + +// Mock 依赖项 +vi.mock('@hello-pangea/dnd', () => ({ + __esModule: true, + DragDropContext: ({ children, onDragEnd, onDragStart }) => { + // 挂载到 window 以便测试用例直接调用 + window.triggerOnDragEnd = (result = { source: { index: 0 }, destination: { index: 1 } }, provided = {}) => { + onDragEnd?.(result, provided) + } + window.triggerOnDragStart = (result = { source: { index: 0 } }, provided = {}) => { + onDragStart?.(result, provided) + } + return
    {children}
    + }, + Droppable: ({ children, renderClone }) => ( +
    + {/* 模拟 renderClone 的调用 */} + {renderClone && + renderClone({ draggableProps: {}, dragHandleProps: {}, innerRef: vi.fn() }, {}, { source: { index: 0 } })} + {children({ droppableProps: {}, innerRef: vi.fn() })} +
    + ), + Draggable: ({ children, draggableId, index }) => ( +
    + {children({ draggableProps: {}, dragHandleProps: {}, innerRef: vi.fn() }, {})} +
    + ) +})) + +vi.mock('@tanstack/react-virtual', () => ({ + useVirtualizer: ({ count }) => ({ + getVirtualItems: () => + Array.from({ length: count }, (_, index) => ({ + index, + key: index, + start: index * 50, + size: 50 + })), + getTotalSize: () => count * 50, + measureElement: vi.fn() + }) +})) + +vi.mock('react-virtualized-auto-sizer', () => ({ + __esModule: true, + default: ({ children }) =>
    {children({ height: 500, width: 300 })}
    +})) + +vi.mock('@renderer/components/Scrollbar', () => ({ + __esModule: true, + default: ({ ref, children, ...props }) => ( +
    + {children} +
    + ) +})) + +declare global { + interface Window { + triggerOnDragEnd: (result?: any, provided?: any) => void + triggerOnDragStart: (result?: any, provided?: any) => void + } +} + +describe('DraggableVirtualList', () => { + const sampleList = [ + { id: 'a', name: 'Item A' }, + { id: 'b', name: 'Item B' }, + { id: 'c', name: 'Item C' } + ] + + describe('rendering', () => { + it('should render all list items provided', () => { + render( + {}}> + {(item) =>
    {item.name}
    } +
    + ) + const items = screen.getAllByTestId('test-item') + // 我们的 mock 中,renderClone 会渲染一个额外的 item + expect(items.length).toBe(sampleList.length + 1) + expect(items[0]).toHaveTextContent('Item A') + expect(items[1]).toHaveTextContent('Item A') + expect(items[2]).toHaveTextContent('Item B') + expect(items[3]).toHaveTextContent('Item C') + }) + + it('should render nothing when the list is empty', () => { + render( + {}}> + {/* @ts-ignore test*/} + {(item) =>
    {item.name}
    } +
    + ) + const items = screen.queryAllByTestId('test-item') + expect(items.length).toBe(0) + }) + }) + + describe('drag and drop', () => { + it('should call onUpdate with the new order after a drag operation', () => { + const onUpdate = vi.fn() + render( + + {(item) =>
    {item.name}
    } +
    + ) + + window.triggerOnDragEnd({ source: { index: 0 }, destination: { index: 2 } }) + const expectedOrder = [sampleList[1], sampleList[2], sampleList[0]] // B, C, A + expect(onUpdate).toHaveBeenCalledWith(expectedOrder) + }) + + it('should call onDragStart and onDragEnd callbacks', () => { + const onDragStart = vi.fn() + const onDragEnd = vi.fn() + render( + {}} onDragStart={onDragStart} onDragEnd={onDragEnd}> + {(item) =>
    {item.name}
    } +
    + ) + + window.triggerOnDragStart() + expect(onDragStart).toHaveBeenCalledTimes(1) + + window.triggerOnDragEnd() + expect(onDragEnd).toHaveBeenCalledTimes(1) + }) + + it('should not call onUpdate if destination is not defined', () => { + const onUpdate = vi.fn() + render( + + {(item) =>
    {item.name}
    } +
    + ) + + window.triggerOnDragEnd({ source: { index: 0 }, destination: null }) + expect(onUpdate).not.toHaveBeenCalled() + }) + }) + + describe('snapshot', () => { + it('should match snapshot with custom styles', () => { + const { container } = render( + {}} + className="custom-class" + style={{ border: '1px solid red' }} + itemStyle={{ background: 'blue' }}> + {(item) =>
    {item.name}
    } +
    + ) + expect(container).toMatchSnapshot() + }) + }) +}) diff --git a/src/renderer/src/components/__tests__/__snapshots__/DragableList.test.tsx.snap b/src/renderer/src/components/__tests__/__snapshots__/DraggableList.test.tsx.snap similarity index 95% rename from src/renderer/src/components/__tests__/__snapshots__/DragableList.test.tsx.snap rename to src/renderer/src/components/__tests__/__snapshots__/DraggableList.test.tsx.snap index a7ddaaaf11..f85a3e07bd 100644 --- a/src/renderer/src/components/__tests__/__snapshots__/DragableList.test.tsx.snap +++ b/src/renderer/src/components/__tests__/__snapshots__/DraggableList.test.tsx.snap @@ -1,6 +1,6 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`DragableList > snapshot > should match snapshot 1`] = ` +exports[`DraggableList > snapshot > should match snapshot 1`] = `
    snapshot > should match snapshot with custom styles 1`] = ` +
    +
    +
    +
    +
    +
    + Item A +
    +
    +
    +
    +
    +
    +
    +
    + Item A +
    +
    +
    +
    +
    +
    +
    +
    + Item B +
    +
    +
    +
    +
    +
    +
    +
    + Item C +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +`; diff --git a/src/renderer/src/components/app/Sidebar.tsx b/src/renderer/src/components/app/Sidebar.tsx index 5e4365c3c9..4e03cee5ff 100644 --- a/src/renderer/src/components/app/Sidebar.tsx +++ b/src/renderer/src/components/app/Sidebar.tsx @@ -33,7 +33,7 @@ import { useTranslation } from 'react-i18next' import { useLocation, useNavigate } from 'react-router-dom' import styled from 'styled-components' -import DragableList from '../DragableList' +import { DraggableList } from '../DraggableList' import MinAppIcon from '../Icons/MinAppIcon' import UserPopup from '../Popups/UserPopup' @@ -288,7 +288,7 @@ const PinnedApps: FC = () => { const { openMinappKeepAlive } = useMinappPopup() return ( - + {(app) => { const menuItems: MenuProps['items'] = [ { @@ -316,7 +316,7 @@ const PinnedApps: FC = () => { ) }} - + ) } diff --git a/src/renderer/src/pages/agents/components/ManageAgentsPopup.tsx b/src/renderer/src/pages/agents/components/ManageAgentsPopup.tsx index 5307293b1e..ab03f71f02 100644 --- a/src/renderer/src/pages/agents/components/ManageAgentsPopup.tsx +++ b/src/renderer/src/pages/agents/components/ManageAgentsPopup.tsx @@ -1,5 +1,5 @@ import { MenuOutlined } from '@ant-design/icons' -import DragableList from '@renderer/components/DragableList' +import { DraggableList } from '@renderer/components/DraggableList' import { Box, HStack } from '@renderer/components/Layout' import { TopView } from '@renderer/components/TopView' import { useAgents } from '@renderer/hooks/useAgents' @@ -43,7 +43,7 @@ const PopupContainer: React.FC = () => { centered> {agents.length > 0 && ( - + {(item) => ( @@ -54,7 +54,7 @@ const PopupContainer: React.FC = () => { )} - + )} {agents.length === 0 && } diff --git a/src/renderer/src/pages/home/Tabs/AssistantsTab.tsx b/src/renderer/src/pages/home/Tabs/AssistantsTab.tsx index b164e7b492..048feb83bd 100644 --- a/src/renderer/src/pages/home/Tabs/AssistantsTab.tsx +++ b/src/renderer/src/pages/home/Tabs/AssistantsTab.tsx @@ -1,5 +1,5 @@ import { DownOutlined, PlusOutlined, RightOutlined } from '@ant-design/icons' -import DragableList from '@renderer/components/DragableList' +import { DraggableList } from '@renderer/components/DraggableList' import Scrollbar from '@renderer/components/Scrollbar' import { useAgents } from '@renderer/hooks/useAgents' import { useAssistants } from '@renderer/hooks/useAssistant' @@ -92,7 +92,7 @@ const Assistants: FC = ({ )} {!collapsedTags[group.tag] && (
    - handleGroupReorder(group.tag, newList)} onDragStart={() => setDragging(true)} @@ -111,7 +111,7 @@ const Assistants: FC = ({ handleSortByChange={handleSortByChange} /> )} - +
    )} @@ -129,7 +129,7 @@ const Assistants: FC = ({ return ( - setDragging(true)} @@ -148,7 +148,7 @@ const Assistants: FC = ({ handleSortByChange={handleSortByChange} /> )} - + {!dragging && ( diff --git a/src/renderer/src/pages/home/Tabs/TopicsTab.tsx b/src/renderer/src/pages/home/Tabs/TopicsTab.tsx index 01a548b8c1..b5fc93e054 100644 --- a/src/renderer/src/pages/home/Tabs/TopicsTab.tsx +++ b/src/renderer/src/pages/home/Tabs/TopicsTab.tsx @@ -9,11 +9,10 @@ import { QuestionCircleOutlined, UploadOutlined } from '@ant-design/icons' -import DragableList from '@renderer/components/DragableList' +import { DraggableVirtualList as DraggableList } from '@renderer/components/DraggableList' import CopyIcon from '@renderer/components/Icons/CopyIcon' import ObsidianExportPopup from '@renderer/components/Popups/ObsidianExportPopup' import PromptPopup from '@renderer/components/Popups/PromptPopup' -import Scrollbar from '@renderer/components/Scrollbar' import { isMac } from '@renderer/config/constant' import { useAssistant, useAssistants } from '@renderer/hooks/useAssistant' import { modelGenerating } from '@renderer/hooks/useRuntime' @@ -447,92 +446,86 @@ const Topics: FC = ({ assistant: _assistant, activeTopic, setActiveTopic }, [assistant.topics, pinTopicsToTop]) return ( - - - - {(topic) => { - const isActive = topic.id === activeTopic?.id - const topicName = topic.name.replace('`', '') - const topicPrompt = topic.prompt - const fullTopicPrompt = t('common.prompt') + ': ' + topicPrompt + + {(topic) => { + const isActive = topic.id === activeTopic?.id + const topicName = topic.name.replace('`', '') + const topicPrompt = topic.prompt + const fullTopicPrompt = t('common.prompt') + ': ' + topicPrompt - const getTopicNameClassName = () => { - if (isRenaming(topic.id)) return 'shimmer' - if (isNewlyRenamed(topic.id)) return 'typing' - return '' - } + const getTopicNameClassName = () => { + if (isRenaming(topic.id)) return 'shimmer' + if (isNewlyRenamed(topic.id)) return 'typing' + return '' + } - return ( - setTargetTopic(topic)} - className={isActive ? 'active' : ''} - onClick={() => onSwitchTopic(topic)} - style={{ borderRadius }}> - {isPending(topic.id) && !isActive && } - - - {topicName} - - {!topic.pinned && ( - -
    - {t('chat.topics.delete.shortcut', { key: isMac ? '⌘' : 'Ctrl' })} -
    + return ( + + setTargetTopic(topic)} + className={isActive ? 'active' : ''} + onClick={() => onSwitchTopic(topic)} + style={{ borderRadius }}> + {isPending(topic.id) && !isActive && } + + + {topicName} + + {!topic.pinned && ( + +
    + {t('chat.topics.delete.shortcut', { key: isMac ? '⌘' : 'Ctrl' })}
    - }> - { - if (e.ctrlKey || e.metaKey) { - handleConfirmDelete(topic, e) - } else if (deletingTopicId === topic.id) { - handleConfirmDelete(topic, e) - } else { - handleDeleteClick(topic.id, e) - } - }}> - {deletingTopicId === topic.id ? ( - - ) : ( - - )} - -
    - )} - {topic.pinned && ( - - +
    + }> + { + if (e.ctrlKey || e.metaKey) { + handleConfirmDelete(topic, e) + } else if (deletingTopicId === topic.id) { + handleConfirmDelete(topic, e) + } else { + handleDeleteClick(topic.id, e) + } + }}> + {deletingTopicId === topic.id ? ( + + ) : ( + + )} - )} - - {topicPrompt && ( - - {fullTopicPrompt} - + )} - {showTopicTime && ( - {dayjs(topic.createdAt).format('MM/DD HH:mm')} + {topic.pinned && ( + + + )} - - ) - }} - -
    - - + + {topicPrompt && ( + + {fullTopicPrompt} + + )} + {showTopicTime && {dayjs(topic.createdAt).format('MM/DD HH:mm')}} + + + ) + }} + ) } -const Container = styled(Scrollbar)` - display: flex; - flex-direction: column; - padding: 10px; -` - const TopicListItem = styled.div` padding: 7px 12px; border-radius: var(--list-item-border-radius); diff --git a/src/renderer/src/pages/knowledge/KnowledgePage.tsx b/src/renderer/src/pages/knowledge/KnowledgePage.tsx index a321e8b72e..aeb52b83eb 100644 --- a/src/renderer/src/pages/knowledge/KnowledgePage.tsx +++ b/src/renderer/src/pages/knowledge/KnowledgePage.tsx @@ -1,6 +1,6 @@ import { DeleteOutlined, EditOutlined, SettingOutlined } from '@ant-design/icons' import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar' -import DragableList from '@renderer/components/DragableList' +import { DraggableList } from '@renderer/components/DraggableList' import ListItem from '@renderer/components/ListItem' import PromptPopup from '@renderer/components/Popups/PromptPopup' import Scrollbar from '@renderer/components/Scrollbar' @@ -98,7 +98,7 @@ const KnowledgePage: FC = () => { - {
    )} - + {!isDragging && ( diff --git a/src/renderer/src/pages/paintings/components/PaintingsList.tsx b/src/renderer/src/pages/paintings/components/PaintingsList.tsx index 391c3381f2..fc119af098 100644 --- a/src/renderer/src/pages/paintings/components/PaintingsList.tsx +++ b/src/renderer/src/pages/paintings/components/PaintingsList.tsx @@ -1,5 +1,5 @@ import { DeleteOutlined, PlusOutlined } from '@ant-design/icons' -import DragableList from '@renderer/components/DragableList' +import { DraggableList } from '@renderer/components/DraggableList' import Scrollbar from '@renderer/components/Scrollbar' import { usePaintings } from '@renderer/hooks/usePaintings' import FileManager from '@renderer/services/FileManager' @@ -38,7 +38,7 @@ const PaintingsList: FC = ({ )} - updatePaintings(namespace, value)} onDragStart={() => setDragging(true)} @@ -61,7 +61,7 @@ const PaintingsList: FC = ({ )} - + ) } diff --git a/src/renderer/src/pages/settings/AssistantSettings/AssistantRegularPromptsSettings.tsx b/src/renderer/src/pages/settings/AssistantSettings/AssistantRegularPromptsSettings.tsx index 13194325f7..ecdd03cda9 100644 --- a/src/renderer/src/pages/settings/AssistantSettings/AssistantRegularPromptsSettings.tsx +++ b/src/renderer/src/pages/settings/AssistantSettings/AssistantRegularPromptsSettings.tsx @@ -1,5 +1,5 @@ import { DeleteOutlined, EditOutlined, ExclamationCircleOutlined, PlusOutlined } from '@ant-design/icons' -import DragableList from '@renderer/components/DragableList' +import { DraggableList } from '@renderer/components/DraggableList' import FileItem from '@renderer/pages/files/FileItem' import { Assistant, QuickPhrase } from '@renderer/types' import { Button, Flex, Input, Modal, Popconfirm, Space } from 'antd' @@ -87,7 +87,7 @@ const AssistantRegularPromptsSettings: FC - handleUpdateOrder([...newPrompts].reverse())} style={{ paddingBottom: dragging ? '34px' : 0 }} @@ -120,7 +120,7 @@ const AssistantRegularPromptsSettings: FC }} /> )} - + diff --git a/src/renderer/src/pages/settings/MCPSettings/McpServersList.tsx b/src/renderer/src/pages/settings/MCPSettings/McpServersList.tsx index de0b83c137..4168099c0d 100644 --- a/src/renderer/src/pages/settings/MCPSettings/McpServersList.tsx +++ b/src/renderer/src/pages/settings/MCPSettings/McpServersList.tsx @@ -1,6 +1,6 @@ import { EditOutlined } from '@ant-design/icons' import { nanoid } from '@reduxjs/toolkit' -import DragableList from '@renderer/components/DragableList' +import { DraggableList } from '@renderer/components/DraggableList' import Scrollbar from '@renderer/components/Scrollbar' import { useMCPServers } from '@renderer/hooks/useMCPServers' import { MCPServer } from '@renderer/types' @@ -117,7 +117,7 @@ const McpServersList: FC = () => { - + {(server: MCPServer) => ( navigate(`/settings/mcp/settings`, { state: { server } })}> @@ -171,7 +171,7 @@ const McpServersList: FC = () => { )} - + {mcpServers.length === 0 && ( { - handleUpdateOrder([...newPhrases].reverse())} style={{ paddingBottom: dragging ? '34px' : 0 }} @@ -109,7 +109,7 @@ const QuickPhraseSettings: FC = () => { }} /> )} - + From 8fd59e89de63dfb8e70649c095babd2e4fb7aeb8 Mon Sep 17 00:00:00 2001 From: SuYao Date: Tue, 8 Jul 2025 17:11:43 +0800 Subject: [PATCH 45/54] feat: provider custom header (#7874) * feat: provider custom header * fix: state update dependency * refactor: migrate to code editor onBlur * fix: lint * fix: migrate --- .../ProviderSettings/ProviderSetting.tsx | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx b/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx index 4f693aaacb..da28985c0c 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx @@ -1,6 +1,7 @@ import { CheckOutlined, CloseCircleFilled, LoadingOutlined } from '@ant-design/icons' import { isOpenAIProvider } from '@renderer/aiCore/clients/ApiClientFactory' import OpenAIAlert from '@renderer/components/Alert/OpenAIAlert' +import CodeEditor from '@renderer/components/CodeEditor' import { StreamlineGoodHealthAndWellBeing } from '@renderer/components/Icons/SVGIcon' import { HStack } from '@renderer/components/Layout' import { ApiKeyConnectivity, ApiKeyListPopup } from '@renderer/components/Popups/ApiKeyListPopup' @@ -79,6 +80,8 @@ const ProviderSetting: FC = ({ providerId }) => { checking: false }) + const [headerText, setHeaderText] = useState(JSON.stringify(provider.extra_headers || {}, null, 2)) + // eslint-disable-next-line react-hooks/exhaustive-deps const debouncedUpdateApiKey = useCallback( debounce((value) => { @@ -308,6 +311,16 @@ const ProviderSetting: FC = ({ providerId }) => { setApiHost(provider.apiHost) }, [provider.apiHost, provider.id]) + const onUpdateHeaders = useCallback(() => { + try { + const headers = headerText.trim() ? JSON.parse(headerText) : {} + updateProvider({ ...provider, extra_headers: headers }) + window.message.success({ content: t('message.save.success.title') }) + } catch (error) { + window.message.error({ content: t('settings.provider.copilot.invalid_json') }) + } + }, [headerText, provider, updateProvider, t]) + return ( @@ -424,6 +437,32 @@ const ProviderSetting: FC = ({ providerId }) => { )} )} + {provider.id !== 'copilot' && ( + <> + + {t('settings.provider.copilot.custom_headers')} + + + {t('settings.provider.copilot.headers_description')} + setHeaderText(value)} + onBlur={onUpdateHeaders} + placeholder={`{\n "Header-Name": "Header-Value"\n}`} + options={{ + lint: true, + collapsible: false, + wrappable: true, + lineNumbers: true, + foldGutter: true, + highlightActiveLine: true, + keymap: true + }} + /> + + + )} )} {isAzureOpenAI && ( From 4f7ca3ede8608e7aaf3f361b8b3d7961c7fd5032 Mon Sep 17 00:00:00 2001 From: LiuVaayne <10231735+vaayne@users.noreply.github.com> Date: Tue, 8 Jul 2025 17:16:59 +0800 Subject: [PATCH 46/54] fix: include headers when importing MCP server configurations (#7944) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add missing headers field to newServer object creation in AddMcpServerModal.tsx - Update streamableHttp JSON example to show headers format - Fixes issue where Content-Type and Authorization headers were not imported Fixes #7932 🤖 Generated with [Claude Code](https://claude.ai/code) Co-authored-by: Claude --- .../src/pages/settings/MCPSettings/AddMcpServerModal.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/pages/settings/MCPSettings/AddMcpServerModal.tsx b/src/renderer/src/pages/settings/MCPSettings/AddMcpServerModal.tsx index c2202d3140..c862394d16 100644 --- a/src/renderer/src/pages/settings/MCPSettings/AddMcpServerModal.tsx +++ b/src/renderer/src/pages/settings/MCPSettings/AddMcpServerModal.tsx @@ -44,7 +44,11 @@ const initialJsonExample = `// 示例 JSON (stdio): // "mcpServers": { // "streamable-http-example": { // "type": "streamableHttp", -// "url": "http://localhost:3001" +// "url": "http://localhost:3001", +// "headers": { +// "Content-Type": "application/json", +// "Authorization": "Bearer your-token" +// } // } // } // } @@ -102,7 +106,8 @@ const AddMcpServerModal: FC = ({ visible, onClose, onSuc provider: serverToAdd!.provider, providerUrl: serverToAdd!.providerUrl, tags: serverToAdd!.tags, - configSample: serverToAdd!.configSample + configSample: serverToAdd!.configSample, + headers: serverToAdd!.headers || {} } onSuccess(newServer) From fba6c1642dc6c0a6b2efd0a9b179bc56f7d40796 Mon Sep 17 00:00:00 2001 From: SuYao Date: Tue, 8 Jul 2025 17:17:58 +0800 Subject: [PATCH 47/54] feat: implement tool call progress handling and status updates (#7303) * feat: implement tool call progress handling and status updates - Update MCP tool response handling to include 'pending' and 'cancelled' statuses. - Introduce new IPC channel for progress updates. - Enhance UI components to reflect tool call statuses, including pending and cancelled states. - Add localization for new status messages in multiple languages. - Refactor message handling logic to accommodate new tool response types. * fix: adjust alignment of action tool container in MessageTools component - Change justify-content from flex-end to flex-start to improve layout consistency. * feat: enhance tool confirmation handling and update related components - Introduced a new tool confirmation mechanism in userConfirmation.ts, allowing for individual tool confirmations. - Updated GeminiAPIClient and OpenAIResponseAPIClient to include tool configuration options. - Refactored MessageTools component to utilize new confirmation functions and improved styling. - Enhanced mcp-tools.ts to manage tool invocation and confirmation processes more effectively, ensuring real-time status updates. * refactor(McpToolChunkMiddleware): enhance tool execution handling and confirmation tracking - Updated createToolHandlingTransform to manage confirmed tool calls and results more effectively. - Refactored executeToolCalls and executeToolUseResponses to return both tool results and confirmed tool calls. - Adjusted buildParamsWithToolResults to utilize confirmed tool calls for building new request messages. - Improved error handling in messageThunk for tool call status updates, ensuring accurate block ID mapping. * feat(McpToolChunkMiddleware, ToolUseExtractionMiddleware, mcp-tools, userConfirmation): enhance tool execution and confirmation handling - Updated McpToolChunkMiddleware to execute tool calls and responses asynchronously, improving performance and response handling. - Enhanced ToolUseExtractionMiddleware to generate unique tool IDs for better tracking. - Modified parseToolUse function to accept a starting index for tool extraction. - Improved user confirmation handling with abort signal support to manage tool action confirmations more effectively. - Updated SYSTEM_PROMPT to clarify the use of multiple tools per message. * fix(tagExtraction): update test expectations for tag extraction results - Adjusted expected length of results from 7 to 9 to reflect changes in tag extraction logic. - Modified content assertions for specific tag contents to ensure accurate validation of extracted tags. * refactor(GeminiAPIClient, OpenAIResponseAPIClient): remove unused function calling configurations - Removed the unused FunctionCallingConfigMode from GeminiAPIClient to streamline the code. - Eliminated the parallel_tool_calls property from OpenAIResponseAPIClient, simplifying the tool call configuration. * feat(McpToolChunkMiddleware): enhance LLM response handling and tool call confirmation - Added notification to UI for new LLM response processing before recursive calls in createToolHandlingTransform. - Improved tool call confirmation logic in executeToolCalls to match tool IDs more accurately, enhancing response validation. * refactor(McpToolChunkMiddleware, ToolUseExtractionMiddleware, messageThunk): remove unnecessary console logs - Eliminated redundant console log statements in McpToolChunkMiddleware, ToolUseExtractionMiddleware, and messageThunk to clean up the code and improve performance. - Focused on enhancing readability and maintainability by reducing clutter in the logging output. * refactor(McpToolChunkMiddleware): remove redundant logging statements - Eliminated unnecessary logging in createToolHandlingTransform to streamline the code and enhance readability. - Focused on reducing clutter in the logging output while maintaining error handling functionality. * feat: enhance action button functionality with cancel and confirm options * refactor(AbortHandlerMiddleware, McpToolChunkMiddleware, ToolUseExtractionMiddleware, messageThunk): improve error handling and code clarity - Updated AbortHandlerMiddleware to skip abort status checks if an error chunk is received, enhancing error handling logic. - Replaced console.error with Logger.error in McpToolChunkMiddleware for consistent logging practices. - Refined ToolUseExtractionMiddleware to improve tool use extraction logic and ensure proper handling of tool_use tags. - Enhanced messageThunk to include initialPlaceholderBlockId in block ID checks, improving error state management. * refactor(ToolUseExtractionMiddleware): enhance tool use parsing logic with counter - Introduced a toolCounter to track the number of tool use responses processed. - Updated parseToolUse function calls to include the toolCounter, improving the extraction logic and ensuring accurate response handling. * feat(McpService, IpcChannel, MessageTools): implement tool abort functionality - Added Mcp_AbortTool channel to handle tool abortion requests. - Implemented abortTool method in McpService to manage active tool calls and provide logging. - Updated MessageTools component to include an abort button for ongoing tool calls, enhancing user control. - Modified API calls to support optional callId for better tracking of tool executions. - Added localization strings for tool abort messages in multiple languages. --------- Co-authored-by: Vaayne --- package.json | 2 +- packages/shared/IpcChannel.ts | 2 + src/main/ipc.ts | 4 + src/main/services/MCPService.ts | 34 +- src/preload/index.ts | 8 +- .../common/AbortHandlerMiddleware.ts | 7 +- .../common/FinalChunkConsumerMiddleware.ts | 1 - .../middleware/core/McpToolChunkMiddleware.ts | 149 ++-- .../feat/ToolUseExtractionMiddleware.ts | 46 +- src/renderer/src/i18n/locales/en-us.json | 4 + src/renderer/src/i18n/locales/ja-jp.json | 6 +- src/renderer/src/i18n/locales/ru-ru.json | 6 +- src/renderer/src/i18n/locales/zh-cn.json | 4 + src/renderer/src/i18n/locales/zh-tw.json | 6 +- .../src/pages/home/Messages/MessageTools.tsx | 342 +++++---- .../src/services/StreamProcessingService.ts | 5 + src/renderer/src/store/thunk/messageThunk.ts | 58 +- src/renderer/src/types/chunk.ts | 7 + src/renderer/src/types/index.ts | 4 +- .../src/utils/__tests__/tagExtraction.test.ts | 693 ++++++++++++++++++ src/renderer/src/utils/mcp-tools.ts | 228 ++++-- src/renderer/src/utils/prompt.ts | 2 +- src/renderer/src/utils/userConfirmation.ts | 86 +++ yarn.lock | 16 +- 24 files changed, 1419 insertions(+), 301 deletions(-) create mode 100644 src/renderer/src/utils/__tests__/tagExtraction.test.ts create mode 100644 src/renderer/src/utils/userConfirmation.ts diff --git a/package.json b/package.json index 5da524deca..c007c64318 100644 --- a/package.json +++ b/package.json @@ -107,7 +107,7 @@ "@langchain/community": "^0.3.36", "@langchain/ollama": "^0.2.1", "@mistralai/mistralai": "^1.6.0", - "@modelcontextprotocol/sdk": "^1.11.4", + "@modelcontextprotocol/sdk": "^1.12.3", "@mozilla/readability": "^0.6.0", "@notionhq/client": "^2.2.15", "@playwright/test": "^1.52.0", diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index 6dd3849786..78208133d5 100644 --- a/packages/shared/IpcChannel.ts +++ b/packages/shared/IpcChannel.ts @@ -74,6 +74,8 @@ export enum IpcChannel { Mcp_ServersChanged = 'mcp:servers-changed', Mcp_ServersUpdated = 'mcp:servers-updated', Mcp_CheckConnectivity = 'mcp:check-connectivity', + Mcp_SetProgress = 'mcp:set-progress', + Mcp_AbortTool = 'mcp:abort-tool', // Python Python_Execute = 'python:execute', diff --git a/src/main/ipc.ts b/src/main/ipc.ts index c3ca33b0f4..be160c8d2a 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -501,6 +501,10 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { ipcMain.handle(IpcChannel.Mcp_GetResource, mcpService.getResource) ipcMain.handle(IpcChannel.Mcp_GetInstallInfo, mcpService.getInstallInfo) ipcMain.handle(IpcChannel.Mcp_CheckConnectivity, mcpService.checkMcpConnectivity) + ipcMain.handle(IpcChannel.Mcp_AbortTool, mcpService.abortTool) + ipcMain.handle(IpcChannel.Mcp_SetProgress, (_, progress: number) => { + mainWindow.webContents.send('mcp-progress', progress) + }) // Register Python execution handler ipcMain.handle( diff --git a/src/main/services/MCPService.ts b/src/main/services/MCPService.ts index 2515c91416..9893c81474 100644 --- a/src/main/services/MCPService.ts +++ b/src/main/services/MCPService.ts @@ -28,6 +28,7 @@ import { app } from 'electron' import Logger from 'electron-log' import { EventEmitter } from 'events' import { memoize } from 'lodash' +import { v4 as uuidv4 } from 'uuid' import { CacheService } from './CacheService' import { CallBackServer } from './mcp/oauth/callback' @@ -71,6 +72,7 @@ function withCache( class McpService { private clients: Map = new Map() private pendingClients: Map> = new Map() + private activeToolCalls: Map = new Map() constructor() { this.initClient = this.initClient.bind(this) @@ -84,6 +86,7 @@ class McpService { this.removeServer = this.removeServer.bind(this) this.restartServer = this.restartServer.bind(this) this.stopServer = this.stopServer.bind(this) + this.abortTool = this.abortTool.bind(this) this.cleanup = this.cleanup.bind(this) } @@ -455,10 +458,14 @@ class McpService { */ public async callTool( _: Electron.IpcMainInvokeEvent, - { server, name, args }: { server: MCPServer; name: string; args: any } + { server, name, args, callId }: { server: MCPServer; name: string; args: any; callId?: string } ): Promise { + const toolCallId = callId || uuidv4() + const abortController = new AbortController() + this.activeToolCalls.set(toolCallId, abortController) + try { - Logger.info('[MCP] Calling:', server.name, name, args) + Logger.info('[MCP] Calling:', server.name, name, args, 'callId:', toolCallId) if (typeof args === 'string') { try { args = JSON.parse(args) @@ -468,12 +475,19 @@ class McpService { } const client = await this.initClient(server) const result = await client.callTool({ name, arguments: args }, undefined, { - timeout: server.timeout ? server.timeout * 1000 : 60000 // Default timeout of 1 minute + onprogress: (process) => { + console.log('[MCP] Progress:', process.progress / (process.total || 1)) + window.api.mcp.setProgress(process.progress / (process.total || 1)) + }, + timeout: server.timeout ? server.timeout * 1000 : 60000, // Default timeout of 1 minute + signal: this.activeToolCalls.get(toolCallId)?.signal }) return result as MCPCallToolResponse } catch (error) { Logger.error(`[MCP] Error calling tool ${name} on ${server.name}:`, error) throw error + } finally { + this.activeToolCalls.delete(toolCallId) } } @@ -664,6 +678,20 @@ class McpService { delete env.http_proxy delete env.https_proxy } + + // 实现 abortTool 方法 + public async abortTool(_: Electron.IpcMainInvokeEvent, callId: string) { + const activeToolCall = this.activeToolCalls.get(callId) + if (activeToolCall) { + activeToolCall.abort() + this.activeToolCalls.delete(callId) + Logger.info(`[MCP] Aborted tool call: ${callId}`) + return true + } else { + Logger.warn(`[MCP] No active tool call found for callId: ${callId}`) + return false + } + } } export default new McpService() diff --git a/src/preload/index.ts b/src/preload/index.ts index 91e3e02aee..ea1a2897f9 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -228,8 +228,8 @@ const api = { restartServer: (server: MCPServer) => ipcRenderer.invoke(IpcChannel.Mcp_RestartServer, server), stopServer: (server: MCPServer) => ipcRenderer.invoke(IpcChannel.Mcp_StopServer, server), listTools: (server: MCPServer) => ipcRenderer.invoke(IpcChannel.Mcp_ListTools, server), - callTool: ({ server, name, args }: { server: MCPServer; name: string; args: any }) => - ipcRenderer.invoke(IpcChannel.Mcp_CallTool, { server, name, args }), + callTool: ({ server, name, args, callId }: { server: MCPServer; name: string; args: any; callId?: string }) => + ipcRenderer.invoke(IpcChannel.Mcp_CallTool, { server, name, args, callId }), listPrompts: (server: MCPServer) => ipcRenderer.invoke(IpcChannel.Mcp_ListPrompts, server), getPrompt: ({ server, name, args }: { server: MCPServer; name: string; args?: Record }) => ipcRenderer.invoke(IpcChannel.Mcp_GetPrompt, { server, name, args }), @@ -237,7 +237,9 @@ const api = { getResource: ({ server, uri }: { server: MCPServer; uri: string }) => ipcRenderer.invoke(IpcChannel.Mcp_GetResource, { server, uri }), getInstallInfo: () => ipcRenderer.invoke(IpcChannel.Mcp_GetInstallInfo), - checkMcpConnectivity: (server: any) => ipcRenderer.invoke(IpcChannel.Mcp_CheckConnectivity, server) + checkMcpConnectivity: (server: any) => ipcRenderer.invoke(IpcChannel.Mcp_CheckConnectivity, server), + abortTool: (callId: string) => ipcRenderer.invoke(IpcChannel.Mcp_AbortTool, callId), + setProgress: (progress: number) => ipcRenderer.invoke(IpcChannel.Mcp_SetProgress, progress) }, python: { execute: (script: string, context?: Record, timeout?: number) => diff --git a/src/renderer/src/aiCore/middleware/common/AbortHandlerMiddleware.ts b/src/renderer/src/aiCore/middleware/common/AbortHandlerMiddleware.ts index 7186cec12f..2acf553533 100644 --- a/src/renderer/src/aiCore/middleware/common/AbortHandlerMiddleware.ts +++ b/src/renderer/src/aiCore/middleware/common/AbortHandlerMiddleware.ts @@ -67,7 +67,12 @@ export const AbortHandlerMiddleware: CompletionsMiddleware = const streamWithAbortHandler = (result.stream as ReadableStream).pipeThrough( new TransformStream({ transform(chunk, controller) { - // 检查 abort 状态 + // 如果已经收到错误块,不再检查 abort 状态 + if (chunk.type === ChunkType.ERROR) { + controller.enqueue(chunk) + return + } + if (abortSignal?.aborted) { // 转换为 ErrorChunk const errorChunk: ErrorChunk = { diff --git a/src/renderer/src/aiCore/middleware/common/FinalChunkConsumerMiddleware.ts b/src/renderer/src/aiCore/middleware/common/FinalChunkConsumerMiddleware.ts index b0b9bd7ce6..80e0cdc5e6 100644 --- a/src/renderer/src/aiCore/middleware/common/FinalChunkConsumerMiddleware.ts +++ b/src/renderer/src/aiCore/middleware/common/FinalChunkConsumerMiddleware.ts @@ -136,7 +136,6 @@ function extractAndAccumulateUsageMetrics(ctx: CompletionsContext, chunk: Generi Logger.debug(`[${MIDDLEWARE_NAME}] First token timestamp: ${ctx._internal.customState.firstTokenTimestamp}`) } if (chunk.type === ChunkType.LLM_RESPONSE_COMPLETE) { - Logger.debug(`[${MIDDLEWARE_NAME}] LLM_RESPONSE_COMPLETE chunk received:`, ctx._internal) // 从LLM_RESPONSE_COMPLETE chunk中提取usage数据 if (chunk.response?.usage) { accumulateUsage(ctx._internal.observer.usage, chunk.response.usage) diff --git a/src/renderer/src/aiCore/middleware/core/McpToolChunkMiddleware.ts b/src/renderer/src/aiCore/middleware/core/McpToolChunkMiddleware.ts index 893018d4c5..c5156dbd53 100644 --- a/src/renderer/src/aiCore/middleware/core/McpToolChunkMiddleware.ts +++ b/src/renderer/src/aiCore/middleware/core/McpToolChunkMiddleware.ts @@ -89,6 +89,11 @@ function createToolHandlingTransform( let hasToolUseResponses = false let streamEnded = false + // 存储已执行的工具结果 + const executedToolResults: SdkMessageParam[] = [] + const executedToolCalls: SdkToolCall[] = [] + const executionPromises: Promise[] = [] + return new TransformStream({ async transform(chunk: GenericChunk, controller) { try { @@ -98,22 +103,64 @@ function createToolHandlingTransform( // 1. 处理Function Call方式的工具调用 if (createdChunk.tool_calls && createdChunk.tool_calls.length > 0) { - toolCalls.push(...createdChunk.tool_calls) hasToolCalls = true + + for (const toolCall of createdChunk.tool_calls) { + toolCalls.push(toolCall) + + const executionPromise = (async () => { + try { + const result = await executeToolCalls( + ctx, + [toolCall], + mcpTools, + allToolResponses, + currentParams.onChunk, + currentParams.assistant.model! + ) + + // 缓存执行结果 + executedToolResults.push(...result.toolResults) + executedToolCalls.push(...result.confirmedToolCalls) + } catch (error) { + console.error(`🔧 [${MIDDLEWARE_NAME}] Error executing tool call asynchronously:`, error) + } + })() + + executionPromises.push(executionPromise) + } } // 2. 处理Tool Use方式的工具调用 if (createdChunk.tool_use_responses && createdChunk.tool_use_responses.length > 0) { - toolUseResponses.push(...createdChunk.tool_use_responses) hasToolUseResponses = true + for (const toolUseResponse of createdChunk.tool_use_responses) { + toolUseResponses.push(toolUseResponse) + const executionPromise = (async () => { + try { + const result = await executeToolUseResponses( + ctx, + [toolUseResponse], // 单个执行 + mcpTools, + allToolResponses, + currentParams.onChunk, + currentParams.assistant.model! + ) + + // 缓存执行结果 + executedToolResults.push(...result.toolResults) + } catch (error) { + console.error(`🔧 [${MIDDLEWARE_NAME}] Error executing tool use response asynchronously:`, error) + // 错误时不影响其他工具的执行 + } + })() + + executionPromises.push(executionPromise) + } } - - // 不转发MCP工具进展chunks,避免重复处理 - return + } else { + controller.enqueue(chunk) } - - // 转发其他所有chunk - controller.enqueue(chunk) } catch (error) { console.error(`🔧 [${MIDDLEWARE_NAME}] Error processing chunk:`, error) controller.error(error) @@ -121,43 +168,33 @@ function createToolHandlingTransform( }, async flush(controller) { - const shouldExecuteToolCalls = hasToolCalls && toolCalls.length > 0 - const shouldExecuteToolUseResponses = hasToolUseResponses && toolUseResponses.length > 0 - - if (!streamEnded && (shouldExecuteToolCalls || shouldExecuteToolUseResponses)) { + // 在流结束时等待所有异步工具执行完成,然后进行递归调用 + if (!streamEnded && (hasToolCalls || hasToolUseResponses)) { streamEnded = true try { - let toolResult: SdkMessageParam[] = [] - - if (shouldExecuteToolCalls) { - toolResult = await executeToolCalls( - ctx, - toolCalls, - mcpTools, - allToolResponses, - currentParams.onChunk, - currentParams.assistant.model! - ) - } else if (shouldExecuteToolUseResponses) { - toolResult = await executeToolUseResponses( - ctx, - toolUseResponses, - mcpTools, - allToolResponses, - currentParams.onChunk, - currentParams.assistant.model! - ) - } - - if (toolResult.length > 0) { + await Promise.all(executionPromises) + if (executedToolResults.length > 0) { const output = ctx._internal.toolProcessingState?.output + const newParams = buildParamsWithToolResults( + ctx, + currentParams, + output, + executedToolResults, + executedToolCalls + ) + + // 在递归调用前通知UI开始新的LLM响应处理 + if (currentParams.onChunk) { + currentParams.onChunk({ + type: ChunkType.LLM_RESPONSE_CREATED + }) + } - const newParams = buildParamsWithToolResults(ctx, currentParams, output, toolResult, toolCalls) await executeWithToolHandling(newParams, depth + 1) } } catch (error) { - console.error(`🔧 [${MIDDLEWARE_NAME}] Error in tool processing:`, error) + Logger.error(`🔧 [${MIDDLEWARE_NAME}] Error in tool processing:`, error) controller.error(error) } finally { hasToolCalls = false @@ -178,8 +215,7 @@ async function executeToolCalls( allToolResponses: MCPToolResponse[], onChunk: CompletionsParams['onChunk'], model: Model -): Promise { - // 转换为MCPToolResponse格式 +): Promise<{ toolResults: SdkMessageParam[]; confirmedToolCalls: SdkToolCall[] }> { const mcpToolResponses: ToolCallResponse[] = toolCalls .map((toolCall) => { const mcpTool = ctx.apiClientInstance.convertSdkToolCallToMcp(toolCall, mcpTools) @@ -192,11 +228,11 @@ async function executeToolCalls( if (mcpToolResponses.length === 0) { console.warn(`🔧 [${MIDDLEWARE_NAME}] No valid MCP tool responses to execute`) - return [] + return { toolResults: [], confirmedToolCalls: [] } } // 使用现有的parseAndCallTools函数执行工具 - const toolResults = await parseAndCallTools( + const { toolResults, confirmedToolResponses } = await parseAndCallTools( mcpToolResponses, allToolResponses, onChunk, @@ -204,10 +240,24 @@ async function executeToolCalls( return ctx.apiClientInstance.convertMcpToolResponseToSdkMessageParam(mcpToolResponse, resp, model) }, model, - mcpTools + mcpTools, + ctx._internal?.flowControl?.abortSignal ) - return toolResults + // 找出已确认工具对应的原始toolCalls + const confirmedToolCalls = toolCalls.filter((toolCall) => { + return confirmedToolResponses.find((confirmed) => { + // 根据不同的ID字段匹配原始toolCall + return ( + ('name' in toolCall && + (toolCall.name?.includes(confirmed.tool.name) || toolCall.name?.includes(confirmed.tool.id))) || + confirmed.tool.name === toolCall.id || + confirmed.tool.id === toolCall.id + ) + }) + }) + + return { toolResults, confirmedToolCalls } } /** @@ -221,9 +271,9 @@ async function executeToolUseResponses( allToolResponses: MCPToolResponse[], onChunk: CompletionsParams['onChunk'], model: Model -): Promise { +): Promise<{ toolResults: SdkMessageParam[] }> { // 直接使用parseAndCallTools函数处理已经解析好的ToolUseResponse - const toolResults = await parseAndCallTools( + const { toolResults } = await parseAndCallTools( toolUseResponses, allToolResponses, onChunk, @@ -231,10 +281,11 @@ async function executeToolUseResponses( return ctx.apiClientInstance.convertMcpToolResponseToSdkMessageParam(mcpToolResponse, resp, model) }, model, - mcpTools + mcpTools, + ctx._internal?.flowControl?.abortSignal ) - return toolResults + return { toolResults } } /** @@ -245,7 +296,7 @@ function buildParamsWithToolResults( currentParams: CompletionsParams, output: SdkRawOutput | string | undefined, toolResults: SdkMessageParam[], - toolCalls: SdkToolCall[] + confirmedToolCalls: SdkToolCall[] ): CompletionsParams { // 获取当前已经转换好的reqMessages,如果没有则使用原始messages const currentReqMessages = getCurrentReqMessages(ctx) @@ -253,7 +304,7 @@ function buildParamsWithToolResults( const apiClient = ctx.apiClientInstance // 从回复中构建助手消息 - const newReqMessages = apiClient.buildSdkMessages(currentReqMessages, output, toolResults, toolCalls) + const newReqMessages = apiClient.buildSdkMessages(currentReqMessages, output, toolResults, confirmedToolCalls) if (output && ctx._internal.toolProcessingState) { ctx._internal.toolProcessingState.output = undefined diff --git a/src/renderer/src/aiCore/middleware/feat/ToolUseExtractionMiddleware.ts b/src/renderer/src/aiCore/middleware/feat/ToolUseExtractionMiddleware.ts index 5f444953a9..b53d7348f1 100644 --- a/src/renderer/src/aiCore/middleware/feat/ToolUseExtractionMiddleware.ts +++ b/src/renderer/src/aiCore/middleware/feat/ToolUseExtractionMiddleware.ts @@ -22,7 +22,8 @@ const TOOL_USE_TAG_CONFIG: TagConfig = { * 1. 从文本流中检测并提取 标签 * 2. 解析工具调用信息并转换为 ToolUseResponse 格式 * 3. 生成 MCP_TOOL_CREATED chunk 供 McpToolChunkMiddleware 处理 - * 4. 清理文本流,移除工具使用标签但保留正常文本 + * 4. 丢弃 tool_use 之后的所有内容(助手幻觉) + * 5. 清理文本流,移除工具使用标签但保留正常文本 * * 注意:此中间件只负责提取和转换,实际工具调用由 McpToolChunkMiddleware 处理 */ @@ -32,13 +33,10 @@ export const ToolUseExtractionMiddleware: CompletionsMiddleware = async (ctx: CompletionsContext, params: CompletionsParams): Promise => { const mcpTools = params.mcpTools || [] - // 如果没有工具,直接调用下一个中间件 if (!mcpTools || mcpTools.length === 0) return next(ctx, params) - // 调用下游中间件 const result = await next(ctx, params) - // 响应后处理:处理工具使用标签提取 if (result.stream) { const resultFromUpstream = result.stream as ReadableStream @@ -60,7 +58,9 @@ function createToolUseExtractionTransform( _ctx: CompletionsContext, mcpTools: MCPTool[] ): TransformStream { - const tagExtractor = new TagExtractor(TOOL_USE_TAG_CONFIG) + const toolUseExtractor = new TagExtractor(TOOL_USE_TAG_CONFIG) + let hasAnyToolUse = false + let toolCounter = 0 return new TransformStream({ async transform(chunk: GenericChunk, controller) { @@ -68,30 +68,37 @@ function createToolUseExtractionTransform( // 处理文本内容,检测工具使用标签 if (chunk.type === ChunkType.TEXT_DELTA) { const textChunk = chunk as TextDeltaChunk - const extractionResults = tagExtractor.processText(textChunk.text) - for (const result of extractionResults) { + // 处理 tool_use 标签 + const toolUseResults = toolUseExtractor.processText(textChunk.text) + + for (const result of toolUseResults) { if (result.complete && result.tagContentExtracted) { // 提取到完整的工具使用内容,解析并转换为 SDK ToolCall 格式 - const toolUseResponses = parseToolUse(result.tagContentExtracted, mcpTools) + const toolUseResponses = parseToolUse(result.tagContentExtracted, mcpTools, toolCounter) + toolCounter += toolUseResponses.length if (toolUseResponses.length > 0) { - // 生成 MCP_TOOL_CREATED chunk,复用现有的处理流程 + // 生成 MCP_TOOL_CREATED chunk const mcpToolCreatedChunk: MCPToolCreatedChunk = { type: ChunkType.MCP_TOOL_CREATED, tool_use_responses: toolUseResponses } controller.enqueue(mcpToolCreatedChunk) + + // 标记已有工具调用 + hasAnyToolUse = true } } else if (!result.isTagContent && result.content) { - // 发送标签外的正常文本内容 - const cleanTextChunk: TextDeltaChunk = { - ...textChunk, - text: result.content + if (!hasAnyToolUse) { + const cleanTextChunk: TextDeltaChunk = { + ...textChunk, + text: result.content + } + controller.enqueue(cleanTextChunk) } - controller.enqueue(cleanTextChunk) } - // 注意:标签内的内容不会作为TEXT_DELTA转发,避免重复显示 + // tool_use 标签内的内容不转发,避免重复显示 } return } @@ -105,16 +112,17 @@ function createToolUseExtractionTransform( }, async flush(controller) { - // 检查是否有未完成的标签内容 - const finalResult = tagExtractor.finalize() - if (finalResult && finalResult.tagContentExtracted) { - const toolUseResponses = parseToolUse(finalResult.tagContentExtracted, mcpTools) + // 检查是否有未完成的 tool_use 标签内容 + const finalToolUseResult = toolUseExtractor.finalize() + if (finalToolUseResult && finalToolUseResult.tagContentExtracted) { + const toolUseResponses = parseToolUse(finalToolUseResult.tagContentExtracted, mcpTools, toolCounter) if (toolUseResponses.length > 0) { const mcpToolCreatedChunk: MCPToolCreatedChunk = { type: ChunkType.MCP_TOOL_CREATED, tool_use_responses: toolUseResponses } controller.enqueue(mcpToolCreatedChunk) + hasAnyToolUse = true } } } diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 79a9b0236c..d80fa4e2b6 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -706,8 +706,12 @@ "success.yuque.export": "Successfully exported to Yuque", "switch.disabled": "Please wait for the current reply to complete", "tools": { + "pending": "Pending", + "cancelled": "Cancelled", "completed": "Completed", "invoking": "Invoking", + "aborted": "Tool call aborted", + "abort_failed": "Tool call abort failed", "error": "Error occurred", "raw": "Raw", "preview": "Preview" diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index e31ecb4a38..5012193624 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -706,9 +706,13 @@ "tools": { "completed": "完了", "invoking": "呼び出し中", + "aborted": "ツール呼び出し中断", + "abort_failed": "ツール呼び出し中断失敗", "error": "エラーが発生しました", "raw": "生データ", - "preview": "プレビュー" + "preview": "プレビュー", + "pending": "保留中", + "cancelled": "キャンセル" }, "topic.added": "新しいトピックが追加されました", "upgrade.success.button": "再起動", diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index 63a23000f3..dc6010e5d8 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -705,11 +705,15 @@ "success.yuque.export": "Успешный экспорт в Yuque", "switch.disabled": "Пожалуйста, дождитесь завершения текущего ответа", "tools": { + "aborted": "Вызов инструмента прерван", + "abort_failed": "Вызов инструмента прерван", "completed": "Завершено", "invoking": "Вызов", "error": "Произошла ошибка", "raw": "Исходный", - "preview": "Предпросмотр" + "preview": "Предпросмотр", + "pending": "Ожидание", + "cancelled": "Отменено" }, "topic.added": "Новый топик добавлен", "upgrade.success.button": "Перезапустить", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 6b26c45588..b0651eab66 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -706,8 +706,12 @@ "success.yuque.export": "成功导出到语雀", "switch.disabled": "请等待当前回复完成后操作", "tools": { + "pending": "等待中", + "cancelled": "已取消", "completed": "已完成", "invoking": "调用中", + "aborted": "工具调用已中断", + "abort_failed": "工具调用中断失败", "error": "发生错误", "raw": "原始", "preview": "预览" diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 2346e15de3..4272ef4467 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -706,11 +706,15 @@ "success.yuque.export": "成功匯出到語雀", "switch.disabled": "請等待當前回覆完成", "tools": { + "aborted": "工具調用已中斷", + "abort_failed": "工具調用中斷失敗", "completed": "已完成", "invoking": "調用中", "error": "發生錯誤", "raw": "原始碼", - "preview": "預覽" + "preview": "預覽", + "pending": "等待中", + "cancelled": "已取消" }, "topic.added": "新話題已新增", "upgrade.success.button": "重新啟動", diff --git a/src/renderer/src/pages/home/Messages/MessageTools.tsx b/src/renderer/src/pages/home/Messages/MessageTools.tsx index 186b81d6a8..7b7e13a245 100644 --- a/src/renderer/src/pages/home/Messages/MessageTools.tsx +++ b/src/renderer/src/pages/home/Messages/MessageTools.tsx @@ -1,8 +1,12 @@ -import { CheckOutlined, ExpandOutlined, LoadingOutlined, WarningOutlined } from '@ant-design/icons' +import { CheckOutlined, CloseOutlined, LoadingOutlined, WarningOutlined } from '@ant-design/icons' import { useCodeStyle } from '@renderer/context/CodeStyleProvider' import { useSettings } from '@renderer/hooks/useSettings' import type { ToolMessageBlock } from '@renderer/types/newMessage' -import { Collapse, message as antdMessage, Modal, Tabs, Tooltip } from 'antd' +import { cancelToolAction, confirmToolAction } from '@renderer/utils/userConfirmation' +import { Collapse, message as antdMessage, Tooltip } from 'antd' +import { message } from 'antd' +import Logger from 'electron-log/renderer' +import { PauseCircle } from 'lucide-react' import { FC, memo, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -14,12 +18,24 @@ interface Props { const MessageTools: FC = ({ block }) => { const [activeKeys, setActiveKeys] = useState([]) const [copiedMap, setCopiedMap] = useState>({}) - const [expandedResponse, setExpandedResponse] = useState<{ content: string; title: string } | null>(null) const { t } = useTranslation() const { messageFont, fontSize } = useSettings() const toolResponse = block.metadata?.rawMcpToolResponse + const { id, tool, status, response } = toolResponse! + + const isPending = status === 'pending' + const isInvoking = status === 'invoking' + const isDone = status === 'done' + + const argsString = useMemo(() => { + if (toolResponse?.arguments) { + return JSON.stringify(toolResponse.arguments, null, 2) + } + return 'No arguments' + }, [toolResponse]) + const resultString = useMemo(() => { try { return JSON.stringify( @@ -50,13 +66,34 @@ const MessageTools: FC = ({ block }) => { setActiveKeys(Array.isArray(keys) ? keys : [keys]) } + const handleConfirmTool = () => { + confirmToolAction(id) + } + + const handleCancelTool = () => { + cancelToolAction(id) + } + + const handleAbortTool = async () => { + if (toolResponse?.id) { + try { + const success = await window.api.mcp.abortTool(toolResponse.id) + if (success) { + message.success({ content: t('message.tools.aborted'), key: 'abort-tool' }) + } else { + message.error({ content: t('message.tools.abort_failed'), key: 'abort-tool' }) + } + } catch (error) { + Logger.error('Failed to abort tool:', error) + message.error({ content: t('message.tools.abort_failed'), key: 'abort-tool' }) + } + } + } + // Format tool responses for collapse items const getCollapseItems = () => { const items: { key: string; label: React.ReactNode; children: React.ReactNode }[] = [] - const { id, tool, status, response } = toolResponse - const isInvoking = status === 'invoking' - const isDone = status === 'done' - const hasError = isDone && response?.isError === true + const hasError = response?.isError === true const result = { params: toolResponse.arguments, response: toolResponse.response @@ -68,34 +105,93 @@ const MessageTools: FC = ({ block }) => { {tool.name} - - {isInvoking - ? t('message.tools.invoking') - : hasError - ? t('message.tools.error') - : t('message.tools.completed')} - {isInvoking && } - {isDone && !hasError && } - {hasError && } + + {(() => { + switch (status) { + case 'pending': + return ( + <> + {t('message.tools.pending')} + + + ) + case 'invoking': + return ( + <> + {t('message.tools.invoking')} + + + ) + case 'cancelled': + return ( + <> + {t('message.tools.cancelled')} + + + ) + case 'done': + if (hasError) { + return ( + <> + {t('message.tools.error')} + + + ) + } else { + return ( + <> + {t('message.tools.completed')} + + + ) + } + default: + return '' + } + })()} - {isDone && response && ( + {isPending && ( <> - + { e.stopPropagation() - setExpandedResponse({ - content: JSON.stringify(response, null, 2), - title: tool.name - }) + handleCancelTool() }} - aria-label={t('common.expand')}> - + aria-label={t('common.cancel')}> + + + { + e.stopPropagation() + handleConfirmTool() + }} + aria-label={t('common.confirm')}> + + + + + )} + {isInvoking && toolResponse?.id && ( + + { + e.stopPropagation() + handleAbortTool() + }} + aria-label={t('chat.input.pause')}> + + + + )} + {isDone && response && ( + <> = ({ block }) => { ), - children: isDone && result && ( - - - - ) + children: + isDone && result ? ( + + + + ) : argsString ? ( + <> + + + + + ) : null }) return items } - const renderPreview = (content: string) => { - if (!content) return null - - try { - const parsedResult = JSON.parse(content) - switch (parsedResult.content[0]?.type) { - case 'text': - return {parsedResult.content[0].text} - default: - return {content} - } - } catch (e) { - console.error('failed to render the preview of mcp results:', e) - return {content} - } - } - return ( - <> + ( - - )} + expandIconPosition="end" /> - - setExpandedResponse(null)} - footer={null} - width="80%" - centered - transitionName="animation-move-down" - styles={{ body: { maxHeight: '80vh', overflow: 'auto' } }}> - {expandedResponse && ( - - { - navigator.clipboard.writeText( - typeof expandedResponse.content === 'string' - ? expandedResponse.content - : JSON.stringify(expandedResponse.content, null, 2) - ) - antdMessage.success({ content: t('message.copied'), key: 'copy-expanded' }) - }} - aria-label={t('common.copy')}> - - - } - items={[ - { - key: 'preview', - label: t('message.tools.preview'), - children: - }, - { - key: 'raw', - label: t('message.tools.raw'), - children: renderPreview(expandedResponse.content) - } - ]} - /> - - )} - - + ) } @@ -230,15 +266,25 @@ const CollapsedContent: FC<{ isExpanded: boolean; resultString: string }> = ({ i } const CollapseContainer = styled(Collapse)` - margin-top: 10px; - margin-bottom: 12px; border-radius: 8px; + border: none; overflow: hidden; .ant-collapse-header { background-color: var(--color-bg-2); transition: background-color 0.2s; - + display: flex; + align-items: center; + .ant-collapse-expand-icon { + height: 100% !important; + } + .ant-collapse-arrow { + height: 28px !important; + svg { + width: 14px; + height: 14px; + } + } &:hover { background-color: var(--color-bg-3); } @@ -249,6 +295,15 @@ const CollapseContainer = styled(Collapse)` } ` +const ToolContainer = styled.div` + margin-top: 10px; + margin-bottom: 12px; + border: 1px solid var(--color-border); + background-color: var(--color-bg-2); + border-radius: 8px; + overflow: hidden; +` + const MarkdownContainer = styled.div` & pre { background: transparent !important; @@ -267,6 +322,7 @@ const MessageTitleLabel = styled.div` min-height: 26px; gap: 10px; padding: 0; + margin-left: 4px; ` const TitleContent = styled.div` @@ -282,18 +338,27 @@ const ToolName = styled.span` font-size: 13px; ` -const StatusIndicator = styled.span<{ $isInvoking: boolean; $hasError?: boolean }>` +const StatusIndicator = styled.span<{ status: string; hasError?: boolean }>` color: ${(props) => { - if (props.$hasError) return 'var(--color-error, #ff4d4f)' - if (props.$isInvoking) return 'var(--color-primary)' - return 'var(--color-success, #52c41a)' + switch (props.status) { + case 'pending': + return 'var(--color-text-2)' + case 'invoking': + return 'var(--color-primary)' + case 'cancelled': + return 'var(--color-error, #ff4d4f)' // Assuming cancelled should also be an error color + case 'done': + return props.hasError ? 'var(--color-error, #ff4d4f)' : 'var(--color-success, #52c41a)' + default: + return 'var(--color-text)' + } }}; font-size: 11px; display: flex; align-items: center; opacity: 0.85; border-left: 1px solid var(--color-border); - padding-left: 8px; + padding-left: 12px; ` const ActionButtonsContainer = styled.div` @@ -307,18 +372,30 @@ const ActionButton = styled.button` border: none; color: var(--color-text-2); cursor: pointer; - padding: 4px 8px; + padding: 4px; display: flex; align-items: center; justify-content: center; opacity: 0.7; transition: all 0.2s; border-radius: 4px; + gap: 4px; + min-width: 28px; + height: 28px; &:hover { opacity: 1; color: var(--color-text); - background-color: var(--color-bg-1); + background-color: var(--color-bg-3); + } + + &.confirm-button { + color: var(--color-primary); + + &:hover { + background-color: var(--color-primary-bg); + color: var(--color-primary); + } } &:focus-visible { @@ -332,12 +409,6 @@ const ActionButton = styled.button` } ` -const CollapsibleIcon = styled.i` - color: var(--color-text-2); - font-size: 12px; - transition: transform 0.2s; -` - const ToolResponseContainer = styled.div` border-radius: 0 0 4px 4px; overflow: auto; @@ -346,35 +417,4 @@ const ToolResponseContainer = styled.div` position: relative; ` -const PreviewBlock = styled.div` - margin: 0; - white-space: pre-wrap; - word-break: break-word; - color: var(--color-text); - user-select: text; -` - -const ExpandedResponseContainer = styled.div` - background: var(--color-bg-1); - border-radius: 8px; - padding: 16px; - position: relative; - - .copy-expanded-button { - position: absolute; - top: 10px; - right: 10px; - background-color: var(--color-bg-2); - border-radius: 4px; - z-index: 1; - } - - pre { - margin: 0; - white-space: pre-wrap; - word-break: break-word; - color: var(--color-text); - } -` - export default memo(MessageTools) diff --git a/src/renderer/src/services/StreamProcessingService.ts b/src/renderer/src/services/StreamProcessingService.ts index 78007e85e2..6c166ca6a9 100644 --- a/src/renderer/src/services/StreamProcessingService.ts +++ b/src/renderer/src/services/StreamProcessingService.ts @@ -16,6 +16,7 @@ export interface StreamProcessorCallbacks { onThinkingChunk?: (text: string, thinking_millsec?: number) => void onThinkingComplete?: (text: string, thinking_millsec?: number) => void // A tool call response chunk (from MCP) + onToolCallPending?: (toolResponse: MCPToolResponse) => void onToolCallInProgress?: (toolResponse: MCPToolResponse) => void onToolCallComplete?: (toolResponse: MCPToolResponse) => void // External tool call in progress @@ -69,6 +70,10 @@ export function createStreamProcessor(callbacks: StreamProcessorCallbacks = {}) if (callbacks.onThinkingComplete) callbacks.onThinkingComplete(data.text, data.thinking_millsec) break } + case ChunkType.MCP_TOOL_PENDING: { + if (callbacks.onToolCallPending) data.responses.forEach((toolResp) => callbacks.onToolCallPending!(toolResp)) + break + } case ChunkType.MCP_TOOL_IN_PROGRESS: { if (callbacks.onToolCallInProgress) data.responses.forEach((toolResp) => callbacks.onToolCallInProgress!(toolResp)) diff --git a/src/renderer/src/store/thunk/messageThunk.ts b/src/renderer/src/store/thunk/messageThunk.ts index 5cb2836e82..b652b70463 100644 --- a/src/renderer/src/store/thunk/messageThunk.ts +++ b/src/renderer/src/store/thunk/messageThunk.ts @@ -529,12 +529,13 @@ const fetchAndProcessAssistantResponseImpl = async ( } thinkingBlockId = null }, - onToolCallInProgress: (toolResponse: MCPToolResponse) => { + onToolCallPending: (toolResponse: MCPToolResponse) => { if (initialPlaceholderBlockId) { lastBlockType = MessageBlockType.TOOL const changes = { type: MessageBlockType.TOOL, - status: MessageBlockStatus.PROCESSING, + status: MessageBlockStatus.PENDING, + toolName: toolResponse.tool.name, metadata: { rawMcpToolResponse: toolResponse } } toolBlockId = initialPlaceholderBlockId @@ -542,14 +543,37 @@ const fetchAndProcessAssistantResponseImpl = async ( dispatch(updateOneBlock({ id: toolBlockId, changes })) saveUpdatedBlockToDB(toolBlockId, assistantMsgId, topicId, getState) toolCallIdToBlockIdMap.set(toolResponse.id, toolBlockId) - } else if (toolResponse.status === 'invoking') { + } else if (toolResponse.status === 'pending') { const toolBlock = createToolBlock(assistantMsgId, toolResponse.id, { toolName: toolResponse.tool.name, - status: MessageBlockStatus.PROCESSING, + status: MessageBlockStatus.PENDING, metadata: { rawMcpToolResponse: toolResponse } }) + toolBlockId = toolBlock.id handleBlockTransition(toolBlock, MessageBlockType.TOOL) toolCallIdToBlockIdMap.set(toolResponse.id, toolBlock.id) + } else { + console.warn( + `[onToolCallPending] Received unhandled tool status: ${toolResponse.status} for ID: ${toolResponse.id}` + ) + } + }, + onToolCallInProgress: (toolResponse: MCPToolResponse) => { + // 根据 toolResponse.id 查找对应的块ID + const targetBlockId = toolCallIdToBlockIdMap.get(toolResponse.id) + + if (targetBlockId && toolResponse.status === 'invoking') { + const changes = { + status: MessageBlockStatus.PROCESSING, + metadata: { rawMcpToolResponse: toolResponse } + } + dispatch(updateOneBlock({ id: targetBlockId, changes })) + saveUpdatedBlockToDB(targetBlockId, assistantMsgId, topicId, getState) + } else if (!targetBlockId) { + console.warn( + `[onToolCallInProgress] No block ID found for tool ID: ${toolResponse.id}. Available mappings:`, + Array.from(toolCallIdToBlockIdMap.entries()) + ) } else { console.warn( `[onToolCallInProgress] Received unhandled tool status: ${toolResponse.status} for ID: ${toolResponse.id}` @@ -559,14 +583,17 @@ const fetchAndProcessAssistantResponseImpl = async ( onToolCallComplete: (toolResponse: MCPToolResponse) => { const existingBlockId = toolCallIdToBlockIdMap.get(toolResponse.id) toolCallIdToBlockIdMap.delete(toolResponse.id) - if (toolResponse.status === 'done' || toolResponse.status === 'error') { + if (toolResponse.status === 'done' || toolResponse.status === 'error' || toolResponse.status === 'cancelled') { if (!existingBlockId) { console.error( `[onToolCallComplete] No existing block found for completed/error tool call ID: ${toolResponse.id}. Cannot update.` ) return } - const finalStatus = toolResponse.status === 'done' ? MessageBlockStatus.SUCCESS : MessageBlockStatus.ERROR + const finalStatus = + toolResponse.status === 'done' || toolResponse.status === 'cancelled' + ? MessageBlockStatus.SUCCESS + : MessageBlockStatus.ERROR const changes: Partial = { content: toolResponse.response, status: finalStatus, @@ -583,6 +610,7 @@ const fetchAndProcessAssistantResponseImpl = async ( `[onToolCallComplete] Received unhandled tool status: ${toolResponse.status} for ID: ${toolResponse.id}` ) } + toolBlockId = null }, onExternalToolInProgress: async () => { const citationBlock = createCitationBlock(assistantMsgId, {}, { status: MessageBlockStatus.PROCESSING }) @@ -762,7 +790,14 @@ const fetchAndProcessAssistantResponseImpl = async ( }) } const possibleBlockId = - mainTextBlockId || thinkingBlockId || toolBlockId || imageBlockId || citationBlockId || lastBlockId + mainTextBlockId || + thinkingBlockId || + toolBlockId || + imageBlockId || + citationBlockId || + initialPlaceholderBlockId || + lastBlockId + if (possibleBlockId) { // 更改上一个block的状态为ERROR const changes: Partial = { @@ -801,7 +836,13 @@ const fetchAndProcessAssistantResponseImpl = async ( const finalContextWithAssistant = [...contextForUsage, finalAssistantMsg] const possibleBlockId = - mainTextBlockId || thinkingBlockId || toolBlockId || imageBlockId || citationBlockId || lastBlockId + mainTextBlockId || + thinkingBlockId || + toolBlockId || + imageBlockId || + citationBlockId || + initialPlaceholderBlockId || + lastBlockId if (possibleBlockId) { const changes: Partial = { status: MessageBlockStatus.SUCCESS @@ -1109,7 +1150,6 @@ export const resendMessageThunk = // 没有相关的助手消息就创建一个或多个 if (userMessageToResend?.mentions?.length) { - console.log('userMessageToResend.mentions', userMessageToResend.mentions) for (const mention of userMessageToResend.mentions) { const assistantMessage = createAssistantMessage(assistant.id, topicId, { askId: userMessageToResend.id, diff --git a/src/renderer/src/types/chunk.ts b/src/renderer/src/types/chunk.ts index f079677a1d..c5e84a4673 100644 --- a/src/renderer/src/types/chunk.ts +++ b/src/renderer/src/types/chunk.ts @@ -13,6 +13,7 @@ export enum ChunkType { KNOWLEDGE_SEARCH_IN_PROGRESS = 'knowledge_search_in_progress', KNOWLEDGE_SEARCH_COMPLETE = 'knowledge_search_complete', MCP_TOOL_CREATED = 'mcp_tool_created', + MCP_TOOL_PENDING = 'mcp_tool_pending', MCP_TOOL_IN_PROGRESS = 'mcp_tool_in_progress', MCP_TOOL_COMPLETE = 'mcp_tool_complete', EXTERNEL_TOOL_COMPLETE = 'externel_tool_complete', @@ -260,6 +261,11 @@ export interface MCPToolCreatedChunk { tool_use_responses?: ToolUseResponse[] // 工具使用响应 } +export interface MCPToolPendingChunk { + type: ChunkType.MCP_TOOL_PENDING + responses: MCPToolResponse[] +} + export interface MCPToolInProgressChunk { /** * The type of the chunk @@ -353,6 +359,7 @@ export type Chunk = | KnowledgeSearchInProgressChunk // 知识库搜索进行中 | KnowledgeSearchCompleteChunk // 知识库搜索完成 | MCPToolCreatedChunk // MCP工具被大模型创建 + | MCPToolPendingChunk // MCP工具调用等待中 | MCPToolInProgressChunk // MCP工具调用中 | MCPToolCompleteChunk // MCP工具调用完成 | ExternalToolCompleteChunk // 外部工具调用完成,外部工具包含搜索互联网,知识库,MCP服务器 diff --git a/src/renderer/src/types/index.ts b/src/renderer/src/types/index.ts index b90e41b79b..d85b4b2ac8 100644 --- a/src/renderer/src/types/index.ts +++ b/src/renderer/src/types/index.ts @@ -683,11 +683,13 @@ export interface MCPConfig { isBunInstalled: boolean } +export type MCPToolResponseStatus = 'pending' | 'cancelled' | 'invoking' | 'done' | 'error' + interface BaseToolResponse { id: string // unique id tool: MCPTool arguments: Record | undefined - status: string // 'invoking' | 'done' + status: MCPToolResponseStatus response?: any } diff --git a/src/renderer/src/utils/__tests__/tagExtraction.test.ts b/src/renderer/src/utils/__tests__/tagExtraction.test.ts new file mode 100644 index 0000000000..6533bda3f0 --- /dev/null +++ b/src/renderer/src/utils/__tests__/tagExtraction.test.ts @@ -0,0 +1,693 @@ +import { describe, expect, test } from 'vitest' + +import { TagConfig, TagExtractor } from '../tagExtraction' + +describe('TagExtractor', () => { + describe('基本标签提取', () => { + test('应该正确提取简单的标签内容', () => { + const config: TagConfig = { + openingTag: '', + closingTag: '' + } + const extractor = new TagExtractor(config) + + const results = extractor.processText('Hello World') + + expect(results).toHaveLength(2) + expect(results[0]).toEqual({ + content: 'Hello World', + isTagContent: true, + complete: false + }) + expect(results[1]).toEqual({ + content: '', + isTagContent: false, + complete: true, + tagContentExtracted: 'Hello World' + }) + }) + + test('应该处理标签前后的普通文本', () => { + const config: TagConfig = { + openingTag: '', + closingTag: '' + } + const extractor = new TagExtractor(config) + + const results = extractor.processText('前文思考内容后文') + + expect(results).toHaveLength(4) + expect(results[0]).toEqual({ + content: '前文', + isTagContent: false, + complete: false + }) + expect(results[1]).toEqual({ + content: '思考内容', + isTagContent: true, + complete: false + }) + expect(results[2]).toEqual({ + content: '', + isTagContent: false, + complete: true, + tagContentExtracted: '思考内容' + }) + expect(results[3]).toEqual({ + content: '后文', + isTagContent: false, + complete: false + }) + }) + + test('应该处理空标签', () => { + const config: TagConfig = { + openingTag: '', + closingTag: '' + } + const extractor = new TagExtractor(config) + + const results = extractor.processText('') + + expect(results).toHaveLength(0) + }) + }) + + describe('分块处理', () => { + test('应该正确处理分块的标签内容', () => { + const config: TagConfig = { + openingTag: '', + closingTag: '' + } + const extractor = new TagExtractor(config) + + let results = extractor.processText('第一') + expect(results).toHaveLength(1) + expect(results[0]).toEqual({ + content: '第一', + isTagContent: true, + complete: false + }) + + results = extractor.processText('部分内容') + expect(results).toHaveLength(1) + expect(results[0]).toEqual({ + content: '部分内容', + isTagContent: true, + complete: false + }) + + results = extractor.processText('') + expect(results).toHaveLength(1) + expect(results[0]).toEqual({ + content: '', + isTagContent: false, + complete: true, + tagContentExtracted: '第一部分内容' + }) + }) + + test('应该处理分块的开始标签', () => { + const config: TagConfig = { + openingTag: '', + closingTag: '' + } + const extractor = new TagExtractor(config) + + let results = extractor.processText('内容') + expect(results).toHaveLength(2) + expect(results[0]).toEqual({ + content: '内容', + isTagContent: true, + complete: false + }) + expect(results[1]).toEqual({ + content: '', + isTagContent: false, + complete: true, + tagContentExtracted: '内容' + }) + }) + + test('应该处理模拟可读流的分块数据', async () => { + const config: TagConfig = { + openingTag: '', + closingTag: '' + } + const extractor = new TagExtractor(config) + + // 模拟流式数据块 + const streamChunks = [ + '这是普通文本', + '这是第一个', + '思考内容', + '', + '中间的一些文本', + '第二', + '个思考内容', + '', + '结束文本' + ] + + const allResults: any[] = [] + + // 模拟异步流式处理 + for (const chunk of streamChunks) { + await new Promise((resolve) => setTimeout(resolve, 10)) // 模拟异步延迟 + const results = extractor.processText(chunk) + allResults.push(...results) + } + + // 验证结果 + expect(allResults).toHaveLength(9) + + // 第一个普通文本 + expect(allResults[0]).toEqual({ + content: '这是普通文本', + isTagContent: false, + complete: false + }) + + // 第一个思考标签内容 + expect(allResults[1]).toEqual({ + content: '这是第一个', + isTagContent: true, + complete: false + }) + + expect(allResults[2]).toEqual({ + content: '思考内容', + isTagContent: true, + complete: false + }) + + // 第一个完整的标签内容提取 + expect(allResults[3]).toEqual({ + content: '', + isTagContent: false, + complete: true, + tagContentExtracted: '这是第一个思考内容' + }) + + // 中间文本 + expect(allResults[4]).toEqual({ + content: '中间的一些文本', + isTagContent: false, + complete: false + }) + + // 第二个思考标签内容 + expect(allResults[5]).toEqual({ + content: '第二', + isTagContent: true, + complete: false + }) + + // 第二个完整的标签内容提取和结束文本 + expect(allResults[6]).toEqual({ + content: '个思考内容', + isTagContent: true, + complete: false + }) + + expect(allResults[7]).toEqual({ + content: '', + isTagContent: false, + complete: true, + tagContentExtracted: '第二个思考内容' + }) + + expect(allResults[8]).toEqual({ + content: '结束文本', + isTagContent: false, + complete: false + }) + }) + }) + + describe('多个标签处理', () => { + test('应该处理连续的多个标签', () => { + const config: TagConfig = { + openingTag: '', + closingTag: '' + } + const extractor = new TagExtractor(config) + + const results = extractor.processText('第一个第二个') + + expect(results).toHaveLength(4) + expect(results[0]).toEqual({ + content: '第一个', + isTagContent: true, + complete: false + }) + expect(results[1]).toEqual({ + content: '', + isTagContent: false, + complete: true, + tagContentExtracted: '第一个' + }) + expect(results[2]).toEqual({ + content: '第二个', + isTagContent: true, + complete: false + }) + expect(results[3]).toEqual({ + content: '', + isTagContent: false, + complete: true, + tagContentExtracted: '第二个' + }) + }) + + test('应该处理标签间的文本内容', () => { + const config: TagConfig = { + openingTag: '', + closingTag: '' + } + const extractor = new TagExtractor(config) + + const results = extractor.processText('思考1中间文本思考2') + + expect(results).toHaveLength(5) + expect(results[0]).toEqual({ + content: '思考1', + isTagContent: true, + complete: false + }) + expect(results[1]).toEqual({ + content: '', + isTagContent: false, + complete: true, + tagContentExtracted: '思考1' + }) + expect(results[2]).toEqual({ + content: '中间文本', + isTagContent: false, + complete: false + }) + expect(results[3]).toEqual({ + content: '思考2', + isTagContent: true, + complete: false + }) + expect(results[4]).toEqual({ + content: '', + isTagContent: false, + complete: true, + tagContentExtracted: '思考2' + }) + }) + + test('应该处理三个连续标签的分次输出', () => { + const config: TagConfig = { + openingTag: '', + closingTag: '' + } + const extractor = new TagExtractor(config) + + // 第一次输入:包含两个完整标签和第三个标签的开始 + let results = extractor.processText('第一个第二个第三个开始') + + expect(results).toHaveLength(5) + expect(results[0]).toEqual({ + content: '第一个', + isTagContent: true, + complete: false + }) + expect(results[1]).toEqual({ + content: '', + isTagContent: false, + complete: true, + tagContentExtracted: '第一个' + }) + expect(results[2]).toEqual({ + content: '第二个', + isTagContent: true, + complete: false + }) + expect(results[3]).toEqual({ + content: '', + isTagContent: false, + complete: true, + tagContentExtracted: '第二个' + }) + expect(results[4]).toEqual({ + content: '第三个开始', + isTagContent: true, + complete: false + }) + + // 第二次输入:继续第三个标签的内容 + results = extractor.processText('继续内容') + + expect(results).toHaveLength(1) + expect(results[0]).toEqual({ + content: '继续内容', + isTagContent: true, + complete: false + }) + + // 第三次输入:完成第三个标签 + results = extractor.processText('结束') + + expect(results).toHaveLength(2) + expect(results[0]).toEqual({ + content: '结束', + isTagContent: true, + complete: false + }) + expect(results[1]).toEqual({ + content: '', + isTagContent: false, + complete: true, + tagContentExtracted: '第三个开始继续内容结束' + }) + }) + + test('应该处理三个连续标签的另一种分次输出模式', () => { + const config: TagConfig = { + openingTag: '', + closingTag: '' + } + const extractor = new TagExtractor(config) + + // 第一次输入:第一个完整标签 + let results = extractor.processText('第一个思考') + + expect(results).toHaveLength(2) + expect(results[0]).toEqual({ + content: '第一个思考', + isTagContent: true, + complete: false + }) + expect(results[1]).toEqual({ + content: '', + isTagContent: false, + complete: true, + tagContentExtracted: '第一个思考' + }) + + // 第二次输入:第二个完整标签和第三个标签的部分内容 + results = extractor.processText('第二个思考第三个开') + + expect(results).toHaveLength(3) + expect(results[0]).toEqual({ + content: '第二个思考', + isTagContent: true, + complete: false + }) + expect(results[1]).toEqual({ + content: '', + isTagContent: false, + complete: true, + tagContentExtracted: '第二个思考' + }) + expect(results[2]).toEqual({ + content: '第三个开', + isTagContent: true, + complete: false + }) + + // 第三次输入:完成第三个标签 + results = extractor.processText('始部分') + + expect(results).toHaveLength(2) + expect(results[0]).toEqual({ + content: '始部分', + isTagContent: true, + complete: false + }) + expect(results[1]).toEqual({ + content: '', + isTagContent: false, + complete: true, + tagContentExtracted: '第三个开始部分' + }) + }) + }) + + describe('不完整标签处理', () => { + test('应该处理只有开始标签的情况', () => { + const config: TagConfig = { + openingTag: '', + closingTag: '' + } + const extractor = new TagExtractor(config) + + const results = extractor.processText('未完成的思考') + + expect(results).toHaveLength(1) + expect(results[0]).toEqual({ + content: '未完成的思考', + isTagContent: true, + complete: false + }) + }) + + test('应该处理文本中间截断的标签', () => { + const config: TagConfig = { + openingTag: '', + closingTag: '' + } + const extractor = new TagExtractor(config) + + const results = extractor.processText('正常文本 { + test('应该返回未完成的标签内容', () => { + const config: TagConfig = { + openingTag: '', + closingTag: '' + } + const extractor = new TagExtractor(config) + + extractor.processText('未完成的内容') + const result = extractor.finalize() + + expect(result).toEqual({ + content: '', + isTagContent: false, + complete: true, + tagContentExtracted: '未完成的内容' + }) + }) + + test('当没有未完成内容时应该返回 null', () => { + const config: TagConfig = { + openingTag: '', + closingTag: '' + } + const extractor = new TagExtractor(config) + + extractor.processText('完整内容') + const result = extractor.finalize() + + expect(result).toBeNull() + }) + + test('对于普通文本应该返回 null', () => { + const config: TagConfig = { + openingTag: '', + closingTag: '' + } + const extractor = new TagExtractor(config) + + extractor.processText('只是普通文本') + const result = extractor.finalize() + + expect(result).toBeNull() + }) + }) + + describe('reset 方法', () => { + test('应该重置所有内部状态', () => { + const config: TagConfig = { + openingTag: '', + closingTag: '' + } + const extractor = new TagExtractor(config) + + // 处理一些文本以改变内部状态 + extractor.processText('一些内容') + + // 重置 + extractor.reset() + + // 重置后应该能正常处理新的文本 + const results = extractor.processText('新内容') + + expect(results).toHaveLength(2) + expect(results[0]).toEqual({ + content: '新内容', + isTagContent: true, + complete: false + }) + expect(results[1]).toEqual({ + content: '', + isTagContent: false, + complete: true, + tagContentExtracted: '新内容' + }) + }) + + test('重置后 finalize 应该返回 null', () => { + const config: TagConfig = { + openingTag: '', + closingTag: '' + } + const extractor = new TagExtractor(config) + + extractor.processText('未完成') + extractor.reset() + + const result = extractor.finalize() + expect(result).toBeNull() + }) + }) + + describe('不同标签配置', () => { + test('应该处理工具使用标签', () => { + const config: TagConfig = { + openingTag: '', + closingTag: '' + } + const extractor = new TagExtractor(config) + + const results = extractor.processText('{"name": "search"}') + + expect(results).toHaveLength(2) + expect(results[0]).toEqual({ + content: '{"name": "search"}', + isTagContent: true, + complete: false + }) + expect(results[1]).toEqual({ + content: '', + isTagContent: false, + complete: true, + tagContentExtracted: '{"name": "search"}' + }) + }) + + test('应该处理自定义标签', () => { + const config: TagConfig = { + openingTag: '[START]', + closingTag: '[END]' + } + const extractor = new TagExtractor(config) + + const results = extractor.processText('前文[START]中间内容[END]后文') + + expect(results).toHaveLength(4) + expect(results[0]).toEqual({ + content: '前文', + isTagContent: false, + complete: false + }) + expect(results[1]).toEqual({ + content: '中间内容', + isTagContent: true, + complete: false + }) + expect(results[2]).toEqual({ + content: '', + isTagContent: false, + complete: true, + tagContentExtracted: '中间内容' + }) + expect(results[3]).toEqual({ + content: '后文', + isTagContent: false, + complete: false + }) + }) + }) + + describe('边界情况', () => { + test('应该处理空字符串输入', () => { + const config: TagConfig = { + openingTag: '', + closingTag: '' + } + const extractor = new TagExtractor(config) + + const results = extractor.processText('') + + expect(results).toHaveLength(0) + }) + + test('应该处理只包含标签的输入', () => { + const config: TagConfig = { + openingTag: '', + closingTag: '' + } + const extractor = new TagExtractor(config) + + const results = extractor.processText('') + + expect(results).toHaveLength(0) + }) + + test('应该处理标签内容包含相似文本的情况', () => { + const config: TagConfig = { + openingTag: '', + closingTag: '' + } + const extractor = new TagExtractor(config) + + const results = extractor.processText('我在思考') + + expect(results).toHaveLength(2) + expect(results[0]).toEqual({ + content: '我在思考', + isTagContent: true, + complete: false + }) + expect(results[1]).toEqual({ + content: '', + isTagContent: false, + complete: true, + tagContentExtracted: '我在思考' + }) + }) + + test('应该处理换行符和特殊字符', () => { + const config: TagConfig = { + openingTag: '', + closingTag: '' + } + const extractor = new TagExtractor(config) + + const content = '多行\n内容\t带制表符' + const results = extractor.processText(`${content}`) + + expect(results).toHaveLength(2) + expect(results[0]).toEqual({ + content: content, + isTagContent: true, + complete: false + }) + expect(results[1]).toEqual({ + content: '', + isTagContent: false, + complete: true, + tagContentExtracted: content + }) + }) + }) +}) diff --git a/src/renderer/src/utils/mcp-tools.ts b/src/renderer/src/utils/mcp-tools.ts index ecc5e4d433..b39d9b6bc3 100644 --- a/src/renderer/src/utils/mcp-tools.ts +++ b/src/renderer/src/utils/mcp-tools.ts @@ -14,9 +14,8 @@ import { Model, ToolUseResponse } from '@renderer/types' -import type { MCPToolCompleteChunk, MCPToolInProgressChunk } from '@renderer/types/chunk' +import type { MCPToolCompleteChunk, MCPToolInProgressChunk, MCPToolPendingChunk } from '@renderer/types/chunk' import { ChunkType } from '@renderer/types/chunk' -import { SdkMessageParam } from '@renderer/types/sdk' import { isArray, isObject, pull, transform } from 'lodash' import { nanoid } from 'nanoid' import OpenAI from 'openai' @@ -28,6 +27,7 @@ import { } from 'openai/resources' import { CompletionsParams } from '../aiCore/middleware/schemas' +import { requestToolConfirmation } from './userConfirmation' const MCP_AUTO_INSTALL_SERVER_NAME = '@cherry/mcp-auto-install' const EXTRA_SCHEMA_KEYS = ['schema', 'headers'] @@ -278,7 +278,8 @@ export async function callMCPTool(toolResponse: MCPToolResponse): Promise void + onChunk: (chunk: MCPToolPendingChunk | MCPToolInProgressChunk | MCPToolCompleteChunk) => void ) { const index = results.findIndex((ret) => ret.id === resp.id) let result = resp @@ -416,10 +417,29 @@ export function upsertMCPToolResponse( } else { results.push(resp) } - onChunk({ - type: resp.status === 'invoking' ? ChunkType.MCP_TOOL_IN_PROGRESS : ChunkType.MCP_TOOL_COMPLETE, - responses: [result] - }) + switch (resp.status) { + case 'pending': + onChunk({ + type: ChunkType.MCP_TOOL_PENDING, + responses: [result] + }) + break + case 'invoking': + onChunk({ + type: ChunkType.MCP_TOOL_IN_PROGRESS, + responses: [result] + }) + break + case 'cancelled': + case 'done': + onChunk({ + type: ChunkType.MCP_TOOL_COMPLETE, + responses: [result] + }) + break + default: + break + } } export function filterMCPTools( @@ -441,7 +461,7 @@ export function getMcpServerByTool(tool: MCPTool) { return servers.find((s) => s.id === tool.serverId) } -export function parseToolUse(content: string, mcpTools: MCPTool[]): ToolUseResponse[] { +export function parseToolUse(content: string, mcpTools: MCPTool[], startIdx: number = 0): ToolUseResponse[] { if (!content || !mcpTools || mcpTools.length === 0) { return [] } @@ -461,7 +481,7 @@ export function parseToolUse(content: string, mcpTools: MCPTool[]): ToolUseRespo /([\s\S]*?)([\s\S]*?)<\/name>([\s\S]*?)([\s\S]*?)<\/arguments>([\s\S]*?)<\/tool_use>/g const tools: ToolUseResponse[] = [] let match - let idx = 0 + let idx = startIdx // Find all tool use blocks while ((match = toolUsePattern.exec(contentToProcess)) !== null) { // const fullMatch = match[0] @@ -505,8 +525,9 @@ export async function parseAndCallTools( onChunk: CompletionsParams['onChunk'], convertToMessage: (mcpToolResponse: MCPToolResponse, resp: MCPCallToolResponse, model: Model) => R | undefined, model: Model, - mcpTools?: MCPTool[] -): Promise + mcpTools?: MCPTool[], + abortSignal?: AbortSignal +): Promise<{ toolResults: R[]; confirmedToolResponses: MCPToolResponse[] }> export async function parseAndCallTools( content: string, @@ -514,8 +535,9 @@ export async function parseAndCallTools( onChunk: CompletionsParams['onChunk'], convertToMessage: (mcpToolResponse: MCPToolResponse, resp: MCPCallToolResponse, model: Model) => R | undefined, model: Model, - mcpTools?: MCPTool[] -): Promise + mcpTools?: MCPTool[], + abortSignal?: AbortSignal +): Promise<{ toolResults: R[]; confirmedToolResponses: MCPToolResponse[] }> export async function parseAndCallTools( content: string | MCPToolResponse[], @@ -523,68 +545,172 @@ export async function parseAndCallTools( onChunk: CompletionsParams['onChunk'], convertToMessage: (mcpToolResponse: MCPToolResponse, resp: MCPCallToolResponse, model: Model) => R | undefined, model: Model, - mcpTools?: MCPTool[] -): Promise { + mcpTools?: MCPTool[], + abortSignal?: AbortSignal +): Promise<{ toolResults: R[]; confirmedToolResponses: MCPToolResponse[] }> { const toolResults: R[] = [] let curToolResponses: MCPToolResponse[] = [] if (Array.isArray(content)) { curToolResponses = content } else { // process tool use - curToolResponses = parseToolUse(content, mcpTools || []) + curToolResponses = parseToolUse(content, mcpTools || [], 0) } if (!curToolResponses || curToolResponses.length === 0) { - return toolResults + return { toolResults, confirmedToolResponses: [] } } - for (let i = 0; i < curToolResponses.length; i++) { - const toolResponse = curToolResponses[i] + + for (const toolResponse of curToolResponses) { upsertMCPToolResponse( allToolResponses, { ...toolResponse, - status: 'invoking' + status: 'pending' }, onChunk! ) } - const toolPromises = curToolResponses.map(async (toolResponse) => { - const images: string[] = [] - const toolCallResponse = await callMCPTool(toolResponse) - upsertMCPToolResponse( - allToolResponses, - { - ...toolResponse, - status: 'done', - response: toolCallResponse - }, - onChunk! - ) + // 创建工具确认Promise映射,并立即处理每个确认 + const confirmedTools: MCPToolResponse[] = [] + const pendingPromises: Promise[] = [] - for (const content of toolCallResponse.content) { - if (content.type === 'image' && content.data) { - images.push(`data:${content.mimeType};base64,${content.data}`) - } - } + curToolResponses.forEach((toolResponse) => { + const confirmationPromise = requestToolConfirmation(toolResponse.id, abortSignal) - if (images.length) { - onChunk?.({ - type: ChunkType.IMAGE_CREATED - }) - onChunk?.({ - type: ChunkType.IMAGE_COMPLETE, - image: { - type: 'base64', - images: images + const processingPromise = confirmationPromise + .then(async (confirmed) => { + if (confirmed) { + // 立即更新为invoking状态 + upsertMCPToolResponse( + allToolResponses, + { + ...toolResponse, + status: 'invoking' + }, + onChunk! + ) + + // 执行工具调用 + try { + const images: string[] = [] + const toolCallResponse = await callMCPTool(toolResponse) + + // 立即更新为done状态 + upsertMCPToolResponse( + allToolResponses, + { + ...toolResponse, + status: 'done', + response: toolCallResponse + }, + onChunk! + ) + + // 处理图片 + for (const content of toolCallResponse.content) { + if (content.type === 'image' && content.data) { + images.push(`data:${content.mimeType};base64,${content.data}`) + } + } + + if (images.length) { + onChunk?.({ + type: ChunkType.IMAGE_CREATED + }) + onChunk?.({ + type: ChunkType.IMAGE_COMPLETE, + image: { + type: 'base64', + images: images + } + }) + } + + // 转换消息并添加到结果 + const convertedMessage = convertToMessage(toolResponse, toolCallResponse, model) + if (convertedMessage) { + confirmedTools.push(toolResponse) + toolResults.push(convertedMessage) + } + } catch (error) { + Logger.error(`🔧 [MCP] Error executing tool ${toolResponse.id}:`, error) + // 更新为错误状态 + upsertMCPToolResponse( + allToolResponses, + { + ...toolResponse, + status: 'done', + response: { + isError: true, + content: [ + { + type: 'text', + text: `Error executing tool: ${error instanceof Error ? error.message : 'Unknown error'}` + } + ] + } + }, + onChunk! + ) + } + } else { + // 立即更新为cancelled状态 + upsertMCPToolResponse( + allToolResponses, + { + ...toolResponse, + status: 'cancelled', + response: { + isError: false, + content: [ + { + type: 'text', + text: 'Tool call cancelled by user.' + } + ] + } + }, + onChunk! + ) } }) - } + .catch((error) => { + Logger.error(`🔧 [MCP] Error waiting for tool confirmation ${toolResponse.id}:`, error) + // 立即更新为cancelled状态 + upsertMCPToolResponse( + allToolResponses, + { + ...toolResponse, + status: 'cancelled', + response: { + isError: true, + content: [ + { + type: 'text', + text: `Error in confirmation process: ${error instanceof Error ? error.message : 'Unknown error'}` + } + ] + } + }, + onChunk! + ) + }) - return convertToMessage(toolResponse, toolCallResponse, model) + pendingPromises.push(processingPromise) }) - toolResults.push(...(await Promise.all(toolPromises)).filter((t) => typeof t !== 'undefined')) - return toolResults + Logger.info( + `🔧 [MCP] Waiting for tool confirmations:`, + curToolResponses.map((t) => t.id) + ) + + // 等待所有工具处理完成(但每个工具的状态已经实时更新) + await Promise.all(pendingPromises) + + Logger.info(`🔧 [MCP] All tools processed. Confirmed tools: ${confirmedTools.length}`) + + return { toolResults, confirmedToolResponses: confirmedTools } } export function mcpToolCallResponseToOpenAICompatibleMessage( diff --git a/src/renderer/src/utils/prompt.ts b/src/renderer/src/utils/prompt.ts index 7ae0b7327f..288bcfec4a 100644 --- a/src/renderer/src/utils/prompt.ts +++ b/src/renderer/src/utils/prompt.ts @@ -2,7 +2,7 @@ import store from '@renderer/store' import { Assistant, MCPTool } from '@renderer/types' export const SYSTEM_PROMPT = `In this environment you have access to a set of tools you can use to answer the user's question. \ -You can use one tool per message, and will receive the result of that tool use in the user's response. You use tools step-by-step to accomplish a given task, with each tool use informed by the result of the previous tool use. +You can use one or more tools per message, and will receive the result of that tool use in the user's response. You use tools step-by-step to accomplish a given task, with each tool use informed by the result of the previous tool use. ## Tool Use Formatting diff --git a/src/renderer/src/utils/userConfirmation.ts b/src/renderer/src/utils/userConfirmation.ts new file mode 100644 index 0000000000..9b23f0466a --- /dev/null +++ b/src/renderer/src/utils/userConfirmation.ts @@ -0,0 +1,86 @@ +import Logger from '@renderer/config/logger' + +// 存储每个工具的确认Promise的resolve函数 +const toolConfirmResolvers = new Map void>() +// 存储每个工具的abort监听器清理函数 +const abortListeners = new Map void>() + +export function requestUserConfirmation(): Promise { + return new Promise((resolve) => { + const globalKey = '_global' + toolConfirmResolvers.set(globalKey, resolve) + }) +} + +export function requestToolConfirmation(toolId: string, abortSignal?: AbortSignal): Promise { + return new Promise((resolve) => { + if (abortSignal?.aborted) { + resolve(false) + return + } + + toolConfirmResolvers.set(toolId, resolve) + + if (abortSignal) { + const abortListener = () => { + const resolver = toolConfirmResolvers.get(toolId) + if (resolver) { + resolver(false) + toolConfirmResolvers.delete(toolId) + abortListeners.delete(toolId) + } + } + + abortSignal.addEventListener('abort', abortListener) + + // 存储清理函数 + const cleanup = () => { + abortSignal.removeEventListener('abort', abortListener) + abortListeners.delete(toolId) + } + abortListeners.set(toolId, cleanup) + } + }) +} + +export function confirmToolAction(toolId: string) { + const resolve = toolConfirmResolvers.get(toolId) + if (resolve) { + resolve(true) + toolConfirmResolvers.delete(toolId) + + // 清理abort监听器 + const cleanup = abortListeners.get(toolId) + if (cleanup) { + cleanup() + } + } else { + Logger.warn(`🔧 [userConfirmation] No resolver found for tool: ${toolId}`) + } +} + +export function cancelToolAction(toolId: string) { + const resolve = toolConfirmResolvers.get(toolId) + if (resolve) { + resolve(false) + toolConfirmResolvers.delete(toolId) + + // 清理abort监听器 + const cleanup = abortListeners.get(toolId) + if (cleanup) { + cleanup() + } + } else { + Logger.warn(`🔧 [userConfirmation] No resolver found for tool: ${toolId}`) + } +} + +// 获取所有待确认的工具ID +export function getPendingToolIds(): string[] { + return Array.from(toolConfirmResolvers.keys()).filter((id) => id !== '_global') +} + +// 检查某个工具是否在等待确认 +export function isToolPending(toolId: string): boolean { + return toolConfirmResolvers.has(toolId) +} diff --git a/yarn.lock b/yarn.lock index 4ff546f911..3514bc0850 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3756,11 +3756,11 @@ __metadata: languageName: node linkType: hard -"@modelcontextprotocol/sdk@npm:^1.11.4": - version: 1.11.4 - resolution: "@modelcontextprotocol/sdk@npm:1.11.4" +"@modelcontextprotocol/sdk@npm:^1.12.3": + version: 1.12.3 + resolution: "@modelcontextprotocol/sdk@npm:1.12.3" dependencies: - ajv: "npm:^8.17.1" + ajv: "npm:^6.12.6" content-type: "npm:^1.0.5" cors: "npm:^2.8.5" cross-spawn: "npm:^7.0.5" @@ -3771,7 +3771,7 @@ __metadata: raw-body: "npm:^3.0.0" zod: "npm:^3.23.8" zod-to-json-schema: "npm:^3.24.1" - checksum: 10c0/797694937e65ccc02e8dc63db711d9d96fbc49b49e6d246e6fed95d8d2bfe98ef203207224e39c9fc3b54da182da865a5d311ea06ef939c5c57ce0cd27c0f546 + checksum: 10c0/8bc0b91e596ec886efc64d68ae8474247647405f1a5ae407e02439c74c2a03528b3fbdce8f9352d9c2df54aa4548411e1aa1816ab3b09e045c2ff4202e2fd374 languageName: node linkType: hard @@ -7092,7 +7092,7 @@ __metadata: "@libsql/client": "npm:0.14.0" "@libsql/win32-x64-msvc": "npm:^0.4.7" "@mistralai/mistralai": "npm:^1.6.0" - "@modelcontextprotocol/sdk": "npm:^1.11.4" + "@modelcontextprotocol/sdk": "npm:^1.12.3" "@mozilla/readability": "npm:^0.6.0" "@notionhq/client": "npm:^2.2.15" "@playwright/test": "npm:^1.52.0" @@ -7349,7 +7349,7 @@ __metadata: languageName: node linkType: hard -"ajv@npm:^6.10.0, ajv@npm:^6.12.0, ajv@npm:^6.12.4": +"ajv@npm:^6.10.0, ajv@npm:^6.12.0, ajv@npm:^6.12.4, ajv@npm:^6.12.6": version: 6.12.6 resolution: "ajv@npm:6.12.6" dependencies: @@ -7361,7 +7361,7 @@ __metadata: languageName: node linkType: hard -"ajv@npm:^8.0.0, ajv@npm:^8.17.1, ajv@npm:^8.6.3": +"ajv@npm:^8.0.0, ajv@npm:^8.6.3": version: 8.17.1 resolution: "ajv@npm:8.17.1" dependencies: From de75992e7b089e02a3b29ed4b3a1c98df1812bf4 Mon Sep 17 00:00:00 2001 From: one Date: Tue, 8 Jul 2025 17:25:14 +0800 Subject: [PATCH 48/54] refactor(CodePreview): smoothing code highlighting on streaming (#7842) --- .../components/CodeBlockView/CodePreview.tsx | 68 ++++++++++++++----- 1 file changed, 50 insertions(+), 18 deletions(-) diff --git a/src/renderer/src/components/CodeBlockView/CodePreview.tsx b/src/renderer/src/components/CodeBlockView/CodePreview.tsx index 3a95a68cd8..3df9491e13 100644 --- a/src/renderer/src/components/CodeBlockView/CodePreview.tsx +++ b/src/renderer/src/components/CodeBlockView/CodePreview.tsx @@ -9,6 +9,7 @@ import { debounce } from 'lodash' import { ChevronsDownUp, ChevronsUpDown, Text as UnWrapIcon, WrapText as WrapIcon } from 'lucide-react' import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' +import { ThemedToken } from 'shiki/core' import styled from 'styled-components' interface CodePreviewProps { @@ -150,7 +151,8 @@ const CodePreview = ({ children, language, setTools }: CodePreviewProps) => { { '--gutter-width': `${gutterDigits}ch`, fontSize: `${fontSize - 1}px`, - maxHeight: shouldCollapse ? MAX_COLLAPSE_HEIGHT : undefined + maxHeight: shouldCollapse ? MAX_COLLAPSE_HEIGHT : undefined, + overflowY: shouldCollapse ? 'auto' : 'hidden' } as React.CSSProperties }>
    { CodePreview.displayName = 'CodePreview' +/** + * 补全代码行 tokens,把原始内容拼接到高亮内容之后,确保渲染出整行来。 + */ +function completeLineTokens(themedTokens: ThemedToken[], rawLine: string): ThemedToken[] { + // 如果出现空行,补一个空格保证行高 + if (rawLine.length === 0) { + return [ + { + content: ' ', + offset: 0, + color: 'inherit', + bgColor: 'inherit', + htmlStyle: { + opacity: '0.35' + } + } + ] + } + + const themedContent = themedTokens.map((token) => token.content).join('') + const extraContent = rawLine.slice(themedContent.length) + + // 已有内容已经全部高亮,直接返回 + if (!extraContent) return themedTokens + + // 补全剩余内容 + return [ + ...themedTokens, + { + content: extraContent, + offset: themedContent.length, + color: 'inherit', + bgColor: 'inherit', + htmlStyle: { + opacity: '0.35' + } + } + ] +} + interface VirtualizedRowData { rawLine: string - tokenLine?: any[] + tokenLine?: ThemedToken[] showLineNumbers: boolean } @@ -210,17 +252,11 @@ const VirtualizedRow = memo(
    {showLineNumbers && {index + 1}} - {tokenLine ? ( - // 渲染高亮后的内容 - tokenLine.map((token, tokenIndex) => ( - - {token.content} - - )) - ) : ( - // 渲染原始内容 - {rawLine || ' '} - )} + {completeLineTokens(tokenLine ?? [], rawLine).map((token, tokenIndex) => ( + + {token.content} + + ))}
    ) @@ -234,7 +270,7 @@ const ScrollContainer = styled.div<{ $lineHeight?: number }>` display: block; - overflow: auto; + overflow-x: auto; position: relative; border-radius: inherit; padding: 0.5em 1em; @@ -264,10 +300,6 @@ const ScrollContainer = styled.div<{ overflow-wrap: ${(props) => (props.$wrap ? 'break-word' : 'normal')}; } } - - .line-content-raw { - opacity: 0.35; - } } ` From a343377a43860b7cbd6d5f25c09e6efe01390d10 Mon Sep 17 00:00:00 2001 From: Calcium-Ion <61247483+Calcium-Ion@users.noreply.github.com> Date: Tue, 8 Jul 2025 17:27:53 +0800 Subject: [PATCH 49/54] feat: add painting support for NewAPI provider (#7905) * feat: add NewAPI painting support * fix(NewApiPage): update help link to point to the correct documentation * feat(NewApiPage): support image generation in API client * fix: resolve the issue of messy drawing data from aihubmix provider * feat: group model options in dropdown by category * fix: update translation to use LanguagesEnum --- .../src/aiCore/clients/NewAPIClient.ts | 2 +- src/renderer/src/config/endpointTypes.ts | 10 + src/renderer/src/hooks/usePaintings.ts | 6 + src/renderer/src/i18n/locales/en-us.json | 13 + src/renderer/src/i18n/locales/ja-jp.json | 13 + src/renderer/src/i18n/locales/ru-ru.json | 13 + src/renderer/src/i18n/locales/zh-cn.json | 13 + src/renderer/src/i18n/locales/zh-tw.json | 13 + .../src/pages/paintings/NewApiPage.tsx | 830 ++++++++++++++++++ .../pages/paintings/PaintingsRoutePage.tsx | 4 +- .../pages/paintings/config/NewApiConfig.ts | 47 + .../ProviderSettings/ModelEditContent.tsx | 11 +- .../ProviderSettings/NewApiAddModelPopup.tsx | 18 +- .../NewApiBatchAddModelPopup.tsx | 11 +- src/renderer/src/store/paintings.ts | 4 +- src/renderer/src/types/index.ts | 6 +- 16 files changed, 991 insertions(+), 23 deletions(-) create mode 100644 src/renderer/src/config/endpointTypes.ts create mode 100644 src/renderer/src/pages/paintings/NewApiPage.tsx create mode 100644 src/renderer/src/pages/paintings/config/NewApiConfig.ts diff --git a/src/renderer/src/aiCore/clients/NewAPIClient.ts b/src/renderer/src/aiCore/clients/NewAPIClient.ts index 3162cad0fe..769ca90acf 100644 --- a/src/renderer/src/aiCore/clients/NewAPIClient.ts +++ b/src/renderer/src/aiCore/clients/NewAPIClient.ts @@ -106,7 +106,7 @@ export class NewAPIClient extends BaseApiClient { return client } - if (model.endpoint_type === 'openai') { + if (model.endpoint_type === 'openai' || model.endpoint_type === 'image-generation') { const client = this.clients.get('openai') if (!client || !this.isValidClient(client)) { throw new Error('Failed to get openai client') diff --git a/src/renderer/src/config/endpointTypes.ts b/src/renderer/src/config/endpointTypes.ts new file mode 100644 index 0000000000..4340e5628f --- /dev/null +++ b/src/renderer/src/config/endpointTypes.ts @@ -0,0 +1,10 @@ +import { EndpointType } from '@renderer/types' + +export const endpointTypeOptions: { label: string; value: EndpointType }[] = [ + { value: 'openai', label: 'endpoint_type.openai' }, + { value: 'openai-response', label: 'endpoint_type.openai-response' }, + { value: 'anthropic', label: 'endpoint_type.anthropic' }, + { value: 'gemini', label: 'endpoint_type.gemini' }, + { value: 'image-generation', label: 'endpoint_type.image-generation' }, + { value: 'jina-rerank', label: 'endpoint_type.jina-rerank' } +] diff --git a/src/renderer/src/hooks/usePaintings.ts b/src/renderer/src/hooks/usePaintings.ts index 47528622f2..79f2277df6 100644 --- a/src/renderer/src/hooks/usePaintings.ts +++ b/src/renderer/src/hooks/usePaintings.ts @@ -11,6 +11,8 @@ export function usePaintings() { const upscale = useAppSelector((state) => state.paintings.upscale) const DMXAPIPaintings = useAppSelector((state) => state.paintings.DMXAPIPaintings) const tokenFluxPaintings = useAppSelector((state) => state.paintings.tokenFluxPaintings) + const openai_image_generate = useAppSelector((state) => state.paintings.openai_image_generate) + const openai_image_edit = useAppSelector((state) => state.paintings.openai_image_edit) const dispatch = useAppDispatch() return { @@ -24,6 +26,10 @@ export function usePaintings() { upscale, tokenFluxPaintings }, + newApiPaintings: { + openai_image_generate, + openai_image_edit + }, addPainting: (namespace: keyof PaintingsState, painting: PaintingAction) => { dispatch(addPainting({ namespace, painting })) return painting diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index d80fa4e2b6..8d6184b132 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -471,6 +471,14 @@ "messages": "Messages", "user": "User" }, + "endpoint_type": { + "openai": "OpenAI", + "openai-response": "OpenAI-Response", + "anthropic": "Anthropic", + "gemini": "Gemini", + "image-generation": "Image Generation", + "jina-rerank": "Jina Rerank" + }, "files": { "actions": "Actions", "all": "All Files", @@ -868,6 +876,8 @@ "title": "Ollama" }, "paintings": { + "no_image_generation_model": "No available image generation model, please add a model and set the endpoint type to {{endpoint_type}}", + "go_to_settings": "Go to Settings", "button.delete.image": "Delete Image", "button.delete.image.confirm": "Are you sure you want to delete this image?", "button.new.image": "New Image", @@ -941,6 +951,9 @@ "allow_adult": "Allow adult", "allow_none": "Not allowed" }, + "image_size_options": { + "auto": "Auto" + }, "quality": "Quality", "moderation": "Moderation", "background": "Background", diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index 5012193624..b3596d7b7a 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -471,6 +471,14 @@ "messages": "メッセージ", "user": "ユーザー" }, + "endpoint_type": { + "openai": "OpenAI", + "openai-response": "OpenAI-Response", + "anthropic": "Anthropic", + "gemini": "Gemini", + "image-generation": "画像生成", + "jina-rerank": "Jina Rerank" + }, "files": { "actions": "操作", "all": "すべてのファイル", @@ -868,6 +876,8 @@ "title": "Ollama" }, "paintings": { + "no_image_generation_model": "利用可能な画像生成モデルがありません。モデルを追加し、エンドポイントタイプを {{endpoint_type}} に設定してください", + "go_to_settings": "設定に移動", "button.delete.image": "画像を削除", "button.delete.image.confirm": "この画像を削除してもよろしいですか?", "button.new.image": "新しい画像", @@ -939,6 +949,9 @@ "allow_adult": "許可する", "allow_none": "許可しない" }, + "image_size_options": { + "auto": "自動" + }, "quality": "品質", "moderation": "敏感度", "background": "背景", diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index dc6010e5d8..48324a8c4f 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -471,6 +471,14 @@ "messages": "Сообщения", "user": "Пользователь" }, + "endpoint_type": { + "openai": "OpenAI", + "openai-response": "OpenAI-Response", + "anthropic": "Anthropic", + "gemini": "Gemini", + "image-generation": "Изображение", + "jina-rerank": "Jina Rerank" + }, "files": { "actions": "Действия", "all": "Все файлы", @@ -868,6 +876,8 @@ "title": "Ollama" }, "paintings": { + "no_image_generation_model": "Нет доступных моделей изображения, пожалуйста, добавьте модель и установите тип конечной точки на {{endpoint_type}}", + "go_to_settings": "Перейти в настройки", "button.delete.image": "Удалить изображение", "button.delete.image.confirm": "Вы уверены, что хотите удалить это изображение?", "button.new.image": "Новое изображение", @@ -940,6 +950,9 @@ "allow_adult": "Разрешено взрослые", "allow_none": "Не разрешено" }, + "image_size_options": { + "auto": "Авто" + }, "quality": "Качество", "moderation": "Сенсорность", "background": "Фон", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index b0651eab66..8c4ca4db4c 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -471,6 +471,14 @@ "messages": "消息数", "user": "用户" }, + "endpoint_type": { + "openai": "OpenAI", + "openai-response": "OpenAI-Response", + "anthropic": "Anthropic", + "gemini": "Gemini", + "image-generation": "图片生成", + "jina-rerank": "Jina 重排序" + }, "files": { "actions": "操作", "all": "所有文件", @@ -868,6 +876,8 @@ "title": "Ollama" }, "paintings": { + "no_image_generation_model": "暂无可用的图片生成模型,请先新增模型并设置端点类型为 {{endpoint_type}}", + "go_to_settings": "去设置", "button.delete.image": "删除图片", "button.delete.image.confirm": "确定要删除此图片吗?", "button.new.image": "新建图片", @@ -936,6 +946,9 @@ "allow_adult": "允许成人", "allow_none": "不允许" }, + "image_size_options": { + "auto": "自动" + }, "aspect_ratios": { "square": "方形", "portrait": "竖图", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 4272ef4467..d85ea3c5b6 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -471,6 +471,14 @@ "messages": "訊息數", "user": "使用者" }, + "endpoint_type": { + "openai": "OpenAI", + "openai-response": "OpenAI-Response", + "anthropic": "Anthropic", + "gemini": "Gemini", + "image-generation": "圖片生成", + "jina-rerank": "Jina Rerank" + }, "files": { "actions": "操作", "all": "所有檔案", @@ -868,6 +876,8 @@ "title": "Ollama" }, "paintings": { + "no_image_generation_model": "暫無可用的圖片生成模型,請先新增模型並設置端點類型為 {{endpoint_type}}", + "go_to_settings": "去設置", "button.delete.image": "刪除繪圖", "button.delete.image.confirm": "確定要刪除此繪圖嗎?", "button.new.image": "新繪圖", @@ -940,6 +950,9 @@ "allow_adult": "允許成人", "allow_none": "不允許" }, + "image_size_options": { + "auto": "自動" + }, "quality": "品質", "moderation": "敏感度", "background": "背景", diff --git a/src/renderer/src/pages/paintings/NewApiPage.tsx b/src/renderer/src/pages/paintings/NewApiPage.tsx new file mode 100644 index 0000000000..030c53d82e --- /dev/null +++ b/src/renderer/src/pages/paintings/NewApiPage.tsx @@ -0,0 +1,830 @@ +import { PlusOutlined } from '@ant-design/icons' +import AiProvider from '@renderer/aiCore' +import IcImageUp from '@renderer/assets/images/paintings/ic_ImageUp.svg' +import { Navbar, NavbarCenter, NavbarRight } from '@renderer/components/app/Navbar' +import Scrollbar from '@renderer/components/Scrollbar' +import TranslateButton from '@renderer/components/TranslateButton' +import { isMac } from '@renderer/config/constant' +import { getProviderLogo } from '@renderer/config/providers' +import { LanguagesEnum } from '@renderer/config/translate' +import { useTheme } from '@renderer/context/ThemeProvider' +import { usePaintings } from '@renderer/hooks/usePaintings' +import { useAllProviders } from '@renderer/hooks/useProvider' +import { useRuntime } from '@renderer/hooks/useRuntime' +import { useSettings } from '@renderer/hooks/useSettings' +import PaintingsList from '@renderer/pages/paintings/components/PaintingsList' +import { + DEFAULT_PAINTING, + getModelGroup, + MODELS, + SUPPORTED_MODELS +} from '@renderer/pages/paintings/config/NewApiConfig' +import FileManager from '@renderer/services/FileManager' +import { translateText } from '@renderer/services/TranslateService' +import { useAppDispatch } from '@renderer/store' +import { setGenerating } from '@renderer/store/runtime' +import type { PaintingAction, PaintingsState } from '@renderer/types' +import { FileMetadata } from '@renderer/types' +import { getErrorMessage, uuid } from '@renderer/utils' +import { Avatar, Button, Empty, InputNumber, Segmented, Select, Upload } from 'antd' +import TextArea from 'antd/es/input/TextArea' +import React, { FC } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useLocation, useNavigate } from 'react-router-dom' +import styled from 'styled-components' + +import SendMessageButton from '../home/Inputbar/SendMessageButton' +import { SettingHelpLink, SettingTitle } from '../settings' +import Artboard from './components/Artboard' + +const NewApiPage: FC<{ Options: string[] }> = ({ Options }) => { + const [mode, setMode] = useState('openai_image_generate') + const { addPainting, removePainting, updatePainting, newApiPaintings } = usePaintings() + const filteredPaintings = useMemo(() => newApiPaintings[mode] || [], [newApiPaintings, mode]) + const [painting, setPainting] = useState(filteredPaintings[0] || DEFAULT_PAINTING) + const [currentImageIndex, setCurrentImageIndex] = useState(0) + const [isLoading, setIsLoading] = useState(false) + const [abortController, setAbortController] = useState(null) + const [spaceClickCount, setSpaceClickCount] = useState(0) + const [isTranslating, setIsTranslating] = useState(false) + const [editImageFiles, setEditImageFiles] = useState([]) + + const { t } = useTranslation() + const { theme } = useTheme() + const providers = useAllProviders() + const providerOptions = Options.map((option) => { + const provider = providers.find((p) => p.id === option) + return { + label: t(`provider.${provider?.id}`), + value: provider?.id + } + }) + const dispatch = useAppDispatch() + const { generating } = useRuntime() + const navigate = useNavigate() + const location = useLocation() + const { autoTranslateWithSpace } = useSettings() + const spaceClickTimer = useRef(null) + const newApiProvider = providers.find((p) => p.id === 'new-api')! + + const modeOptions = [ + { label: t('paintings.mode.generate'), value: 'openai_image_generate' }, + { label: t('paintings.mode.edit'), value: 'openai_image_edit' } + ] + + const textareaRef = useRef(null) + + // 获取编辑模式的图片文件 + const editImages = useMemo(() => { + return editImageFiles + }, [editImageFiles]) + + const updatePaintingState = (updates: Partial) => { + const updatedPainting = { ...painting, ...updates } + setPainting(updatedPainting) + updatePainting(mode, updatedPainting) + } + + // ---------------- Model Related Configurations ---------------- + // const modelOptions = MODELS.map((m) => ({ label: m.name, value: m.name })) + + const modelOptions = useMemo(() => { + const customModels = newApiProvider.models + .filter((m) => m.endpoint_type && m.endpoint_type === 'image-generation') + .map((m) => ({ + label: m.name, + value: m.id, + custom: !SUPPORTED_MODELS.includes(m.id), + group: getModelGroup(m.id) + })) + return [...customModels] + }, [newApiProvider.models]) + + // 根据 group 将模型进行分组,便于在下拉列表中分组渲染 + const groupedModelOptions = useMemo(() => { + return modelOptions.reduce>((acc, option) => { + const groupName = option.group + if (!acc[groupName]) { + acc[groupName] = [] + } + acc[groupName].push(option) + return acc + }, {}) + }, [modelOptions]) + + const getNewPainting = useCallback(() => { + return { + ...DEFAULT_PAINTING, + model: painting.model || modelOptions[0]?.value || '', + id: uuid() + } + }, [modelOptions, painting.model]) + + const selectedModelConfig = useMemo( + () => MODELS.find((m) => m.name === painting.model) || MODELS[0], + [painting.model] + ) + + const handleModelChange = (value: string) => { + const modelConfig = MODELS.find((m) => m.name === value) + const updates: Partial = { model: value } + + // 设置默认值 + if (modelConfig?.imageSizes?.length) { + updates.size = modelConfig.imageSizes[0].value + } + if (modelConfig?.quality?.length) { + updates.quality = modelConfig.quality[0].value + } + if (modelConfig?.moderation?.length) { + updates.moderation = modelConfig.moderation[0].value + } + updates.n = 1 + updatePaintingState(updates) + } + + const handleSizeChange = (value: string) => { + updatePaintingState({ size: value }) + } + + const handleQualityChange = (value: string) => { + updatePaintingState({ quality: value }) + } + + const handleModerationChange = (value: string) => { + updatePaintingState({ moderation: value }) + } + + const handleNChange = (value: number | string | null) => { + if (value !== null && value !== undefined && value !== '') { + updatePaintingState({ n: Number(value) }) + } + } + + const handleError = (error: unknown) => { + if (error instanceof Error && error.name !== 'AbortError') { + window.modal.error({ + content: getErrorMessage(error), + centered: true + }) + } + } + + const downloadImages = async (urls: string[]) => { + const downloadedFiles = await Promise.all( + urls.map(async (url) => { + try { + if (!url?.trim()) { + console.error('图像URL为空') + window.message.warning({ + content: t('message.empty_url'), + key: 'empty-url-warning' + }) + return null + } + return await window.api.file.download(url) + } catch (error) { + console.error('下载图像失败:', error) + if ( + error instanceof Error && + (error.message.includes('Failed to parse URL') || error.message.includes('Invalid URL')) + ) { + window.message.warning({ + content: t('message.empty_url'), + key: 'empty-url-warning' + }) + } + return null + } + }) + ) + + return downloadedFiles.filter((file): file is FileMetadata => file !== null) + } + + const onGenerate = async () => { + if (painting.files.length > 0) { + const confirmed = await window.modal.confirm({ + content: t('paintings.regenerate.confirm'), + centered: true + }) + + if (!confirmed) return + await FileManager.deleteFiles(painting.files) + } + + const prompt = textareaRef.current?.resizableTextArea?.textArea?.value || '' + updatePaintingState({ prompt }) + + if (!newApiProvider.enabled) { + window.modal.error({ + content: t('error.provider_disabled'), + centered: true + }) + return + } + + const AI = new AiProvider(newApiProvider) + + if (!AI.getApiKey()) { + window.modal.error({ + content: t('error.no_api_key'), + centered: true + }) + return + } + + if (!painting.model || !painting.prompt) { + return + } + + const controller = new AbortController() + setAbortController(controller) + setIsLoading(true) + dispatch(setGenerating(true)) + + let body: string | FormData = '' + const headers: Record = { + Authorization: `Bearer ${AI.getApiKey()}` + } + const url = newApiProvider.apiHost + `/v1/images/generations` + const editUrl = newApiProvider.apiHost + `/v1/images/edits` + + try { + if (mode === 'openai_image_generate') { + const requestData = { + prompt, + model: painting.model, + size: painting.size === 'auto' ? undefined : painting.size, + background: painting.background === 'auto' ? undefined : painting.background, + n: painting.n, + quality: painting.quality === 'auto' ? undefined : painting.quality, + moderation: painting.moderation === 'auto' ? undefined : painting.moderation + } + + body = JSON.stringify(requestData) + headers['Content-Type'] = 'application/json' + } else if (mode === 'openai_image_edit') { + // -------- Edit Mode -------- + if (editImages.length === 0) { + window.message.warning({ content: t('paintings.image_file_required') }) + return + } + + const formData = new FormData() + formData.append('prompt', prompt) + if (painting.background && painting.background !== 'auto') { + formData.append('background', painting.background) + } + + if (painting.size && painting.size !== 'auto') { + formData.append('size', painting.size) + } + + if (painting.quality && painting.quality !== 'auto') { + formData.append('quality', painting.quality) + } + + if (painting.moderation && painting.moderation !== 'auto') { + formData.append('moderation', painting.moderation) + } + + // append images + editImages.forEach((file) => { + formData.append('image', file) + }) + + // TODO: mask support later + + body = formData + + // For edit mode we do not set content-type; browser will set multipart boundary + } + + const requestUrl = mode === 'openai_image_edit' ? editUrl : url + const response = await fetch(requestUrl, { method: 'POST', headers, body }) + + if (!response.ok) { + const errorData = await response.json() + throw new Error(errorData.error?.message || '生成图像失败') + } + + const data = await response.json() + const urls = data.data.filter((item) => item.url).map((item) => item.url) + const base64s = data.data.filter((item) => item.b64_json).map((item) => item.b64_json) + + if (urls.length > 0) { + const validFiles = await downloadImages(urls) + await FileManager.addFiles(validFiles) + updatePaintingState({ files: validFiles, urls }) + } + + if (base64s?.length > 0) { + const validFiles = await Promise.all( + base64s.map(async (base64) => { + return await window.api.file.saveBase64Image(base64) + }) + ) + await FileManager.addFiles(validFiles) + updatePaintingState({ files: validFiles, urls: validFiles.map((file) => file.name) }) + } + } catch (error: unknown) { + handleError(error) + } finally { + setIsLoading(false) + dispatch(setGenerating(false)) + setAbortController(null) + } + } + + const handleRetry = async (painting: PaintingAction) => { + setIsLoading(true) + try { + const validFiles = await downloadImages(painting.urls) + await FileManager.addFiles(validFiles) + updatePaintingState({ files: validFiles, urls: painting.urls }) + } catch (error) { + handleError(error) + } finally { + setIsLoading(false) + } + } + + const onCancel = () => { + abortController?.abort() + } + + const nextImage = () => { + setCurrentImageIndex((prev) => (prev + 1) % painting.files.length) + } + + const prevImage = () => { + setCurrentImageIndex((prev) => (prev - 1 + painting.files.length) % painting.files.length) + } + + const handleAddPainting = () => { + const newPainting = addPainting(mode, getNewPainting()) + updatePainting(mode, newPainting) + setPainting(newPainting) + return newPainting + } + + const onDeletePainting = (paintingToDelete: PaintingAction) => { + if (paintingToDelete.id === painting.id) { + const currentIndex = filteredPaintings.findIndex((p) => p.id === paintingToDelete.id) + + if (currentIndex > 0) { + setPainting(filteredPaintings[currentIndex - 1]) + } else if (filteredPaintings.length > 1) { + setPainting(filteredPaintings[1]) + } + } + + removePainting(mode, paintingToDelete) + } + + const translate = async () => { + if (isTranslating) { + return + } + + if (!painting.prompt) { + return + } + + try { + setIsTranslating(true) + const translatedText = await translateText(painting.prompt, LanguagesEnum.enUS) + updatePaintingState({ prompt: translatedText }) + } catch (error) { + console.error('Translation failed:', error) + } finally { + setIsTranslating(false) + } + } + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (autoTranslateWithSpace && event.key === ' ') { + setSpaceClickCount((prev) => prev + 1) + + if (spaceClickTimer.current) { + clearTimeout(spaceClickTimer.current) + } + + spaceClickTimer.current = setTimeout(() => { + setSpaceClickCount(0) + }, 200) + + if (spaceClickCount === 2) { + setSpaceClickCount(0) + setIsTranslating(true) + translate() + } + } + } + + const handleProviderChange = (providerId: string) => { + const routeName = location.pathname.split('/').pop() + if (providerId !== routeName) { + navigate('../' + providerId, { replace: true }) + } + } + + // 处理模式切换 + const handleModeChange = (value: string) => { + setMode(value as keyof PaintingsState) + if (newApiPaintings[value as keyof PaintingsState] && newApiPaintings[value as keyof PaintingsState].length > 0) { + setPainting(newApiPaintings[value as keyof PaintingsState][0]) + } else { + setPainting(DEFAULT_PAINTING) + } + } + + // 渲染配置项的函数 + const onSelectPainting = (newPainting: PaintingAction) => { + if (generating) return + setPainting(newPainting) + setCurrentImageIndex(0) + } + + const handleImageUpload = (file: File) => { + setEditImageFiles((prev) => [...prev, file]) + return false // 阻止默认上传行为 + } + + // 当 modelOptions 为空时,引导用户跳转到 Provider 设置页面,新增 image-generation 端点模型 + const handleShowAddModelPopup = () => { + navigate(`/settings/provider?id=${newApiProvider.id}`) + } + + useEffect(() => { + if (filteredPaintings.length === 0) { + const newPainting = getNewPainting() + addPainting(mode, newPainting) + setPainting(newPainting) + } + }, [filteredPaintings, mode, addPainting, painting, getNewPainting]) + + useEffect(() => { + const timer = spaceClickTimer.current + return () => { + if (timer) { + clearTimeout(timer) + } + } + }, []) + + return ( + + + {t('paintings.title')} + {isMac && ( + + + + )} + + + + + {t('common.provider')} + + {t('paintings.learn_more')} + + + + + + + {/* 当没有可用的 Image Generation 模型时,提示用户先去新增 */} + {modelOptions.length === 0 && ( + + + + )} + + {modelOptions.length > 0 && ( + <> + {mode === 'openai_image_edit' && ( + <> + {t('paintings.input_image')} + + + + + + + )} + + {/* Model Selector */} + {t('paintings.model')} + + + {/* Image Size */} + {selectedModelConfig?.imageSizes && selectedModelConfig.imageSizes.length > 0 && ( + <> + {t('paintings.image.size')} + + + )} + + {/* Quality */} + {selectedModelConfig?.quality && selectedModelConfig.quality.length > 0 && ( + <> + {t('paintings.quality')} + + + )} + + {/* Moderation */} + {mode !== 'openai_image_edit' && + selectedModelConfig?.moderation && + selectedModelConfig.moderation.length > 0 && ( + <> + {t('paintings.moderation')} + + + )} + + {/* Background */} + {mode === 'openai_image_edit' && + selectedModelConfig?.background && + selectedModelConfig.background.length > 0 && ( + <> + {t('paintings.background')} + + + )} + + {/* Number of Images (n) */} + {selectedModelConfig?.max_images && ( + <> + {t('paintings.number_images')} + + + )} + + )} + + + {/* 添加功能切换分段控制器 */} + + + + + +