// 创作工具箱纯逻辑:声明式表单值 → 请求体组装、字段校验、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; // —— 表单初值 —— // 从 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; // 原文输入(扩写/降AI/拆书样本,text_input 策略):非空才带(空白省略)。 const text = values["text"]?.trim(); if (text) body.text = text; 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 { // 用于「入库」时贴对应 schema(world_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[] { if (!Array.isArray(v)) return []; return v.filter( (x): x is Record => typeof x === "object" && x !== null, ); } // 取 preview 对象里第一个数组型字段(各工具的列表键名不同:ideas/titles/variants/names/...)。 function firstArrayEntry( preview: Record | 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, 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): PreviewField[] { const rules = asStringArray(row["rules"]); return rules.length > 0 ? [{ label: "硬规则", value: rules.join(";") }] : []; } // 取 preview 里的原始记录数组(供入库形变;与 mapPreview 同源,保证选中下标对齐)。 export function previewRows( preview: Record | undefined, ): Record[] { return asRecordArray(firstArrayEntry(preview)); } // 单对象产物(非列表)的 output_kind:续写/扩写/降AI(纯正文 {text})+ 拆书(结构化对象)。 // 这些 preview 本身就是一个对象(无顶层数组),故不走 firstArrayEntry 的「记录列表」假设, // 而归一为「单张卡」(Scope B 竞品快赢生成器)。 const SINGLE_OBJECT_KINDS = new Set([ "ContinuationResult", "PolishResult", "DeAiResult", "BookTeardownResult", ]); // output_kind → 行渲染策略。未知 kind 走兜底(heading=首个字符串字段,其余键值列出)。 export function mapPreview( outputKind: string, preview: Record | undefined, ): PreviewModel { if (SINGLE_OBJECT_KINDS.has(outputKind)) { const item = singleObjectItem(outputKind, preview); return { kind: outputKind, items: item ? [item] : [] }; } const rows = asRecordArray(firstArrayEntry(preview)); const items = rows.map((row) => previewItem(outputKind, row)); return { kind: outputKind, items }; } // 把单对象产物归一为一张预览卡:纯正文类 → body;拆书 → structure 作 heading + 列表字段。 // preview 缺失/正文为空 → 返 null(mapPreview 据此给空 items,不渲空卡)。 function singleObjectItem( outputKind: string, preview: Record | undefined, ): PreviewItem | null { if (!preview) return null; if (outputKind === "BookTeardownResult") { return teardownItem(preview); } const text = asString(preview["text"]); return text ? { heading: null, fields: [], body: text } : null; } // 拆书结构化产物 → 一张卡:structure 作标题,themes/archetypes/hooks 各拼成一行字段。 function teardownItem(preview: Record): PreviewItem { const listField = (key: string, label: string): PreviewField[] => { const joined = asStringArray(preview[key]).join(";"); return joined ? [{ label, value: joined }] : []; }; return { heading: asString(preview["structure"]) || null, fields: [ ...listField("themes", "主题"), ...listField("archetypes", "人物原型"), ...listField("hooks", "钩子套路"), ], body: null, }; } function previewItem( outputKind: string, row: Record, ): 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): 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 }; } // —— 模板填入目标 —— // 模板 body 一键填入的目标输入字段:优先 brief(多数生成器主输入),否则 text(原文输入类)。 // 都没有则 null(不渲染「从模板填入」)。F3:纯前端,不改生成器后端。 const TEMPLATE_FILL_FIELDS = ["brief", "text"] as const; export function templateFillTarget( fields: readonly ToolInputFieldView[] | undefined, ): string | null { const names = new Set((fields ?? []).map((f) => f.name)); for (const candidate of TEMPLATE_FILL_FIELDS) { if (names.has(candidate)) return candidate; } return 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): boolean { return tool.is_legacy && !!tool.legacy_route; }