feat: M4 文风 + M5 生成/多provider/Skill + Kimi Code 订阅接入 + 本地联调修复
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>
This commit is contained in:
@@ -4,15 +4,25 @@ 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 { 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,
|
||||
TierRoutingView,
|
||||
} from "@/lib/api/types";
|
||||
|
||||
interface ProvidersSettingsProps {
|
||||
initial: ProvidersResponse;
|
||||
// Kimi Code OAuth 连接态(GET .../oauth/status,进页 Server Component 取)。
|
||||
kimiOauth: { connected: boolean; expiresAt: string | null };
|
||||
}
|
||||
|
||||
interface TestResult {
|
||||
@@ -20,11 +30,17 @@ interface TestResult {
|
||||
capabilities: CapabilitiesView;
|
||||
}
|
||||
|
||||
// 设置页主体(UX §6.10):档位路由(只读展示)+ 凭据行(脱敏 / 添加更新 / 测试连接)。
|
||||
export function ProvidersSettings({ initial }: ProvidersSettingsProps) {
|
||||
// 设置页主体(UX §6.10):档位路由(可编辑)+ API-key 凭据行 + Kimi Code OAuth 连接区。
|
||||
export function ProvidersSettings({
|
||||
initial,
|
||||
kimiOauth,
|
||||
}: ProvidersSettingsProps) {
|
||||
const toast = useToast();
|
||||
const [providers, setProviders] = useState(initial.providers ?? []);
|
||||
const tierRouting: TierRoutingView[] = initial.tier_routing ?? [];
|
||||
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);
|
||||
@@ -33,6 +49,35 @@ export function ProvidersSettings({ initial }: ProvidersSettingsProps) {
|
||||
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) {
|
||||
@@ -74,34 +119,61 @@ export function ProvidersSettings({ initial }: ProvidersSettingsProps) {
|
||||
<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"
|
||||
<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"
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
<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 ? (
|
||||
@@ -111,7 +183,7 @@ export function ProvidersSettings({ initial }: ProvidersSettingsProps) {
|
||||
</p>
|
||||
) : null}
|
||||
<ul className="divide-y divide-line rounded border border-line bg-panel">
|
||||
{KNOWN_PROVIDERS.map((prov) => {
|
||||
{API_KEY_PROVIDERS.map((prov) => {
|
||||
const masked = maskedFor(prov.id);
|
||||
const result = results[prov.id];
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user