refactor: rebuild desktop app with Tauri

This commit is contained in:
leookun
2026-08-24 02:49:00 +08:00
parent a3ec2a0dfc
commit 4053a7fb20
808 changed files with 87359 additions and 181491 deletions
@@ -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;
}
+10
View File
@@ -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>;
}
+162
View File
@@ -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%;
}
+101
View File
@@ -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);
}
+240
View File
@@ -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 }]} />;
}