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:
291
apps/web/components/review/ReviewReport.tsx
Normal file
291
apps/web/components/review/ReviewReport.tsx
Normal 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 §9);M2 仅 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;
|
||||
}
|
||||
Reference in New Issue
Block a user