Web 前端分两半构建,在运行时相遇。shell(apps/web 架在 @deepseek-ai/dsh-client-web 之上)是一个编译好的 Vite 应用;插件是懒加载的 client.js bundle。本页顺着整条链走一遍:dsh web → 被伺服 dist → index.html → shell 启动 → slot 渲染 UI。
两个构建目标
| 目标 | 包 | 构建者 | 产物 |
|---|---|---|---|
| 插件 bundle | 每个 dsh.client 包 | tsdown(packages/client/tsdown.client.ts) | 每包一个 ./client.js,外加 map 与 package.json exports["./client"] |
| shell | @deepseek-ai/dsh-web-frontend = apps/web | Vite(apps/web/vite.config.ts) | dist/index.html + 带哈希的 assets/ 块 |
apps/web 不是独立应用——其 Vite 配置在你尝试裸 serve/preview 时会抛错(rejectStandaloneServe),因为只有宿主注入 window.__DSH_BOOT__。入口很薄:
// apps/web/src/main.ts
import { AppWebEntry } from '@deepseek-ai/dsh-client-web'
const el = document.getElementById('root')
if (el === null) throw new Error('web app: missing #root')
void new AppWebEntry(el).run()Vite 的 manifest.webmanifest 与 favicon.svg 在 apps/web/public/;docs/web-styling.md 记录了 shell 遵守的 CSS 约定。
伺服构建产物
Web 传输的宿主面(bundle/web-app/cordis.patch.yml 里的补丁)挂载这些 web 行。webserver 行(@deepseek-ai/dsh-host-webserver,默认 127.0.0.1:3080)注册 /api 网关前缀与 /api/remote.mux WebSocket upgrade;web-runtime 行挂载 @deepseek-ai/dsh-web-app(bundle 的胶水插件,packages/bundle/web-app/src/index.ts)。
@deepseek-ai/dsh-web-app 经 require.resolve('@deepseek-ai/dsh-web-frontend/dist/index.html') 解析前端 dist(workspace 知识,绝非用户配置),然后把 @deepseek-ai/dsh-host-frontend-static 挂到 web 服务器的fallback 座椅(registerFallback)上。frontend-static(packages/host/frontend-static/src/index.ts)以锁定语义伺服:越出 dist 根 → 403,缺失或非文件目标 → 404(深层 SPA 未命中不再回退到 index.html),非 GET/HEAD → 405。index 路径在读取字节前由 ctx.connection.authorizeIndex 把关(见客户端运行时与链路):URL 携带有效启动令牌时铸造 dsh-auth-* cookie 并 303 跳到干净的 /,携带有效 cookie 就伺服页面,其余请求一律得到相同的最小 401。每个 index 响应都流经 ctx.webServer.renderIndex——结构化注入渲染器,运行 boot 清单与 boot 主题注入,并追加 __DSH_BOOT_READY__ 尾。
dsh web CLI 缝
@deepseek-ai/dsh-web-app/startup(packages/bundle/web-app/src/startup.ts)用 commander 命令解析 --profile web 旗标族(--host、--port、--trusted-host,以及新的 --no-open),并以 WEB_STARTUP_SERVICE 提供不可变值。旗标配置的行 inject 该服务(在它存在后解析)——例如 webserver 行的 host/port 默认与 connection 行的信任围栏:
context: `host: 127.0.0.1, port: 3080 (webStartup overrides), trustedHosts: ctx.webRuntime.trustedHosts, openBrowser: true`--host 0.0.0.0 会被大声拒绝——把 RCE 暴露给网络是不可辩护的;--port 0 让 OS 挑一个。服务器绑定后,web 运行时一次性采样 LAN IPv4 字面量(resolveLanTrust)、发布 webRuntime,并在 Loader 树落定后打印就绪 URL 行。打印的 URL 是已鉴权 URL:dsh web: <origin>/?token=<launchToken>(全接口绑定时附 (LAN: …))——这是浏览器无需 cookie 就能打开的唯一 URL(见 packages/bundle/web-app/src/index.ts:261-280)。除非传了 --no-open,它还会在默认浏览器里打开该 URL。宿主还设置 DSH_WEB_URL shell 变量(指向规范本地 URL 的 bash 变量),供会话 prompt 使用。监督器与无密钥 CLI 冒烟测试看到 URL 行即开始 RPC。
结构化 index 注入与就绪尾
来自 frontend-static 的每个 index.html 响应都流经 ctx.webServer.renderIndex,按组合顺序运行已注册的注入行(IndexInjection 值):
client-modulesboot 注入 tap——bootInjections(graph)(packages/client/modules/src/index.ts)发出结构化行:在<head>安装__ModuleLoader__队列门面的内联<script>、application 阶段 bundle 的script-preload行、bootstrap 阶段 bundle 的阻塞script-src行,以及{ kind: 'global', name: '__DSH_BOOT__', value: graph }行。转义纪律不变:插件可控字符串无法逃出 script 元素。ui-themeboot-theme tap——在<body>后放一个<script>,解析system(以及存储的fontSize),写入colorScheme+data-ds-dark-theme+ CSS 变量--dsh-content-font-size,让首屏主题正确。
渲染器随后追加 __DSH_BOOT_READY__ 尾——一个解析 Promise.withResolvers() 延期的 <script>(globalThis.__DSH_BOOT_READY__)——启动内核在每行都生效之后等待它(见下)。深层路由未命中反正会 404,无关紧要;被伺服页面的相对资源 URL 经 <base href="/"> 拼接锚定到站点根。
dsh web (--host --port --trusted-host --no-open)
└─ @deepseek-ai/dsh-web-app 解析 dist → frontend-static;打印已鉴权 URL
└─ @deepseek-ai/dsh-host-webserver fallback 座椅
└─ authorizeIndex(401 / 令牌→cookie 303)
└─ renderIndex → 结构化 IndexInjection 行 + __DSH_BOOT_READY__ 尾启动序列
AppWebEntry.run()(packages/client/web/src/boot.ts,纯 .ts——旧的 boot.tsx/app-shell.ts/AppRoot.tsx 已删除)是 shell 内核。分阶段:
- 先等 boot 就绪门——
await __DSH_BOOT_READY__.promise(boot.ts:54):被伺服 index 在渲染尾里解析它,因此该 await 在下一个微任务返回;异步 bootstrap 在它的最后一行后解析。 - 解析
window.__DSH_BOOT__为双视图BootManifest(模块行 + 插件行)。 - 构建 基于模块行的
ClientModuleSystem,平台staticModules来自seed.ts——react 家族、cordis、dsh-client-store、ui-slots、ui-primitives。不再有app-shell模块,也没有ui-attachment/schema-form种子词;shell 只把自己(@deepseek-ai/dsh-client-web)与client-modules注册为静态行。 - 立刻渲染无框架
BootPage——shell 自足规则:插件加载期间页面必须仍可工作。 new Context()、挂载 vendored CordisLoader、把模块系统注入为loader.internal、每插件行建一个 loader 入口、预取immediately行,然后loader.await()。- 全量 fiber 清扫(
assertEntriesActive)大声失败,点名哪一行 pending(等待缺失服务)或失败。 - 把挂载点交给渲染器——
ctx.inject(['uiRenderer'], …)→scope.uiRenderer.mount(container)(boot.ts的mountApp)。渲染器经BootHandoff(packages/client/ui-renderer/src/client/index.ts)水合内核拥有的 boot DOM:React 的hydrateRoot保留data-dsh-boot标记,直到useLayoutEffect把它切换成组装好的应用——无闪烁、单次切换。
启动图中标记 immediately 的行在 Loader 挂载期间并行预取(仅工厂注册),因此跨包同步 require 边可在任何入口具现化前解析;按行预取失败保持静默,因为 create 侧 import 会重载并报告它。
面向测试的 AppWebEntry 缝
AppWebEntry 接受可选 BootSeams 对象(Pick<ClientModuleCreateOptions, 'loadBundle'>),让 jsdom 环境的测试用进程内替身替换 <script> bundle 传输钩子。生产不传 seams,用默认同源组合 URL <script src> 加载器。内核里其余一切——parseBootManifest、ClientModuleSystem、BootPage、staticModules——都可针对同一 __DSH_BOOT__ 链路测试,因为启动与任何真实宿主完全解耦,直到 connection.start 被调用。
这也是 apps/web/src/main.ts 能只有三行的原因:shell 库拥有启动;应用只负责找到挂载节点。
插件如何加 UI
插件完全经由 slots + modules 加 UI——shell 对任何具体功能一无所知。插件入口(拥有 Cordis 上下文上的 _apply)调用 ctx.slots.register({ name: '…', … }, Component);渲染器组合其 props,并在该 slot 的出口渲染 root 时挂载它。root slot 的唯一占用者是 ui-layout 的 AppFrame,后者再渲染它的 sidebar/conversation/details/shell.overlay 子级。因此「加 UI」通常意味着挑一个已声明座椅(例如新的 conversation.chat.node 键或某个 shell.overlay id)并注册——见 UI 模块。
大声失败的 boot 页
BootPage(packages/client/web/src/boot-page.ts)是零插件依赖的纯内核组件——大声失败的呈现不能依赖它所报告的那个系统。它订阅 Loader fiber 状态;落定前渲染加载卡(wordmark + spinner + 经 loader-status.ts 的每入口标签),失败或 boot 就绪 promise 过期时点名每一个 fiber failed 的入口与错误,停留在 boot 页——不存在部分 UI。
成功路径是一次交接:当 uiRenderer.mount 运行,渲染器的 slot 树渲染 root,组装好的应用在那一次水合中被替换进 boot DOM。
Vite 构建细节
apps/web/vite.config.ts 做了四件值得注意的事:
- 拒绝独立伺服——
rejectStandaloneServe在 bundle 不是由dsh web伺服时抛错(只有它注入window.__DSH_BOOT__),裸 dev server 永远暴露不出无 boot 清单的 shell。 - 哈希每个 workspace 模块——shell bundle 是 Vite 编译的唯一 workspace 代码:插件包绝不在这里打包(shell 自足);它们在运行时作为
./client.jsbundle 经模块系统到达。 - 手工 vendor 分块——math(KaTeX)、语法高亮(shiki)、markdown(micromark/mdast)进
vendor(assets/langs/放懒加载文法、assets/fonts/放 KaTeX);三个 boot 文法(typescript、shellscript、json)骑在 vendor 上,保持首载小。 - 删除 workspace 源别名列表——workspace 包如今作为构建好的库产品经它们自己的
package.jsonexports被消费;剩下的唯一别名用抛错浏览器替身(./src/node-module-stub.ts)stub 掉node:module,并固定process.versions.node/process.execArgv/CORDIS_SHARED,让 vendored loader 的 Node 探测在浏览器里保持惰性。
因此构建出的 dist/ 包含 index.html、assets/index-*.js、assets/vendor-*.js、assets/langs/*.js(懒加载 Shiki 文法)、assets/fonts/*.{woff2,woff,ttf}(KaTeX 字体),以及 public/ 透传的 manifest.webmanifest + favicon.svg。因为只有 index 块在 shell 源码变化时重新哈希,回头客保留缓存的 vendor 块——手工分块既是缓存纪律也是代码组织。
插件 bundle 则相反,由 client-modules Node 面以组合 URL 伺服——/plugins/??<pkg>/client.js,<pkg2>/client.js&rev=<hash>——带 cache-control: public, max-age=31536000, immutable(IMMUTABLE_CACHE);按插件的 /plugins/<id>/client.js URL 留给 HMR 与 source map。rev 是缓存杀手:重建的 bundle(经 pnpm run dev:web + HMR 链)获得新图 rev,浏览器绝不滞留陈旧插件。Rev 经 clientModules.rebuilt(id) 收敛,并经 HMR SSE 通道级联到页面——见客户端运行时与链路。
主题
@deepseek-ai/dsh-client-ui-theme 拥有一个持久 ui-theme 设置分区——preference(light | dark | system,默认 system)与新的 fontSize 偏好(默认 14,范围 12–17)——外加一个用 matchMedia('(prefers-color-scheme: dark)') 解析 system 的浏览器 ThemeRuntime。基础调色板是 packages/client/ui-theme/src/styles/ 里的令牌化 CSS 自定义属性(--dsw-alias-*)。
插件树激活前,宿主向每个 index.html 响应注入 boot 主题脚本(boot-theme.ts):读取宿主的持久偏好 + 字号,在浏览器里解析 system,写入 document.documentElement.style.colorScheme、body[data-ds-dark-theme] 与 document.body.style.setProperty('--dsh-content-font-size', '<n>px')——即选择暗色板与内容字号的属性。
客户端起来后,ui-layout 的 ThemePresenter(theme-presenter.ts)把每个解析出的 ThemeSnapshot 投影到文档:根 color-scheme、来自 active.colorScheme 的暗色属性(绝非 id)、body 上作为内联 CSS 变量的别名令牌覆盖,以及一个呈现器拥有的 meta[name="theme-color"]。第三方主题注册 { id, colorScheme, tokens } 或叠加 overrideTokens(source, {token: {light,dark}})。Appearance 与 FontSize 行(AppearanceRow.tsx、FontSizeRow)是 settings.general.item 条目(order 10 与 11)。
令牌模型与暗色模式
基础变量表在 packages/client/ui-theme/src/styles/(base.css、design-platform.css、scrollbar.css、shiki.css、gradient-shadow-text.css)。设计系统是双调色板令牌 CSS:明暗板都携带相同的 --dsw-alias-* 令牌名,body[data-ds-dark-theme] 选择暗值,无需类名重写。覆盖层(主题包或模型写的皮肤)在上面加第三轴——body 上的内联 --dsw-alias-* 变量,其 {light, dark} 对由活跃 colorScheme 选择。因为令牌是唯一风格通货,重绘整个应用的主题从不碰组件 CSS——它注册令牌。
boot <script> 保证首屏已正确着色与定字号(插件树解析 system 前无亮色闪烁),而 ThemeRuntime 的 prefers-color-scheme 监听器在偏好为 system 时于 OS 方案翻转时重新发出。DSH_CLIENT_BUILD_PROFILE='official' 额外激活 ui-brand-official 对 sidebar.brand.mark/sidebar.brand.name 的占用者(否则用鱼形标记兜底)。
本页涉及的包
| 包 |
|---|
@deepseek-ai/dsh-web-frontend(apps/web) |
@deepseek-ai/dsh-client-web |
@deepseek-ai/dsh-client-store |
@deepseek-ai/dsh-client-ui-renderer |
@deepseek-ai/dsh-client-ui-theme |
@deepseek-ai/dsh-client-ui-layout |
@deepseek-ai/dsh-client-ui-sidebar |
@deepseek-ai/dsh-web-app(bundle/web-app) |
@deepseek-ai/dsh-host-frontend-static |
@deepseek-ai/dsh-host-webserver |
@deepseek-ai/cordis(vendored) |
延伸阅读
- 前端:客户端运行时与链路 — 这个 boot 之下的 Connection RPC 信封、浏览器会话鉴权与
/api传输。 - 前端:UI 模块 — 38 模块名册与框架声明的 slot 座椅。
- 前端:本地化 与前端:设置 Schema 与表单 — boot 的 UX 与设置邻居。
- LLM 平台:宿主平台 — 宿主 webserver、frontend-static 与
/api信任平面。 packages/client/web/src/boot.ts—AppWebEntry、boot 就绪门;apps/web/vite.config.ts讲构建 +apps/web/index.html。packages/host/frontend-static/src/index.ts— 404/401 把关的 dist 服务器与穿越守卫。