Files
ttstd-infinite-canvas/web/src/lib/canvas/canvas-character-reference.ts

210 lines
11 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 normalizeCharacterImageMentions(prompt: string, imageCount: number, characterLabels: string[]) {
if (imageCount > 0 || !characterLabels.length) return prompt;
return prompt.replace(/@图片([1-9]\d*)(?=$|\s|[,.!?;:,。!?;:、)\]}】)])/g, (token, index: string) => {
const label = characterLabels[Number(index) - 1];
return label ? "@" + label : token;
});
}
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;
}
export function extractCharacterBreakdownJson(raw: string, allowEmptyCharacters = false) {
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;
// 章节资产允许无人场景;仍交由章节解析器校验三个必填数组。
if (allowEmptyCharacters && parsed && typeof parsed === "object" && !Array.isArray(parsed)
&& Array.isArray((parsed as { characters?: unknown }).characters)) 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;
}