"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(() => initialFieldValues(tool.input_fields), ); // 入库勾选:预览行下标集合(与 gen.rawPreview 行对齐)。 const [selected, setSelected] = useState>(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 => { 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 => { 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 (

{tool.title}

{tool.ingestable ? ( ) : null}

{tool.subtitle}

{ e.preventDefault(); void onGenerate(); }} > {canUseManuscript ? (
据当前正文反推(自动摘录首尾,控制长度)
) : null} {(tool.input_fields ?? []).map((field) => ( setField(field.name, v)} /> ))} {generating ? ( ) : null} {gen.genStatus === "preview" && gen.preview ? (

预览({gen.preview.items.length}) {canIngest ? singleObject ? "(可入库)" : "(勾选后可入库)" : null}

    {gen.preview.items.map((item, i) => ( onInsertText(item.body ?? item.heading ?? "") : undefined } /> ))}
{canIngest && gen.ingestStatus !== "conflict" ? ( ) : null} {gen.ingestStatus === "conflict" && gen.conflicts ? ( void runIngest(true)} onCancel={() => gen.reset()} /> ) : null} {gen.ingestStatus === "done" ? (

内容已写入设定库,可在对应页面查看与续编。

) : null}
) : null}
); } interface FormFieldProps { field: ToolInputFieldView; value: string; onChange: (value: string) => void; } // 声明式控件映射:type → text / textarea / number(select 暂同 text,后端未给 options)。 function FormField({ field, value, onChange }: FormFieldProps) { return ( {field.type === "textarea" ? (