Files
writer-work-flow/apps/web/lib/toolbox/toolbox.ts
Yaojia Wang f43ccd293f 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>
2026-06-22 20:37:55 +02:00

260 lines
8.6 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.

// 创作工具箱纯逻辑:声明式表单值 → 请求体组装、字段校验、output_kind → 预览渲染模型、legacy 路由解析。
// 对齐通用 toolbox 契约GET /skills/toolbox + generate/ingest。纯逻辑便于 node 环境单测。
// 关键边界UI 不硬编码每个工具的表单/预览,全部由 descriptor + 此处映射驱动("加生成器=加声明")。
import type {
ToolDescriptorView,
ToolGenerateRequest,
ToolInputFieldView,
} from "@/lib/api/types";
// 声明式表单字段值(控件原始值;提交前经此处归一为 ToolGenerateRequest
export type FieldValues = Record<string, string>;
// —— 表单初值 ——
// 从 descriptor 的 input_fields 取默认值组装初始表单状态(缺省值用空串,保证受控输入)。
export function initialFieldValues(
fields: readonly ToolInputFieldView[] | undefined,
): FieldValues {
const values: FieldValues = {};
for (const field of fields ?? []) {
values[field.name] = field.default ?? "";
}
return values;
}
// —— 校验 ——
// 必填字段缺失 → 返回字段名列表(空数组=通过。number 类型仅校验非空(值合法性交后端)。
export function missingRequiredFields(
fields: readonly ToolInputFieldView[] | undefined,
values: FieldValues,
): string[] {
const missing: string[] = [];
for (const field of fields ?? []) {
if (!field.required) continue;
if ((values[field.name] ?? "").trim().length === 0) {
missing.push(field.label);
}
}
return missing;
}
// —— 请求体组装 ——
// 已知入参字段brief/chapter_no/count/kind按 ToolGenerateRequest snake_case 取用;
// 其余字段名忽略descriptor 的 input_fields 命名须落在该联合内。number 安全解析,非法→省略。
const KNOWN_NUMBER_FIELDS = ["chapter_no", "count"] as const;
function parseOptionalNumber(raw: string | undefined): number | null {
if (raw === undefined) return null;
const trimmed = raw.trim();
if (trimmed.length === 0) return null;
const n = Number(trimmed);
return Number.isFinite(n) ? n : null;
}
export function buildGenerateRequest(values: FieldValues): ToolGenerateRequest {
const body: ToolGenerateRequest = { brief: (values["brief"] ?? "").trim() };
for (const key of KNOWN_NUMBER_FIELDS) {
const n = parseOptionalNumber(values[key]);
if (n !== null) body[key] = n;
}
const kind = values["kind"]?.trim();
if (kind) body.kind = kind;
return body;
}
// —— output_kind → 预览渲染模型 ——
// 把后端结构化产物preview: 任意 schema 的 model_dump归一为「卡片列表」渲染模型
// 组件据此渲染(小而稳的 switch + 兜底),无需为每个工具写专属组件。
export interface PreviewField {
label: string;
value: string;
}
export interface PreviewItem {
// 标题行(如脑洞 premise / 书名 title / 实体 name可空纯文本变体
heading: string | null;
// 次要字段angle/rationale/mechanism…逐行展示。
fields: PreviewField[];
// 大段正文(开篇/简介变体的 text独占一段。
body: string | null;
}
export interface PreviewModel {
// 用于「入库」时贴对应 schemaworld_entities / outline scenes
kind: string;
items: PreviewItem[];
}
function asString(v: unknown): string {
if (typeof v === "string") return v;
if (typeof v === "number" || typeof v === "boolean") return String(v);
return "";
}
function asStringArray(v: unknown): string[] {
if (!Array.isArray(v)) return [];
return v.filter((x): x is string => typeof x === "string");
}
function asRecordArray(v: unknown): Record<string, unknown>[] {
if (!Array.isArray(v)) return [];
return v.filter(
(x): x is Record<string, unknown> => typeof x === "object" && x !== null,
);
}
// 取 preview 对象里第一个数组型字段各工具的列表键名不同ideas/titles/variants/names/...)。
function firstArrayEntry(
preview: Record<string, unknown> | undefined,
): unknown[] {
if (!preview) return [];
for (const value of Object.values(preview)) {
if (Array.isArray(value)) return value;
}
return [];
}
// 把一条记录的若干「次要字段」按 (key,label) 映射收集为 PreviewField缺省字段跳过
function collectFields(
row: Record<string, unknown>,
specs: readonly { key: string; label: string }[],
): PreviewField[] {
const fields: PreviewField[] = [];
for (const spec of specs) {
const value = asString(row[spec.key]);
if (value) fields.push({ label: spec.label, value });
}
return fields;
}
function rulesToField(row: Record<string, unknown>): PreviewField[] {
const rules = asStringArray(row["rules"]);
return rules.length > 0
? [{ label: "硬规则", value: rules.join("") }]
: [];
}
// 取 preview 里的原始记录数组(供入库形变;与 mapPreview 同源,保证选中下标对齐)。
export function previewRows(
preview: Record<string, unknown> | undefined,
): Record<string, unknown>[] {
return asRecordArray(firstArrayEntry(preview));
}
// output_kind → 行渲染策略。未知 kind 走兜底heading=首个字符串字段,其余键值列出)。
export function mapPreview(
outputKind: string,
preview: Record<string, unknown> | undefined,
): PreviewModel {
const rows = asRecordArray(firstArrayEntry(preview));
const items = rows.map((row) => previewItem(outputKind, row));
return { kind: outputKind, items };
}
function previewItem(
outputKind: string,
row: Record<string, unknown>,
): PreviewItem {
switch (outputKind) {
case "IdeaListResult":
return {
heading: asString(row["premise"]) || null,
fields: collectFields(row, [
{ key: "hook", label: "爽点" },
{ key: "genre_fit", label: "题材契合" },
]),
body: null,
};
case "TitleListResult":
return {
heading: asString(row["title"]) || null,
fields: collectFields(row, [{ key: "rationale", label: "立意" }]),
body: null,
};
case "BlurbResult":
return {
heading: asString(row["angle"]) || null,
fields: [],
body: asString(row["text"]) || null,
};
case "NameListResult":
return {
heading: asString(row["name"]) || null,
fields: collectFields(row, [
{ key: "kind", label: "类别" },
{ key: "note", label: "说明" },
]),
body: null,
};
case "GoldenFingerResult":
return {
heading: asString(row["name"]) || null,
fields: collectFields(row, [
{ key: "mechanism", label: "机制" },
{ key: "growth", label: "成长" },
{ key: "limits", label: "限制" },
]),
body: null,
};
case "GlossaryResult":
return {
heading: asString(row["name"]) || null,
fields: [
...collectFields(row, [
{ key: "type", label: "类型" },
{ key: "definition", label: "释义" },
]),
...rulesToField(row),
],
body: null,
};
case "OpeningResult":
return { heading: null, fields: [], body: asString(row["text"]) || null };
case "DetailedOutlineResult":
return {
heading: asString(row["beat"]) || null,
fields: collectFields(row, [
{ key: "idx", label: "序" },
{ key: "purpose", label: "作用" },
{ key: "conflict", label: "冲突" },
{ key: "hook", label: "钩子" },
]),
body: null,
};
default:
return fallbackItem(row);
}
}
// 兜底:首个字符串字段当标题,其余键值平铺(未知 output_kind 也能展示,不报错)。
function fallbackItem(row: Record<string, unknown>): PreviewItem {
const entries = Object.entries(row);
let heading: string | null = null;
const fields: PreviewField[] = [];
for (const [key, raw] of entries) {
const value = Array.isArray(raw) ? asStringArray(raw).join("") : asString(raw);
if (!value) continue;
if (heading === null) {
heading = value;
} else {
fields.push({ label: key, value });
}
}
return { heading, fields, body: null };
}
// —— legacy 路由解析 ——
// legacy 工具的 legacy_route 含 {id} 占位(如 /projects/{id}/codex?gen=world→ 替换为真实 projectId。
export function resolveLegacyRoute(
route: string,
projectId: string,
): string {
return route.replace(/\{id\}/g, projectId);
}
// 判定工具点击应导航legacy还是开 runner新工具
export function isLegacyTool(tool: Readonly<ToolDescriptorView>): boolean {
return tool.is_legacy && !!tool.legacy_route;
}