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:
Yaojia Wang
2026-06-20 10:39:58 +02:00
parent 5fb7bfb1de
commit 765dbdfbd4
161 changed files with 17330 additions and 208 deletions

View File

@@ -0,0 +1,110 @@
"use client";
import { useCallback, useState, type ChangeEvent } from "react";
import { hasUsableSamples, type StyleLearnMode } from "@/lib/style/style";
import { useToast } from "@/components/Toast";
interface StyleUploadProps {
busy: boolean;
pollStatus: "idle" | "polling" | "done" | "error";
progress: number;
// 是否已有指纹(决定默认 mode = update
hasFingerprint: boolean;
onLearn: (samples: string[], mode: StyleLearnMode) => void;
}
// 学文风输入UX §6.9):多段文本粘贴 + 文件选择(在浏览器读成文本)。
// 样本以正文文本入 body无对象存储确认决策
export function StyleUpload({
busy,
pollStatus,
progress,
hasFingerprint,
onLearn,
}: StyleUploadProps) {
const [text, setText] = useState("");
const toast = useToast();
// 读取选中的文本文件,追加到文本框(多文件用空行分隔)。
const onFiles = useCallback(
async (e: ChangeEvent<HTMLInputElement>): Promise<void> => {
const files = Array.from(e.target.files ?? []);
if (files.length === 0) return;
try {
const contents = await Promise.all(files.map((f) => f.text()));
setText((prev) => {
const joined = contents.join("\n\n");
return prev.trim().length > 0 ? `${prev}\n\n${joined}` : joined;
});
} catch {
toast("读取文件失败,请改用粘贴。", "error");
} finally {
e.target.value = ""; // 允许重复选同一文件。
}
},
[toast],
);
const submit = (): void => {
// 以空行切分成多段样本(对齐后端 samples:list[str])。
const samples = text
.split(/\n{2,}/)
.map((s) => s.trim())
.filter((s) => s.length > 0);
if (!hasUsableSamples(samples)) {
toast("请粘贴或选择至少一段样本正文。", "error");
return;
}
onLearn(samples, hasFingerprint ? "update" : "create");
};
return (
<div className="space-y-3">
<div>
<label
htmlFor="style-samples"
className="mb-1 block text-sm font-semibold text-ink"
>
</label>
<textarea
id="style-samples"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="粘贴你想学习文风的章节正文…"
className="min-h-[30vh] w-full resize-y rounded border border-line bg-panel p-3 font-serif text-[15px] leading-[1.9] text-ink focus:border-cinnabar focus:outline-none"
/>
</div>
<div className="flex items-center gap-3">
<label className="cursor-pointer rounded border border-line px-3 py-1.5 text-sm text-ink hover:border-cinnabar hover:text-cinnabar">
<input
type="file"
accept=".txt,.md,text/plain,text/markdown"
multiple
onChange={(e) => void onFiles(e)}
className="sr-only"
/>
</label>
<button
type="button"
onClick={submit}
disabled={busy}
className="rounded bg-cinnabar px-4 py-1.5 text-sm text-panel hover:opacity-90 disabled:opacity-50"
>
{hasFingerprint ? "重新学习文风" : "学习文风"}
</button>
</div>
{pollStatus === "polling" ? (
<p className="text-xs text-info" aria-live="polite">
{progress}%
</p>
) : pollStatus === "error" ? (
<p className="text-xs text-conflict"></p>
) : null}
</div>
);
}