agent-Specialization/website-design/tools/build-mocks.cjs
JOJO 2f0b970060 chore(website-demo): 新增 mock 构建/校验/本地预览工具脚本
- build-mocks.cjs:从真实对话导出演示 mock 数据
- check-mocks.cjs:mock 数据完整性校验
- rebuild-cat-html.cjs / serve.cjs:分类页重建与本地预览服务
2026-09-03 21:54:58 +08:00

412 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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 成功渲染为 PDF233KB3页——这是最强的验证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);",
'})();',
'</scr' + 'ipt>',
].join('\n');
const m = msgs.find((x) => x.role === 'assistant' && String(x.content).includes('</show_html>'));
if (!m) throw new Error('plane: 未找到 show_html 卡片');
m.content = m.content.replace('</show_html>', '\n' + FIT + '\n</show_html>');
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('✅ 全部完成');