Files
ttstd-infinite-canvas/web/src/lib/canvas/canvas-character-reference.ts
liang007 df2821e1f8 fix(ai): 兼容模型返回的多种 JSON 顶层形态,修复角色卡与分镜解析误报失败
部分模型不遵守 JSON Schema:直接返回顶层数组、数组外再包一层、每个角色单独包一层 characters、或正文先列出角色名数组再给出契约对象。原解析只取第一个可解析 JSON 值,容易命中旁枝片段并被判为解析失败。

- prompt_template.go: extractPreferredJSONText 优先选取包含 preferKey 的顶层对象候选;角色卡校验按形态归一处理,结果为空仍提示未识别到角色,字段缺失报出具体角色序号与字段名
- storyboard.go: normalizeStoryboardRootJSON 兼容单元素 plan 数组解包与镜头对象数组自动包装为完整 plan
- canvas-character-reference.ts: 前端角色卡提取同步统一归一逻辑
- 补充前后端形态兼容回归测试与 CHANGELOG
2026-09-01 17:32:38 +08:00

199 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
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.
import { resourceFileUrl } from "@/services/api/resources";
import type { ProjectAsset } from "@/services/api/projects";
import type { CanvasNodeData } from "@/types/canvas";
export type CharacterBreakdown = {
name: string;
aliases: string[];
role: string;
appearance: string;
clothing: string;
physique: string;
personality: string;
props: string;
consistencyPrompt: string;
multiViewPrompt: string;
voiceLanguage: string;
voiceAge: string;
voiceTimbre: string;
};
export function refreshCanvasCharacterReferenceNodes(nodes: CanvasNodeData[], assets: ProjectAsset[]) {
const characters = new Map(assets.filter((asset) => asset.category === "character" && asset.character).map((asset) => [asset.id, asset]));
let changed = false;
const next = nodes.map((node) => {
const metadata = node.metadata;
const assetId = metadata?.workflowKind === "character" ? metadata.characterAssetId : "";
const asset = assetId ? characters.get(assetId) : undefined;
if (!metadata || !asset?.character || metadata.characterVersionPolicy === "pinned") return node;
const card = asset.character;
const cover = card.representations.find((item) => item.role === "turnaround_sheet") || card.representations.find((item) => item.role === "primary") || card.representations.find((item) => item.role === "front");
const aliases = Array.isArray(card.definition.aliases) ? card.definition.aliases.filter((value): value is string => typeof value === "string") : [];
const patch = {
characterVersionId: card.versionId,
characterName: asset.title,
characterPrompt: compileCharacterReferencePrompt(asset.title, card.definition),
characterAliases: aliases,
characterDefinition: card.definition,
characterCoverUrl: cover ? resourceFileUrl(cover.resourceId) : undefined,
characterVisualStatus: card.visualStatus,
characterVoiceStatus: card.voiceStatus,
characterVoiceName: card.voice?.profile.name,
characterVoiceProfile: card.voice ? {
name: card.voice.profile.name,
provider: card.voice.profile.provider,
language: card.voice.profile.language,
timbre: card.voice.profile.timbre,
} : undefined,
characterVoiceInstructions: card.voice?.instructions,
};
if (node.title === asset.title
&& metadata.characterVersionId === patch.characterVersionId
&& metadata.characterPrompt === patch.characterPrompt
&& metadata.characterCoverUrl === patch.characterCoverUrl
&& metadata.characterVisualStatus === patch.characterVisualStatus
&& metadata.characterVoiceStatus === patch.characterVoiceStatus
&& metadata.characterVoiceName === patch.characterVoiceName
&& JSON.stringify(metadata.characterDefinition) === JSON.stringify(patch.characterDefinition)
&& JSON.stringify(metadata.characterVoiceProfile) === JSON.stringify(patch.characterVoiceProfile)
&& metadata.characterVoiceInstructions === patch.characterVoiceInstructions
&& (metadata.characterAliases || []).join("\u0000") === aliases.join("\u0000")) return node;
changed = true;
return { ...node, title: asset.title, metadata: { ...metadata, ...patch } };
});
return changed ? next : nodes;
}
export function compileCharacterReferencePrompt(name: string, definition: Record<string, unknown>) {
const parts = [definition.role, definition.appearance, definition.physique, definition.clothing, definition.personality, definition.props, definition.consistencyPrompt]
.map((value) => typeof value === "string" ? value.trim() : "")
.filter(Boolean);
return [`【角色卡:${name}】`, ...parts].join("\n");
}
export function normalizeCharacterName(value?: string) {
return (value || "").toLocaleLowerCase("zh-CN").replace(/^角色[::]\s*/, "").replace(/[\s·•・._-]+/g, "").trim();
}
function findJsonValueEnd(source: string, start: number) {
const stack: string[] = [];
let inString = false;
let escaped = false;
for (let index = start; index < source.length; index += 1) {
const character = source[index];
if (inString) {
if (escaped) {
escaped = false;
} else if (character === "\\") {
escaped = true;
} else if (character === '"') {
inString = false;
}
continue;
}
if (character === '"') {
inString = true;
continue;
}
if (character === "{" || character === "[") {
stack.push(character);
continue;
}
if (character !== "}" && character !== "]") continue;
const opener = stack.pop();
if ((character === "}" && opener !== "{") || (character === "]" && opener !== "[")) return -1;
if (!stack.length) return index;
}
return -1;
}
// 角色卡数组的最小特征:至少有一个元素带 name 字段。用于把真正的角色数组和模型正文里的
// 旁枝数组(角色名列表、aliases 片段等)区分开,避免提取阶段命中错误片段。
function isCharacterCardArray(value: unknown): value is unknown[] {
if (!Array.isArray(value) || !value.length) return false;
return value.some((item) => Boolean(item) && typeof item === "object" && typeof (item as Record<string, unknown>).name === "string");
}
// 模型偶尔把角色数组再包一层,或把 characters 写成以角色名为键的对象,这里统一摊平成候选列表。
function flattenCharacterCandidates(value: unknown): unknown[] | undefined {
if (Array.isArray(value)) {
const result: unknown[] = [];
value.forEach((item) => {
if (Array.isArray(item)) {
result.push(...(flattenCharacterCandidates(item) ?? []));
return;
}
result.push(item);
});
return result;
}
if (value && typeof value === "object") return Object.values(value as Record<string, unknown>);
return undefined;
}
function extractCharacterBreakdownJson(raw: string) {
for (let start = 0; start < raw.length; start += 1) {
if (raw[start] !== "{" && raw[start] !== "[") continue;
const end = findJsonValueEnd(raw, start);
if (end < start) continue;
try {
const parsed: unknown = JSON.parse(raw.slice(start, end + 1));
// 顶层角色数组是常见的契约偏离,先判数组再判对象,两者都视为合法载荷。
if (isCharacterCardArray(parsed)) return parsed;
// 模型的推理文字可能包含 aliases: [] 等合法 JSON 片段;角色契约只接受带 characters 字段的对象。
const candidates = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? flattenCharacterCandidates((parsed as { characters?: unknown }).characters) : undefined;
if (Array.isArray(candidates) && candidates.length) return parsed;
} catch {
// Ignore unrelated braces in model prose and continue to the next complete JSON value.
}
}
throw new Error("角色拆解没有返回符合契约的 JSON");
}
export function parseCharacterBreakdown(raw: string): CharacterBreakdown[] {
const unfenced = raw.trim().replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/i, "");
const parsed = extractCharacterBreakdownJson(unfenced);
const candidates = Array.isArray(parsed) ? flattenCharacterCandidates(parsed) : flattenCharacterCandidates((parsed as { characters?: unknown }).characters);
if (!Array.isArray(candidates)) throw new Error("角色拆解结果缺少 characters 数组");
const seen = new Set<string>();
const characters: CharacterBreakdown[] = [];
candidates.forEach((candidate) => {
if (!candidate || typeof candidate !== "object") return;
const value = candidate as Record<string, unknown>;
const name = String(value.name || "").trim();
const key = normalizeCharacterName(name);
const aliases = Array.isArray(value.aliases) ? value.aliases.map((alias) => String(alias).trim()).filter(Boolean) : [];
const identityKeys = [key, ...aliases.map(normalizeCharacterName)].filter(Boolean);
if (!name || !key || identityKeys.some((identityKey) => seen.has(identityKey))) return;
const role = String(value.role || "").trim();
const descriptiveFields = [value.appearance, value.clothing, value.physique, value.personality, value.consistencyPrompt, value.multiViewPrompt]
.map((field) => String(field || "").trim())
.filter(Boolean);
const voiceLanguage = String(value.voiceLanguage || "").trim();
const voiceAge = String(value.voiceAge || "").trim();
const voiceTimbre = String(value.voiceTimbre || "").trim();
// AI 提取属于角色写入路径:只有名称不足以建立角色卡,避免空设定进入项目后再由用户猜测补全。
if (!role || descriptiveFields.length < 3 || !voiceLanguage || !voiceAge || !voiceTimbre) throw new Error(`角色“${name}”缺少剧情定位、稳定设定或声音画像,请重新提取`);
identityKeys.forEach((identityKey) => seen.add(identityKey));
characters.push({
name,
aliases: Array.from(new Set(aliases.filter((alias) => normalizeCharacterName(alias) !== key))),
role,
appearance: String(value.appearance || "").trim(),
clothing: String(value.clothing || "").trim(),
physique: String(value.physique || "").trim(),
personality: String(value.personality || "").trim(),
props: String(value.props || "").trim(),
consistencyPrompt: String(value.consistencyPrompt || "").trim(),
multiViewPrompt: String(value.multiViewPrompt || "").trim(),
voiceLanguage,
voiceAge,
voiceTimbre,
});
});
if (!characters.length) throw new Error("没有从章节正文中识别到可用角色");
return characters;
}