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:
Syngnat
2026-06-18 12:06:58 +08:00
parent c8fe90cbee
commit f457f6aaca
16 changed files with 842 additions and 30 deletions

View File

@@ -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?.({

View File

@@ -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 默认网关');
});
});

View File

@@ -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}` }}

View File

@@ -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('自动选择');
});
});

View File

@@ -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

View File

@@ -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);
});

View File

@@ -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: [] },
];

View File

@@ -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;

View File

@@ -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');
});
});

View File

@@ -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 !== ''