diff --git a/apps/web/components/workbench/ChapterRewritePanel.tsx b/apps/web/components/workbench/ChapterRewritePanel.tsx
new file mode 100644
index 0000000..edc889b
--- /dev/null
+++ b/apps/web/components/workbench/ChapterRewritePanel.tsx
@@ -0,0 +1,130 @@
+"use client";
+
+import { useState } from "react";
+import { Check, RotateCcw, Square, X } from "lucide-react";
+
+import { ThinkingIndicator } from "@/components/ThinkingIndicator";
+import { Button } from "@/components/ui/Button";
+import { SectionHeader } from "@/components/ui/SectionHeader";
+import { StatusNote } from "@/components/ui/StatusNote";
+import { TextArea } from "@/components/ui/TextArea";
+import { friendlyError } from "@/lib/errors/messages";
+import { useChapterRewrite } from "@/lib/workbench/useChapterRewrite";
+
+interface ChapterRewritePanelProps {
+ projectId: string;
+ chapterNo: number;
+ // 当前整章正文(首轮重写的 prior_draft)。
+ currentDraft: string;
+ // 接受某一版 → 替换整章正文(HITL,作者显式点选才落草稿)。
+ onAccept: (text: string) => void;
+ onClose: () => void;
+}
+
+// 整章「再沟通 / 重写」结果卡(内联,非模态):作者给意见 → AI 就着当前整章 + 完整记忆注入
+// 流式重写一版 → 版本栈可多轮迭代 → 接受某版才替换整章正文。生成不写库(HITL,不变量 #3)。
+export function ChapterRewritePanel({
+ projectId,
+ chapterNo,
+ currentDraft,
+ onAccept,
+ onClose,
+}: ChapterRewritePanelProps) {
+ const rewrite = useChapterRewrite();
+ const [feedback, setFeedback] = useState("");
+
+ const hasVersions = rewrite.versions.length > 0;
+ const canSend = feedback.trim().length > 0 && !rewrite.isStreaming;
+ // 下一轮以最新版正文为基础(无版本则用当前整章正文)迭代。
+ const priorDraft = rewrite.latest?.text ?? currentDraft;
+ const err = rewrite.state.phase === "error" ? rewrite.state.error : null;
+
+ const onSend = (): void => {
+ if (!canSend) return;
+ const fb = feedback.trim();
+ setFeedback("");
+ void rewrite.send(projectId, chapterNo, fb, priorDraft);
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+ {rewrite.isStreaming ? (
+
+ ) : (
+
+ )}
+ {rewrite.isStreaming ? (
+
+ ) : null}
+
+ {err ? (
+
+ {friendlyError(err.code, err.message).text}
+
+ ) : null}
+
+
+ {rewrite.isStreaming && rewrite.state.text ? (
+
+ 生成中…
+ {rewrite.state.text}
+
+ ) : null}
+
+ {hasVersions && !rewrite.isStreaming ? (
+
+ ) : null}
+
+ );
+}
diff --git a/apps/web/components/workbench/Workbench.tsx b/apps/web/components/workbench/Workbench.tsx
index 27451fd..46d78e3 100644
--- a/apps/web/components/workbench/Workbench.tsx
+++ b/apps/web/components/workbench/Workbench.tsx
@@ -12,6 +12,7 @@ import {
PanelLeft,
PanelRight,
PenLine,
+ RefreshCw,
Sparkles,
Square,
Wand2,
@@ -48,6 +49,7 @@ import { Editor, type EditorSelection } from "./Editor";
import { RefinePanel } from "./RefinePanel";
import { ContinuePanel } from "./ContinuePanel";
import { InlineToolbox } from "./InlineToolbox";
+import { ChapterRewritePanel } from "./ChapterRewritePanel";
import { ContextDrawer, CONTEXT_DRAWER_ENABLED } from "./ContextDrawer";
import { GenrePicker } from "./GenrePicker";
import { GENRES } from "@/lib/wizard/wizard";
@@ -87,6 +89,8 @@ export function Workbench({
const [refineOpen, setRefineOpen] = useState(false);
const [continueOpen, setContinueOpen] = useState(false);
const [toolboxOpen, setToolboxOpen] = useState(false);
+ // WFW-8:整章「再沟通/重写」结果卡开合(意见 → 流式重写一版 → 版本栈 → 接受替换整章)。
+ const [rewriteOpen, setRewriteOpen] = useState(false);
// WFW-7:首次写章的极轻 genre 采集门。项目无题材 → 写章前先点一个题材(仅临时并入本次指令,不落库)。
const genreGate = useGenreGate(project.genre ?? null);
const [genrePromptOpen, setGenrePromptOpen] = useState(false);
@@ -170,10 +174,11 @@ export function Workbench({
setSelection(null);
};
- // 三张 AI 结果卡(润色/续写/工具箱)互斥,避免同时占位。
+ // 四张 AI 结果卡(润色/续写/工具箱/整章重写)互斥,避免同时占位。
const openRefine = (): void => {
setContinueOpen(false);
setToolboxOpen(false);
+ setRewriteOpen(false);
setRefineOpen(true);
};
@@ -182,15 +187,31 @@ export function Workbench({
autosave.flush();
setRefineOpen(false);
setToolboxOpen(false);
+ setRewriteOpen(false);
setContinueOpen(true);
};
const openToolbox = (): void => {
setRefineOpen(false);
setContinueOpen(false);
+ setRewriteOpen(false);
setToolboxOpen(true);
};
+ const openRewrite = (): void => {
+ setRefineOpen(false);
+ setContinueOpen(false);
+ setToolboxOpen(false);
+ setRewriteOpen(true);
+ };
+
+ // 接受某一版整章重写 → 替换整章正文并落草稿(HITL,作者点选才落)。
+ const onAcceptRewrite = (next: string): void => {
+ setText(next);
+ autosave.onChange(next);
+ setRewriteOpen(false);
+ };
+
// 追加一段文本到章末并落草稿(续写候选 / 工具箱产物共用)。
const appendToDraft = (chunk: string): void => {
const base = text.trimEnd();
@@ -289,6 +310,15 @@ export function Workbench({
onClose={() => setToolboxOpen(false)}
/>
) : null}
+ {rewriteOpen ? (
+ setRewriteOpen(false)}
+ />
+ ) : null}
{chapters.length === 0 ? (
@@ -317,6 +347,7 @@ export function Workbench({
onRefineSelection={openRefine}
onContinue={openContinue}
onToolbox={openToolbox}
+ onRewrite={openRewrite}
onOpenContext={() => setContextOpen(true)}
contextTriggerRef={contextTriggerRef}
/>
@@ -549,6 +580,8 @@ interface ToolbarProps {
onContinue: () => void;
// 工具箱:编辑器内联调用生成器,结果回填正文。
onToolbox: () => void;
+ // 整章再沟通/重写(WFW-8)。
+ onRewrite: () => void;
// 打开上下文速查抽屉(WFW-6)。
onOpenContext: () => void;
contextTriggerRef: RefObject;
@@ -569,6 +602,7 @@ function Toolbar({
onRefineSelection,
onContinue,
onToolbox,
+ onRewrite,
onOpenContext,
contextTriggerRef,
}: ToolbarProps) {
@@ -608,6 +642,10 @@ function Toolbar({
工具箱
+
>
) : null}
{streaming ? (
diff --git a/apps/web/lib/api/schema.d.ts b/apps/web/lib/api/schema.d.ts
index c3bb42f..0227856 100644
--- a/apps/web/lib/api/schema.d.ts
+++ b/apps/web/lib/api/schema.d.ts
@@ -157,6 +157,29 @@ export interface paths {
patch?: never;
trace?: never;
};
+ "/projects/{project_id}/chapters/{chapter_no}/rewrite": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /**
+ * Stream Rewrite
+ * @description 整章再沟通/重写:组装记忆 + 当前草稿 + 作者意见 → 网关流 → 归一 SSE → event-stream。
+ *
+ * HITL:新版停前端/草稿,接受才落库(不变量 #3);rewrite 是工具非写节点,不破坏章
+ * 纯函数性(不变量 #7)。项目不存在 → 404(触网关前 fail-fast,同 draft,QA C1)。
+ */
+ post: operations["stream_rewrite_projects__project_id__chapters__chapter_no__rewrite_post"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
"/projects/{project_id}/chapters/{chapter_no}/review": {
parameters: {
query?: never;
@@ -1868,6 +1891,20 @@ export interface components {
/** Draft */
draft?: string | null;
};
+ /**
+ * RewriteStreamRequest
+ * @description POST /projects/:id/chapters/:no/rewrite:整章再沟通/重写。
+ *
+ * `feedback` = 作者对本章的修改意见;`prior_draft` = 当前整章草稿(前端传入,据此 + 记忆
+ * 注入流式重写一版)。临时输入、不持久化、无迁移;只入 volatile(守缓存前缀不变量 #9)。
+ * 新版停前端/草稿,接受才落库(HITL,不变量 #3)。
+ */
+ RewriteStreamRequest: {
+ /** Feedback */
+ feedback: string;
+ /** Prior Draft */
+ prior_draft: string;
+ };
/**
* RuleCreateRequest
* @description POST /projects/:id/rules:新增一条规则。
@@ -2727,6 +2764,42 @@ export interface operations {
};
};
};
+ stream_rewrite_projects__project_id__chapters__chapter_no__rewrite_post: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ project_id: string;
+ chapter_no: number;
+ };
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["RewriteStreamRequest"];
+ };
+ };
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": unknown;
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
review_chapter_projects__project_id__chapters__chapter_no__review_post: {
parameters: {
query?: never;
diff --git a/apps/web/lib/workbench/useChapterRewrite.test.ts b/apps/web/lib/workbench/useChapterRewrite.test.ts
new file mode 100644
index 0000000..20f4ad1
--- /dev/null
+++ b/apps/web/lib/workbench/useChapterRewrite.test.ts
@@ -0,0 +1,162 @@
+// @vitest-environment jsdom
+import { act, renderHook } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import { useChapterRewrite } from "./useChapterRewrite";
+
+// fetch(SSE 流)是 hook 的外部副作用边界,单测一律 stub 全局 fetch。
+const fetchMock = vi.fn();
+
+function sseStream(chunks: string[]): ReadableStream {
+ const enc = new TextEncoder();
+ return new ReadableStream({
+ start(c) {
+ for (const ch of chunks) c.enqueue(enc.encode(ch));
+ c.close();
+ },
+ });
+}
+
+function sseResponse(chunks: string[]): Response {
+ return new Response(sseStream(chunks), {
+ status: 200,
+ headers: { "Content-Type": "text/event-stream" },
+ });
+}
+
+describe("useChapterRewrite", () => {
+ beforeEach(() => {
+ fetchMock.mockReset();
+ vi.stubGlobal("fetch", fetchMock);
+ });
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ vi.clearAllMocks();
+ });
+
+ it("初始为 idle、无版本", () => {
+ const { result } = renderHook(() => useChapterRewrite());
+ expect(result.current.state.phase).toBe("idle");
+ expect(result.current.versions).toEqual([]);
+ expect(result.current.latest).toBeNull();
+ });
+
+ it("重写完成(token+done):流式累积正文并压入版本栈(带作者意见)", async () => {
+ fetchMock.mockResolvedValue(
+ sseResponse([
+ 'event:token\ndata:{"text":"新版"}\n\n',
+ 'event:token\ndata:{"text":"正文"}\n\n',
+ 'event:done\ndata:{"length":4}\n\n',
+ ]),
+ );
+
+ const { result } = renderHook(() => useChapterRewrite());
+ await act(async () => {
+ await result.current.send("p1", 3, "节奏太慢", "旧草稿");
+ });
+
+ // POST body 带 feedback + prior_draft(snake_case)。
+ const call = fetchMock.mock.calls[0];
+ expect(call?.[0]).toContain("/projects/p1/chapters/3/rewrite");
+ expect(JSON.parse((call?.[1]?.body as string) ?? "")).toEqual({
+ feedback: "节奏太慢",
+ prior_draft: "旧草稿",
+ });
+ expect(result.current.state.phase).toBe("done");
+ expect(result.current.versions).toEqual([
+ { feedback: "节奏太慢", text: "新版正文" },
+ ]);
+ expect(result.current.latest?.text).toBe("新版正文");
+ });
+
+ it("多轮迭代:第二轮再压一版,latest 为最新版", async () => {
+ fetchMock
+ .mockResolvedValueOnce(
+ sseResponse([
+ 'event:token\ndata:{"text":"第一版"}\n\n',
+ 'event:done\ndata:{"length":3}\n\n',
+ ]),
+ )
+ .mockResolvedValueOnce(
+ sseResponse([
+ 'event:token\ndata:{"text":"第二版"}\n\n',
+ 'event:done\ndata:{"length":3}\n\n',
+ ]),
+ );
+
+ const { result } = renderHook(() => useChapterRewrite());
+ await act(async () => {
+ await result.current.send("p1", 1, "意见一", "原草稿");
+ });
+ await act(async () => {
+ await result.current.send("p1", 1, "意见二", "第一版");
+ });
+
+ expect(result.current.versions).toHaveLength(2);
+ expect(result.current.latest).toEqual({ feedback: "意见二", text: "第二版" });
+ });
+
+ it("error 帧:phase=error 且不入版本栈", async () => {
+ fetchMock.mockResolvedValue(
+ sseResponse([
+ 'event:error\ndata:{"code":"RATE_LIMIT","message":"配额不足"}\n\n',
+ ]),
+ );
+
+ const { result } = renderHook(() => useChapterRewrite());
+ await act(async () => {
+ await result.current.send("p1", 1, "改", "草稿");
+ });
+
+ expect(result.current.state.phase).toBe("error");
+ expect(result.current.state.error).toMatchObject({ code: "RATE_LIMIT" });
+ expect(result.current.versions).toEqual([]);
+ });
+
+ it("流前错误(!res.ok):解析 JSON 信封提取错误码", async () => {
+ fetchMock.mockResolvedValue(
+ new Response(
+ JSON.stringify({ error: { code: "LLM_UNAVAILABLE", message: "无可用凭据" } }),
+ { status: 503 },
+ ),
+ );
+
+ const { result } = renderHook(() => useChapterRewrite());
+ await act(async () => {
+ await result.current.send("p1", 1, "改", "草稿");
+ });
+
+ expect(result.current.state.error).toMatchObject({ code: "LLM_UNAVAILABLE" });
+ expect(result.current.versions).toEqual([]);
+ });
+
+ it("网络抛异常(非 Abort):phase=error 且 code=NETWORK", async () => {
+ fetchMock.mockRejectedValue(new Error("connection reset"));
+
+ const { result } = renderHook(() => useChapterRewrite());
+ await act(async () => {
+ await result.current.send("p1", 1, "改", "草稿");
+ });
+
+ expect(result.current.state.error).toMatchObject({
+ code: "NETWORK",
+ message: "connection reset",
+ });
+ });
+
+ it("reset 清回 idle 与空版本栈", async () => {
+ fetchMock.mockResolvedValue(
+ sseResponse([
+ 'event:token\ndata:{"text":"x"}\n\n',
+ 'event:done\ndata:{"length":1}\n\n',
+ ]),
+ );
+ const { result } = renderHook(() => useChapterRewrite());
+ await act(async () => {
+ await result.current.send("p1", 1, "改", "草稿");
+ });
+ act(() => result.current.reset());
+ expect(result.current.state.phase).toBe("idle");
+ expect(result.current.versions).toEqual([]);
+ });
+});
diff --git a/apps/web/lib/workbench/useChapterRewrite.ts b/apps/web/lib/workbench/useChapterRewrite.ts
new file mode 100644
index 0000000..ef5f378
--- /dev/null
+++ b/apps/web/lib/workbench/useChapterRewrite.ts
@@ -0,0 +1,163 @@
+"use client";
+
+import { useCallback, useReducer, useRef, useState } from "react";
+
+import { API_BASE_PUBLIC } from "@/lib/api/config";
+import {
+ SseFrameBuffer,
+ initialStreamState,
+ reduceStream,
+ type StreamState,
+} from "@/lib/stream/sse";
+
+// 一轮整章重写的产出(作者意见 + 产出正文)。多轮迭代累积成版本栈。
+export interface RewriteVersion {
+ feedback: string;
+ text: string;
+}
+
+type Action =
+ | { type: "start" }
+ | { type: "events"; events: ReturnType }
+ | { type: "abort" }
+ | { type: "fail"; code: string; message: string }
+ | { type: "reset" };
+
+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;
+ default:
+ return state;
+ }
+}
+
+export interface UseChapterRewrite {
+ // 当前流式状态(text = 正在生成/最新一版正文,供 live 展示)。
+ state: StreamState;
+ isStreaming: boolean;
+ // 已完成的版本栈(每轮一版)。
+ versions: RewriteVersion[];
+ latest: RewriteVersion | null;
+ // 发起一轮整章重写:作者意见 + 当前整章草稿 → 流式重写一版。
+ send: (
+ projectId: string,
+ chapterNo: number,
+ feedback: string,
+ priorDraft: string,
+ ) => Promise;
+ stop: () => void;
+ reset: () => void;
+}
+
+// 整章再沟通/重写流:POST .../rewrite(feedback + prior_draft)→ 消费 SSE → 累积成新一版。
+// 每轮完成压入版本栈;下一轮以最新版正文为 prior_draft + 新意见迭代(HITL:接受才落草稿)。
+// 用 fetch+ReadableStream(EventSource 不支持 POST),复用 lib/stream/sse 的帧解析与归一。
+export function useChapterRewrite(): UseChapterRewrite {
+ const [state, dispatch] = useReducer(reducer, initialStreamState);
+ const [versions, setVersions] = useState([]);
+ const controllerRef = useRef(null);
+ const textRef = useRef("");
+
+ const stop = useCallback(() => {
+ controllerRef.current?.abort();
+ controllerRef.current = null;
+ dispatch({ type: "abort" });
+ }, []);
+
+ const reset = useCallback(() => {
+ dispatch({ type: "reset" });
+ setVersions([]);
+ }, []);
+
+ const send = useCallback(
+ async (projectId, chapterNo, feedback, priorDraft) => {
+ const controller = new AbortController();
+ controllerRef.current = controller;
+ textRef.current = "";
+ dispatch({ type: "start" });
+ try {
+ const res = await fetch(
+ `${API_BASE_PUBLIC}/projects/${projectId}/chapters/${chapterNo}/rewrite`,
+ {
+ method: "POST",
+ headers: {
+ Accept: "text/event-stream",
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ feedback, prior_draft: priorDraft }),
+ signal: controller.signal,
+ },
+ );
+ if (!res.ok || !res.body) {
+ // 流前错误(如无凭据 → 503 LLM_UNAVAILABLE,JSON 信封而非帧)。
+ 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();
+ let done = false;
+ for (;;) {
+ const { value, done: readerDone } = await reader.read();
+ if (readerDone) break;
+ const events = buffer.push(decoder.decode(value, { stream: true }));
+ if (events.length > 0) {
+ dispatch({ type: "events", events });
+ for (const ev of events) {
+ if (ev.event === "token") textRef.current += ev.data.text;
+ if (ev.event === "done") done = true;
+ }
+ }
+ }
+ // 收到 done 且有正文 → 压入版本栈(未 done/空文本不入栈,避免脏版本)。
+ if (done && textRef.current.length > 0) {
+ const version: RewriteVersion = { feedback, text: textRef.current };
+ setVersions((prev) => [...prev, version]);
+ }
+ } 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,
+ isStreaming: state.phase === "streaming",
+ versions,
+ latest: versions.at(-1) ?? null,
+ send,
+ stop,
+ reset,
+ };
+}