feat(toolbox): T6 创作工具箱通用生成器框架 — 8 新生成器 + 声明驱动落地页 + P2 收尾
通用执行路径驱动全部生成器("加生成器=加一份声明"):
- @llm: ww_agents +7 输出 schema + 7 spec(book-title/blurb/name/golden-finger/
glossary/opening/fine-outline,只声明 tier)+ build_outline_chapter_context
- @backend: ww_skills GeneratorTool 描述符 + TOOLBOX(11) + get_tool;3 通用端点
GET /skills/toolbox · POST .../skills/{tool_key}/generate(预览不写库,仅记账) ·
POST .../ingest(复用 continuity 409 + partition_writes 白名单);纯 context 派发
- @frontend: 工具箱落地页 RSC + 声明驱动 GeneratorRunner + lib/toolbox 纯函数
+ LeftNav「工具箱」+ ⌘K nav-toolbox/action-gen-*;legacy 3 跳现页
- @qa: tests/test_t6_toolbox_e2e.py 5 用例真 pg + mock 网关零 token,无端点 bug
- P2 收尾: 限流→decisions.md 记延后(单用户原型);noopener/Committable 早已修
守不变量 #2(只声明 tier)/#3(预览不写库,入库经验收 gate)/#9(缓存前缀)。无 DB 迁移。
门禁绿: 后端 ruff/format/mypy 195/alembic 无漂移/pytest 583;前端 lint/tsc/vitest 279/build。
spec 回写 PRODUCT_SPEC §7 + ARCHITECTURE §7.2 端点表。
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
271
apps/web/components/toolbox/GeneratorRunner.tsx
Normal file
271
apps/web/components/toolbox/GeneratorRunner.tsx
Normal file
@@ -0,0 +1,271 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
|
||||
import { useToast } from "@/components/Toast";
|
||||
import { ConflictAdjudication } from "@/components/generation/ConflictAdjudication";
|
||||
import type { ToolDescriptorView, ToolInputFieldView } from "@/lib/api/types";
|
||||
import {
|
||||
buildGenerateRequest,
|
||||
initialFieldValues,
|
||||
missingRequiredFields,
|
||||
previewRows,
|
||||
type FieldValues,
|
||||
type PreviewItem,
|
||||
} from "@/lib/toolbox/toolbox";
|
||||
import { ingestTable } from "@/lib/toolbox/ingest";
|
||||
import { useGenerator } from "@/lib/toolbox/useGenerator";
|
||||
|
||||
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 }));
|
||||
}, []);
|
||||
|
||||
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;
|
||||
const canIngest = tool.ingestable && table !== null && 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;
|
||||
await gen.ingest(projectId, tool.key, {
|
||||
outputKind: gen.outputKind,
|
||||
rows: selectedRows,
|
||||
chapterNo,
|
||||
acknowledgeConflicts: acknowledge,
|
||||
});
|
||||
},
|
||||
[gen, projectId, tool.key, selectedRows, values],
|
||||
);
|
||||
|
||||
const generating = gen.genStatus === "generating";
|
||||
const ingesting = gen.ingestStatus === "ingesting";
|
||||
|
||||
return (
|
||||
<section
|
||||
className="flex flex-col gap-4 rounded border border-line bg-panel p-5"
|
||||
aria-label={tool.title}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="font-serif text-lg text-ink">{tool.title}</h2>
|
||||
<p className="mt-1 text-sm text-ink-soft">{tool.subtitle}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded border border-line px-3 py-1 text-sm text-ink-soft hover:text-ink"
|
||||
>
|
||||
返回
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="flex flex-col gap-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void onGenerate();
|
||||
}}
|
||||
>
|
||||
{(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}
|
||||
className="self-start rounded bg-cinnabar px-4 py-2 text-sm text-white disabled:opacity-50"
|
||||
>
|
||||
{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 ? "(勾选后可入库)" : null}
|
||||
</h3>
|
||||
<ul className="flex flex-col gap-2">
|
||||
{gen.preview.items.map((item, i) => (
|
||||
<PreviewRow
|
||||
key={i}
|
||||
item={item}
|
||||
index={i}
|
||||
selectable={canIngest}
|
||||
checked={selected.has(i)}
|
||||
onToggle={toggle}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{canIngest && gen.ingestStatus !== "conflict" ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void runIngest(false)}
|
||||
disabled={ingesting || selected.size === 0}
|
||||
className="self-start rounded border border-cinnabar px-4 py-2 text-sm text-cinnabar disabled:opacity-50"
|
||||
>
|
||||
{ingesting ? "入库中…" : `入库选中(${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 / number(select 暂同 text,后端未给 options)。
|
||||
function FormField({ field, value, onChange }: FormFieldProps) {
|
||||
const labelText = field.required ? `${field.label} *` : field.label;
|
||||
const common =
|
||||
"mt-1 w-full rounded border border-line bg-bg px-3 py-2 text-sm text-ink";
|
||||
return (
|
||||
<label className="block text-sm text-ink-soft">
|
||||
{labelText}
|
||||
{field.type === "textarea" ? (
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
rows={3}
|
||||
className={`${common} resize-y`}
|
||||
aria-label={field.label}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type={field.type === "number" ? "number" : "text"}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className={common}
|
||||
aria-label={field.label}
|
||||
/>
|
||||
)}
|
||||
{field.help ? (
|
||||
<span className="mt-1 block text-xs text-ink-soft/80">{field.help}</span>
|
||||
) : null}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user