fix(canvas): 角色提取 - 兼容带推理文本的 JSON 输出 (#164)
This commit is contained in:
@@ -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 ."
|
||||
|
||||
@@ -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 数组");
|
||||
|
||||
|
||||
45
web/test/canvas-character-reference.test.ts
Normal file
45
web/test/canvas-character-reference.test.ts
Normal 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(["林夏", "吴奶奶"]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user