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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user