"use client"; import { useCallback, useReducer, useRef } from "react"; import { API_BASE_PUBLIC } from "@/lib/api/config"; import { ReviewFrameBuffer, initialReviewState, reduceReview, type CharacterizationReport, type ForeshadowSuggestion, type PaceReport, type ReviewConflict, type ReviewStreamState, type StyleDriftReport, } from "./sse"; export interface ReviewSeed { conflicts: ReviewConflict[]; foreshadow: ForeshadowSuggestion[]; pace: PaceReport | null; style: StyleDriftReport | null; characterization: CharacterizationReport | null; } type Action = | { type: "start" } | { type: "events"; events: ReturnType } | { type: "abort" } | { type: "fail"; code: string; message: string } | { type: "seed"; seed: ReviewSeed }; 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.seed.conflicts, foreshadow: action.seed.foreshadow, pace: action.seed.pace, style: action.seed.style, characterization: action.seed.characterization, }; default: return state; } } export interface UseReviewStream { state: ReviewStreamState; isReviewing: boolean; // 用当前编辑器草稿重新审稿。 start: (projectId: string, chapterNo: number, draft: string) => Promise; stop: () => void; // 进页用历史留痕(冲突 + 伏笔建议 + 节奏)种入(无需重审即可裁决/查看)。 seed: (seed: ReviewSeed) => void; } // 消费 POST .../review 的 SSE 流:fetch+ReadableStream(EventSource 不支持 POST)。 // "停" = abort(已收 section/conflict 留在 state,裁决草稿不丢)。 // 流前 503 LLM_UNAVAILABLE 是 JSON 信封(非帧)→ 经 !res.ok 检出。 export function useReviewStream(): UseReviewStream { const [state, dispatch] = useReducer(reducer, initialReviewState); const controllerRef = useRef(null); const stop = useCallback(() => { controllerRef.current?.abort(); controllerRef.current = null; dispatch({ type: "abort" }); }, []); const seed = useCallback((seedData: ReviewSeed) => { dispatch({ type: "seed", seed: seedData }); }, []); const start = useCallback( async ( projectId: string, chapterNo: number, draft: string, ): Promise => { 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, }; }