refactor(frontend): 四生成 hook 结束后额外返回产物(AC-3 留痕前置)

useRefine.refine/recommunicate→RefineVersion|null、useContinue.generate→string|null、
useChapterRewrite.send→RewriteVersion|null、useGenerator.generate→GeneratorArtifact|null。
向后兼容(既有调用忽略返回值不受影响);各 hook 成功用例补返回值断言。
面板据此 if(artifact) 才 append,空候选/错误路径不留痕。
This commit is contained in:
Yaojia Wang
2026-07-09 17:15:49 +02:00
parent ddf7fa061a
commit 59f2f60a7e
8 changed files with 66 additions and 25 deletions

View File

@@ -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("生成后端返回 errorgenStatus=error 且弹错误 toast", async () => {

View File

@@ -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<string, unknown> | 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<void>;
) => Promise<GeneratorArtifact | null>;
// 入库选中行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],

View File

@@ -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_draftsnake_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 () => {

View File

@@ -51,13 +51,13 @@ export interface UseChapterRewrite {
// 已完成的版本栈(每轮一版)。
versions: RewriteVersion[];
latest: RewriteVersion | null;
// 发起一轮整章重写:作者意见 + 当前整章草稿 → 流式重写一版。
// 发起一轮整章重写:作者意见 + 当前整章草稿 → 流式重写一版。返回本版done 且有正文),否则 null。
send: (
projectId: string,
chapterNo: number,
feedback: string,
priorDraft: string,
) => Promise<void>;
) => Promise<RewriteVersion | null>;
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;
}

View File

@@ -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 () => {

View File

@@ -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<void>;
// 生成一条续写候选累加不覆盖chapterNo 指定承接哪一章。返回本条候选文本,空/失败返回 null。
generate: (projectId: string, chapterNo: number) => Promise<string | null>;
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],

View File

@@ -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 () => {

View File

@@ -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<void>;
// 以上一版产出为输入 + 作者新意见再回炉一次。无历史版本时为空操作。
) => Promise<RefineVersion | null>;
// 以上一版产出为输入 + 作者新意见再回炉一次。无历史版本时为空操作(返回 null
recommunicate: (
projectId: string,
chapterNo: number,
instruction: string,
) => Promise<void>;
) => Promise<RefineVersion | null>;
reset: () => void;
}
@@ -52,7 +53,7 @@ export function useRefine(): UseRefine {
chapterNo: number,
segment: string,
instruction: string,
): Promise<void> => {
): Promise<RefineVersion | null> => {
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],