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 走通闭环
This commit is contained in:
209
apps/web/components/settings/ProvidersSettings.tsx
Normal file
209
apps/web/components/settings/ProvidersSettings.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user