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:
Yaojia Wang
2026-06-22 20:37:55 +02:00
parent 1f1afa37b6
commit f43ccd293f
46 changed files with 4848 additions and 12 deletions

View 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 / numberselect 暂同 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>
);
}

View File

@@ -0,0 +1,41 @@
"use client";
import type { ToolDescriptorView } from "@/lib/api/types";
interface ToolCardProps {
tool: ToolDescriptorView;
// 点击legacy 工具导航到现有页面;新工具开 GeneratorRunner。
onOpen: (tool: ToolDescriptorView) => void;
}
// 工具箱卡片(对齐 ProjectCard 纸感视觉):标题 + 副标题 + 题材徽标 + NEW/可入库标。
// 纯展示;点击交由 ToolboxPage 分派legacy 跳转 / 新工具开 runner
export function ToolCard({ tool, onOpen }: ToolCardProps) {
return (
<button
type="button"
onClick={() => onOpen(tool)}
className="flex h-full min-h-[140px] flex-col rounded border border-line bg-panel p-5 text-left shadow-paper transition-colors hover:border-cinnabar focus-visible:border-cinnabar focus-visible:outline-none"
>
<div className="mb-2 flex flex-wrap items-center gap-2">
<h2 className="font-serif text-xl text-ink">{tool.title}</h2>
{tool.genre ? (
<span className="rounded bg-bg px-2 py-0.5 text-xs text-ink-soft">
{tool.genre}
</span>
) : null}
{tool.is_legacy ? null : (
<span className="rounded bg-[var(--color-cinnabar-wash)] px-2 py-0.5 text-xs text-cinnabar">
NEW
</span>
)}
{tool.ingestable ? (
<span className="rounded bg-bg px-2 py-0.5 text-xs text-ink-soft">
</span>
) : null}
</div>
<p className="line-clamp-2 text-sm text-ink-soft">{tool.subtitle}</p>
</button>
);
}

View File

@@ -0,0 +1,92 @@
"use client";
import { useCallback, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { AppShell } from "@/components/AppShell";
import type { ProjectResponse, ToolDescriptorView } from "@/lib/api/types";
import { isLegacyTool, resolveLegacyRoute } from "@/lib/toolbox/toolbox";
import { ToolCard } from "./ToolCard";
import { GeneratorRunner } from "./GeneratorRunner";
interface ToolboxPageProps {
project: ProjectResponse;
tools: ToolDescriptorView[];
// 命令面板 action-gen-<key> 深链:进页直接打开该工具的 runner仅新工具
initialOpenKey?: string;
}
// 创作工具箱(通用生成器框架):声明驱动的卡片栅格。
// legacy 工具(世界观/人设/大纲)跳现有页面保其更丰富的入库流;新工具开 GeneratorRunner。
export function ToolboxPage({
project,
tools,
initialOpenKey,
}: ToolboxPageProps) {
const router = useRouter();
const findOpenable = useCallback(
(key: string | undefined): ToolDescriptorView | null => {
if (!key) return null;
const tool = tools.find((t) => t.key === key);
return tool && !isLegacyTool(tool) ? tool : null;
},
[tools],
);
const [active, setActive] = useState<ToolDescriptorView | null>(() =>
findOpenable(initialOpenKey),
);
const onOpen = useCallback(
(tool: ToolDescriptorView): void => {
if (isLegacyTool(tool) && tool.legacy_route) {
router.push(resolveLegacyRoute(tool.legacy_route, project.id));
return;
}
setActive(tool);
},
[router, project.id],
);
const sorted = useMemo(
// 新工具靠前更想被发现legacy 收后;同组保持后端顺序。
() => [...tools].sort((a, b) => Number(a.is_legacy) - Number(b.is_legacy)),
[tools],
);
return (
<AppShell
title={`${project.title}`}
subtitle="工具箱"
projectId={project.id}
activeNav="toolbox"
>
<div className="mx-auto flex max-w-5xl flex-col gap-6 p-6">
<header>
<h1 className="font-serif text-lg text-ink"></h1>
<p className="mt-1 text-xs text-ink-soft">
/ / / / / / /
</p>
</header>
{active ? (
<GeneratorRunner
projectId={project.id}
tool={active}
onClose={() => setActive(null)}
/>
) : sorted.length === 0 ? (
<p className="text-sm text-ink-soft"></p>
) : (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{sorted.map((tool) => (
<ToolCard key={tool.key} tool={tool} onOpen={onOpen} />
))}
</div>
)}
</div>
</AppShell>
);
}