From 9195cf36f31befec49d29775f668b183ab2dcda9 Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Tue, 7 Jul 2026 19:41:57 +0200 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20=E7=BC=96=E8=BE=91=E5=99=A8?= =?UTF-8?q?=E5=86=85=E3=80=8C=E7=BB=AD=E5=86=99=E3=80=8D=E2=80=94=E2=80=94?= =?UTF-8?q?=E8=AF=BB=E6=9C=AC=E7=AB=A0=E5=89=8D=E6=96=87=E7=94=9F=E6=88=90?= =?UTF-8?q?=E5=A4=9A=E5=80=99=E9=80=89=EF=BC=8C=E7=82=B9=E9=80=89=E6=8F=92?= =?UTF-8?q?=E5=85=A5=E6=AD=A3=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1(写作工作台重构):编辑器「续写」按钮复用 continue 生成器(with_prior_chapter 自动读该章已写正文承接),每次产出一条候选并累加成多候选(useContinue,已单测), 「再来一个」可多生成几版;作者点某条「插入正文」才追加到章末(HITL,不静默写入)。 续写前 flush 自动保存,确保读到最新草稿。润色/续写结果卡互斥不并占。 --- .../components/workbench/ContinuePanel.tsx | 104 +++++++++++++++++ apps/web/components/workbench/Workbench.tsx | 65 +++++++++-- apps/web/lib/workbench/useContinue.test.ts | 106 ++++++++++++++++++ apps/web/lib/workbench/useContinue.ts | 72 ++++++++++++ 4 files changed, 336 insertions(+), 11 deletions(-) create mode 100644 apps/web/components/workbench/ContinuePanel.tsx create mode 100644 apps/web/lib/workbench/useContinue.test.ts create mode 100644 apps/web/lib/workbench/useContinue.ts diff --git a/apps/web/components/workbench/ContinuePanel.tsx b/apps/web/components/workbench/ContinuePanel.tsx new file mode 100644 index 0000000..2617650 --- /dev/null +++ b/apps/web/components/workbench/ContinuePanel.tsx @@ -0,0 +1,104 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { Check, Plus, 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 { useContinue } from "@/lib/workbench/useContinue"; + +interface ContinuePanelProps { + projectId: string; + chapterNo: number; + // 插入选中候选到正文(追加到章末)。 + onInsert: (text: string) => void; + onClose: () => void; +} + +// 续写结果卡(内联,非模态):打开即读该章前文续写一条候选;可「再来一个」累加多候选, +// 作者点某条「插入正文」才落回草稿(HITL,不静默写入)。 +export function ContinuePanel({ + projectId, + chapterNo, + onInsert, + onClose, +}: ContinuePanelProps) { + const { status, candidates, generate } = useContinue(); + const ranRef = useRef(false); + + useEffect(() => { + if (ranRef.current) return; + ranRef.current = true; + void generate(projectId, chapterNo); + }, [projectId, chapterNo, generate]); + + const busy = status === "generating"; + + return ( +
+
+ + +
+ + {candidates.length === 0 && busy ? ( +
+ +
+ ) : null} + + {candidates.length === 0 && status === "error" ? ( + + + + ) : null} + + + +
+ +
+
+ ); +} diff --git a/apps/web/components/workbench/Workbench.tsx b/apps/web/components/workbench/Workbench.tsx index 391dc70..01dbe0b 100644 --- a/apps/web/components/workbench/Workbench.tsx +++ b/apps/web/components/workbench/Workbench.tsx @@ -13,6 +13,7 @@ import { PenLine, Square, Wand2, + WrapText, } from "lucide-react"; import { AppShell } from "@/components/AppShell"; @@ -38,6 +39,7 @@ import { ChapterList, ChapterListContent } from "./ChapterList"; import { ChapterAssistant, AssistantContent } from "./ChapterAssistant"; import { Editor, type EditorSelection } from "./Editor"; import { RefinePanel } from "./RefinePanel"; +import { ContinuePanel } from "./ContinuePanel"; interface WorkbenchProps { project: ProjectResponse; @@ -71,6 +73,7 @@ export function Workbench({ // 选区级 AI 动作(润色/再沟通):编辑器上报的最新选区 + 结果卡开合。 const [selection, setSelection] = useState(null); const [refineOpen, setRefineOpen] = useState(false); + const [continueOpen, setContinueOpen] = useState(false); const toast = useToast(); const autosave = useAutosave(project.id, chapterNo, initialText); const stream = useDraftStream(); @@ -131,6 +134,28 @@ export function Workbench({ setSelection(null); }; + // 打开润色/续写互斥,避免两张结果卡同时占位。 + const openRefine = (): void => { + setContinueOpen(false); + setRefineOpen(true); + }; + + // 续写前先 flush 自动保存,确保 continue 读到的是最新草稿(with_prior_chapter 读库)。 + const openContinue = (): void => { + autosave.flush(); + setRefineOpen(false); + setContinueOpen(true); + }; + + // 插入续写候选:追加到章末(续写语义),落回草稿。 + const onInsertContinuation = (candidate: string): void => { + const base = text.trimEnd(); + const next = base.length > 0 ? `${base}\n\n${candidate}` : candidate; + setText(next); + autosave.onChange(next); + setContinueOpen(false); + }; + // 字数统计非空白字符(与中文读者直觉一致:标点/空格不计入正文体量)。 const wordCount = text.replace(/\s+/g, "").length; const closePanel = (): void => setMobilePanel(null); @@ -189,6 +214,14 @@ export function Workbench({ onClose={() => setRefineOpen(false)} /> ) : null} + {continueOpen ? ( + setContinueOpen(false)} + /> + ) : null}
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 }; +}