feat: M2 — 写→审(一致性)→裁决→验收(事务);未决冲突禁验收

- 续审 Agent 声明(AgentSpec) + 结构化输出契约(ContinuityReview/Conflict 五类)
- LangGraph 并行审子图(可扩四审) + collect 落 chapter_reviews 留痕 + review SSE(section/conflict)
- 验收-side Repository:章节 accepted 版本晋升 + digest append-only + 审稿留痕/裁决
- API:review(SSE) + reviews 历史 + accept(单原子事务:晋升 version + 终稿 digest + 裁决留痕)
- 冲突 gate:未决裁决拦截(CONFLICT_UNRESOLVED);digest 从终稿提炼(不变量#4)
- 前端:审稿报告页 + 冲突就地标注 + 裁决(采纳/忽略/手改) + 未决禁验收 + 「本次将更新」清单
- M2 E2E:真实 DB + 多档位 mock 网关零 token 走通 写→审→裁决→验收→摘要入库
- 多 agent 协同台账(PROGRESS.md) + 共享记忆(memory/contracts·decisions·gotchas)
This commit is contained in:
Yaojia Wang
2026-06-18 11:38:28 +02:00
parent b523b4fd21
commit 68f194a043
36 changed files with 3881 additions and 0 deletions

View File

@@ -0,0 +1,40 @@
import { notFound } from "next/navigation";
import { ReviewReport } from "@/components/review/ReviewReport";
import { fetchProject, fetchReviews } from "@/lib/api/server";
import { latestReview } from "@/lib/review/history";
interface PageProps {
params: Promise<{ id: string }>;
searchParams: Promise<{ chapter?: string }>;
}
const DEFAULT_CHAPTER_NO = 1;
// 审稿报告页UX §6.4。Server Component 取项目 + 审稿留痕历史(新→旧);
// ReviewReportClient承载 SSE 重审 / 裁决 / 验收交互。
export default async function ReviewPage({ params, searchParams }: PageProps) {
const { id } = await params;
const { chapter } = await searchParams;
const chapterNo = parsePositiveInt(chapter) ?? DEFAULT_CHAPTER_NO;
try {
const project = await fetchProject(id);
const history = await fetchReviews(id, chapterNo);
return (
<ReviewReport
project={project}
chapterNo={chapterNo}
initialReview={latestReview(history.reviews)}
initialDraft=""
/>
);
} catch {
notFound();
}
}
function parsePositiveInt(raw: string | undefined): number | null {
if (!raw) return null;
const n = Number.parseInt(raw, 10);
return Number.isInteger(n) && n > 0 ? n : null;
}

View File

@@ -0,0 +1,76 @@
"use client";
import type { AcceptResponse } from "@/lib/api/types";
interface AcceptPanelProps {
conflictCount: number;
unresolvedCount: number;
accepting: boolean;
result: AcceptResponse | null;
onAccept: () => void;
}
// 验收 gateUX §9 / §8.4):未决禁验收(置灰 + 文案),验收后呈现「本次将更新」清单。
export function AcceptPanel({
conflictCount,
unresolvedCount,
accepting,
result,
onAccept,
}: AcceptPanelProps) {
const blocked = unresolvedCount > 0;
if (result) {
return (
<div className="rounded border border-pass/50 bg-panel p-4">
<h3 className="mb-2 text-sm font-semibold text-pass"></h3>
<ul className="space-y-1 text-xs text-ink-soft">
<li>
<span className="font-mono text-ink">v{result.accepted_version}</span>
</li>
<li>
{result.digest_added ? "已新增一行" : "未变更"}
</li>
<li>
<span className="font-mono text-ink">
{result.decisions_recorded}
</span>{" "}
</li>
{result.review_id ? (
<li className="truncate">
<span className="font-mono">{result.review_id}</span>
</li>
) : null}
</ul>
</div>
);
}
return (
<div className="rounded border border-line bg-panel p-4">
{blocked ? (
<p className="mb-2 text-xs text-conflict" role="status">
{unresolvedCount}
</p>
) : (
<p className="mb-2 text-xs text-ink-soft">
{conflictCount === 0
? "无冲突,可直接验收。"
: "全部冲突已裁决,可验收本章。"}
</p>
)}
<button
type="button"
onClick={onAccept}
disabled={blocked || accepting}
aria-disabled={blocked || accepting}
className="w-full rounded bg-cinnabar px-4 py-2 text-sm text-panel hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-40"
>
{accepting ? "验收中…" : "全部处理完 → 验收本章"}
</button>
</div>
);
}

View File

@@ -0,0 +1,54 @@
"use client";
import type { ReviewConflict } from "@/lib/review/sse";
interface AnnotatedTextProps {
text: string;
conflicts: ReviewConflict[];
// 当前聚焦的冲突下标(来自报告卡「跳转」),用于锚点联动。
focusedIndex: number | null;
onAnchorClick: (index: number) => void;
}
// 正文就地标注UX §8.3 / §6.1)。
// M2 占位R6`where` 是文字定位如「第4段」M2 不做精确字符 range —
// 改为段级/锚点联动:每个冲突渲染为一枚朱砂波浪线锚点挂在正文上方,点击=回跳报告卡。
// 精确 inline range 标注留 M3+(需后端给字符 offset
export function AnnotatedText({
text,
conflicts,
focusedIndex,
onAnchorClick,
}: AnnotatedTextProps) {
return (
<div className="flex h-full flex-col">
{conflicts.length > 0 ? (
<div className="mb-3 flex flex-wrap gap-2 border-b border-line pb-3">
{conflicts.map((c, i) => (
<button
key={i}
type="button"
id={`anchor-${i}`}
onClick={() => onAnchorClick(i)}
aria-current={focusedIndex === i ? "true" : undefined}
className={`conflict-anchor rounded px-2 py-0.5 text-xs ${
focusedIndex === i
? "bg-cinnabar text-panel"
: "text-conflict hover:bg-[var(--color-conflict)]/10"
}`}
title={`${c.type}${c.where || "正文"}`}
>
<span aria-hidden="true"></span> {c.type}
{c.where ? ` · ${c.where}` : ""}
</button>
))}
</div>
) : null}
<article className="flex-1 overflow-auto whitespace-pre-wrap font-serif text-[17px] leading-[1.9] text-ink">
{text || (
<span className="text-ink-soft/60"></span>
)}
</article>
</div>
);
}

View File

@@ -0,0 +1,119 @@
"use client";
import type { ReviewConflict } from "@/lib/review/sse";
import type { DecisionDraft, Verdict } from "@/lib/review/decisions";
interface ConflictCardProps {
index: number;
conflict: ReviewConflict;
draft: DecisionDraft;
missing: boolean;
onVerdict: (index: number, verdict: Verdict) => void;
onNote: (index: number, note: string) => void;
onJump: (index: number) => void;
}
const VERDICT_OPTIONS: { value: Verdict; label: string }[] = [
{ value: "accept", label: "采纳改法" },
{ value: "ignore", label: "忽略" },
{ value: "manual", label: "手改" },
];
// 单个冲突报告卡UX §6.4):五类徽标 + where + refs + suggestion + 裁决三态。
// 冲突=赭红;未决/缺判时加图标+文案不单靠色a11y §10
export function ConflictCard({
index,
conflict,
draft,
missing,
onVerdict,
onNote,
onJump,
}: ConflictCardProps) {
const resolved = draft.verdict !== null;
return (
<li
id={`conflict-card-${index}`}
className={`rounded border bg-panel p-4 ${
missing
? "border-conflict ring-1 ring-conflict"
: resolved
? "border-pass/40"
: "border-line"
}`}
>
<div className="flex items-start gap-3">
<span
className="shrink-0 rounded bg-[var(--color-conflict)]/10 px-2 py-0.5 text-xs text-conflict"
aria-hidden="true"
>
{conflict.type}
</span>
<p className="flex-1 text-sm text-ink">{conflict.suggestion}</p>
</div>
<div className="mt-2 flex flex-wrap items-center gap-2 pl-1 text-xs text-ink-soft">
{conflict.where ? (
<button
type="button"
onClick={() => onJump(index)}
className="rounded border border-line px-2 py-0.5 hover:border-cinnabar hover:text-cinnabar"
>
{conflict.where}
</button>
) : null}
{conflict.refs.map((ref) => (
<span key={ref} className="font-mono">
{ref}
</span>
))}
</div>
<fieldset className="mt-3">
<legend className="sr-only"> {index + 1} </legend>
<div className="flex flex-wrap items-center gap-2">
{VERDICT_OPTIONS.map((opt) => {
const active = draft.verdict === opt.value;
return (
<button
key={opt.value}
type="button"
aria-pressed={active}
onClick={() => onVerdict(index, opt.value)}
className={`rounded px-3 py-1 text-xs ${
active
? "bg-cinnabar text-panel"
: "border border-line text-ink hover:border-cinnabar"
}`}
>
{opt.label}
</button>
);
})}
{resolved ? (
<span className="text-xs text-pass" aria-hidden="true">
</span>
) : (
<span className="text-xs text-conflict"></span>
)}
</div>
{draft.verdict === "manual" ? (
<div className="mt-2">
<label htmlFor={`note-${index}`} className="sr-only">
</label>
<input
id={`note-${index}`}
type="text"
value={draft.note}
onChange={(e) => onNote(index, e.target.value)}
placeholder="手改说明(可选)"
className="w-full rounded border border-line bg-bg px-3 py-1.5 text-sm text-ink focus:border-cinnabar focus:outline-none"
/>
</div>
) : null}
</fieldset>
</li>
);
}

View File

@@ -0,0 +1,291 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { AppShell } from "@/components/AppShell";
import type { ProjectResponse, ReviewHistoryItem } from "@/lib/api/types";
import {
allResolved,
emptyDecisions,
setNote,
setVerdict,
unresolvedIndices,
type DecisionDraft,
type Verdict,
} from "@/lib/review/decisions";
import { normalizeConflicts } from "@/lib/review/history";
import { useAccept } from "@/lib/review/useAccept";
import { useReviewStream } from "@/lib/review/useReviewStream";
import type { ReviewConflict } from "@/lib/review/sse";
import { AcceptPanel } from "./AcceptPanel";
import { AnnotatedText } from "./AnnotatedText";
import { ConflictCard } from "./ConflictCard";
interface ReviewReportProps {
project: ProjectResponse;
chapterNo: number;
initialReview: ReviewHistoryItem | undefined;
initialDraft: string;
}
// 审稿报告页主体UX §6.4 / §8.3 / §9
// 数据源:进页用历史留痕 conflicts 种入;「重新审稿」用当前终稿跑 SSE 覆盖。
// 裁决草稿随 conflicts 长度对齐未决禁验收gate
export function ReviewReport({
project,
chapterNo,
initialReview,
initialDraft,
}: ReviewReportProps) {
const review = useReviewStream();
const accept = useAccept();
const [finalText, setFinalText] = useState(initialDraft);
const [drafts, setDrafts] = useState<DecisionDraft[]>(() =>
emptyDecisions(normalizeConflicts(initialReview).length),
);
const [focusedIndex, setFocusedIndex] = useState<number | null>(null);
const [missing, setMissing] = useState<Set<number>>(new Set());
const seededRef = useRef(false);
// 进页一次性把历史 conflicts 种入流状态(无需重审即可裁决)。
const seededConflicts = useMemo(
() => normalizeConflicts(initialReview),
[initialReview],
);
useEffect(() => {
if (seededRef.current) return;
seededRef.current = true;
if (seededConflicts.length > 0) review.seed(seededConflicts);
}, [review, seededConflicts]);
// 当前生效冲突 = 流状态(重审后即时更新,否则种入的历史)。
const conflicts: ReviewConflict[] = review.state.conflicts;
// 重审完成done 边沿)→ 重置裁决草稿对齐新冲突集(冲突可能整组变化)。
const conflictCount = conflicts.length;
const wasReviewingRef = useRef(false);
useEffect(() => {
if (review.state.phase === "reviewing") {
wasReviewingRef.current = true;
return;
}
if (review.state.phase === "done" && wasReviewingRef.current) {
wasReviewingRef.current = false;
setDrafts(emptyDecisions(conflictCount));
setMissing(new Set());
}
}, [review.state.phase, conflictCount]);
const onReReview = (): void => {
setMissing(new Set());
void review.start(project.id, chapterNo, finalText);
};
const onVerdict = (index: number, verdict: Verdict): void => {
setDrafts((prev) => setVerdict(prev, index, verdict));
setMissing((prev) => {
if (!prev.has(index)) return prev;
const next = new Set(prev);
next.delete(index);
return next;
});
};
const onNote = (index: number, note: string): void =>
setDrafts((prev) => setNote(prev, index, note));
const jumpToCard = (index: number): void => {
setFocusedIndex(index);
document
.getElementById(`conflict-card-${index}`)
?.scrollIntoView({ behavior: "smooth", block: "center" });
};
const jumpToAnchor = (index: number): void => {
setFocusedIndex(index);
document
.getElementById(`anchor-${index}`)
?.scrollIntoView({ behavior: "smooth", block: "center" });
};
const onAccept = async (): Promise<void> => {
const outcome = await accept.accept(
project.id,
chapterNo,
finalText,
drafts,
);
if (outcome.missingIndices.length > 0) {
setMissing(new Set(outcome.missingIndices));
}
};
const resolved = allResolved(drafts);
const unresolved = unresolvedIndices(drafts).length;
const reviewing = review.isReviewing;
return (
<AppShell
title={`${project.title}`}
subtitle={`${chapterNo} 章 · 审稿报告`}
>
<div className="grid h-[calc(100vh-4rem)] grid-cols-1 lg:grid-cols-[1fr_24rem]">
{/* 左:终稿正文 + 就地标注 */}
<section className="flex min-w-0 flex-col bg-bg">
<div className="flex items-center gap-3 border-b border-line bg-panel px-6 py-3">
<h1 className="font-serif text-lg text-ink">
{chapterNo} 稿
</h1>
<span className="font-mono text-xs text-ink-soft">
{conflictCount}
</span>
<div className="ml-auto">
{reviewing ? (
<button
type="button"
onClick={review.stop}
className="rounded border border-conflict px-3 py-1.5 text-sm text-conflict"
>
</button>
) : (
<button
type="button"
onClick={onReReview}
className="rounded bg-cinnabar px-3 py-1.5 text-sm text-panel hover:opacity-90"
>
稿
</button>
)}
</div>
</div>
{review.state.error ? (
<p className="border-b border-line bg-panel px-6 py-2 text-sm text-conflict">
稿{review.state.error.code}
{review.state.error.message}
{review.state.error.code === "LLM_UNAVAILABLE" ? (
<>
{" "}
<a
href="/settings/providers"
className="underline hover:text-cinnabar"
>
</a>
</>
) : null}
</p>
) : null}
<div className="flex-1 overflow-auto px-6 py-6">
<AnnotatedText
text={finalText}
conflicts={conflicts}
focusedIndex={focusedIndex}
onAnchorClick={jumpToCard}
/>
<details className="mt-4">
<summary className="cursor-pointer text-xs text-ink-soft hover:text-cinnabar">
/稿稿 稿
</summary>
<label htmlFor="final-text" className="sr-only">
稿
</label>
<textarea
id="final-text"
value={finalText}
onChange={(e) => setFinalText(e.target.value)}
className="mt-2 min-h-[20vh] w-full resize-y rounded border border-line bg-panel p-3 font-serif text-[16px] leading-[1.9] text-ink focus:border-cinnabar focus:outline-none"
/>
</details>
</div>
</section>
{/* 右:审稿分区 + 冲突裁决 + 验收 gate */}
<aside className="flex flex-col overflow-auto border-l border-line bg-panel px-4 py-4">
<section className="mb-4">
<h2 className="text-xs font-semibold uppercase tracking-wide text-ink-soft">
(continuity)
</h2>
<SectionStatusLine
reviewing={reviewing}
done={review.state.phase === "done"}
conflictCount={conflictCount}
sections={review.state.sections}
/>
</section>
<section className="flex-1">
{conflicts.length === 0 ? (
<p className="rounded border border-dashed border-line p-4 text-xs text-ink-soft">
{review.state.phase === "done" || seededConflicts.length === 0
? "未发现一致性冲突。"
: "进页未带审稿留痕,点「重新审稿」开始。"}
</p>
) : (
<ul className="space-y-3">
{conflicts.map((c, i) => (
<ConflictCard
key={i}
index={i}
conflict={c}
draft={drafts[i] ?? { verdict: null, note: "" }}
missing={missing.has(i)}
onVerdict={onVerdict}
onNote={onNote}
onJump={jumpToAnchor}
/>
))}
</ul>
)}
</section>
<section className="mt-4">
<AcceptPanel
conflictCount={conflictCount}
unresolvedCount={resolved ? 0 : unresolved}
accepting={accept.status === "accepting"}
result={accept.result}
onAccept={() => void onAccept()}
/>
</section>
</aside>
</div>
</AppShell>
);
}
interface SectionStatusLineProps {
reviewing: boolean;
done: boolean;
conflictCount: number;
sections: { name: string; status: string }[];
}
// 四审进行中骨架逐项点亮UX §9M2 仅 continuity 一项。
function SectionStatusLine({
reviewing,
done,
conflictCount,
sections,
}: SectionStatusLineProps) {
if (reviewing) {
return (
<p className="mt-1 text-xs text-info" aria-live="polite">
稿
</p>
);
}
if (done || sections.length > 0) {
return (
<p className="mt-1 text-xs">
{conflictCount > 0 ? (
<span className="text-conflict"> {conflictCount} </span>
) : (
<span className="text-pass"> </span>
)}
</p>
);
}
return null;
}

View File

@@ -0,0 +1,101 @@
import { describe, expect, it } from "vitest";
import {
allResolved,
buildAcceptRequest,
emptyDecisions,
missingConflictIndices,
setNote,
setVerdict,
unresolvedIndices,
} from "./decisions";
describe("emptyDecisions", () => {
it("creates N unresolved drafts", () => {
expect(emptyDecisions(2)).toEqual([
{ verdict: null, note: "" },
{ verdict: null, note: "" },
]);
});
});
describe("setVerdict / setNote (immutable)", () => {
it("sets verdict at index without mutating others", () => {
const drafts = emptyDecisions(2);
const next = setVerdict(drafts, 1, "ignore");
expect(next[1]?.verdict).toBe("ignore");
expect(next[0]?.verdict).toBeNull();
expect(drafts[1]?.verdict).toBeNull(); // 原数组不变
});
it("sets note at index", () => {
const drafts = emptyDecisions(1);
const next = setNote(drafts, 0, "手改说明");
expect(next[0]?.note).toBe("手改说明");
expect(drafts[0]?.note).toBe("");
});
});
describe("allResolved / unresolvedIndices", () => {
it("zero conflicts pass through (allResolved true)", () => {
expect(allResolved([])).toBe(true);
expect(unresolvedIndices([])).toEqual([]);
});
it("requires every conflict to have a verdict", () => {
let drafts = emptyDecisions(3);
expect(allResolved(drafts)).toBe(false);
expect(unresolvedIndices(drafts)).toEqual([0, 1, 2]);
drafts = setVerdict(drafts, 0, "accept");
drafts = setVerdict(drafts, 2, "manual");
expect(allResolved(drafts)).toBe(false);
expect(unresolvedIndices(drafts)).toEqual([1]);
drafts = setVerdict(drafts, 1, "ignore");
expect(allResolved(drafts)).toBe(true);
expect(unresolvedIndices(drafts)).toEqual([]);
});
});
describe("buildAcceptRequest", () => {
it("includes only resolved decisions and trims notes", () => {
let drafts = emptyDecisions(3);
drafts = setVerdict(drafts, 0, "accept");
drafts = setVerdict(drafts, 2, "manual");
drafts = setNote(drafts, 2, " 改成后天淬炼 ");
// index 1 仍未决 → 不应进入请求
const req = buildAcceptRequest("终稿正文", drafts);
expect(req.final_text).toBe("终稿正文");
expect(req.decisions).toEqual([
{ conflict_index: 0, verdict: "accept" },
{ conflict_index: 2, verdict: "manual", note: "改成后天淬炼" },
]);
});
it("omits empty/whitespace-only notes", () => {
let drafts = emptyDecisions(1);
drafts = setVerdict(drafts, 0, "ignore");
drafts = setNote(drafts, 0, " ");
const req = buildAcceptRequest("t", drafts);
expect(req.decisions).toEqual([{ conflict_index: 0, verdict: "ignore" }]);
});
});
describe("missingConflictIndices", () => {
it("extracts a set from 409 details", () => {
const set = missingConflictIndices({
missing_conflict_indices: [1, 3],
conflict_count: 4,
});
expect(set.has(1)).toBe(true);
expect(set.has(3)).toBe(true);
expect(set.has(0)).toBe(false);
});
it("returns empty set for malformed/missing details", () => {
expect(missingConflictIndices(null).size).toBe(0);
expect(missingConflictIndices({}).size).toBe(0);
expect(missingConflictIndices({ missing_conflict_indices: "x" }).size).toBe(
0,
);
});
});

View File

@@ -0,0 +1,74 @@
// 裁决纯逻辑完整性判定未决禁验收、accept 请求体组装、缺判高亮映射。
// 对齐 C3 accept 契约 + R5 冲突 gate裁决 conflict_index 须覆盖 range(len(conflicts)))。
import type { AcceptRequest, ConflictDecision } from "@/lib/api/types";
export type Verdict = "accept" | "ignore" | "manual";
// 单个冲突的本地裁决草稿(按下标定位)。
export interface DecisionDraft {
verdict: Verdict | null;
note: string;
}
// 初始化 N 个冲突的裁决草稿(全部未决)。
export function emptyDecisions(conflictCount: number): DecisionDraft[] {
return Array.from({ length: conflictCount }, () => ({
verdict: null,
note: "",
}));
}
// 不可变更新:设置某个冲突的 verdict。
export function setVerdict(
drafts: readonly DecisionDraft[],
index: number,
verdict: Verdict,
): DecisionDraft[] {
return drafts.map((d, i) => (i === index ? { ...d, verdict } : d));
}
// 不可变更新:设置某个冲突的 note。
export function setNote(
drafts: readonly DecisionDraft[],
index: number,
note: string,
): DecisionDraft[] {
return drafts.map((d, i) => (i === index ? { ...d, note } : d));
}
// 是否全部已决:每个冲突都有 verdict覆盖 range(len(conflicts)))。零冲突 → 直通。
export function allResolved(drafts: readonly DecisionDraft[]): boolean {
return drafts.every((d) => d.verdict !== null);
}
// 本地仍未决的冲突下标(用于禁用态提示)。
export function unresolvedIndices(drafts: readonly DecisionDraft[]): number[] {
return drafts.flatMap((d, i) => (d.verdict === null ? [i] : []));
}
// 组装 accept 请求体仅含已决项note 去空白后非空才带)。
export function buildAcceptRequest(
finalText: string,
drafts: readonly DecisionDraft[],
): AcceptRequest {
const decisions: ConflictDecision[] = drafts.flatMap((d, i) => {
if (d.verdict === null) return [];
const trimmed = d.note.trim();
const decision: ConflictDecision = {
conflict_index: i,
verdict: d.verdict,
};
return [trimmed ? { ...decision, note: trimmed } : decision];
});
return { final_text: finalText, decisions };
}
// 从 409 CONFLICT_UNRESOLVED 的 details 提取缺判下标集合(用于报告卡高亮)。
export function missingConflictIndices(details: unknown): Set<number> {
if (typeof details !== "object" || details === null) return new Set();
const raw = (details as { missing_conflict_indices?: unknown })
.missing_conflict_indices;
if (!Array.isArray(raw)) return new Set();
return new Set(raw.filter((n): n is number => typeof n === "number"));
}

View File

@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import type { ReviewHistoryItem } from "@/lib/api/types";
import { latestReview, normalizeConflicts } from "./history";
const item = (
conflicts: Record<string, unknown>[],
): ReviewHistoryItem => ({
id: "00000000-0000-0000-0000-000000000001",
project_id: "00000000-0000-0000-0000-000000000002",
chapter_no: 128,
conflicts,
});
describe("normalizeConflicts", () => {
it("tightens loose dicts into ReviewConflict, preserving order", () => {
const out = normalizeConflicts(
item([
{
type: "设定违例",
where: "第4段",
refs: ["第30章"],
suggestion: "统一血脉",
},
{ type: "时间线倒错", where: "第8段", refs: [], suggestion: "调整" },
]),
);
expect(out).toEqual([
{ type: "设定违例", where: "第4段", refs: ["第30章"], suggestion: "统一血脉" },
{ type: "时间线倒错", where: "第8段", refs: [], suggestion: "调整" },
]);
});
it("fills safe defaults for missing/wrong-typed fields", () => {
const out = normalizeConflicts(item([{ where: 42, refs: "x" }]));
expect(out).toEqual([
{ type: "未分类", where: "", refs: [], suggestion: "" },
]);
});
it("returns [] for undefined item or missing conflicts", () => {
expect(normalizeConflicts(undefined)).toEqual([]);
});
});
describe("latestReview", () => {
it("returns first item (history is newest-first)", () => {
const a = item([]);
const b = item([]);
expect(latestReview([a, b])).toBe(a);
expect(latestReview([])).toBeUndefined();
expect(latestReview(undefined)).toBeUndefined();
});
});

View File

@@ -0,0 +1,34 @@
// 审稿历史归一:把后端 ReviewHistoryItem 的松散 conflictsdict[])收紧成 ReviewConflict[]。
// 纯逻辑可单测schema 把 conflicts 标成 {[k]:unknown}[],这里安全收窄。
import type { ReviewHistoryItem } from "@/lib/api/types";
import type { ReviewConflict } from "./sse";
function asString(v: unknown, fallback = ""): string {
return typeof v === "string" ? v : fallback;
}
function asStringArray(v: unknown): string[] {
if (!Array.isArray(v)) return [];
return v.filter((x): x is string => typeof x === "string");
}
// 把一条留痕的 conflicts 收紧(缺字段给安全默认;保持顺序=下标身份,对齐冲突 gate
export function normalizeConflicts(
item: ReviewHistoryItem | undefined,
): ReviewConflict[] {
const raw = item?.conflicts ?? [];
return raw.map((c) => ({
type: asString(c["type"], "未分类"),
where: asString(c["where"]),
refs: asStringArray(c["refs"]),
suggestion: asString(c["suggestion"]),
}));
}
// 最近一条留痕GET .../reviews 已按新→旧排序)。
export function latestReview(
items: readonly ReviewHistoryItem[] | undefined,
): ReviewHistoryItem | undefined {
return items?.[0];
}

View File

@@ -0,0 +1,130 @@
import { describe, expect, it } from "vitest";
import {
ReviewFrameBuffer,
initialReviewState,
parseReviewBlock,
reduceReview,
type ReviewSseEvent,
} from "./sse";
describe("parseReviewBlock", () => {
it("parses a section frame", () => {
const evt = parseReviewBlock(
'event:section\ndata:{"name":"continuity","status":"done"}',
);
expect(evt).toEqual({
event: "section",
data: { name: "continuity", status: "done" },
});
});
it("parses a conflict frame", () => {
const evt = parseReviewBlock(
'event:conflict\ndata:{"type":"设定违例","where":"第4段","refs":["第30章"],"suggestion":"统一血脉设定"}',
);
expect(evt).toEqual({
event: "conflict",
data: {
type: "设定违例",
where: "第4段",
refs: ["第30章"],
suggestion: "统一血脉设定",
},
});
});
it("parses done and error frames", () => {
expect(parseReviewBlock('event:done\ndata:{"length":1}')).toEqual({
event: "done",
data: { length: 1 },
});
expect(
parseReviewBlock(
'event:error\ndata:{"code":"INTERNAL","message":"boom","request_id":"r1"}',
),
).toEqual({
event: "error",
data: { code: "INTERNAL", message: "boom", request_id: "r1" },
});
});
it("returns null for unknown event or malformed json", () => {
expect(parseReviewBlock('event:token\ndata:{"text":"x"}')).toBeNull();
expect(parseReviewBlock("event:conflict\ndata:{not json")).toBeNull();
});
});
describe("ReviewFrameBuffer", () => {
it("emits complete blocks and buffers the tail", () => {
const buf = new ReviewFrameBuffer();
const first = buf.push(
'event:section\ndata:{"name":"continuity","status":"started"}\n\nevent:conf',
);
expect(first).toEqual([
{ event: "section", data: { name: "continuity", status: "started" } },
]);
const second = buf.push(
'lict\ndata:{"type":"性格漂移","where":"第2段","refs":[],"suggestion":"x"}\n\n',
);
expect(second).toEqual([
{
event: "conflict",
data: { type: "性格漂移", where: "第2段", refs: [], suggestion: "x" },
},
]);
});
});
describe("reduceReview", () => {
it("upserts sections by name (last status wins) and marks reviewing", () => {
const events: ReviewSseEvent[] = [
{ event: "section", data: { name: "continuity", status: "started" } },
{ event: "section", data: { name: "continuity", status: "done" } },
];
const state = events.reduce(reduceReview, initialReviewState);
expect(state.sections).toEqual([
{ name: "continuity", status: "done" },
]);
expect(state.phase).toBe("reviewing");
});
it("accumulates conflicts in order", () => {
const events: ReviewSseEvent[] = [
{
event: "conflict",
data: { type: "设定违例", where: "a", refs: [], suggestion: "s1" },
},
{
event: "conflict",
data: { type: "时间线倒错", where: "b", refs: ["第3章"], suggestion: "s2" },
},
];
const state = events.reduce(reduceReview, initialReviewState);
expect(state.conflicts.map((c) => c.type)).toEqual([
"设定违例",
"时间线倒错",
]);
});
it("marks done and captures error preserving collected data", () => {
let s = reduceReview(initialReviewState, {
event: "section",
data: { name: "continuity", status: "done" },
});
s = reduceReview(s, { event: "done", data: { length: 1 } });
expect(s.phase).toBe("done");
let e = reduceReview(initialReviewState, {
event: "conflict",
data: { type: "能力不符", where: "x", refs: [], suggestion: "y" },
});
e = reduceReview(e, {
event: "error",
data: { code: "LLM_UNAVAILABLE", message: "no key" },
});
expect(e.phase).toBe("error");
expect(e.conflicts).toHaveLength(1);
expect(e.error?.code).toBe("LLM_UNAVAILABLE");
});
});

155
apps/web/lib/review/sse.ts Normal file
View File

@@ -0,0 +1,155 @@
// 审稿 SSE 帧归一 + reducer对齐 C3 / C4 扩 / ARCH §7.3)。
// 帧:`section{name,status}` / `conflict{type,where,refs,suggestion}` / `done{length}` / `error{...}`。
// 纯逻辑,便于 node 环境单测(不依赖 DOM
// 五类冲突C6 ConflictType / ARCH §6.1)。
export type ConflictType =
| "性格漂移"
| "能力不符"
| "设定违例"
| "地理矛盾"
| "时间线倒错";
export interface ReviewConflict {
type: string;
where: string;
refs: string[];
suggestion: string;
}
export type SectionStatus = "started" | "done" | "incomplete";
export interface SectionEvent {
event: "section";
data: { name: string; status: SectionStatus };
}
export interface ConflictEvent {
event: "conflict";
data: ReviewConflict;
}
export interface DoneEvent {
event: "done";
data: { length: number };
}
export interface ErrorEvent {
event: "error";
data: { code: string; message: string; request_id?: string | null };
}
export type ReviewSseEvent =
| SectionEvent
| ConflictEvent
| DoneEvent
| ErrorEvent;
const KNOWN_EVENTS = new Set(["section", "conflict", "done", "error"]);
// 把一个完整 SSE 块(多行)解析成事件;无法解析则返回 null跳过
export function parseReviewBlock(block: string): ReviewSseEvent | null {
let event = "";
const dataLines: string[] = [];
for (const rawLine of block.split("\n")) {
const line = rawLine.replace(/\r$/, "");
if (line.startsWith(":")) continue; // 注释/心跳
const sep = line.indexOf(":");
if (sep === -1) continue;
const field = line.slice(0, sep);
const value = line.slice(sep + 1).replace(/^ /, "");
if (field === "event") event = value;
else if (field === "data") dataLines.push(value);
}
if (!KNOWN_EVENTS.has(event) || dataLines.length === 0) return null;
let data: unknown;
try {
data = JSON.parse(dataLines.join("\n"));
} catch {
return null;
}
return { event, data } as ReviewSseEvent;
}
// 增量缓冲:吃进一段文本,吐出已完成的事件块(空行分隔),保留未完成尾部。
export class ReviewFrameBuffer {
private buf = "";
push(chunk: string): ReviewSseEvent[] {
this.buf += chunk;
const events: ReviewSseEvent[] = [];
let idx: number;
while ((idx = this.findBoundary(this.buf)) !== -1) {
const block = this.buf.slice(0, idx);
this.buf = this.buf.slice(this.boundaryEnd(this.buf, idx));
const evt = parseReviewBlock(block);
if (evt) events.push(evt);
}
return events;
}
private findBoundary(s: string): number {
const a = s.indexOf("\n\n");
const b = s.indexOf("\r\n\r\n");
if (a === -1) return b;
if (b === -1) return a;
return Math.min(a, b);
}
private boundaryEnd(s: string, idx: number): number {
return s.startsWith("\r\n\r\n", idx) ? idx + 4 : idx + 2;
}
}
export type ReviewPhase = "idle" | "reviewing" | "done" | "error" | "aborted";
export interface ReviewSection {
name: string;
status: SectionStatus;
}
export interface ReviewStreamState {
phase: ReviewPhase;
sections: ReviewSection[];
conflicts: ReviewConflict[];
error: { code: string; message: string; request_id?: string | null } | null;
}
export const initialReviewState: ReviewStreamState = {
phase: "idle",
sections: [],
conflicts: [],
error: null,
};
// 纯 reducer把单个事件折叠进状态。section 按 name upsert最后状态生效
export function reduceReview(
state: ReviewStreamState,
event: ReviewSseEvent,
): ReviewStreamState {
switch (event.event) {
case "section":
return {
...state,
phase: "reviewing",
sections: upsertSection(state.sections, event.data),
};
case "conflict":
return {
...state,
phase: "reviewing",
conflicts: [...state.conflicts, event.data],
};
case "done":
return { ...state, phase: "done" };
case "error":
return { ...state, phase: "error", error: event.data };
default:
return state;
}
}
function upsertSection(
sections: ReviewSection[],
next: ReviewSection,
): ReviewSection[] {
const idx = sections.findIndex((s) => s.name === next.name);
if (idx === -1) return [...sections, next];
return sections.map((s, i) => (i === idx ? next : s));
}

View File

@@ -0,0 +1,76 @@
"use client";
import { useCallback, useState } from "react";
import { api } from "@/lib/api/client";
import { useToast } from "@/components/Toast";
import type { AcceptResponse } from "@/lib/api/types";
import { buildAcceptRequest, type DecisionDraft } from "./decisions";
export type AcceptStatus = "idle" | "accepting" | "accepted" | "error";
export interface AcceptOutcome {
result: AcceptResponse | null;
// 409 CONFLICT_UNRESOLVED 缺判下标(高亮报告卡)。
missingIndices: number[];
}
export interface UseAccept {
status: AcceptStatus;
result: AcceptResponse | null;
accept: (
projectId: string,
chapterNo: number,
finalText: string,
drafts: readonly DecisionDraft[],
) => Promise<AcceptOutcome>;
}
interface ApiErrorEnvelope {
error?: {
code?: string;
message?: string;
details?: { missing_conflict_indices?: number[] } | null;
};
}
// 验收:乐观置 accepting → 成功置 accepted呈现「本次将更新」清单
// 失败回滚状态 + toast409 CONFLICT_UNRESOLVED 解析缺判下标供高亮。
export function useAccept(): UseAccept {
const [status, setStatus] = useState<AcceptStatus>("idle");
const [result, setResult] = useState<AcceptResponse | null>(null);
const toast = useToast();
const accept = useCallback<UseAccept["accept"]>(
async (projectId, chapterNo, finalText, drafts) => {
setStatus("accepting");
const body = buildAcceptRequest(finalText, drafts);
const { data, error } = await api.POST(
"/projects/{project_id}/chapters/{chapter_no}/accept",
{
params: { path: { project_id: projectId, chapter_no: chapterNo } },
body,
},
);
if (error || !data) {
setStatus("error");
const env = error as ApiErrorEnvelope | undefined;
const code = env?.error?.code;
const missing = env?.error?.details?.missing_conflict_indices ?? [];
if (code === "CONFLICT_UNRESOLVED") {
toast("尚有冲突未裁决,请先处理高亮项", "error");
return { result: null, missingIndices: missing };
}
toast("验收失败,请重试(正文未丢失)", "error");
return { result: null, missingIndices: [] };
}
setResult(data);
setStatus("accepted");
toast("本章已验收", "success");
return { result: data, missingIndices: [] };
},
[toast],
);
return { status, result, accept };
}

View File

@@ -0,0 +1,135 @@
"use client";
import { useCallback, useReducer, useRef } from "react";
import { API_BASE_PUBLIC } from "@/lib/api/config";
import {
ReviewFrameBuffer,
initialReviewState,
reduceReview,
type ReviewConflict,
type ReviewStreamState,
} from "./sse";
type Action =
| { type: "start" }
| { type: "events"; events: ReturnType<ReviewFrameBuffer["push"]> }
| { type: "abort" }
| { type: "fail"; code: string; message: string }
| { type: "seed"; conflicts: ReviewConflict[] };
function reducer(state: ReviewStreamState, action: Action): ReviewStreamState {
switch (action.type) {
case "start":
return { ...initialReviewState, phase: "reviewing" };
case "events":
return action.events.reduce(reduceReview, state);
case "abort":
return { ...state, phase: "aborted" };
case "fail":
return {
...state,
phase: "error",
error: { code: action.code, message: action.message },
};
case "seed":
return { ...initialReviewState, conflicts: action.conflicts };
default:
return state;
}
}
export interface UseReviewStream {
state: ReviewStreamState;
isReviewing: boolean;
// 用当前编辑器草稿重新审稿。
start: (projectId: string, chapterNo: number, draft: string) => Promise<void>;
stop: () => void;
// 进页用历史留痕的冲突种入(无需重审即可裁决)。
seed: (conflicts: ReviewConflict[]) => void;
}
// 消费 POST .../review 的 SSE 流fetch+ReadableStreamEventSource 不支持 POST
// "停" = abort已收 section/conflict 留在 state裁决草稿不丢
// 流前 503 LLM_UNAVAILABLE 是 JSON 信封(非帧)→ 经 !res.ok 检出。
export function useReviewStream(): UseReviewStream {
const [state, dispatch] = useReducer(reducer, initialReviewState);
const controllerRef = useRef<AbortController | null>(null);
const stop = useCallback(() => {
controllerRef.current?.abort();
controllerRef.current = null;
dispatch({ type: "abort" });
}, []);
const seed = useCallback((conflicts: ReviewConflict[]) => {
dispatch({ type: "seed", conflicts });
}, []);
const start = useCallback(
async (
projectId: string,
chapterNo: number,
draft: string,
): Promise<void> => {
const controller = new AbortController();
controllerRef.current = controller;
dispatch({ type: "start" });
try {
const res = await fetch(
`${API_BASE_PUBLIC}/projects/${projectId}/chapters/${chapterNo}/review`,
{
method: "POST",
headers: {
Accept: "text/event-stream",
"Content-Type": "application/json",
},
body: JSON.stringify({ draft }),
signal: controller.signal,
},
);
if (!res.ok || !res.body) {
let code = "REVIEW_FAILED";
let message = `审稿请求失败(${res.status}`;
try {
const body = (await res.json()) as {
error?: { code?: string; message?: string };
};
if (body.error?.code) code = body.error.code;
if (body.error?.message) message = body.error.message;
} catch {
// 非 JSON 信封,沿用默认文案。
}
dispatch({ type: "fail", code, message });
return;
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
const buffer = new ReviewFrameBuffer();
for (;;) {
const { value, done } = await reader.read();
if (done) break;
const events = buffer.push(decoder.decode(value, { stream: true }));
if (events.length > 0) dispatch({ type: "events", events });
}
} catch (err: unknown) {
if (err instanceof DOMException && err.name === "AbortError") {
return; // 用户主动停止state 已置 aborted。
}
const message = err instanceof Error ? err.message : "未知网络错误";
dispatch({ type: "fail", code: "NETWORK", message });
} finally {
controllerRef.current = null;
}
},
[],
);
return {
state,
isReviewing: state.phase === "reviewing",
start,
stop,
seed,
};
}