// viewer.mts —— 记忆库查看器的一键启动(同步最新页面 → 起写盘桥 → 开浏览器) // // 为什么要有它:直接跑 `viewer-bridge.mjs` 还差三件事 —— // ① 查看器源码改了但还没部署(启动后会自动监听并同步),直接跑桥会看到旧页面; // ② 5174 被占时桥直接退出,得自己找空闲端口; // ③ 还得手动复制终端里的地址去浏览器。 // 这三件事都是"每次都要做、忘了就出问题"的机械步骤,所以收进一个入口。 // // 用法: // node --experimental-strip-types scripts/l1-admin/viewer.mts [--profile ] [--port ] [--no-open] [--no-sync] // [--lan] [--host ] // (Windows 也可直接双击 viewers/启动查看器.cmd,参数照传:`启动查看器.cmd --lan`) // // `--lan` = 让桥**额外**监听本机局域网网卡(环回保留),好让别的设备打开同一个查看器调试; // `--host ` 指定确切地址。地址探测规则与桥共用 `lan-addresses.mjs`(一份规则、两个消费者)—— // 各写一份迟早出现"启动器打印 A 地址、桥实际听的是 B 地址"这种最难查的错配。 // 注意这个开关把写端点也放到了局域网上(无鉴权),安全代价写在 viewer-bridge.mjs 头部。 // // profile 的解析**复用扩展自己的 ProfileManager**(读宿主 settings.json 的 memorySystem.activeProfile, // 含旧版顶层键兼容)—— 不硬编码路径,也就不会出现"查看器看的是 A profile、扩展写的是 B profile"。 // // 退出行为:Ctrl+C 会先把桥子进程停掉再退。Windows 上 Ctrl+C 其实会同时打到整个进程组, // 但显式 kill 一次能确保不留孤儿进程占着端口(实测被占过一次,之后每次都要手动 taskkill)。 import { spawn, type ChildProcess } from "node:child_process"; import { existsSync, watch, type FSWatcher } from "node:fs"; import { createServer } from "node:net"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { argv } from "node:process"; import { ProfileManager } from "../../memory/core/profile.ts"; import { resolveAgentDir } from "../../util/agent-dir.ts"; import { deployViewers, VIEWER_SOURCE_DIR } from "../../memory/viewers/deploy.ts"; import { MEMORY_DB_DIR } from "../../memory/paths.ts"; import { lanAddresses } from "./lan-addresses.mjs"; const TOOL_DIR = dirname(fileURLToPath(import.meta.url)); const BRIDGE = join(TOOL_DIR, "viewer-bridge.mjs"); /** 取 `--flag value` 形式的值。 */ function flagValue(flag: string): string | undefined { const i = argv.indexOf(flag); return i >= 0 ? argv[i + 1] : undefined; } const explicitProfile = flagValue("--profile"); const explicitPort = flagValue("--port"); const noOpen = argv.includes("--no-open"); const noSync = argv.includes("--no-sync"); /** 局域网调试开关(原样透传给桥;地址探测也在这里做一次,只为把"别的设备该访问哪个地址"打印出来)。 */ const lanMode = argv.includes("--lan"); const explicitHost = flagValue("--host") ?? ""; /** * 解析目标 profile。 * * 顺序:`--profile` → 环境变量 `PI_PROFILE_PATH` → 宿主 settings.json 里的激活 profile。 * 最后一档走 `ProfileManager`,与扩展同一份实现(避免"查看器与扩展看不同 profile"这类无声错配)。 */ function resolveProfile(): { path: string; source: string } { if (explicitProfile) return { path: explicitProfile, source: "--profile 参数" }; const env = process.env.PI_PROFILE_PATH; if (env) return { path: env, source: "环境变量 PI_PROFILE_PATH" }; try { const pm = new ProfileManager(resolveAgentDir()); const active = pm.getActiveProfile(); if (active?.path) return { path: active.path, source: `宿主 settings.json 的激活 profile「${pm.getActiveProfileName()}」` }; } catch { /* 读不到就退到默认档 */ } return { path: join(resolveAgentDir(), "profiles", "default"), source: "默认档 default(读不到激活 profile)" }; } /** 探测一个空闲端口:能 listen 成功即视为空闲,随即释放交给桥去听。 */ function isPortFree(port: number): Promise { return new Promise((resolve) => { const probe = createServer(); probe.once("error", () => resolve(false)); probe.once("listening", () => probe.close(() => resolve(true))); probe.listen(port, "127.0.0.1"); }); } /** 从 preferred 起往上找第一个空闲端口。 */ async function pickPort(preferred: number): Promise { for (let p = preferred; p <= preferred + 20; p++) { if (await isPortFree(p)) return p; } throw new Error(`端口 ${preferred}~${preferred + 20} 全被占用,请用 --port 指定其它端口`); } /** 用系统默认浏览器打开 URL(不等待、不阻塞)。 */ function openBrowser(url: string): void { const cmd = process.platform === "win32" ? "cmd" : process.platform === "darwin" ? "open" : "xdg-open"; // Windows 的 start 是 cmd 内建命令;第一个空参数是窗口标题,必须留,否则带引号的 URL 会被当成标题 const args = process.platform === "win32" ? ["/c", "start", "", url] : [url]; try { spawn(cmd, args, { detached: true, stdio: "ignore" }).unref(); } catch { console.log(`(自动打开浏览器失败,请手动访问 ${url})`); } } /** 轮询健康检查直到桥就绪。 */ async function waitHealthy(port: number, timeoutMs = 15000): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { try { const r = await fetch(`http://127.0.0.1:${port}/api/health`, { signal: AbortSignal.timeout(1500) }); if (r.ok) return true; } catch { /* 还没起来 */ } await new Promise((r) => setTimeout(r, 250)); } return false; } // ── 主流程 ──────────────────────────────────────────────────────────────── const { path: profilePath, source: profileSource } = resolveProfile(); if (!existsSync(join(profilePath, MEMORY_DB_DIR))) { console.error(`[NG] profile 下没有 ${MEMORY_DB_DIR}/:${profilePath}`); console.error(` 来源:${profileSource}`); console.error(" 该 profile 还没跑过记忆系统(或路径不对)。可用 --profile 指定。"); process.exit(2); } console.log("记忆库查看器"); console.log(` profile : ${profilePath}`); console.log(` (来源:${profileSource})`); console.log(` 桥脚本 : ${BRIDGE}`); console.log(""); // ① 同步最新页面:部署是幂等的(内容没变就零写入),所以每次启动都跑一遍最省心 if (noSync) { console.log("① 跳过页面同步(--no-sync)"); } else { try { const r = deployViewers(profilePath); if (r.errors.length > 0) { console.log(`① 页面同步有 ${r.errors.length} 项失败(不影响查看已部署的版本):`); for (const e of r.errors) console.log(` · ${e}`); } else if (r.written.length > 0) { console.log(`① 已同步最新页面 ${r.version}(写入 ${r.written.length} 个文件,其余 ${r.skipped.length} 个未变)`); } else { console.log(`① 页面已是最新 ${r.version}(${r.skipped.length} 个文件内容一致,零写入)`); } if (r.retired.length > 0) console.log(` 已退役:${r.retired.join(", ")}(移入 backups/viewers-retired)`); } catch (e) { console.log(`① 页面同步异常(继续用已部署版本):${e instanceof Error ? e.message : String(e)}`); } } // 启动后监听仓库源码;只在收到变更事件后调用部署器,避免重复写版本清单。 let sourceWatcher: FSWatcher | null = null; let deployTimer: ReturnType | null = null; if (!noSync) { try { sourceWatcher = watch(VIEWER_SOURCE_DIR, { recursive: true }, (_event, filename) => { if (deployTimer) clearTimeout(deployTimer); deployTimer = setTimeout(() => { deployTimer = null; try { const r = deployViewers(profilePath); if (r.errors.length > 0) { console.error(`[热更新] 部署 ${r.version || "失败"},有 ${r.errors.length} 项错误;页面继续使用当前可用版本:`); for (const error of r.errors) console.error(` · ${error}`); } else if (r.written.length > 0 || r.retired.length > 0) { console.log(`[热更新] 已部署 ${r.version}(写入 ${r.written.length} 个文件)${filename ? `;触发文件:${filename}` : ""}。打开的页面将在约 2 秒内自动重载。`); } } catch (error) { console.error(`[热更新] 部署失败,页面继续使用当前可用版本:${error instanceof Error ? error.message : String(error)}`); } }, 300); }); sourceWatcher.on("error", (error) => { console.error(`[热更新] 源码监听发生错误,后续变更可能不会自动部署:${error.message}`); }); console.log(`① 热更新监听已启动:${VIEWER_SOURCE_DIR}`); } catch (error) { console.error(`[热更新] 无法监听源码目录,自动部署不可用:${error instanceof Error ? error.message : String(error)}`); } } // ② 选端口 let port: number; try { port = await pickPort(Number(explicitPort) || 5174); } catch (e) { console.error(`[NG] ${e instanceof Error ? e.message : String(e)}`); process.exit(2); } if (explicitPort && port !== Number(explicitPort)) { console.log(`② 端口 ${explicitPort} 被占用,改用 ${port}`); } else { console.log(`② 端口 ${port}`); } // ③ 起桥(stdio 继承,桥自己的日志直接显示在这里) console.log("③ 启动写盘桥…"); const bridgeArgs = [BRIDGE, "--port", String(port), "--profile", profilePath]; if (lanMode) bridgeArgs.push("--lan"); if (explicitHost) bridgeArgs.push("--host", explicitHost); const child: ChildProcess = spawn(process.execPath, bridgeArgs, { stdio: "inherit", }); let exiting = false; function shutdown(): void { if (exiting) return; exiting = true; console.log("\n正在停止写盘桥…"); if (deployTimer) clearTimeout(deployTimer); deployTimer = null; if (sourceWatcher) sourceWatcher.close(); sourceWatcher = null; try { if (child.exitCode === null && !child.killed) child.kill(); } catch { /* 已退出 */ } // 给子进程一点时间退干净:父进程先走会在 Windows 上留下占着端口的孤儿进程 setTimeout(() => process.exit(0), 400).unref(); } process.on("SIGINT", shutdown); process.on("SIGTERM", shutdown); // 桥自己退出(端口冲突、profile 有误等)时,父进程跟着退,不留空转窗口 child.on("exit", (code, signal) => { if (exiting) return; console.log(`\n写盘桥已退出(code=${code ?? "null"}${signal ? `, signal=${signal}` : ""})。`); process.exit(code ?? 0); }); // ④ 等就绪 → 开浏览器 const url = `http://localhost:${port}/viewers/index.html?port=${port}`; if (await waitHealthy(port)) { console.log(""); console.log(` ✔ 已就绪:${url}`); console.log(" 写盘可用(改任务状态 / 删除都会真正落盘);Ctrl+C 停止。"); if (lanMode || explicitHost) { // 局域网地址由桥真正监听的那张网卡决定:`--host` 给了就用它,否则用与桥同一份规则选出的首选地址 const host = explicitHost || (lanAddresses()[0] ?? ""); if (host) { console.log(""); console.log(` ✔ 别的设备打开这个地址(同一局域网):http://${host}:${port}/viewers/index.html?port=${port}`); console.log(" 同一个查看器、同一份数据,页面的 /api 请求会跟着这个地址走。"); console.log(" ⚠ 该地址上没有鉴权:同网段任何设备都能读全部记忆、也能调用写端点。用完即关。"); console.log(" 别的设备打不开时,先看 Windows 防火墙是否放行了 node 的入站(专用网络 / 该网段)。"); } else { console.log(" [warn] --lan/--host 已给,但没探测到可用的局域网 IPv4 —— 别的设备访问不了,检查网卡。"); } } if (noOpen) console.log(" (--no-open:未自动打开浏览器)"); else { console.log(" 正在打开浏览器…"); openBrowser(url); } } else { console.error("[NG] 桥启动后 15 秒内未响应健康检查。请查看上方的桥日志。"); shutdown(); }