// 生成(世界观/角色)+ 入库的纯逻辑:请求体组装、卡片归一、409 冲突收窄。 // 对齐 C3 扩(world/characters generate + characters ingest)。纯逻辑,便于 node 环境单测。 import type { CharacterCardView, CharacterGenerateRequest, CharacterIngestRequest, WorldEntityCardView, WorldGenerateRequest, } from "@/lib/api/types"; import type { ReviewConflict } from "@/lib/review/sse"; // 角色数量边界(对齐后端 CharacterGenerateRequest ge=1,le=12)。 export const MIN_CHARACTER_COUNT = 1; export const MAX_CHARACTER_COUNT = 12; function asString(v: unknown, fallback = ""): string { return typeof v === "string" ? v : fallback; } function asStringArray(v: unknown): string[] { if (!Array.isArray(v)) return []; return v.filter((x): x is string => typeof x === "string"); } // —— 世界观生成 —— export function buildWorldGenerateRequest(brief: string): WorldGenerateRequest { return { brief: brief.trim() }; } // —— 角色生成 —— export function clampCharacterCount(count: number): number { if (!Number.isFinite(count)) return MIN_CHARACTER_COUNT; const n = Math.round(count); if (n < MIN_CHARACTER_COUNT) return MIN_CHARACTER_COUNT; if (n > MAX_CHARACTER_COUNT) return MAX_CHARACTER_COUNT; return n; } export function buildCharacterGenerateRequest( brief: string, count: number, role?: string | null, ): CharacterGenerateRequest { const body: CharacterGenerateRequest = { brief: brief.trim(), count: clampCharacterCount(count), }; const trimmedRole = role?.trim(); if (trimmedRole) body.role = trimmedRole; return body; } // 把作者勾选的角色卡组装成入库请求;acknowledge 用于越过 continuity gate(409 后重发)。 export function buildCharacterIngestRequest( cards: readonly CharacterCardView[], acknowledgeConflicts = false, ): CharacterIngestRequest { return { cards: cards.map((c) => ({ ...c })), acknowledge_conflicts: acknowledgeConflicts, }; } // 入库前作者可编辑角色卡:返回新卡(不可变更新)。 export function updateCard( card: Readonly, patch: Partial, ): CharacterCardView { return { ...card, ...patch }; } // —— 409 CONFLICT_UNRESOLVED 收窄 —— // 后端入库 gate(C3 扩 T5.2):details{conflicts:[{type,where,refs,suggestion}], conflict_count}。 // 复用 ReviewConflict 形(同 accept gate)。安全收窄松散 unknown。 export interface IngestConflicts { conflicts: ReviewConflict[]; conflictCount: number; } function narrowConflict(raw: unknown): ReviewConflict { if (typeof raw !== "object" || raw === null) { return { type: "未分类", where: "", refs: [], suggestion: "" }; } const c = raw as Record; return { type: asString(c["type"], "未分类"), where: asString(c["where"]), refs: asStringArray(c["refs"]), suggestion: asString(c["suggestion"]), }; } // 从错误信封提取 409 冲突详情;非冲突错误 → null。 export function extractIngestConflicts(error: unknown): IngestConflicts | null { if (typeof error !== "object" || error === null) return null; const env = error as { error?: { code?: unknown; details?: { conflicts?: unknown; conflict_count?: unknown } | null; }; }; if (env.error?.code !== "CONFLICT_UNRESOLVED") return null; const details = env.error?.details; if (typeof details !== "object" || details === null) return null; const rawConflicts = Array.isArray(details.conflicts) ? details.conflicts : []; const conflicts = rawConflicts.map(narrowConflict); const count = typeof details.conflict_count === "number" ? details.conflict_count : conflicts.length; return { conflicts, conflictCount: count }; } // 通用错误码提取(503 LLM_UNAVAILABLE 等)。 export function errorCode(error: unknown): string | undefined { if (typeof error !== "object" || error === null) return undefined; const env = error as { error?: { code?: unknown } }; return typeof env.error?.code === "string" ? env.error.code : undefined; } // 生成预览失败的统一文案(503 无凭据引导去设置)。 export function generationErrorMessage(error: unknown): string { return errorCode(error) === "LLM_UNAVAILABLE" ? "未配置提供商,请先去设置页连一家。" : "生成失败,请稍后重试。"; } // 世界观实体卡展示用:rules 安全取数组。 export function worldEntityRules(entity: WorldEntityCardView): string[] { return entity.rules ?? []; } // —— 设定库 Codex 列表合并 —— // 初始持久化列表(GET .../characters)+ 本会话新入库卡:按 name 去重(持久化优先, // 同名以初始列表为准——避免刚入库的回显与刷新后真源重复显示)。保序:持久化在前、新卡补后。 export function mergeCharacterCards( persisted: readonly CharacterCardView[], sessionCards: readonly CharacterCardView[], ): CharacterCardView[] { const seen = new Set(persisted.map((c) => c.name)); const appended = sessionCards.filter((c) => !seen.has(c.name)); return [...persisted, ...appended]; } // 世界观实体列表合并:同上,按 (type,name) 去重。 export function mergeWorldEntities( persisted: readonly WorldEntityCardView[], sessionEntities: readonly WorldEntityCardView[], ): WorldEntityCardView[] { const key = (e: WorldEntityCardView) => `${e.type}${e.name}`; const seen = new Set(persisted.map(key)); const appended = sessionEntities.filter((e) => !seen.has(key(e))); return [...persisted, ...appended]; }