- 契约: CharacterGenerateRequest 加 role_mix{定位:数量},model_validator 归一
count=各值之和(每项≥1、总和≤12),缺省退回单一 role+count
- @llm: build_character_gen_context/run_character_gen 加 role_mix 参并注入配比块
(确定性、无时间戳,守 assemble 纯函数);router 穿参
- character-gen.md 加"按配比严格分配 role"纪律 + regen prompt_hashes 金标准
- 前端: sanitizeRoleMix/roleMixTotal + buildCharacterGenerateRequest 第4参 +
useCharacterGen 穿参 + RoleMixEditor + CharacterGenerator 单一/配比双模
214 lines
7.4 KiB
TypeScript
214 lines
7.4 KiB
TypeScript
// 生成(世界观/角色)+ 入库的纯逻辑:请求体组装、卡片归一、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<string, number> {
|
||
const out: Record<string, number> = {};
|
||
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<Record<string, number>>): number {
|
||
return Object.values(mix).reduce((sum, n) => sum + n, 0);
|
||
}
|
||
|
||
export function buildCharacterGenerateRequest(
|
||
brief: string,
|
||
count: number,
|
||
role?: string | null,
|
||
roleMix?: Readonly<Record<string, number>> | 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<CharacterCardView>,
|
||
patch: Partial<CharacterCardView>,
|
||
): 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<string, unknown>;
|
||
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} |