Files
writer-work-flow/apps/web/components/toolbox/GeneratorRunner.tsx
Yaojia Wang 1afeb2cdb5 feat(ui): 强化 AI 工作中动效——构思占位/流式进度条/波动三点/转圈
- 动效基元:ai-dot 波动(三点竖跳)、ai-stream 不确定进度条、ai-breathe 呼吸微光(均带 reduced-motion 回退)
- ThinkingIndicator 升级为明显波动;新增 Spinner / StreamingBar / GenerationSkeleton 原子件;Button 加 loading 态
- 写作台:首 token 前正文区『AI 正在构思本章…』占位(补最大缺口,不再空白像卡死)+ 流式顶部进度条
- 整章重写流式进度条;续写/润色忙碌骨架 + 触发键转圈;生成器/角色/世界观改用 Button loading
2026-07-12 19:32:18 +02:00

418 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useCallback, useMemo, useRef, useState } from "react";
import { ArrowLeft, Database, Sparkles } from "lucide-react";
import { useToast } from "@/components/Toast";
import { ConflictAdjudication } from "@/components/generation/ConflictAdjudication";
import { Button } from "@/components/ui/Button";
import { Badge } from "@/components/ui/Badge";
import { Checkbox } from "@/components/ui/Checkbox";
import { Field } from "@/components/ui/Field";
import { StatusNote } from "@/components/ui/StatusNote";
import { TextArea } from "@/components/ui/TextArea";
import { TextInput } from "@/components/ui/TextInput";
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
import type { ToolDescriptorView, ToolInputFieldView } from "@/lib/api/types";
import {
buildGenerateRequest,
initialFieldValues,
missingRequiredFields,
previewRows,
templateFillTarget,
type FieldValues,
type PreviewItem,
} from "@/lib/toolbox/toolbox";
import { ingestTable, isSingleObjectIngest } from "@/lib/toolbox/ingest";
import { fieldSummary, renderPreviewText } from "@/lib/toolbox/aiSummary";
import { buildManuscriptExcerpt } from "@/lib/workbench/manuscriptExcerpt";
import { useGenerator } from "@/lib/toolbox/useGenerator";
import type { AiTurnInput } from "@/lib/workbench/useAiConversation";
import { TemplateFiller } from "./TemplateFiller";
interface GeneratorRunnerProps {
projectId: string;
tool: ToolDescriptorView;
onClose: () => void;
// 可选:把某条预览文本插入正文(编辑器内联工具箱场景)。工具箱页不传 → 不显示「插入正文」。
onInsertText?: (text: string) => void;
// 可选:作者【已写正文】。传入且当前工具有 brief 字段时,表单里出现「用当前正文」按钮,
// 点击把正文摘录填进 brief让内容感知型生成器如书名据正文反推。工具箱页不传 → 不显示。
manuscriptText?: string;
// 可选留痕:生成成功后 append 一批 [author(字段摘要), ai(预览文本)]。
// 内联工具箱传 conversation.appendMessages + 当前章号;工具箱页传 silent 直投 + chapterNo=null。
appendMessages?: (turn: AiTurnInput) => void;
logChapterNo?: number | null;
}
// 声明驱动的通用生成器(核心):按 descriptor.input_fields 渲染表单 → 调通用 generate →
// 按 output_kind 渲染结构化预览 → 可入库者勾选 + 入库(复用 ConflictAdjudication 过 409
// 不硬编码任何工具的表单/预览/入库形状——全部由声明 + lib/toolbox 纯逻辑驱动。
export function GeneratorRunner({
projectId,
tool,
onClose,
onInsertText,
manuscriptText,
appendMessages,
logChapterNo,
}: GeneratorRunnerProps) {
const gen = useGenerator();
const toast = useToast();
const [values, setValues] = useState<FieldValues>(() =>
initialFieldValues(tool.input_fields),
);
// 入库勾选:预览行下标集合(与 gen.rawPreview 行对齐)。
const [selected, setSelected] = useState<Set<number>>(new Set());
// 本 runner 一次会话 = 一个 thread多次生成归此组。
const threadId = useRef(crypto.randomUUID()).current;
const setField = useCallback((name: string, value: string): void => {
setValues((prev) => ({ ...prev, [name]: value }));
}, []);
// 「从模板填入」的目标字段brief / text无则不渲染。匹配按 name展示用中文 label。
const fillTarget = useMemo(
() => templateFillTarget(tool.input_fields),
[tool.input_fields],
);
const fillLabel = useMemo(
() =>
(tool.input_fields ?? []).find((f) => f.name === fillTarget)?.label ??
fillTarget,
[tool.input_fields, fillTarget],
);
// 当前工具是否有名为 brief 的输入字段——只有它才承接「据正文反推」。
const hasBriefField = useMemo(
() => (tool.input_fields ?? []).some((f) => f.name === "brief"),
[tool.input_fields],
);
// 有 brief 字段且拿到非空正文时,才提供「用当前正文」。
const canUseManuscript =
hasBriefField && (manuscriptText?.trim().length ?? 0) > 0;
const onUseManuscript = useCallback((): void => {
if (!manuscriptText) return;
const excerpt = buildManuscriptExcerpt(manuscriptText);
if (excerpt.length === 0) {
toast("当前还没有正文可供反推。", "error");
return;
}
setField("brief", excerpt);
toast("已按当前正文填入,可继续编辑后生成。", "success");
}, [manuscriptText, setField, toast]);
const onGenerate = useCallback(async (): Promise<void> => {
const missing = missingRequiredFields(tool.input_fields, values);
if (missing.length > 0) {
toast(`请填写:${missing.join("、")}`, "error");
return;
}
setSelected(new Set());
const artifact = await gen.generate(
projectId,
tool.key,
buildGenerateRequest(values),
);
// 留痕成功且有可读预览时author(字段摘要) + ai(渲染预览);空产物/错误路径不记。
if (artifact && appendMessages) {
const aiText = renderPreviewText(artifact.preview);
if (aiText.length > 0) {
appendMessages({
threadId,
kind: "generator",
toolKey: tool.key,
chapterNo: logChapterNo ?? null,
messages: [
{
role: "author",
content: fieldSummary(tool.input_fields ?? [], values) || tool.title,
meta: { tool_key: tool.key, input_fields: values },
},
{
role: "ai",
content: aiText,
meta: { tool_key: tool.key, output_kind: artifact.outputKind },
},
],
});
}
}
}, [gen, projectId, tool, values, toast, appendMessages, logChapterNo, threadId]);
const rows = useMemo(
() => previewRows(gen.rawPreview),
[gen.rawPreview],
);
const table = gen.outputKind ? ingestTable(gen.outputKind) : null;
// 单对象产物(如拆书):整个 preview 即一条入库项,无逐行勾选;行式产物按勾选入库。
const singleObject = gen.outputKind
? isSingleObjectIngest(gen.outputKind)
: false;
const hasPreview = (gen.preview?.items.length ?? 0) > 0;
const canIngest =
tool.ingestable &&
table !== null &&
(singleObject ? hasPreview : rows.length > 0);
const toggle = useCallback((idx: number): void => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(idx)) {
next.delete(idx);
} else {
next.add(idx);
}
return next;
});
}, []);
const selectedRows = useMemo(
() => rows.filter((_, i) => selected.has(i)),
[rows, selected],
);
const runIngest = useCallback(
async (acknowledge: boolean): Promise<void> => {
if (!gen.outputKind) return;
const chapterNo = Number(values["chapter_no"]?.trim() || "") || null;
// 单对象产物(拆书):整个 rawPreview 作唯一入库行;行式产物用勾选行。
const ingestRows =
singleObject && gen.rawPreview ? [gen.rawPreview] : selectedRows;
await gen.ingest(projectId, tool.key, {
outputKind: gen.outputKind,
rows: ingestRows,
chapterNo,
acknowledgeConflicts: acknowledge,
});
},
[gen, projectId, tool.key, selectedRows, values, singleObject],
);
const generating = gen.genStatus === "generating";
const ingesting = gen.ingestStatus === "ingesting";
return (
<section
className="flex flex-col gap-5 rounded border border-line bg-panel p-5 shadow-paper"
aria-label={tool.title}
>
<div className="flex items-start justify-between gap-4">
<div>
<div className="mb-2 flex flex-wrap items-center gap-2">
<h2 className="font-serif text-lg text-ink">{tool.title}</h2>
{tool.ingestable ? (
<Badge variant="info">
<Database className="h-3 w-3" aria-hidden="true" />
</Badge>
) : null}
</div>
<p className="mt-1 text-sm text-ink-soft">{tool.subtitle}</p>
</div>
<Button onClick={onClose} variant="secondary" size="sm">
<ArrowLeft className="h-4 w-4" aria-hidden="true" />
</Button>
</div>
<form
className="grid gap-3 rounded border border-line bg-bg/50 p-4"
onSubmit={(e) => {
e.preventDefault();
void onGenerate();
}}
>
<TemplateFiller
targetField={fillTarget}
targetLabel={fillLabel}
onFill={setField}
/>
{canUseManuscript ? (
<div className="flex items-center justify-between gap-2 rounded border border-line bg-bg/50 p-2">
<span className="text-xs text-ink-soft">
</span>
<Button
type="button"
onClick={onUseManuscript}
variant="outline"
size="sm"
>
<Sparkles className="h-4 w-4" aria-hidden="true" />
</Button>
</div>
) : null}
{(tool.input_fields ?? []).map((field) => (
<FormField
key={field.name}
field={field}
value={values[field.name] ?? ""}
onChange={(v) => setField(field.name, v)}
/>
))}
<Button type="submit" loading={generating} variant="primary">
<Sparkles className="h-4 w-4" aria-hidden="true" />
</Button>
{generating ? (
<ThinkingIndicator
label="生成中,长文可能需要十几秒…"
className="text-cinnabar"
/>
) : null}
</form>
{gen.genStatus === "preview" && gen.preview ? (
<div className="flex flex-col gap-3">
<h3 className="font-serif text-sm text-ink">
{gen.preview.items.length}
{canIngest
? singleObject
? "(可入库)"
: "(勾选后可入库)"
: null}
</h3>
<ul className="flex flex-col gap-2">
{gen.preview.items.map((item, i) => (
<PreviewRow
key={i}
item={item}
index={i}
selectable={canIngest && !singleObject}
checked={selected.has(i)}
onToggle={toggle}
onInsert={
onInsertText
? () => onInsertText(item.body ?? item.heading ?? "")
: undefined
}
/>
))}
</ul>
{canIngest && gen.ingestStatus !== "conflict" ? (
<Button
onClick={() => void runIngest(false)}
loading={ingesting}
disabled={!singleObject && selected.size === 0}
variant="outline"
>
<Database className="h-4 w-4" aria-hidden="true" />
{singleObject
? `入库为规则至 ${table}`
: `入库选中(${selected.size})至 ${table}`}
</Button>
) : null}
{gen.ingestStatus === "conflict" && gen.conflicts ? (
<ConflictAdjudication
conflicts={gen.conflicts}
busy={ingesting}
onAcknowledge={() => void runIngest(true)}
onCancel={() => gen.reset()}
/>
) : null}
{gen.ingestStatus === "done" ? (
<StatusNote variant="success" title={`已入库 ${gen.created.length}`}>
<p></p>
</StatusNote>
) : null}
</div>
) : null}
</section>
);
}
interface FormFieldProps {
field: ToolInputFieldView;
value: string;
onChange: (value: string) => void;
}
// 声明式控件映射type → text / textarea / numberselect 暂同 text后端未给 options
function FormField({ field, value, onChange }: FormFieldProps) {
return (
<Field label={field.label} help={field.help} required={field.required}>
{field.type === "textarea" ? (
<TextArea
value={value}
onChange={(e) => onChange(e.target.value)}
rows={3}
aria-label={field.label}
/>
) : (
<TextInput
type={field.type === "number" ? "number" : "text"}
value={value}
onChange={(e) => onChange(e.target.value)}
aria-label={field.label}
/>
)}
</Field>
);
}
interface PreviewRowProps {
item: PreviewItem;
index: number;
selectable: boolean;
checked: boolean;
onToggle: (index: number) => void;
// 可选:把本条插入正文(编辑器内联工具箱)。有可插入文本时才渲染按钮。
onInsert?: () => void;
}
// 统一预览行渲染heading + 次要字段 + 正文段),由 mapPreview 归一,跨工具复用。
function PreviewRow({
item,
index,
selectable,
checked,
onToggle,
onInsert,
}: PreviewRowProps) {
const canInsert = onInsert && (item.body || item.heading);
return (
<li className="flex gap-2 rounded border border-line bg-bg p-3 text-sm">
{selectable ? (
<Checkbox
checked={checked}
onChange={() => onToggle(index)}
className="mt-1"
aria-label={`选择第 ${index + 1} 项入库`}
/>
) : null}
<div className="min-w-0 flex-1">
{item.heading ? (
<p className="mb-1 font-serif text-base text-ink">{item.heading}</p>
) : null}
{item.fields.map((f, j) => (
<p key={j} className="text-ink-soft">
<span className="text-ink-soft/80">{f.label}</span>
{f.value}
</p>
))}
{item.body ? (
<p className="whitespace-pre-wrap text-ink">{item.body}</p>
) : null}
{canInsert ? (
<Button
onClick={onInsert}
variant="outline"
size="sm"
className="mt-2"
>
<ArrowLeft className="h-4 w-4 rotate-90" aria-hidden="true" />
</Button>
) : null}
</div>
</li>
);
}