M4(文风): style-auditor 双轨(提取指纹/漂移第四审)+ jobs 长任务框架(zombie reaper) + 回炉 refine + GET /style read-back。 M5(生成+扩展): worldbuilder/character-gen(入库 continuity 409 gate + partition_writes 白名单 + schema→JSONB 形变); 网关多 provider 回退链/熔断/能力降级(Anthropic/Gemini 适配器);Skill registry + 表权限沙箱 + 规则; 前端 角色生成器/世界观/Codex/规则页/技能库/⌘K 命令面板。 K1(Kimi Code 订阅接入): OAuth device-flow(kimi-code)+ 静态 Console key(kimi-code-key)两路径; coding 端点 KimiCLI 伪造头(实测 UA allow-list 门禁,缺则 403)+ JSON 模式结构化(thinking ⊥ tool_choice)。 本地联调修复: CORS 中间件;assemble 注入 premise+「写第N章」指令(修空 prompt 400); GET /outline·/draft read-back + 大纲/工作台/审稿页重载;写页 client/server 常量边界 + notFound 健壮化; 字数 toLocaleString locale 水合;审稿页终稿从已存草稿 seed(修 accept 422)。 门禁: backend ruff/mypy(157)/alembic 无漂移/pytest 451 · frontend lint/tsc/vitest/build。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
282 lines
10 KiB
TypeScript
282 lines
10 KiB
TypeScript
"use client";
|
||
|
||
import { useState } from "react";
|
||
|
||
import { api } from "@/lib/api/client";
|
||
import { useToast } from "@/components/Toast";
|
||
import { KimiCodeOauth } from "@/components/settings/KimiCodeOauth";
|
||
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;
|
||
}
|
||
|
||
// 设置页主体(UX §6.10):档位路由(可编辑)+ API-key 凭据行 + Kimi Code OAuth 连接区。
|
||
export function ProvidersSettings({
|
||
initial,
|
||
kimiOauth,
|
||
}: ProvidersSettingsProps) {
|
||
const toast = useToast();
|
||
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 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);
|
||
const { data, error } = await api.PUT("/settings/providers", {
|
||
body: { tier_routing: draftsToRoutingInput(routing) },
|
||
});
|
||
setSavingRouting(false);
|
||
if (error || !data) {
|
||
toast("保存档位路由失败,请重试", "error");
|
||
return;
|
||
}
|
||
setProviders(data.providers ?? []);
|
||
setRouting(toRoutingDrafts(data.tier_routing ?? []));
|
||
toast("档位路由已保存", "success");
|
||
};
|
||
|
||
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>
|
||
<ul className="divide-y divide-line rounded border border-line bg-panel">
|
||
{routing.map((row) => (
|
||
<li
|
||
key={row.tier}
|
||
className="flex flex-wrap items-center gap-3 px-4 py-3 text-sm"
|
||
>
|
||
<span className="w-20 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)}
|
||
className="rounded border border-line bg-bg px-2 py-1.5 text-sm text-ink focus:border-cinnabar focus:outline-none"
|
||
>
|
||
<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>
|
||
<input
|
||
id={`route-model-${row.tier}`}
|
||
value={row.model}
|
||
onChange={(e) => updateRoutingModel(row.tier, e.target.value)}
|
||
placeholder="model"
|
||
className="min-w-[10rem] flex-1 rounded border border-line bg-bg px-3 py-1.5 font-mono text-sm text-ink focus:border-cinnabar focus:outline-none"
|
||
/>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
<div className="mt-3 flex justify-end">
|
||
<button
|
||
type="button"
|
||
onClick={() => void saveRouting()}
|
||
disabled={savingRouting}
|
||
className="rounded bg-cinnabar px-3 py-1.5 text-sm text-panel disabled:opacity-40"
|
||
>
|
||
{savingRouting ? "保存中…" : "保存档位路由"}
|
||
</button>
|
||
</div>
|
||
</section>
|
||
|
||
<KimiCodeOauth
|
||
initialConnected={kimiOauth.connected}
|
||
initialExpiresAt={kimiOauth.expiresAt}
|
||
/>
|
||
|
||
<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">
|
||
{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="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>
|
||
);
|
||
}
|