Files
writer-work-flow/apps/web/lib/stream/useDraftStream.ts
Yaojia Wang cc0aabdd9a feat(ux): T4-b 前端 — 本章指令框 + 风格快捷预设传入 draft 生成
新 lib/workbench/directive.ts (STYLE_PRESETS + composeDirective 纯函数+测);useDraftStream.start 加 directive 参数(非空时 POST JSON body);Workbench 加「本章指令」textarea + 风格预设 chips,写本章时 composeDirective 传入。TDD: directive 测。前端门禁绿。docs(progress): 记 Tier4 完成。
2026-06-20 18:27:47 +02:00

144 lines
4.4 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 {
SseFrameBuffer,
initialStreamState,
reduceStream,
type StreamState,
} from "./sse";
type Action =
| { type: "start" }
| { type: "events"; events: ReturnType<SseFrameBuffer["push"]> }
| { type: "abort" }
| { type: "fail"; code: string; message: string }
| { type: "reset"; text: string };
function reducer(state: StreamState, action: Action): StreamState {
switch (action.type) {
case "start":
return { phase: "streaming", text: "", error: null };
case "events":
return action.events.reduce(reduceStream, state);
case "abort":
return { ...state, phase: "aborted" };
case "fail":
return {
...state,
phase: "error",
error: { code: action.code, message: action.message },
};
case "reset":
return { ...initialStreamState, text: action.text };
default:
return state;
}
}
export interface UseDraftStream {
state: StreamState;
isStreaming: boolean;
start: (
projectId: string,
chapterNo: number,
directive?: string,
) => Promise<void>;
stop: () => void;
reset: (text: string) => void;
}
// 消费 POST .../draft 的 SSE 流。用 fetch+ReadableStreamEventSource 不支持 POST
// "停" = abort 连接(已生成部分保留在 state.text由调用方落入草稿
export function useDraftStream(): UseDraftStream {
const [state, dispatch] = useReducer(reducer, initialStreamState);
const controllerRef = useRef<AbortController | null>(null);
const stop = useCallback(() => {
controllerRef.current?.abort();
controllerRef.current = null;
dispatch({ type: "abort" });
}, []);
const reset = useCallback((text: string) => {
dispatch({ type: "reset", text });
}, []);
const start = useCallback(
async (projectId: string, chapterNo: number, directive?: string) => {
const controller = new AbortController();
controllerRef.current = controller;
dispatch({ type: "start" });
try {
// 仅当本章指令非空才发 JSON body无指令退回裸 POST与旧后端调用兼容
const trimmed = directive?.trim();
const init: RequestInit =
trimmed && trimmed.length > 0
? {
method: "POST",
headers: {
Accept: "text/event-stream",
"Content-Type": "application/json",
},
body: JSON.stringify({ directive: trimmed }),
signal: controller.signal,
}
: {
method: "POST",
headers: { Accept: "text/event-stream" },
signal: controller.signal,
};
const res = await fetch(
`${API_BASE_PUBLIC}/projects/${projectId}/chapters/${chapterNo}/draft`,
init,
);
if (!res.ok || !res.body) {
// 流前错误(如无凭据 → 503 LLM_UNAVAILABLEJSON 信封而非帧)。
let code = "STREAM_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 SseFrameBuffer();
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") {
// 用户主动停止state 已置 aborted。
return;
}
const message = err instanceof Error ? err.message : "未知网络错误";
dispatch({ type: "fail", code: "NETWORK", message });
} finally {
controllerRef.current = null;
}
},
[],
);
return {
state,
isStreaming: state.phase === "streaming",
start,
stop,
reset,
};
}