From f457f6aaca5da2acb356d114b437bec2d410f212 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Thu, 18 Jun 2026 12:06:58 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(ai):=20=E6=8E=A5=E5=85=A5=20Co?= =?UTF-8?q?deBuddy=20CLI=20=E5=B9=B6=E5=85=BC=E5=AE=B9=E5=AE=98=E6=96=B9?= =?UTF-8?q?=E7=99=BB=E5=BD=95=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 CodeBuddy CLI provider,支持 codebuddy/cbc 命令调用与输出解析 - 将 Base URL、API Key/Auth Token、自定义请求头映射到 CodeBuddy CLI 环境变量 - 扩展 custom provider 路由与测试链路,兼容空 Base URL 和 CLI 默认模型选择 - AI 设置新增 CodeBuddy 预设,并补齐 preset 回显识别与匹配逻辑 - 修正就绪态、模型列表与表单校验,允许留空凭证直接复用本机已登录账号 - 补充前后端定向测试并覆盖 CodeBuddy 配置展示文案 Close #574 --- frontend/src/components/AISettingsModal.tsx | 5 +- .../ai/AISettingsProvidersSection.test.tsx | 42 ++ .../ai/AISettingsProvidersSection.tsx | 43 +- .../src/components/ai/aiChatReadiness.test.ts | 24 + frontend/src/components/ai/aiChatReadiness.ts | 18 +- .../ai/aiSettingsModalConfig.test.tsx | 11 + .../components/ai/aiSettingsModalConfig.tsx | 1 + frontend/src/types.ts | 2 +- frontend/src/utils/aiProviderPresets.test.ts | 17 +- frontend/src/utils/aiProviderPresets.ts | 13 +- internal/ai/provider/codebuddy_cli.go | 446 ++++++++++++++++++ internal/ai/provider/codebuddy_cli_test.go | 140 ++++++ internal/ai/provider/custom.go | 9 +- internal/ai/service/service.go | 38 ++ internal/ai/service/service_codebuddy_test.go | 61 +++ internal/ai/types.go | 2 +- 16 files changed, 842 insertions(+), 30 deletions(-) create mode 100644 internal/ai/provider/codebuddy_cli.go create mode 100644 internal/ai/provider/codebuddy_cli_test.go create mode 100644 internal/ai/service/service_codebuddy_test.go diff --git a/frontend/src/components/AISettingsModal.tsx b/frontend/src/components/AISettingsModal.tsx index fa35078f..1ad4f159 100644 --- a/frontend/src/components/AISettingsModal.tsx +++ b/frontend/src/components/AISettingsModal.tsx @@ -345,7 +345,7 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo // 构建 payload,处理 model/models 逻辑 const preset = findPreset(values.presetKey); - const isCustomLike = values.presetKey === 'custom' || values.presetKey === 'ollama'; + const isCustomLike = values.presetKey === 'custom' || values.presetKey === 'ollama' || values.presetKey === 'codebuddy'; const { model: finalModel, models: resolvedModels } = resolvePresetModelSelection({ presetKey: values.presetKey, presetDefaultModel: preset.defaultModel, @@ -630,10 +630,11 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo presetFixedApiFormat: preset.fixedApiFormat, valuesApiFormat: values.apiFormat, }); + const allowEmptySecret = values.presetKey === 'codebuddy'; const secretDraft = resolveProviderSecretDraft({ apiKeyInput: values.apiKey, }); - if (secretDraft.mode === 'clear') { + if (secretDraft.mode === 'clear' && !allowEmptySecret) { throw new Error(t('ai_settings.message.test_requires_new_api_key')); } const res = await Service?.AITestProvider?.({ diff --git a/frontend/src/components/ai/AISettingsProvidersSection.test.tsx b/frontend/src/components/ai/AISettingsProvidersSection.test.tsx index e143d1f0..5496f7d2 100644 --- a/frontend/src/components/ai/AISettingsProvidersSection.test.tsx +++ b/frontend/src/components/ai/AISettingsProvidersSection.test.tsx @@ -108,4 +108,46 @@ describe('AISettingsProvidersSection', () => { expect(markup).toContain('API Endpoint (URL)'); expect(markup).toContain('测试连接'); }); + + it('renders CodeBuddy optional-login copy when editing the CodeBuddy preset', () => { + const Wrap = () => { + const [form] = Form.useForm(); + return ( + {}} + resolveProviderPreset={() => ({ label: 'CodeBuddy', icon: C })} + resolvePresetByKey={(key) => providerPresets.find((item) => item.key === key) || providerPresets[0]} + onAddProvider={() => {}} + onEditProvider={() => {}} + onDeleteProvider={() => {}} + onSetActiveProvider={() => {}} + onCancelEdit={() => {}} + onPresetChange={() => {}} + onTestProvider={() => {}} + onSaveProvider={() => {}} + /> + ); + }; + + const markup = renderToStaticMarkup(); + expect(markup).toContain('API Key / Auth Token'); + expect(markup).toContain('本机 CodeBuddy CLI 已登录账号'); + expect(markup).toContain('留空则使用 CodeBuddy CLI 默认网关'); + }); }); diff --git a/frontend/src/components/ai/AISettingsProvidersSection.tsx b/frontend/src/components/ai/AISettingsProvidersSection.tsx index 81d9d019..94badc3d 100644 --- a/frontend/src/components/ai/AISettingsProvidersSection.tsx +++ b/frontend/src/components/ai/AISettingsProvidersSection.tsx @@ -106,6 +106,8 @@ const AISettingsProvidersSection: React.FC = ({ onSaveProvider, }) => { const presetKeyFromForm = watchedPresetKey || (editingProvider as (AIProviderConfig & { presetKey?: string }) | null)?.presetKey || 'openai'; + const supportsAdvancedEndpoint = presetKeyFromForm === 'custom' || presetKeyFromForm === 'ollama' || presetKeyFromForm === 'codebuddy'; + const codeBuddyUsesOptionalSecret = presetKeyFromForm === 'codebuddy'; const sectionLabelColor = darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.4)'; const currentFieldGroupStyle = fieldGroupStyle(cardBorder, cardBg); const currentFieldLabelStyle = fieldLabelStyle(sectionLabelColor); @@ -132,6 +134,7 @@ const AISettingsProvidersSection: React.FC = ({ {providers.map((provider) => { const matchedPreset = resolveProviderPreset(provider); const isActive = provider.id === activeProviderId; + const modelLabel = provider.model || (provider.apiFormat === 'codebuddy-cli' ? '自动选择' : '未选择模型'); return (
= ({
{matchedPreset.label} · - {provider.model || '未选择模型'} + {modelLabel}
@@ -267,19 +270,21 @@ const AISettingsProvidersSection: React.FC = ({ - {(presetKeyFromForm === 'custom' || presetKeyFromForm === 'ollama') && ( + {supportsAdvancedEndpoint && (
基本信息
- 供应商名称} name="name" rules={[{ required: true, message: '请输入名称' }]} style={{ marginBottom: 16 }}> - - + {presetKeyFromForm === 'custom' && ( + 供应商名称} name="name" rules={[{ required: true, message: '请输入名称' }]} style={{ marginBottom: 16 }}> + + + )} {presetKeyFromForm === 'custom' && ( API 格式} name="apiFormat" style={{ marginBottom: 16 }}> @@ -314,7 +319,7 @@ const AISettingsProvidersSection: React.FC = ({ )} 可用模型列表(可选配置)} name="models" style={{ marginBottom: 0 }}> -
)} @@ -326,21 +331,22 @@ const AISettingsProvidersSection: React.FC = ({ 认证 & 连接 API Key} + label={{codeBuddyUsesOptionalSecret ? 'API Key / Auth Token(可选)' : 'API Key'}} name="apiKey" rules={[{ validator: (_, value) => { const apiKey = String(value || '').trim(); - if (apiKey || editingProvider?.id) { + if (apiKey || editingProvider?.id || codeBuddyUsesOptionalSecret) { return Promise.resolve(); } return Promise.reject(new Error('请输入 API Key')); }, }]} + extra={codeBuddyUsesOptionalSecret ? '留空则使用本机 CodeBuddy CLI 已登录账号;填写后优先使用当前凭证。' : undefined} style={{ marginBottom: 16 }} > = ({ /> - {(presetKeyFromForm === 'custom' || presetKeyFromForm === 'ollama') && ( - API Endpoint (URL)} name="baseUrl" rules={[{ required: true, message: '请输入有效的接口地址' }]} style={{ marginBottom: 0 }}> + {supportsAdvancedEndpoint && ( + API Endpoint (URL)} + name="baseUrl" + rules={presetKeyFromForm === 'codebuddy' ? [] : [{ required: true, message: '请输入有效的接口地址' }]} + style={{ marginBottom: 0 }} + > } style={{ borderRadius: 8, background: inputBg, border: `1px solid ${cardBorder}` }} diff --git a/frontend/src/components/ai/aiChatReadiness.test.ts b/frontend/src/components/ai/aiChatReadiness.test.ts index 65ac2980..d8027ba4 100644 --- a/frontend/src/components/ai/aiChatReadiness.test.ts +++ b/frontend/src/components/ai/aiChatReadiness.test.ts @@ -92,4 +92,28 @@ describe('buildAIChatReadinessSnapshot', () => { expect(snapshot.contextAttachedCount).toBe(1); expect(snapshot.title).toContain('OpenAI 主账号 / gpt-5.5'); }); + + it('treats CodeBuddy CLI as ready without explicit base url or model', () => { + const snapshot = buildAIChatReadinessSnapshot({ + providers: [{ + id: 'provider-1', + type: 'custom', + name: 'CodeBuddy', + apiKey: '', + hasSecret: true, + baseUrl: '', + model: '', + apiFormat: 'codebuddy-cli', + models: [], + maxTokens: 4096, + temperature: 0.2, + }], + activeProviderId: 'provider-1', + }); + + expect(snapshot.status).toBe('ready'); + expect(snapshot.ready).toBe(true); + expect(snapshot.title).toContain('CodeBuddy'); + expect(snapshot.title).toContain('自动选择'); + }); }); diff --git a/frontend/src/components/ai/aiChatReadiness.ts b/frontend/src/components/ai/aiChatReadiness.ts index a3de8c86..551d4d3a 100644 --- a/frontend/src/components/ai/aiChatReadiness.ts +++ b/frontend/src/components/ai/aiChatReadiness.ts @@ -62,6 +62,12 @@ const getProviderHost = (baseUrl: string): string => { const hasProviderSecret = (provider: AIProviderConfig): boolean => provider.hasSecret ?? Boolean(provider.secretRef || provider.apiKey); +const isBaseURLOptionalProvider = (provider: AIProviderConfig): boolean => + provider.type === 'custom' && trimText(provider.apiFormat) === 'codebuddy-cli'; + +const isModelOptionalProvider = (provider: AIProviderConfig): boolean => + provider.type === 'custom' && trimText(provider.apiFormat) === 'codebuddy-cli'; + const getSelectedProvider = (params: { providers?: AIProviderConfig[]; activeProvider?: AIProviderConfig | null; @@ -143,10 +149,10 @@ export const buildAIChatReadinessSnapshot = (params: { if (!hasProviderSecret(activeProvider)) { issues.push('missing_secret'); } - if (!trimText(activeProvider.baseUrl)) { + if (!isBaseURLOptionalProvider(activeProvider) && !trimText(activeProvider.baseUrl)) { issues.push('missing_base_url'); } - if (!trimText(activeProvider.model)) { + if (!isModelOptionalProvider(activeProvider) && !trimText(activeProvider.model)) { issues.push('missing_selected_model'); } @@ -189,7 +195,7 @@ export const buildAIChatReadinessSnapshot = (params: { }; } - if (!providerSummary.model) { + if (!providerSummary.model && !isModelOptionalProvider(activeProvider)) { const title = params.loadingModels ? `正在加载 ${providerSummary.name || providerSummary.id || '当前供应商'} 的模型列表` : `先为 ${providerSummary.name || providerSummary.id || '当前供应商'} 选择一个模型`; @@ -218,7 +224,11 @@ export const buildAIChatReadinessSnapshot = (params: { }; } - const title = `AI 已就绪:${providerSummary.name || providerSummary.id} / ${providerSummary.model}`; + const resolvedProviderLabel = providerSummary.name || providerSummary.id; + const resolvedModelLabel = providerSummary.model || (isModelOptionalProvider(activeProvider) ? '自动选择' : ''); + const title = resolvedModelLabel + ? `AI 已就绪:${resolvedProviderLabel} / ${resolvedModelLabel}` + : `AI 已就绪:${resolvedProviderLabel}`; const description = contextAttachedCount > 0 ? `当前已关联 ${contextAttachedCount} 张表结构上下文,可直接发送。` : hasConnectionContext diff --git a/frontend/src/components/ai/aiSettingsModalConfig.test.tsx b/frontend/src/components/ai/aiSettingsModalConfig.test.tsx index 8b6f95e2..952864fd 100644 --- a/frontend/src/components/ai/aiSettingsModalConfig.test.tsx +++ b/frontend/src/components/ai/aiSettingsModalConfig.test.tsx @@ -25,6 +25,16 @@ describe('aiSettingsModalConfig', () => { expect(preset.key).toBe('qwen-coding-plan'); }); + it('matches a CodeBuddy CLI provider back to the dedicated preset', () => { + const preset = matchProviderPreset({ + type: 'custom', + baseUrl: '', + apiFormat: 'codebuddy-cli', + }); + + expect(preset.key).toBe('codebuddy'); + }); + it('creates MCP server drafts and skill drafts with stable defaults', () => { const server = EMPTY_MCP_SERVER({ name: 'Browser', args: ['stdio'] }); const skill = EMPTY_SKILL(); @@ -38,6 +48,7 @@ describe('aiSettingsModalConfig', () => { it('keeps the provider preset list available for the settings modal', () => { expect(PROVIDER_PRESETS.some((item) => item.key === 'codex')).toBe(false); + expect(PROVIDER_PRESETS.some((item) => item.key === 'codebuddy')).toBe(true); expect(PROVIDER_PRESETS.some((item) => item.key === 'openai')).toBe(true); expect(PROVIDER_PRESETS.some((item) => item.key === 'custom')).toBe(true); }); diff --git a/frontend/src/components/ai/aiSettingsModalConfig.tsx b/frontend/src/components/ai/aiSettingsModalConfig.tsx index a806d615..5e543556 100644 --- a/frontend/src/components/ai/aiSettingsModalConfig.tsx +++ b/frontend/src/components/ai/aiSettingsModalConfig.tsx @@ -46,6 +46,7 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [ { key: 'volcengine-ark', label: '火山方舟', icon: , desc: 'Ark 通用推理 / 豆包模型', color: '#0ea5e9', backendType: 'openai', defaultBaseUrl: 'https://ark.cn-beijing.volces.com/api/v3', defaultModel: '', models: [] }, { key: 'volcengine-coding', label: '火山 Coding Plan', icon: , desc: 'Ark Code / Coding Plan', color: '#0284c7', backendType: 'openai', defaultBaseUrl: 'https://ark.cn-beijing.volces.com/api/coding/v3', defaultModel: '', models: [] }, { key: 'minimax', label: 'MiniMax', icon: , desc: 'M3 / M2.7 系列 (Anthropic 兼容)', color: '#e11d48', backendType: 'anthropic', defaultBaseUrl: 'https://api.minimaxi.com/anthropic', defaultModel: 'MiniMax-M3', models: ['MiniMax-M3', 'MiniMax-M2.7', 'MiniMax-M2.7-highspeed'] }, + { key: 'codebuddy', label: 'CodeBuddy', icon: , desc: '本地 CodeBuddy CLI / 官方登录态', color: '#2563eb', backendType: 'custom', fixedApiFormat: 'codebuddy-cli', defaultBaseUrl: '', defaultModel: '', models: [] }, { key: 'ollama', label: 'Ollama', icon: , desc: '本地部署开源模型', color: '#78716c', backendType: 'openai', defaultBaseUrl: 'http://localhost:11434/v1', defaultModel: 'llama3', models: [] }, { key: 'custom', label: '自定义', icon: , desc: '自定义 API 端点', color: '#64748b', backendType: 'custom', defaultBaseUrl: '', defaultModel: '', models: [] }, ]; diff --git a/frontend/src/types.ts b/frontend/src/types.ts index c01860d3..98473a90 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -600,7 +600,7 @@ export interface AIProviderConfig { baseUrl: string; model: string; models?: string[]; - apiFormat?: string; // custom 专用: openai | anthropic | gemini | claude-cli + apiFormat?: string; // custom 专用: openai | anthropic | gemini | claude-cli | codebuddy-cli headers?: Record; maxTokens: number; temperature: number; diff --git a/frontend/src/utils/aiProviderPresets.test.ts b/frontend/src/utils/aiProviderPresets.test.ts index c1384e1f..f872fc88 100644 --- a/frontend/src/utils/aiProviderPresets.test.ts +++ b/frontend/src/utils/aiProviderPresets.test.ts @@ -29,6 +29,7 @@ const PRESETS: PresetMatcher[] = [ defaultBaseUrl: QWEN_CODING_PLAN_ANTHROPIC_BASE_URL, fixedApiFormat: 'claude-cli', }, + { key: 'codebuddy', backendType: 'custom', defaultBaseUrl: '', fixedApiFormat: 'codebuddy-cli' }, { key: 'custom', backendType: 'custom', defaultBaseUrl: '' }, ]; @@ -112,7 +113,7 @@ describe('ai provider preset helpers', () => { it('keeps the user-entered base URL for custom-like presets', () => { expect(resolvePresetBaseURL({ - presetKey: 'custom', + presetKey: 'codebuddy', presetDefaultBaseUrl: '', valuesBaseUrl: 'https://example-proxy.internal/v1', })).toBe('https://example-proxy.internal/v1'); @@ -182,4 +183,18 @@ describe('resolveProviderPresetKey', () => { expect(key).toBe('qwen-bailian'); }); + + it('能识别没有 Base URL 的 CodeBuddy CLI 预设', () => { + const key = resolveProviderPresetKey( + { + type: 'custom', + apiFormat: 'codebuddy-cli', + baseUrl: '', + }, + PRESETS, + 'custom', + ); + + expect(key).toBe('codebuddy'); + }); }); diff --git a/frontend/src/utils/aiProviderPresets.ts b/frontend/src/utils/aiProviderPresets.ts index 0e965586..fd1fe33c 100644 --- a/frontend/src/utils/aiProviderPresets.ts +++ b/frontend/src/utils/aiProviderPresets.ts @@ -17,7 +17,7 @@ export const QWEN_CODING_PLAN_MODELS = [ 'glm-4.7', ]; -const CUSTOM_LIKE_PRESET_KEYS = new Set(['custom', 'ollama']); +const CUSTOM_LIKE_PRESET_KEYS = new Set(['custom', 'ollama', 'codebuddy']); export interface ResolvePresetModelSelectionInput { presetKey: string; @@ -126,6 +126,17 @@ export const resolveProviderPresetKey = ( } const fingerprint = getProviderFingerprint(provider.baseUrl); + const formatOnlyPreset = presets.find((preset) => + preset.backendType === provider.type + && Boolean(preset.fixedApiFormat) + && preset.fixedApiFormat === provider.apiFormat + && getProviderFingerprint(preset.defaultBaseUrl) === '' + && fingerprint === '', + ); + if (formatOnlyPreset) { + return formatOnlyPreset.key; + } + const exactPreset = presets.find((preset) => preset.backendType === provider.type && fingerprint !== '' diff --git a/internal/ai/provider/codebuddy_cli.go b/internal/ai/provider/codebuddy_cli.go new file mode 100644 index 00000000..84714c8e --- /dev/null +++ b/internal/ai/provider/codebuddy_cli.go @@ -0,0 +1,446 @@ +package provider + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "os/exec" + "runtime" + "strings" + "time" + + ai "GoNavi-Wails/internal/ai" + "GoNavi-Wails/internal/logger" +) + +var codebuddyLookPath = exec.LookPath +var codebuddyCommandContext = exec.CommandContext +var codebuddyCLIRequestTimeout = 90 * time.Second + +// CodeBuddyCLIProvider 通过 CodeBuddy CLI 发送聊天请求。 +type CodeBuddyCLIProvider struct { + config ai.ProviderConfig +} + +// NewCodeBuddyCLIProvider 创建 CodeBuddyCLIProvider 实例。 +func NewCodeBuddyCLIProvider(config ai.ProviderConfig) (Provider, error) { + return &CodeBuddyCLIProvider{config: config}, nil +} + +func (p *CodeBuddyCLIProvider) Name() string { + return "CodeBuddyCLI" +} + +func (p *CodeBuddyCLIProvider) Validate() error { + _, err := resolveCodeBuddyCLICommand(codebuddyLookPath) + if err != nil { + return err + } + return nil +} + +func (p *CodeBuddyCLIProvider) Chat(ctx context.Context, req ai.ChatRequest) (*ai.ChatResponse, error) { + if err := p.Validate(); err != nil { + return nil, err + } + + ctx, cancel := ensureClaudeCLITimeout(ctx, codebuddyCLIRequestTimeout) + defer cancel() + + commandName, err := resolveCodeBuddyCLICommand(codebuddyLookPath) + if err != nil { + return nil, err + } + + prompt := buildPrompt(req.Messages) + args := []string{"-p", prompt, "--output-format", "json", "--no-session-persistence"} + if strings.TrimSpace(p.config.Model) != "" { + args = append(args, "--model", strings.TrimSpace(p.config.Model)) + } + + cmd := codebuddyCommandContext(ctx, commandName, args...) + if err := p.setEnv(cmd); err != nil { + return nil, err + } + + requestLog := logAIUpstreamRequestStart( + p.Name(), + "CLI", + codebuddyCLIEndpointForLog(p.config), + buildCodeBuddyCLIRequestLogBody("json", commandName, args, prompt, p.config, req), + ) + var requestErr error + defer func() { + logAIUpstreamRequestFinish(requestLog, 0, requestErr) + }() + + output, err := cmd.Output() + if err != nil { + if isClaudeCLITimeout(ctx, err) { + requestErr = fmt.Errorf("CodeBuddy CLI 执行超时(%s),当前登录态、Base URL 或 API Key 可能没有返回有效响应", codebuddyCLIRequestTimeout) + return nil, requestErr + } + if exitErr, ok := err.(*exec.ExitError); ok { + requestErr = fmt.Errorf("CodeBuddy CLI 执行失败: %s", string(exitErr.Stderr)) + return nil, requestErr + } + requestErr = fmt.Errorf("CodeBuddy CLI 执行失败: %w", err) + return nil, requestErr + } + + resp, parseErr := parseCodeBuddyCLIChatOutput(output) + if parseErr != nil { + requestErr = parseErr + return nil, requestErr + } + return resp, nil +} + +func (p *CodeBuddyCLIProvider) ChatStream(ctx context.Context, req ai.ChatRequest, callback func(ai.StreamChunk)) error { + if err := p.Validate(); err != nil { + return err + } + + ctx, cancel := ensureClaudeCLITimeout(ctx, codebuddyCLIRequestTimeout) + defer cancel() + + commandName, err := resolveCodeBuddyCLICommand(codebuddyLookPath) + if err != nil { + return err + } + + prompt := buildPrompt(req.Messages) + args := []string{"-p", prompt, "--output-format", "stream-json", "--verbose", "--include-partial-messages", "--no-session-persistence"} + if strings.TrimSpace(p.config.Model) != "" { + args = append(args, "--model", strings.TrimSpace(p.config.Model)) + } + + cmd := codebuddyCommandContext(ctx, commandName, args...) + if err := p.setEnv(cmd); err != nil { + return err + } + + requestLog := logAIUpstreamRequestStart( + p.Name(), + "CLI", + codebuddyCLIEndpointForLog(p.config), + buildCodeBuddyCLIRequestLogBody("stream-json", commandName, args, prompt, p.config, req), + ) + var requestErr error + defer func() { + logAIUpstreamRequestFinish(requestLog, 0, requestErr) + }() + + cmd.Stdin = nil + + stdout, err := cmd.StdoutPipe() + if err != nil { + requestErr = fmt.Errorf("创建 stdout 管道失败: %w", err) + return requestErr + } + + var stderrBuf bytes.Buffer + cmd.Stderr = &stderrBuf + + if err := cmd.Start(); err != nil { + requestErr = fmt.Errorf("启动 CodeBuddy CLI 失败: %w", err) + return requestErr + } + + if cmd.Process != nil { + logger.Infof("CodeBuddyCLI 请求进程已启动:requestId=%s pid=%d", requestLog.id, cmd.Process.Pid) + } + + scanner := bufio.NewScanner(stdout) + scanner.Buffer(make([]byte, 64*1024), 1024*1024) + + for scanner.Scan() { + line := scanner.Text() + if strings.TrimSpace(line) == "" { + continue + } + + var event cliStreamEvent + if err := json.Unmarshal([]byte(line), &event); err != nil { + logger.Warnf("CodeBuddyCLI 忽略非 JSON 输出:requestId=%s line=%s", requestLog.id, RedactAIUpstreamLogText(line)) + continue + } + + switch event.Type { + case "system": + if isCodeBuddyCLISystemRetryEvent(event) { + if errMsg, hasError := extractCodeBuddyCLISystemRetryError(event); hasError { + callback(ai.StreamChunk{Error: errMsg, Done: true}) + requestErr = fmt.Errorf("CodeBuddy CLI 鉴权失败: %s", errMsg) + if cmd.Process != nil { + _ = cmd.Process.Kill() + } + _ = cmd.Wait() + return nil + } + } + case "assistant": + if errMsg, hasError := extractCodeBuddyCLIEventError(event); hasError { + callback(ai.StreamChunk{Error: errMsg, Done: true}) + requestErr = fmt.Errorf("CodeBuddy CLI 返回错误: %s", errMsg) + _ = cmd.Wait() + return nil + } + if event.Message.Content != nil { + for _, block := range event.Message.Content { + if block.Type == "thinking" && block.Thinking != "" { + callback(ai.StreamChunk{Thinking: block.Thinking}) + } else if block.Type == "text" && block.Text != "" { + callback(ai.StreamChunk{Content: block.Text}) + } + } + } + case "content_block_delta": + if event.Delta.Type == "thinking_delta" && event.Delta.Thinking != "" { + callback(ai.StreamChunk{Thinking: event.Delta.Thinking}) + } else if event.Delta.Text != "" { + callback(ai.StreamChunk{Content: event.Delta.Text}) + } + case "result": + if errMsg, hasError := extractCodeBuddyCLIEventError(event); hasError { + callback(ai.StreamChunk{Error: errMsg, Done: true}) + requestErr = fmt.Errorf("CodeBuddy CLI 返回错误: %s", errMsg) + _ = cmd.Wait() + return nil + } + callback(ai.StreamChunk{Done: true}) + _ = cmd.Wait() + return nil + case "error": + errMsg, _ := extractCodeBuddyCLIEventError(event) + callback(ai.StreamChunk{Error: errMsg, Done: true}) + requestErr = fmt.Errorf("CodeBuddy CLI 返回错误: %s", errMsg) + _ = cmd.Wait() + return nil + } + } + + waitErr := cmd.Wait() + stderrStr := strings.TrimSpace(stderrBuf.String()) + + if isClaudeCLITimeout(ctx, waitErr) { + requestErr = fmt.Errorf("CodeBuddy CLI 执行超时(%s),当前登录态、Base URL 或 API Key 可能没有返回有效响应", codebuddyCLIRequestTimeout) + callback(ai.StreamChunk{ + Error: requestErr.Error(), + Done: true, + }) + return nil + } + + if waitErr != nil { + errMsg := fmt.Sprintf("CodeBuddy CLI 异常退出: %v", waitErr) + if stderrStr != "" { + errMsg = fmt.Sprintf("CodeBuddy CLI 异常退出: %s", stderrStr) + } + requestErr = fmt.Errorf("%s", errMsg) + callback(ai.StreamChunk{Error: errMsg, Done: true}) + return nil + } + + callback(ai.StreamChunk{Done: true}) + return nil +} + +func resolveCodeBuddyCLICommand(lookPath func(string) (string, error)) (string, error) { + for _, command := range []string{"codebuddy", "cbc"} { + if _, err := lookPath(command); err == nil { + return command, nil + } + } + return "", fmt.Errorf("未找到 codebuddy 命令,请先安装 CodeBuddy CLI: npm install -g @tencent/codebuddy") +} + +func codebuddyCLIEndpointForLog(config ai.ProviderConfig) string { + baseURL := strings.TrimRight(strings.TrimSpace(config.BaseURL), "/") + if baseURL != "" { + return sanitizeAIUpstreamURL(baseURL) + } + return "codebuddy://cli" +} + +func buildCodeBuddyCLIRequestLogBody(outputFormat string, commandName string, args []string, prompt string, config ai.ProviderConfig, req ai.ChatRequest) map[string]any { + return map[string]any{ + "command": commandName, + "args": claudeCLIArgsForLog(args), + "prompt": prompt, + "output_format": outputFormat, + "model": strings.TrimSpace(config.Model), + "base_url": codebuddyCLIEndpointForLog(config), + "has_api_key": strings.TrimSpace(config.APIKey) != "", + "message_count": len(req.Messages), + "tool_count": len(req.Tools), + "tool_names": claudeCLIToolNamesForLog(req.Tools), + } +} + +func parseCodeBuddyCLIChatOutput(output []byte) (*ai.ChatResponse, error) { + trimmed := bytes.TrimSpace(output) + if len(trimmed) == 0 { + return &ai.ChatResponse{}, nil + } + + var events []cliStreamEvent + if err := json.Unmarshal(trimmed, &events); err == nil && len(events) > 0 { + return buildCodeBuddyCLIResponseFromEvents(events) + } + + var event cliStreamEvent + if err := json.Unmarshal(trimmed, &event); err == nil { + return buildCodeBuddyCLIResponseFromEvents([]cliStreamEvent{event}) + } + + return &ai.ChatResponse{Content: strings.TrimSpace(string(output))}, nil +} + +func buildCodeBuddyCLIResponseFromEvents(events []cliStreamEvent) (*ai.ChatResponse, error) { + parts := make([]string, 0, len(events)) + resultText := "" + + for _, event := range events { + if errMsg, hasError := extractCodeBuddyCLIEventError(event); hasError { + return nil, fmt.Errorf("CodeBuddy CLI 返回错误: %s", errMsg) + } + if strings.TrimSpace(event.Result) != "" { + resultText = strings.TrimSpace(event.Result) + } + for _, block := range event.Message.Content { + if block.Type == "text" && strings.TrimSpace(block.Text) != "" { + parts = append(parts, block.Text) + } + } + } + + if resultText != "" { + return &ai.ChatResponse{Content: resultText}, nil + } + if len(parts) > 0 { + return &ai.ChatResponse{Content: strings.Join(parts, "")}, nil + } + return &ai.ChatResponse{}, nil +} + +func (p *CodeBuddyCLIProvider) setEnv(cmd *exec.Cmd) error { + env, err := buildCodeBuddyCLIEnv(p.config, cmd.Environ(), runtime.GOOS, codebuddyLookPath, fileExists) + if err != nil { + return err + } + cmd.Env = env + return nil +} + +func buildCodeBuddyCLIEnv(config ai.ProviderConfig, baseEnv []string, goos string, lookPath func(string) (string, error), exists func(string) bool) ([]string, error) { + env := append([]string(nil), baseEnv...) + if strings.TrimSpace(config.BaseURL) != "" { + env = upsertEnv(env, "CODEBUDDY_BASE_URL", strings.TrimRight(strings.TrimSpace(config.BaseURL), "/")) + } + if strings.TrimSpace(config.APIKey) != "" { + env = upsertEnv(env, "CODEBUDDY_API_KEY", strings.TrimSpace(config.APIKey)) + env = upsertEnv(env, "CODEBUDDY_AUTH_TOKEN", strings.TrimSpace(config.APIKey)) + } + if len(config.Headers) > 0 { + if payload, err := json.Marshal(config.Headers); err == nil { + env = upsertEnv(env, "CODEBUDDY_CUSTOM_HEADERS", string(payload)) + } + } + + gitBashPath, err := resolveCodeBuddyGitBashPath(env, goos, lookPath, exists) + if err != nil { + return nil, err + } + if gitBashPath != "" { + env = upsertEnv(env, "CODEBUDDY_CODE_GIT_BASH_PATH", gitBashPath) + } + return env, nil +} + +func resolveCodeBuddyGitBashPath(env []string, goos string, lookPath func(string) (string, error), exists func(string) bool) (string, error) { + if goos != "windows" { + return "", nil + } + + if configured := strings.TrimSpace(envValue(env, "CODEBUDDY_CODE_GIT_BASH_PATH")); configured != "" { + if exists(configured) { + return configured, nil + } + return "", fmt.Errorf("CodeBuddy CLI 在 Windows 下配置的 CODEBUDDY_CODE_GIT_BASH_PATH 不存在: %s", configured) + } + + for _, command := range []string{"bash.exe", "bash"} { + if bashPath, err := lookPath(command); err == nil && exists(bashPath) { + return bashPath, nil + } + } + + if gitPath, err := lookPath("git.exe"); err == nil { + gitDir := parentWindowsPath(gitPath) + for _, candidate := range []string{ + joinWindowsPath(parentWindowsPath(gitDir), "bin", "bash.exe"), + joinWindowsPath(gitDir, "bash.exe"), + } { + if candidate != "" && exists(candidate) { + return candidate, nil + } + } + } + + for _, candidate := range windowsGitBashCandidates(env) { + if exists(candidate) { + return candidate, nil + } + } + + return "", nil +} + +func extractCodeBuddyCLIEventError(event cliStreamEvent) (string, bool) { + if event.Type != "error" && !event.IsError { + return "", false + } + + if msg := strings.TrimSpace(event.Result); msg != "" { + return msg, true + } + + for _, block := range event.Message.Content { + if block.Type == "text" && strings.TrimSpace(block.Text) != "" { + return strings.TrimSpace(block.Text), true + } + } + + if msg := strings.TrimSpace(event.Error.Message); msg != "" { + return msg, true + } + + return "CodeBuddy CLI 返回未知错误", true +} + +func isCodeBuddyCLISystemRetryEvent(event cliStreamEvent) bool { + return event.Type == "system" && event.Subtype == "api_retry" +} + +func extractCodeBuddyCLISystemRetryError(event cliStreamEvent) (string, bool) { + if !isCodeBuddyCLISystemRetryEvent(event) { + return "", false + } + + errText := strings.TrimSpace(event.Error.Message) + if event.ErrorStatus != 401 && event.ErrorStatus != 403 && !strings.EqualFold(errText, "authentication_failed") { + return "", false + } + + if errText == "" { + errText = "authentication_failed" + } + + if event.ErrorStatus > 0 { + return fmt.Sprintf("CodeBuddy CLI 鉴权失败 (HTTP %d): %s", event.ErrorStatus, errText), true + } + return fmt.Sprintf("CodeBuddy CLI 鉴权失败: %s", errText), true +} diff --git a/internal/ai/provider/codebuddy_cli_test.go b/internal/ai/provider/codebuddy_cli_test.go new file mode 100644 index 00000000..6112fa83 --- /dev/null +++ b/internal/ai/provider/codebuddy_cli_test.go @@ -0,0 +1,140 @@ +package provider + +import ( + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + + "GoNavi-Wails/internal/ai" +) + +func TestBuildCodeBuddyCLIEnv_IncludesOfficialEnvNames(t *testing.T) { + env, err := buildCodeBuddyCLIEnv(ai.ProviderConfig{ + BaseURL: "https://gateway.codebuddy.example/", + APIKey: "cb-test", + Headers: map[string]string{ + "X-Workspace": "gonavi", + }, + }, []string{"PATH=/usr/bin"}, "darwin", func(name string) (string, error) { + return "", errors.New("unexpected lookup") + }, func(path string) bool { + return false + }) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if got := envValue(env, "CODEBUDDY_BASE_URL"); got != "https://gateway.codebuddy.example" { + t.Fatalf("expected trimmed base url, got %q", got) + } + if got := envValue(env, "CODEBUDDY_API_KEY"); got != "cb-test" { + t.Fatalf("expected api key in env, got %q", got) + } + if got := envValue(env, "CODEBUDDY_AUTH_TOKEN"); got != "cb-test" { + t.Fatalf("expected auth token in env, got %q", got) + } + if got := envValue(env, "CODEBUDDY_CUSTOM_HEADERS"); !strings.Contains(got, `"X-Workspace":"gonavi"`) { + t.Fatalf("expected custom headers JSON in env, got %q", got) + } +} + +func TestBuildCodeBuddyCLIEnv_AllowsMissingGitBashOnWindows(t *testing.T) { + env, err := buildCodeBuddyCLIEnv(ai.ProviderConfig{}, []string{"ProgramFiles=C:\\Program Files"}, "windows", func(name string) (string, error) { + return "", errors.New("not found") + }, func(path string) bool { + return false + }) + if err != nil { + t.Fatalf("expected no error when git bash is missing on windows, got %v", err) + } + if got := envValue(env, "CODEBUDDY_CODE_GIT_BASH_PATH"); got != "" { + t.Fatalf("expected no git bash env when missing, got %q", got) + } +} + +func TestCodeBuddyCLIProvider_ChatParsesJSONEventArray(t *testing.T) { + fakeCodeBuddy := writeFakeCodeBuddyScript(t, "#!/bin/sh\necho '[{\"type\":\"assistant\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"hello \"}]}},{\"type\":\"result\",\"subtype\":\"success\",\"is_error\":false,\"result\":\"hello world\"}]'\n") + restore := overrideCodeBuddyCLIForTest(t, fakeCodeBuddy) + defer restore() + + provider, err := NewCodeBuddyCLIProvider(ai.ProviderConfig{ + APIKey: "cb-test", + }) + if err != nil { + t.Fatalf("unexpected provider error: %v", err) + } + + resp, err := provider.Chat(context.Background(), ai.ChatRequest{ + Messages: []ai.Message{{Role: "user", Content: "ping"}}, + }) + if err != nil { + t.Fatalf("expected chat to succeed, got %v", err) + } + if resp.Content != "hello world" { + t.Fatalf("expected result content, got %#v", resp) + } +} + +func writeFakeCodeBuddyScript(t *testing.T, content string) string { + t.Helper() + dir := t.TempDir() + + if runtime.GOOS == "windows" { + bashPath, err := resolveClaudeCodeGitBashPath(os.Environ(), runtime.GOOS, exec.LookPath, fileExists) + if err != nil { + t.Fatalf("failed to resolve git bash for fake codebuddy command: %v", err) + } + + scriptPath := filepath.Join(dir, "codebuddy.sh") + if err := os.WriteFile(scriptPath, []byte(content), 0o755); err != nil { + t.Fatalf("failed to write fake codebuddy shell script: %v", err) + } + + wrapperPath := filepath.Join(dir, "codebuddy.cmd") + wrapper := "@echo off\r\n\"" + bashPath + "\" \"" + scriptPath + "\" %*\r\n" + if err := os.WriteFile(wrapperPath, []byte(wrapper), 0o755); err != nil { + t.Fatalf("failed to write fake codebuddy wrapper: %v", err) + } + return wrapperPath + } + + path := filepath.Join(dir, "codebuddy") + if err := os.WriteFile(path, []byte(content), 0o755); err != nil { + t.Fatalf("failed to write fake codebuddy script: %v", err) + } + return path +} + +func overrideCodeBuddyCLIForTest(t *testing.T, fakeCodeBuddyPath string) func() { + t.Helper() + + originalLookPath := codebuddyLookPath + originalCommandContext := codebuddyCommandContext + codebuddyLookPath = func(name string) (string, error) { + if name == "codebuddy" || name == "cbc" { + return fakeCodeBuddyPath, nil + } + return originalLookPath(name) + } + codebuddyCommandContext = func(ctx context.Context, name string, args ...string) *exec.Cmd { + if name == "codebuddy" || name == "cbc" { + return exec.CommandContext(ctx, fakeCodeBuddyPath, args...) + } + return originalCommandContext(ctx, name, args...) + } + + originalPath := os.Getenv("PATH") + if err := os.Setenv("PATH", filepath.Dir(fakeCodeBuddyPath)+string(os.PathListSeparator)+originalPath); err != nil { + t.Fatalf("failed to override PATH: %v", err) + } + + return func() { + codebuddyLookPath = originalLookPath + codebuddyCommandContext = originalCommandContext + _ = os.Setenv("PATH", originalPath) + } +} diff --git a/internal/ai/provider/custom.go b/internal/ai/provider/custom.go index 7900ec27..04a59838 100644 --- a/internal/ai/provider/custom.go +++ b/internal/ai/provider/custom.go @@ -17,15 +17,14 @@ type CustomProvider struct { // NewCustomProvider 创建自定义 Provider 实例 func NewCustomProvider(config ai.ProviderConfig) (Provider, error) { - if strings.TrimSpace(config.BaseURL) == "" { - return nil, fmt.Errorf("自定义 Provider 必须指定 Base URL") - } - // 根据 apiFormat 决定使用哪个底层协议,默认 openai apiFormat := strings.ToLower(strings.TrimSpace(config.APIFormat)) if apiFormat == "" { apiFormat = "openai" } + if strings.TrimSpace(config.BaseURL) == "" && apiFormat != "claude-cli" && apiFormat != "codebuddy-cli" { + return nil, fmt.Errorf("自定义 Provider 必须指定 Base URL") + } var innerProvider Provider var err error @@ -36,6 +35,8 @@ func NewCustomProvider(config ai.ProviderConfig) (Provider, error) { innerProvider, err = NewGeminiProvider(config) case "claude-cli": innerProvider, err = NewClaudeCLIProvider(config) + case "codebuddy-cli": + innerProvider, err = NewCodeBuddyCLIProvider(config) default: // "openai" 及其他 innerProvider, err = NewOpenAIProvider(config) } diff --git a/internal/ai/service/service.go b/internal/ai/service/service.go index c36a423c..9edd5562 100644 --- a/internal/ai/service/service.go +++ b/internal/ai/service/service.go @@ -102,6 +102,25 @@ var claudeCLIHealthCheckFunc = func(config ai.ProviderConfig) error { return err } +var codebuddyCLIHealthCheckFunc = func(config ai.ProviderConfig) error { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + cliProvider, err := provider.NewProvider(config) + if err != nil { + return err + } + + _, err = cliProvider.Chat(ctx, ai.ChatRequest{ + Messages: []ai.Message{ + {Role: "user", Content: "ping"}, + }, + MaxTokens: 1, + Temperature: 0, + }) + return err +} + // NewService 创建 AI Service 实例 func NewService() *Service { return NewServiceWithSecretStore(secretstore.NewKeyringStore()) @@ -533,6 +552,8 @@ func (s *Service) AITestProvider(config ai.ProviderConfig) map[string]interface{ testConfig.Model = dashScopeCodingPlanModels[0] } err = claudeCLIHealthCheckFunc(testConfig) + case "codebuddy-cli": + err = codebuddyCLIHealthCheckFunc(config) default: if baseURL != "" { req, _ := http.NewRequest("GET", baseURL, nil) @@ -660,6 +681,9 @@ func filterFetchedModelsForProvider(config ai.ProviderConfig, models []string) ( } func defaultStaticModelsForProvider(config ai.ProviderConfig) []string { + if normalizedProviderType(config) == "codebuddy-cli" { + return append([]string(nil), config.Models...) + } if isMiniMaxAnthropicProvider(config) { return append([]string(nil), miniMaxAnthropicModels...) } @@ -726,6 +750,8 @@ func resolveModelsURL(config ai.ProviderConfig) string { baseURL = "https://generativelanguage.googleapis.com" } return baseURL + "/v1beta/models?key=" + config.APIKey + case "codebuddy-cli": + return "" case "openai": fallthrough default: @@ -736,6 +762,9 @@ func resolveModelsURL(config ai.ProviderConfig) string { func newModelsRequest(config ai.ProviderConfig) (*http.Request, error) { config = normalizeProviderConfig(config) url := resolveModelsURL(config) + if strings.TrimSpace(url) == "" { + return nil, fmt.Errorf("当前供应商不支持远端模型列表") + } req, err := http.NewRequest("GET", url, nil) if err != nil { return nil, fmt.Errorf("创建请求失败: %w", err) @@ -862,6 +891,13 @@ func (s *Service) AIListModels() map[string]interface{} { } config = normalizeProviderConfig(config) + if normalizedProviderType(config) == "codebuddy-cli" { + return map[string]interface{}{ + "success": true, + "models": append([]string(nil), config.Models...), + "source": "static", + } + } if staticModels := defaultStaticModelsForProvider(config); len(staticModels) > 0 { return map[string]interface{}{"success": true, "models": staticModels, "source": "static"} } @@ -899,6 +935,8 @@ func fetchModels(config ai.ProviderConfig) ([]string, error) { return fetchAnthropicModels(config) case "gemini": return fetchGeminiModels(config) + case "codebuddy-cli": + return append([]string(nil), config.Models...), nil default: return fetchOpenAIModels(config) } diff --git a/internal/ai/service/service_codebuddy_test.go b/internal/ai/service/service_codebuddy_test.go new file mode 100644 index 00000000..77b0f7e2 --- /dev/null +++ b/internal/ai/service/service_codebuddy_test.go @@ -0,0 +1,61 @@ +package aiservice + +import ( + "testing" + + "GoNavi-Wails/internal/ai" +) + +func TestAIListModels_ReturnsStaticModelsForCodeBuddyCLIWithoutRemoteFetch(t *testing.T) { + service := NewService() + service.providers = []ai.ProviderConfig{ + { + ID: "provider-codebuddy", + Type: "custom", + APIFormat: "codebuddy-cli", + Models: []string{"claude-sonnet-4", "gpt-4.1"}, + }, + } + service.activeProvider = "provider-codebuddy" + + result := service.AIListModels() + if result["success"] != true { + t.Fatalf("expected AIListModels to succeed, got %#v", result) + } + models, ok := result["models"].([]string) + if !ok { + t.Fatalf("expected []string models, got %#v", result["models"]) + } + if len(models) != 2 || models[0] != "claude-sonnet-4" { + t.Fatalf("expected static CodeBuddy models, got %#v", models) + } + if source, _ := result["source"].(string); source != "static" { + t.Fatalf("expected static source, got %#v", result["source"]) + } +} + +func TestAITestProvider_UsesCodeBuddyCLIHealthCheck(t *testing.T) { + originalHealthCheckFunc := codebuddyCLIHealthCheckFunc + defer func() { + codebuddyCLIHealthCheckFunc = originalHealthCheckFunc + }() + + var received ai.ProviderConfig + codebuddyCLIHealthCheckFunc = func(config ai.ProviderConfig) error { + received = config + return nil + } + + service := NewService() + result := service.AITestProvider(ai.ProviderConfig{ + Type: "custom", + APIFormat: "codebuddy-cli", + APIKey: "cb-test", + }) + if result["success"] != true { + t.Fatalf("expected AITestProvider to succeed, got %#v", result) + } + if received.APIFormat != "codebuddy-cli" { + t.Fatalf("expected CodeBuddy test to use codebuddy-cli api format, got %q", received.APIFormat) + } +} diff --git a/internal/ai/types.go b/internal/ai/types.go index c296e503..223b9cce 100644 --- a/internal/ai/types.go +++ b/internal/ai/types.go @@ -80,7 +80,7 @@ type ProviderConfig struct { BaseURL string `json:"baseUrl"` Model string `json:"model"` Models []string `json:"models,omitempty"` - APIFormat string `json:"apiFormat,omitempty"` // custom 专用: openai | anthropic | gemini | claude-cli + APIFormat string `json:"apiFormat,omitempty"` // custom 专用: openai | anthropic | gemini | claude-cli | codebuddy-cli Headers map[string]string `json:"headers,omitempty"` MaxTokens int `json:"maxTokens"` Temperature float64 `json:"temperature"`