From 2b0d56f8e3236369709ac9d146a901c91ce983d5 Mon Sep 17 00:00:00 2001 From: dyhhhhh <63825522+dyhhhhhh@users.noreply.github.com> Date: Fri, 7 Aug 2026 10:27:02 +0800 Subject: [PATCH] =?UTF-8?q?fix(canvas):=20=E8=A7=92=E8=89=B2=E6=8F=90?= =?UTF-8?q?=E5=8F=96=20-=20=E5=85=BC=E5=AE=B9=E5=B8=A6=E6=8E=A8=E7=90=86?= =?UTF-8?q?=E6=96=87=E6=9C=AC=E7=9A=84=20JSON=20=E8=BE=93=E5=87=BA=20(#164?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/package.json | 2 +- .../lib/canvas/canvas-character-reference.ts | 62 +++++++++++++++---- web/test/canvas-character-reference.test.ts | 45 ++++++++++++++ 3 files changed, 97 insertions(+), 12 deletions(-) create mode 100644 web/test/canvas-character-reference.test.ts diff --git a/web/package.json b/web/package.json index cd9dc5834..346ee1b21 100644 --- a/web/package.json +++ b/web/package.json @@ -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 ." diff --git a/web/src/lib/canvas/canvas-character-reference.ts b/web/src/lib/canvas/canvas-character-reference.ts index faf776d25..c22aad1e8 100644 --- a/web/src/lib/canvas/canvas-character-reference.ts +++ b/web/src/lib/canvas/canvas-character-reference.ts @@ -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 数组"); diff --git a/web/test/canvas-character-reference.test.ts b/web/test/canvas-character-reference.test.ts new file mode 100644 index 000000000..26fd85406 --- /dev/null +++ b/web/test/canvas-character-reference.test.ts @@ -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(["林夏", "吴奶奶"]); + }); +});