// 审稿 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; // 一键采纳补丁对(可缺):可局部修复时审稿提议,前端「采纳改法」据此 find-replace 进终稿。 original: string | null; replacement: string | null; } export type SectionStatus = "started" | "done" | "incomplete"; // 伏笔建议(C4 扩 T3.3):planted=新埋待确认 / resolved=疑似回收(只读建议)。 export type ForeshadowKind = "planted" | "resolved"; export interface ForeshadowSuggestion { kind: ForeshadowKind; code?: string | null; title: string; where?: string | null; note?: string | null; } // 节奏报告(C4 扩 T3.3):注水段 + 章末钩子 + 爽点节拍图。 export interface PaceIssue { where: string; reason: string; } export interface PaceReport { water: PaceIssue[]; hook: boolean; beat_map: number[]; } // 文风漂移(第四审,C4 扩 T4.2):整体相似度 score + 段级漂移。 // `text` = 该漂移段从草稿逐字摘录的原文(内容锚,供前端内容匹配定位回炉目标, // 不靠位置 idx;旧数据/降级可为空串)。 export interface StyleDriftSegment { idx: number; text: string; score: number; label: string | null; } export interface StyleDriftReport { score: number; segments: StyleDriftSegment[]; } // 人物塑造(灵感③ advisory 单维):只读建议,不阻断验收(无裁决 UI)。 // 以人物卡 motive/appearance 为客观锚点;每条带逐字引用 quote + 置信度 confidence。 export interface CharacterizationIssue { character: string; aspect: string; where: string; quote: string; diagnosis: string; suggestion: string; confidence: number; } export interface CharacterizationReport { issues: CharacterizationIssue[]; } export interface SectionEvent { event: "section"; data: { name: string; status: SectionStatus }; } export interface ConflictEvent { event: "conflict"; data: ReviewConflict; } export interface ForeshadowEvent { event: "foreshadow"; data: { kind: ForeshadowKind; code?: string | null; title: string; where?: string | null; note?: string | null; }; } export interface PaceEvent { event: "pace"; data: { water: PaceIssue[]; hook: boolean; beat_map: number[]; }; } export interface StyleEvent { event: "style"; data: { score: number; segments: { idx: number; text?: string | null; score: number; label?: string | null; }[]; }; } export interface CharacterizationEvent { event: "characterization"; data: { issues: CharacterizationIssue[] }; } 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 | ForeshadowEvent | PaceEvent | StyleEvent | CharacterizationEvent | DoneEvent | ErrorEvent; const KNOWN_EVENTS = new Set([ "section", "conflict", "foreshadow", "pace", "style", "characterization", "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 narrowReviewEvent(event, data); } function isRecord(v: unknown): v is Record { return typeof v === "object" && v !== null && !Array.isArray(v); } function asStringArray(v: unknown): string[] { return Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : []; } function asNumberArray(v: unknown): number[] { return Array.isArray(v) ? v.filter((x): x is number => typeof x === "number" && Number.isFinite(x)) : []; } function nullableString(v: unknown): string | null { return typeof v === "string" ? v : null; } function asPaceIssues(v: unknown): PaceIssue[] { if (!Array.isArray(v)) return []; return v.flatMap((x) => isRecord(x) && typeof x.where === "string" && typeof x.reason === "string" ? [{ where: x.where, reason: x.reason }] : [], ); } function asNumber(v: unknown, fallback = 0): number { return typeof v === "number" && Number.isFinite(v) ? v : fallback; } function asStringField(v: unknown): string { return typeof v === "string" ? v : ""; } // 收窄人物塑造 issues(每字段安全默认,advisory 韧性;缺字段不丢整条)。 export function asCharacterizationIssues(v: unknown): CharacterizationIssue[] { if (!Array.isArray(v)) return []; return v.flatMap((x) => isRecord(x) ? [ { character: asStringField(x.character), aspect: asStringField(x.aspect), where: asStringField(x.where), quote: asStringField(x.quote), diagnosis: asStringField(x.diagnosis), suggestion: asStringField(x.suggestion), confidence: asNumber(x.confidence), }, ] : [], ); } function asStyleSegments(v: unknown): StyleEvent["data"]["segments"] { if (!Array.isArray(v)) return []; return v.flatMap((x) => isRecord(x) && typeof x.idx === "number" && typeof x.score === "number" ? [ { idx: x.idx, text: nullableString(x.text), score: x.score, label: nullableString(x.label), }, ] : [], ); } // 逐事件类型守卫:按 event 名收窄 data 形状;形状不符返回 null(安全跳过)。 function narrowReviewEvent(event: string, data: unknown): ReviewSseEvent | null { if (!isRecord(data)) return null; switch (event) { case "section": return typeof data.name === "string" && isSectionStatus(data.status) ? { event: "section", data: { name: data.name, status: data.status } } : null; case "conflict": return typeof data.type === "string" && typeof data.where === "string" && typeof data.suggestion === "string" ? { event: "conflict", data: { type: data.type, where: data.where, refs: asStringArray(data.refs), suggestion: data.suggestion, original: nullableString(data.original), replacement: nullableString(data.replacement), }, } : null; case "foreshadow": return isForeshadowKind(data.kind) && typeof data.title === "string" ? { event: "foreshadow", data: { kind: data.kind, code: nullableString(data.code), title: data.title, where: nullableString(data.where), note: nullableString(data.note), }, } : null; case "pace": return { event: "pace", data: { water: asPaceIssues(data.water), hook: data.hook === true, beat_map: asNumberArray(data.beat_map), }, }; case "style": return typeof data.score === "number" ? { event: "style", data: { score: data.score, segments: asStyleSegments(data.segments), }, } : null; case "characterization": return { event: "characterization", data: { issues: asCharacterizationIssues(data.issues) }, }; case "done": return typeof data.length === "number" ? { event: "done", data: { length: data.length } } : null; case "error": return typeof data.code === "string" && typeof data.message === "string" ? { event: "error", data: { code: data.code, message: data.message, request_id: nullableString(data.request_id), }, } : null; default: return null; } } function isSectionStatus(v: unknown): v is SectionStatus { return v === "started" || v === "done" || v === "incomplete"; } function isForeshadowKind(v: unknown): v is ForeshadowKind { return v === "planted" || v === "resolved"; } // 增量缓冲:吃进一段文本,吐出已完成的事件块(空行分隔),保留未完成尾部。 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[]; foreshadow: ForeshadowSuggestion[]; pace: PaceReport | null; style: StyleDriftReport | null; characterization: CharacterizationReport | null; error: { code: string; message: string; request_id?: string | null } | null; } export const initialReviewState: ReviewStreamState = { phase: "idle", sections: [], conflicts: [], foreshadow: [], pace: null, style: null, characterization: null, 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 "foreshadow": return { ...state, phase: "reviewing", foreshadow: [...state.foreshadow, { ...event.data }], }; case "pace": return { ...state, phase: "reviewing", pace: { ...event.data }, }; case "style": return { ...state, phase: "reviewing", style: { score: event.data.score, segments: event.data.segments.map((s) => ({ idx: s.idx, text: s.text ?? "", score: s.score, label: s.label ?? null, })), }, }; case "characterization": return { ...state, phase: "reviewing", characterization: { issues: event.data.issues.map((i) => ({ ...i })) }, }; 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)); }