Files
writer-work-flow/apps/web/lib/review/useReviewStream.ts
Yaojia Wang 765dbdfbd4 feat: M4 文风 + M5 生成/多provider/Skill + Kimi Code 订阅接入 + 本地联调修复
M4(文风): style-auditor 双轨(提取指纹/漂移第四审)+ jobs 长任务框架(zombie reaper) + 回炉 refine + GET /style read-back。
M5(生成+扩展): worldbuilder/character-gen(入库 continuity 409 gate + partition_writes 白名单 + schema→JSONB 形变);
  网关多 provider 回退链/熔断/能力降级(Anthropic/Gemini 适配器);Skill registry + 表权限沙箱 + 规则;
  前端 角色生成器/世界观/Codex/规则页/技能库/⌘K 命令面板。
K1(Kimi Code 订阅接入): OAuth device-flow(kimi-code)+ 静态 Console key(kimi-code-key)两路径;
  coding 端点 KimiCLI 伪造头(实测 UA allow-list 门禁,缺则 403)+ JSON 模式结构化(thinking ⊥ tool_choice)。
本地联调修复: CORS 中间件;assemble 注入 premise+「写第N章」指令(修空 prompt 400);
  GET /outline·/draft read-back + 大纲/工作台/审稿页重载;写页 client/server 常量边界 + notFound 健壮化;
  字数 toLocaleString locale 水合;审稿页终稿从已存草稿 seed(修 accept 422)。
门禁: backend ruff/mypy(157)/alembic 无漂移/pytest 451 · frontend lint/tsc/vitest/build。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 10:39:58 +02:00

152 lines
4.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useCallback, useReducer, useRef } from "react";
import { API_BASE_PUBLIC } from "@/lib/api/config";
import {
ReviewFrameBuffer,
initialReviewState,
reduceReview,
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;
}
type Action =
| { type: "start" }
| { type: "events"; events: ReturnType<ReviewFrameBuffer["push"]> }
| { 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,
};
default:
return state;
}
}
export interface UseReviewStream {
state: ReviewStreamState;
isReviewing: boolean;
// 用当前编辑器草稿重新审稿。
start: (projectId: string, chapterNo: number, draft: string) => Promise<void>;
stop: () => void;
// 进页用历史留痕(冲突 + 伏笔建议 + 节奏)种入(无需重审即可裁决/查看)。
seed: (seed: ReviewSeed) => 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((seedData: ReviewSeed) => {
dispatch({ type: "seed", seed: seedData });
}, []);
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,
};
}