490 lines
17 KiB
TypeScript
490 lines
17 KiB
TypeScript
"use client";
|
||
|
||
import {
|
||
CheckCircle2,
|
||
KeyRound,
|
||
PlugZap,
|
||
Route,
|
||
Save,
|
||
XCircle,
|
||
type LucideIcon,
|
||
} 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";
|
||
|
||
// 分组的单一标签源:桌面 aside 与移动 SegmentedControl 共用,消除「档位路由 / 路由」漂移。
|
||
const SETTINGS_SECTIONS: Array<{
|
||
value: SettingsSection;
|
||
label: string;
|
||
icon: LucideIcon;
|
||
}> = [
|
||
{ value: "routing", label: "档位路由", icon: Route },
|
||
{ value: "oauth", label: "OAuth", icon: PlugZap },
|
||
{ value: "keys", label: "API Key", icon: KeyRound },
|
||
];
|
||
|
||
const SECTION_OPTIONS = SETTINGS_SECTIONS.map(({ value, label }) => ({
|
||
value,
|
||
label,
|
||
}));
|
||
|
||
// 档位路由「模型」输入框的候选 model id(datalist 建议,仍可自由输入)。按 provider 分组。
|
||
const MODEL_SUGGESTIONS: Record<string, readonly string[]> = {
|
||
anthropic: ["claude-opus-4", "claude-sonnet-4", "claude-3-5-haiku"],
|
||
deepseek: ["deepseek-chat", "deepseek-reasoner"],
|
||
kimi: ["moonshot-v1-128k", "moonshot-v1-32k", "kimi-k2"],
|
||
openai: ["gpt-4o", "gpt-4o-mini", "o3-mini"],
|
||
qwen: ["qwen-max", "qwen-plus", "qwen-turbo"],
|
||
glm: ["glm-4-plus", "glm-4-air", "glm-4-flash"],
|
||
gemini: ["gemini-2.0-flash", "gemini-1.5-pro"],
|
||
"kimi-code-key": ["kimi-for-coding"],
|
||
"kimi-code": ["kimi-for-coding"],
|
||
};
|
||
|
||
// 设置页主体(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 sectionDetail = (value: SettingsSection): string => {
|
||
if (value === "routing") {
|
||
return `${routing.filter((r) => r.provider && r.model).length}/3 已配置`;
|
||
}
|
||
if (value === "oauth") {
|
||
return kimiOauth.connected ? "Kimi 已连接" : "未连接";
|
||
}
|
||
return `${providers.length} 个凭据`;
|
||
};
|
||
|
||
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-20 p-2")}>
|
||
{SETTINGS_SECTIONS.map((section) => (
|
||
<SettingsNavButton
|
||
key={section.value}
|
||
active={activeSection === section.value}
|
||
icon={<section.icon className="h-4 w-4" aria-hidden="true" />}
|
||
label={section.label}
|
||
detail={sectionDetail(section.value)}
|
||
onClick={() => setActiveSection(section.value)}
|
||
/>
|
||
))}
|
||
</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>
|
||
<div className="min-w-0">
|
||
<TextInput
|
||
id={`route-model-${row.tier}`}
|
||
value={row.model}
|
||
onChange={(e) =>
|
||
updateRoutingModel(row.tier, e.target.value)
|
||
}
|
||
placeholder="model"
|
||
className="w-full font-mono"
|
||
list={`route-models-${row.tier}`}
|
||
/>
|
||
<datalist id={`route-models-${row.tier}`}>
|
||
{(MODEL_SUGGESTIONS[row.provider] ?? []).map((m) => (
|
||
<option key={m} value={m} />
|
||
))}
|
||
</datalist>
|
||
</div>
|
||
</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) => (
|
||
<Badge
|
||
key={b.label}
|
||
variant={b.on ? "accent" : "neutral"}
|
||
className="text-2xs"
|
||
>
|
||
{b.label}
|
||
</Badge>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|