- 薄自建 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 走通闭环
210 lines
7.6 KiB
TypeScript
210 lines
7.6 KiB
TypeScript
"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>
|
||
);
|
||
}
|