mirror of
https://wget.la/https://github.com/leookun/cursor-byok
synced 2026-10-06 13:44:21 +08:00
merge old config
This commit is contained in:
@@ -1,23 +1,24 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { api, type Model, type ProviderSelection, type TabSettings } from "../api";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { api, type Model, type ModelInput } from "../api";
|
||||
import { CursorCaGate, CursorCaProvider, CursorModelGate, CursorModelProvider } from "../components/cursor/CursorGates";
|
||||
import { CursorModelCards } from "../components/cursor/CursorModelCards";
|
||||
import { CursorModelEditor, emptyCursorModelDraft, type CursorModelDraft } from "../components/cursor/CursorModelEditor";
|
||||
import { CursorModelTestResult, type CursorModelTestState } from "../components/cursor/CursorModelTestResult";
|
||||
import { TabSettingsCard } from "../components/cursor/TabSettingsCard";
|
||||
import styles from "../components/cursor/CursorSettings.module.scss";
|
||||
import { PageContent } from "../components/layout/PageContent";
|
||||
import { LegacyModelImport } from "../components/models/LegacyModelImport";
|
||||
import { ConfirmDialog } from "../components/ui/ConfirmDialog";
|
||||
import controls from "../components/ui/Controls.module.scss";
|
||||
import { Icon } from "../components/ui/Icon";
|
||||
import { Modal } from "../components/ui/Modal";
|
||||
import { TitledCard } from "../components/ui/TitledCard";
|
||||
import { TooltipTrigger } from "../components/ui/TooltipTrigger";
|
||||
import { addIcon, claudeIcon, editIcon, openAiIcon, trashIcon } from "../components/ui/icons";
|
||||
import { addIcon } from "../components/ui/icons";
|
||||
import { useMessage } from "../components/ui/message";
|
||||
import { PageActions } from "../layouts/PageActions";
|
||||
import { appStore, useAppStore } from "../store/appStore";
|
||||
|
||||
export function CursorSettingsPage() {
|
||||
const { providers, models, cursorHarness, cursorBusy } = useAppStore();
|
||||
const { models, cursorHarness, cursorBusy } = useAppStore();
|
||||
const message = useMessage();
|
||||
const [draft, setDraft] = useState<CursorModelDraft | null>(null);
|
||||
const [editing, setEditing] = useState<Model | null>(null);
|
||||
@@ -26,70 +27,71 @@ export function CursorSettingsPage() {
|
||||
const [caCommand, setCaCommand] = useState<string | null>(null);
|
||||
const [waitingForCaRefresh, setWaitingForCaRefresh] = useState(false);
|
||||
const [deleting, setDeleting] = useState<Model | null>(null);
|
||||
const [tabDraft, setTabDraft] = useState<TabSettings | null>(null);
|
||||
const [savingTab, setSavingTab] = useState(false);
|
||||
const [testingModelHashes, setTestingModelHashes] = useState<Set<string>>(() => new Set());
|
||||
const [modelTestResults, setModelTestResults] = useState<Map<string, CursorModelTestState>>(() => new Map());
|
||||
const [savingAndTesting, setSavingAndTesting] = useState(false);
|
||||
const [batchTesting, setBatchTesting] = useState(false);
|
||||
const grouped = useMemo(() => providers.map((provider) => ({ provider, models: models.filter((model) => model.provider_id === provider.provider_id) })).filter((group) => group.models.length > 0), [providers, models]);
|
||||
const caReady = cursorHarness?.ca === "ready";
|
||||
|
||||
useEffect(() => {
|
||||
if (!caCommand) return;
|
||||
void api.copyCursorText(caCommand);
|
||||
if (caCommand) void api.copyCursorText(caCommand);
|
||||
}, [caCommand]);
|
||||
useEffect(() => {
|
||||
void api.tabSettings()
|
||||
.then(setTabDraft)
|
||||
.catch((cause) => message(cause instanceof Error ? cause.message : String(cause)));
|
||||
}, [message]);
|
||||
|
||||
const initializeCa = async () => {
|
||||
const status = await appStore.initializeCursorCa();
|
||||
if (status?.ca === "untrusted" && status.ca_install_command) setCaCommand(status.ca_install_command);
|
||||
};
|
||||
|
||||
const openNew = () => { setEditing(null); setModelOptions([]); setDraft(emptyCursorModelDraft()); };
|
||||
const openEdit = (model: Model) => {
|
||||
const openNew = () => {
|
||||
const next = emptyCursorModelDraft();
|
||||
next.providerMode = String(model.provider_id);
|
||||
next.model = {
|
||||
model_id: model.model_id, display_name: model.display_name, enabled: model.enabled, sort_order: model.sort_order,
|
||||
endpoint_type: model.endpoint_type, request_url: model.request_url,
|
||||
context_window_tokens: model.context_window_tokens, max_output_tokens: null,
|
||||
reasoning_enabled: model.reasoning_enabled, reasoning_effort: null,
|
||||
supports_image_generation: model.supports_image_generation,
|
||||
};
|
||||
next.modelIds = [model.model_id];
|
||||
next.customRequestUrl = Boolean(model.request_url);
|
||||
setEditing(model); setModelOptions([model.model_id]); setDraft(next);
|
||||
next.model.sort_order = models.length + 1;
|
||||
setEditing(null);
|
||||
setModelOptions([]);
|
||||
setDraft(next);
|
||||
};
|
||||
const openEdit = (model: Model) => {
|
||||
setEditing(model);
|
||||
setModelOptions([model.model_id]);
|
||||
setDraft({
|
||||
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 providerSelection = (value: CursorModelDraft): ProviderSelection => value.providerMode === "new"
|
||||
? { kind: "new", input: { ...value.provider, name: providerName(value.provider.base_url), custom_headers: parseHeaders(value.headersText), extra_params: parseObject(value.extraText, t("额外参数")) } }
|
||||
: { kind: "existing", provider_id: Number(value.providerMode) };
|
||||
const discover = async () => {
|
||||
if (!draft) return;
|
||||
setDiscovering(true);
|
||||
try {
|
||||
const discovered = [...new Set((await api.discoverCursorModels(providerSelection(draft))).models)];
|
||||
const existing = draft.providerMode === "new"
|
||||
? new Set<string>()
|
||||
: new Set(models.filter((model) => model.provider_id === Number(draft.providerMode)).map((model) => model.model_id));
|
||||
setModelOptions(editing ? discovered : discovered.filter((modelId) => !existing.has(modelId)));
|
||||
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)]);
|
||||
} catch (cause) {
|
||||
message(cause instanceof Error ? cause.message : String(cause));
|
||||
message(errorText(cause));
|
||||
} finally {
|
||||
setDiscovering(false);
|
||||
}
|
||||
};
|
||||
const persist = async (): Promise<Model | null> => {
|
||||
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 () => {
|
||||
if (!draft) return;
|
||||
try {
|
||||
const modelInputs = cursorModelInputs(draft, editing !== null);
|
||||
const saved = editing
|
||||
? await appStore.updateCursorModel(editing.model_hash, modelInputs[0])
|
||||
: await appStore.createCursorModels(providerSelection(draft), modelInputs);
|
||||
if (saved) { setDraft(null); setEditing(null); }
|
||||
} catch (cause) { message(cause instanceof Error ? cause.message : String(cause)); }
|
||||
if (await persist()) {
|
||||
setDraft(null);
|
||||
setEditing(null);
|
||||
}
|
||||
} catch (cause) {
|
||||
message(errorText(cause));
|
||||
}
|
||||
};
|
||||
const testModel = async (model: Model, notify = true) => {
|
||||
setTestingModelHashes((current) => new Set(current).add(model.model_hash));
|
||||
@@ -99,7 +101,7 @@ export function CursorSettingsPage() {
|
||||
if (notify) message(t("模型 {model} 连通性测试成功({duration} ms)", { model: model.display_name, duration: result.duration_ms }));
|
||||
return true;
|
||||
} catch (cause) {
|
||||
const error = cause instanceof Error ? cause.message : String(cause);
|
||||
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 false;
|
||||
@@ -111,120 +113,135 @@ export function CursorSettingsPage() {
|
||||
});
|
||||
}
|
||||
};
|
||||
const testSingleModel = async (model: Model) => {
|
||||
await testModel(model);
|
||||
await appStore.refresh();
|
||||
};
|
||||
const saveAndTest = async () => {
|
||||
if (!draft || !editing) return;
|
||||
setSavingAndTesting(true);
|
||||
try {
|
||||
const [input] = cursorModelInputs(draft, true);
|
||||
const saved = await appStore.updateCursorModel(editing.model_hash, input);
|
||||
if (!saved) {
|
||||
const error = appStore.getSnapshot().error;
|
||||
if (error) message(error);
|
||||
return;
|
||||
}
|
||||
const saved = await persist();
|
||||
if (!saved) return;
|
||||
setEditing(saved);
|
||||
await testSingleModel(saved);
|
||||
await testModel(saved);
|
||||
await appStore.refresh();
|
||||
} catch (cause) {
|
||||
message(cause instanceof Error ? cause.message : String(cause));
|
||||
message(errorText(cause));
|
||||
} finally {
|
||||
setSavingAndTesting(false);
|
||||
}
|
||||
};
|
||||
const testAllModels = async () => {
|
||||
if (!models.length || batchTesting) return;
|
||||
const targets = [...models];
|
||||
setBatchTesting(true);
|
||||
try {
|
||||
const results = await Promise.all(targets.map((model) => testModel(model, false)));
|
||||
await appStore.refresh();
|
||||
const results = await Promise.all(models.map((model) => testModel(model, false)));
|
||||
const successful = results.filter(Boolean).length;
|
||||
const failed = targets.length - successful;
|
||||
const failed = models.length - successful;
|
||||
message(failed === 0
|
||||
? t("全部 {count} 个模型连通性测试成功", { count: targets.length })
|
||||
: t("连通性测试完成:成功 {successful},失败 {failed}", { successful, failed }), { duration: failed === 0 ? 2400 : 5000 });
|
||||
? t("全部 {count} 个模型连通性测试成功", { count: models.length })
|
||||
: t("连通性测试完成:成功 {successful},失败 {failed}", { successful, failed }),
|
||||
{ duration: failed === 0 ? 2400 : 5000 });
|
||||
} finally {
|
||||
setBatchTesting(false);
|
||||
}
|
||||
};
|
||||
const list = <div className={styles.groups}>{grouped.map(({ provider, models: childModels }) => <TitledCard key={provider.provider_id} title={<div className={styles.providerTitle}><Icon icon={provider.provider_type === "anthropic" ? claudeIcon : openAiIcon} /><span>{provider.name}</span></div>}>
|
||||
<div className={styles.models}>{childModels.map((model) => <div className={styles.modelRow} key={model.model_hash}>
|
||||
<div className={styles.modelName}><strong>{model.display_name}</strong><small>{model.model_id} · {model.model_hash}</small></div>
|
||||
{/* <span className={styles.badge}>{model.enabled ? t("已启用") : t("已停用")}</span> */}
|
||||
{modelTestResults.get(model.model_hash) && <CursorModelTestResult state={modelTestResults.get(model.model_hash)!} />}
|
||||
<div className={styles.rowActions}>
|
||||
<button type="button" className={`${controls.secondary} ${controls.small}`} disabled={testingModelHashes.size > 0 || cursorBusy || batchTesting} onClick={() => void testSingleModel(model)}>{testingModelHashes.has(model.model_hash) ? t("测试中…") : t("测试")}</button>
|
||||
<TooltipTrigger label={t("编辑模型")}><button className={controls.iconButton} aria-label={t("编辑模型")} onClick={() => openEdit(model)}><Icon icon={editIcon} size="1.1em" /></button></TooltipTrigger>
|
||||
<TooltipTrigger label={t("删除模型")}><button className={`${controls.iconButton} ${controls.danger}`} aria-label={t("删除模型")} onClick={() => setDeleting(model)}><Icon icon={trashIcon} size="1.1em" /></button></TooltipTrigger>
|
||||
</div>
|
||||
</div>)}</div>
|
||||
</TitledCard>)}</div>;
|
||||
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 reorderModels = useCallback(async (modelHashes: string[]) => {
|
||||
if (!await appStore.reorderCursorModels(modelHashes)) {
|
||||
message(appStore.getSnapshot().error || t("排序失败"));
|
||||
}
|
||||
}, [message]);
|
||||
|
||||
const list = <CursorModelCards
|
||||
models={models}
|
||||
disabled={testingModelHashes.size > 0 || cursorBusy || batchTesting}
|
||||
testingModelHashes={testingModelHashes}
|
||||
testResults={modelTestResults}
|
||||
onTest={(model) => void testModel(model)}
|
||||
onEdit={openEdit}
|
||||
onDuplicate={(model) => void duplicateModel(model)}
|
||||
onDelete={setDeleting}
|
||||
onReorder={reorderModels}
|
||||
/>;
|
||||
|
||||
const refreshCa = async () => {
|
||||
await appStore.refresh();
|
||||
if (appStore.getSnapshot().cursorHarness?.ca !== "ready") {
|
||||
setWaitingForCaRefresh(false);
|
||||
}
|
||||
if (appStore.getSnapshot().cursorHarness?.ca !== "ready") setWaitingForCaRefresh(false);
|
||||
};
|
||||
const openCaTerminal = () => {
|
||||
if (caCommand) {
|
||||
api
|
||||
.openCursorCaInstallTerminal(caCommand)
|
||||
.catch((cause) => message(cause instanceof Error ? cause.message : String(cause)));
|
||||
}
|
||||
if (caCommand) void api.openCursorCaInstallTerminal(caCommand).catch((cause) => message(errorText(cause)));
|
||||
setCaCommand(null);
|
||||
setWaitingForCaRefresh(true);
|
||||
};
|
||||
const saveTab = async () => {
|
||||
if (!tabDraft) return;
|
||||
try {
|
||||
if (tabDraft.mode === "custom" && !tabDraft.address.trim()) throw new Error(t("TAB 服务地址不能为空"));
|
||||
setSavingTab(true);
|
||||
setTabDraft(await api.setTabSettings(tabDraft));
|
||||
message(t("TAB 设置已保存"));
|
||||
} catch (cause) {
|
||||
message(cause instanceof Error ? cause.message : String(cause));
|
||||
} finally {
|
||||
setSavingTab(false);
|
||||
}
|
||||
};
|
||||
const content = <CursorCaProvider><CursorCaGate busy={cursorBusy} waitingForRefresh={waitingForCaRefresh} onInitialize={() => void initializeCa()} onRefresh={() => void refreshCa()}>
|
||||
<div className={styles.page}>
|
||||
{tabDraft && <TabSettingsCard settings={tabDraft} saving={savingTab} onChange={setTabDraft} onSave={() => void saveTab()} />}
|
||||
<CursorModelProvider><CursorModelGate onAdd={openNew}>{list}</CursorModelGate></CursorModelProvider>
|
||||
<LegacyModelImport>{({ busy: importingLegacyModels, previewing, open }) =>
|
||||
<CursorModelProvider><CursorModelGate busy={cursorBusy || importingLegacyModels} previewingImport={previewing} onAdd={openNew} onImport={open}>{list}</CursorModelGate></CursorModelProvider>
|
||||
}</LegacyModelImport>
|
||||
</div>
|
||||
</CursorCaGate></CursorCaProvider>;
|
||||
|
||||
const editorTestState = editing ? modelTestResults.get(editing.model_hash) : undefined;
|
||||
const editorTesting = savingAndTesting || Boolean(editing && testingModelHashes.has(editing.model_hash));
|
||||
|
||||
return <>
|
||||
{models.length > 0 && <PageActions position="left">
|
||||
<button type="button" className={controls.secondary} disabled={cursorBusy || testingModelHashes.size > 0 || batchTesting} onClick={() => void testAllModels()}>{batchTesting ? t("测试中…") : t("一键测试")}</button>
|
||||
</PageActions>}
|
||||
<PageActions>
|
||||
<TooltipTrigger label={caReady ? t("添加模型") : t("请先初始化 CA")}><button className={controls.iconButton} aria-label={t("添加模型")} disabled={!caReady || cursorBusy} onClick={openNew}><Icon icon={addIcon} size="1.1em" /></button></TooltipTrigger>
|
||||
</PageActions>
|
||||
<PageContent title={t("Cursor 设置")} sections={[{ key: "cursor-settings", estimatedHeight: Math.max(430, models.length * 55 + grouped.length * 62 + 145), content }]} />
|
||||
<Modal open={draft !== null} title={editing ? t("编辑模型") : t("添加模型")} busy={cursorBusy || savingAndTesting} onClose={() => setDraft(null)} onSubmit={() => void save()} secondaryAction={editing ? <button type="button" className={controls.secondary} disabled={cursorBusy || savingAndTesting} onClick={() => void saveAndTest()}>{savingAndTesting ? t("测试中…") : t("保存并测试")}</button> : undefined}>
|
||||
{models.length > 0 && <PageActions position="left"><button type="button" className={controls.secondary} disabled={cursorBusy || testingModelHashes.size > 0 || batchTesting} onClick={() => void testAllModels()}>{batchTesting ? t("测试中…") : t("一键测试")}</button></PageActions>}
|
||||
<PageActions><TooltipTrigger label={caReady ? t("添加模型") : t("请先初始化 CA")}><button className={controls.iconButton} aria-label={t("添加模型")} disabled={!caReady || cursorBusy} onClick={openNew}><Icon icon={addIcon} size="1.1em" /></button></TooltipTrigger></PageActions>
|
||||
<PageContent title={t("Cursor 配置")} sections={[{ key: "cursor-settings", estimatedHeight: Math.max(380, Math.ceil(models.length / 3) * 196), content }]} />
|
||||
<Modal open={draft !== null} title={editing ? t("编辑模型") : t("添加模型")} banner={draft && (editorTesting || editorTestState) ? <CursorModelTestResult state={editorTestState} testing={editorTesting} /> : undefined} busy={cursorBusy || savingAndTesting} onClose={() => setDraft(null)} onSubmit={() => void save()} secondaryAction={<button type="button" className={controls.secondary} disabled={cursorBusy || savingAndTesting} onClick={() => void saveAndTest()}>{savingAndTesting ? t("测试中…") : t("保存并测试")}</button>}>
|
||||
{draft && <>
|
||||
<CursorModelEditor draft={draft} providers={providers} editing={editing !== null} modelOptions={modelOptions} discovering={discovering} onChange={setDraft} onDiscover={() => void discover()} />
|
||||
{editing && modelTestResults.get(editing.model_hash) && <div className={styles.editorTestResult}><CursorModelTestResult state={modelTestResults.get(editing.model_hash)!} /></div>}
|
||||
<CursorModelEditor draft={draft} modelOptions={modelOptions} discovering={discovering} onChange={setDraft} onDiscover={() => void discover()} />
|
||||
</>}
|
||||
</Modal>
|
||||
<Modal open={caCommand !== null} title={t("安装本地 CA")} closeLabel={t("关闭")} submitLabel={t("打开终端")} onClose={() => setCaCommand(null)} onSubmit={openCaTerminal}>
|
||||
<div className={styles.editor}>
|
||||
<strong>{t("需要授权安装证书")}</strong>
|
||||
<span>{t("安装命令已自动复制。点击“打开终端”,将命令粘贴到终端中执行,并按提示输入密码。")}</span>
|
||||
<pre className={styles.command}>{caCommand}</pre>
|
||||
</div>
|
||||
</Modal>
|
||||
<Modal open={deleting !== null} title={t("删除模型")} closeLabel={t("取消")} submitLabel={t("删除")} onClose={() => setDeleting(null)} onSubmit={() => { if (deleting) void appStore.deleteModel(deleting.model_hash); setDeleting(null); }}>
|
||||
<p>{t("确定删除这个模型吗?")}</p>
|
||||
</Modal>
|
||||
<ConfirmDialog open={caCommand !== null} title={t("安装本地 CA")} cancelLabel={t("关闭")} confirmLabel={t("打开终端")} onCancel={() => setCaCommand(null)} onConfirm={openCaTerminal}>
|
||||
<div className={styles.editor}><strong>{t("需要授权安装证书")}</strong><span>{t("安装命令已自动复制。点击“打开终端”,将命令粘贴到终端中执行,并按提示输入密码。")}</span><pre className={styles.command}>{caCommand}</pre></div>
|
||||
</ConfirmDialog>
|
||||
<ConfirmDialog open={deleting !== null} title={t("删除模型")} cancelLabel={t("取消")} confirmLabel={t("删除")} onCancel={() => setDeleting(null)} onConfirm={() => { if (deleting) void appStore.deleteModel(deleting.model_hash); setDeleting(null); }}><p>{t("确定删除这个模型吗?")}</p></ConfirmDialog>
|
||||
</>;
|
||||
}
|
||||
|
||||
function modelInput(model: Model): ModelInput {
|
||||
const { model_hash: _hash, created_at_ms: _created, updated_at_ms: _updated, ...input } = model;
|
||||
return input;
|
||||
}
|
||||
|
||||
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<string, string> {
|
||||
const parsed = parseObject(text, t("自定义 Headers"));
|
||||
if (Object.values(parsed).some((value) => typeof value !== "string")) throw new Error(t("自定义 Headers 的值必须都是字符串"));
|
||||
return parsed as Record<string, string>;
|
||||
}
|
||||
|
||||
function parseObject(text: string, label: string): Record<string, unknown> {
|
||||
let parsed: unknown;
|
||||
try { parsed = JSON.parse(text || "{}"); } catch { throw new Error(t("{label} 必须是有效 JSON", { label })); }
|
||||
@@ -232,32 +249,6 @@ function parseObject(text: string, label: string): Record<string, unknown> {
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function cursorModelInputs(draft: CursorModelDraft, editing: boolean) {
|
||||
const modelIds = editing
|
||||
? [draft.model.model_id.trim()]
|
||||
: [...new Set(draft.modelIds.map((modelId) => modelId.trim()).filter(Boolean))];
|
||||
if (!modelIds.length) throw new Error(t("请至少选择或输入一个模型"));
|
||||
if (editing && !draft.model.display_name.trim()) throw new Error(t("Model ID 和显示名称不能为空"));
|
||||
if (draft.customRequestUrl && !draft.model.request_url.trim()) throw new Error(t("请求完整地址不能为空"));
|
||||
if (draft.model.context_window_tokens !== null && (!Number.isSafeInteger(draft.model.context_window_tokens) || draft.model.context_window_tokens <= 0)) throw new Error(t("自定义上下文必须是大于 0 的整数"));
|
||||
return modelIds.map((modelId, index) => ({
|
||||
...draft.model,
|
||||
model_id: modelId,
|
||||
display_name: modelIds.length === 1 ? draft.model.display_name.trim() || modelId : modelId,
|
||||
sort_order: draft.model.sort_order + index,
|
||||
}));
|
||||
}
|
||||
|
||||
function providerName(baseUrl: string): string {
|
||||
try {
|
||||
return new URL(baseUrl.trim()).hostname;
|
||||
} catch {
|
||||
throw new Error(t("Base URL 必须是有效地址"));
|
||||
}
|
||||
}
|
||||
|
||||
function parseHeaders(text: string): Record<string, string> {
|
||||
const parsed = parseObject(text, t("自定义 Headers"));
|
||||
if (Object.values(parsed).some((value) => typeof value !== "string")) throw new Error(t("自定义 Headers 的值必须都是字符串"));
|
||||
return parsed as Record<string, string>;
|
||||
function errorText(cause: unknown) {
|
||||
return cause instanceof Error ? cause.message : String(cause);
|
||||
}
|
||||
|
||||
@@ -30,16 +30,14 @@ function presetRange(preset: Exclude<OverviewRangePreset, "custom">, now = new D
|
||||
}
|
||||
|
||||
export function HomePage() {
|
||||
const { overview, busy, models, providers } = useAppStore();
|
||||
const { overview, busy, models } = useAppStore();
|
||||
const [preset, setPreset] = useState<OverviewRangePreset>("month");
|
||||
const [customRange, setCustomRange] = useState<TimeRange | null>(null);
|
||||
const [customOpen, setCustomOpen] = useState(false);
|
||||
const [customStart, setCustomStart] = useState("");
|
||||
const [customEnd, setCustomEnd] = useState("");
|
||||
const [selectedModels, setSelectedModels] = useState<string[]>([]);
|
||||
const [selectedProviders, setSelectedProviders] = useState<string[]>([]);
|
||||
const [appliedModels, setAppliedModels] = useState<string[]>([]);
|
||||
const [appliedProviders, setAppliedProviders] = useState<string[]>([]);
|
||||
const [rangeOverview, setRangeOverview] = useState<Overview | null>(null);
|
||||
const [rangeBusy, setRangeBusy] = useState(false);
|
||||
const [refreshVersion, setRefreshVersion] = useState(0);
|
||||
@@ -52,14 +50,13 @@ export function HomePage() {
|
||||
void api.overview({
|
||||
...selectedRange,
|
||||
modelHashes: appliedModels,
|
||||
providerIds: appliedProviders.map(Number),
|
||||
}).then((next) => {
|
||||
if (active) setRangeOverview(next);
|
||||
}).finally(() => {
|
||||
if (active) setRangeBusy(false);
|
||||
});
|
||||
return () => { active = false; };
|
||||
}, [preset, customRange, overview, refreshVersion, appliedModels, appliedProviders]);
|
||||
}, [preset, customRange, overview, refreshVersion, appliedModels]);
|
||||
|
||||
const filteredOverview = rangeOverview ?? overview;
|
||||
const dailyTokenUsage = filteredOverview.token_usage_series.map((bucket) => ({
|
||||
@@ -96,7 +93,6 @@ export function HomePage() {
|
||||
if (startMs === null || endMs === null || startMs >= endMs) return;
|
||||
setCustomRange({ startMs, endMs });
|
||||
setAppliedModels(selectedModels);
|
||||
setAppliedProviders(selectedProviders);
|
||||
setPreset("custom");
|
||||
setCustomOpen(false);
|
||||
};
|
||||
@@ -108,12 +104,7 @@ export function HomePage() {
|
||||
const modelOptions = models.map((model) => ({
|
||||
value: model.model_hash,
|
||||
label: model.display_name,
|
||||
icon: iconFor(model.endpoint_type),
|
||||
}));
|
||||
const providerOptions = providers.map((provider) => ({
|
||||
value: String(provider.provider_id),
|
||||
label: provider.name,
|
||||
icon: iconFor(provider.provider_type),
|
||||
icon: iconFor(model.type),
|
||||
}));
|
||||
const sections: VirtualPageSection[] = [
|
||||
{
|
||||
@@ -144,16 +135,13 @@ export function HomePage() {
|
||||
customStart={customStart}
|
||||
customEnd={customEnd}
|
||||
modelOptions={modelOptions}
|
||||
providerOptions={providerOptions}
|
||||
selectedModels={selectedModels}
|
||||
selectedProviders={selectedProviders}
|
||||
busy={busy || rangeBusy}
|
||||
onSelect={(value) => { setPreset(value); setCustomOpen(false); }}
|
||||
onCustomOpenChange={openCustom}
|
||||
onCustomStartChange={setCustomStart}
|
||||
onCustomEndChange={setCustomEnd}
|
||||
onSelectedModelsChange={setSelectedModels}
|
||||
onSelectedProvidersChange={setSelectedProviders}
|
||||
onCustomApply={applyCustom}
|
||||
onRefresh={() => void refresh()}
|
||||
/></PageActions>
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
.pageContent {
|
||||
height: 100%;
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import type { Provider, ProviderInput } from "../api";
|
||||
import { ProviderEditor } from "../components/ProviderEditor";
|
||||
import { ProviderTable } from "../components/ProviderTable";
|
||||
import { PageContent } from "../components/layout/PageContent";
|
||||
import controls from "../components/ui/Controls.module.scss";
|
||||
import { Icon } from "../components/ui/Icon";
|
||||
import { Modal } from "../components/ui/Modal";
|
||||
import { TooltipTrigger } from "../components/ui/TooltipTrigger";
|
||||
import { addIcon } from "../components/ui/icons";
|
||||
import { useMessage } from "../components/ui/message";
|
||||
import { PageActions } from "../layouts/PageActions";
|
||||
import { appStore, useAppStore } from "../store/appStore";
|
||||
import { defaultCustomHeaders, defaultCustomHeadersText } from "../utils/providerDefaults";
|
||||
import styles from "./ProvidersPage.module.scss";
|
||||
|
||||
const emptyProvider = (): ProviderInput => ({
|
||||
name: "",
|
||||
provider_type: "openai-chat",
|
||||
base_url: "",
|
||||
api_key: "",
|
||||
custom_headers: { ...defaultCustomHeaders },
|
||||
extra_params: {},
|
||||
});
|
||||
|
||||
export function ProvidersPage() {
|
||||
const { providers, busy } = useAppStore();
|
||||
const message = useMessage();
|
||||
const [draft, setDraft] = useState<ProviderInput | null>(null);
|
||||
const [editing, setEditing] = useState<Provider | null>(null);
|
||||
const [headersText, setHeadersText] = useState(defaultCustomHeadersText);
|
||||
const [extraText, setExtraText] = useState("{}");
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const openNew = () => {
|
||||
setEditing(null);
|
||||
setDraft(emptyProvider());
|
||||
setHeadersText(defaultCustomHeadersText);
|
||||
setExtraText("{}");
|
||||
};
|
||||
const openEdit = (provider: Provider) => {
|
||||
setEditing(provider);
|
||||
setDraft({ name: provider.name, provider_type: provider.provider_type, base_url: provider.base_url, api_key: provider.api_key ?? "", custom_headers: provider.custom_headers, extra_params: provider.extra_params });
|
||||
setHeadersText(JSON.stringify(provider.custom_headers, null, 2));
|
||||
setExtraText(JSON.stringify(provider.extra_params, null, 2));
|
||||
};
|
||||
const closeEditor = () => {
|
||||
if (saving) return;
|
||||
setDraft(null);
|
||||
setEditing(null);
|
||||
};
|
||||
const save = async () => {
|
||||
if (!draft) return;
|
||||
try {
|
||||
const name = draft.name.trim();
|
||||
const baseUrl = draft.base_url.trim();
|
||||
if (!name || !baseUrl) throw new Error(t("名称和 Base URL 不能为空"));
|
||||
const input: ProviderInput = {
|
||||
...draft,
|
||||
name,
|
||||
base_url: baseUrl,
|
||||
api_key: editing && !draft.api_key?.trim() ? undefined : draft.api_key,
|
||||
custom_headers: parseHeaders(headersText),
|
||||
extra_params: parseObject(extraText, t("额外参数")),
|
||||
};
|
||||
setSaving(true);
|
||||
const ok = editing ? await appStore.updateProvider(editing.provider_id, input) : await appStore.createProvider(input);
|
||||
if (ok) {
|
||||
setDraft(null);
|
||||
setEditing(null);
|
||||
}
|
||||
} catch (cause) {
|
||||
message(cause instanceof Error ? cause.message : String(cause));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const content = <ProviderTable providers={providers} onEdit={openEdit} onDelete={(provider) => void appStore.deleteProvider(provider.provider_id)} />;
|
||||
|
||||
return <>
|
||||
<PageActions><TooltipTrigger label={t("添加上游")}><button className={controls.iconButton} aria-label={t("添加上游")} disabled={busy} onClick={openNew}><Icon icon={addIcon} size="1.1em" /></button></TooltipTrigger></PageActions>
|
||||
<PageContent fixed title={t("上游")} contentClassName={styles.pageContent} sections={[{ key: "providers", estimatedHeight: 720, content }]} />
|
||||
<Modal open={draft !== null} title={editing ? t("编辑上游") : t("添加上游")} busy={saving} onClose={closeEditor} onSubmit={() => void save()}>
|
||||
{draft && <ProviderEditor value={draft} headersText={headersText} extraText={extraText} editing={editing !== null} onChange={setDraft} onHeadersChange={setHeadersText} onExtraChange={setExtraText} />}
|
||||
</Modal>
|
||||
</>;
|
||||
}
|
||||
|
||||
function parseHeaders(text: string): Record<string, string | null> {
|
||||
const parsed = parseObject(text, t("自定义 Headers"));
|
||||
if (Object.values(parsed).some((value) => typeof value !== "string" && value !== null)) throw new Error(t("自定义 Headers 的值必须是字符串或 null"));
|
||||
return parsed as Record<string, string | null>;
|
||||
}
|
||||
|
||||
function parseObject(text: string, label: string): Record<string, unknown> {
|
||||
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<string, unknown>;
|
||||
}
|
||||
@@ -41,6 +41,24 @@
|
||||
}
|
||||
}
|
||||
|
||||
.importRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
padding: 16px;
|
||||
|
||||
> div {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
small {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-xs;
|
||||
}
|
||||
}
|
||||
|
||||
.textButton {
|
||||
padding: 4px;
|
||||
color: var(--vscode-textLink-foreground);
|
||||
@@ -132,6 +150,11 @@
|
||||
.portFields {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.importRow {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.themeActions .selected {
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, type ProxySettings, type ProxySettingsInput, type StatisticsStorage } from "../api";
|
||||
import { api, type ProxySettings, type ProxySettingsInput, type StatisticsStorage, type TabSettings } from "../api";
|
||||
import { PageContent } from "../components/layout/PageContent";
|
||||
import { LegacyModelImport } from "../components/models/LegacyModelImport";
|
||||
import { AppLifecycleSettingsCard } from "../components/settings/AppLifecycleSettingsCard";
|
||||
import { ProxySettingsCard } from "../components/settings/ProxySettingsCard";
|
||||
import { TabSettingsCard } from "../components/settings/TabSettingsCard";
|
||||
import { Button } from "../components/ui/Button";
|
||||
import { Checkbox } from "../components/ui/Checkbox";
|
||||
import { ConfirmDialog } from "../components/ui/ConfirmDialog";
|
||||
import { FormField, TextInput } from "../components/ui/FormControls";
|
||||
import { Modal } from "../components/ui/Modal";
|
||||
import { Select } from "../components/ui/Select";
|
||||
import { TitledCard } from "../components/ui/TitledCard";
|
||||
import { setLocalePreference, useI18n, type LocalePreference } from "../i18n/store";
|
||||
@@ -30,11 +32,17 @@ export function SettingsPage() {
|
||||
const [proxyDraft, setProxyDraft] = useState<ProxySettingsInput>({ mode: "system", address: "", auth_enabled: false, username: "", password: "" });
|
||||
const [editingProxy, setEditingProxy] = useState(false);
|
||||
const [savingProxy, setSavingProxy] = useState(false);
|
||||
const [tabSettings, setTabSettings] = useState<TabSettings | null>(null);
|
||||
const [tabDraft, setTabDraft] = useState<TabSettings>({ mode: "public", address: "" });
|
||||
const [editingTab, setEditingTab] = useState(false);
|
||||
const [savingTab, setSavingTab] = useState(false);
|
||||
useEffect(() => {
|
||||
void Promise.all([api.statisticsStorage(), api.proxySettings()]).then(([nextStorage, nextProxy]) => {
|
||||
void Promise.all([api.statisticsStorage(), api.proxySettings(), api.tabSettings()]).then(([nextStorage, nextProxy, nextTab]) => {
|
||||
setStorage(nextStorage);
|
||||
setOutboundProxy(nextProxy);
|
||||
setProxyDraft({ mode: nextProxy.mode, address: nextProxy.address, auth_enabled: nextProxy.auth_enabled, username: nextProxy.username, password: "" });
|
||||
setTabSettings(nextTab);
|
||||
setTabDraft(nextTab);
|
||||
}).catch((cause) => message(cause instanceof Error ? cause.message : String(cause)));
|
||||
}, [message]);
|
||||
useEffect(() => {
|
||||
@@ -114,6 +122,30 @@ export function SettingsPage() {
|
||||
setSavingProxy(false);
|
||||
}
|
||||
};
|
||||
const editTab = () => {
|
||||
if (!tabSettings) return;
|
||||
setTabDraft(tabSettings);
|
||||
setEditingTab(true);
|
||||
};
|
||||
const cancelTabEdit = () => {
|
||||
if (tabSettings) setTabDraft(tabSettings);
|
||||
setEditingTab(false);
|
||||
};
|
||||
const saveTab = async () => {
|
||||
try {
|
||||
if (tabDraft.mode === "custom" && !tabDraft.address.trim()) throw new Error(t("TAB 服务地址不能为空"));
|
||||
setSavingTab(true);
|
||||
const saved = await api.setTabSettings({ ...tabDraft, address: tabDraft.address.trim() });
|
||||
setTabSettings(saved);
|
||||
setTabDraft(saved);
|
||||
setEditingTab(false);
|
||||
message(t("TAB 设置已保存"));
|
||||
} catch (cause) {
|
||||
message(cause instanceof Error ? cause.message : String(cause));
|
||||
} finally {
|
||||
setSavingTab(false);
|
||||
}
|
||||
};
|
||||
const formatBytes = (bytes: number) => {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
const units = ["KB", "MB", "GB", "TB"];
|
||||
@@ -191,7 +223,19 @@ export function SettingsPage() {
|
||||
</div>
|
||||
</TitledCard>
|
||||
<ProxySettingsCard settings={outboundProxy} draft={proxyDraft} editing={editingProxy} saving={savingProxy} onDraftChange={setProxyDraft} onEdit={editProxy} onCancel={cancelProxyEdit} onSave={() => void saveProxy()} />
|
||||
<TabSettingsCard settings={tabSettings} draft={tabDraft} editing={editingTab} saving={savingTab} onDraftChange={setTabDraft} onEdit={editTab} onCancel={cancelTabEdit} onSave={() => void saveTab()} />
|
||||
<AppLifecycleSettingsCard />
|
||||
<LegacyModelImport>{({ busy, previewing, open }) => <TitledCard title={t("导入")}>
|
||||
<div className={styles.importRow}>
|
||||
<div>
|
||||
<strong>{t("旧版配置")}</strong>
|
||||
<small>{t("从本机旧版配置读取模型;确认前会显示新增和已存在的模型。")}</small>
|
||||
</div>
|
||||
<Button size="small" disabled={busy} onClick={open}>
|
||||
{previewing ? t("读取中…") : t("查看并导入")}
|
||||
</Button>
|
||||
</div>
|
||||
</TitledCard>}</LegacyModelImport>
|
||||
<TitledCard title={t("语言")}>
|
||||
<div className={styles.settingRow}>
|
||||
<div>
|
||||
@@ -240,24 +284,24 @@ export function SettingsPage() {
|
||||
</button>
|
||||
</div>
|
||||
</TitledCard>
|
||||
<Modal
|
||||
<ConfirmDialog
|
||||
open={confirmClear}
|
||||
title={t("确定要清理所有统计数据吗?")}
|
||||
busy={clearing}
|
||||
closeLabel={t("取消")}
|
||||
submitLabel={t("确认清理")}
|
||||
onClose={() => setConfirmClear(false)}
|
||||
onSubmit={() => void clearStorage()}
|
||||
cancelLabel={t("取消")}
|
||||
confirmLabel={t("确认清理")}
|
||||
onCancel={() => setConfirmClear(false)}
|
||||
onConfirm={() => void clearStorage()}
|
||||
>
|
||||
<div className={styles.confirmContent}>
|
||||
<small>
|
||||
{t(
|
||||
"所有调用记录和详细追踪数据都会被删除。供应商、模型、CA 和应用设置不会受到影响,此操作无法撤销。",
|
||||
"所有调用记录和详细追踪数据都会被删除。模型配置、CA 和应用设置不会受到影响,此操作无法撤销。",
|
||||
)}
|
||||
</small>
|
||||
</div>
|
||||
</Modal>
|
||||
</ConfirmDialog>
|
||||
</div>
|
||||
);
|
||||
return <PageContent title={t("设置")} sections={[{ key: "settings", estimatedHeight: 900, content }]} />;
|
||||
return <PageContent title={t("设置")} sections={[{ key: "settings", estimatedHeight: 1200, content }]} />;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user