Files
writer-work-flow/apps/web/lib/toolbox/toolbox.ts
Yaojia Wang 1a402f5ccc feat(web): F1/F2/F3 前端——拆书入库为规则 / 续写式链选择 / 模板库
- gen:api 纳入 /templates、chain continue_volume、teardown rules ingest 端点
- F1:GeneratorRunner 识别拆书为单对象入库(整 preview 作一条 teardown),
  ingestTable(BookTeardownResult)=rules,buildIngestRequest 组装 teardown 体,
  按 isSingleObjectIngest 隐藏勾选框、改文案「入库为规则」
- F2:ChainStarter 加链类型单选(draft_volume 从头写 / continue_volume 续写),
  chain_key 作 path 参传给 run(CHAIN_KINDS 对齐后端 SUPPORTED_CHAINS)
- F3:模板库页 app/templates + 全局 nav 入口 + TemplatesManager(列/建/删,乐观更新回滚);
  GeneratorRunner 加 TemplateFiller「从模板填入」(body 填进 brief/text,纯前端)
- vitest 覆盖 templates/ingest teardown/templateFillTarget/CHAIN_KINDS 纯逻辑
2026-06-23 20:29:48 +02:00

323 lines
11 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;
// 原文输入(扩写/降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 {
// 用于「入库」时贴对应 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续写/扩写/降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<string, unknown> | 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 缺失/正文为空 → 返 nullmapPreview 据此给空 items不渲空卡
function singleObjectItem(
outputKind: string,
preview: Record<string, unknown> | 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<string, unknown>): 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<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 };
}
// —— 模板填入目标 ——
// 模板 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<ToolDescriptorView>): boolean {
return tool.is_legacy && !!tool.legacy_route;
}