mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-12 01:24:12 +08:00
✨ feat(ai): 接入 CodeBuddy CLI 并兼容官方登录态
- 新增 CodeBuddy CLI provider,支持 codebuddy/cbc 命令调用与输出解析 - 将 Base URL、API Key/Auth Token、自定义请求头映射到 CodeBuddy CLI 环境变量 - 扩展 custom provider 路由与测试链路,兼容空 Base URL 和 CLI 默认模型选择 - AI 设置新增 CodeBuddy 预设,并补齐 preset 回显识别与匹配逻辑 - 修正就绪态、模型列表与表单校验,允许留空凭证直接复用本机已登录账号 - 补充前后端定向测试并覆盖 CodeBuddy 配置展示文案 Close #574
This commit is contained in:
@@ -345,7 +345,7 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ 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<AISettingsModalProps> = ({ 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?.({
|
||||
|
||||
@@ -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 (
|
||||
<AISettingsProvidersSection
|
||||
providers={[provider]}
|
||||
activeProviderId="provider-1"
|
||||
editingProvider={{ ...provider, apiFormat: 'codebuddy-cli' }}
|
||||
isEditing
|
||||
form={form}
|
||||
providerPresets={providerPresets}
|
||||
watchedPresetKey="codebuddy"
|
||||
watchedApiFormat="codebuddy-cli"
|
||||
loading={false}
|
||||
testStatus="idle"
|
||||
primaryPasswordVisible={false}
|
||||
darkMode={false}
|
||||
overlayTheme={overlayTheme}
|
||||
cardBg="#fff"
|
||||
cardBorder="rgba(0,0,0,0.08)"
|
||||
inputBg="#fff"
|
||||
onPrimaryPasswordVisibleChange={() => {}}
|
||||
resolveProviderPreset={() => ({ label: 'CodeBuddy', icon: <span>C</span> })}
|
||||
resolvePresetByKey={(key) => providerPresets.find((item) => item.key === key) || providerPresets[0]}
|
||||
onAddProvider={() => {}}
|
||||
onEditProvider={() => {}}
|
||||
onDeleteProvider={() => {}}
|
||||
onSetActiveProvider={() => {}}
|
||||
onCancelEdit={() => {}}
|
||||
onPresetChange={() => {}}
|
||||
onTestProvider={() => {}}
|
||||
onSaveProvider={() => {}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const markup = renderToStaticMarkup(<Wrap />);
|
||||
expect(markup).toContain('API Key / Auth Token');
|
||||
expect(markup).toContain('本机 CodeBuddy CLI 已登录账号');
|
||||
expect(markup).toContain('留空则使用 CodeBuddy CLI 默认网关');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -106,6 +106,8 @@ const AISettingsProvidersSection: React.FC<AISettingsProvidersSectionProps> = ({
|
||||
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<AISettingsProvidersSectionProps> = ({
|
||||
{providers.map((provider) => {
|
||||
const matchedPreset = resolveProviderPreset(provider);
|
||||
const isActive = provider.id === activeProviderId;
|
||||
const modelLabel = provider.model || (provider.apiFormat === 'codebuddy-cli' ? '自动选择' : '未选择模型');
|
||||
return (
|
||||
<div
|
||||
key={provider.id}
|
||||
@@ -170,7 +173,7 @@ const AISettingsProvidersSection: React.FC<AISettingsProvidersSectionProps> = ({
|
||||
<div style={{ fontSize: 12, color: overlayTheme.mutedText, marginTop: 4, display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span>{matchedPreset.label}</span>
|
||||
<span style={{ opacity: 0.4 }}>·</span>
|
||||
<span style={{ fontFamily: 'var(--gn-font-mono)', fontSize: 12 }}>{provider.model || '未选择模型'}</span>
|
||||
<span style={{ fontFamily: 'var(--gn-font-mono)', fontSize: 12 }}>{modelLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Space size={2}>
|
||||
@@ -267,19 +270,21 @@ const AISettingsProvidersSection: React.FC<AISettingsProvidersSectionProps> = ({
|
||||
<Form.Item name="type" hidden><Input /></Form.Item>
|
||||
</div>
|
||||
|
||||
{(presetKeyFromForm === 'custom' || presetKeyFromForm === 'ollama') && (
|
||||
{supportsAdvancedEndpoint && (
|
||||
<div style={{ ...currentFieldGroupStyle, marginTop: 16 }}>
|
||||
<div style={currentFieldLabelStyle}>
|
||||
<RobotOutlined style={{ fontSize: 14 }} /> 基本信息
|
||||
</div>
|
||||
|
||||
<Form.Item label={<span style={{ fontWeight: 500, color: overlayTheme.titleText }}>供应商名称</span>} name="name" rules={[{ required: true, message: '请输入名称' }]} style={{ marginBottom: 16 }}>
|
||||
<Input
|
||||
placeholder="例如:我的自建 OpenAI / 专属大模型"
|
||||
size="middle"
|
||||
style={{ borderRadius: 8, background: inputBg, border: `1px solid ${cardBorder}` }}
|
||||
/>
|
||||
</Form.Item>
|
||||
{presetKeyFromForm === 'custom' && (
|
||||
<Form.Item label={<span style={{ fontWeight: 500, color: overlayTheme.titleText }}>供应商名称</span>} name="name" rules={[{ required: true, message: '请输入名称' }]} style={{ marginBottom: 16 }}>
|
||||
<Input
|
||||
placeholder="例如:我的自建 OpenAI / 专属大模型"
|
||||
size="middle"
|
||||
style={{ borderRadius: 8, background: inputBg, border: `1px solid ${cardBorder}` }}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
{presetKeyFromForm === 'custom' && (
|
||||
<Form.Item label={<span style={{ fontWeight: 500, color: overlayTheme.titleText }}>API 格式</span>} name="apiFormat" style={{ marginBottom: 16 }}>
|
||||
@@ -314,7 +319,7 @@ const AISettingsProvidersSection: React.FC<AISettingsProvidersSectionProps> = ({
|
||||
)}
|
||||
|
||||
<Form.Item label={<span style={{ fontWeight: 500, color: overlayTheme.titleText }}>可用模型列表(可选配置)</span>} name="models" style={{ marginBottom: 0 }}>
|
||||
<Select mode="tags" size="middle" placeholder="配置指定的模型ID,留空则默认去服务端拉取" style={{ width: '100%' }} />
|
||||
<Select mode="tags" size="middle" placeholder={codeBuddyUsesOptionalSecret ? '可选:预填常用模型;留空则由 CodeBuddy CLI 或服务端自动选择' : '配置指定的模型ID,留空则默认去服务端拉取'} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
)}
|
||||
@@ -326,21 +331,22 @@ const AISettingsProvidersSection: React.FC<AISettingsProvidersSectionProps> = ({
|
||||
<KeyOutlined style={{ fontSize: 14 }} /> 认证 & 连接
|
||||
</div>
|
||||
<Form.Item
|
||||
label={<span style={{ fontWeight: 500, color: overlayTheme.titleText }}>API Key</span>}
|
||||
label={<span style={{ fontWeight: 500, color: overlayTheme.titleText }}>{codeBuddyUsesOptionalSecret ? 'API Key / Auth Token(可选)' : 'API Key'}</span>}
|
||||
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 }}
|
||||
>
|
||||
<Input.Password
|
||||
placeholder="sk-... / 你的 API Key"
|
||||
placeholder={codeBuddyUsesOptionalSecret ? '留空走本机登录态,或填写 API Key / Token 覆盖' : 'sk-... / 你的 API Key'}
|
||||
size="middle"
|
||||
visibilityToggle={{
|
||||
visible: primaryPasswordVisible,
|
||||
@@ -350,10 +356,15 @@ const AISettingsProvidersSection: React.FC<AISettingsProvidersSectionProps> = ({
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{(presetKeyFromForm === 'custom' || presetKeyFromForm === 'ollama') && (
|
||||
<Form.Item label={<span style={{ fontWeight: 500, color: overlayTheme.titleText }}>API Endpoint (URL)</span>} name="baseUrl" rules={[{ required: true, message: '请输入有效的接口地址' }]} style={{ marginBottom: 0 }}>
|
||||
{supportsAdvancedEndpoint && (
|
||||
<Form.Item
|
||||
label={<span style={{ fontWeight: 500, color: overlayTheme.titleText }}>API Endpoint (URL)</span>}
|
||||
name="baseUrl"
|
||||
rules={presetKeyFromForm === 'codebuddy' ? [] : [{ required: true, message: '请输入有效的接口地址' }]}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<Input
|
||||
placeholder={resolvePresetByKey(presetKeyFromForm).defaultBaseUrl || 'https://...'}
|
||||
placeholder={presetKeyFromForm === 'codebuddy' ? '留空则使用 CodeBuddy CLI 默认网关' : (resolvePresetByKey(presetKeyFromForm).defaultBaseUrl || 'https://...')}
|
||||
size="middle"
|
||||
suffix={<LinkOutlined style={{ color: overlayTheme.mutedText }} />}
|
||||
style={{ borderRadius: 8, background: inputBg, border: `1px solid ${cardBorder}` }}
|
||||
|
||||
@@ -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('自动选择');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -46,6 +46,7 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
|
||||
{ key: 'volcengine-ark', label: '火山方舟', icon: <CloudOutlined />, desc: 'Ark 通用推理 / 豆包模型', color: '#0ea5e9', backendType: 'openai', defaultBaseUrl: 'https://ark.cn-beijing.volces.com/api/v3', defaultModel: '', models: [] },
|
||||
{ key: 'volcengine-coding', label: '火山 Coding Plan', icon: <CloudOutlined />, 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: <ExperimentOutlined />, 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: <ApiOutlined />, desc: '本地 CodeBuddy CLI / 官方登录态', color: '#2563eb', backendType: 'custom', fixedApiFormat: 'codebuddy-cli', defaultBaseUrl: '', defaultModel: '', models: [] },
|
||||
{ key: 'ollama', label: 'Ollama', icon: <AppstoreOutlined />, desc: '本地部署开源模型', color: '#78716c', backendType: 'openai', defaultBaseUrl: 'http://localhost:11434/v1', defaultModel: 'llama3', models: [] },
|
||||
{ key: 'custom', label: '自定义', icon: <AppstoreOutlined />, desc: '自定义 API 端点', color: '#64748b', backendType: 'custom', defaultBaseUrl: '', defaultModel: '', models: [] },
|
||||
];
|
||||
|
||||
@@ -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<string, string>;
|
||||
maxTokens: number;
|
||||
temperature: number;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 !== ''
|
||||
|
||||
446
internal/ai/provider/codebuddy_cli.go
Normal file
446
internal/ai/provider/codebuddy_cli.go
Normal file
@@ -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
|
||||
}
|
||||
140
internal/ai/provider/codebuddy_cli_test.go
Normal file
140
internal/ai/provider/codebuddy_cli_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
61
internal/ai/service/service_codebuddy_test.go
Normal file
61
internal/ai/service/service_codebuddy_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
Reference in New Issue
Block a user