merge old config

This commit is contained in:
leookun
2026-08-26 15:46:11 +08:00
parent 34f97334dc
commit e1937233ec
70 changed files with 4515 additions and 3764 deletions
+141 -150
View File
@@ -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);
}
+3 -15
View File
@@ -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%;
}
-101
View File
@@ -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 {
+55 -11
View File
@@ -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 }]} />;
}