Files
Yaojia Wang 7babb4854b feat(web): 角色群像按定位配比生成(role_mix 缩减版)
- 契约: 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 单一/配比双模
2026-07-06 17:24:16 +02:00

214 lines
7.4 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 生成(世界观/角色)+ 入库的纯逻辑请求体组装、卡片归一、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_mixcount 取各值之和;与后端归一化一致。
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 gate409 后重发)。
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 收窄 ——
// 后端入库 gateC3 扩 T5.2details{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}${e.name}`;
const seen = new Set(persisted.map(key));
const appended = sessionEntities.filter((e) => !seen.has(key(e)));
return [...persisted, ...appended];
}