mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-07 19:11:39 +08:00
✨ feat(ai): 接入 Cursor Cloud Agents API
- 新增 cursor-agent provider,支持创建 agent、轮询 run 状态和 SSE 流式响应 - 接入 AITestProvider 与 AIListModels,支持 Cursor 官方 /v1/models 连通性测试和模型发现 - 在 AI 设置中新增 Cursor 供应商预设,固定 cursor-agent 协议并补齐默认端点配置 - 调整 provider readiness 与 insights 规则,允许 Cursor 未显式选模型时走官方默认模型 - 补充后端 provider/service 测试和前端 preset、表单、readiness 相关用例 Close #576
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' || values.presetKey === 'codebuddy';
|
||||
const isCustomLike = values.presetKey === 'custom' || values.presetKey === 'ollama' || values.presetKey === 'codebuddy' || values.presetKey === 'cursor';
|
||||
const { model: finalModel, models: resolvedModels } = resolvePresetModelSelection({
|
||||
presetKey: values.presetKey,
|
||||
presetDefaultModel: preset.defaultModel,
|
||||
|
||||
@@ -9,6 +9,7 @@ import AISettingsProvidersSection from './AISettingsProvidersSection';
|
||||
|
||||
const providerPresets = [
|
||||
{ key: 'openai', label: 'OpenAI', icon: <span>O</span>, desc: 'GPT', defaultBaseUrl: 'https://api.openai.com/v1' },
|
||||
{ key: 'cursor', label: 'Cursor', icon: <span>R</span>, desc: 'Cursor API', defaultBaseUrl: 'https://api.cursor.com/v1' },
|
||||
{ key: 'custom', label: '自定义', icon: <span>C</span>, desc: '自定义接口', defaultBaseUrl: 'https://example.com' },
|
||||
];
|
||||
|
||||
@@ -150,4 +151,44 @@ describe('AISettingsProvidersSection', () => {
|
||||
expect(markup).toContain('本机 CodeBuddy CLI 已登录账号');
|
||||
expect(markup).toContain('留空则使用 CodeBuddy CLI 默认网关');
|
||||
});
|
||||
|
||||
it('renders automatic-model copy for the Cursor preset', () => {
|
||||
const Wrap = () => {
|
||||
const [form] = Form.useForm();
|
||||
return (
|
||||
<AISettingsProvidersSection
|
||||
providers={[provider]}
|
||||
activeProviderId="provider-1"
|
||||
editingProvider={{ ...provider, apiFormat: 'cursor-agent', baseUrl: 'https://api.cursor.com/v1' }}
|
||||
isEditing
|
||||
form={form}
|
||||
providerPresets={providerPresets}
|
||||
watchedPresetKey="cursor"
|
||||
watchedApiFormat="cursor-agent"
|
||||
loading={false}
|
||||
testStatus="idle"
|
||||
primaryPasswordVisible={false}
|
||||
darkMode={false}
|
||||
overlayTheme={overlayTheme}
|
||||
cardBg="#fff"
|
||||
cardBorder="rgba(0,0,0,0.08)"
|
||||
inputBg="#fff"
|
||||
onPrimaryPasswordVisibleChange={() => {}}
|
||||
resolveProviderPreset={() => ({ label: 'Cursor', icon: <span>R</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('可选:预填常用 Cursor 模型 ID;留空则由 Cursor 默认模型自动选择');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -106,8 +106,9 @@ 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 supportsAdvancedEndpoint = presetKeyFromForm === 'custom' || presetKeyFromForm === 'ollama' || presetKeyFromForm === 'codebuddy' || presetKeyFromForm === 'cursor';
|
||||
const codeBuddyUsesOptionalSecret = presetKeyFromForm === 'codebuddy';
|
||||
const cursorUsesOptionalModel = presetKeyFromForm === 'cursor';
|
||||
const sectionLabelColor = darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.4)';
|
||||
const currentFieldGroupStyle = fieldGroupStyle(cardBorder, cardBg);
|
||||
const currentFieldLabelStyle = fieldLabelStyle(sectionLabelColor);
|
||||
@@ -134,7 +135,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' ? '自动选择' : '未选择模型');
|
||||
const modelLabel = provider.model || (provider.apiFormat === 'codebuddy-cli' || provider.apiFormat === 'cursor-agent' ? '自动选择' : '未选择模型');
|
||||
return (
|
||||
<div
|
||||
key={provider.id}
|
||||
@@ -295,7 +296,7 @@ const AISettingsProvidersSection: React.FC<AISettingsProvidersSectionProps> = ({
|
||||
borderRadius: 8,
|
||||
gap: 4,
|
||||
}}>
|
||||
{[{ value: 'openai', label: 'OpenAI' }, { value: 'anthropic', label: 'Anthropic' }, { value: 'gemini', label: 'Gemini' }, { value: 'claude-cli', label: 'Claude CLI' }].map((format) => (
|
||||
{[{ value: 'openai', label: 'OpenAI' }, { value: 'anthropic', label: 'Anthropic' }, { value: 'gemini', label: 'Gemini' }, { value: 'cursor-agent', label: 'Cursor Agent' }, { value: 'claude-cli', label: 'Claude CLI' }].map((format) => (
|
||||
<div
|
||||
key={format.value}
|
||||
onClick={() => form.setFieldsValue({ apiFormat: format.value })}
|
||||
@@ -319,7 +320,16 @@ 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={codeBuddyUsesOptionalSecret ? '可选:预填常用模型;留空则由 CodeBuddy CLI 或服务端自动选择' : '配置指定的模型ID,留空则默认去服务端拉取'} style={{ width: '100%' }} />
|
||||
<Select
|
||||
mode="tags"
|
||||
size="middle"
|
||||
placeholder={codeBuddyUsesOptionalSecret
|
||||
? '可选:预填常用模型;留空则由 CodeBuddy CLI 或服务端自动选择'
|
||||
: cursorUsesOptionalModel
|
||||
? '可选:预填常用 Cursor 模型 ID;留空则由 Cursor 默认模型自动选择'
|
||||
: '配置指定的模型ID,留空则默认去服务端拉取'}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -116,4 +116,28 @@ describe('buildAIChatReadinessSnapshot', () => {
|
||||
expect(snapshot.title).toContain('CodeBuddy');
|
||||
expect(snapshot.title).toContain('自动选择');
|
||||
});
|
||||
|
||||
it('treats Cursor Agent as ready without an explicit model', () => {
|
||||
const snapshot = buildAIChatReadinessSnapshot({
|
||||
providers: [{
|
||||
id: 'provider-1',
|
||||
type: 'custom',
|
||||
name: 'Cursor',
|
||||
apiKey: '',
|
||||
hasSecret: true,
|
||||
baseUrl: 'https://api.cursor.com/v1',
|
||||
model: '',
|
||||
apiFormat: 'cursor-agent',
|
||||
models: [],
|
||||
maxTokens: 4096,
|
||||
temperature: 0.2,
|
||||
}],
|
||||
activeProviderId: 'provider-1',
|
||||
});
|
||||
|
||||
expect(snapshot.status).toBe('ready');
|
||||
expect(snapshot.ready).toBe(true);
|
||||
expect(snapshot.title).toContain('Cursor');
|
||||
expect(snapshot.title).toContain('自动选择');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -66,7 +66,7 @@ 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';
|
||||
provider.type === 'custom' && ['codebuddy-cli', 'cursor-agent'].includes(trimText(provider.apiFormat));
|
||||
|
||||
const getSelectedProvider = (params: {
|
||||
providers?: AIProviderConfig[];
|
||||
|
||||
@@ -70,4 +70,26 @@ describe('aiProviderInsights', () => {
|
||||
expect(JSON.stringify(snapshot)).not.toContain('apiKey');
|
||||
expect(JSON.stringify(snapshot)).not.toContain('secret-token');
|
||||
});
|
||||
|
||||
it('does not flag Cursor Agent for a missing selected model', () => {
|
||||
const snapshot = buildAIProviderSnapshot({
|
||||
providers: [{
|
||||
id: 'provider-cursor',
|
||||
type: 'custom',
|
||||
name: 'Cursor',
|
||||
apiKey: '',
|
||||
hasSecret: true,
|
||||
baseUrl: 'https://api.cursor.com/v1',
|
||||
model: '',
|
||||
models: [],
|
||||
apiFormat: 'cursor-agent',
|
||||
maxTokens: 4096,
|
||||
temperature: 0.2,
|
||||
}],
|
||||
activeProviderId: 'provider-cursor',
|
||||
});
|
||||
|
||||
expect(snapshot.missingSelectedModelCount).toBe(0);
|
||||
expect(snapshot.providers[0].issues).toEqual(['missing_declared_models']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,6 +17,12 @@ const trimText = (value: unknown): string => String(value || '').trim();
|
||||
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' && ['codebuddy-cli', 'cursor-agent'].includes(trimText(provider.apiFormat));
|
||||
|
||||
const getProviderHost = (baseUrl: string): string => {
|
||||
const normalized = trimText(baseUrl);
|
||||
if (!normalized) {
|
||||
@@ -41,10 +47,10 @@ const buildProviderIssues = (provider: AIProviderConfig): string[] => {
|
||||
if (!hasSecret) {
|
||||
issues.push('missing_secret');
|
||||
}
|
||||
if (!baseUrl) {
|
||||
if (!isBaseURLOptionalProvider(provider) && !baseUrl) {
|
||||
issues.push('missing_base_url');
|
||||
}
|
||||
if (!model) {
|
||||
if (!isModelOptionalProvider(provider) && !model) {
|
||||
issues.push('missing_selected_model');
|
||||
}
|
||||
if (declaredModels.length === 0) {
|
||||
|
||||
@@ -35,6 +35,16 @@ describe('aiSettingsModalConfig', () => {
|
||||
expect(preset.key).toBe('codebuddy');
|
||||
});
|
||||
|
||||
it('matches a Cursor Agent provider back to the dedicated preset', () => {
|
||||
const preset = matchProviderPreset({
|
||||
type: 'custom',
|
||||
baseUrl: 'https://api.cursor.com/v1',
|
||||
apiFormat: 'cursor-agent',
|
||||
});
|
||||
|
||||
expect(preset.key).toBe('cursor');
|
||||
});
|
||||
|
||||
it('creates MCP server drafts and skill drafts with stable defaults', () => {
|
||||
const server = EMPTY_MCP_SERVER({ name: 'Browser', args: ['stdio'] });
|
||||
const skill = EMPTY_SKILL();
|
||||
@@ -49,6 +59,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 === 'cursor')).toBe(true);
|
||||
expect(PROVIDER_PRESETS.some((item) => item.key === 'openai')).toBe(true);
|
||||
expect(PROVIDER_PRESETS.some((item) => item.key === 'custom')).toBe(true);
|
||||
});
|
||||
|
||||
@@ -47,6 +47,7 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
|
||||
{ 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: 'cursor', label: 'Cursor', icon: <ApiOutlined />, desc: 'Cloud Agents API / 官方 API Key', color: '#7c3aed', backendType: 'custom', fixedApiFormat: 'cursor-agent', defaultBaseUrl: 'https://api.cursor.com/v1', 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 | codebuddy-cli
|
||||
apiFormat?: string; // custom 专用: openai | anthropic | gemini | cursor-agent | claude-cli | codebuddy-cli
|
||||
headers?: Record<string, string>;
|
||||
maxTokens: number;
|
||||
temperature: number;
|
||||
|
||||
@@ -30,6 +30,7 @@ const PRESETS: PresetMatcher[] = [
|
||||
fixedApiFormat: 'claude-cli',
|
||||
},
|
||||
{ key: 'codebuddy', backendType: 'custom', defaultBaseUrl: '', fixedApiFormat: 'codebuddy-cli' },
|
||||
{ key: 'cursor', backendType: 'custom', defaultBaseUrl: 'https://api.cursor.com/v1', fixedApiFormat: 'cursor-agent' },
|
||||
{ key: 'custom', backendType: 'custom', defaultBaseUrl: '' },
|
||||
];
|
||||
|
||||
@@ -103,6 +104,19 @@ describe('ai provider preset helpers', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps Cursor model empty when only a suggested model list is configured', () => {
|
||||
expect(resolvePresetModelSelection({
|
||||
presetKey: 'cursor',
|
||||
presetDefaultModel: '',
|
||||
presetModels: [],
|
||||
valuesModel: '',
|
||||
customModels: ['composer-2', 'composer-latest'],
|
||||
})).toEqual({
|
||||
model: '',
|
||||
models: ['composer-2', 'composer-latest'],
|
||||
});
|
||||
});
|
||||
|
||||
it('forces built-in presets back to their standard base URL when saving or testing', () => {
|
||||
expect(resolvePresetBaseURL({
|
||||
presetKey: 'qwen-bailian',
|
||||
@@ -119,6 +133,14 @@ describe('ai provider preset helpers', () => {
|
||||
})).toBe('https://example-proxy.internal/v1');
|
||||
});
|
||||
|
||||
it('keeps the user-entered base URL for the Cursor preset', () => {
|
||||
expect(resolvePresetBaseURL({
|
||||
presetKey: 'cursor',
|
||||
presetDefaultBaseUrl: 'https://api.cursor.com/v1',
|
||||
valuesBaseUrl: 'https://cursor-proxy.internal/v1',
|
||||
})).toBe('https://cursor-proxy.internal/v1');
|
||||
});
|
||||
|
||||
it('forces qwen coding plan to save as custom plus claude-cli', () => {
|
||||
expect(resolvePresetTransport({
|
||||
presetBackendType: 'custom',
|
||||
@@ -197,4 +219,18 @@ describe('resolveProviderPresetKey', () => {
|
||||
|
||||
expect(key).toBe('codebuddy');
|
||||
});
|
||||
|
||||
it('能识别 Cursor Agent 预设', () => {
|
||||
const key = resolveProviderPresetKey(
|
||||
{
|
||||
type: 'custom',
|
||||
apiFormat: 'cursor-agent',
|
||||
baseUrl: 'https://api.cursor.com/v1',
|
||||
},
|
||||
PRESETS,
|
||||
'custom',
|
||||
);
|
||||
|
||||
expect(key).toBe('cursor');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,7 +17,7 @@ export const QWEN_CODING_PLAN_MODELS = [
|
||||
'glm-4.7',
|
||||
];
|
||||
|
||||
const CUSTOM_LIKE_PRESET_KEYS = new Set(['custom', 'ollama', 'codebuddy']);
|
||||
const CUSTOM_LIKE_PRESET_KEYS = new Set(['custom', 'ollama', 'codebuddy', 'cursor']);
|
||||
|
||||
export interface ResolvePresetModelSelectionInput {
|
||||
presetKey: string;
|
||||
@@ -183,6 +183,12 @@ export const resolvePresetModelSelection = ({
|
||||
}: ResolvePresetModelSelectionInput): ResolvePresetModelSelectionResult => {
|
||||
const isCustomLike = CUSTOM_LIKE_PRESET_KEYS.has(presetKey);
|
||||
const resolvedModels = isCustomLike ? (customModels || []) : presetModels;
|
||||
if (presetKey === 'cursor') {
|
||||
return {
|
||||
models: resolvedModels,
|
||||
model: valuesModel || '',
|
||||
};
|
||||
}
|
||||
const fallbackModel = resolvedModels.length > 0 ? resolvedModels[0] : '';
|
||||
return {
|
||||
models: resolvedModels,
|
||||
|
||||
Reference in New Issue
Block a user