Skip to content

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/webVite(apps/web/vite.config.ts)dist/index.html + 带哈希的 assets/ 块

apps/web 不是独立应用——其 Vite 配置在你尝试裸 serve/preview 时会抛错(rejectStandaloneServe),因为只有宿主注入 window.__DSH_BOOT__。入口很薄:

ts
// 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 行的信任围栏:

ts
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 值):

  1. client-modules boot 注入 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 元素。
  2. ui-theme boot-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="/"> 拼接锚定到站点根。

txt
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 内核。分阶段:

  1. 先等 boot 就绪门——await __DSH_BOOT_READY__.promise(boot.ts:54):被伺服 index 在渲染尾里解析它,因此该 await 在下一个微任务返回;异步 bootstrap 在它的最后一行后解析。
  2. 解析 window.__DSH_BOOT__ 为双视图 BootManifest(模块行 + 插件行)。
  3. 构建 基于模块行的 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 注册为静态行。
  4. 立刻渲染无框架 BootPage——shell 自足规则:插件加载期间页面必须仍可工作。
  5. new Context()、挂载 vendored Cordis Loader、把模块系统注入为 loader.internal、每插件行建一个 loader 入口、预取 immediately 行,然后 loader.await()。
  6. 全量 fiber 清扫(assertEntriesActive)大声失败,点名哪一行 pending(等待缺失服务)或失败。
  7. 把挂载点交给渲染器——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 做了四件值得注意的事:

  1. 拒绝独立伺服——rejectStandaloneServe 在 bundle 不是由 dsh web 伺服时抛错(只有它注入 window.__DSH_BOOT__),裸 dev server 永远暴露不出无 boot 清单的 shell。
  2. 哈希每个 workspace 模块——shell bundle 是 Vite 编译的唯一 workspace 代码:插件包绝不在这里打包(shell 自足);它们在运行时作为 ./client.js bundle 经模块系统到达。
  3. 手工 vendor 分块——math(KaTeX)、语法高亮(shiki)、markdown(micromark/mdast)进 vendor(assets/langs/ 放懒加载文法、assets/fonts/ 放 KaTeX);三个 boot 文法(typescript、shellscript、json)骑在 vendor 上,保持首载小。
  4. 删除 workspace 源别名列表——workspace 包如今作为构建好的库产品经它们自己的 package.json exports 被消费;剩下的唯一别名用抛错浏览器替身(./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)

延伸阅读 ​