diff --git a/packages/ai-sdk-provider/package.json b/packages/ai-sdk-provider/package.json index bf509ee963..95f0dbb01d 100644 --- a/packages/ai-sdk-provider/package.json +++ b/packages/ai-sdk-provider/package.json @@ -1,6 +1,6 @@ { "name": "@cherrystudio/ai-sdk-provider", - "version": "0.1.2", + "version": "0.1.3", "description": "Cherry Studio AI SDK provider bundle with CherryIN routing.", "keywords": [ "ai-sdk", diff --git a/packages/ai-sdk-provider/src/cherryin-provider.ts b/packages/ai-sdk-provider/src/cherryin-provider.ts index 478380a411..1f799133d9 100644 --- a/packages/ai-sdk-provider/src/cherryin-provider.ts +++ b/packages/ai-sdk-provider/src/cherryin-provider.ts @@ -67,6 +67,10 @@ export interface CherryInProviderSettings { * Optional static headers applied to every request. */ headers?: HeadersInput + /** + * Optional endpoint type to distinguish different endpoint behaviors. + */ + endpointType?: 'openai' | 'openai-response' | 'anthropic' | 'gemini' | 'image-generation' | 'jina-rerank' } export interface CherryInProvider extends ProviderV2 { @@ -151,7 +155,8 @@ export const createCherryIn = (options: CherryInProviderSettings = {}): CherryIn baseURL = DEFAULT_CHERRYIN_BASE_URL, anthropicBaseURL = DEFAULT_CHERRYIN_ANTHROPIC_BASE_URL, geminiBaseURL = DEFAULT_CHERRYIN_GEMINI_BASE_URL, - fetch + fetch, + endpointType } = options const getJsonHeaders = createJsonHeadersGetter(options) @@ -205,7 +210,7 @@ export const createCherryIn = (options: CherryInProviderSettings = {}): CherryIn fetch }) - const createChatModel = (modelId: string, settings: OpenAIProviderSettings = {}) => { + const createChatModelByModelId = (modelId: string, settings: OpenAIProviderSettings = {}) => { if (isAnthropicModel(modelId)) { return createAnthropicModel(modelId) } @@ -223,6 +228,29 @@ export const createCherryIn = (options: CherryInProviderSettings = {}): CherryIn }) } + const createChatModel = (modelId: string, settings: OpenAIProviderSettings = {}) => { + if (!endpointType) return createChatModelByModelId(modelId, settings) + switch (endpointType) { + case 'anthropic': + return createAnthropicModel(modelId) + case 'gemini': + return createGeminiModel(modelId) + case 'openai': + return createOpenAIChatModel(modelId) + case 'openai-response': + default: + return new OpenAIResponsesLanguageModel(modelId, { + provider: `${CHERRYIN_PROVIDER_NAME}.openai`, + url, + headers: () => ({ + ...getJsonHeaders(), + ...settings.headers + }), + fetch + }) + } + } + const createCompletionModel = (modelId: string, settings: OpenAIProviderSettings = {}) => new OpenAICompletionLanguageModel(modelId, { provider: `${CHERRYIN_PROVIDER_NAME}.completion`, diff --git a/packages/aiCore/package.json b/packages/aiCore/package.json index fbbea52d40..75f75b0ab6 100644 --- a/packages/aiCore/package.json +++ b/packages/aiCore/package.json @@ -35,7 +35,7 @@ "peerDependencies": { "@ai-sdk/google": "^2.0.36", "@ai-sdk/openai": "^2.0.64", - "@cherrystudio/ai-sdk-provider": "^0.1.2", + "@cherrystudio/ai-sdk-provider": "^0.1.3", "ai": "^5.0.26" }, "dependencies": { diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index 3060f12144..2ebdf9f15b 100644 --- a/packages/shared/IpcChannel.ts +++ b/packages/shared/IpcChannel.ts @@ -251,6 +251,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/apiServer/routes/models.ts b/src/main/apiServer/routes/models.ts index 5f414057fa..6d4f1e3362 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/ipc.ts b/src/main/ipc.ts index b305f6d3f8..850c117094 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -494,6 +494,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/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/preload/index.ts b/src/preload/index.ts index 172e6755e0..41e34b285e 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -127,7 +127,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/aiCore/provider/providerConfig.ts b/src/renderer/src/aiCore/provider/providerConfig.ts index 094ab3de1e..45382a8944 100644 --- a/src/renderer/src/aiCore/provider/providerConfig.ts +++ b/src/renderer/src/aiCore/provider/providerConfig.ts @@ -228,6 +228,13 @@ export function providerToAiSdkConfig( baseConfig.baseURL += aiSdkProviderId === 'google-vertex' ? '/publishers/google' : '/publishers/anthropic/models' } + // cherryin + if (aiSdkProviderId === 'cherryin') { + if (model.endpoint_type) { + extraOptions.endpointType = model.endpoint_type + } + } + if (hasProviderConfig(aiSdkProviderId) && aiSdkProviderId !== 'openai-compatible') { const options = ProviderConfigFactory.fromProvider(aiSdkProviderId, baseConfig, extraOptions) return { 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/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 }} + /> + )}