# Architecture roadmap — removing host coupling **English summary.** A *plan* (no code changes in this document): replace the plugin-style lookups in `src\lib\index.js` (resolving `resolveActivePetId` on demand, pulling services off `ctx`, hiding state in assembly closures) with direct internal calls, so the pet engine is a plain module inside its own process instead of a plugin hosted by something else. Sections 6.1–6.8 add per-topic plans with real `file:line` anchors (scheduler, proposal/governance, character, long-term memory, idle CPU, control-bridge security, mapping table, and why a big refactor is not in this version). §6.6 (control-bridge hardening) is **done in 1.1.0**. **This document is in Chinese.** --- # 架构演进:去宿主化 > 这份文件是**计划**,不含任何代码改动。目标:把 `src\lib\index.js` 里那种「插件式」写法 > (每次用到才去解析、拿 `ctx` 上的服务、把状态藏在装配闭包里)一步步改成更直接的内部调用, > 让桌宠本体的默认活法就是「独立进程里的一个普通模块」,而不是「寄生在宿主里的插件」。 > > 适用版本:1.1.0。行号均指本仓库当前文件,重构时会漂移,以函数名/关键字为准检索。 --- ## 1. 现状:一份源码,两种活法 ``` ┌──────────────────────────────────────────┐ 宿主半侧 (host) │ src\lib\index.js 2969 行 / 113465 B │ │ name="pet" inject=[...] apply(ctx) │ └───────────────┬──────────────────────────┘ │ 同一份 apply(ctx) ┌────────────────────────────┴────────────────────────────┐ │ │ ┌────▼─────────────────────┐ ┌──────────────▼─────────────┐ │ ① DSH 宿主(插件形态) │ │ ② 独立版(standalone\) │ │ DSH 提供真 ctx: │ │ 伪 ctx(context.mjs) │ │ webServer/llm/credentials│ │ + 真 http 服务(server.mjs)│ │ /commands/session 事件 │ │ + 三个包替身(shims\) │ │ 浏览器半侧由 DSH 装载 │ │ 桌面渲染端自带 renderer │ └──────────────────────────┘ └────────────────────────────┘ ``` 关键事实(都已核对): - **插件三件套**:`src\lib\index.js:1496 const name = "pet";`、`:1497 const inject = ["webServer","agentDefaultModel","credentials","llm","commands"];`、`:2969 export { apply, inject, name };` 挂载声明在 `src\cordis.patch.yml`(`insert: [{ id: cute-fat-fish-pet, name: 'cute-fat-fish-pet' }]`),由 `src\package.json` 的 `dsh.bundle.patch` 声明。 - **独立版怎么跑起来的**:`standalone\main.mjs:69-73` 用 `ALIASES` 把三个宿主包指向替身,`:75-84` 用 `registerHooks({ resolve })` 在**运行期**做 alias(上游是构建期 alias,我们没有构建链);`:32 DEFAULT_APP_DIR = resolve(HERE, "..", "app")`;`:508` 特意「先监听、后 apply」(插件在 `apply` 里就要读 `ctx.webServer.port`);`:574` 再动态 `import()` 宿主入口并调用 `apply(ctx)`。 - **`standalone\` 已经证明核心是宿主无关的**:伪 ctx 只实现了插件真正用到的那点表面,插件照样完整跑起来。 - **桌面桌宠其实早就和 DSH 无关**:`src\runtime\electron-helper\main.js:504` 用 `loadFile('index.html')` 装载**自己的**渲染端(`index.html:169-173` 引入 `shared-core.js / constants.js / sprite.js / events.js / renderer.js`),它不加载 `lib/client.js`。`lib/client.js`(浏览器半侧,4066 行)只服务「在 DSH 网页里出现的那只宠物」。 --- ## 2. 耦合清单(重构的靶子) ### 2.1 对三个宿主包的静态依赖 | 位置 | 依赖 | 用途 | | --- | --- | --- | | `src\lib\index.js:6-8` | `@deepseek-ai/dsh-home-paths`、`-credentials`、`-llm` | `resolveDshHome` / `credentialRef` / `BlockAssembler, ReasoningEffortId, createAssistantMessage, createUserMessage` | | `src\src\host\index.ts:56-57` | 同上前两个 | 同上(`lib\` 是它的构建产物) | | `src\src\host\chat.ts:25`、`src\src\host\whisper.ts:20` | `@deepseek-ai/dsh-llm` | 消息形状 + 流式聚合 | | `src\src\env.d.ts:3` | 声明 `@deepseek-ai/dsh-home-paths` | 类型 | 替身已经给出**等价实现**:`standalone\shims\dsh-home-paths.mjs:13-16`(`DSH_HOME \|\| ~/.dsh`,口径与 `src\lib\index.js:1194-1196` 的 `dshHomeDir()` 逐字一致)、`shims\dsh-credentials.mjs:11-13`(`credentialRef` 恒等)、`shims\dsh-llm.mjs:13-40`(四个导出)。 → **结论:这三个包都不是真依赖,是「宿主约定」。可以把替身升级为本体实现,DSH 侧再包一层。** ### 2.2 `ctx` 表面(`src\lib\index.js` 里全部 33 处用法) | 用法 | 行号 | 独立版怎么糊的 | | --- | --- | --- | | `const llm = ctx.llm;` | 146 / 199 / 485 / 2138 | `context.mjs:294` 的 `stream()` → 本机 Ollama 或联网 | | `ctx.agentDefaultModel.currentSelection()` | 186 / 472 / 2130 / 2402 | `context.mjs:203-212`(同步契约) | | `ctx.get("attachments")` | 2139 | `context.mjs:157-162` | | `ctx.get("onlineVisionReady")` | 2124 | `context.mjs:160`(**发布版补丁 V3** 加的私有口子) | | `ctx.webServer?.port` | 1845 | `context.mjs:174` getter | | `ctx.webServer.register({ path: ROUTE_PREFIX, … })` | 2756-2758 | `context.mjs:177-188` 收进 routes 数组 | | `ctx.logger?.{warn,info}` | 1820 / 1890 / 1892 / 1904 / 1955 | `context.mjs:90-102` | | `ctx.credentials.resolve(credentialRef(ref))` | 2407 | `context.mjs:216-218` 恒 `undefined` | | `ctx.petBalanceOverride()` | 2405 | `context.mjs:226-266`(**发布版补丁 B1**,非 DSH API) | | `ctx.effect(...)` | 2304 / 2756 / 2771 / 2830 / 2837 / 2858 / 2869 / 2915 / 2961 | `context.mjs:127-139` | | `ctx.on("session/event", …)` | 2772 / 2831 / 2838 / 2846 | `context.mjs:142-144` 返回 noop ⇒ 工作状态永远空闲 | | `ctx.commands.register(...)` | 2858 / 2869 / 2915 | `context.mjs:191-195` noop | > 值得注意的两个「私有口子」:`ctx.get("onlineVisionReady")` 与 `ctx.petBalanceOverride` 是独立版**塞进 ctx 的自定义成员**, > 插件用 `typeof` 探测后使用(`2124`、`2405`)。这已经是「宿主适配层」的雏形,只是挂错了地方。 ### 2.3 「插件式」写法本身 ```js // src\lib\index.js:1799-1808 —— 当前实现 /** 当前交互桌宠 id:/pet 已选且仍存在 → 该宠物;未选/已失效 → 有效宠物列表第一只(进程内,重启回默认) */ const resolveActivePetId = () => { try { const eff = effectivePetList(); if (eff.length === 0) return ""; if (activePetId && eff.some((p) => String(p.id) === activePetId)) return activePetId; return String(eff[0].id); } catch { return activePetId; } }; ``` 调用点:`2183`(通知)、`2603`(`/say` 的默认宠物)、`2882`、`2920`(两个命令)。特征: 1. **状态藏在装配闭包里**:`activePetId` 是 `apply()` 作用域内的可变变量,外部无法注入、无法在测试里换掉; 2. **每次调用都重新解析**:读配置(`effectivePetList()`)→ 校验 → 回退,失败还会 `catch` 吞掉, 于是「同一时刻不同调用点的答案可能不同」,且失败静默返回上一个值; 3. **调用点散在 4 个互不相关的功能里**(通知、路由、命令),谁都不是「宠物注册表」的主人; 4. 同类写法在文件里还有一批(`helper` / `startRetryTimer` / `electronEnsure` / `disposed` / `hasDesktopPet` / `broadcastCache` 等装配级可变状态,见 `1814`、`1835-1840`、`1791-1797`)。 ### 2.4 浏览器半侧的耦合 - 路由前缀 `/dsh-pet-7340` 在 `lib\client.js` 里**硬编码 20 余处**:`137`、`363`、`387`、`411`、`445`、`464`、`999`、`1186`、`1255`、`1269`、`1516`、`1527`、`1546`、`1636`、`1664`、`2680`、`2809`、`3003`、`3036`、`3659`、`3670`、`3854`、`3913`(部分函数已经支持 `opts.baseUrl`,但默认值仍是硬编码)。 - 挂载依赖 DSH 的 UI 槽位:`lib\client.js:3986` `const inject = ["slots", …, "connection"]`、`:4027` `ctx.slots.inject("shell.overlay", …)`、`:4040` `ctx.slots.inject("settings.section", …)`。 - `src\package.json:97-108` 11 个 `@deepseek-ai/*` peerDependencies + `dsh.client.inject: ["@deepseek-ai/dsh-client-connection"]`。 --- ## 3. 目标架构:core / adapter / host 三层 ``` src\lib\index.js ← 最终只剩一个薄适配器:把 DSH 的 ctx 翻译成 core 的 options src\lib\client.js ← 浏览器半侧:不再自己拼 URL,base 由宿主传入 ▲ ▲ │ │ ┌───────┴─────────────────┐ ┌───────────┴──────────────┐ │ host\adapters\dsh.ts │ │ host\adapters\standalone │ ← 现在 standalone\ 里那套「伪 ctx」升级 │ ctx → { log, register, │ │ { log, register, llm, │ │ llm, events } │ │ events, balance } │ └───────┬─────────────────┘ └───────────┬──────────────┘ │ │ └──────────►┌────────────────────────┴───────────────┐ │ host\core\ (宿主无关的桌宠本体) │ │ PetRegistry / Router / Chat / Watch / │ │ Audio / Automation / Storage │ └────────────────────────────────────────┘ ``` - **core**:只认自己定义的接口(`Logger`、`RouteRegistrar`、`Llm`、`EventSource`、`Balance`、`Paths`),不认 `ctx`; 所有状态由 `createPetCore(options)` 一次性构建并返回句柄(句柄里含受控的 `registry`、`dispose()`)。 - **adapter**:每个宿主一个薄文件,做「翻译」和「补口子」,**不含业务逻辑**(现在 `standalone\context.mjs` 里那段 「两路模型降级」「余额直查 DeepSeek」应该从 `ctx` 挪进 core 或独立服务,而不是继续挂在 ctx 上)。 - **host**:DSH 插件形态(保留兼容,可选)、独立版(默认)。两者都只做「起 adapter、调 core、注册路由」。 --- ## 4. 分阶段计划 > 原则:**先加适配层,再切默认,最后才删旧路**。每一步都能单独回滚,且每一步结束时产品行为不变。 ### P0 · 立契约,不动行为 - 给 core 定接口(TypeScript 类型 + 一页说明),把 `standalone\context.mjs` 的伪 ctx 当作**第一个 adapter** 的参考实现。 - 把三个 shim 的语义写成测试(`resolveDshHome` 与 `dshHomeDir` 逐字一致;`credentialRef` 恒等;`BlockAssembler` 的 `blocks()` 形状)。 - 产出:接口清单 + 依赖表(谁读谁的什么),落成文档,不改代码。 **验收**:`npm test`(`src\src\host\*.test.ts` 等 9 个 host 测试 + shared 测试)全绿;`standalone\check.mjs` 全 OK。 ### P1 · 消灭 `resolveActivePetId` 这类「延迟解析」 - 建 `PetRegistry`:构造时读一次配置,暴露 `list()`、`byId(id)`、`active()`、`setActive(id)`、`subscribe(fn)`; 「id 失效就回退第一只」这条规则**只写一次**,放在 `active()` 里。 - 4 个调用点(`2183`、`2603`、`2882`、`2920`)改成 `pets.active()`;`activePetId` 从闭包变量变成 registry 内部状态,可注入、可单测。 - 同类处理:把 `hasDesktopPet` / `refreshDesktop()`(`1814-1823`)并进 registry 的派生视图,避免「两处各自算一遍」。 **验收**:新增 `pet-registry.test.ts`(空列表、当前 id 失效、显式指定 id、配置抛错四个用例);命令文案与 `/say` 行为逐条对齐旧行为。 ### P2 · 路由前缀与资源 URL 注入化 - host 侧:`ROUTE_PREFIX`(`1509`)与 `2857` 一带的 `configUrl` 改成由 adapter 传入的 `options.routePrefix`(独立版保留 `/dsh-pet-7340` 以免破坏老数据,DSH 侧可换)。 - client 侧:`lib\client.js` 里那 20 余处硬编码前缀统一走一个 `routes` 对象(已有 `opts.baseUrl` 的函数就接上它), CSS 里的 `url("/dsh-pet-7340/…")`(`464`、`2680-2681`)改成注入的 CSS 变量。 - 目标:**换前缀只改一处**,且桌面渲染端与浏览器半侧共用同一张路由表。 **验收**:独立版截图/动画/表情/气泡/设置面板六条路径全部正常(`standalone\check.mjs` 的 `/config/meta` 存储项检查 + 手工点一遍控制台)。 ### P3 · 三个宿主包去依赖 - 把 `@deepseek-ai/dsh-home-paths`、`-credentials`、`-llm` 的**本体实现**放进 `src\src\host\platform\`(内容就是现在 shim 的等价实现,`blockassembler` 等保持同一形状)。 - `src\lib\index.js:6-8` 改成从 `platform\` 导入;DSH 适配器里再用真包覆盖(或保持原样优先)。 - `src\package.json` 的 `peerDependencies` **暂不删除**(DSH 插件形态还要用),只在文档里标注「独立版不再需要」。 **验收**:`standalone\` 里删掉 `registerHooks` 也能跑(这条作为 P3 的标志性成果);DSH 插件形态回归通过。 ### P4 · 浏览器半侧与 UI 槽位解耦 - `lib\client.js` 的 `inject = ["slots", …, "connection"]`(`3986`)与两处 `ctx.slots.inject`(`4027`、`4040`) 改为「优先用宿主槽位,没有就挂到自己创建的挂载点」——桌面模式已经有自渲染端,浏览器侧只差一个 fallback 容器。 - `settings.section` 这类 DSH 专有 UI 在无宿主时降级为控制台里的对应面板(控制台本来就是单 HTML,独立可用)。 **验收**:不装 DSH 也能在普通浏览器里打开桌宠页面(把 `standalone\server.mjs` 的路由挂到一个静态页上即可验证)。 ### P5 · 收尾 - 默认活法切成独立版;DSH 插件形态作为**可选适配器**保留(`inject`/`name`/`export` 三件套不删,`dsh` 字段不删)。 - 删除 `standalone\shims\` 的别名机制(`main.mjs:69-84`),`lib\index.js` 不再需要运行期 alias。 - 更新 `src\README.md`(现在那份是上游说明文档,顶部已标注)与本文档的「已完成」章节。 --- ## 5. 边界与风险 **明确不做的事**(做下去会搬走用户配置或破坏署名): - 不改 `src\package.json` 的 `main` / `exports` / `dsh` / `peerDependencies` 的**键名**(`name` 已于 2026-10-10 由 `dsh-pet` 改成 `cute-fat-fish-pet`); - 不改桌面渲染端包名 `dsh-pet-electron-helper`、APP_ID / 数据目录 `BlueHairMaid`、localStorage `dsh-pet-*`、 路由前缀 `/dsh-pet-7340`、数据根 `%APPDATA%\BlueHairMaid`(见 [NOTICE](../NOTICE.md) 第 5 节); - 不动上游 MIT 署名与 `assets\LICENSE.txt`; - 不在同一版里既重构又改行为。 **风险点**: | 风险 | 说明 | 缓解 | | --- | --- | --- | | `ctx.llm.stream` 契约 | 必须是 **AsyncIterable**,写成 `async` 函数会立刻 TypeError,且 rejected promise 无人接管会以 unhandled rejection 终止进程 | `context.mjs:15-20` 的两条硬教训写进适配层文档与测试 | | `resolveModelInfo` 契约 | 必须返回 Promise(同步返回 undefined 会让调用方 `.then()` 炸) | 同上 | | 工作状态 | 插件靠 `ctx.on("session/event")` 驱动;独立版目前是 noop(工作状态永远空闲) | P1 之后把「工作状态源」也做成注入接口,独立版可用本机端口/文件事件替代 | | 构建链 | 现在**没有** tsdown 构建链(`src\lib\` 是签入的产物) | 改 TS 源码后必须重新生成 `lib\`;P0 要先确认这一步由谁做、怎么验证 | | 版本漂移 | 上游 0.3.6 已有 `src/standalone/`(本仓库的独立版是从它演化来的) | 去宿主化后与上游差异变大,**不再跟随上游**,只保留 MIT 署名 | --- ## 6. 未来方向:6 条架构参考(不算承诺,只算路标) > 这 6 条是主人给的参考建议(「你仅做参考,该怎么发挥你来决定」),已按「现状锚点 → 想改成什么 → 为什么值得 → 风险/代价 → 怎么验收 → 建议阶段」逐条落成可执行的样子。 > **本版 1.1.0 一律不做**,理由见 6.8。所有锚点都打开文件核对过,行号对应本仓库当前状态。 ### 6.1 事件驱动(EDA,参考 U.E.P-s-Core) **现状锚点** - 宿主半侧全部逻辑在 `src\lib\index.js` 一个文件里:2969 行 / 113465 B,`#region` 从 `:14`(balance)排到 `:1495`(host index)共 11 段。 - 屏幕监测不是事件驱动,而是**定时轮询 + 时间戳节流**:`src\lib\index.js:2304-2313` 在 `ctx.effect` 里 `setInterval(5000)`,`:2308` 用 `SW_RT.lastTick` 与 `intervalSec || 12` 秒比较后才 `swRun()`。 - 抓屏与消费粘在一条链上:`src\lib\index.js:1966`(`SW_DIR`)、`:1972`(`SW_EXE` = SwCapture.exe)、`:2028`(原生抓屏工具是否存在)、`:2238`(「省下一次 SwCapture」的注释)——「画面变了没有」由这条链自己判断,没有任何信号发出来。 - 两侧对齐靠拉:`src\lib\index.js:2318 handlePetRoute` 被 webServer 与桌面 bridge 各自消费,谁需要状态谁来拉。 **想改成什么**:进程内加一个轻量事件总线(`emit/subscribe/dispose`,不引第三方)。 屏幕监测只发布 `screen:changed`(附帧摘要 + 时间戳);碎碎念与对话订阅 `screen:changed` / `pet:user-said`;动画播放订阅 `pet:action` / `pet:mood`;控制台订阅 `state:changed` 取代轮询。 现有 `ctx.on("session/event")`(`src\lib\index.js:2772`、`:2831`、`:2838`、`:2846`)降级为**适配层**:宿主事件进总线,核心只认总线。 **为什么值得**:屏幕监测与 AI 对话可以各自开关、各自替换;轮询改订阅后待机开销和「改配置要等下一个 tick」的手感一起变好;一个 2969 行的文件想拆模块,先要有一个比函数调用更松的耦合面。 **风险 / 代价**:事件顺序与重入是新 bug 源(必须定「同步派发 + 订阅里禁止再发同类事件」,否则画面变化 → 视觉 → 又发事件 → 打转);总线必须支持 `dispose`,否则独立版反复起停会漏订阅(现在靠 `ctx.effect` 的清理语义,见 `:2304`、`:2756`、`:2771`、`:2830` 等 9 处);与 P1 的 PetRegistry 有重叠,得等「状态主人」定下来再上总线。 **怎么验收**:关掉对话功能,屏幕监测仍按 `intervalSec` 工作且 CPU 不高于今天;反之亦然。`standalone` 连续 start/stop 20 次,订阅数不增长。 **建议阶段**:**2.0**(1.2 只做「事件名常量化 + 一处试点」,不铺开) ### 6.2 提案-治理(参考 PacaPet):LLM 只提建议,确定性模块放行 **现状锚点** - 模型今天能**直接**调工具:`launcher\agent.js:43-129` 是 `TOOLS` 表(`open_app:102-107`、`open_file:108-113`、`open_url:114-119`、`record:74-84`…),决定之后直接 `def.run(args)`(`:443`)。 - 唯一的把关是**权限门**:`launcher\agent.js:435` `if (def.perm && !ctx.perm[def.perm])` → 回 `permission-denied`(`:436-440`);权限状态读不到时只写一句提示(`:368`)。 - 防绕圈靠启发式:`:429-431` 同一调用重复两次就放弃、`:460-464` `ONE_SHOT` 一次性动作办完即收尾、`:257` 把权限状态拼进提示词让模型「自己知道」。 **想改成什么**:执行拆三段——模型只产出**结构化提案**(`{intent, args, reason, confidence}`)→ **治理模块**用确定性规则校验(读同一份权限状态;路径白名单;同意图 60 秒去重;单次会话动作上限;危险动词黑名单)→ 通过才执行,拒绝时给人话理由。提案与结果各记一条审计(今天只有 `appendPermLog`,`:448`)。 **为什么值得**:现在只要权限开着,模型说开什么就开什么,治理是事后日志不是事前校验;规则是纯函数,能单测、能解释;对将来更小的本地模型是必要的安全边界。 **风险 / 代价**:多一层会带来「明明能做却被拦」的挫败感,规则必须保守起步;提案格式变化要与小模型重新调提示词(现存「兜底解析」路径 `:425-427` 要一起改);审计别写进用户隐私(路径只留 basename)。 **怎么验收**:治理规则单测四类拒绝(权限关、路径越界、重复提案、超频)且文案可读;端到端构造「模型硬要开」的场景,权限关时必须不执行。 **建议阶段**:**2.0**(1.2 可先落「提案结构 + 审计」,执行仍走老路,用来收集真实提案分布) ### 6.3 插件化 / 模块化(参考 DyberPet、OpenPets):`PetCharacter` 接口 **现状锚点** - 宠物在代码里是**松散字典**:`src\src\host\config.ts:411` `export function flattenPetList(merged: Record>): Record[]`;`src\src\host\index.ts:425` `const effectivePetList = () => flattenPetList(readAllConfig(configPaths));`;`src\src\shared\config.ts:16` `isDesktopVisible = (display) => display === 'desktop' || display === 'both'`。 - 条目形状是运行时约定、没有校验:`src\lib\index.js:2345` 的错误文案写着 `{pets:[{name?,id,size,balanceEnabled,display,position:{corner,marginX,marginY}}]}`。 - 「角色」与「能力」混在一起:动画在 `src\runtime\electron-helper\sprite.js:137/139`(两个 `