Files
ttstd-infinite-canvas/web/test/canvas-character-reference.test.ts
liang007 552f902c25 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

107 lines
4.6 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 { describe, expect, test } from "bun:test";
import { parseCharacterBreakdown } from "../src/lib/canvas/canvas-character-reference";
describe("parseCharacterBreakdown", () => {
test("忽略推理文字中的 JSON 碎片,读取最终角色契约对象", () => {
const result = `让我先分析正文。\n\naliases: [](正文未提供别名)\n\n最终结果:\n\n\`\`\`json
{
"characters": [
{
"name": "林夏",
"aliases": [],
"role": "女主角,26岁,加班晚归的都市上班族",
"appearance": "26岁东亚女性,面带疲态",
"clothing": "米色风衣,背深色帆布工作包",
"physique": "年轻女性常规体态",
"personality": "善良内敛,观察力强",
"props": "深色帆布工作包,磁吸小夜灯",
"consistencyPrompt": "26岁东亚女性,米色风衣,深色帆布工作包",
"multiViewPrompt": "正面、侧面、背面保持风衣和帆布包一致",
"voiceLanguage": "中文普通话",
"voiceAge": "26岁青年女性",
"voiceTimbre": "温和、略带疲惫感"
},
{
"name": "吴奶奶",
"aliases": [],
"role": "林夏的对门邻居,七十岁出头老人",
"appearance": "七十岁出头东亚老年女性,银灰短发",
"clothing": "深绿色针织开衫",
"physique": "老年女性体态",
"personality": "独立谨慎,感恩含蓄",
"props": "米色布袋和钥匙",
"consistencyPrompt": "银灰短发,深绿色针织开衫,米色布袋",
"multiViewPrompt": "正面、侧面、背面保持短发和开衫一致",
"voiceLanguage": "中文普通话",
"voiceAge": "七十岁出头老年女性",
"voiceTimbre": "语速偏慢的老年女性声线"
}
]
}
\`\`\``;
expect(parseCharacterBreakdown(result).map((character) => character.name)).toEqual(["林夏", "吴奶奶"]);
});
});
const characterCard = {
name: "林夏",
aliases: ["夏夏"],
role: "女主角,加班晚归的都市上班族",
appearance: "26岁东亚女性,短发",
clothing: "米色风衣",
physique: "年轻女性常规体态",
personality: "善良内敛",
props: "磁吸小夜灯",
consistencyPrompt: "短发,米色风衣",
multiViewPrompt: "正面、侧面、背面保持一致",
voiceLanguage: "中文普通话",
voiceAge: "26岁青年女性",
voiceTimbre: "温和",
};
describe("parseCharacterBreakdown 顶层形态兼容", () => {
test("接受顶层角色数组", () => {
expect(parseCharacterBreakdown(JSON.stringify([characterCard])).map((character) => character.name)).toEqual(["林夏"]);
});
test("接受数组外再包一层", () => {
expect(parseCharacterBreakdown(JSON.stringify([[characterCard]])).map((character) => character.name)).toEqual(["林夏"]);
});
test("接受每个角色单独包一层 characters", () => {
const payload = [{ characters: [characterCard] }];
expect(parseCharacterBreakdown(JSON.stringify(payload)).map((character) => character.name)).toEqual(["林夏"]);
});
test("接受 characters 写成以角色名为键的对象", () => {
const payload = { characters: { 林夏: characterCard } };
expect(parseCharacterBreakdown(JSON.stringify(payload)).map((character) => character.name)).toEqual(["林夏"]);
});
test("跳过数组里的非角色元素", () => {
const payload = [{ index: 1 }, characterCard];
expect(parseCharacterBreakdown(JSON.stringify(payload)).map((character) => character.name)).toEqual(["林夏"]);
});
test("正文先列角色名数组时仍取到最终契约对象", () => {
const result = `本章角色:["林夏","陈默"]\n\n${JSON.stringify({ characters: [characterCard] })}`;
expect(parseCharacterBreakdown(result).map((character) => character.name)).toEqual(["林夏"]);
});
test("顶层数组被 markdown 代码块包裹", () => {
const result = "```json\n" + JSON.stringify([characterCard]) + "\n```";
expect(parseCharacterBreakdown(result).map((character) => character.name)).toEqual(["林夏"]);
});
test("角色名单数组不是角色卡,继续向后寻找契约对象", () => {
const result = `旁白提到了名字:["林夏"]`;
expect(() => parseCharacterBreakdown(result)).toThrow(/符合契约的 JSON/);
});
test("角色缺少设定字段时报出角色名", () => {
const payload = [{ name: "林夏", role: "女主角" }];
expect(() => parseCharacterBreakdown(JSON.stringify(payload))).toThrow(/林夏/);
});
});