Files
writer-work-flow/apps/web/components/settings/ProvidersSettings.tsx
Yaojia Wang b523b4fd21 feat: M1 — 立项→写章草稿(SSE)→自动保存;连一家 provider
- 薄自建 LLM 网关:OpenAI 兼容适配器(DeepSeek) + instructor 结构化输出 + usage_ledger 记账 + 档位路由
- 记忆服务 assemble:确定性选择(显式+主角+近况) + 渲染卡 + 缓存断点(中性文本)
- LangGraph 写章节点 + Postgres checkpointer + SSE 归一(token/done/error)
- API:立项 + 写章 draft(SSE) + PUT 自动保存 + 提供商凭据(Fernet 加密/测试连接)
- 前端:AppShell + 作品库 + 5 步立项向导 + 写作工作台(流式打字机+自动保存) + 设置页
- M1 E2E:真实 DB + mock 网关零 token 走通闭环
2026-06-18 11:38:28 +02:00

210 lines
7.6 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 { useState } from "react";
import { api } from "@/lib/api/client";
import { useToast } from "@/components/Toast";
import { KNOWN_PROVIDERS, TIER_LABELS } from "@/lib/settings/providers";
import type {
CapabilitiesView,
ProvidersResponse,
TierRoutingView,
} from "@/lib/api/types";
interface ProvidersSettingsProps {
initial: ProvidersResponse;
}
interface TestResult {
ok: boolean;
capabilities: CapabilitiesView;
}
// 设置页主体UX §6.10):档位路由(只读展示)+ 凭据行(脱敏 / 添加更新 / 测试连接)。
export function ProvidersSettings({ initial }: ProvidersSettingsProps) {
const toast = useToast();
const [providers, setProviders] = useState(initial.providers ?? []);
const tierRouting: TierRoutingView[] = initial.tier_routing ?? [];
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 saveCredential = async (providerId: string): Promise<void> => {
const apiKey = (drafts[providerId] ?? "").trim();
if (!apiKey) {
toast("请先输入 API Key", "error");
return;
}
setSavingId(providerId);
const { data, error } = await api.PUT("/settings/providers", {
body: { credentials: [{ provider: providerId, api_key: apiKey }] },
});
setSavingId(null);
if (error || !data) {
toast("保存凭据失败,请重试", "error");
return;
}
setProviders(data.providers ?? []);
setDrafts((prev) => ({ ...prev, [providerId]: "" }));
toast("凭据已保存", "success");
};
const testConnection = async (providerId: string): Promise<void> => {
setTestingId(providerId);
const { data, error } = await api.POST("/settings/providers/test", {
body: { provider: providerId },
});
setTestingId(null);
if (error || !data) {
toast("测试连接失败", "error");
return;
}
setResults((prev) => ({
...prev,
[providerId]: { ok: data.ok, capabilities: data.capabilities },
}));
toast(data.ok ? "连接成功" : "连接未通过", data.ok ? "success" : "error");
};
return (
<div>
<section className="mb-10">
<h2 className="mb-3 font-serif text-xl text-ink"></h2>
{tierRouting.length === 0 ? (
<p className="rounded border border-dashed border-line bg-panel p-4 text-sm text-ink-soft">
使
</p>
) : (
<ul className="divide-y divide-line rounded border border-line bg-panel">
{tierRouting.map((t) => (
<li
key={t.tier}
className="flex items-center gap-4 px-4 py-3 text-sm"
>
<span className="w-20 text-ink">
{TIER_LABELS[t.tier] ?? t.tier}
</span>
<span className="font-mono text-ink-soft">
{t.provider} · {t.model}
</span>
{t.fallback && t.fallback.length > 0 ? (
<span className="ml-auto font-mono text-xs text-ink-soft">
退 {t.fallback.join(", ")}
</span>
) : null}
</li>
))}
</ul>
)}
</section>
<section>
<h2 className="mb-3 font-serif text-xl text-ink"></h2>
{providers.length === 0 ? (
<p className="mb-4 rounded border border-dashed border-line bg-panel p-4 text-sm text-ink-soft">
Anthropic /
DeepSeek
</p>
) : null}
<ul className="divide-y divide-line rounded border border-line bg-panel">
{KNOWN_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="flex flex-wrap items-center gap-3">
<span
className={`h-2 w-2 rounded-full ${masked ? "bg-pass" : "bg-line"}`}
aria-hidden="true"
/>
<span className="w-28 text-sm text-ink">{prov.label}</span>
{masked ? (
<span className="font-mono text-xs text-ink-soft">
{masked}
</span>
) : null}
<label className="sr-only" htmlFor={`key-${prov.id}`}>
{prov.label} API Key
</label>
<input
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"}
className="min-w-[12rem] flex-1 rounded border border-line bg-bg px-3 py-1.5 text-sm text-ink focus:border-cinnabar focus:outline-none"
/>
<button
type="button"
onClick={() => saveCredential(prov.id)}
disabled={savingId === prov.id}
className="rounded bg-cinnabar px-3 py-1.5 text-sm text-panel disabled:opacity-40"
>
{savingId === prov.id
? "保存中…"
: masked
? "更新凭据"
: "添加凭据"}
</button>
<button
type="button"
onClick={() => testConnection(prov.id)}
disabled={testingId === prov.id}
className="rounded border border-line px-3 py-1.5 text-sm text-ink disabled:opacity-40"
>
{testingId === prov.id ? "测试中…" : "测试连接"}
</button>
</div>
{result ? (
<div className="mt-2 flex items-center gap-2 pl-5">
<span
className={`text-xs ${result.ok ? "text-pass" : "text-conflict"}`}
>
{result.ok ? "✓ 已连接" : "✗ 未连接"}
</span>
<CapabilityBadges caps={result.capabilities} />
</div>
) : null}
</li>
);
})}
</ul>
</section>
</div>
);
}
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 px-2 py-0.5 text-[11px] ${
b.on
? "bg-[var(--color-cinnabar-wash)] text-cinnabar"
: "bg-bg text-ink-soft/50"
}`}
>
{b.label}
</span>
))}
</div>
);
}