From a12b6bfeca9ec8d149f6424800a097dec7430f61 Mon Sep 17 00:00:00 2001 From: beyondkmp Date: Thu, 20 Nov 2025 19:23:27 +0800 Subject: [PATCH 001/102] feat: enable native language emoji search with CLDR data format (#11381) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add i18n support and local data to emoji picker - Add emoji-picker-element-data package for offline-first emoji data - Implement i18n translations for emoji picker UI (de, en, es, fr, ja, pt, ru, zh) - Switch from CDN to local emoji data to improve performance and reliability - Add locale mapping to match app language with emoji picker data - Move emoji-picker-element import to EmojiPicker component for better encapsulation - Use proper TypeScript types instead of 'any' for type safety This improves user experience by providing localized emoji picker interface and eliminating dependency on external CDN, ensuring the picker works offline. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * feat: enable native language emoji search with CLDR data format Switch from emojibase to CLDR format for emoji-picker-element data to support full multi-language search functionality. Users can now search for emojis in their native language (e.g., German users can search "Herz" for ❤️, Spanish users can search "corazón"). Also improves type safety by using the LanguageVarious type for locale mappings. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --------- Co-authored-by: Claude --- package.json | 1 + .../src/components/EmojiPicker/index.tsx | 99 +++++++++++++++++-- .../components/AddAssistantPresetPopup.tsx | 2 - yarn.lock | 8 ++ 4 files changed, 101 insertions(+), 9 deletions(-) diff --git a/package.json b/package.json index 36fe62337c..ceb0cbf3ac 100644 --- a/package.json +++ b/package.json @@ -86,6 +86,7 @@ "@napi-rs/system-ocr": "patch:@napi-rs/system-ocr@npm%3A1.0.2#~/.yarn/patches/@napi-rs-system-ocr-npm-1.0.2-59e7a78e8b.patch", "@paymoapp/electron-shutdown-handler": "^1.1.2", "@strongtz/win32-arm64-msvc": "^0.4.7", + "emoji-picker-element-data": "^1", "express": "^5.1.0", "font-list": "^2.0.0", "graceful-fs": "^4.2.11", diff --git a/src/renderer/src/components/EmojiPicker/index.tsx b/src/renderer/src/components/EmojiPicker/index.tsx index 8ba9d3e967..9a4158d469 100644 --- a/src/renderer/src/components/EmojiPicker/index.tsx +++ b/src/renderer/src/components/EmojiPicker/index.tsx @@ -1,35 +1,120 @@ +import 'emoji-picker-element' + import TwemojiCountryFlagsWoff2 from '@renderer/assets/fonts/country-flag-fonts/TwemojiCountryFlags.woff2?url' import { useTheme } from '@renderer/context/ThemeProvider' +import type { LanguageVarious } from '@renderer/types' import { polyfillCountryFlagEmojis } from 'country-flag-emoji-polyfill' +// i18n translations from emoji-picker-element +import de from 'emoji-picker-element/i18n/de' +import en from 'emoji-picker-element/i18n/en' +import es from 'emoji-picker-element/i18n/es' +import fr from 'emoji-picker-element/i18n/fr' +import ja from 'emoji-picker-element/i18n/ja' +import pt_PT from 'emoji-picker-element/i18n/pt_PT' +import ru_RU from 'emoji-picker-element/i18n/ru_RU' +import zh_CN from 'emoji-picker-element/i18n/zh_CN' +import type Picker from 'emoji-picker-element/picker' +import type { EmojiClickEvent, NativeEmoji } from 'emoji-picker-element/shared' +// Emoji data from emoji-picker-element-data (local, no CDN) +// Using CLDR format for full multi-language search support (28 languages) +import dataDE from 'emoji-picker-element-data/de/cldr/data.json?url' +import dataEN from 'emoji-picker-element-data/en/cldr/data.json?url' +import dataES from 'emoji-picker-element-data/es/cldr/data.json?url' +import dataFR from 'emoji-picker-element-data/fr/cldr/data.json?url' +import dataJA from 'emoji-picker-element-data/ja/cldr/data.json?url' +import dataPT from 'emoji-picker-element-data/pt/cldr/data.json?url' +import dataRU from 'emoji-picker-element-data/ru/cldr/data.json?url' +import dataZH from 'emoji-picker-element-data/zh/cldr/data.json?url' +import dataZH_HANT from 'emoji-picker-element-data/zh-hant/cldr/data.json?url' import type { FC } from 'react' import { useEffect, useRef } from 'react' +import { useTranslation } from 'react-i18next' interface Props { onEmojiClick: (emoji: string) => void } +// Mapping from app locale to emoji-picker-element i18n +const i18nMap: Record = { + 'en-US': en, + 'zh-CN': zh_CN, + 'zh-TW': zh_CN, // Closest available + 'de-DE': de, + 'el-GR': en, // No Greek available, fallback to English + 'es-ES': es, + 'fr-FR': fr, + 'ja-JP': ja, + 'pt-PT': pt_PT, + 'ru-RU': ru_RU +} + +// Mapping from app locale to emoji data URL +// Using CLDR format provides native language search support for all locales +const dataSourceMap: Record = { + 'en-US': dataEN, + 'zh-CN': dataZH, + 'zh-TW': dataZH_HANT, + 'de-DE': dataDE, + 'el-GR': dataEN, // No Greek CLDR available, fallback to English + 'es-ES': dataES, + 'fr-FR': dataFR, + 'ja-JP': dataJA, + 'pt-PT': dataPT, + 'ru-RU': dataRU +} + +// Mapping from app locale to emoji-picker-element locale string +// Must match the data source locale for proper IndexedDB caching +const localeMap: Record = { + 'en-US': 'en', + 'zh-CN': 'zh', + 'zh-TW': 'zh-hant', + 'de-DE': 'de', + 'el-GR': 'en', + 'es-ES': 'es', + 'fr-FR': 'fr', + 'ja-JP': 'ja', + 'pt-PT': 'pt', + 'ru-RU': 'ru' +} + const EmojiPicker: FC = ({ onEmojiClick }) => { const { theme } = useTheme() - const ref = useRef(null) + const { i18n } = useTranslation() + const ref = useRef(null) + const currentLocale = i18n.language as LanguageVarious useEffect(() => { polyfillCountryFlagEmojis('Twemoji Mozilla', TwemojiCountryFlagsWoff2) }, []) + // Configure picker with i18n and dataSource useEffect(() => { - const refValue = ref.current + const picker = ref.current + if (picker) { + picker.i18n = i18nMap[currentLocale] || en + picker.dataSource = dataSourceMap[currentLocale] || dataEN + picker.locale = localeMap[currentLocale] || 'en' + } + }, [currentLocale]) - if (refValue) { - const handleEmojiClick = (event: any) => { + useEffect(() => { + const picker = ref.current + + if (picker) { + const handleEmojiClick = (event: EmojiClickEvent) => { event.stopPropagation() - onEmojiClick(event.detail.unicode || event.detail.emoji.unicode) + const { detail } = event + // Use detail.unicode (processed with skin tone) or fallback to emoji's unicode for native emoji + const unicode = detail.unicode || ('unicode' in detail.emoji ? (detail.emoji as NativeEmoji).unicode : '') + onEmojiClick(unicode) } // 添加事件监听器 - refValue.addEventListener('emoji-click', handleEmojiClick) + picker.addEventListener('emoji-click', handleEmojiClick) // 清理事件监听器 return () => { - refValue.removeEventListener('emoji-click', handleEmojiClick) + picker.removeEventListener('emoji-click', handleEmojiClick) } } return diff --git a/src/renderer/src/pages/store/assistants/presets/components/AddAssistantPresetPopup.tsx b/src/renderer/src/pages/store/assistants/presets/components/AddAssistantPresetPopup.tsx index d5558b8e88..a6f733dfef 100644 --- a/src/renderer/src/pages/store/assistants/presets/components/AddAssistantPresetPopup.tsx +++ b/src/renderer/src/pages/store/assistants/presets/components/AddAssistantPresetPopup.tsx @@ -1,5 +1,3 @@ -import 'emoji-picker-element' - import { CheckOutlined, LoadingOutlined, RollbackOutlined, ThunderboltOutlined } from '@ant-design/icons' import { loggerService } from '@logger' import EmojiPicker from '@renderer/components/EmojiPicker' diff --git a/yarn.lock b/yarn.lock index 18bde2062e..240532dfb3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10074,6 +10074,7 @@ __metadata: electron-window-state: "npm:^5.0.3" emittery: "npm:^1.0.3" emoji-picker-element: "npm:^1.22.1" + emoji-picker-element-data: "npm:^1" epub: "patch:epub@npm%3A1.3.0#~/.yarn/patches/epub-npm-1.3.0-8325494ffe.patch" eslint: "npm:^9.22.0" eslint-plugin-import-zod: "npm:^1.2.0" @@ -13655,6 +13656,13 @@ __metadata: languageName: node linkType: hard +"emoji-picker-element-data@npm:^1": + version: 1.8.0 + resolution: "emoji-picker-element-data@npm:1.8.0" + checksum: 10c0/c8976b636205a0cc90d2690859a1193add71a948dadf743962b47c338a4c3715768404d0ccbc02156608b44abf41f3e1d51756e06f1bbed9d164dd4cb1752103 + languageName: node + linkType: hard + "emoji-picker-element@npm:^1.22.1": version: 1.26.3 resolution: "emoji-picker-element@npm:1.26.3" From dcdd1bf852efa5661fd4606262a0257cc2515ab9 Mon Sep 17 00:00:00 2001 From: SuYao Date: Fri, 21 Nov 2025 09:55:46 +0800 Subject: [PATCH 002/102] 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 003/102] 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 004/102] 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 }} + /> + )}