From dcdd1bf852efa5661fd4606262a0257cc2515ab9 Mon Sep 17 00:00:00 2001 From: SuYao Date: Fri, 21 Nov 2025 09:55:46 +0800 Subject: [PATCH 1/6] refactor: replace renderToolContent function with ToolContent component for improved readability (#11300) * refactor: replace renderToolContent function with ToolContent component for improved readability * fix * fix test --- src/main/apiServer/routes/models.ts | 6 - src/main/apiServer/services/models.ts | 2 +- .../claudecode/__tests__/transform.test.ts | 193 ++++++++++++++++++ .../claudecode/claude-stream-state.ts | 18 +- .../agents/services/claudecode/index.ts | 17 -- .../agents/services/claudecode/transform.ts | 53 ++++- .../MessageAgentTools/BashOutputTool.tsx | 127 ++++++------ .../Tools/MessageAgentTools/ReadTool.tsx | 83 ++++---- .../MessageAgentTools/UnknownToolRenderer.tsx | 98 ++++----- .../Tools/MessageAgentTools/index.tsx | 31 ++- src/renderer/src/store/thunk/messageThunk.ts | 4 +- 11 files changed, 417 insertions(+), 215 deletions(-) diff --git a/src/main/apiServer/routes/models.ts b/src/main/apiServer/routes/models.ts index 8481e1ea59..d776d5ea91 100644 --- a/src/main/apiServer/routes/models.ts +++ b/src/main/apiServer/routes/models.ts @@ -104,12 +104,6 @@ const router = express logger.warn('No models available from providers', { filter }) } - logger.info('Models response ready', { - filter, - total: response.total, - modelIds: response.data.map((m) => m.id) - }) - return res.json(response satisfies ApiModelsResponse) } catch (error: any) { logger.error('Error fetching models', { error }) diff --git a/src/main/apiServer/services/models.ts b/src/main/apiServer/services/models.ts index a32d6d37dc..52f0db857f 100644 --- a/src/main/apiServer/services/models.ts +++ b/src/main/apiServer/services/models.ts @@ -32,7 +32,7 @@ export class ModelsService { for (const model of models) { const provider = providers.find((p) => p.id === model.provider) - logger.debug(`Processing model ${model.id}`) + // logger.debug(`Processing model ${model.id}`) if (!provider) { logger.debug(`Skipping model ${model.id} . Reason: Provider not found.`) continue diff --git a/src/main/services/agents/services/claudecode/__tests__/transform.test.ts b/src/main/services/agents/services/claudecode/__tests__/transform.test.ts index e38f897f2a..2565f5e605 100644 --- a/src/main/services/agents/services/claudecode/__tests__/transform.test.ts +++ b/src/main/services/agents/services/claudecode/__tests__/transform.test.ts @@ -21,6 +21,11 @@ describe('stripLocalCommandTags', () => { 'line1\nkeep\nError' expect(stripLocalCommandTags(input)).toBe('line1\nkeep\nError') }) + + it('if no tags present, returns original string', () => { + const input = 'just some normal text' + expect(stripLocalCommandTags(input)).toBe(input) + }) }) describe('Claude → AiSDK transform', () => { @@ -188,6 +193,111 @@ describe('Claude → AiSDK transform', () => { expect(toolResult.output).toBe('ok') }) + it('handles tool calls without streaming events (no content_block_start/stop)', () => { + const state = new ClaudeStreamState({ agentSessionId: '12344' }) + const parts: ReturnType[number][] = [] + + const messages: SDKMessage[] = [ + { + ...baseStreamMetadata, + type: 'assistant', + uuid: uuid(20), + message: { + id: 'msg-tool-no-stream', + type: 'message', + role: 'assistant', + model: 'claude-test', + content: [ + { + type: 'tool_use', + id: 'tool-read', + name: 'Read', + input: { file_path: '/test.txt' } + }, + { + type: 'tool_use', + id: 'tool-bash', + name: 'Bash', + input: { command: 'ls -la' } + } + ], + stop_reason: 'tool_use', + stop_sequence: null, + usage: { + input_tokens: 10, + output_tokens: 20 + } + } + } as unknown as SDKMessage, + { + ...baseStreamMetadata, + type: 'user', + uuid: uuid(21), + message: { + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'tool-read', + content: 'file contents', + is_error: false + } + ] + } + } as SDKMessage, + { + ...baseStreamMetadata, + type: 'user', + uuid: uuid(22), + message: { + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'tool-bash', + content: 'total 42\n...', + is_error: false + } + ] + } + } as SDKMessage + ] + + for (const message of messages) { + const transformed = transformSDKMessageToStreamParts(message, state) + parts.push(...transformed) + } + + const types = parts.map((part) => part.type) + expect(types).toEqual(['tool-call', 'tool-call', 'tool-result', 'tool-result']) + + const toolCalls = parts.filter((part) => part.type === 'tool-call') as Extract< + (typeof parts)[number], + { type: 'tool-call' } + >[] + expect(toolCalls).toHaveLength(2) + expect(toolCalls[0].toolName).toBe('Read') + expect(toolCalls[0].toolCallId).toBe('12344:tool-read') + expect(toolCalls[1].toolName).toBe('Bash') + expect(toolCalls[1].toolCallId).toBe('12344:tool-bash') + + const toolResults = parts.filter((part) => part.type === 'tool-result') as Extract< + (typeof parts)[number], + { type: 'tool-result' } + >[] + expect(toolResults).toHaveLength(2) + // This is the key assertion - toolName should NOT be 'unknown' + expect(toolResults[0].toolName).toBe('Read') + expect(toolResults[0].toolCallId).toBe('12344:tool-read') + expect(toolResults[0].input).toEqual({ file_path: '/test.txt' }) + expect(toolResults[0].output).toBe('file contents') + + expect(toolResults[1].toolName).toBe('Bash') + expect(toolResults[1].toolCallId).toBe('12344:tool-bash') + expect(toolResults[1].input).toEqual({ command: 'ls -la' }) + expect(toolResults[1].output).toBe('total 42\n...') + }) + it('handles streaming text completion', () => { const state = new ClaudeStreamState({ agentSessionId: baseStreamMetadata.session_id }) const parts: ReturnType[number][] = [] @@ -300,4 +410,87 @@ describe('Claude → AiSDK transform', () => { expect(finishStep.finishReason).toBe('stop') expect(finishStep.usage).toEqual({ inputTokens: 2, outputTokens: 4, totalTokens: 6 }) }) + + it('emits fallback text when Claude sends a snapshot instead of deltas', () => { + const state = new ClaudeStreamState({ agentSessionId: '12344' }) + const parts: ReturnType[number][] = [] + + const messages: SDKMessage[] = [ + { + ...baseStreamMetadata, + type: 'stream_event', + uuid: uuid(30), + event: { + type: 'message_start', + message: { + id: 'msg-fallback', + type: 'message', + role: 'assistant', + model: 'claude-test', + content: [], + stop_reason: null, + stop_sequence: null, + usage: {} + } + } + } as unknown as SDKMessage, + { + ...baseStreamMetadata, + type: 'stream_event', + uuid: uuid(31), + event: { + type: 'content_block_start', + index: 0, + content_block: { + type: 'text', + text: '' + } + } + } as unknown as SDKMessage, + { + ...baseStreamMetadata, + type: 'assistant', + uuid: uuid(32), + message: { + id: 'msg-fallback-content', + type: 'message', + role: 'assistant', + model: 'claude-test', + content: [ + { + type: 'text', + text: 'Final answer without streaming deltas.' + } + ], + stop_reason: 'end_turn', + stop_sequence: null, + usage: { + input_tokens: 3, + output_tokens: 7 + } + } + } as unknown as SDKMessage + ] + + for (const message of messages) { + const transformed = transformSDKMessageToStreamParts(message, state) + parts.push(...transformed) + } + + const types = parts.map((part) => part.type) + expect(types).toEqual(['start-step', 'text-start', 'text-delta', 'text-end', 'finish-step']) + + const delta = parts.find((part) => part.type === 'text-delta') as Extract< + (typeof parts)[number], + { type: 'text-delta' } + > + expect(delta.text).toBe('Final answer without streaming deltas.') + + const finish = parts.find((part) => part.type === 'finish-step') as Extract< + (typeof parts)[number], + { type: 'finish-step' } + > + expect(finish.usage).toEqual({ inputTokens: 3, outputTokens: 7, totalTokens: 10 }) + expect(finish.finishReason).toBe('stop') + }) }) diff --git a/src/main/services/agents/services/claudecode/claude-stream-state.ts b/src/main/services/agents/services/claudecode/claude-stream-state.ts index 19a664a8d1..30b5790c82 100644 --- a/src/main/services/agents/services/claudecode/claude-stream-state.ts +++ b/src/main/services/agents/services/claudecode/claude-stream-state.ts @@ -153,6 +153,20 @@ export class ClaudeStreamState { return this.blocksByIndex.get(index) } + getFirstOpenTextBlock(): TextBlockState | undefined { + const candidates: TextBlockState[] = [] + for (const block of this.blocksByIndex.values()) { + if (block.kind === 'text') { + candidates.push(block) + } + } + if (candidates.length === 0) { + return undefined + } + candidates.sort((a, b) => a.index - b.index) + return candidates[0] + } + getToolBlockById(toolCallId: string): ToolBlockState | undefined { const index = this.toolIndexByNamespacedId.get(toolCallId) if (index === undefined) return undefined @@ -217,10 +231,10 @@ export class ClaudeStreamState { * Persists the final input payload for a tool block once the provider signals * completion so that downstream tool results can reference the original call. */ - completeToolBlock(toolCallId: string, input: unknown, providerMetadata?: ProviderMetadata): void { + completeToolBlock(toolCallId: string, toolName: string, input: unknown, providerMetadata?: ProviderMetadata): void { const block = this.getToolBlockByRawId(toolCallId) this.registerToolCall(toolCallId, { - toolName: block?.toolName ?? 'unknown', + toolName, input, providerMetadata }) diff --git a/src/main/services/agents/services/claudecode/index.ts b/src/main/services/agents/services/claudecode/index.ts index 327031d2f3..83d3e49311 100644 --- a/src/main/services/agents/services/claudecode/index.ts +++ b/src/main/services/agents/services/claudecode/index.ts @@ -414,23 +414,6 @@ class ClaudeCodeService implements AgentServiceInterface { } } - if (message.type === 'assistant' || message.type === 'user') { - logger.silly('claude response', { - message, - content: JSON.stringify(message.message.content) - }) - } else if (message.type === 'stream_event') { - // logger.silly('Claude stream event', { - // message, - // event: JSON.stringify(message.event) - // }) - } else { - logger.silly('Claude response', { - message, - event: JSON.stringify(message) - }) - } - const chunks = transformSDKMessageToStreamParts(message, streamState) for (const chunk of chunks) { stream.emit('data', { diff --git a/src/main/services/agents/services/claudecode/transform.ts b/src/main/services/agents/services/claudecode/transform.ts index cbd2f735d6..00be683ba8 100644 --- a/src/main/services/agents/services/claudecode/transform.ts +++ b/src/main/services/agents/services/claudecode/transform.ts @@ -110,7 +110,7 @@ const sdkMessageToProviderMetadata = (message: SDKMessage): ProviderMetadata => * blocks across calls so that incremental deltas can be correlated correctly. */ export function transformSDKMessageToStreamParts(sdkMessage: SDKMessage, state: ClaudeStreamState): AgentStreamPart[] { - logger.silly('Transforming SDKMessage', { message: sdkMessage }) + logger.silly('Transforming SDKMessage', { message: JSON.stringify(sdkMessage) }) switch (sdkMessage.type) { case 'assistant': return handleAssistantMessage(sdkMessage, state) @@ -186,14 +186,13 @@ function handleAssistantMessage( for (const block of content) { switch (block.type) { - case 'text': - if (!isStreamingActive) { - const sanitizedText = stripLocalCommandTags(block.text) - if (sanitizedText) { - textBlocks.push(sanitizedText) - } + case 'text': { + const sanitizedText = stripLocalCommandTags(block.text) + if (sanitizedText) { + textBlocks.push(sanitizedText) } break + } case 'tool_use': handleAssistantToolUse(block as ToolUseContent, providerMetadata, state, chunks) break @@ -203,7 +202,16 @@ function handleAssistantMessage( } } - if (!isStreamingActive && textBlocks.length > 0) { + if (textBlocks.length === 0) { + return chunks + } + + const combinedText = textBlocks.join('') + if (!combinedText) { + return chunks + } + + if (!isStreamingActive) { const id = message.uuid?.toString() || generateMessageId() state.beginStep() chunks.push({ @@ -219,7 +227,7 @@ function handleAssistantMessage( chunks.push({ type: 'text-delta', id, - text: textBlocks.join(''), + text: combinedText, providerMetadata }) chunks.push({ @@ -230,7 +238,27 @@ function handleAssistantMessage( return finalizeNonStreamingStep(message, state, chunks) } - return chunks + const existingTextBlock = state.getFirstOpenTextBlock() + const fallbackId = existingTextBlock?.id || message.uuid?.toString() || generateMessageId() + if (!existingTextBlock) { + chunks.push({ + type: 'text-start', + id: fallbackId, + providerMetadata + }) + } + chunks.push({ + type: 'text-delta', + id: fallbackId, + text: combinedText, + providerMetadata + }) + chunks.push({ + type: 'text-end', + id: fallbackId, + providerMetadata + }) + return finalizeNonStreamingStep(message, state, chunks) } /** @@ -252,7 +280,7 @@ function handleAssistantToolUse( providerExecuted: true, providerMetadata }) - state.completeToolBlock(block.id, block.input, providerMetadata) + state.completeToolBlock(block.id, block.name, block.input, providerMetadata) } /** @@ -459,6 +487,9 @@ function handleStreamEvent( } case 'message_stop': { + if (!state.hasActiveStep()) { + break + } const pending = state.getPendingUsage() chunks.push({ type: 'finish-step', diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/BashOutputTool.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/BashOutputTool.tsx index 71fa6307d2..b47bb3f64a 100644 --- a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/BashOutputTool.tsx +++ b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/BashOutputTool.tsx @@ -1,7 +1,6 @@ import type { CollapseProps } from 'antd' import { Tag } from 'antd' import { CheckCircle, Terminal, XCircle } from 'lucide-react' -import { useMemo } from 'react' import { ToolTitle } from './GenericTools' import type { BashOutputToolInput, BashOutputToolOutput } from './types' @@ -16,6 +15,63 @@ interface ParsedBashOutput { tool_use_error?: string } +const parseBashOutput = (output?: BashOutputToolOutput): ParsedBashOutput | null => { + if (!output) return null + + try { + const parser = new DOMParser() + const hasToolError = output.includes('') + const xmlStr = output.includes('') || hasToolError ? `${output}` : output + const xmlDoc = parser.parseFromString(xmlStr, 'application/xml') + const parserError = xmlDoc.querySelector('parsererror') + if (parserError) return null + + const getElementText = (tagName: string): string | undefined => { + const element = xmlDoc.getElementsByTagName(tagName)[0] + return element?.textContent?.trim() + } + + return { + status: getElementText('status'), + exit_code: getElementText('exit_code') ? parseInt(getElementText('exit_code')!) : undefined, + stdout: getElementText('stdout'), + stderr: getElementText('stderr'), + timestamp: getElementText('timestamp'), + tool_use_error: getElementText('tool_use_error') + } + } catch { + return null + } +} + +const getStatusConfig = (parsedOutput: ParsedBashOutput | null) => { + if (!parsedOutput) return null + + if (parsedOutput.tool_use_error) { + return { + color: 'danger', + icon: , + text: 'Error' + } as const + } + + const isCompleted = parsedOutput.status === 'completed' + const isSuccess = parsedOutput.exit_code === 0 + + return { + color: isCompleted && isSuccess ? 'success' : isCompleted && !isSuccess ? 'danger' : 'warning', + icon: + isCompleted && isSuccess ? ( + + ) : isCompleted && !isSuccess ? ( + + ) : ( + + ), + text: isCompleted ? (isSuccess ? 'Success' : 'Failed') : 'Running' + } as const +} + export function BashOutputTool({ input, output @@ -23,73 +79,8 @@ export function BashOutputTool({ input: BashOutputToolInput output?: BashOutputToolOutput }): NonNullable[number] { - // 解析 XML 输出 - const parsedOutput = useMemo(() => { - if (!output) return null - - try { - const parser = new DOMParser() - // 检查是否包含 tool_use_error 标签 - const hasToolError = output.includes('') - // 包装成有效的 XML(如果还没有根元素) - const xmlStr = output.includes('') || hasToolError ? `${output}` : output - const xmlDoc = parser.parseFromString(xmlStr, 'application/xml') - - // 检查是否有解析错误 - const parserError = xmlDoc.querySelector('parsererror') - if (parserError) { - return null - } - - const getElementText = (tagName: string): string | undefined => { - const element = xmlDoc.getElementsByTagName(tagName)[0] - return element?.textContent?.trim() - } - - const result: ParsedBashOutput = { - status: getElementText('status'), - exit_code: getElementText('exit_code') ? parseInt(getElementText('exit_code')!) : undefined, - stdout: getElementText('stdout'), - stderr: getElementText('stderr'), - timestamp: getElementText('timestamp'), - tool_use_error: getElementText('tool_use_error') - } - - return result - } catch { - return null - } - }, [output]) - - // 获取状态配置 - const statusConfig = useMemo(() => { - if (!parsedOutput) return null - - // 如果有 tool_use_error,直接显示错误状态 - if (parsedOutput.tool_use_error) { - return { - color: 'danger', - icon: , - text: 'Error' - } as const - } - - const isCompleted = parsedOutput.status === 'completed' - const isSuccess = parsedOutput.exit_code === 0 - - return { - color: isCompleted && isSuccess ? 'success' : isCompleted && !isSuccess ? 'danger' : 'warning', - icon: - isCompleted && isSuccess ? ( - - ) : isCompleted && !isSuccess ? ( - - ) : ( - - ), - text: isCompleted ? (isSuccess ? 'Success' : 'Failed') : 'Running' - } as const - }, [parsedOutput]) + const parsedOutput = parseBashOutput(output) + const statusConfig = getStatusConfig(parsedOutput) const children = parsedOutput ? (
diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/ReadTool.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/ReadTool.tsx index 0b665a9fd4..043d8a94c4 100644 --- a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/ReadTool.tsx +++ b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/ReadTool.tsx @@ -1,12 +1,47 @@ import type { CollapseProps } from 'antd' import { FileText } from 'lucide-react' -import { useMemo } from 'react' import ReactMarkdown from 'react-markdown' import { ToolTitle } from './GenericTools' import type { ReadToolInput as ReadToolInputType, ReadToolOutput as ReadToolOutputType, TextOutput } from './types' import { AgentToolsType } from './types' +const removeSystemReminderTags = (text: string): string => { + return text.replace(/[\s\S]*?<\/system-reminder>/gi, '') +} + +const normalizeOutputString = (output?: ReadToolOutputType): string | null => { + if (!output) return null + + const toText = (item: TextOutput) => removeSystemReminderTags(item.text) + + if (Array.isArray(output)) { + return output + .filter((item): item is TextOutput => item.type === 'text') + .map(toText) + .join('') + } + + return removeSystemReminderTags(output) +} + +const getOutputStats = (outputString: string | null) => { + if (!outputString) return null + + const bytes = new Blob([outputString]).size + const formatSize = (size: number) => { + if (size < 1024) return `${size} B` + if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB` + return `${(size / (1024 * 1024)).toFixed(1)} MB` + } + + return { + lineCount: outputString.split('\n').length, + fileSize: bytes, + formatSize + } +} + export function ReadTool({ input, output @@ -14,50 +49,8 @@ export function ReadTool({ input: ReadToolInputType output?: ReadToolOutputType }): NonNullable[number] { - // 移除 system-reminder 标签及其内容的辅助函数 - const removeSystemReminderTags = (text: string): string => { - // 使用正则表达式匹配 标签及其内容,包括换行符 - return text.replace(/[\s\S]*?<\/system-reminder>/gi, '') - } - - // 将 output 统一转换为字符串 - const outputString = useMemo(() => { - if (!output) return null - - let processedOutput: string - - // 如果是 TextOutput[] 类型,提取所有 text 内容 - if (Array.isArray(output)) { - processedOutput = output - .filter((item): item is TextOutput => item.type === 'text') - .map((item) => removeSystemReminderTags(item.text)) - .join('') - } else { - // 如果是字符串,直接使用 - processedOutput = output - } - - // 移除 system-reminder 标签及其内容 - return removeSystemReminderTags(processedOutput) - }, [output]) - - // 如果有输出,计算统计信息 - const stats = useMemo(() => { - if (!outputString) return null - - const bytes = new Blob([outputString]).size - const formatSize = (bytes: number) => { - if (bytes < 1024) return `${bytes} B` - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` - return `${(bytes / (1024 * 1024)).toFixed(1)} MB` - } - - return { - lineCount: outputString.split('\n').length, - fileSize: bytes, - formatSize - } - }, [outputString]) + const outputString = normalizeOutputString(output) + const stats = getOutputStats(outputString) return { key: AgentToolsType.Read, diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/UnknownToolRenderer.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/UnknownToolRenderer.tsx index 969eda9507..8a6965b6f6 100644 --- a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/UnknownToolRenderer.tsx +++ b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/UnknownToolRenderer.tsx @@ -11,11 +11,24 @@ interface UnknownToolProps { output?: unknown } -export function UnknownToolRenderer({ - toolName = '', - input, - output -}: UnknownToolProps): NonNullable[number] { +const getToolDisplayName = (name: string) => { + if (name.startsWith('mcp__')) { + const parts = name.substring(5).split('__') + if (parts.length >= 2) { + return `${parts[0]}:${parts.slice(1).join(':')}` + } + } + return name +} + +const getToolDescription = (toolName: string) => { + if (toolName.startsWith('mcp__')) { + return 'MCP Server Tool' + } + return 'Tool' +} + +const UnknownToolContent = ({ input, output }: { input?: unknown; output?: unknown }) => { const { highlightCode } = useCodeStyle() const [inputHtml, setInputHtml] = useState('') const [outputHtml, setOutputHtml] = useState('') @@ -34,58 +47,49 @@ export function UnknownToolRenderer({ } }, [output, highlightCode]) - const getToolDisplayName = (name: string) => { - if (name.startsWith('mcp__')) { - const parts = name.substring(5).split('__') - if (parts.length >= 2) { - return `${parts[0]}:${parts.slice(1).join(':')}` - } - } - return name + if (input === undefined && output === undefined) { + return
No data available for this tool
} - const getToolDescription = () => { - if (toolName.startsWith('mcp__')) { - return 'MCP Server Tool' - } - return 'Tool' - } + return ( +
+ {input !== undefined && ( +
+
Input:
+
+
+ )} + {output !== undefined && ( +
+
Output:
+
+
+ )} +
+ ) +} + +export function UnknownToolRenderer({ + toolName = '', + input, + output +}: UnknownToolProps): NonNullable[number] { return { key: 'unknown-tool', label: ( } label={getToolDisplayName(toolName)} - params={getToolDescription()} + params={getToolDescription(toolName)} /> ), - children: ( -
- {input !== undefined && ( -
-
Input:
-
-
- )} - - {output !== undefined && ( -
-
Output:
-
-
- )} - - {input === undefined && output === undefined && ( -
No data available for this tool
- )} -
- ) + children: } } diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/index.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/index.tsx index 27abf7426c..42a1cf403b 100644 --- a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/index.tsx +++ b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/index.tsx @@ -6,8 +6,6 @@ import { Collapse } from 'antd' // 导出所有类型 export * from './types' -import { useMemo } from 'react' - // 导入所有渲染器 import ToolPermissionRequestCard from '../ToolPermissionRequestCard' import { BashOutputTool } from './BashOutputTool' @@ -57,22 +55,19 @@ export function isValidAgentToolsType(toolName: unknown): toolName is AgentTools return typeof toolName === 'string' && Object.values(AgentToolsType).includes(toolName as AgentToolsType) } -// 统一的渲染函数 -function renderToolContent(toolName: AgentToolsType, input: ToolInput, output?: ToolOutput) { +// 统一的渲染组件 +function ToolContent({ toolName, input, output }: { toolName: AgentToolsType; input: ToolInput; output?: ToolOutput }) { const Renderer = toolRenderers[toolName] + const renderedItem = Renderer + ? Renderer({ input: input as any, output: output as any }) + : UnknownToolRenderer({ input: input as any, output: output as any, toolName }) - // eslint-disable-next-line react-hooks/rules-of-hooks - const toolContentItem = useMemo(() => { - const rendered = Renderer - ? Renderer({ input: input as any, output: output as any }) - : UnknownToolRenderer({ input: input as any, output: output as any, toolName }) - return { - ...rendered, - classNames: { - body: 'bg-foreground-50 p-2 text-foreground-900 dark:bg-foreground-100 max-h-96 p-2 overflow-scroll' - } as NonNullable[number]['classNames'] - } as NonNullable[number] - }, [Renderer, input, output, toolName]) + const toolContentItem: NonNullable[number] = { + ...renderedItem, + classNames: { + body: 'bg-foreground-50 p-2 text-foreground-900 dark:bg-foreground-100 max-h-96 p-2 overflow-scroll' + } + } return ( } - return renderToolContent(tool.name as AgentToolsType, args as ToolInput, response as ToolOutput) + return ( + + ) } diff --git a/src/renderer/src/store/thunk/messageThunk.ts b/src/renderer/src/store/thunk/messageThunk.ts index c9b33eea8e..a70fdf572d 100644 --- a/src/renderer/src/store/thunk/messageThunk.ts +++ b/src/renderer/src/store/thunk/messageThunk.ts @@ -585,9 +585,11 @@ const fetchAndProcessAgentResponseImpl = async ( return } + // Only mark as cleared if there was a previous session ID (not initial assignment) + sessionWasCleared = !!latestAgentSessionId + latestAgentSessionId = sessionId agentSession.agentSessionId = sessionId - sessionWasCleared = true logger.debug(`Agent session ID updated`, { topicId, From eee49d1580655febb510a1d591a8fa48d7f63a05 Mon Sep 17 00:00:00 2001 From: Pleasure1234 <3196812536@qq.com> Date: Fri, 21 Nov 2025 06:58:47 +0000 Subject: [PATCH 2/6] feat: add ChatGPT conversation import feature (#11272) * feat: add ChatGPT conversation import feature Introduces a new import workflow for ChatGPT conversations, including UI components, service logic, and i18n support for English, Simplified Chinese, and Traditional Chinese. Adds an import menu to data settings, a popup for file selection and progress, and a service to parse and store imported conversations as topics and messages. * fix: ci failure * refactor: import service and add modular importers Refactored the import service to support a modular importer architecture. Moved ChatGPT import logic to a dedicated importer class and directory. Updated UI components and i18n descriptions for clarity. Removed unused Redux selector in ImportMenuSettings. This change enables easier addition of new importers in the future. * Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix: improve ChatGPT import UX and set model for assistant Added a loading state and spinner for file selection in the ChatGPT import popup, with new translations for the 'selecting' state in en-us, zh-cn, and zh-tw locales. Also, set the model property for imported assistant messages to display the GPT-5 logo. --------- Co-authored-by: SuYao Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../src/components/Popups/ImportPopup.tsx | 141 +++++++++ src/renderer/src/i18n/locales/en-us.json | 36 +++ src/renderer/src/i18n/locales/zh-cn.json | 36 +++ src/renderer/src/i18n/locales/zh-tw.json | 36 +++ .../settings/DataSettings/DataSettings.tsx | 12 +- .../DataSettings/ImportMenuSettings.tsx | 29 ++ .../src/services/import/ImportService.ts | 167 +++++++++++ .../import/importers/ChatGPTImporter.ts | 268 ++++++++++++++++++ .../src/services/import/importers/index.ts | 12 + src/renderer/src/services/import/index.ts | 3 + src/renderer/src/services/import/types.ts | 52 ++++ .../src/services/import/utils/database.ts | 34 +++ 12 files changed, 824 insertions(+), 2 deletions(-) create mode 100644 src/renderer/src/components/Popups/ImportPopup.tsx create mode 100644 src/renderer/src/pages/settings/DataSettings/ImportMenuSettings.tsx create mode 100644 src/renderer/src/services/import/ImportService.ts create mode 100644 src/renderer/src/services/import/importers/ChatGPTImporter.ts create mode 100644 src/renderer/src/services/import/importers/index.ts create mode 100644 src/renderer/src/services/import/index.ts create mode 100644 src/renderer/src/services/import/types.ts create mode 100644 src/renderer/src/services/import/utils/database.ts diff --git a/src/renderer/src/components/Popups/ImportPopup.tsx b/src/renderer/src/components/Popups/ImportPopup.tsx new file mode 100644 index 0000000000..cd7e8f1252 --- /dev/null +++ b/src/renderer/src/components/Popups/ImportPopup.tsx @@ -0,0 +1,141 @@ +import { importChatGPTConversations } from '@renderer/services/import' +import { Alert, Modal, Progress, Space, Spin } from 'antd' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' + +import { TopView } from '../TopView' + +interface PopupResult { + success?: boolean +} + +interface Props { + resolve: (data: PopupResult) => void +} + +const PopupContainer: React.FC = ({ resolve }) => { + const [open, setOpen] = useState(true) + const [selecting, setSelecting] = useState(false) + const [importing, setImporting] = useState(false) + const { t } = useTranslation() + + const onOk = async () => { + setSelecting(true) + try { + // Select ChatGPT JSON file + const file = await window.api.file.open({ + filters: [{ name: 'ChatGPT Conversations', extensions: ['json'] }] + }) + + setSelecting(false) + + if (!file) { + return + } + + setImporting(true) + + // Parse file content + const fileContent = typeof file.content === 'string' ? file.content : new TextDecoder().decode(file.content) + + // Import conversations + const result = await importChatGPTConversations(fileContent) + + if (result.success) { + window.toast.success( + t('import.chatgpt.success', { + topics: result.topicsCount, + messages: result.messagesCount + }) + ) + setOpen(false) + } else { + window.toast.error(result.error || t('import.chatgpt.error.unknown')) + } + } catch (error) { + window.toast.error(t('import.chatgpt.error.unknown')) + setOpen(false) + } finally { + setSelecting(false) + setImporting(false) + } + } + + const onCancel = () => { + setOpen(false) + } + + const onClose = () => { + resolve({}) + } + + ImportPopup.hide = onCancel + + return ( + + {!selecting && !importing && ( + +
{t('import.chatgpt.description')}
+ +

{t('import.chatgpt.help.step1')}

+

{t('import.chatgpt.help.step2')}

+

{t('import.chatgpt.help.step3')}

+
+ } + type="info" + showIcon + style={{ marginTop: 12 }} + /> + + )} + {selecting && ( +
+ +
{t('import.chatgpt.selecting')}
+
+ )} + {importing && ( +
+ +
{t('import.chatgpt.importing')}
+
+ )} + + ) +} + +const TopViewKey = 'ImportPopup' + +export default class ImportPopup { + static topviewId = 0 + static hide() { + TopView.hide(TopViewKey) + } + static show() { + return new Promise((resolve) => { + TopView.show( + { + resolve(v) + TopView.hide(TopViewKey) + }} + />, + TopViewKey + ) + }) + } +} diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index c1c0dc2620..e34c8e65b2 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -1376,6 +1376,36 @@ "preview": "Preview", "split": "Split" }, + "import": { + "chatgpt": { + "assistant_name": "ChatGPT Import", + "button": "Select File", + "description": "Only imports conversation text, does not include images and attachments", + "error": { + "invalid_json": "Invalid JSON file format", + "no_conversations": "No conversations found in file", + "no_valid_conversations": "No valid conversations to import", + "unknown": "Import failed, please check file format" + }, + "help": { + "step1": "1. Log in to ChatGPT, go to Settings > Data controls > Export data", + "step2": "2. Wait for the export file via email", + "step3": "3. Extract the downloaded file and find conversations.json", + "title": "How to export ChatGPT conversations?" + }, + "importing": "Importing conversations...", + "selecting": "Selecting file...", + "success": "Successfully imported {{topics}} conversations with {{messages}} messages", + "title": "Import ChatGPT Conversations", + "untitled_conversation": "Untitled Conversation" + }, + "confirm": { + "button": "Select Import File", + "label": "Are you sure you want to import external data?" + }, + "content": "Select external application conversation file to import, currently only supports ChatGPT JSON format files", + "title": "Import External Conversations" + }, "knowledge": { "add": { "title": "Add Knowledge Base" @@ -3085,6 +3115,7 @@ "basic": "Basic Data Settings", "cloud_storage": "Cloud Backup Settings", "export_settings": "Export Settings", + "import_settings": "Import Settings", "third_party": "Third-party Connections" }, "export_menu": { @@ -3143,6 +3174,11 @@ }, "hour_interval_one": "{{count}} hour", "hour_interval_other": "{{count}} hours", + "import_settings": { + "button": "Import Json File", + "chatgpt": "Import from ChatGPT", + "title": "Import Outside Application Data" + }, "joplin": { "check": { "button": "Check", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 2a1ee09688..ad9e94af9c 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -1376,6 +1376,36 @@ "preview": "预览", "split": "分屏" }, + "import": { + "chatgpt": { + "assistant_name": "ChatGPT 导入", + "button": "选择文件", + "description": "仅导入对话文字,不携带图片和附件", + "error": { + "invalid_json": "无效的 JSON 文件格式", + "no_conversations": "文件中未找到任何对话", + "no_valid_conversations": "没有可导入的有效对话", + "unknown": "导入失败,请检查文件格式" + }, + "help": { + "step1": "1. 登录 ChatGPT,进入设置 > 数据控制 > 导出数据", + "step2": "2. 等待邮件接收导出文件", + "step3": "3. 解压下载的文件,找到 conversations.json", + "title": "如何导出 ChatGPT 对话?" + }, + "importing": "正在导入对话...", + "selecting": "正在选择文件...", + "success": "成功导入 {{topics}} 个对话,共 {{messages}} 条消息", + "title": "导入 ChatGPT 对话", + "untitled_conversation": "未命名对话" + }, + "confirm": { + "button": "选择导入文件", + "label": "确定要导入外部数据吗?" + }, + "content": "选择要导入的外部应用对话文件,暂时仅支持ChatGPT的JSON格式文件", + "title": "导入外部对话" + }, "knowledge": { "add": { "title": "添加知识库" @@ -3085,6 +3115,7 @@ "basic": "基础数据设置", "cloud_storage": "云备份设置", "export_settings": "导出设置", + "import_settings": "导入设置", "third_party": "第三方连接" }, "export_menu": { @@ -3143,6 +3174,11 @@ }, "hour_interval_one": "{{count}} 小时", "hour_interval_other": "{{count}} 小时", + "import_settings": { + "button": "导入文件", + "chatgpt": "导入 ChatGPT 数据", + "title": "导入外部应用数据" + }, "joplin": { "check": { "button": "检测", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index f5a0264875..4ed89ac7a1 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -1376,6 +1376,36 @@ "preview": "預覽", "split": "分屏" }, + "import": { + "chatgpt": { + "assistant_name": "ChatGPT 匯入", + "button": "選擇檔案", + "description": "僅匯入對話文字,不攜帶圖片和附件", + "error": { + "invalid_json": "無效的 JSON 檔案格式", + "no_conversations": "檔案中未找到任何對話", + "no_valid_conversations": "沒有可匯入的有效對話", + "unknown": "匯入失敗,請檢查檔案格式" + }, + "help": { + "step1": "1. 登入 ChatGPT,進入設定 > 資料控制 > 匯出資料", + "step2": "2. 等待郵件接收匯出檔案", + "step3": "3. 解壓下載的檔案,找到 conversations.json", + "title": "如何匯出 ChatGPT 對話?" + }, + "importing": "正在匯入對話...", + "selecting": "正在選擇檔案...", + "success": "成功匯入 {{topics}} 個對話,共 {{messages}} 則訊息", + "title": "匯入 ChatGPT 對話", + "untitled_conversation": "未命名對話" + }, + "confirm": { + "button": "選擇匯入檔案", + "label": "確定要匯入外部資料嗎?" + }, + "content": "選擇要匯入的外部應用對話檔案,暫時僅支援 ChatGPT 的 JSON 格式檔案", + "title": "匯入外部對話" + }, "knowledge": { "add": { "title": "新增知識庫" @@ -3085,6 +3115,7 @@ "basic": "基礎數據設定", "cloud_storage": "雲備份設定", "export_settings": "匯出設定", + "import_settings": "匯入設定", "third_party": "第三方連接" }, "export_menu": { @@ -3143,6 +3174,11 @@ }, "hour_interval_one": "{{count}} 小時", "hour_interval_other": "{{count}} 小時", + "import_settings": { + "button": "匯入 Json 檔案", + "chatgpt": "匯入 ChatGPT 數據", + "title": "匯入外部應用程式數據" + }, "joplin": { "check": { "button": "檢查", diff --git a/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx b/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx index 379165192e..b72db6fd61 100644 --- a/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx +++ b/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx @@ -16,6 +16,7 @@ import RestorePopup from '@renderer/components/Popups/RestorePopup' import { useTheme } from '@renderer/context/ThemeProvider' import { useKnowledgeFiles } from '@renderer/hooks/useKnowledgeFiles' import { useTimer } from '@renderer/hooks/useTimer' +import ImportMenuOptions from '@renderer/pages/settings/DataSettings/ImportMenuSettings' import { reset } from '@renderer/services/BackupService' import store, { useAppDispatch } from '@renderer/store' import { setSkipBackupFile as _setSkipBackupFile } from '@renderer/store/settings' @@ -95,7 +96,13 @@ const DataSettings: FC = () => { { key: 'webdav', title: t('settings.data.webdav.title'), icon: }, { key: 'nutstore', title: t('settings.data.nutstore.title'), icon: }, { key: 's3', title: t('settings.data.s3.title.label'), icon: }, - { key: 'divider_2', isDivider: true, text: t('settings.data.divider.export_settings') }, + { key: 'divider_2', isDivider: true, text: t('settings.data.divider.import_settings') }, + { + key: 'import_settings', + title: t('settings.data.import_settings.title'), + icon: + }, + { key: 'divider_3', isDivider: true, text: t('settings.data.divider.export_settings') }, { key: 'export_menu', title: t('settings.data.export_menu.title'), @@ -107,7 +114,7 @@ const DataSettings: FC = () => { icon: }, - { key: 'divider_3', isDivider: true, text: t('settings.data.divider.third_party') }, + { key: 'divider_4', isDivider: true, text: t('settings.data.divider.third_party') }, { key: 'notion', title: t('settings.data.notion.title'), icon: }, { key: 'yuque', @@ -691,6 +698,7 @@ const DataSettings: FC = () => { {menu === 'webdav' && } {menu === 'nutstore' && } {menu === 's3' && } + {menu === 'import_settings' && } {menu === 'export_menu' && } {menu === 'markdown_export' && } {menu === 'notion' && } diff --git a/src/renderer/src/pages/settings/DataSettings/ImportMenuSettings.tsx b/src/renderer/src/pages/settings/DataSettings/ImportMenuSettings.tsx new file mode 100644 index 0000000000..c4b1afe8e7 --- /dev/null +++ b/src/renderer/src/pages/settings/DataSettings/ImportMenuSettings.tsx @@ -0,0 +1,29 @@ +import { HStack } from '@renderer/components/Layout' +import ImportPopup from '@renderer/components/Popups/ImportPopup' +import { useTheme } from '@renderer/context/ThemeProvider' +import { Button } from 'antd' +import type { FC } from 'react' +import { useTranslation } from 'react-i18next' + +import { SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '..' + +const ImportMenuOptions: FC = () => { + const { t } = useTranslation() + const { theme } = useTheme() + return ( + + + {t('settings.data.import_settings.title')} + + + + {t('settings.data.import_settings.chatgpt')} + + + + + + ) +} + +export default ImportMenuOptions diff --git a/src/renderer/src/services/import/ImportService.ts b/src/renderer/src/services/import/ImportService.ts new file mode 100644 index 0000000000..07dc72ab2d --- /dev/null +++ b/src/renderer/src/services/import/ImportService.ts @@ -0,0 +1,167 @@ +import { loggerService } from '@logger' +import i18n from '@renderer/i18n' +import store from '@renderer/store' +import { addAssistant } from '@renderer/store/assistants' +import type { Assistant } from '@renderer/types' +import { uuid } from '@renderer/utils' + +import { DEFAULT_ASSISTANT_SETTINGS } from '../AssistantService' +import { availableImporters } from './importers' +import type { ConversationImporter, ImportResponse } from './types' +import { saveImportToDatabase } from './utils/database' + +const logger = loggerService.withContext('ImportService') + +/** + * Main import service that manages all conversation importers + */ +class ImportServiceClass { + private importers: Map = new Map() + + constructor() { + // Register all available importers + for (const importer of availableImporters) { + this.importers.set(importer.name.toLowerCase(), importer) + logger.info(`Registered importer: ${importer.name}`) + } + } + + /** + * Get all registered importers + */ + getImporters(): ConversationImporter[] { + return Array.from(this.importers.values()) + } + + /** + * Get importer by name + */ + getImporter(name: string): ConversationImporter | undefined { + return this.importers.get(name.toLowerCase()) + } + + /** + * Auto-detect the appropriate importer for the file content + */ + detectImporter(fileContent: string): ConversationImporter | null { + for (const importer of this.importers.values()) { + if (importer.validate(fileContent)) { + logger.info(`Detected importer: ${importer.name}`) + return importer + } + } + logger.warn('No matching importer found for file content') + return null + } + + /** + * Import conversations from file content + * Automatically detects the format and uses the appropriate importer + */ + async importConversations(fileContent: string, importerName?: string): Promise { + try { + logger.info('Starting import...') + + // Parse JSON first to validate format + let importer: ConversationImporter | null = null + + if (importerName) { + // Use specified importer + const foundImporter = this.getImporter(importerName) + if (!foundImporter) { + return { + success: false, + topicsCount: 0, + messagesCount: 0, + error: `Importer "${importerName}" not found` + } + } + importer = foundImporter + } else { + // Auto-detect importer + importer = this.detectImporter(fileContent) + if (!importer) { + return { + success: false, + topicsCount: 0, + messagesCount: 0, + error: i18n.t('import.error.unsupported_format', { defaultValue: 'Unsupported file format' }) + } + } + } + + // Validate format + if (!importer.validate(fileContent)) { + return { + success: false, + topicsCount: 0, + messagesCount: 0, + error: i18n.t('import.error.invalid_format', { + defaultValue: `Invalid ${importer.name} format` + }) + } + } + + // Create assistant + const assistantId = uuid() + + // Parse conversations + const result = await importer.parse(fileContent, assistantId) + + // Save to database + await saveImportToDatabase(result) + + // Create assistant + const importerKey = `import.${importer.name.toLowerCase()}.assistant_name` + const assistant: Assistant = { + id: assistantId, + name: i18n.t(importerKey, { + defaultValue: `${importer.name} Import` + }), + emoji: importer.emoji, + prompt: '', + topics: result.topics, + messages: [], + type: 'assistant', + settings: DEFAULT_ASSISTANT_SETTINGS + } + + // Add assistant to store + store.dispatch(addAssistant(assistant)) + + logger.info( + `Import completed: ${result.topics.length} conversations, ${result.messages.length} messages imported` + ) + + return { + success: true, + assistant, + topicsCount: result.topics.length, + messagesCount: result.messages.length + } + } catch (error) { + logger.error('Import failed:', error as Error) + return { + success: false, + topicsCount: 0, + messagesCount: 0, + error: + error instanceof Error ? error.message : i18n.t('import.error.unknown', { defaultValue: 'Unknown error' }) + } + } + } + + /** + * Import ChatGPT conversations (backward compatibility) + * @deprecated Use importConversations() instead + */ + async importChatGPTConversations(fileContent: string): Promise { + return this.importConversations(fileContent, 'chatgpt') + } +} + +// Export singleton instance +export const ImportService = new ImportServiceClass() + +// Export for backward compatibility +export const importChatGPTConversations = (fileContent: string) => ImportService.importChatGPTConversations(fileContent) diff --git a/src/renderer/src/services/import/importers/ChatGPTImporter.ts b/src/renderer/src/services/import/importers/ChatGPTImporter.ts new file mode 100644 index 0000000000..3c95af919b --- /dev/null +++ b/src/renderer/src/services/import/importers/ChatGPTImporter.ts @@ -0,0 +1,268 @@ +import { loggerService } from '@logger' +import i18n from '@renderer/i18n' +import type { Topic } from '@renderer/types' +import { + AssistantMessageStatus, + type MainTextMessageBlock, + type Message, + MessageBlockStatus, + MessageBlockType, + UserMessageStatus +} from '@renderer/types/newMessage' +import { uuid } from '@renderer/utils' + +import type { ConversationImporter, ImportResult } from '../types' + +const logger = loggerService.withContext('ChatGPTImporter') + +/** + * ChatGPT Export Format Types + */ +interface ChatGPTMessage { + id: string + author: { + role: 'user' | 'assistant' | 'system' | 'tool' + } + content: { + content_type: string + parts?: string[] + } + metadata?: any + create_time?: number +} + +interface ChatGPTNode { + id: string + message?: ChatGPTMessage + parent?: string + children?: string[] +} + +interface ChatGPTConversation { + title: string + create_time: number + update_time: number + mapping: Record + current_node?: string +} + +/** + * ChatGPT conversation importer + * Handles importing conversations from ChatGPT's conversations.json export format + */ +export class ChatGPTImporter implements ConversationImporter { + readonly name = 'ChatGPT' + readonly emoji = '💬' + + /** + * Validate if the file content is a valid ChatGPT export + */ + validate(fileContent: string): boolean { + try { + const parsed = JSON.parse(fileContent) + const conversations = Array.isArray(parsed) ? parsed : [parsed] + + // Check if it has the basic ChatGPT conversation structure + return conversations.every( + (conv) => + conv && + typeof conv === 'object' && + 'mapping' in conv && + typeof conv.mapping === 'object' && + 'title' in conv && + 'create_time' in conv + ) + } catch { + return false + } + } + + /** + * Parse ChatGPT conversations and convert to unified format + */ + async parse(fileContent: string, assistantId: string): Promise { + logger.info('Starting ChatGPT import...') + + // Parse JSON + const parsed = JSON.parse(fileContent) + const conversations: ChatGPTConversation[] = Array.isArray(parsed) ? parsed : [parsed] + + if (!conversations || conversations.length === 0) { + throw new Error(i18n.t('import.chatgpt.error.no_conversations')) + } + + logger.info(`Found ${conversations.length} conversations`) + + const topics: Topic[] = [] + const allMessages: Message[] = [] + const allBlocks: MainTextMessageBlock[] = [] + + // Convert each conversation + for (const conversation of conversations) { + try { + const { topic, messages, blocks } = this.convertConversationToTopic(conversation, assistantId) + topics.push(topic) + allMessages.push(...messages) + allBlocks.push(...blocks) + } catch (convError) { + logger.warn(`Failed to convert conversation "${conversation.title}":`, convError as Error) + // Continue with other conversations + } + } + + if (topics.length === 0) { + throw new Error(i18n.t('import.chatgpt.error.no_valid_conversations')) + } + + return { + topics, + messages: allMessages, + blocks: allBlocks + } + } + + /** + * Extract main conversation thread from ChatGPT's tree structure + * Traces back from current_node to root to get the main conversation path + */ + private extractMainThread(mapping: Record, currentNode?: string): ChatGPTMessage[] { + const messages: ChatGPTMessage[] = [] + const nodeIds: string[] = [] + + // Start from current_node or find the last node + let nodeId = currentNode + if (!nodeId) { + // Find node with no children (leaf node) + const leafNodes = Object.entries(mapping).filter(([, node]) => !node.children || node.children.length === 0) + if (leafNodes.length > 0) { + nodeId = leafNodes[0][0] + } + } + + // Trace back to root + while (nodeId) { + const node = mapping[nodeId] + if (!node) break + + nodeIds.unshift(nodeId) + nodeId = node.parent + } + + // Extract messages from the path + for (const id of nodeIds) { + const node = mapping[id] + if (node?.message) { + const message = node.message + // Filter out empty messages and tool messages + if ( + message.author.role !== 'tool' && + message.content?.parts && + message.content.parts.length > 0 && + message.content.parts.some((part) => part && part.trim().length > 0) + ) { + messages.push(message) + } + } + } + + return messages + } + + /** + * Map ChatGPT role to Cherry Studio role + */ + private mapRole(chatgptRole: string): 'user' | 'assistant' | 'system' { + if (chatgptRole === 'user') return 'user' + if (chatgptRole === 'assistant') return 'assistant' + return 'system' + } + + /** + * Create Message and MessageBlock from ChatGPT message + */ + private createMessageAndBlock( + chatgptMessage: ChatGPTMessage, + topicId: string, + assistantId: string + ): { message: Message; block: MainTextMessageBlock } { + const messageId = uuid() + const blockId = uuid() + const role = this.mapRole(chatgptMessage.author.role) + + // Extract text content from parts + const content = (chatgptMessage.content?.parts || []).filter((part) => part && part.trim()).join('\n\n') + + const createdAt = chatgptMessage.create_time + ? new Date(chatgptMessage.create_time * 1000).toISOString() + : new Date().toISOString() + + // Create message + const message: Message = { + id: messageId, + role, + assistantId, + topicId, + createdAt, + updatedAt: createdAt, + status: role === 'user' ? UserMessageStatus.SUCCESS : AssistantMessageStatus.SUCCESS, + blocks: [blockId], + // Set model for assistant messages to display GPT-5 logo + ...(role === 'assistant' && { + model: { + id: 'gpt-5', + provider: 'openai', + name: 'GPT-5', + group: 'gpt-5' + } + }) + } + + // Create block + const block: MainTextMessageBlock = { + id: blockId, + messageId, + type: MessageBlockType.MAIN_TEXT, + content, + createdAt, + updatedAt: createdAt, + status: MessageBlockStatus.SUCCESS + } + + return { message, block } + } + + /** + * Convert ChatGPT conversation to Cherry Studio Topic + */ + private convertConversationToTopic( + conversation: ChatGPTConversation, + assistantId: string + ): { topic: Topic; messages: Message[]; blocks: MainTextMessageBlock[] } { + const topicId = uuid() + const messages: Message[] = [] + const blocks: MainTextMessageBlock[] = [] + + // Extract main thread messages + const chatgptMessages = this.extractMainThread(conversation.mapping, conversation.current_node) + + // Convert each message + for (const chatgptMessage of chatgptMessages) { + const { message, block } = this.createMessageAndBlock(chatgptMessage, topicId, assistantId) + messages.push(message) + blocks.push(block) + } + + // Create topic + const topic: Topic = { + id: topicId, + assistantId, + name: conversation.title || i18n.t('import.chatgpt.untitled_conversation'), + createdAt: new Date(conversation.create_time * 1000).toISOString(), + updatedAt: new Date(conversation.update_time * 1000).toISOString(), + messages, + isNameManuallyEdited: true + } + + return { topic, messages, blocks } + } +} diff --git a/src/renderer/src/services/import/importers/index.ts b/src/renderer/src/services/import/importers/index.ts new file mode 100644 index 0000000000..35e8f071b4 --- /dev/null +++ b/src/renderer/src/services/import/importers/index.ts @@ -0,0 +1,12 @@ +import { ChatGPTImporter } from './ChatGPTImporter' + +/** + * Export all available importers + */ +export { ChatGPTImporter } + +/** + * Registry of all available importers + * Add new importers here as they are implemented + */ +export const availableImporters = [new ChatGPTImporter()] as const diff --git a/src/renderer/src/services/import/index.ts b/src/renderer/src/services/import/index.ts new file mode 100644 index 0000000000..8e9391eedd --- /dev/null +++ b/src/renderer/src/services/import/index.ts @@ -0,0 +1,3 @@ +export { ChatGPTImporter } from './importers/ChatGPTImporter' +export { importChatGPTConversations, ImportService } from './ImportService' +export type { ConversationImporter, ImportResponse, ImportResult } from './types' diff --git a/src/renderer/src/services/import/types.ts b/src/renderer/src/services/import/types.ts new file mode 100644 index 0000000000..279a087f3a --- /dev/null +++ b/src/renderer/src/services/import/types.ts @@ -0,0 +1,52 @@ +import type { Assistant, Topic } from '@renderer/types' +import type { MainTextMessageBlock, Message } from '@renderer/types/newMessage' + +/** + * Import result containing parsed data + */ +export interface ImportResult { + topics: Topic[] + messages: Message[] + blocks: MainTextMessageBlock[] + metadata?: Record +} + +/** + * Response returned to caller after import + */ +export interface ImportResponse { + success: boolean + assistant?: Assistant + topicsCount: number + messagesCount: number + error?: string +} + +/** + * Base interface for conversation importers + * Each chat application (ChatGPT, Claude, Gemini, etc.) should implement this interface + */ +export interface ConversationImporter { + /** + * Unique name of the importer (e.g., 'ChatGPT', 'Claude', 'Gemini') + */ + readonly name: string + + /** + * Emoji or icon for the assistant created by this importer + */ + readonly emoji: string + + /** + * Validate if the file content matches this importer's format + */ + validate(fileContent: string): boolean + + /** + * Parse file content and convert to unified format + * @param fileContent - Raw file content (usually JSON string) + * @param assistantId - ID of the assistant to associate with + * @returns Parsed topics, messages, and blocks + */ + parse(fileContent: string, assistantId: string): Promise +} diff --git a/src/renderer/src/services/import/utils/database.ts b/src/renderer/src/services/import/utils/database.ts new file mode 100644 index 0000000000..6705b9a4be --- /dev/null +++ b/src/renderer/src/services/import/utils/database.ts @@ -0,0 +1,34 @@ +import { loggerService } from '@logger' +import db from '@renderer/databases' + +import type { ImportResult } from '../types' + +const logger = loggerService.withContext('ImportDatabase') + +/** + * Save import result to database + * Handles saving topics, messages, and message blocks in a transaction + */ +export async function saveImportToDatabase(result: ImportResult): Promise { + const { topics, messages, blocks } = result + + logger.info(`Saving import: ${topics.length} topics, ${messages.length} messages, ${blocks.length} blocks`) + + await db.transaction('rw', db.topics, db.message_blocks, async () => { + // Save all message blocks + if (blocks.length > 0) { + await db.message_blocks.bulkAdd(blocks) + logger.info(`Saved ${blocks.length} message blocks`) + } + + // Save all topics with messages + for (const topic of topics) { + const topicMessages = messages.filter((m) => m.topicId === topic.id) + await db.topics.add({ + id: topic.id, + messages: topicMessages + }) + } + logger.info(`Saved ${topics.length} topics`) + }) +} From 852192dce6d5a269c87d6a7daa5d230251db6b33 Mon Sep 17 00:00:00 2001 From: beyondkmp Date: Fri, 21 Nov 2025 21:32:53 +0800 Subject: [PATCH 3/6] feat: add Git Bash detection and requirement check for Windows agents (#11388) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add Git Bash detection and requirement check for Windows agents - Add System_CheckGitBash IPC channel for detecting Git Bash installation - Implement detection logic checking common installation paths and PATH environment - Display non-closable error alert in AgentModal when Git Bash is not found - Disable agent creation/edit button until Git Bash is installed - Add recheck functionality to verify installation without restarting app Git Bash is required for agents to function properly on Windows systems. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * i18n: add Git Bash requirement translations for agent modal - Add English translations for Git Bash detection warnings - Add Simplified Chinese (zh-cn) translations - Add Traditional Chinese (zh-tw) translations 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * format code --------- Co-authored-by: Claude --- packages/shared/IpcChannel.ts | 1 + src/main/ipc.ts | 38 ++++++++++++ src/preload/index.ts | 3 +- .../components/Popups/agent/AgentModal.tsx | 59 ++++++++++++++++++- src/renderer/src/i18n/locales/en-us.json | 9 +++ src/renderer/src/i18n/locales/zh-cn.json | 9 +++ src/renderer/src/i18n/locales/zh-tw.json | 9 +++ src/renderer/src/i18n/translate/de-de.json | 9 +++ src/renderer/src/i18n/translate/el-gr.json | 9 +++ src/renderer/src/i18n/translate/es-es.json | 9 +++ src/renderer/src/i18n/translate/fr-fr.json | 9 +++ src/renderer/src/i18n/translate/ja-jp.json | 9 +++ src/renderer/src/i18n/translate/pt-pt.json | 9 +++ src/renderer/src/i18n/translate/ru-ru.json | 9 +++ 14 files changed, 188 insertions(+), 3 deletions(-) diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index b90ef3b356..67bd137b8e 100644 --- a/packages/shared/IpcChannel.ts +++ b/packages/shared/IpcChannel.ts @@ -235,6 +235,7 @@ export enum IpcChannel { System_GetDeviceType = 'system:getDeviceType', System_GetHostname = 'system:getHostname', System_GetCpuName = 'system:getCpuName', + System_CheckGitBash = 'system:checkGitBash', // DevTools System_ToggleDevTools = 'system:toggleDevTools', diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 9750a4cf05..e537b85261 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -493,6 +493,44 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { ipcMain.handle(IpcChannel.System_GetDeviceType, () => (isMac ? 'mac' : isWin ? 'windows' : 'linux')) ipcMain.handle(IpcChannel.System_GetHostname, () => require('os').hostname()) ipcMain.handle(IpcChannel.System_GetCpuName, () => require('os').cpus()[0].model) + ipcMain.handle(IpcChannel.System_CheckGitBash, () => { + if (!isWin) { + return true // Non-Windows systems don't need Git Bash + } + + try { + // Check common Git Bash installation paths + const commonPaths = [ + path.join(process.env.ProgramFiles || 'C:\\Program Files', 'Git', 'bin', 'bash.exe'), + path.join(process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)', 'Git', 'bin', 'bash.exe'), + path.join(process.env.LOCALAPPDATA || '', 'Programs', 'Git', 'bin', 'bash.exe') + ] + + // Check if any of the common paths exist + for (const bashPath of commonPaths) { + if (fs.existsSync(bashPath)) { + logger.debug('Git Bash found', { path: bashPath }) + return true + } + } + + // Check if git is in PATH + const { execSync } = require('child_process') + try { + execSync('git --version', { stdio: 'ignore' }) + logger.debug('Git found in PATH') + return true + } catch { + // Git not in PATH + } + + logger.debug('Git Bash not found on Windows system') + return false + } catch (error) { + logger.error('Error checking Git Bash', error as Error) + return false + } + }) ipcMain.handle(IpcChannel.System_ToggleDevTools, (e) => { const win = BrowserWindow.fromWebContents(e.sender) win && win.webContents.toggleDevTools() diff --git a/src/preload/index.ts b/src/preload/index.ts index 11a8e4589f..92f44075aa 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -122,7 +122,8 @@ const api = { system: { getDeviceType: () => ipcRenderer.invoke(IpcChannel.System_GetDeviceType), getHostname: () => ipcRenderer.invoke(IpcChannel.System_GetHostname), - getCpuName: () => ipcRenderer.invoke(IpcChannel.System_GetCpuName) + getCpuName: () => ipcRenderer.invoke(IpcChannel.System_GetCpuName), + checkGitBash: (): Promise => ipcRenderer.invoke(IpcChannel.System_CheckGitBash) }, devTools: { toggle: () => ipcRenderer.invoke(IpcChannel.System_ToggleDevTools) diff --git a/src/renderer/src/components/Popups/agent/AgentModal.tsx b/src/renderer/src/components/Popups/agent/AgentModal.tsx index 2574cbe669..e72433e88a 100644 --- a/src/renderer/src/components/Popups/agent/AgentModal.tsx +++ b/src/renderer/src/components/Popups/agent/AgentModal.tsx @@ -15,7 +15,7 @@ import type { UpdateAgentForm } from '@renderer/types' import { AgentConfigurationSchema, isAgentType } from '@renderer/types' -import { Button, Input, Modal, Select } from 'antd' +import { Alert, Button, Input, Modal, Select } from 'antd' import { AlertTriangleIcon } from 'lucide-react' import type { ChangeEvent, FormEvent } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' @@ -58,6 +58,7 @@ const PopupContainer: React.FC = ({ agent, afterSubmit, resolve }) => { const isEditing = (agent?: AgentWithTools) => agent !== undefined const [form, setForm] = useState(() => buildAgentForm(agent)) + const [hasGitBash, setHasGitBash] = useState(true) useEffect(() => { if (open) { @@ -65,6 +66,30 @@ const PopupContainer: React.FC = ({ agent, afterSubmit, resolve }) => { } }, [agent, open]) + const checkGitBash = useCallback( + async (showToast = false) => { + try { + const gitBashInstalled = await window.api.system.checkGitBash() + setHasGitBash(gitBashInstalled) + if (showToast) { + if (gitBashInstalled) { + window.toast.success(t('agent.gitBash.success', 'Git Bash detected successfully!')) + } else { + window.toast.error(t('agent.gitBash.notFound', 'Git Bash not found. Please install it first.')) + } + } + } catch (error) { + logger.error('Failed to check Git Bash:', error as Error) + setHasGitBash(true) // Default to true on error to avoid false warnings + } + }, + [t] + ) + + useEffect(() => { + checkGitBash() + }, [checkGitBash]) + const selectedPermissionMode = form.configuration?.permission_mode ?? 'default' const onPermissionModeChange = useCallback((value: PermissionMode) => { @@ -275,6 +300,36 @@ const PopupContainer: React.FC = ({ agent, afterSubmit, resolve }) => { footer={null}> + {!hasGitBash && ( + +
+ {t( + 'agent.gitBash.error.description', + 'Git Bash is required to run agents on Windows. The agent cannot function without it. Please install Git for Windows from' + )}{' '} + { + e.preventDefault() + window.api.openWebsite('https://git-scm.com/download/win') + }} + style={{ textDecoration: 'underline' }}> + git-scm.com + +
+ +
+ } + type="error" + showIcon + style={{ marginBottom: 16 }} + /> + )}