462 lines
16 KiB
TypeScript
462 lines
16 KiB
TypeScript
"use client";
|
||
|
||
import {
|
||
CheckCircle2,
|
||
KeyRound,
|
||
PlugZap,
|
||
Route,
|
||
Save,
|
||
XCircle,
|
||
} from "lucide-react";
|
||
import { useState, type ReactNode } from "react";
|
||
|
||
import { useToast } from "@/components/Toast";
|
||
import { KimiCodeOauth } from "@/components/settings/KimiCodeOauth";
|
||
import { Badge } from "@/components/ui/Badge";
|
||
import { Button } from "@/components/ui/Button";
|
||
import { EmptyState } from "@/components/ui/EmptyState";
|
||
import { SectionHeader } from "@/components/ui/SectionHeader";
|
||
import { SegmentedControl } from "@/components/ui/SegmentedControl";
|
||
import { Select } from "@/components/ui/Select";
|
||
import { StatusNote } from "@/components/ui/StatusNote";
|
||
import { TextInput } from "@/components/ui/TextInput";
|
||
import { api } from "@/lib/api/client";
|
||
import { cardClass } from "@/lib/ui/variants";
|
||
import {
|
||
API_KEY_PROVIDERS,
|
||
KNOWN_PROVIDERS,
|
||
TIER_LABELS,
|
||
applyProviderChange,
|
||
draftsToRoutingInput,
|
||
toRoutingDrafts,
|
||
type RoutingDraft,
|
||
} from "@/lib/settings/providers";
|
||
import type {
|
||
CapabilitiesView,
|
||
ProvidersResponse,
|
||
} from "@/lib/api/types";
|
||
|
||
interface ProvidersSettingsProps {
|
||
initial: ProvidersResponse;
|
||
// Kimi Code OAuth 连接态(GET .../oauth/status,进页 Server Component 取)。
|
||
kimiOauth: { connected: boolean; expiresAt: string | null };
|
||
}
|
||
|
||
interface TestResult {
|
||
ok: boolean;
|
||
capabilities: CapabilitiesView;
|
||
}
|
||
|
||
type SettingsSection = "routing" | "oauth" | "keys";
|
||
|
||
const SECTION_OPTIONS: Array<{ value: SettingsSection; label: string }> = [
|
||
{ value: "routing", label: "路由" },
|
||
{ value: "oauth", label: "OAuth" },
|
||
{ value: "keys", label: "API Key" },
|
||
];
|
||
|
||
// 设置页主体(UX §6.10):档位路由(可编辑)+ API-key 凭据行 + Kimi Code OAuth 连接区。
|
||
export function ProvidersSettings({
|
||
initial,
|
||
kimiOauth,
|
||
}: ProvidersSettingsProps) {
|
||
const toast = useToast();
|
||
const [activeSection, setActiveSection] =
|
||
useState<SettingsSection>("routing");
|
||
const [providers, setProviders] = useState(initial.providers ?? []);
|
||
const [routing, setRouting] = useState<RoutingDraft[]>(
|
||
toRoutingDrafts(initial.tier_routing ?? []),
|
||
);
|
||
const [savingRouting, setSavingRouting] = useState(false);
|
||
const [drafts, setDrafts] = useState<Record<string, string>>({});
|
||
const [savingId, setSavingId] = useState<string | null>(null);
|
||
const [testingId, setTestingId] = useState<string | null>(null);
|
||
const [results, setResults] = useState<Record<string, TestResult>>({});
|
||
|
||
const maskedFor = (id: string): string | null =>
|
||
providers.find((p) => p.provider === id)?.masked_key ?? null;
|
||
|
||
const updateRouting = (tier: string, providerId: string): void => {
|
||
setRouting((prev) =>
|
||
prev.map((d) =>
|
||
d.tier === tier ? applyProviderChange(d, providerId) : d,
|
||
),
|
||
);
|
||
};
|
||
|
||
const updateRoutingModel = (tier: string, model: string): void => {
|
||
setRouting((prev) =>
|
||
prev.map((d) => (d.tier === tier ? { ...d, model } : d)),
|
||
);
|
||
};
|
||
|
||
const saveRouting = async (): Promise<void> => {
|
||
setSavingRouting(true);
|
||
try {
|
||
const { data, error } = await api.PUT("/settings/providers", {
|
||
body: { tier_routing: draftsToRoutingInput(routing) },
|
||
});
|
||
if (error || !data) {
|
||
toast("保存档位路由失败,请重试", "error");
|
||
return;
|
||
}
|
||
setProviders(data.providers ?? []);
|
||
setRouting(toRoutingDrafts(data.tier_routing ?? []));
|
||
toast("档位路由已保存", "success");
|
||
} catch {
|
||
toast("保存档位路由失败,请检查后端连接", "error");
|
||
} finally {
|
||
setSavingRouting(false);
|
||
}
|
||
};
|
||
|
||
const saveCredential = async (providerId: string): Promise<void> => {
|
||
const apiKey = (drafts[providerId] ?? "").trim();
|
||
if (!apiKey) {
|
||
toast("请先输入 API Key", "error");
|
||
return;
|
||
}
|
||
setSavingId(providerId);
|
||
try {
|
||
const { data, error } = await api.PUT("/settings/providers", {
|
||
body: { credentials: [{ provider: providerId, api_key: apiKey }] },
|
||
});
|
||
if (error || !data) {
|
||
toast("保存凭据失败,请重试", "error");
|
||
return;
|
||
}
|
||
setProviders(data.providers ?? []);
|
||
setDrafts((prev) => ({ ...prev, [providerId]: "" }));
|
||
toast("凭据已保存", "success");
|
||
} catch {
|
||
toast("保存凭据失败,请检查后端连接", "error");
|
||
} finally {
|
||
setSavingId(null);
|
||
}
|
||
};
|
||
|
||
const testConnection = async (providerId: string): Promise<void> => {
|
||
setTestingId(providerId);
|
||
try {
|
||
const { data, error } = await api.POST("/settings/providers/test", {
|
||
body: { provider: providerId },
|
||
});
|
||
if (error || !data) {
|
||
toast("测试连接失败", "error");
|
||
return;
|
||
}
|
||
setResults((prev) => ({
|
||
...prev,
|
||
[providerId]: { ok: data.ok, capabilities: data.capabilities },
|
||
}));
|
||
toast(
|
||
data.ok ? "连接成功" : "连接未通过",
|
||
data.ok ? "success" : "error",
|
||
);
|
||
} catch {
|
||
toast("测试连接失败,请检查后端连接", "error");
|
||
} finally {
|
||
setTestingId(null);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="grid gap-6 lg:grid-cols-[12rem_1fr]">
|
||
<aside className="hidden lg:block">
|
||
<nav aria-label="设置分组" className={cardClass("sticky top-4 p-2")}>
|
||
<SettingsNavButton
|
||
active={activeSection === "routing"}
|
||
icon={<Route className="h-4 w-4" aria-hidden="true" />}
|
||
label="档位路由"
|
||
detail={`${routing.filter((r) => r.provider && r.model).length}/3 已配置`}
|
||
onClick={() => setActiveSection("routing")}
|
||
/>
|
||
<SettingsNavButton
|
||
active={activeSection === "oauth"}
|
||
icon={<PlugZap className="h-4 w-4" aria-hidden="true" />}
|
||
label="OAuth"
|
||
detail={kimiOauth.connected ? "Kimi 已连接" : "未连接"}
|
||
onClick={() => setActiveSection("oauth")}
|
||
/>
|
||
<SettingsNavButton
|
||
active={activeSection === "keys"}
|
||
icon={<KeyRound className="h-4 w-4" aria-hidden="true" />}
|
||
label="API Key"
|
||
detail={`${providers.length} 个凭据`}
|
||
onClick={() => setActiveSection("keys")}
|
||
/>
|
||
</nav>
|
||
</aside>
|
||
|
||
<div className="min-w-0">
|
||
<SegmentedControl
|
||
options={SECTION_OPTIONS}
|
||
value={activeSection}
|
||
onChange={setActiveSection}
|
||
ariaLabel="设置分组"
|
||
className="mb-4 w-full justify-center lg:hidden"
|
||
/>
|
||
|
||
{activeSection === "routing" ? (
|
||
<SettingsPanel>
|
||
<SectionHeader
|
||
title="能力档位路由"
|
||
description="写手、分析、轻量三类能力可分别指向不同模型。保存时只提交完整填写的行。"
|
||
/>
|
||
<StatusNote className="mt-3" variant="info">
|
||
写章、审稿和摘要提炼会分别读取对应档位;未完整填写的行不会覆盖现有路由。
|
||
</StatusNote>
|
||
<ul className="mt-4 divide-y divide-line rounded border border-line bg-panel">
|
||
{routing.map((row) => (
|
||
<li
|
||
key={row.tier}
|
||
className="grid gap-3 px-4 py-3 text-sm md:grid-cols-[6rem_minmax(10rem,14rem)_1fr]"
|
||
>
|
||
<span className="self-center text-ink">
|
||
{TIER_LABELS[row.tier] ?? row.tier}
|
||
</span>
|
||
<label
|
||
className="sr-only"
|
||
htmlFor={`route-provider-${row.tier}`}
|
||
>
|
||
{TIER_LABELS[row.tier] ?? row.tier} 提供商
|
||
</label>
|
||
<Select
|
||
id={`route-provider-${row.tier}`}
|
||
value={row.provider}
|
||
onChange={(e) => updateRouting(row.tier, e.target.value)}
|
||
>
|
||
<option value="">(未配置)</option>
|
||
{KNOWN_PROVIDERS.map((p) => (
|
||
<option key={p.id} value={p.id}>
|
||
{p.label}
|
||
</option>
|
||
))}
|
||
</Select>
|
||
<label className="sr-only" htmlFor={`route-model-${row.tier}`}>
|
||
{TIER_LABELS[row.tier] ?? row.tier} 模型
|
||
</label>
|
||
<TextInput
|
||
id={`route-model-${row.tier}`}
|
||
value={row.model}
|
||
onChange={(e) =>
|
||
updateRoutingModel(row.tier, e.target.value)
|
||
}
|
||
placeholder="model"
|
||
className="font-mono"
|
||
/>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
<div className="mt-3 flex justify-end">
|
||
<Button
|
||
onClick={() => void saveRouting()}
|
||
disabled={savingRouting}
|
||
variant="primary"
|
||
size="sm"
|
||
>
|
||
<Save className="h-4 w-4" aria-hidden="true" />
|
||
{savingRouting ? "保存中…" : "保存档位路由"}
|
||
</Button>
|
||
</div>
|
||
</SettingsPanel>
|
||
) : null}
|
||
|
||
{activeSection === "oauth" ? (
|
||
<SettingsPanel>
|
||
<KimiCodeOauth
|
||
initialConnected={kimiOauth.connected}
|
||
initialExpiresAt={kimiOauth.expiresAt}
|
||
/>
|
||
</SettingsPanel>
|
||
) : null}
|
||
|
||
{activeSection === "keys" ? (
|
||
<SettingsPanel>
|
||
<SectionHeader
|
||
title="提供商凭据"
|
||
description="API Key 只用于后端探活和调用,列表中只显示脱敏后的已保存凭据。"
|
||
/>
|
||
<div className="mt-3 grid gap-2 text-xs text-ink-soft sm:grid-cols-3">
|
||
<div className="rounded border border-line bg-panel px-3 py-2">
|
||
已保存 <span className="font-mono text-ink">{providers.length}</span>
|
||
</div>
|
||
<div className="rounded border border-line bg-panel px-3 py-2">
|
||
可配置{" "}
|
||
<span className="font-mono text-ink">
|
||
{API_KEY_PROVIDERS.length}
|
||
</span>
|
||
</div>
|
||
<div className="rounded border border-line bg-panel px-3 py-2">
|
||
测试结果{" "}
|
||
<span className="font-mono text-ink">
|
||
{Object.keys(results).length}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
{providers.length === 0 ? (
|
||
<EmptyState
|
||
icon={PlugZap}
|
||
title="还没有可用提供商"
|
||
description="至少连接一个提供商即可开始写作。求质量可选 Anthropic,求性价比可选 DeepSeek。"
|
||
className="mt-4"
|
||
/>
|
||
) : null}
|
||
<ul className="mt-4 divide-y divide-line rounded border border-line bg-panel">
|
||
{API_KEY_PROVIDERS.map((prov) => {
|
||
const masked = maskedFor(prov.id);
|
||
const result = results[prov.id];
|
||
return (
|
||
<li key={prov.id} className="px-4 py-4">
|
||
<div className="grid gap-3 lg:grid-cols-[10rem_8rem_minmax(12rem,1fr)_auto_auto] lg:items-center">
|
||
<span className="flex items-center gap-2 text-sm text-ink">
|
||
<span
|
||
className={`h-2 w-2 rounded ${
|
||
masked ? "bg-pass" : "bg-line"
|
||
}`}
|
||
aria-hidden="true"
|
||
/>
|
||
{prov.label}
|
||
</span>
|
||
<span className="font-mono text-xs text-ink-soft">
|
||
{masked ?? "未配置"}
|
||
</span>
|
||
<label className="sr-only" htmlFor={`key-${prov.id}`}>
|
||
{prov.label} API Key
|
||
</label>
|
||
<TextInput
|
||
id={`key-${prov.id}`}
|
||
type="password"
|
||
autoComplete="off"
|
||
value={drafts[prov.id] ?? ""}
|
||
onChange={(e) =>
|
||
setDrafts((prev) => ({
|
||
...prev,
|
||
[prov.id]: e.target.value,
|
||
}))
|
||
}
|
||
placeholder={
|
||
masked ? "输入新 Key 以更新" : "输入 API Key"
|
||
}
|
||
/>
|
||
<Button
|
||
onClick={() => saveCredential(prov.id)}
|
||
disabled={
|
||
savingId === prov.id ||
|
||
(drafts[prov.id] ?? "").trim().length === 0
|
||
}
|
||
variant="primary"
|
||
size="sm"
|
||
>
|
||
<Save className="h-4 w-4" aria-hidden="true" />
|
||
{savingId === prov.id
|
||
? "保存中…"
|
||
: masked
|
||
? "更新"
|
||
: "添加"}
|
||
</Button>
|
||
<Button
|
||
onClick={() => testConnection(prov.id)}
|
||
disabled={testingId === prov.id}
|
||
variant="secondary"
|
||
size="sm"
|
||
>
|
||
<PlugZap className="h-4 w-4" aria-hidden="true" />
|
||
{testingId === prov.id ? "测试中…" : "测试"}
|
||
</Button>
|
||
</div>
|
||
<p className="mt-2 text-xs leading-5 text-ink-soft lg:pl-[10.5rem]">
|
||
{masked
|
||
? "已保存脱敏凭据;输入新 Key 可覆盖更新。"
|
||
: "保存后再测试连接,成功后即可在档位路由中使用。"}
|
||
</p>
|
||
{result ? (
|
||
<div className="mt-2 flex flex-wrap items-center gap-2 lg:pl-[10.5rem]">
|
||
<Badge variant={result.ok ? "success" : "danger"}>
|
||
{result.ok ? (
|
||
<CheckCircle2
|
||
className="h-3 w-3"
|
||
aria-hidden="true"
|
||
/>
|
||
) : (
|
||
<XCircle className="h-3 w-3" aria-hidden="true" />
|
||
)}
|
||
{result.ok ? "已连接" : "未连接"}
|
||
</Badge>
|
||
<CapabilityBadges caps={result.capabilities} />
|
||
</div>
|
||
) : null}
|
||
</li>
|
||
);
|
||
})}
|
||
</ul>
|
||
</SettingsPanel>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function SettingsPanel({ children }: { children: ReactNode }) {
|
||
return <section className="min-w-0">{children}</section>;
|
||
}
|
||
|
||
interface SettingsNavButtonProps {
|
||
active: boolean;
|
||
icon: ReactNode;
|
||
label: string;
|
||
detail: string;
|
||
onClick: () => void;
|
||
}
|
||
|
||
function SettingsNavButton({
|
||
active,
|
||
icon,
|
||
label,
|
||
detail,
|
||
onClick,
|
||
}: SettingsNavButtonProps) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
aria-pressed={active}
|
||
onClick={onClick}
|
||
className={`mb-1 flex w-full items-start gap-2 rounded px-3 py-2 text-left transition-colors ${
|
||
active
|
||
? "bg-[var(--color-cinnabar-wash)] text-cinnabar"
|
||
: "text-ink hover:bg-bg hover:text-cinnabar"
|
||
}`}
|
||
>
|
||
<span className="mt-0.5 shrink-0">{icon}</span>
|
||
<span className="min-w-0">
|
||
<span className="block text-sm font-medium">{label}</span>
|
||
<span className="block truncate text-xs text-ink-soft">{detail}</span>
|
||
</span>
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function CapabilityBadges({ caps }: { caps: CapabilitiesView }) {
|
||
const badges: { on: boolean; label: string }[] = [
|
||
{ on: caps.structured_output, label: "结构化" },
|
||
{ on: caps.prefix_cache, label: "前缀缓存" },
|
||
{ on: caps.thinking, label: "思考" },
|
||
];
|
||
return (
|
||
<div className="flex gap-1.5">
|
||
{badges.map((b) => (
|
||
<span
|
||
key={b.label}
|
||
className={`rounded border px-2 py-0.5 text-[11px] ${
|
||
b.on
|
||
? "border-cinnabar/20 bg-[var(--color-cinnabar-wash)] text-cinnabar"
|
||
: "border-line bg-bg text-ink-soft/50"
|
||
}`}
|
||
>
|
||
{b.label}
|
||
</span>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|