// 审稿 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"; // 伏笔建议(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[]; } 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 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 | DoneEvent | ErrorEvent; const KNOWN_EVENTS = new Set([ "section", "conflict", "foreshadow", "pace", "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[]; foreshadow: ForeshadowSuggestion[]; pace: PaceReport | null; error: { code: string; message: string; request_id?: string | null } | null; } export const initialReviewState: ReviewStreamState = { phase: "idle", sections: [], conflicts: [], foreshadow: [], pace: 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 "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)); }