Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 45 additions & 8 deletions apps/PersonaSim.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -159,7 +169,7 @@ const PersonaSim: React.FC<Props> = ({ 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]);
Expand Down Expand Up @@ -1237,29 +1247,56 @@ 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;
}
Comment on lines +1252 to +1264

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

normalizeScript 中,直接对传入的 s 进行修改(in-place mutation)会带来潜在的 React 渲染和状态管理问题。

当重播时,sim.script 作为 React 组件的 Prop 传入,直接修改它会违反 React 的不可变性(Immutability)原则,可能导致父组件状态被污染或组件未能正确触发重渲染。

此外,如果 snullundefined(例如 JSON.parse 解析失败返回 null),直接读取 s.beats 会抛出 TypeError

建议在函数开头增加空值校验,并使用 JSON.parse(JSON.stringify(s)) 进行深拷贝,以确保不污染原始数据。

function normalizeScript(s: SimScript): SimScript {
    if (!s || !Array.isArray(s.beats)) return s;
    // 避免直接修改 React props/state,先进行深拷贝
    const cloned = JSON.parse(JSON.stringify(s)) as SimScript;
    for (const b of cloned.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 cloned;
}


function parseScript(raw: string): SimScript | null {
if (!raw) return null;
let s = raw.replace(/```json/gi, '').replace(/```/g, '').trim();
const first = s.indexOf('{');
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++) {
const ch = str[i];
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;
};
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;