337 lines
12 KiB
TypeScript
337 lines
12 KiB
TypeScript
// 创作工具箱纯逻辑:声明式表单值 → 请求体组装、字段校验、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 {
|
||
// 用于「入库」时贴对应 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<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 缺失/正文为空 → 返 null(mapPreview 据此给空 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;
|
||
}
|
||
|
||
// —— 「关联工具」下拉选项 ——
|
||
// 工具描述符 → 下拉选项(value=key 用于 tool_key 关联,label=中文标题供展示,不暴露英文 key)。
|
||
// 模板库「关联生成器」字段据此从纯文本框升级为 Select。
|
||
export interface ToolKeyOption {
|
||
key: string;
|
||
title: string;
|
||
}
|
||
|
||
export function toolKeyOptions(
|
||
tools: readonly ToolDescriptorView[] | undefined,
|
||
): ToolKeyOption[] {
|
||
return (tools ?? []).map((tool) => ({ key: tool.key, title: tool.title }));
|
||
}
|