# Agent Note: client 插件装载——普通包、dsh.client 插件与双阶段 boot Status: implemented [English](2026-07-23-client-plugin-loading-model.md) | 中文 > 范围:浏览器侧的插件装载机件——什么是插件、代码怎么到达、热重载如何搭在这套模型上。装载链归本篇所有;[Web 客户端架构笔记](2026-07-19-gui-web-client-architecture.md) 在装载问题上以本篇为准,继续拥有 slot、数据对象层与 React 面。 ## Problem host 侧,cordis 插件装载站在 Node 的模块机制之上——require cache 与内部 ESM loader 拥有模块身份与字节。vendored `@cordisjs/plugin-loader` 在这层基座之上实现插件治理与热重载,二者在唯一一道边界相接:`Loader.internal`。 浏览器客户端跑同一套 cordis 插件机制,因此底下需要同样的基座——而浏览器没有 Node 模块系统。 常规前端工程在构建期消化全部依赖:单一 bundle,external 由打包器解决,运行时无物可管。在此之上再做运行时模块管理,正是这里的特殊需求。client 因此拆成两层:上层是经同一份 vendored Loader 的 cordis 插件装载,下层是模块粒度的依赖管理——`dsh-client-modules`。 下层供给四项能力:external(平台清单)、远程到达(同源外部 classic script 加惰性工厂登记)、版本化(内容哈希 rev)、热更新(invalidate/prefetch)。 插件 bundle 独立构建在 Vite 模块图之外。若把响应文本塞进内联 script,浏览器只能看到一次动态源码执行:网络资源、生成 bundle、TypeScript/TSX 源码之间没有标准 sourcemap 链,性能 profile 与 stack 只能落到生成后的 `client.js`;模块系统还要持有整份源码文本,并把同一项到达职责拆成 fetch 与 execute 两道传输边界。 在此之上,client 与 host 插件以一致的方式注册与装载:包声明一次 `dsh.client`,host 把声明扫描进 boot 图,同一套 Loader 语义在两侧治理 entry。 第一代 client loader(`createClientLoader`)把这两层手写进了同一个函数。这一融合留下的是:没有卸载/重载路径(装载一次性,style 标签从不移除)、在三个文件间人肉抄写且早已漂移的依赖清单、一条供跨插件 import 走的模块表后门——既复制了 cordis 的服务机制,又把装载顺序变成正确性约束。下文的结构取代了它。 ## Decision ### 两类包;`dsh.client` 即插件,别无他义 什么让一个包成为插件?只有一条规则:**一个包的消费方式一旦是 cordis 依赖注入,它就是插件包;在此之前它是普通包。**代码怎么到达页面不属于分类体系——到达方式由包的类别推得,而不是反过来定义类别。 - **普通包**是模块系统自身所需的绝对基座,加上尚未转成 DI 的库:react 家族、cordis、`@deepseek-ai/dsh-client-modules`(模块系统本身——它永远不可能是插件,因为模块先于一切模块)、web 壳内核,以及——暂时——ui-slots、web-react、ui-primitives。普通包打进壳 bundle、播种进模块表、对 host 图不可见。 - **插件包**是其余一切。每个都携带 `dsh.client` manifest(元数据清单)声明(`{ platform, inject, immediately? }`)和同一种统一形态:共享 tsdown 预设产出 `lib/client.js`,`exports["./client"]` 指向该 bundle。每个都是 host 独家撰写的图里受治理的 entry。当前包括:connection、runtime、ui-theme、i18n、hmr(仅进 dev 图)、ui-layout、ui-sidebar、ui-conversation、ui-model-selector、ui-question、ui-trajectory。 manifest 拥有包的装载约定:它的 `inject` 依赖边,加可选的 `immediately` 预取标记(缺省即 lazy)。负责组合的 app 只拥有名册。 新增一个插件包:声明 `dsh.client`,经共享预设产出 `./client` bundle,把包名加进负责组合的 app 的名册。除此之外无需任何交接。 普通包何时升格为插件?升级法则,记录在案让迁移路径保持诚实:**普通包在其消费方改用 cordis DI 之时升格为插件包,绝不提前。**三项升格在排队:ui-slots(现居 runtime 的 slots 机件——SlotsService、渲染器约定、root slot)、web-react(渲染器安装移入自己的 `apply`)、ui-primitives(组件经 slot/服务供给之时)。在那之前它们保持普通包身份,符号导出保持普通的静态 import。 四条边规则治理横跨两类包的 import。没有一条依赖任何单包标记: - **插件 ↔ 插件的值 import 是构建错误。**与两侧的 `immediately` 声明无关——规则不得依赖一个人人可翻转的标记。协作走 cordis inject/服务。`import type` 豁免;类型链分毫未动。这条规则正是 `scopeOf` 是 `SessionsService` 方法、`transportError` 住在 `dsh-host-apiproxy` wire 层(它的 `RpcResult` 老家,内联安全)的原因。 - **插件 → 普通包的值 import 外置为 external**,按平台清单判定。清单是壳里的一个常量(`platform.ts`:react 家族、cordis、ui-slots、web-react、ui-primitives),tsdown 预设(external 判定)与 `seed.ts`(模块表预热)都 import 它。一个常量、两个消费方——人肉同步这一漂移缺陷类死透。 - **纯度门禁覆盖每个插件包。**它的三条分支:平台 import 外置为 external;INLINE_SAFE wire 层内联;其余任何 workspace 泄漏即构建错误。正是统一的 bundle 形态让这一覆盖不留死角——每个插件都经同一预设构建,没有包能坐在门禁之外。 - **壳自足。**内核(boot + loading 页)对任何插件包零值 import;其状态 store 为手写。大声失败的呈现不得依赖它所报告失败的那个系统。 ### 一套模块系统,一个插件治理器 浏览器复刻 host 侧的分工。`dsh-client-modules`(`ClientModuleSystem`)坐上 host 侧由 Node 内部 ESM loader 占据的模块系统席位;同一份 vendored `@cordisjs/plugin-loader` 在两侧都坐治理席。二者的分界线一句话说尽:**模块系统拥有模块身份与字节——代码怎么到达、怎么登记、怎么变成导出内容;Loader 拥有插件生命周期——插件何时挂载、等待什么、如何拆除。** `ClientModuleSystem` 是一张 lazy CJS 表。执行 bundle 只**登记**其工厂——bundle 调用 `window.__ModuleLoader__.load({ id, factory })`,此外什么都不发生。模块体的一切副作用(包括 CSS 注入)都住在工厂闭包里,在物化时运行:物化即该 id 的首次 `require`/import,此后记忆化。工厂若 require 一个已登记未物化的同伴,就递归物化它,因此任何地方都不存在排序。被要求 import 一个 id 时,表按固定分支顺序解析:种子词条 → 记忆化的记录 → 静态登记(壳自有模块,如 app-shell)→ 已登记的工厂 → 图行外部 classic script 加载 → 大声抛错。最后这一抛是构建期纯度门禁在运行期的镜像。系统还保管逐模块的簿记——名下 `