feat(frontend): 编辑器内「续写」——读本章前文生成多候选,点选插入正文
Phase 1(写作工作台重构):编辑器「续写」按钮复用 continue 生成器(with_prior_chapter 自动读该章已写正文承接),每次产出一条候选并累加成多候选(useContinue,已单测), 「再来一个」可多生成几版;作者点某条「插入正文」才追加到章末(HITL,不静默写入)。 续写前 flush 自动保存,确保读到最新草稿。润色/续写结果卡互斥不并占。
This commit is contained in:
104
apps/web/components/workbench/ContinuePanel.tsx
Normal file
104
apps/web/components/workbench/ContinuePanel.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="border-b border-line bg-panel px-4 py-3 sm:px-6">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<SectionHeader
|
||||||
|
title="续写候选"
|
||||||
|
description="承接本章前文续写;点「再来一个」多生成几版,选中意的插入正文。"
|
||||||
|
/>
|
||||||
|
<Button onClick={onClose} variant="ghost" size="icon" aria-label="关闭续写">
|
||||||
|
<X className="h-4 w-4" aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{candidates.length === 0 && busy ? (
|
||||||
|
<div className="mt-3">
|
||||||
|
<ThinkingIndicator label="续写中" className="text-xs text-cinnabar" />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{candidates.length === 0 && status === "error" ? (
|
||||||
|
<StatusNote variant="danger" className="mt-3 text-xs" title="续写失败">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void generate(projectId, chapterNo)}
|
||||||
|
className="mt-1 inline-flex items-center gap-1 text-cinnabar hover:underline"
|
||||||
|
>
|
||||||
|
重试
|
||||||
|
</button>
|
||||||
|
</StatusNote>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<ul className="mt-3 space-y-2">
|
||||||
|
{candidates.map((text, i) => (
|
||||||
|
<li
|
||||||
|
key={i}
|
||||||
|
className="rounded border border-line bg-bg p-3"
|
||||||
|
>
|
||||||
|
<div className="mb-1 flex items-center justify-between gap-2">
|
||||||
|
<span className="text-2xs text-ink-soft">候选 {i + 1}</span>
|
||||||
|
<Button
|
||||||
|
onClick={() => onInsert(text)}
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<Check className="h-4 w-4" aria-hidden="true" />
|
||||||
|
插入正文
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="whitespace-pre-wrap text-sm text-ink">{text}</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div className="mt-3">
|
||||||
|
<Button
|
||||||
|
onClick={() => void generate(projectId, chapterNo)}
|
||||||
|
disabled={busy}
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" aria-hidden="true" />
|
||||||
|
再来一个候选
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
PenLine,
|
PenLine,
|
||||||
Square,
|
Square,
|
||||||
Wand2,
|
Wand2,
|
||||||
|
WrapText,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import { AppShell } from "@/components/AppShell";
|
import { AppShell } from "@/components/AppShell";
|
||||||
@@ -38,6 +39,7 @@ import { ChapterList, ChapterListContent } from "./ChapterList";
|
|||||||
import { ChapterAssistant, AssistantContent } from "./ChapterAssistant";
|
import { ChapterAssistant, AssistantContent } from "./ChapterAssistant";
|
||||||
import { Editor, type EditorSelection } from "./Editor";
|
import { Editor, type EditorSelection } from "./Editor";
|
||||||
import { RefinePanel } from "./RefinePanel";
|
import { RefinePanel } from "./RefinePanel";
|
||||||
|
import { ContinuePanel } from "./ContinuePanel";
|
||||||
|
|
||||||
interface WorkbenchProps {
|
interface WorkbenchProps {
|
||||||
project: ProjectResponse;
|
project: ProjectResponse;
|
||||||
@@ -71,6 +73,7 @@ export function Workbench({
|
|||||||
// 选区级 AI 动作(润色/再沟通):编辑器上报的最新选区 + 结果卡开合。
|
// 选区级 AI 动作(润色/再沟通):编辑器上报的最新选区 + 结果卡开合。
|
||||||
const [selection, setSelection] = useState<EditorSelection | null>(null);
|
const [selection, setSelection] = useState<EditorSelection | null>(null);
|
||||||
const [refineOpen, setRefineOpen] = useState(false);
|
const [refineOpen, setRefineOpen] = useState(false);
|
||||||
|
const [continueOpen, setContinueOpen] = useState(false);
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const autosave = useAutosave(project.id, chapterNo, initialText);
|
const autosave = useAutosave(project.id, chapterNo, initialText);
|
||||||
const stream = useDraftStream();
|
const stream = useDraftStream();
|
||||||
@@ -131,6 +134,28 @@ export function Workbench({
|
|||||||
setSelection(null);
|
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 wordCount = text.replace(/\s+/g, "").length;
|
||||||
const closePanel = (): void => setMobilePanel(null);
|
const closePanel = (): void => setMobilePanel(null);
|
||||||
@@ -189,6 +214,14 @@ export function Workbench({
|
|||||||
onClose={() => setRefineOpen(false)}
|
onClose={() => setRefineOpen(false)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
{continueOpen ? (
|
||||||
|
<ContinuePanel
|
||||||
|
projectId={project.id}
|
||||||
|
chapterNo={chapterNo}
|
||||||
|
onInsert={onInsertContinuation}
|
||||||
|
onClose={() => setContinueOpen(false)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<div className="flex-1 overflow-auto px-6 py-8">
|
<div className="flex-1 overflow-auto px-6 py-8">
|
||||||
<Editor
|
<Editor
|
||||||
value={text}
|
value={text}
|
||||||
@@ -209,7 +242,8 @@ export function Workbench({
|
|||||||
onWrite={onWrite}
|
onWrite={onWrite}
|
||||||
onStop={stream.stop}
|
onStop={stream.stop}
|
||||||
canRefine={canRefine}
|
canRefine={canRefine}
|
||||||
onRefineSelection={() => setRefineOpen(true)}
|
onRefineSelection={openRefine}
|
||||||
|
onContinue={openContinue}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -400,6 +434,8 @@ interface ToolbarProps {
|
|||||||
// 选区级润色:有非空选区时可用;点击打开润色/再沟通结果卡。
|
// 选区级润色:有非空选区时可用;点击打开润色/再沟通结果卡。
|
||||||
canRefine: boolean;
|
canRefine: boolean;
|
||||||
onRefineSelection: () => void;
|
onRefineSelection: () => void;
|
||||||
|
// 续写:读本章前文续写候选。
|
||||||
|
onContinue: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Toolbar({
|
function Toolbar({
|
||||||
@@ -415,6 +451,7 @@ function Toolbar({
|
|||||||
onStop,
|
onStop,
|
||||||
canRefine,
|
canRefine,
|
||||||
onRefineSelection,
|
onRefineSelection,
|
||||||
|
onContinue,
|
||||||
}: ToolbarProps) {
|
}: ToolbarProps) {
|
||||||
return (
|
return (
|
||||||
<div className="sticky bottom-0 z-10 border-t border-line bg-panel/95 px-4 py-2 backdrop-blur sm:px-6 sm:py-3">
|
<div className="sticky bottom-0 z-10 border-t border-line bg-panel/95 px-4 py-2 backdrop-blur sm:px-6 sm:py-3">
|
||||||
@@ -433,16 +470,22 @@ function Toolbar({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{!streaming ? (
|
{!streaming ? (
|
||||||
<Button
|
<>
|
||||||
onClick={onRefineSelection}
|
<Button onClick={onContinue} variant="secondary" size="sm">
|
||||||
disabled={!canRefine}
|
<WrapText className="h-4 w-4" aria-hidden="true" />
|
||||||
variant="secondary"
|
续写
|
||||||
size="sm"
|
</Button>
|
||||||
title={canRefine ? undefined : "先在正文里选中一段再润色"}
|
<Button
|
||||||
>
|
onClick={onRefineSelection}
|
||||||
<Wand2 className="h-4 w-4" aria-hidden="true" />
|
disabled={!canRefine}
|
||||||
润色选段
|
variant="secondary"
|
||||||
</Button>
|
size="sm"
|
||||||
|
title={canRefine ? undefined : "先在正文里选中一段再润色"}
|
||||||
|
>
|
||||||
|
<Wand2 className="h-4 w-4" aria-hidden="true" />
|
||||||
|
润色选段
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
{streaming ? (
|
{streaming ? (
|
||||||
// ThinkingIndicator 自带 role=status,开始时播报一次「生成中」;
|
// ThinkingIndicator 自带 role=status,开始时播报一次「生成中」;
|
||||||
|
|||||||
106
apps/web/lib/workbench/useContinue.test.ts
Normal file
106
apps/web/lib/workbench/useContinue.test.ts
Normal file
@@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
72
apps/web/lib/workbench/useContinue.ts
Normal file
72
apps/web/lib/workbench/useContinue.ts
Normal file
@@ -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<void>;
|
||||||
|
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<ContinueStatus>("idle");
|
||||||
|
const [candidates, setCandidates] = useState<string[]>([]);
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
|
const generate = useCallback<UseContinue["generate"]>(
|
||||||
|
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 };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user