mirror of
https://wget.la/https://github.com/leookun/cursor-byok
synced 2026-10-06 13:44:21 +08:00
refactor: rebuild desktop app with Tauri
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
@use "../styles/typography" as type;
|
||||
|
||||
.root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--vscode-editor-background);
|
||||
}
|
||||
|
||||
.scroller {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.content {
|
||||
width: calc(100% - 36px);
|
||||
max-width: var(--app-page-max-width);
|
||||
padding: 28px 0 40px;
|
||||
margin-right: auto;
|
||||
margin-left: auto;
|
||||
|
||||
h1 {
|
||||
margin: 0 0 20px;
|
||||
font-size: type.$font-size-lg;
|
||||
font-weight: 630;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { api, type CallDetail } from "../api";
|
||||
import { CallDetails } from "../components/CallDetails";
|
||||
import { TitledCard } from "../components/ui/TitledCard";
|
||||
import styles from "./CallDetailsPage.module.scss";
|
||||
import { ScrollableContent } from "../components/virtual/ScrollableContent";
|
||||
|
||||
export function CallDetailsPage() {
|
||||
const { callId = "" } = useParams();
|
||||
const [detail, setDetail] = useState<CallDetail | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setDetail(null);
|
||||
setError(null);
|
||||
void api.call(callId).then((value) => {
|
||||
if (!cancelled) setDetail(value);
|
||||
}).catch((cause) => {
|
||||
if (!cancelled) setError(cause instanceof Error ? cause.message : String(cause));
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [callId]);
|
||||
|
||||
const content = error
|
||||
? <TitledCard title={t("无法加载调用详情")}><div style={{ padding: 16 }}>{error}</div></TitledCard>
|
||||
: detail
|
||||
? <CallDetails detail={detail} />
|
||||
: <TitledCard title={t("调用详情")}><div style={{ padding: 16 }}>{t("正在加载调用详情…")}</div></TitledCard>;
|
||||
|
||||
return <main className={styles.root}>
|
||||
<ScrollableContent className={styles.scroller} contentClassName={styles.content}>
|
||||
<h1>{detail?.call.display_name ?? t("调用详情")}</h1>
|
||||
{content}
|
||||
</ScrollableContent>
|
||||
</main>;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
.pageContent {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.page {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { CallTable } from "../components/CallTable";
|
||||
import { PageContent } from "../components/layout/PageContent";
|
||||
import { appStore, useAppStore } from "../store/appStore";
|
||||
import styles from "./CallsPage.module.scss";
|
||||
|
||||
export function CallsPage() {
|
||||
const { calls } = useAppStore();
|
||||
const content = <div className={styles.page}><CallTable calls={calls} onDetails={(call) => void appStore.openCallDetails(call.call_id)} /></div>;
|
||||
return <PageContent fixed title={t("调用")} contentClassName={styles.pageContent} sections={[{ key: "calls", estimatedHeight: 720, content }]} />;
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { api, type Model, type ProviderSelection } from "../api";
|
||||
import { CursorCaGate, CursorCaProvider, CursorModelGate, CursorModelProvider } from "../components/cursor/CursorGates";
|
||||
import { CursorModelEditor, emptyCursorModelDraft, type CursorModelDraft } from "../components/cursor/CursorModelEditor";
|
||||
import styles from "../components/cursor/CursorSettings.module.scss";
|
||||
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 { TitledCard } from "../components/ui/TitledCard";
|
||||
import { TooltipTrigger } from "../components/ui/TooltipTrigger";
|
||||
import { addIcon, claudeIcon, editIcon, openAiIcon, trashIcon } 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 message = useMessage();
|
||||
const [draft, setDraft] = useState<CursorModelDraft | null>(null);
|
||||
const [editing, setEditing] = useState<Model | null>(null);
|
||||
const [modelOptions, setModelOptions] = useState<string[]>([]);
|
||||
const [discovering, setDiscovering] = useState(false);
|
||||
const [caCommand, setCaCommand] = useState<string | null>(null);
|
||||
const [waitingForCaRefresh, setWaitingForCaRefresh] = useState(false);
|
||||
const [deleting, setDeleting] = useState<Model | null>(null);
|
||||
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);
|
||||
}, [caCommand]);
|
||||
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 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: null, 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);
|
||||
};
|
||||
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)));
|
||||
} catch (cause) {
|
||||
message(cause instanceof Error ? cause.message : String(cause));
|
||||
} finally {
|
||||
setDiscovering(false);
|
||||
}
|
||||
};
|
||||
const save = async () => {
|
||||
if (!draft) return;
|
||||
try {
|
||||
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("请求完整地址不能为空"));
|
||||
const modelInputs = 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,
|
||||
}));
|
||||
const ok = editing
|
||||
? await appStore.updateCursorModel(editing.model_hash, modelInputs[0])
|
||||
: await appStore.createCursorModels(providerSelection(draft), modelInputs);
|
||||
if (ok) { setDraft(null); setEditing(null); }
|
||||
} catch (cause) { message(cause instanceof Error ? cause.message : String(cause)); }
|
||||
};
|
||||
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> */}
|
||||
<div className={styles.rowActions}>
|
||||
<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 refreshCa = async () => {
|
||||
await appStore.refresh();
|
||||
if (appStore.getSnapshot().cursorHarness?.ca !== "ready") {
|
||||
setWaitingForCaRefresh(false);
|
||||
}
|
||||
};
|
||||
const openCaTerminal = () => {
|
||||
if (caCommand) void api.openCursorCaInstallTerminal(caCommand);
|
||||
setCaCommand(null);
|
||||
setWaitingForCaRefresh(true);
|
||||
};
|
||||
const content = <CursorCaProvider><CursorCaGate busy={cursorBusy} waitingForRefresh={waitingForCaRefresh} onInitialize={() => void initializeCa()} onRefresh={() => void refreshCa()}>
|
||||
<CursorModelProvider><CursorModelGate onAdd={openNew}>{list}</CursorModelGate></CursorModelProvider>
|
||||
</CursorCaGate></CursorCaProvider>;
|
||||
|
||||
return <>
|
||||
<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(280, models.length * 55 + grouped.length * 62), content }]} />
|
||||
<Modal open={draft !== null} title={editing ? t("编辑模型") : t("添加模型")} busy={cursorBusy} onClose={() => setDraft(null)} onSubmit={() => void save()}>
|
||||
{draft && <CursorModelEditor draft={draft} providers={providers} editing={editing !== null} 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>
|
||||
</>;
|
||||
}
|
||||
|
||||
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>;
|
||||
}
|
||||
|
||||
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>;
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, type Overview } from "../api";
|
||||
import { ContributionCalendarChart } from "../components/charts/ContributionCalendarChart";
|
||||
import { DailyTokenUsageChart } from "../components/charts/DailyTokenUsageChart";
|
||||
import { HomeMetrics } from "../components/metrics/HomeMetrics";
|
||||
import { PageContent } from "../components/layout/PageContent";
|
||||
import type { VirtualPageSection } from "../components/layout/VirtualPage";
|
||||
import { OverviewTimeRangeFilter, type OverviewRangePreset } from "../components/overview/OverviewTimeRangeFilter";
|
||||
import { PageActions } from "../layouts/PageActions";
|
||||
import { appStore, useAppStore } from "../store/appStore";
|
||||
import { formatTimeInput, parseTimeInput } from "../utils/parseTimeInput";
|
||||
import { claudeIcon, openAiIcon } from "../components/ui/icons";
|
||||
|
||||
type TimeRange = { startMs: number; endMs: number };
|
||||
|
||||
function presetRange(preset: Exclude<OverviewRangePreset, "custom">, now = new Date()): TimeRange {
|
||||
const endMs = now.getTime();
|
||||
if (preset === "today") {
|
||||
const start = new Date(now);
|
||||
start.setHours(0, 0, 0, 0);
|
||||
return { startMs: start.getTime(), endMs };
|
||||
}
|
||||
if (preset === "month") {
|
||||
const start = new Date(now);
|
||||
start.setMonth(start.getMonth() - 1);
|
||||
return { startMs: start.getTime(), endMs };
|
||||
}
|
||||
const duration = preset === "ten-minutes" ? 10 * 60_000 : preset === "hour" ? 60 * 60_000 : 7 * 24 * 60 * 60_000;
|
||||
return { startMs: now.getTime() - duration, endMs };
|
||||
}
|
||||
|
||||
export function HomePage() {
|
||||
const { overview, busy, models, providers } = 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);
|
||||
const selectedRange = preset === "custom" ? customRange : presetRange(preset);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedRange) return;
|
||||
let active = true;
|
||||
setRangeBusy(true);
|
||||
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]);
|
||||
|
||||
const filteredOverview = rangeOverview ?? overview;
|
||||
const dailyTokenUsage = filteredOverview.token_usage_series.map((bucket) => ({
|
||||
bucketStartMs: bucket.bucket_start_ms,
|
||||
inputTokens: bucket.input_tokens,
|
||||
cacheReadTokens: bucket.cache_read_tokens,
|
||||
cacheWriteTokens: bucket.cache_write_tokens,
|
||||
outputTokens: bucket.output_tokens,
|
||||
}));
|
||||
const contribution = overview.token_usage_series.map((bucket) => ({
|
||||
date: new Date(bucket.bucket_start_ms).toISOString().slice(0, 10),
|
||||
tokens: bucket.input_tokens + bucket.cache_read_tokens + bucket.cache_write_tokens + bucket.output_tokens,
|
||||
}));
|
||||
const metrics = {
|
||||
llmCalls: filteredOverview.metrics.llm_calls,
|
||||
successfulCalls: filteredOverview.metrics.successful_calls,
|
||||
failedCalls: filteredOverview.metrics.failed_calls,
|
||||
tokenUsage: filteredOverview.metrics.token_usage,
|
||||
promptTokens: filteredOverview.metrics.prompt_tokens,
|
||||
cacheReadTokens: filteredOverview.metrics.cache_read_tokens,
|
||||
cacheWriteTokens: filteredOverview.metrics.cache_write_tokens,
|
||||
};
|
||||
const openCustom = (open: boolean) => {
|
||||
if (open && !customStart && !customEnd) {
|
||||
const now = new Date();
|
||||
setCustomEnd(formatTimeInput(now));
|
||||
setCustomStart(formatTimeInput(new Date(now.getTime() - 60 * 60_000)));
|
||||
}
|
||||
setCustomOpen(open);
|
||||
};
|
||||
const applyCustom = () => {
|
||||
const startMs = parseTimeInput(customStart);
|
||||
const endMs = parseTimeInput(customEnd);
|
||||
if (startMs === null || endMs === null || startMs >= endMs) return;
|
||||
setCustomRange({ startMs, endMs });
|
||||
setAppliedModels(selectedModels);
|
||||
setAppliedProviders(selectedProviders);
|
||||
setPreset("custom");
|
||||
setCustomOpen(false);
|
||||
};
|
||||
const refresh = async () => {
|
||||
await appStore.refresh();
|
||||
setRefreshVersion((version) => version + 1);
|
||||
};
|
||||
const iconFor = (type: string) => type === "anthropic" ? claudeIcon : openAiIcon;
|
||||
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),
|
||||
}));
|
||||
const sections: VirtualPageSection[] = [
|
||||
{
|
||||
key: "daily-token-usage",
|
||||
estimatedHeight: 240,
|
||||
content: <DailyTokenUsageChart
|
||||
data={dailyTokenUsage}
|
||||
granularity={filteredOverview.token_usage_granularity}
|
||||
/>,
|
||||
},
|
||||
{
|
||||
key: "metrics",
|
||||
estimatedHeight: 130,
|
||||
content: <HomeMetrics data={metrics} />,
|
||||
},
|
||||
|
||||
{
|
||||
key: "activity",
|
||||
estimatedHeight: 106,
|
||||
content: <ContributionCalendarChart data={contribution} />,
|
||||
},
|
||||
];
|
||||
|
||||
return <>
|
||||
<PageActions><OverviewTimeRangeFilter
|
||||
value={preset}
|
||||
customOpen={customOpen}
|
||||
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>
|
||||
<PageContent title={t("概览")} sections={sections} />
|
||||
</>;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
.pageContent {
|
||||
height: 100%;
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
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: "", 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>;
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
@use "../styles/typography" as type;
|
||||
|
||||
.page {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.settingRow {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
.storageRow {
|
||||
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);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
|
||||
&:hover { color: var(--vscode-textLink-activeForeground); }
|
||||
}
|
||||
|
||||
.confirmContent {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
|
||||
|
||||
small {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-xs;
|
||||
}
|
||||
}
|
||||
|
||||
.themeActions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 14px;
|
||||
|
||||
button {
|
||||
min-height: 30px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 11px;
|
||||
color: var(--vscode-foreground);
|
||||
background: var(--vscode-input-background);
|
||||
border: 1px solid var(--vscode-input-border);
|
||||
border-radius: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.portSettings {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.portFields {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.portFooter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
|
||||
small {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-xs;
|
||||
}
|
||||
}
|
||||
|
||||
.portValue {
|
||||
min-width: 0;
|
||||
min-height: 34px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
|
||||
span {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
}
|
||||
}
|
||||
|
||||
.cardActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.portFields {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.themeActions .selected {
|
||||
border-color: var(--vscode-focusBorder);
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, type ProxySettings, type ProxySettingsInput, type StatisticsStorage } from "../api";
|
||||
import { PageContent } from "../components/layout/PageContent";
|
||||
import { AppLifecycleSettingsCard } from "../components/settings/AppLifecycleSettingsCard";
|
||||
import { ProxySettingsCard } from "../components/settings/ProxySettingsCard";
|
||||
import { Button } from "../components/ui/Button";
|
||||
import { Checkbox } from "../components/ui/Checkbox";
|
||||
import { FormField, TextInput } from "../components/ui/FormControls";
|
||||
import { Modal } from "../components/ui/Modal";
|
||||
import { TitledCard } from "../components/ui/TitledCard";
|
||||
import { useMessage } from "../components/ui/message";
|
||||
import { appStore, useAppStore } from "../store/appStore";
|
||||
import { themeOptions } from "../theme/theme";
|
||||
import styles from "./SettingsPage.module.scss";
|
||||
|
||||
export function SettingsPage() {
|
||||
const { detailed, ports, theme } = useAppStore();
|
||||
const message = useMessage();
|
||||
const [proxyPort, setProxyPort] = useState(String(ports.proxy_port));
|
||||
const [servicePort, setServicePort] = useState(String(ports.service_port));
|
||||
const [editingPorts, setEditingPorts] = useState(false);
|
||||
const [savingPorts, setSavingPorts] = useState(false);
|
||||
const [storage, setStorage] = useState<StatisticsStorage | null>(null);
|
||||
const [confirmClear, setConfirmClear] = useState(false);
|
||||
const [clearing, setClearing] = useState(false);
|
||||
const [outboundProxy, setOutboundProxy] = useState<ProxySettings | null>(null);
|
||||
const [proxyDraft, setProxyDraft] = useState<ProxySettingsInput>({ mode: "system", address: "", auth_enabled: false, username: "", password: "" });
|
||||
const [editingProxy, setEditingProxy] = useState(false);
|
||||
const [savingProxy, setSavingProxy] = useState(false);
|
||||
useEffect(() => {
|
||||
void Promise.all([api.statisticsStorage(), api.proxySettings()]).then(([nextStorage, nextProxy]) => {
|
||||
setStorage(nextStorage);
|
||||
setOutboundProxy(nextProxy);
|
||||
setProxyDraft({ mode: nextProxy.mode, address: nextProxy.address, auth_enabled: nextProxy.auth_enabled, username: nextProxy.username, password: "" });
|
||||
}).catch((cause) => message(cause instanceof Error ? cause.message : String(cause)));
|
||||
}, [message]);
|
||||
useEffect(() => {
|
||||
setProxyPort(String(ports.proxy_port));
|
||||
setServicePort(String(ports.service_port));
|
||||
}, [ports.proxy_port, ports.service_port]);
|
||||
|
||||
const parsePort = (value: string, label: string) => {
|
||||
const port = Number(value);
|
||||
if (!Number.isInteger(port) || port < 0 || port > 65_535) {
|
||||
throw new Error(`${label}${t("必须是 0–65535 之间的整数")}`);
|
||||
}
|
||||
return port;
|
||||
};
|
||||
const savePorts = async () => {
|
||||
try {
|
||||
const next = {
|
||||
proxy_port: parsePort(proxyPort, t("代理端口")),
|
||||
service_port: parsePort(servicePort, t("服务端口")),
|
||||
};
|
||||
setSavingPorts(true);
|
||||
if (await appStore.updatePorts(next)) {
|
||||
setEditingPorts(false);
|
||||
message(t("端口设置已保存,重启软件后生效"), { duration: 4_000 });
|
||||
}
|
||||
} catch (cause) {
|
||||
message(cause instanceof Error ? cause.message : String(cause));
|
||||
} finally {
|
||||
setSavingPorts(false);
|
||||
}
|
||||
};
|
||||
const editPorts = () => {
|
||||
setProxyPort(String(ports.proxy_port));
|
||||
setServicePort(String(ports.service_port));
|
||||
setEditingPorts(true);
|
||||
};
|
||||
const cancelPortEdit = () => {
|
||||
setProxyPort(String(ports.proxy_port));
|
||||
setServicePort(String(ports.service_port));
|
||||
setEditingPorts(false);
|
||||
};
|
||||
const clearStorage = async () => {
|
||||
try {
|
||||
setClearing(true);
|
||||
setStorage(await api.clearStatisticsStorage());
|
||||
setConfirmClear(false);
|
||||
await appStore.refresh();
|
||||
message(t("统计数据已清理"));
|
||||
} catch (cause) {
|
||||
message(cause instanceof Error ? cause.message : String(cause));
|
||||
} finally {
|
||||
setClearing(false);
|
||||
}
|
||||
};
|
||||
const editProxy = () => {
|
||||
if (!outboundProxy) return;
|
||||
setProxyDraft({ mode: outboundProxy.mode, address: outboundProxy.address, auth_enabled: outboundProxy.auth_enabled, username: outboundProxy.username, password: "" });
|
||||
setEditingProxy(true);
|
||||
};
|
||||
const cancelProxyEdit = () => {
|
||||
if (outboundProxy) {
|
||||
setProxyDraft({ mode: outboundProxy.mode, address: outboundProxy.address, auth_enabled: outboundProxy.auth_enabled, username: outboundProxy.username, password: "" });
|
||||
}
|
||||
setEditingProxy(false);
|
||||
};
|
||||
const saveProxy = async () => {
|
||||
try {
|
||||
setSavingProxy(true);
|
||||
const saved = await api.setProxySettings({ ...proxyDraft, password: proxyDraft.password || undefined });
|
||||
setOutboundProxy(saved);
|
||||
setProxyDraft({ mode: saved.mode, address: saved.address, auth_enabled: saved.auth_enabled, username: saved.username, password: "" });
|
||||
setEditingProxy(false);
|
||||
message(t("代理设置已保存"));
|
||||
} catch (cause) {
|
||||
message(cause instanceof Error ? cause.message : String(cause));
|
||||
} finally {
|
||||
setSavingProxy(false);
|
||||
}
|
||||
};
|
||||
const formatBytes = (bytes: number) => {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
const units = ["KB", "MB", "GB", "TB"];
|
||||
let value = bytes / 1024;
|
||||
let unit = 0;
|
||||
while (value >= 1024 && unit < units.length - 1) { value /= 1024; unit += 1; }
|
||||
return `${value < 10 ? value.toFixed(1) : Math.round(value)} ${units[unit]}`;
|
||||
};
|
||||
const content = (
|
||||
<div className={styles.page}>
|
||||
<TitledCard title={t("调用观测")}>
|
||||
<div className={styles.settingRow}>
|
||||
<div>
|
||||
<strong>{t("详细模式")}</strong>
|
||||
<small>
|
||||
{t("额外保存完整请求和流响应;默认只保存时间、状态与用量。")}
|
||||
</small>
|
||||
</div>
|
||||
<Checkbox
|
||||
label={t("详细模式")}
|
||||
checked={detailed}
|
||||
onChange={(checked) => void appStore.updateDetailed(checked)}
|
||||
/>
|
||||
</div>
|
||||
</TitledCard>
|
||||
<TitledCard title={t("端口设置")} action={editingPorts ? (
|
||||
<div className={styles.cardActions}>
|
||||
<Button size="small" disabled={savingPorts} onClick={cancelPortEdit}>{t("取消")}</Button>
|
||||
<Button variant="primary" size="small" disabled={savingPorts} onClick={() => void savePorts()}>{savingPorts ? t("保存中…") : t("保存")}</Button>
|
||||
</div>
|
||||
) : (
|
||||
<button type="button" className={styles.textButton} onClick={editPorts}>{t("编辑")}</button>
|
||||
)}>
|
||||
<div className={styles.portSettings}>
|
||||
<div className={styles.portFields}>
|
||||
{editingPorts ? <><FormField
|
||||
label={t("代理端口")}
|
||||
hint={t("Cursor 使用的本地代理端口;填写 0 时启动时随机选择。")}
|
||||
>
|
||||
<TextInput
|
||||
type="number"
|
||||
min={0}
|
||||
max={65535}
|
||||
step={1}
|
||||
value={proxyPort}
|
||||
onChange={(event) => setProxyPort(event.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t("服务端口")}
|
||||
hint={t(
|
||||
"桌面前端连接的本地管理服务端口;填写 0 时启动时随机选择。",
|
||||
)}
|
||||
>
|
||||
<TextInput
|
||||
type="number"
|
||||
min={0}
|
||||
max={65535}
|
||||
step={1}
|
||||
value={servicePort}
|
||||
onChange={(event) => setServicePort(event.target.value)}
|
||||
/>
|
||||
</FormField></> : <>
|
||||
<div className={styles.portValue}><strong>{t("代理端口")}</strong><span>{ports.proxy_port}</span></div>
|
||||
<div className={styles.portValue}><strong>{t("服务端口")}</strong><span>{ports.service_port}</span></div>
|
||||
</>}
|
||||
</div>
|
||||
<div className={styles.portFooter}>
|
||||
<small>
|
||||
{t(
|
||||
"端口被占用时会自动选择新的随机端口并保存。修改后需要重启软件才会生效。",
|
||||
)}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</TitledCard>
|
||||
<ProxySettingsCard settings={outboundProxy} draft={proxyDraft} editing={editingProxy} saving={savingProxy} onDraftChange={setProxyDraft} onEdit={editProxy} onCancel={cancelProxyEdit} onSave={() => void saveProxy()} />
|
||||
<AppLifecycleSettingsCard />
|
||||
<TitledCard title={t("主题")}>
|
||||
<div className={styles.themeActions}>
|
||||
{themeOptions.map(({ id, name }) => (
|
||||
<button
|
||||
className={theme === id ? styles.selected : ""}
|
||||
key={id}
|
||||
onClick={() => appStore.selectTheme(id)}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</TitledCard>
|
||||
<TitledCard title={t("存储管理")}>
|
||||
<div className={styles.storageRow}>
|
||||
<div>
|
||||
<strong>{t("统计数据")}</strong>
|
||||
<small>{storage ? formatBytes(storage.bytes) : t("计算中…")}</small>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.textButton}
|
||||
onClick={() => setConfirmClear(true)}
|
||||
>
|
||||
{t("清理存储空间")}
|
||||
</button>
|
||||
</div>
|
||||
</TitledCard>
|
||||
<Modal
|
||||
open={confirmClear}
|
||||
title={t("确定要清理所有统计数据吗?")}
|
||||
busy={clearing}
|
||||
closeLabel={t("取消")}
|
||||
submitLabel={t("确认清理")}
|
||||
onClose={() => setConfirmClear(false)}
|
||||
onSubmit={() => void clearStorage()}
|
||||
>
|
||||
<div className={styles.confirmContent}>
|
||||
<small>
|
||||
{t(
|
||||
"所有调用记录和详细追踪数据都会被删除。供应商、模型、CA 和应用设置不会受到影响,此操作无法撤销。",
|
||||
)}
|
||||
</small>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
return <PageContent title={t("设置")} sections={[{ key: "settings", estimatedHeight: 900, content }]} />;
|
||||
}
|
||||
Reference in New Issue
Block a user