From 91dcbbf5c5b833f1065db2ecd9c756614637b92d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 22 Jun 2026 09:29:35 +0000 Subject: [PATCH 1/2] =?UTF-8?q?fix(persona-sim):=20=E6=BC=94=E5=87=BA?= =?UTF-8?q?=E8=84=9A=E6=9C=AC=E5=AD=97=E6=AE=B5=E4=B8=8D=E5=85=A8=E5=AF=BC?= =?UTF-8?q?=E8=87=B4=E6=95=B4=E9=A1=B5=E5=B4=A9=E6=BA=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LLM 偶尔生成的 app beat 缺嵌套数组(如 view:'chat' 却无 chat.lines), 渲染到 .map 时整页崩(Safari 报 undefined is not an object)。 新增 normalizeScript 把 chat.lines / search.queries / notes.items / browser.tabs / compose.drafts 统一兜底成数组,parseScript 与生活记录 重播两条路径都过一遍,覆盖新生成脚本和旧损坏快照。 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CRNUmkjGJzft1rG8DvBdTw --- apps/PersonaSim.tsx | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/apps/PersonaSim.tsx b/apps/PersonaSim.tsx index 2e273fab..8bf836a5 100644 --- a/apps/PersonaSim.tsx +++ b/apps/PersonaSim.tsx @@ -159,7 +159,7 @@ const PersonaSim: React.FC = ({ targetChar, onExit, openLifeLog, sim, onS if (phase === 'idle' && sim.status === 'ready') { setMode(sim.mode); setTheme(sim.theme); // 重播:脚本来自生活记录已存档的快照,别再 persist 一遍(否则生活记录里出现重复) - setScript(sim.script); setIdx(0); savedRef.current = !!sim.replay; setMemorySent(false); setPhase('play'); + setScript(normalizeScript(sim.script)); setIdx(0); savedRef.current = !!sim.replay; setMemorySent(false); setPhase('play'); onConsumed(); } }, [sim, phase, onConsumed]); @@ -1237,6 +1237,22 @@ kind 取值与字段: 请严格贴合上面的【本场变奏】,并把【下猛料】那段吃透:beats 给足 40~64 个、独白密集、细节具体、数字行为反复、高潮拉长、结尾收束落地。**务必保证 JSON 完整闭合、结尾收好**——若篇幅吃紧,宁可砍掉几个中段 beat,也要留足收尾、把括号全部闭合,绝不允许写到一半被截断。直接输出 JSON 对象。`; } +// 归一化 LLM 输出:模型偶尔会漏掉 app beat 里的嵌套数组(如 view:'chat' 却没有 chat.lines), +// 渲染到 .map 时会整页崩(Safari 报「undefined is not an object」)。这里统一兜底成数组。 +function normalizeScript(s: SimScript): SimScript { + if (!Array.isArray(s.beats)) return s; + for (const b of s.beats) { + const a = b?.app; + if (!a) continue; + if (a.chat && !Array.isArray(a.chat.lines)) a.chat.lines = []; + if (a.search && !Array.isArray(a.search.queries)) a.search.queries = []; + if (a.notes && !Array.isArray(a.notes.items)) a.notes.items = []; + if (a.browser && !Array.isArray(a.browser.tabs)) a.browser.tabs = []; + if (a.compose && !Array.isArray(a.compose.drafts)) a.compose.drafts = []; + } + return s; +} + function parseScript(raw: string): SimScript | null { if (!raw) return null; let s = raw.replace(/```json/gi, '').replace(/```/g, '').trim(); @@ -1258,8 +1274,8 @@ function parseScript(raw: string): SimScript | null { } return out; }; - try { return JSON.parse(s); } catch { } - try { return JSON.parse(repair(s)); } catch (e) { console.warn('persona parse failed', e); return null; } + try { return normalizeScript(JSON.parse(s)); } catch { } + try { return normalizeScript(JSON.parse(repair(s))); } catch (e) { console.warn('persona parse failed', e); return null; } } export default PersonaSim; From 3c26a720b170e8edb1626ea2257dbc08f7936a4e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 22 Jun 2026 14:20:08 +0000 Subject: [PATCH 2/2] =?UTF-8?q?fix(persona-sim):=20=E5=8A=A0=E5=9B=BA?= =?UTF-8?q?=E8=84=9A=E6=9C=AC=20JSON=20=E8=A7=A3=E6=9E=90=E5=B9=B6?= =?UTF-8?q?=E7=BB=86=E5=8C=96=E8=A7=A3=E6=9E=90=E5=A4=B1=E8=B4=A5=E8=AF=8A?= =?UTF-8?q?=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 「演出解析失败(parse)」是与渲染崩溃不同的失败:模型返回的 JSON 解析不出来。本次: - repair 增加去除 }/] 前尾随逗号(LLM 高频语法错误) - 解析失败时不再抛笼统的 'parse',改为带原因(空/语法错/疑似截断)+ 原文长度与首尾片段的报错,便于从系统调试终端复制定位真实原因 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CRNUmkjGJzft1rG8DvBdTw --- apps/PersonaSim.tsx | 31 ++++++++++++++++++++++++++----- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/apps/PersonaSim.tsx b/apps/PersonaSim.tsx index 8bf836a5..4bc761fa 100644 --- a/apps/PersonaSim.tsx +++ b/apps/PersonaSim.tsx @@ -106,8 +106,18 @@ export async function generatePersonaScript(opts: { const data = await safeResponseJson(res); // 截断直接报错,不兜底:模型输出被 token 上限截断时 finish_reason 为 'length' if (data.choices?.[0]?.finish_reason === 'length') throw new Error('演出生成被截断'); - const parsed = parseScript(data.choices[0].message.content); - if (!parsed || !parsed.beats?.length) throw new Error('parse'); + const rawContent: string = data.choices?.[0]?.message?.content ?? ''; + const parsed = parseScript(rawContent); + // 诊断友好:把失败原因和原文片段带进报错,方便从「系统调试终端」复制定位 + if (!parsed) { + const head = rawContent.slice(0, 200).replace(/\s+/g, ' '); + const tail = rawContent.length > 400 ? ' … ' + rawContent.slice(-200).replace(/\s+/g, ' ') : ''; + // 大括号能配上但解析仍失败 → 多半是字符串里有未转义引号;配不上 → 多半是被截断 + const balanced = rawContent.lastIndexOf('}') > rawContent.indexOf('{') && rawContent.indexOf('{') !== -1; + const hint = !rawContent.trim() ? '模型返回为空' : balanced ? 'JSON 语法错误(疑似未转义引号)' : '输出不完整/疑似截断'; + throw new Error(`演出解析失败: ${hint} · len=${rawContent.length} · ${head}${tail}`); + } + if (!parsed.beats?.length) throw new Error('演出解析失败: 无 beats'); // 不兜底:结尾必须是模型自己收束好的 end,否则视为不完整/被截断,报错让用户重试 if (parsed.beats[parsed.beats.length - 1].kind !== 'end') throw new Error('演出结尾不完整'); return parsed; @@ -1260,6 +1270,8 @@ function parseScript(raw: string): SimScript | null { const last = s.lastIndexOf('}'); if (first === -1 || last === -1) return null; s = s.slice(first, last + 1); + // 状态机修复:① 把字符串内的裸控制字符转义;② 去掉 } / ] 前的尾随逗号(仅在字符串外)。 + // 这两类是 LLM 输出 JSON 最常见的语法破坏。 const repair = (str: string) => { let inStr = false, esc = false, out = ''; for (let i = 0; i < str.length; i++) { @@ -1267,9 +1279,18 @@ function parseScript(raw: string): SimScript | null { if (esc) { out += ch; esc = false; continue; } if (ch === '\\') { out += ch; esc = true; continue; } if (ch === '"') { inStr = !inStr; out += ch; continue; } - if (inStr && ch === '\n') { out += '\\n'; continue; } - if (inStr && ch === '\r') { out += '\\r'; continue; } - if (inStr && ch === '\t') { out += '\\t'; continue; } + if (inStr) { + if (ch === '\n') { out += '\\n'; continue; } + if (ch === '\r') { out += '\\r'; continue; } + if (ch === '\t') { out += '\\t'; continue; } + out += ch; continue; + } + // 字符串外:跳过 } 或 ] 前的尾随逗号 + if (ch === ',') { + let j = i + 1; + while (j < str.length && /\s/.test(str[j])) j++; + if (str[j] === '}' || str[j] === ']') continue; + } out += ch; } return out;