fix(canvas): 角色提取 - 兼容带推理文本的 JSON 输出 (#164)

This commit is contained in:
dyhhhhh
2026-08-07 10:27:02 +08:00
committed by GitHub
parent e8b18bdc1b
commit 2b0d56f8e3
3 changed files with 97 additions and 12 deletions

View File

@@ -7,7 +7,7 @@
"dev": "vite --host 0.0.0.0 --port 3000",
"build": "tsc --noEmit && vite build",
"typecheck": "tsc --noEmit",
"test": "bun test test/agent-prompt-cache.test.ts test/canvas-document.test.ts test/canvas-drawing-engine.test.ts test/canvas-generation-layout.test.ts test/canvas-leafer-viewport.test.ts test/client-id.test.ts",
"test": "bun test test/agent-prompt-cache.test.ts test/canvas-character-reference.test.ts test/canvas-document.test.ts test/canvas-drawing-engine.test.ts test/canvas-generation-layout.test.ts test/canvas-leafer-viewport.test.ts test/client-id.test.ts",
"start": "vite preview --host 0.0.0.0 --port 3000",
"format": "prettier --write .",
"format:check": "prettier --check ."

View File

@@ -75,19 +75,59 @@ 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;
}
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));
// 模型的推理文字可能包含 aliases: [] 等合法 JSON 片段;角色契约只接受带 characters 字段的对象。
const candidates = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as { characters?: unknown }).characters : undefined;
if (Array.isArray(candidates)) 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 starts = [unfenced.indexOf("{"), unfenced.indexOf("[")].filter((index) => index >= 0);
const start = starts.length ? Math.min(...starts) : -1;
const end = Math.max(unfenced.lastIndexOf("}"), unfenced.lastIndexOf("]"));
if (start < 0 || end < start) throw new Error("角色拆解没有返回可识别的 JSON");
let parsed: unknown;
try {
parsed = JSON.parse(unfenced.slice(start, end + 1));
} catch (error) {
throw new Error(`角色拆解结果格式不正确:${error instanceof Error ? error.message : "无法解析 JSON"}`);
}
const parsed = extractCharacterBreakdownJson(unfenced);
const candidates = Array.isArray(parsed) ? parsed : parsed && typeof parsed === "object" ? (parsed as { characters?: unknown }).characters : undefined;
if (!Array.isArray(candidates)) throw new Error("角色拆解结果缺少 characters 数组");

View File

@@ -0,0 +1,45 @@
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(["林夏", "吴奶奶"]);
});
});