import { useCallback, useEffect, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; import { api, configuredPluginModels, type Model, type ModelInput } from "../../shared/api"; import { CursorCaGate, CursorCaProvider, CursorModelGate, CursorModelProvider } from "./CursorGates"; import { CursorModelCards, cursorModelGroups, type CursorModelGroup, type CursorModelGrouping } from "./CursorModelCards"; import { CursorModelEditor, emptyCursorModelDraft, type CursorModelDraft } from "./CursorModelEditor"; import { CursorModelTestResult, type CursorModelTestState } from "./CursorModelTestResult"; import styles from "./CursorSettings.module.scss"; import { PageContent } from "../../shell/layout/PageContent"; import { LegacyModelImport } from "./LegacyModelImport"; import { ConfirmDialog } from "../../shared/ui/ConfirmDialog"; import { FormField, SecretTextInput, TextInput } from "../../shared/ui/FormControls"; import controls from "../../shared/ui/Controls.module.scss"; import { Icon } from "../../shared/ui/Icon"; import { Modal } from "../../shared/ui/Modal"; import { Switch } from "../../shared/ui/Switch"; import { TooltipTrigger } from "../../shared/ui/TooltipTrigger"; import { addIcon } from "../../shared/ui/icons"; import { useMessage } from "../../shared/ui/message"; import { PageActions } from "../../shell/PageActions"; import { appStore, useAppStore } from "../../shared/store/appStore"; export function CursorSettingsPage() { const { models, cursorHarness, cursorBusy, plugins } = useAppStore(); const navigate = useNavigate(); const message = useMessage(); const [draft, setDraft] = useState(null); const [editing, setEditing] = useState(null); const [modelOptions, setModelOptions] = useState([]); const [discovering, setDiscovering] = useState(false); const [caCommand, setCaCommand] = useState(null); const [waitingForCaRefresh, setWaitingForCaRefresh] = useState(false); const [deleting, setDeleting] = useState(null); const [confirmDisableTakeover, setConfirmDisableTakeover] = useState(false); const [testingModelHashes, setTestingModelHashes] = useState>(() => new Set()); const [modelTestResults, setModelTestResults] = useState>(() => new Map()); const [savingAndTesting, setSavingAndTesting] = useState(false); const [batchTesting, setBatchTesting] = useState(false); const [grouping, setGrouping] = useState("flat"); const [settingsGroup, setSettingsGroup] = useState(null); const [groupNameDraft, setGroupNameDraft] = useState(""); const [groupBaseUrlDraft, setGroupBaseUrlDraft] = useState(""); const [groupApiKeyDraft, setGroupApiKeyDraft] = useState(""); const [groupSettingsBusy, setGroupSettingsBusy] = useState(false); const activeModelTests = useRef(new Map()); const caReady = cursorHarness?.ca === "ready"; const cursorTakenOver = cursorHarness?.settings_applied ?? false; const takeoverLabel = cursorTakenOver ? t("关闭接管Cursor") : t("开启接管Cursor"); const pluginModels = configuredPluginModels(plugins); const testTargets = [ ...models.map((model) => ({ model_hash: model.model_hash, display_name: model.display_name })), ...pluginModels.map((model) => ({ model_hash: model.id, display_name: model.displayName })), ]; const providerGroups = cursorModelGroups(models, "provider"); const typeGroups = cursorModelGroups(models, "type"); const canGroupByProvider = providerGroups.length > 1; const canGroupByType = typeGroups.length > 1; useEffect(() => { if ((grouping === "provider" && !canGroupByProvider) || (grouping === "type" && !canGroupByType)) { setGrouping("flat"); } }, [canGroupByProvider, canGroupByType, grouping]); useEffect(() => { if (caCommand) void api.copyCursorText(caCommand); }, [caCommand]); const initializeCa = async () => { const status = await appStore.initializeCursorCa(); if (status?.ca === "untrusted" && status.ca_install_command) setCaCommand(status.ca_install_command); }; const openNew = () => { const next = emptyCursorModelDraft(); next.model.sort_order = models.length + 1; setEditing(null); setModelOptions([]); setDraft(next); }; const openEdit = (model: Model) => { setEditing(model); setModelOptions([model.model_id]); setDraft({ providerId: `builtin/${model.type}`, model: modelInput(model), openAIExtraParamsText: JSON.stringify(model.openai_extra_params, null, 2), customHeadersText: JSON.stringify(model.custom_headers, null, 2), anthropicExtraParamsText: JSON.stringify(model.anthropic_extra_params, null, 2), }); }; const discover = async (): Promise => { if (!draft) return false; setDiscovering(true); try { const custom_headers = parseHeaders(draft.customHeadersText); const result = await api.discoverModels({ type: draft.model.type, base_url: draft.model.base_url.trim(), api_key: draft.model.api_key.trim(), custom_headers_enabled: draft.model.custom_headers_enabled, custom_headers, }); setModelOptions([...new Set(result.models)]); return true; } catch (cause) { message(errorText(cause)); return false; } finally { setDiscovering(false); } }; const persist = async (): Promise => { if (!draft) return null; const input = draftInput(draft); if (editing) return appStore.updateCursorModel(editing.model_hash, input); return (await appStore.createModels([input]))?.[0] ?? null; }; const save = async () => { try { if (await persist()) { setDraft(null); setEditing(null); } } catch (cause) { message(errorText(cause)); } }; const cancelModelTest = async (modelHash: string) => { const active = activeModelTests.current.get(modelHash); if (!active || active.cancelling) return; active.cancelling = true; active.controller.abort(); try { await api.cancelModelTest(modelHash, active.testId); } catch (cause) { message(t("取消测试失败:{error}", { error: errorText(cause) }), { duration: 5000 }); } }; const cancelAllModelTests = async () => { await Promise.all([...activeModelTests.current.keys()].map((modelHash) => cancelModelTest(modelHash))); }; const testModel = async (model: { model_hash: string; display_name: string }, notify = true): Promise<"success" | "failure" | "cancelled"> => { if (activeModelTests.current.has(model.model_hash)) { await cancelModelTest(model.model_hash); return "cancelled"; } const active = { testId: crypto.randomUUID(), controller: new AbortController(), cancelling: false }; activeModelTests.current.set(model.model_hash, active); setTestingModelHashes((current) => new Set(current).add(model.model_hash)); try { const result = await api.testModel(model.model_hash, active.testId, active.controller.signal); setModelTestResults((current) => new Map(current).set(model.model_hash, { status: "success", result })); if (notify) message(t("模型 {model} 连通性测试成功({duration} ms)", { model: model.display_name, duration: result.duration_ms })); return "success"; } catch (cause) { if (active.cancelling || active.controller.signal.aborted) { setModelTestResults((current) => new Map(current).set(model.model_hash, { status: "cancelled" })); return "cancelled"; } const error = errorText(cause); setModelTestResults((current) => new Map(current).set(model.model_hash, { status: "error", error })); if (notify) message(t("连通性测试失败:{error}", { error }), { duration: 5000 }); return "failure"; } finally { if (activeModelTests.current.get(model.model_hash) === active) activeModelTests.current.delete(model.model_hash); setTestingModelHashes((current) => { const next = new Set(current); next.delete(model.model_hash); return next; }); } }; const saveAndTest = async () => { setSavingAndTesting(true); let saved: Model | null = null; try { saved = await persist(); } catch (cause) { message(errorText(cause)); } finally { setSavingAndTesting(false); } if (!saved) return; setEditing(saved); await testModel(saved); await appStore.refresh(); }; const testAllModels = async () => { if (!testTargets.length || batchTesting) return; setBatchTesting(true); try { const results = await Promise.all(testTargets.map((model) => testModel(model, false))); const successful = results.filter((result) => result === "success").length; const failed = results.filter((result) => result === "failure").length; const cancelled = results.filter((result) => result === "cancelled").length; message(cancelled > 0 ? t("连通性测试已取消:成功 {successful},失败 {failed}", { successful, failed }) : failed === 0 ? t("全部 {count} 个模型连通性测试成功", { count: testTargets.length }) : t("连通性测试完成:成功 {successful},失败 {failed}", { successful, failed }), { duration: failed === 0 && cancelled === 0 ? 2400 : 5000 }); } finally { setBatchTesting(false); } }; const duplicateModel = async (model: Model) => { const names = new Set(models.map((item) => item.display_name)); const baseName = t("{name} 副本", { name: model.display_name }); let displayName = baseName; let suffix = 2; while (names.has(displayName)) { displayName = `${baseName} ${suffix}`; suffix += 1; } const created = await appStore.createModels([{ ...modelInput(model), sort_order: models.length + 1, display_name: displayName, }]); if (created) message(t("模型已复制")); }; const openGroupSettings = (group: CursorModelGroup) => { setGroupNameDraft(group.models.find((model) => model.group_name?.trim())?.group_name?.trim() ?? ""); setGroupBaseUrlDraft(sharedValue(group.models.map((model) => model.base_url)) ?? ""); setGroupApiKeyDraft(sharedValue(group.models.map((model) => model.api_key)) ?? ""); setSettingsGroup(group); }; const saveGroupSettings = async () => { if (!settingsGroup) return; const group_name = groupNameDraft.trim() || null; const base_url = groupBaseUrlDraft.trim(); const api_key = groupApiKeyDraft.trim(); setGroupSettingsBusy(true); try { for (const model of settingsGroup.models) { const input: ModelInput = { ...modelInput(model), group_name, ...(base_url ? { base_url } : {}), ...(api_key ? { api_key } : {}), }; if (input.group_name === (model.group_name ?? null) && input.base_url === model.base_url && input.api_key === model.api_key) continue; await api.updateModel(model.model_hash, input); } await appStore.refresh(); setSettingsGroup(null); } catch (cause) { message(errorText(cause)); } finally { setGroupSettingsBusy(false); } }; const reorderModels = useCallback(async (modelHashes: string[]) => { if (!await appStore.reorderCursorModels(modelHashes)) { message(appStore.getSnapshot().error || t("排序失败")); } }, [message]); const list = void testModel(model)} onEdit={openEdit} onDuplicate={(model) => void duplicateModel(model)} onDelete={setDeleting} onTestPluginModel={(model) => void testModel({ model_hash: model.id, display_name: model.displayName })} onPluginSettings={() => navigate("/plugins")} onReorder={reorderModels} onGroupSettings={openGroupSettings} />; const refreshCa = async () => { await appStore.refresh(); if (appStore.getSnapshot().cursorHarness?.ca !== "ready") setWaitingForCaRefresh(false); }; const openCaTerminal = () => { if (caCommand) void api.openCursorCaInstallTerminal(caCommand).catch((cause) => message(errorText(cause))); setCaCommand(null); setWaitingForCaRefresh(true); }; const content = void initializeCa()} onRefresh={() => void refreshCa()}>
{({ busy: importingLegacyModels, previewing, open }) => {list} }
; const editorTestState = editing ? modelTestResults.get(editing.model_hash) : undefined; const editorTesting = Boolean(editing && testingModelHashes.has(editing.model_hash)); const activeGroups = grouping === "provider" ? providerGroups : typeGroups; const pluginSectionHeight = pluginModels.length > 0 ? 60 + pluginModels.length * 56 : 0; const estimatedModelHeight = grouping === "flat" ? Math.max(380, Math.ceil(models.length / 3) * 196 + pluginSectionHeight) : Math.max(380, activeGroups.reduce((height, group) => height + 60 + group.models.length * 56, 0) + Math.max(0, activeGroups.length - 1) * 20 + pluginSectionHeight); return <>
{cursorTakenOver ? t("已接管") : t("未接管")} { if (enabled) void appStore.setCursorEnabled(true); else setConfirmDisableTakeover(true); }} /> {testTargets.length > 0 &&
{canGroupByProvider && } {canGroupByType && }
}
setConfirmDisableTakeover(false)} onConfirm={() => { setConfirmDisableTakeover(false); void appStore.setCursorEnabled(false); }} >

{t("关闭后将移除 Cursor 本地代理配置。如果你需要登陆官方账号,通常不需要关闭操作,推荐直接登陆你的账号即可(byok模型与官方账号的模型已支持无缝衔接),是否继续关闭并清理代理?")}

: undefined} busy={cursorBusy || savingAndTesting} onClose={() => { if (editing && editorTesting) void cancelModelTest(editing.model_hash); setDraft(null); setEditing(null); }} onSubmit={() => void save()} submitLabel={t("保存")} secondaryAction={}> {draft && <> } setCaCommand(null)} onConfirm={openCaTerminal}>
{t("需要授权安装证书")}{t("安装命令已自动复制。点击“打开终端”,将命令粘贴到终端中执行,并按提示输入密码。")}
{caCommand}
setSettingsGroup(null)} onSubmit={() => void saveGroupSettings()} submitLabel={t("保存")}> {settingsGroup &&
setGroupNameDraft(event.target.value)} /> setGroupBaseUrlDraft(event.target.value)} /> setGroupApiKeyDraft(event.target.value)} />
}
setDeleting(null)} onConfirm={() => { if (deleting) void appStore.deleteModel(deleting.model_hash); setDeleting(null); }}>

{t("确定删除这个模型吗?")}

; } function modelInput(model: Model): ModelInput { const { model_hash: _hash, created_at_ms: _created, updated_at_ms: _updated, ...input } = model; return input; } /** 组内所有模型取值一致时返回该值,否则返回 null(表单留空表示保持不变)。 */ function sharedValue(values: string[]): string | null { const [first, ...rest] = values; if (first === undefined) return null; return rest.every((value) => value === first) ? first : null; } function draftInput(draft: CursorModelDraft): ModelInput { const model = { ...draft.model, display_name: draft.model.display_name.trim(), base_url: draft.model.base_url.trim(), api_key: draft.model.api_key.trim(), tooltip_data: draft.model.tooltip_data.trim(), model_id: draft.model.model_id.trim(), openai_extra_params: parseObject(draft.openAIExtraParamsText, t("OpenAI 额外参数")), custom_headers: parseHeaders(draft.customHeadersText), anthropic_extra_params: parseObject(draft.anthropicExtraParamsText, t("Anthropic 额外参数")), }; if (!model.display_name || !model.base_url || !model.api_key || !model.tooltip_data || !model.model_id) throw new Error(t("服务器地址或完整请求 URL、API Key、模型名称、显示名称和备注不能为空")); for (const [label, value] of [[t("上下文窗口 Token"), model.context_window_tokens], [t("最大输出 Token"), model.type === "openai" ? model.max_completion_tokens : model.anthropic_max_tokens], [t("思考预算 Token"), model.thinking_budget_tokens]] as const) { if (value !== null && (!Number.isSafeInteger(value) || value <= 0)) throw new Error(t("{label} 必须是大于 0 的整数", { label })); } return model; } function parseHeaders(text: string): Record { const parsed = parseObject(text, t("自定义 Headers")); if (Object.values(parsed).some((value) => typeof value !== "string")) throw new Error(t("自定义 Headers 的值必须都是字符串")); return parsed as Record; } function parseObject(text: string, label: string): Record { let parsed: unknown; try { parsed = JSON.parse(text || "{}"); } catch { throw new Error(t("{label} 必须是有效 JSON", { label })); } if (!parsed || Array.isArray(parsed) || typeof parsed !== "object") throw new Error(t("{label} 必须是 JSON 对象", { label })); return parsed as Record; } function errorText(cause: unknown) { return cause instanceof Error ? cause.message : String(cause); }