setRefineOpen(true)}
+ onRefineSelection={openRefine}
+ onContinue={openContinue}
/>
@@ -400,6 +434,8 @@ interface ToolbarProps {
// 选区级润色:有非空选区时可用;点击打开润色/再沟通结果卡。
canRefine: boolean;
onRefineSelection: () => void;
+ // 续写:读本章前文续写候选。
+ onContinue: () => void;
}
function Toolbar({
@@ -415,6 +451,7 @@ function Toolbar({
onStop,
canRefine,
onRefineSelection,
+ onContinue,
}: ToolbarProps) {
return (
@@ -433,16 +470,22 @@ function Toolbar({
)}
{!streaming ? (
-
+ <>
+
+
+ >
) : null}
{streaming ? (
// ThinkingIndicator 自带 role=status,开始时播报一次「生成中」;
diff --git a/apps/web/lib/workbench/useContinue.test.ts b/apps/web/lib/workbench/useContinue.test.ts
new file mode 100644
index 0000000..032c7c4
--- /dev/null
+++ b/apps/web/lib/workbench/useContinue.test.ts
@@ -0,0 +1,106 @@
+// @vitest-environment jsdom
+import { act, renderHook } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import { useContinue } from "./useContinue";
+
+const post = vi.fn();
+const toast = vi.fn();
+vi.mock("@/lib/api/client", () => ({
+ api: { POST: (...a: unknown[]) => post(...a) },
+}));
+vi.mock("@/components/Toast", () => ({ useToast: () => toast }));
+
+const GEN_PATH = "/projects/{project_id}/skills/{tool_key}/generate";
+
+describe("useContinue", () => {
+ beforeEach(() => {
+ post.mockReset();
+ toast.mockReset();
+ });
+ afterEach(() => vi.clearAllMocks());
+
+ it("初始为 idle,无候选", () => {
+ const { result } = renderHook(() => useContinue());
+ expect(result.current.status).toBe("idle");
+ expect(result.current.candidates).toEqual([]);
+ });
+
+ it("续写:调 continue 生成器(读该章前文),产出一条候选,status=ready", async () => {
+ post.mockResolvedValue({
+ data: { output_kind: "ContinuationResult", preview: { text: "续写正文一" } },
+ error: null,
+ });
+
+ const { result } = renderHook(() => useContinue());
+ await act(async () => {
+ await result.current.generate("p1", 3);
+ });
+
+ expect(post).toHaveBeenCalledWith(GEN_PATH, {
+ params: { path: { project_id: "p1", tool_key: "continue" } },
+ body: { brief: "", chapter_no: 3 },
+ });
+ expect(result.current.status).toBe("ready");
+ expect(result.current.candidates).toEqual(["续写正文一"]);
+ });
+
+ it("多候选:再次生成累加候选,不覆盖前一条", async () => {
+ post
+ .mockResolvedValueOnce({ data: { preview: { text: "候选一" } }, error: null })
+ .mockResolvedValueOnce({ data: { preview: { text: "候选二" } }, error: null });
+
+ const { result } = renderHook(() => useContinue());
+ await act(async () => {
+ await result.current.generate("p1", 1);
+ });
+ await act(async () => {
+ await result.current.generate("p1", 1);
+ });
+
+ expect(result.current.candidates).toEqual(["候选一", "候选二"]);
+ });
+
+ it("产出为空:不加候选、提示重试、status=ready", async () => {
+ post.mockResolvedValue({ data: { preview: { text: "" } }, error: null });
+ const { result } = renderHook(() => useContinue());
+ await act(async () => {
+ await result.current.generate("p1", 1);
+ });
+ expect(result.current.candidates).toEqual([]);
+ expect(result.current.status).toBe("ready");
+ expect(toast).toHaveBeenCalledWith(expect.any(String), "info");
+ });
+
+ it("后端返回 error:status=error、弹错误 toast、候选不变", async () => {
+ post.mockResolvedValue({ data: null, error: { detail: "配额不足" } });
+ const { result } = renderHook(() => useContinue());
+ await act(async () => {
+ await result.current.generate("p1", 1);
+ });
+ expect(result.current.status).toBe("error");
+ expect(result.current.candidates).toEqual([]);
+ expect(toast).toHaveBeenCalledWith(expect.any(String), "error");
+ });
+
+ it("请求抛异常:status=error、弹网络异常 toast", async () => {
+ post.mockRejectedValue(new Error("network down"));
+ const { result } = renderHook(() => useContinue());
+ await act(async () => {
+ await result.current.generate("p1", 1);
+ });
+ expect(result.current.status).toBe("error");
+ expect(toast).toHaveBeenCalledWith("续写请求异常,请检查网络。", "error");
+ });
+
+ it("reset 清回 idle 与空候选", async () => {
+ post.mockResolvedValue({ data: { preview: { text: "候选" } }, error: null });
+ const { result } = renderHook(() => useContinue());
+ await act(async () => {
+ await result.current.generate("p1", 1);
+ });
+ act(() => result.current.reset());
+ expect(result.current.status).toBe("idle");
+ expect(result.current.candidates).toEqual([]);
+ });
+});
diff --git a/apps/web/lib/workbench/useContinue.ts b/apps/web/lib/workbench/useContinue.ts
new file mode 100644
index 0000000..0f0f185
--- /dev/null
+++ b/apps/web/lib/workbench/useContinue.ts
@@ -0,0 +1,72 @@
+"use client";
+
+import { useCallback, useState } from "react";
+
+import { api } from "@/lib/api/client";
+import { useToast } from "@/components/Toast";
+import { generationErrorMessage } from "@/lib/generation/cards";
+
+// 编辑器「续写」:复用 continue 生成器(with_prior_chapter,自动读该章已写正文承接),
+// 每次生成一条候选并累加成多候选,作者点选某条插入正文(HITL,不静默写入)。
+export type ContinueStatus = "idle" | "generating" | "ready" | "error";
+
+export interface UseContinue {
+ status: ContinueStatus;
+ candidates: string[];
+ // 生成一条续写候选(累加,不覆盖);chapterNo 指定承接哪一章。
+ generate: (projectId: string, chapterNo: number) => Promise;
+ reset: () => void;
+}
+
+function extractText(preview: unknown): string {
+ if (preview && typeof preview === "object" && "text" in preview) {
+ const text = (preview as { text: unknown }).text;
+ return typeof text === "string" ? text : "";
+ }
+ return "";
+}
+
+export function useContinue(): UseContinue {
+ const [status, setStatus] = useState("idle");
+ const [candidates, setCandidates] = useState([]);
+ const toast = useToast();
+
+ const generate = useCallback(
+ async (projectId, chapterNo) => {
+ setStatus("generating");
+ try {
+ const { data, error } = await api.POST(
+ "/projects/{project_id}/skills/{tool_key}/generate",
+ {
+ params: { path: { project_id: projectId, tool_key: "continue" } },
+ body: { brief: "", chapter_no: chapterNo },
+ },
+ );
+ if (error || !data) {
+ setStatus("error");
+ toast(generationErrorMessage(error), "error");
+ return;
+ }
+ const text = extractText(data.preview).trim();
+ if (text.length === 0) {
+ setStatus("ready");
+ toast("本次未生成有效续写,请再试一次。", "info");
+ return;
+ }
+ setCandidates((prev) => [...prev, text]);
+ setStatus("ready");
+ } catch {
+ setStatus("error");
+ toast("续写请求异常,请检查网络。", "error");
+ }
+ },
+ [toast],
+ );
+
+ const reset = useCallback((): void => {
+ setStatus("idle");
+ setCandidates([]);
+ }, []);
+
+ return { status, candidates, generate, reset };
+}