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:
65
apps/web/components/style/FingerprintView.tsx
Normal file
65
apps/web/components/style/FingerprintView.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import type { Fingerprint } from "@/lib/style/style";
|
||||
|
||||
interface FingerprintViewProps {
|
||||
fingerprint: Fingerprint | null;
|
||||
}
|
||||
|
||||
// 文风指纹展示(UX §6.9):16 维行(维度名 + 值 + 可展开原文证据)。
|
||||
export function FingerprintView({ fingerprint }: FingerprintViewProps) {
|
||||
if (fingerprint === null) {
|
||||
return (
|
||||
<p className="rounded border border-dashed border-line p-4 text-sm text-ink-soft">
|
||||
尚未学习文风。在左侧粘贴样本正文(或选文件)后点「学习文风」。
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<h2 className="font-serif text-lg text-ink">文风指纹</h2>
|
||||
<span className="font-mono text-xs text-ink-soft">
|
||||
v{fingerprint.version} · {fingerprint.dimensions.length} 维
|
||||
</span>
|
||||
</div>
|
||||
{fingerprint.dimensions.length === 0 ? (
|
||||
<p className="text-sm text-ink-soft">指纹无维度数据。</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{fingerprint.dimensions.map((dim) => (
|
||||
<li
|
||||
key={dim.name}
|
||||
className="rounded border border-line bg-panel p-3"
|
||||
>
|
||||
<div className="flex items-baseline gap-3">
|
||||
<span className="w-28 shrink-0 text-sm font-semibold text-ink">
|
||||
{dim.name}
|
||||
</span>
|
||||
<span className="text-sm text-ink-soft">{dim.value}</span>
|
||||
</div>
|
||||
{dim.evidence.length > 0 ? (
|
||||
<details className="mt-2">
|
||||
<summary className="cursor-pointer text-xs text-ink-soft hover:text-cinnabar">
|
||||
原文证据({dim.evidence.length})
|
||||
</summary>
|
||||
<ul className="mt-1 space-y-1 border-l-2 border-line pl-3">
|
||||
{dim.evidence.map((quote, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className="text-xs italic leading-relaxed text-ink-soft"
|
||||
>
|
||||
「{quote}」
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
100
apps/web/components/style/RefineView.tsx
Normal file
100
apps/web/components/style/RefineView.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import { useRefine } from "@/lib/style/useRefine";
|
||||
import { useEffect } from "react";
|
||||
|
||||
interface RefineViewProps {
|
||||
projectId: string;
|
||||
chapterNo: number;
|
||||
// 选中漂移段的原文(从终稿按段切出;空则提示先编辑终稿)。
|
||||
segment: string;
|
||||
// 采纳:把 refined 合入终稿(经审稿页 finalText → 既有 draft 自动保存路径)。
|
||||
onAdopt: (original: string, refined: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// 回炉 diff(UX §8.3):POST .../refine → 展示原段/重写段对比 → 采纳合入正文。
|
||||
// 不另写库(不变量#3);采纳经既有 draft 自动保存合入。
|
||||
export function RefineView({
|
||||
projectId,
|
||||
chapterNo,
|
||||
segment,
|
||||
onAdopt,
|
||||
onClose,
|
||||
}: RefineViewProps) {
|
||||
const refiner = useRefine();
|
||||
|
||||
// 进入即触发回炉(段非空)。
|
||||
useEffect(() => {
|
||||
if (segment.trim().length > 0) {
|
||||
void refiner.refine(projectId, chapterNo, segment);
|
||||
}
|
||||
// 仅在段变化时触发。
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [segment, projectId, chapterNo]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded border border-cinnabar bg-panel p-3 text-xs"
|
||||
role="dialog"
|
||||
aria-label="回炉对比"
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h3 className="font-semibold text-ink">回炉对比</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-ink-soft hover:text-cinnabar"
|
||||
aria-label="关闭回炉对比"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{segment.trim().length === 0 ? (
|
||||
<p className="text-overdue">该段在终稿中为空,请先在终稿中保留该段正文。</p>
|
||||
) : refiner.status === "refining" ? (
|
||||
<p className="text-info" aria-live="polite">
|
||||
回炉中…
|
||||
</p>
|
||||
) : refiner.status === "error" ? (
|
||||
<p className="text-conflict">回炉失败,请稍后重试。</p>
|
||||
) : refiner.result ? (
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<p className="mb-1 text-ink-soft">原段</p>
|
||||
<p className="whitespace-pre-wrap rounded border border-line bg-bg p-2 text-ink-soft line-through">
|
||||
{refiner.result.original}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-1 text-cinnabar">重写段</p>
|
||||
<p className="whitespace-pre-wrap rounded border border-cinnabar bg-bg p-2 text-ink">
|
||||
{refiner.result.refined}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (refiner.result) {
|
||||
onAdopt(refiner.result.original, refiner.result.refined);
|
||||
}
|
||||
}}
|
||||
className="rounded bg-cinnabar px-3 py-1.5 text-panel hover:opacity-90"
|
||||
>
|
||||
采纳合入正文
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded border border-line px-3 py-1.5 text-ink hover:border-cinnabar"
|
||||
>
|
||||
放弃
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
48
apps/web/components/style/StylePage.tsx
Normal file
48
apps/web/components/style/StylePage.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import type { ProjectResponse } from "@/lib/api/types";
|
||||
import { useStyleLearn } from "@/lib/style/useStyleLearn";
|
||||
import type { Fingerprint, StyleLearnMode } from "@/lib/style/style";
|
||||
import { FingerprintView } from "./FingerprintView";
|
||||
import { StyleUpload } from "./StyleUpload";
|
||||
|
||||
interface StylePageProps {
|
||||
project: ProjectResponse;
|
||||
initialFingerprint: Fingerprint | null;
|
||||
}
|
||||
|
||||
// 文风页(UX §6.9):左侧上传样本学文风,右侧展示 16 维指纹 + 证据。
|
||||
export function StylePage({ project, initialFingerprint }: StylePageProps) {
|
||||
const learn = useStyleLearn(initialFingerprint);
|
||||
|
||||
const onLearn = (samples: string[], mode: StyleLearnMode): void => {
|
||||
void learn.learn(project.id, samples, mode);
|
||||
};
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
title={`〈${project.title}〉`}
|
||||
subtitle="文风指纹"
|
||||
projectId={project.id}
|
||||
activeNav="style"
|
||||
>
|
||||
<div className="grid h-[calc(100vh-4rem)] grid-cols-1 lg:grid-cols-[28rem_1fr]">
|
||||
<section className="flex flex-col overflow-auto border-r border-line bg-panel px-6 py-6">
|
||||
<h1 className="mb-3 font-serif text-lg text-ink">学习文风</h1>
|
||||
<StyleUpload
|
||||
busy={learn.busy}
|
||||
pollStatus={learn.pollStatus === "idle" ? "idle" : learn.pollStatus}
|
||||
progress={learn.progress}
|
||||
hasFingerprint={learn.fingerprint !== null}
|
||||
onLearn={onLearn}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="overflow-auto bg-bg px-6 py-6">
|
||||
<FingerprintView fingerprint={learn.fingerprint} />
|
||||
</section>
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
87
apps/web/components/style/StylePanel.tsx
Normal file
87
apps/web/components/style/StylePanel.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import type { StyleDriftReport, StyleDriftSegment } from "@/lib/review/sse";
|
||||
|
||||
interface StylePanelProps {
|
||||
style: StyleDriftReport | null;
|
||||
incomplete: boolean;
|
||||
// 选中段做回炉(打开 RefineView)。
|
||||
onRefine: (segment: StyleDriftSegment) => void;
|
||||
}
|
||||
|
||||
// 整体相似度的四分圆字符档(◔◑◕●),按 score 映射(UX §6.4 ③)。
|
||||
const QUARTER_CHARS = ["○", "◔", "◑", "◕", "●"] as const;
|
||||
|
||||
function quarterChar(score: number): string {
|
||||
const clamped = Math.min(100, Math.max(0, score));
|
||||
const idx = Math.round((clamped / 100) * (QUARTER_CHARS.length - 1));
|
||||
return QUARTER_CHARS[idx] ?? QUARTER_CHARS[0];
|
||||
}
|
||||
|
||||
// 文风审区(UX §6.4 ③):整体相似度 ◔% + 漂移段列表,每段可一键回炉。只读建议。
|
||||
export function StylePanel({ style, incomplete, onRefine }: StylePanelProps) {
|
||||
return (
|
||||
<section className="mb-4">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wide text-ink-soft">
|
||||
文风 (style-auditor)
|
||||
</h2>
|
||||
{incomplete ? (
|
||||
<p className="mt-1 text-xs text-overdue">◌ 未完成</p>
|
||||
) : style === null ? (
|
||||
<p className="mt-1 text-xs text-ink-soft">
|
||||
无文风报告(学文风后第四审才能打分)。
|
||||
</p>
|
||||
) : (
|
||||
<div className="mt-2 space-y-2 text-xs">
|
||||
<p
|
||||
className="flex items-center gap-1.5"
|
||||
aria-label={`整体文风相似度 ${style.score}%`}
|
||||
role="img"
|
||||
>
|
||||
<span className="text-lg leading-none text-cinnabar" aria-hidden="true">
|
||||
{quarterChar(style.score)}
|
||||
</span>
|
||||
<span className="text-ink">
|
||||
整体相似度 <span className="font-mono">{style.score}%</span>
|
||||
</span>
|
||||
</p>
|
||||
|
||||
{style.segments.length === 0 ? (
|
||||
<p className="text-pass">✓ 无明显文风漂移</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{style.segments.map((seg, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className="rounded border border-line bg-panel p-2"
|
||||
data-testid="drift-segment"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-overdue">
|
||||
第 {seg.idx} 段
|
||||
</span>
|
||||
<span className="font-mono text-ink-soft">
|
||||
相似 {seg.score}%
|
||||
</span>
|
||||
{seg.label ? (
|
||||
<span className="text-ink-soft">{seg.label}</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-1.5 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRefine(seg)}
|
||||
className="rounded bg-cinnabar px-2 py-1 text-panel hover:opacity-90"
|
||||
>
|
||||
一键回炉
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
110
apps/web/components/style/StyleUpload.tsx
Normal file
110
apps/web/components/style/StyleUpload.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user