Files
writer-work-flow/apps/web/components/toolbox/GeneratorRunner.tsx
Yaojia Wang 04f4785292 feat(frontend): 编辑器内联工具箱——就地调生成器(含金手指),结果一键插入正文
Phase 1(写作工作台重构):编辑器「工具箱」按钮打开内联面板,不离开写作界面即可调用
全部生成器(含金手指——后端 golden-finger 早已齐全,此处首次露出)。复用 GeneratorRunner,
为其加可选 onInsertText:文本类产物(开篇/简介/续写/扩写等)点「插入正文」直接追加到章末,
可入库类仍走既有 ingest。useToolboxTools 客户端拉描述符(已单测)。润色/续写/工具箱三卡互斥。
2026-07-07 19:48:13 +02:00

340 lines
11 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, 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 { 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 { useGenerator } from "@/lib/toolbox/useGenerator";
import { TemplateFiller } from "./TemplateFiller";
interface GeneratorRunnerProps {
projectId: string;
tool: ToolDescriptorView;
onClose: () => void;
// 可选:把某条预览文本插入正文(编辑器内联工具箱场景)。工具箱页不传 → 不显示「插入正文」。
onInsertText?: (text: string) => void;
}
// 声明驱动的通用生成器(核心):按 descriptor.input_fields 渲染表单 → 调通用 generate →
// 按 output_kind 渲染结构化预览 → 可入库者勾选 + 入库(复用 ConflictAdjudication 过 409
// 不硬编码任何工具的表单/预览/入库形状——全部由声明 + lib/toolbox 纯逻辑驱动。
export function GeneratorRunner({
projectId,
tool,
onClose,
onInsertText,
}: 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());
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],
);
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());
await gen.generate(projectId, tool.key, buildGenerateRequest(values));
}, [gen, projectId, tool, values, toast]);
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}
/>
{(tool.input_fields ?? []).map((field) => (
<FormField
key={field.name}
field={field}
value={values[field.name] ?? ""}
onChange={(v) => setField(field.name, v)}
/>
))}
<Button type="submit" disabled={generating} variant="primary">
<Sparkles className="h-4 w-4" aria-hidden="true" />
{generating ? "生成中…" : "生成"}
</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)}
disabled={ingesting || (!singleObject && selected.size === 0)}
variant="outline"
>
<Database className="h-4 w-4" aria-hidden="true" />
{ingesting
? "入库中…"
: 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 ? (
<input
type="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>
);
}