Files
writer-work-flow/apps/web/components/toolbox/GeneratorRunner.tsx
2026-06-28 07:31:20 +02:00

299 lines
9.5 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 { TextArea } from "@/components/ui/TextArea";
import { TextInput } from "@/components/ui/TextInput";
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;
}
// 声明驱动的通用生成器(核心):按 descriptor.input_fields 渲染表单 → 调通用 generate →
// 按 output_kind 渲染结构化预览 → 可入库者勾选 + 入库(复用 ConflictAdjudication 过 409
// 不硬编码任何工具的表单/预览/入库形状——全部由声明 + lib/toolbox 纯逻辑驱动。
export function GeneratorRunner({
projectId,
tool,
onClose,
}: 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无则不渲染
const fillTarget = useMemo(
() => templateFillTarget(tool.input_fields),
[tool.input_fields],
);
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} 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>
</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}
/>
))}
</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" ? (
<p className="text-sm text-ink-soft">
{gen.created.length}
</p>
) : 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;
}
// 统一预览行渲染heading + 次要字段 + 正文段),由 mapPreview 归一,跨工具复用。
function PreviewRow({
item,
index,
selectable,
checked,
onToggle,
}: PreviewRowProps) {
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}
</div>
</li>
);
}