- 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 纯逻辑
143 lines
4.9 KiB
TypeScript
143 lines
4.9 KiB
TypeScript
// 工具箱入库纯逻辑:把结构化预览产物按 output_kind 形变为 ToolIngestRequest 的入库项。
|
||
// 复用既有入库双 gate(continuity 预检 409 + partition_writes 白名单),故仅负责 body 组装。
|
||
// 对齐契约:world_entities(金手指/词条)贴 WorldEntityCardView;outline(细纲)贴 OutlineSceneIngestView。
|
||
|
||
import type {
|
||
OutlineSceneIngestView,
|
||
TeardownIngestView,
|
||
ToolIngestRequest,
|
||
WorldEntityCardView,
|
||
} from "@/lib/api/types";
|
||
|
||
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 asInt(v: unknown, fallback: number): number {
|
||
if (typeof v === "number" && Number.isFinite(v)) return Math.round(v);
|
||
if (typeof v === "string") {
|
||
const n = Number(v.trim());
|
||
if (Number.isFinite(n)) return Math.round(n);
|
||
}
|
||
return fallback;
|
||
}
|
||
|
||
// 金手指 → world_entities:type 固定「力量体系」,mechanism/growth/limits 合并入 rules(硬规则供续审比对)。
|
||
function goldenFingerToEntity(
|
||
row: Record<string, unknown>,
|
||
): WorldEntityCardView {
|
||
const rules = [
|
||
asString(row["mechanism"]),
|
||
asString(row["growth"]),
|
||
asString(row["limits"]),
|
||
].filter((r) => r.length > 0);
|
||
return { type: "力量体系", name: asString(row["name"]), rules };
|
||
}
|
||
|
||
// 词条 → world_entities:保留 type/name,rules 取 rules(已是硬规则清单)。
|
||
function glossaryToEntity(row: Record<string, unknown>): WorldEntityCardView {
|
||
return {
|
||
type: asString(row["type"]) || "概念",
|
||
name: asString(row["name"]),
|
||
rules: asStringArray(row["rules"]),
|
||
};
|
||
}
|
||
|
||
// 细纲场景 → outline scenes:贴 OutlineSceneIngestView(idx/beat/purpose/conflict/hook)。
|
||
function rowToScene(
|
||
row: Record<string, unknown>,
|
||
fallbackIdx: number,
|
||
): OutlineSceneIngestView {
|
||
return {
|
||
idx: asInt(row["idx"], fallbackIdx),
|
||
beat: asString(row["beat"]),
|
||
purpose: asString(row["purpose"]),
|
||
conflict: asString(row["conflict"]),
|
||
hook: asString(row["hook"]),
|
||
};
|
||
}
|
||
|
||
// 拆书结构化产物 → TeardownIngestView(贴后端 schema;后端再拍平为可读 rules 条目)。
|
||
// 单对象产物:整个 preview 对象即一条入库项(无逐行勾选)。
|
||
function rowToTeardown(row: Record<string, unknown>): TeardownIngestView {
|
||
return {
|
||
themes: asStringArray(row["themes"]),
|
||
archetypes: asStringArray(row["archetypes"]),
|
||
structure: asString(row["structure"]),
|
||
hooks: asStringArray(row["hooks"]),
|
||
};
|
||
}
|
||
|
||
export interface IngestBuildInput {
|
||
outputKind: string;
|
||
// 作者勾选要入库的预览行(结构化产物原始记录)。
|
||
rows: readonly Record<string, unknown>[];
|
||
// 细纲入库的目标章号(outputKind=DetailedOutlineResult 时必填)。
|
||
chapterNo?: number | null;
|
||
acknowledgeConflicts?: boolean;
|
||
}
|
||
|
||
// 单对象入库的 output_kind:整个 preview 即一条入库项(无逐行勾选)。
|
||
// 当前仅拆书(结构化结论拍平为 rules)。行式产物(金手指/词条/细纲)不在此列。
|
||
const SINGLE_OBJECT_INGEST_KINDS = new Set(["BookTeardownResult"]);
|
||
|
||
// 该产物是否「单对象入库」(UI 据此隐藏勾选框、改文案、用整个 rawPreview 作唯一行)。
|
||
export function isSingleObjectIngest(outputKind: string): boolean {
|
||
return SINGLE_OBJECT_INGEST_KINDS.has(outputKind);
|
||
}
|
||
|
||
// 入库目标表(供 UI 文案 / 判定)。未知 → null(不可入库)。
|
||
export function ingestTable(outputKind: string): string | null {
|
||
switch (outputKind) {
|
||
case "GoldenFingerResult":
|
||
case "GlossaryResult":
|
||
return "world_entities";
|
||
case "DetailedOutlineResult":
|
||
return "outline";
|
||
case "BookTeardownResult":
|
||
return "rules";
|
||
default:
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// 组装通用入库请求;不可入库的 output_kind → null(调用方据此隐藏入库 UI)。
|
||
export function buildIngestRequest(
|
||
input: IngestBuildInput,
|
||
): ToolIngestRequest | null {
|
||
const acknowledge_conflicts = input.acknowledgeConflicts ?? false;
|
||
switch (input.outputKind) {
|
||
case "GoldenFingerResult":
|
||
return {
|
||
world_entities: input.rows.map(goldenFingerToEntity),
|
||
acknowledge_conflicts,
|
||
};
|
||
case "GlossaryResult":
|
||
return {
|
||
world_entities: input.rows.map(glossaryToEntity),
|
||
acknowledge_conflicts,
|
||
};
|
||
case "DetailedOutlineResult":
|
||
return {
|
||
chapter_no: input.chapterNo ?? null,
|
||
scenes: input.rows.map((row, i) => rowToScene(row, i)),
|
||
acknowledge_conflicts,
|
||
};
|
||
case "BookTeardownResult": {
|
||
// 单对象产物:取首行(整个拆书结论)作 teardown;无产物 → null。
|
||
const row = input.rows[0];
|
||
if (!row) return null;
|
||
return { teardown: rowToTeardown(row), acknowledge_conflicts };
|
||
}
|
||
default:
|
||
return null;
|
||
}
|
||
}
|