#!/usr/bin/env node /** * 官网演示 mock 构建脚本(一次性,含人工手术逻辑) * 输入:用户真实对话 JSON(~/.astrion 数据目录) * 输出:website-design/exp/mock/ 下的 bootstrap/列表/activity/媒体 mock * 手术内容: * - word:剪 vlm_analyze(失败)+ send_qq_file(私人工具)+ 预览图生成段,改标题/首尾文案,todo 4 项改 3 项 * - cat:剪 3 次 sleep 超时等待(含对应思考文案续接),其余不动 * - 全量:路径改写 /Users/jojo/... → /Users/demo/...,剥离 edit_summary */ const fs = require('fs'); const path = require('path'); const EXP = path.join(__dirname, '..', 'exp'); const MOCK = path.join(EXP, 'mock'); const MEDIA_OUT = path.join(MOCK, 'media'); const MEDIA_STORE = '/Users/jojo/.astrion/astrion/host/data/conversations/media_store'; const SUB_TASKS_DIR = '/Users/jojo/.astrion/astrion/host/data/sub_agent_tasks'; const CONVS = { car: { src: '/Users/jojo/.astrion/astrion/host/data/conversations/workspace-6/conv_20260903_003950_896.json', id: 'conv_20260903_003950_896', oldBase: '/Users/jojo/Desktop/游戏', newBase: '/Users/demo/daily', ma: false, model: 'DeepSeek-V4-Flash', // 真实模型(演示站修正用户库里的 Flah 笔误) }, word: { src: '/Users/jojo/.astrion/astrion/host/data/conversations/workspace-3/conv_20260825_210916_394.json', id: 'conv_20260825_210916_394', oldBase: '/Users/jojo/Desktop/整理资料', newBase: '/Users/demo/daily', ma: false, title: '整理周会速记为 Word', }, plane: { src: '/Users/jojo/.astrion/astrion/host/data/conversations/workspace-8/conv_20260825_222209_771.json', id: 'conv_20260825_222209_771', oldBase: '/Users/jojo/Desktop/个人网站', newBase: '/Users/demo/starraid', ma: false, }, cat: { src: '/Users/jojo/.astrion/astrion/host/mutiagents/conversations/workspace/conv_20260902_235153_495.json', id: 'conv_20260902_235153_495', oldBase: '/Users/jojo/Desktop/语音', newBase: '/Users/demo/dev', ma: true, }, }; // ── 通用工具 ────────────────────────────────────────────── function makeSanitizer(oldBase, newBase) { return function sanitize(s) { if (typeof s !== 'string') return s; return s .split(oldBase).join(newBase) .replace(/\/Users\/jojo/g, '/Users/demo') .replace(/\bjojo\b/g, 'demo'); }; } function deepSanitize(v, san) { if (typeof v === 'string') return san(v); if (Array.isArray(v)) return v.map((x) => deepSanitize(x, san)); if (v && typeof v === 'object') { const o = {}; for (const [k, val] of Object.entries(v)) o[k] = deepSanitize(val, san); return o; } return v; } const MSG_KEYS = ['role', 'content', 'reasoning_content', 'timestamp', 'message_id', 'metadata', 'tool_calls', 'tool_call_id', 'name']; const META_KEYS = ['message_source', 'visibility', 'starts_work', 'work_timer', 'model_key', 'tool_payload', 'files', 'hidden', 'source', 'citations', 'runtime_injected', 'inline', 'is_auto_generated', 'auto_message_type', 'multi_agent_message', 'multi_agent_display_name', 'multi_agent_subtype', 'runtime_guidance', 'runtime_guidance_original', 'tool_image_path', 'media_refs']; // 演示站 MODELS 只列 Kimi-K3,消息里的模型名统一归一(纯展示字段) const DEMO_MODEL = 'Kimi-K3'; function transformMessage(m, san, demoModel) { const o = {}; for (const k of MSG_KEYS) if (m[k] !== undefined) o[k] = m[k]; if (o.metadata) { const meta = {}; for (const k of META_KEYS) if (m.metadata[k] !== undefined) meta[k] = m.metadata[k]; if (meta.model_key) meta.model_key = demoModel; o.metadata = meta; } return deepSanitize(o, san); } function replaceOnce(haystack, needle, replacement, label) { if (!haystack || !haystack.includes(needle)) throw new Error(`手术失败:未找到文本 [${label}]`); return haystack.split(needle).join(replacement); } function replaceOnceRe(haystack, re, replacement, label) { if (!haystack || !re.test(haystack)) throw new Error(`手术失败:正则未命中 [${label}]`); return haystack.replace(re, replacement); } // ── word 手术 ────────────────────────────────────────────── function wordSurgery(msgs) { const cut = new Set(); const idxOf = (pred, label) => { const i = msgs.findIndex(pred); if (i < 0) throw new Error('word: 未找到 ' + label); return i; }; const cutPair = (iTool, label) => { if (msgs[iTool].role !== 'tool' || msgs[iTool - 1].role !== 'assistant') throw new Error('word: 配对异常 ' + label); cut.add(iTool); cut.add(iTool - 1); }; // Quick Look 缩略图生成对(preview png 只服务被剪掉的 vlm) cutPair(idxOf((m) => m.role === 'tool' && typeof m.content === 'string' && m.content.includes('Quick Look thumbnails'), '缩略图结果'), '缩略图'); // ls 预览图对 cutPair(idxOf((m) => m.role === 'tool' && typeof m.content === 'string' && m.content.includes('_preview_page1.png'), '预览图ls'), '预览图ls'); // vlm_analyze 对(失败调用) cutPair(idxOf((m) => m.role === 'tool' && m.name === 'vlm_analyze', 'vlm结果'), 'vlm'); // send_qq_file 结果(其 assistant 只摘 tool_call,保留 todo 调用) cut.add(idxOf((m) => m.role === 'tool' && m.name === 'send_qq_file', 'qq结果')); // 收尾 todo 对("进度 4/4") cutPair(idxOf((m) => m.role === 'tool' && m.name === 'todo_update_task' && String(m.content).includes('进度 4/4'), '收尾todo'), '收尾todo'); const kept = msgs.filter((_, i) => !cut.has(i)); if (msgs.length - kept.length !== 9) throw new Error('word: 应剪 9 条,实际 ' + (msgs.length - kept.length)); // 首条用户消息去掉"然后发给我" const u0 = kept.find((m) => m.role === 'user' && String(m.content).includes('发给我')); u0.content = replaceOnce(u0.content, '帮我整理这份文件为word然后发给我', '帮我整理这份文件为word', '首条用户消息'); // todo_create 所在 assistant:改思考 + 重写 todo 参数(4 项 → 3 项,去发送) const todoAsst = kept.find((m) => (m.tool_calls || []).some((t) => t.function.name === 'todo_create')); todoAsst.reasoning_content = replaceOnce(todoAsst.reasoning_content, '5. 发送给用户(用户说"发给我"——可以用 send_qq_file 发送到 QQ 群,这是用户的习惯;也可以提供下载链接)', '5. 交付给用户(提供下载链接,并转一份 PDF 用文件卡片直接预览)', '思考-交付方式1'); todoAsst.reasoning_content = replaceOnceRe(todoAsst.reasoning_content, /用户说"发给我",考虑到有 send_qq_file 工具[\s\S]*?同时提供下载链接。/, '交付方式:生成 Word 后提供工作区下载链接,并转换一份 PDF 用文件卡片预览,方便直接查看。', '思考-交付方式2'); todoAsst.reasoning_content = replaceOnce(todoAsst.reasoning_content, '5. 发送\n', '5. 交付\n', '思考-交付方式3'); for (const t of todoAsst.tool_calls) { if (t.function.name !== 'todo_create') continue; const args = JSON.parse(t.function.arguments); args.overview = '将周会速记整理为结构化 Word 会议纪要'; args.tasks = args.tasks.filter((x) => !x.title.includes('发送')); if (args.tasks.length !== 3) throw new Error('word: todo 应为 3 项'); t.function.arguments = JSON.stringify(args); } // todo_create 结果 const todoRes = kept.find((m) => m.role === 'tool' && m.name === 'todo_create'); todoRes.content = '已创建 TODO:将周会速记整理为结构化 Word 会议纪要(共 3 项)'; // 清理/交付 assistant(原 vlm 失败后的那条):重写思考与正文,rm 命令去掉预览图 const cleanup = kept.find((m) => m.role === 'assistant' && String(m.content).includes('VLM 配置缺失')); cleanup.reasoning_content = '三层验证已经足够:\n1. docx-js 生成成功\n2. zip 结构完整,所有 XML 可解析\n3. LibreOffice 成功渲染为 PDF(233KB,3页)——这是最强的验证:LibreOffice 能正常打开并渲染,说明文档在 Word/WPS 中也能正常打开\n\n中文字体:我用了宋体/微软雅黑(eastAsia),这是标准做法,LibreOffice 渲染成功说明字体回退正常。\n\n现在收尾:\n1. 删除临时脚本 _gen_minutes.js\n2. 更新待办\n3. 交付:提供下载链接 + 文件预览\n\n先清理临时文件。'; cleanup.content = '三重验证都已通过(zip 结构完整、XML 全部可解析、LibreOffice 成功渲染 3 页 PDF),文档可用性已确认。清理临时文件:'; for (const t of cleanup.tool_calls) { t.function.arguments = t.function.arguments.replace('rm -f _gen_minutes.js .astrion/user_upload/_preview_page1.png &&', 'rm -f _gen_minutes.js &&'); } // send_qq_file 所在 assistant:摘掉该 tool_call(保留 todo_update_task) const deliver = kept.find((m) => (m.tool_calls || []).some((t) => t.function.name === 'send_qq_file')); deliver.tool_calls = deliver.tool_calls.filter((t) => t.function.name !== 'send_qq_file'); // 其 todo 结果文案:3/4 → 全部完成 3/3 const todoRes2 = kept.find((m) => m.role === 'tool' && m.name === 'todo_update_task' && String(m.content).includes('进度 3/4')); todoRes2.content = '所有任务已完成。;进度 3/3'; // 最终总结:去掉 QQ 发送表述 const final = kept.find((m) => m.role === 'assistant' && String(m.content).startsWith('搞定!')); final.content = replaceOnce(final.content, '搞定!文件已整理成 Word 并通过 QQ 发送,同时也保留在工作区,可随时下载:', '搞定!文件已整理成 Word,保存在工作区,可随时下载:', '最终总结正文'); final.reasoning_content = replaceOnce(final.reasoning_content, '文件已发送到 QQ + 提供下载链接。', '提供下载链接与文件预览。', '最终总结思考'); // todo 进度 1/4 → 1/3(任务数从 4 改为 3) const todoRes1 = kept.find((m) => m.role === 'tool' && m.name === 'todo_update_task' && String(m.content).includes('进度 1/4')); if (todoRes1) todoRes1.content = String(todoRes1.content).replace('进度 1/4', '进度 1/3'); // 其余 assistant 思考中的发送/QQ 残留(首条用户消息已改,相关思考同步改) const r2 = kept.find((m) => m.role === 'assistant' && String(m.reasoning_content).includes('可能是通过 QQ 或下载链接')); r2.reasoning_content = replaceOnce(r2.reasoning_content, '整理成 Word 文档,然后发给他。', '整理成 Word 文档。', '思考2-开头'); r2.reasoning_content = replaceOnce(r2.reasoning_content, '4. 发送给用户(可能是通过 QQ 或下载链接)', '4. 交付给用户(提供下载链接,并转一份 PDF 预览)', '思考2-交付'); r2.reasoning_content = replaceOnceRe(r2.reasoning_content, /用户说"发给我",工作区里有 send_qq_file 工具[\s\S]*?(如果那是用户习惯的方式)。/, '交付方式:生成 Word 文件后提供下载链接,并转换一份 PDF 用文件卡片预览,方便直接查看。', '思考2-发给我'); const r8 = kept.find((m) => m.role === 'assistant' && String(m.reasoning_content).includes('验证通过后删除脚本')); r8.reasoning_content = replaceOnce(r8.reasoning_content, '验证通过后删除脚本,然后 send_qq_file 发送 + 提供下载链接。', '验证通过后删除脚本,然后提供下载链接与文件预览。', '思考8-交付'); r8.reasoning_content = replaceOnceRe(r8.reasoning_content, /用户说"整理这份文件为word然后发给我",重点是发给他。/, '用户要的是整理成 Word。', '思考8-发给我'); const r17 = kept.find((m) => m.role === 'assistant' && String(m.reasoning_content).includes('send_qq_file 到 QQ 群')); r17.reasoning_content = replaceOnceRe(r17.reasoning_content, /然后:\n1\. 删除临时脚本 _gen_minutes\.js\n2\. 发送给用户[\s\S]*?同时提供下载链接。/, '然后:\n1. 删除临时脚本 _gen_minutes.js\n2. 交付给用户:提供下载链接 + 文件预览卡片', '思考17-交付'); // 残留扫描(硬失败,确保清理干净) const rest = JSON.stringify(kept); for (const bad of ['send_qq_file', 'vlm_analyze', 'VLM', 'QQ', '发给我', '发给他', '视觉模型']) { if (rest.includes(bad)) throw new Error('word 残留关键词: ' + bad); } return kept; } // ── plane 手术:卡片按 620px 基准设计,演示区实际渲染 580px,两侧标注会被裁; // 注入等比缩放脚本(卡片内外同为 3:4,按宽缩放即满填)。仅改 mock,不动原对话。 ── function planeSurgery(msgs) { const FIT = [ '<' + 'script>', '(function () {', " var DESIGN_W = 620;", " var card = document.querySelector('.ps-card');", ' if (!card) return;', ' function fit() {', ' var w = document.documentElement.clientWidth;', ' var s = Math.min(1, w / DESIGN_W);', " document.body.style.overflow = 'hidden';", " card.style.width = DESIGN_W + 'px';", " card.style.minHeight = (DESIGN_W / 0.75) + 'px';", " card.style.marginLeft = ((w - DESIGN_W) / 2) + 'px';", " card.style.transform = 'scale(' + s + ')';", " card.style.transformOrigin = 'top center';", " document.body.style.height = (DESIGN_W / 0.75 * s) + 'px';", ' }', ' fit();', " window.addEventListener('resize', fit);", '})();', '', ].join('\n'); const m = msgs.find((x) => x.role === 'assistant' && String(x.content).includes('')); if (!m) throw new Error('plane: 未找到 show_html 卡片'); m.content = m.content.replace('', '\n' + FIT + '\n'); return msgs; } // ── cat 手术:只剪超时等待对,续接思考 ────────────────────── function catSurgery(msgs) { const cut = new Set(); msgs.forEach((m, i) => { if (m.role === 'tool' && m.name === 'sleep' && String(m.content).includes('sleep 失败')) { const a = msgs[i - 1]; if (!a || a.role !== 'assistant' || (a.tool_calls || []).length !== 1 || a.tool_calls[0].function.name !== 'sleep') { throw new Error('cat: 超时配对异常 @' + i); } cut.add(i); cut.add(i - 1); } }); if (cut.size !== 6) throw new Error('cat: 应剪 3 对(6 条),实际 ' + cut.size); const kept = msgs.filter((_, i) => !cut.has(i)); // 续接 1:状态检查的思考(原"超时了") const s1 = kept.find((m) => m.role === 'assistant' && String(m.content).startsWith('5 分钟没有输出')); s1.reasoning_content = replaceOnce(s1.reasoning_content, 'UI Operator_1 超时了(5分钟无输出)。', 'UI Operator_1 有一阵没有新输出了。', 'cat续接1'); // 续接 2:第二次状态检查的思考(无正文) const s2 = kept.find((m) => m.role === 'assistant' && String(m.reasoning_content).startsWith('又超时了')); s2.reasoning_content = replaceOnce(s2.reasoning_content, '又超时了。再查一下状态', '还是没有新输出。再查一下状态', 'cat续接2'); // 续接 3:直接检查工作区 const s3 = kept.find((m) => m.role === 'assistant' && String(m.content).startsWith('连续超时')); s3.reasoning_content = replaceOnce(s3.reasoning_content, '连续两次超时了。', '等了挺久还没有新动静。', 'cat续接3思考'); s3.content = replaceOnce(s3.content, '连续超时,我直接检查一下工作区', '等了一阵还没新动静,我直接检查一下工作区', 'cat续接3正文'); const rest = JSON.stringify(kept); for (const bad of ['sleep 失败', '超时']) { if (rest.includes(bad)) console.warn('⚠️ cat 残留关键词:', bad); } return kept; } // ── 媒体导出 ────────────────────────────────────────────── function exportMedia(keptMsgs, san) { fs.mkdirSync(MEDIA_OUT, { recursive: true }); const seen = new Map(); for (const m of keptMsgs) { for (const ref of (m.metadata && m.metadata.media_refs) || []) { if (ref && ref.sha256 && !seen.has(ref.sha256)) seen.set(ref.sha256, ref); } } let n = 0; for (const [sha, ref] of seen) { const ext = ref.mime_type === 'image/png' ? '.png' : ref.mime_type === 'image/jpeg' ? '.jpg' : ''; if (!ext) { console.warn('⚠️ 未知媒体类型', ref.mime_type); continue; } const src = path.join(MEDIA_STORE, 'blobs', sha.slice(0, 2), sha); const dst = path.join(MEDIA_OUT, sha + ext); if (fs.existsSync(src)) fs.copyFileSync(src, dst); else if (fs.existsSync(src + ext)) fs.copyFileSync(src + ext, dst); else { console.warn('⚠️ 媒体 blob 缺失:', sha.slice(0, 12)); continue; } n++; } console.log('媒体导出:', n, '个'); } // ── activity mock(猫咪的 4 个子智能体)──────────────────── function truncateArgs(v) { if (typeof v === 'string') return v.length > 300 ? v.slice(0, 300) + '…' : v; if (Array.isArray(v)) return v.map(truncateArgs); if (v && typeof v === 'object') { const o = {}; for (const [k, val] of Object.entries(v)) o[k] = truncateArgs(val); return o; } return v; } function buildActivityMocks(san) { const taskIds = ['sub_1_1788364427_985302', 'sub_2_1788364429_91688a', 'sub_3_1788364431_74db3e', 'sub_4_1788364765_5d6208']; // 多智能体实例快照:activity 状态与实例列表共用的真值来源 const MA_SNAP = (JSON.parse(fs.readFileSync('/Users/jojo/.astrion/astrion/host/data/sub_agents.json', 'utf8')).multi_agent_states || {})['conv_20260902_235153_495']; const byTask = {}; for (const inst of Object.values((MA_SNAP && MA_SNAP.agents) || {})) byTask[inst.task_id] = inst; const list = []; for (const tid of taskIds) { const dir = path.join(SUB_TASKS_DIR, tid); const entries = fs.readFileSync(path.join(dir, 'progress.jsonl'), 'utf8') .split('\n').filter(Boolean) .map((l) => JSON.parse(l)) .filter((e) => e.type === 'progress') .map((e) => deepSanitize({ ...e, args: truncateArgs(e.args || {}) }, san)); // status 必须还原实例真实状态(idle/terminated)——写死 completed 会让前端在查看 // 详情时把多智能体实例错误显示为「已完成」(单智能体语义),轮询后才跳回。 const instStatus = (byTask[tid] && byTask[tid].status) || 'completed'; fs.writeFileSync(path.join(MOCK, `activity-${tid}.json`), JSON.stringify({ success: true, data: { status: instStatus, entries } })); const out = JSON.parse(fs.readFileSync(path.join(dir, 'output.json'), 'utf8')); const lastTool = entries.length ? entries[entries.length - 1].tool : null; const ts = tid.split('_'); list.push({ task_id: tid, status: 'completed', summary: san((out.summary || '').slice(0, 60)), last_tool: lastTool, conversation_id: 'conv_20260902_235153_495', created_at: Number(ts[2]) / 1000, }); } fs.writeFileSync(path.join(MOCK, 'sub-agents-conv_20260902_235153_495.json'), JSON.stringify({ success: true, data: list }, null, 1)); // 多智能体实例列表(/api/multiagent/active_sub_agents):猫咪对话 QuickDock 子智能体窗口的数据源。 // 字段对齐 MultiAgentInstance.to_dict() + 后端接口补充的 last_tool/current_context_tokens。 const maAgents = taskIds.map((tid) => { const inst = byTask[tid] || {}; const base = list.find((x) => x.task_id === tid) || {}; return { agent_id: inst.agent_id ?? null, role_id: inst.role_id || '', display_name: inst.display_name || tid, task_id: tid, status: inst.status || 'idle', summary: san(String(inst.summary || base.summary || '').slice(0, 60)), created_at: inst.created_at || base.created_at, last_output: san(String(inst.last_output || '').slice(0, 300)), conversation_id: 'conv_20260902_235153_495', last_tool: base.last_tool || null, current_context_tokens: 0, }; }); fs.writeFileSync(path.join(MOCK, 'ma-agents-conv_20260902_235153_495.json'), JSON.stringify({ success: true, agents: maAgents }, null, 1)); console.log('activity mock: 4 个子智能体已导出'); } // ── 主流程 ────────────────────────────────────────────── const RUNNING = { has_running_background_commands: false, has_running_multi_agent: false, has_running_sub_agents: false, is_main_running: false, is_truly_active: false, main_task_id: null, main_task_type: null }; const listEntries = { normal: [], ma: [] }; for (const [name, cfg] of Object.entries(CONVS)) { const raw = JSON.parse(fs.readFileSync(cfg.src, 'utf8')); const san = makeSanitizer(cfg.oldBase, cfg.newBase); const demoModel = cfg.model || DEMO_MODEL; let msgs = raw.messages.map((m) => transformMessage(m, san, demoModel)); const before = msgs.length; if (name === 'word') msgs = wordSurgery(msgs); if (name === 'plane') msgs = planeSurgery(msgs); if (name === 'cat') msgs = catSurgery(msgs); const title = cfg.title || raw.title; const meta = { title: san(title), model_key: demoModel, thinking_mode: true, run_mode: 'thinking', multi_agent_mode: !!cfg.ma, permission_mode: 'unrestricted', execution_mode: 'sandbox', network_permission: '', messages_count: msgs.length, }; // 真实 edited_files 随 bootstrap 还原(QuickDock 文件记录窗口);真实 todo_list 导出独立 // mock 文件(/api/todo-list 按 conversation_id 读取,QuickDock 待办窗口)。此前均钉空/钉 null。 const rawEdited = (raw.metadata && Array.isArray(raw.metadata.edited_files)) ? raw.metadata.edited_files : []; const editedFiles = rawEdited .filter((it) => it && typeof it === 'object' && it.path) .map((it) => ({ path: san(String(it.path)), op: it.op || 'edit', ts: it.ts || '' })); const boot = { success: true, data: { conversation_id: cfg.id, edited_files: editedFiles, messages: msgs, meta, running: RUNNING } }; fs.writeFileSync(path.join(MOCK, `bootstrap-${cfg.id}.json`), JSON.stringify(boot)); if (raw.todo_list && Array.isArray(raw.todo_list.tasks) && raw.todo_list.tasks.length) { fs.writeFileSync(path.join(MOCK, `todo-${cfg.id}.json`), JSON.stringify({ success: true, data: deepSanitize(raw.todo_list, san) })); } // 真实 token 统计导出独立 mock(/tokens 与 /token-statistics 按 conversation_id 读取)。 // 字段名与对话文件一致(total_input_tokens 等),前端 resource store 直接消费; // 老对话无 total_cached_input_tokens/cache_exempt_input_tokens 字段,前端 `|| 0` 兜底。 if (raw.token_statistics && typeof raw.token_statistics === 'object') { fs.writeFileSync(path.join(MOCK, `tokens-${cfg.id}.json`), JSON.stringify(raw.token_statistics, null, 1)); } const tools = msgs.filter((m) => m.role === 'tool').length; console.log(`${name}: ${before} → ${msgs.length} 条消息,工具消息 ${tools} 条`); exportMedia(msgs, san); listEntries[cfg.ma ? 'ma' : 'normal'].push({ id: cfg.id, title: meta.title, project_path: cfg.newBase, project_relative_path: null, multi_agent_mode: !!cfg.ma, thinking_mode: true, status: 'active', total_messages: msgs.length, total_tools: tools, created_at: raw.created_at, updated_at: raw.updated_at, }); } // 保留原有的弹幕调研对话(不动),插入 normal 列表头部 const oldList = JSON.parse(fs.readFileSync(path.join(MOCK, 'conversations-normal.json'), 'utf8')); const research = oldList.data.conversations.find((c) => c.id === 'conv_20260823_122836_778'); // 弹幕调研对话不在上方循环里,单独导出真实 token 统计(老格式:无 cached/exempt 字段) const danmakuRaw = JSON.parse(fs.readFileSync('/Users/jojo/.astrion/astrion/host/data/conversations/me/conv_20260823_122836_778.json', 'utf8')); if (danmakuRaw.token_statistics) { fs.writeFileSync(path.join(MOCK, 'tokens-conv_20260823_122836_778.json'), JSON.stringify(danmakuRaw.token_statistics, null, 1)); } const normal = [research, ...listEntries.normal.sort((a, b) => String(a.created_at).localeCompare(String(b.created_at)))]; fs.writeFileSync(path.join(MOCK, 'conversations-normal.json'), JSON.stringify({ success: true, data: { conversations: normal, has_more: false, limit: 20, offset: 0, total: normal.length } }, null, 1)); fs.writeFileSync(path.join(MOCK, 'conversations-ma.json'), JSON.stringify({ success: true, data: { conversations: listEntries.ma, has_more: false, limit: 20, offset: 0, total: listEntries.ma.length } }, null, 1)); console.log('列表更新:normal', normal.length, '条 / ma', listEntries.ma.length, '条'); buildActivityMocks(makeSanitizer('/Users/jojo/Desktop/语音', '/Users/demo/dev')); console.log('✅ 全部完成');