写作工作台重构剩余 3 项(多 agent 并行构建各自新文件,主线统一集成): - WFW-5 内容感知书名(#6):buildManuscriptExcerpt 取正文首尾摘录(硬上界控 token); GeneratorRunner 加 manuscriptText,有 brief 字段时出现「用当前正文」按钮把摘录填入 brief,让 book-title 等据正文反推。纯前端零后端;HITL——只填表单,生成仍需作者触发。 - WFW-6 上下文速查抽屉(#7):ContextDrawer 视口无关右侧 slide-over,设定库/大纲完整 只读速查 + 伏笔/规则/文风摘要跳链;底栏「速查」按钮触发。加法式、CONTEXT_DRAWER_ENABLED 一键回滚、旧侧栏/全宽页路由全保留。 - WFW-7 genre 采集 + 无大纲降级:项目无题材时首次写章前弹 GenrePicker 采集(临时并入 本次 directive 不落库);chapters 为空时编辑器显式提示「尚无大纲,将按设定自由生成」。 各项 TDD:manuscriptExcerpt/useGenreGate/contextDrawer/useContextDrawer 共 32 新单测。 门禁全绿:vitest 629 / tsc / lint / build / coverage 95.43%。
381 lines
13 KiB
TypeScript
381 lines
13 KiB
TypeScript
"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 { buildManuscriptExcerpt } from "@/lib/workbench/manuscriptExcerpt";
|
||
import { useGenerator } from "@/lib/toolbox/useGenerator";
|
||
import { TemplateFiller } from "./TemplateFiller";
|
||
|
||
interface GeneratorRunnerProps {
|
||
projectId: string;
|
||
tool: ToolDescriptorView;
|
||
onClose: () => void;
|
||
// 可选:把某条预览文本插入正文(编辑器内联工具箱场景)。工具箱页不传 → 不显示「插入正文」。
|
||
onInsertText?: (text: string) => void;
|
||
// 可选:作者【已写正文】。传入且当前工具有 brief 字段时,表单里出现「用当前正文」按钮,
|
||
// 点击把正文摘录填进 brief,让内容感知型生成器(如书名)据正文反推。工具箱页不传 → 不显示。
|
||
manuscriptText?: string;
|
||
}
|
||
|
||
// 声明驱动的通用生成器(核心):按 descriptor.input_fields 渲染表单 → 调通用 generate →
|
||
// 按 output_kind 渲染结构化预览 → 可入库者勾选 + 入库(复用 ConflictAdjudication 过 409)。
|
||
// 不硬编码任何工具的表单/预览/入库形状——全部由声明 + lib/toolbox 纯逻辑驱动。
|
||
export function GeneratorRunner({
|
||
projectId,
|
||
tool,
|
||
onClose,
|
||
onInsertText,
|
||
manuscriptText,
|
||
}: 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],
|
||
);
|
||
|
||
// 当前工具是否有名为 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());
|
||
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}
|
||
/>
|
||
{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" 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 / number(select 暂同 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>
|
||
);
|
||
}
|