diff --git a/apps/web/lib/toolbox/useGenerator.test.ts b/apps/web/lib/toolbox/useGenerator.test.ts index 21f0bb9..c8fc9f5 100644 --- a/apps/web/lib/toolbox/useGenerator.test.ts +++ b/apps/web/lib/toolbox/useGenerator.test.ts @@ -55,8 +55,9 @@ describe("useGenerator", () => { }); const { result } = renderHook(() => useGenerator()); + let returned: { outputKind: string; rawPreview: unknown } | null = null; await act(async () => { - await result.current.generate("p1", "idea", { brief: "脑洞" }); + returned = await result.current.generate("p1", "idea", { brief: "脑洞" }); }); expect(result.current.genStatus).toBe("preview"); @@ -65,6 +66,9 @@ describe("useGenerator", () => { expect(result.current.preview?.kind).toBe("IdeaListResult"); expect(result.current.preview?.items[0]?.heading).toBe("废柴逆袭"); expect(toast).not.toHaveBeenCalled(); + // 追加断言:generate 返回本次产物(供 GeneratorRunner 结束后留痕)。 + expect(returned?.outputKind).toBe("IdeaListResult"); + expect(returned?.rawPreview).toEqual(preview); }); it("生成后端返回 error:genStatus=error 且弹错误 toast", async () => { diff --git a/apps/web/lib/toolbox/useGenerator.ts b/apps/web/lib/toolbox/useGenerator.ts index 77127c1..2e734e7 100644 --- a/apps/web/lib/toolbox/useGenerator.ts +++ b/apps/web/lib/toolbox/useGenerator.ts @@ -16,6 +16,13 @@ import type { ToolGenerateRequest } from "@/lib/api/types"; export type GenStatus = "idle" | "generating" | "preview" | "error"; export type IngestStatus = "idle" | "ingesting" | "conflict" | "done" | "error"; +// 一次生成的产物(供调用方在结束后留痕;与 hook state 同源)。 +export interface GeneratorArtifact { + preview: PreviewModel; + rawPreview: Record | undefined; + outputKind: string; +} + export interface UseGenerator { genStatus: GenStatus; preview: PreviewModel | null; @@ -30,7 +37,7 @@ export interface UseGenerator { projectId: string, toolKey: string, body: ToolGenerateRequest, - ) => Promise; + ) => Promise; // 入库选中行;acknowledge 用于过 409(已查看冲突后重发)。 ingest: ( projectId: string, @@ -74,15 +81,22 @@ export function useGenerator(): UseGenerator { if (error || !data) { setGenStatus("error"); toast(generationErrorMessage(error), "error"); - return; + return null; } - setPreview(mapPreview(data.output_kind, data.preview)); + const mapped = mapPreview(data.output_kind, data.preview); + setPreview(mapped); setRawPreview(data.preview); setOutputKind(data.output_kind); setGenStatus("preview"); + return { + preview: mapped, + rawPreview: data.preview, + outputKind: data.output_kind, + }; } catch { setGenStatus("error"); toast("生成请求异常,请检查网络。", "error"); + return null; } }, [toast], diff --git a/apps/web/lib/workbench/useChapterRewrite.test.ts b/apps/web/lib/workbench/useChapterRewrite.test.ts index 20f4ad1..2962a11 100644 --- a/apps/web/lib/workbench/useChapterRewrite.test.ts +++ b/apps/web/lib/workbench/useChapterRewrite.test.ts @@ -51,8 +51,9 @@ describe("useChapterRewrite", () => { ); const { result } = renderHook(() => useChapterRewrite()); + let returned: unknown; await act(async () => { - await result.current.send("p1", 3, "节奏太慢", "旧草稿"); + returned = await result.current.send("p1", 3, "节奏太慢", "旧草稿"); }); // POST body 带 feedback + prior_draft(snake_case)。 @@ -67,6 +68,8 @@ describe("useChapterRewrite", () => { { feedback: "节奏太慢", text: "新版正文" }, ]); expect(result.current.latest?.text).toBe("新版正文"); + // 追加断言:send 返回本版(供 ChapterRewritePanel 结束后留痕)。 + expect(returned).toEqual({ feedback: "节奏太慢", text: "新版正文" }); }); it("多轮迭代:第二轮再压一版,latest 为最新版", async () => { diff --git a/apps/web/lib/workbench/useChapterRewrite.ts b/apps/web/lib/workbench/useChapterRewrite.ts index ef5f378..72dab59 100644 --- a/apps/web/lib/workbench/useChapterRewrite.ts +++ b/apps/web/lib/workbench/useChapterRewrite.ts @@ -51,13 +51,13 @@ export interface UseChapterRewrite { // 已完成的版本栈(每轮一版)。 versions: RewriteVersion[]; latest: RewriteVersion | null; - // 发起一轮整章重写:作者意见 + 当前整章草稿 → 流式重写一版。 + // 发起一轮整章重写:作者意见 + 当前整章草稿 → 流式重写一版。返回本版(done 且有正文),否则 null。 send: ( projectId: string, chapterNo: number, feedback: string, priorDraft: string, - ) => Promise; + ) => Promise; stop: () => void; reset: () => void; } @@ -115,7 +115,7 @@ export function useChapterRewrite(): UseChapterRewrite { // 非 JSON 信封,沿用默认文案。 } dispatch({ type: "fail", code, message }); - return; + return null; } const reader = res.body.getReader(); const decoder = new TextDecoder(); @@ -137,13 +137,16 @@ export function useChapterRewrite(): UseChapterRewrite { if (done && textRef.current.length > 0) { const version: RewriteVersion = { feedback, text: textRef.current }; setVersions((prev) => [...prev, version]); + return version; } + return null; } catch (err: unknown) { if (err instanceof DOMException && err.name === "AbortError") { - return; // 用户主动停止:state 已置 aborted。 + return null; // 用户主动停止:state 已置 aborted。 } const message = err instanceof Error ? err.message : "未知网络错误"; dispatch({ type: "fail", code: "NETWORK", message }); + return null; } finally { controllerRef.current = null; } diff --git a/apps/web/lib/workbench/useContinue.test.ts b/apps/web/lib/workbench/useContinue.test.ts index 032c7c4..1b0ffe8 100644 --- a/apps/web/lib/workbench/useContinue.test.ts +++ b/apps/web/lib/workbench/useContinue.test.ts @@ -33,8 +33,9 @@ describe("useContinue", () => { }); const { result } = renderHook(() => useContinue()); + let returned: unknown; await act(async () => { - await result.current.generate("p1", 3); + returned = await result.current.generate("p1", 3); }); expect(post).toHaveBeenCalledWith(GEN_PATH, { @@ -43,6 +44,8 @@ describe("useContinue", () => { }); expect(result.current.status).toBe("ready"); expect(result.current.candidates).toEqual(["续写正文一"]); + // 追加断言:generate 返回本条候选文本(供 ContinuePanel 留痕)。 + expect(returned).toBe("续写正文一"); }); it("多候选:再次生成累加候选,不覆盖前一条", async () => { diff --git a/apps/web/lib/workbench/useContinue.ts b/apps/web/lib/workbench/useContinue.ts index 0f0f185..04dcb9f 100644 --- a/apps/web/lib/workbench/useContinue.ts +++ b/apps/web/lib/workbench/useContinue.ts @@ -13,8 +13,8 @@ export type ContinueStatus = "idle" | "generating" | "ready" | "error"; export interface UseContinue { status: ContinueStatus; candidates: string[]; - // 生成一条续写候选(累加,不覆盖);chapterNo 指定承接哪一章。 - generate: (projectId: string, chapterNo: number) => Promise; + // 生成一条续写候选(累加,不覆盖);chapterNo 指定承接哪一章。返回本条候选文本,空/失败返回 null。 + generate: (projectId: string, chapterNo: number) => Promise; reset: () => void; } @@ -45,19 +45,21 @@ export function useContinue(): UseContinue { if (error || !data) { setStatus("error"); toast(generationErrorMessage(error), "error"); - return; + return null; } const text = extractText(data.preview).trim(); if (text.length === 0) { setStatus("ready"); toast("本次未生成有效续写,请再试一次。", "info"); - return; + return null; } setCandidates((prev) => [...prev, text]); setStatus("ready"); + return text; } catch { setStatus("error"); toast("续写请求异常,请检查网络。", "error"); + return null; } }, [toast], diff --git a/apps/web/lib/workbench/useRefine.test.ts b/apps/web/lib/workbench/useRefine.test.ts index 574d5b5..e504581 100644 --- a/apps/web/lib/workbench/useRefine.test.ts +++ b/apps/web/lib/workbench/useRefine.test.ts @@ -36,8 +36,9 @@ describe("useRefine", () => { }); const { result } = renderHook(() => useRefine()); + let returned: unknown; await act(async () => { - await result.current.refine("p1", 3, "原文段"); + returned = await result.current.refine("p1", 3, "原文段"); }); expect(post).toHaveBeenCalledWith(REFINE_PATH, { @@ -52,6 +53,12 @@ describe("useRefine", () => { refined: "润色后的段落", }); expect(result.current.versions).toHaveLength(1); + // 追加断言:refine 返回本版产物(供 RefinePanel 结束后留痕)。 + expect(returned).toEqual({ + segment: "原文段", + instruction: "", + refined: "润色后的段落", + }); }); it("润色带指令:instruction 透传后端", async () => { diff --git a/apps/web/lib/workbench/useRefine.ts b/apps/web/lib/workbench/useRefine.ts index 2d795be..1d8fb3e 100644 --- a/apps/web/lib/workbench/useRefine.ts +++ b/apps/web/lib/workbench/useRefine.ts @@ -26,18 +26,19 @@ export interface UseRefine { latest: RefineVersion | null; // 最初选中的原文(accept 回填的对比基线)。 original: string | null; + // 回炉并返回本版产出(供调用方在结束后留痕);失败返回 null。 refine: ( projectId: string, chapterNo: number, segment: string, instruction?: string, - ) => Promise; - // 以上一版产出为输入 + 作者新意见再回炉一次。无历史版本时为空操作。 + ) => Promise; + // 以上一版产出为输入 + 作者新意见再回炉一次。无历史版本时为空操作(返回 null)。 recommunicate: ( projectId: string, chapterNo: number, instruction: string, - ) => Promise; + ) => Promise; reset: () => void; } @@ -52,7 +53,7 @@ export function useRefine(): UseRefine { chapterNo: number, segment: string, instruction: string, - ): Promise => { + ): Promise => { setStatus("refining"); try { const trimmed = instruction.trim(); @@ -66,16 +67,20 @@ export function useRefine(): UseRefine { if (error || !data) { setStatus("error"); toast("回炉失败,请重试。", "error"); - return; + return null; } - setVersions((prev) => [ - ...prev, - { segment, instruction: trimmed, refined: data.refined }, - ]); + const version: RefineVersion = { + segment, + instruction: trimmed, + refined: data.refined, + }; + setVersions((prev) => [...prev, version]); setStatus("ready"); + return version; } catch { setStatus("error"); toast("回炉请求异常,请检查网络。", "error"); + return null; } }, [toast], @@ -91,7 +96,7 @@ export function useRefine(): UseRefine { (projectId, chapterNo, instruction) => { const last = versions[versions.length - 1]; // 无历史版本 → 无从「再沟通」;空操作,不打后端。 - if (!last) return Promise.resolve(); + if (!last) return Promise.resolve(null); return runRefine(projectId, chapterNo, last.refined, instruction); }, [runRefine, versions],