// 生成(世界观/角色)+ 入库的纯逻辑:请求体组装、卡片归一、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"); } function asOptionalString(v: unknown): string | null { return typeof v === "string" ? v : null; } // —— 世界观生成 —— 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; } // —— 群像按定位配比(⑦)—— // role_mix = {定位: 数量};在场时后端把 count 定为各值之和(每项≥1、总和≤MAX)。 // 单个定位数量 clamp:非有限/非正 → 1,四舍五入取整。 function clampRoleCount(count: number): number { if (!Number.isFinite(count)) return MIN_CHARACTER_COUNT; const n = Math.round(count); return n < MIN_CHARACTER_COUNT ? MIN_CHARACTER_COUNT : n; } // 把 UI 配比行 sanitize 为 {定位: 数量}:去空定位、去重(先到先得)、数量 clamp 到 ≥1、 // 按总额 MAX_CHARACTER_COUNT 截断(超额的行只填剩余额度,再超则丢弃)。保插入序。 export function sanitizeRoleMix( rows: ReadonlyArray<{ role: string; count: number }>, ): Record { const out: Record = {}; let total = 0; for (const row of rows) { const role = row.role.trim(); if (!role || role in out) continue; const remaining = MAX_CHARACTER_COUNT - total; if (remaining <= 0) break; const n = Math.min(clampRoleCount(row.count), remaining); out[role] = n; total += n; } return out; } export function roleMixTotal(mix: Readonly>): number { return Object.values(mix).reduce((sum, n) => sum + n, 0); } export function buildCharacterGenerateRequest( brief: string, count: number, role?: string | null, roleMix?: Readonly> | null, ): CharacterGenerateRequest { // 配比在场(非空)→ 用 role_mix,count 取各值之和;与后端归一化一致。 if (roleMix && Object.keys(roleMix).length > 0) { return { brief: brief.trim(), count: roleMixTotal(roleMix), role_mix: { ...roleMix }, }; } 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: "", original: null, replacement: null, }; } const c = raw as Record; return { type: asString(c["type"], "未分类"), where: asString(c["where"]), refs: asStringArray(c["refs"]), suggestion: asString(c["suggestion"]), original: asOptionalString(c["original"]), replacement: asOptionalString(c["replacement"]), }; } // 从错误信封提取 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]; }