feat(frontend): 编辑器内「续写」——读本章前文生成多候选,点选插入正文

Phase 1(写作工作台重构):编辑器「续写」按钮复用 continue 生成器(with_prior_chapter
自动读该章已写正文承接),每次产出一条候选并累加成多候选(useContinue,已单测),
「再来一个」可多生成几版;作者点某条「插入正文」才追加到章末(HITL,不静默写入)。
续写前 flush 自动保存,确保读到最新草稿。润色/续写结果卡互斥不并占。
This commit is contained in:
Yaojia Wang
2026-07-07 19:41:57 +02:00
parent 2f0b84191a
commit 9195cf36f3
4 changed files with 336 additions and 11 deletions

View 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>
);
}

View File

@@ -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<EditorSelection | null>(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 ? (
<ContinuePanel
projectId={project.id}
chapterNo={chapterNo}
onInsert={onInsertContinuation}
onClose={() => setContinueOpen(false)}
/>
) : null}
<div className="flex-1 overflow-auto px-6 py-8">
<Editor
value={text}
@@ -209,7 +242,8 @@ export function Workbench({
onWrite={onWrite}
onStop={stream.stop}
canRefine={canRefine}
onRefineSelection={() => setRefineOpen(true)}
onRefineSelection={openRefine}
onContinue={openContinue}
/>
</section>
@@ -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 (
<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>
)}
{!streaming ? (
<Button
onClick={onRefineSelection}
disabled={!canRefine}
variant="secondary"
size="sm"
title={canRefine ? undefined : "先在正文里选中一段再润色"}
>
<Wand2 className="h-4 w-4" aria-hidden="true" />
</Button>
<>
<Button onClick={onContinue} variant="secondary" size="sm">
<WrapText className="h-4 w-4" aria-hidden="true" />
</Button>
<Button
onClick={onRefineSelection}
disabled={!canRefine}
variant="secondary"
size="sm"
title={canRefine ? undefined : "先在正文里选中一段再润色"}
>
<Wand2 className="h-4 w-4" aria-hidden="true" />
</Button>
</>
) : null}
{streaming ? (
// ThinkingIndicator 自带 role=status开始时播报一次「生成中」