Files
writer-work-flow/apps/web/components/settings/ProvidersSettings.tsx

490 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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 iddatalist 建议,仍可自由输入)。按 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>
);
}