From aba1aae262c014c2bac8638a61b75ae0e07ad36b Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Fri, 4 Sep 2026 02:35:14 +0800 Subject: [PATCH] =?UTF-8?q?chore:=20=E8=BF=81=E5=85=A5=20mock=20=E6=9E=84?= =?UTF-8?q?=E5=BB=BA=E7=AE=A1=E7=BA=BF=E8=84=9A=E6=9C=AC=EF=BC=88tools/?= =?UTF-8?q?=EF=BC=89+=20=E5=AF=B9=E9=BD=90=E6=9C=80=E6=96=B0=20conversatio?= =?UTF-8?q?ns-ma=20mock?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- demo/mock/conversations-ma.json | 2 +- tools/build-mocks.cjs | 411 ++++++++++++++++++++++++++++++++ tools/check-mocks.cjs | 89 +++++++ tools/rebuild-cat-html.cjs | 52 ++++ tools/serve.cjs | 33 +++ 5 files changed, 586 insertions(+), 1 deletion(-) create mode 100644 tools/build-mocks.cjs create mode 100644 tools/check-mocks.cjs create mode 100644 tools/rebuild-cat-html.cjs create mode 100644 tools/serve.cjs diff --git a/demo/mock/conversations-ma.json b/demo/mock/conversations-ma.json index de3d1d9..29502aa 100644 --- a/demo/mock/conversations-ma.json +++ b/demo/mock/conversations-ma.json @@ -13,7 +13,7 @@ "total_messages": 185, "total_tools": 87, "created_at": "2026-09-02T23:51:53.495751", - "updated_at": "2026-09-03T00:40:08.788991" + "updated_at": "2026-09-03T20:39:59.397333" } ], "has_more": false, diff --git a/tools/build-mocks.cjs b/tools/build-mocks.cjs new file mode 100644 index 0000000..a3dc87b --- /dev/null +++ b/tools/build-mocks.cjs @@ -0,0 +1,411 @@ +#!/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('✅ 全部完成'); diff --git a/tools/check-mocks.cjs b/tools/check-mocks.cjs new file mode 100644 index 0000000..61ce115 --- /dev/null +++ b/tools/check-mocks.cjs @@ -0,0 +1,89 @@ +#!/usr/bin/env node +/* mock 一致性自检:列表↔bootstrap↔资源↔媒体↔activity 全部交叉核对 */ +const fs = require('fs'); +const path = require('path'); +const MOCK = path.join(__dirname, '..', 'exp', 'mock'); +let errors = 0; +const bad = (msg) => { console.error('❌', msg); errors++; }; +const ok = (msg) => console.log('✓', msg); + +// 1. 列表 ↔ bootstrap +const normal = JSON.parse(fs.readFileSync(path.join(MOCK, 'conversations-normal.json'), 'utf8')).data.conversations; +const ma = JSON.parse(fs.readFileSync(path.join(MOCK, 'conversations-ma.json'), 'utf8')).data.conversations; +const all = [...normal, ...ma]; +console.log(`列表: normal ${normal.length} 条, ma ${ma.length} 条`); +for (const c of all) { + const f = path.join(MOCK, `bootstrap-${c.id}.json`); + if (!fs.existsSync(f)) { bad(`缺 bootstrap: ${c.id}`); continue; } + const b = JSON.parse(fs.readFileSync(f, 'utf8')); + if (b.data.messages.length !== c.total_messages) bad(`${c.id} 列表消息数 ${c.total_messages} != bootstrap ${b.data.messages.length}`); + if (b.data.meta.title !== c.title) bad(`${c.id} 标题不一致: ${c.title} vs ${b.data.meta.title}`); + if (b.data.meta.multi_agent_mode !== c.multi_agent_mode) bad(`${c.id} MA 标记不一致`); + ok(`${c.title} (${c.id})`); +} + +// 2. bootstrap 内部引用核对 +const filesDir = fs.readdirSync(path.join(MOCK, 'files')); +const fileMocks = fs.readdirSync(MOCK).filter((f) => f.startsWith('file-')); +const mediaDir = fs.readdirSync(path.join(MOCK, 'media')); +let mediaRefs = 0, showFiles = 0, downloads = 0; +for (const c of all) { + const f = path.join(MOCK, `bootstrap-${c.id}.json`); + if (!fs.existsSync(f)) continue; + const b = JSON.parse(fs.readFileSync(f, 'utf8')); + for (const m of b.data.messages) { + const text = typeof m.content === 'string' ? m.content : ''; + // show_file 卡片 + for (const mm of text.matchAll(/ fs.readdirSync(d, { withFileTypes: true }).flatMap((e) => e.isDirectory() ? walk(path.join(d, e.name)) : [path.join(d, e.name)]); +for (const f of walk(MOCK)) { + if (!/\.(json|txt|md)$/.test(f)) continue; + const s = fs.readFileSync(f, 'utf8'); + if (s.includes('/Users/jojo')) bad(`${path.basename(f)} 残留 /Users/jojo`); + if (/\bjojo\b/.test(s)) bad(`${path.basename(f)} 残留 jojo`); +} +ok('隐私扫描完成'); + +// 4. activity mock +const subList = JSON.parse(fs.readFileSync(path.join(MOCK, 'sub-agents-conv_20260902_235153_495.json'), 'utf8')).data; +for (const t of subList) { + const f = path.join(MOCK, `activity-${t.task_id}.json`); + if (!fs.existsSync(f)) { bad(`缺 activity: ${t.task_id}`); continue; } + const a = JSON.parse(fs.readFileSync(f, 'utf8')); + ok(`activity ${t.task_id}: ${a.data.entries.length} 条事件`); +} + +console.log(errors ? `\n共 ${errors} 个问题` : '\n✅ 全部通过'); +process.exit(errors ? 1 : 0); diff --git a/tools/rebuild-cat-html.cjs b/tools/rebuild-cat-html.cjs new file mode 100644 index 0000000..152100e --- /dev/null +++ b/tools/rebuild-cat-html.cjs @@ -0,0 +1,52 @@ +#!/usr/bin/env node +/** + * 重建猫咪对话的 cat-cafe/index.html: + * 子智能体 write_file 全文 + 主对话两次 edit_file(太阳修复、按钮修复) + * 输出到 cache/website-mock-review/assets/cat-cafe/index.html + */ +const fs = require('fs'); +const path = require('path'); + +const SUB = '/Users/jojo/.astrion/astrion/host/data/sub_agent_tasks/sub_4_1788364765_5d6208/conversation.json'; +const MAIN = '/Users/jojo/.astrion/astrion/host/mutiagents/conversations/workspace/conv_20260902_235153_495.json'; +const OUT = '/Users/jojo/Desktop/agents/正在修复中/agents/cache/website-mock-review/assets/cat-cafe/index.html'; + +// 1. 取子智能体的 write_file 全文 +const sub = JSON.parse(fs.readFileSync(SUB, 'utf8')); +const msgs = sub.messages || sub; +let html = null; +for (const m of msgs) { + for (const tc of m.tool_calls || []) { + if (tc.function.name === 'write_file') { + const args = JSON.parse(tc.function.arguments); + if (args.file_path && args.file_path.includes('cat-cafe')) html = args.content; + } + } +} +if (!html) throw new Error('未找到 cat-cafe 的 write_file'); +console.log('write_file 全文:', html.length, '字符'); + +// 2. 按主对话顺序应用 edit_file +const main = JSON.parse(fs.readFileSync(MAIN, 'utf8')); +let edits = []; +for (const m of main.messages) { + for (const tc of m.tool_calls || []) { + if (tc.function.name === 'edit_file') { + const args = JSON.parse(tc.function.arguments); + if ((args.file_path || '').includes('cat-cafe')) edits.push(args); + } + } +} +console.log('待应用 edit_file:', edits.length, '个'); +for (const [i, e] of edits.entries()) { + for (const [j, r] of (e.replacements || []).entries()) { + const cnt = html.split(r.old_string).length - 1; + console.log(` edit ${i + 1}.${j + 1}: 匹配 ${cnt} 处`); + if (cnt !== 1) throw new Error('匹配数异常,重建不保真'); + html = html.split(r.old_string).join(r.new_string); + } +} + +fs.mkdirSync(path.dirname(OUT), { recursive: true }); +fs.writeFileSync(OUT, html); +console.log('已写出:', OUT, fs.statSync(OUT).size, '字节'); diff --git a/tools/serve.cjs b/tools/serve.cjs new file mode 100644 index 0000000..0ed9fe3 --- /dev/null +++ b/tools/serve.cjs @@ -0,0 +1,33 @@ +#!/usr/bin/env node +/* 极简静态文件服务器(官网预览用,替代沙箱内不可用的 python3 -m http.server) */ +const http = require('http'); +const fs = require('fs'); +const path = require('path'); + +const ROOT = path.join(__dirname, '..', 'exp'); +const PORT = Number(process.argv[2]) || 8898; + +const MIME = { + html: 'text/html; charset=utf-8', js: 'text/javascript', mjs: 'text/javascript', + css: 'text/css', json: 'application/json; charset=utf-8', png: 'image/png', + jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', svg: 'image/svg+xml', + webp: 'image/webp', woff: 'font/woff', woff2: 'font/woff2', ttf: 'font/ttf', + pdf: 'application/pdf', docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + txt: 'text/plain; charset=utf-8', md: 'text/plain; charset=utf-8', ico: 'image/x-icon', + mp4: 'video/mp4', webm: 'video/webm', +}; + +http.createServer((req, res) => { + try { + let p = decodeURIComponent(new URL(req.url, 'http://x').pathname); + if (p.endsWith('/')) p += 'index.html'; + const file = path.normalize(path.join(ROOT, p)); + if (!file.startsWith(ROOT)) { res.writeHead(403); return res.end(); } + fs.readFile(file, (err, data) => { + if (err) { res.writeHead(404); return res.end('not found'); } + const ext = path.extname(file).slice(1).toLowerCase(); + res.writeHead(200, { 'Content-Type': MIME[ext] || 'application/octet-stream' }); + res.end(data); + }); + } catch (e) { res.writeHead(500); res.end(String(e)); } +}).listen(PORT, '127.0.0.1', () => console.log(`preview: http://127.0.0.1:${PORT}/ (root: ${ROOT})`));