Skip to content

Web 前端按两个互不相交的半数构建,在运行时才汇合。shellapps/web 之于 @deepseek-ai/dsh-client-web)是编译过的 Vite 应用;插件则是按需加载的 client.js bundle。本页走通整条链路:dsh web → 被伺服 distindex.html → shell 启动 → 经 slot 渲染 UI。

两个构建目标

目标构建工具产物
插件 bundle每个 dsh.clienttsdownpackages/client/tsdown.client.ts每个包一个 ./client.js,外加 map 与 package.json exports["./client"]
Shell@deepseek-ai/dsh-web-frontend = apps/webVite(apps/web/vite.config.tsdist/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.webmanifestfavicon.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 网关与 WebSocket/SSE 下行;web-runtime 行挂载 @deepseek-ai/dsh-web-app(该 bundle 的胶水插件,packages/bundle/web-app/src/index.ts)。

@deepseek-ai/dsh-web-app 解析前端 dist(工作区事实,绝非用户配置)——通过 require.resolve('@deepseek-ai/dsh-web-frontend/dist/index.html')——然后把 @deepseek-ai/dsh-host-frontend-static 挂到 Web 服务器的兜底座registerFallback)上。frontend-staticpackages/host/frontend-static/src/index.ts)以锁定的语义伺服:越出 dist 根目录的穿越 → 403,任何未命中 → 以 200 返回 index.html(SPA 路由),非 GET/HEAD → 405。每个 index 响应都流经 ctx.webServer.applyIndexTaps,后者执行启动清单与启动主题注入。

dsh web 的 CLI 缝

@deepseek-ai/dsh-web-app/startuppackages/bundle/web-app/src/startup.ts)用一个 commander 命令解析 --profile web 的标志家族(--host--port--trusted-host),并作为 WEB_STARTUP_SERVICE 提供不可变值。由标志配置的行会 inject 该服务(在它存在后解析)——例如 webserver 行的 host/port 默认与 connection 行的信任围栏:

ts
context: `host: 127.0.0.1, port: 3080(webStartup 覆盖)、trustedHosts: ctx.webRuntime.trustedHosts`

--host 0.0.0.0 会被响亮拒绝——把 RCE 暴露给网络不可接受;--port 0 可让操作系统挑选端口。服务器绑定后,web 运行时一次性采样 LAN IPv4 字面量(resolveLanTrust)、发布 webRuntime,并在 Loader 树落定后打印就绪 URL 行(dsh web: http://127.0.0.1:3080,绑定所有接口时附 (LAN: …))。监管器与无键 CLI 冒烟一看到该行便发起 RPC。

两次 index tap

每个 index.html 响应都按组合顺序跑两条已注册的 index 变换:

  1. client-modules启动清单 tap——injectBootManifest<head>第一个子节点处插入 <script>window.__DSH_BOOT__ = {…}\u003c…</script>,并转义 < 以防插件控制的字符串逃出 script 元素。这是内核在存在任何 Cordis 之前解析的线上数据。
  2. ui-theme启动主题 tap——injectBootTheme 恰在 <body> 之后放一个解析 system 并写 colorScheme + data-ds-dark-theme<script>,让首屏就带正确主题。

两条都在 SPA 兜底路径上运行(任何深路由都以带有这两条 tap 的 index.html 响应),因此在 /conversation/… 上刷新会重新注入同样的启动状态。

txt
dsh web (--host --port --trusted-host)
   └─ @deepseek-ai/dsh-web-app            解析 dist → frontend-static
        └─ @deepseek-ai/dsh-host-webserver  兜底座
             └─ applyIndexTaps
                  ├─ client-modules:window.__DSH_BOOT__ 注入
                  └─ ui-theme:<body> 之后的启动主题 <script>

启动时序

AppWebEntry.run()packages/client/web/src/boot.tsx)是 shell 内核。各阶段:

  1. 解析 window.__DSH_BOOT__ 为双视图 BootManifest(模块行 + 插件行)。
  2. 构建基于模块行的 ClientModuleSystem,带上平台 staticModules(react 家族、cordis、ui-slots、ui-primitives、ui-attachment、schema-form,来自 seed.ts),随后 registerStatic 注册 shell 自有模块 @deepseek-ai/dsh-client-app-shell@deepseek-ai/dsh-client-modules
  3. 立即渲染加载页AppRoot)——一条「shell 自足」规则:插件加载期间页面必须照常可用。
  4. new Context()、挂载 vendored 的 Cordis Loader、把模块系统注入为 loader.internal、为每个插件行以及 app-shell 组装入口各创建一个 loader 入口,然后 loader.await()
  5. 全量 fiber 清扫assertEntriesActive)响亮失败,列出哪个入口还在 pending(等待缺失的服务)或 failed。
  6. 翻转 settled,让 AppRoot 一次性切换到真实 UI。

启动图里标记 immediately 的行会与 Loader 挂载并行预取(仅注册工厂),使无论哪个入口具现化之前都能解析跨包的同步 require 边;单行预取失败会静默,因为创建侧导入会重载并上报。

app-shellpackages/client/web/src/app-shell.ts)在其 inject 集合激活后才运行;它安装 createSlotRenderer()(仅启动一次)并提供 appShell.renderApp,后者调用唯一的 ctx 级 renderSlot('root', {})

ts
// app.tsx
return () => (<>
  <SessionDocumentTitle />
  {ctx.slots.renderSlot('root', {})}
</>)

供测试用的 AppWebEntry

AppWebEntry 接收一个可选的 BootSeams 对象(Pick<ClientModuleSystemOptions, 'loadBundle'>),让启用 jsdom 的测试用一个进程内替身替换 <script> 的 bundle 传输钩子。生产环境不传任何缝,使用默认的同源 <script src> 加载器(system.ts 里的 defaultLoadBundle)。内核其余部分——parseBootManifestClientModuleSystemAppRootstaticModules——都能对同一条 __DSH_BOOT__ 线上数据做测试,因为启动直到 connection.start 被调用前都与任何真实宿主完全解耦。

这也是 apps/web/src/main.ts 可以只有三行的原因:shell 库拥有启动;应用只负责找到挂载节点。

插件如何添加 UI

插件完全通过 slot + 模块添加 UI——shell 对任何具体功能一无所知。插件入口在 Cordis 上下文上调用 ctx.slots.register({ name: '…', … }, Component);当某 slot 的出口渲染 root 时,渲染器拼装其 props 并挂载它。root slot 的唯一占用者是 ui-layoutAppFrame,它随后渲染自己的 sidebar/conversation/details/shell.overlay 子项。因此「添加 UI」通常就是选一个已声明的座(例如一个新的 conversation.chat.node 键或某个 shell.overlay id)去注册——参见 UI 模块

AppRoot 门与响亮失败的启动

AppRootpackages/client/web/src/AppRoot.tsx)是一个零插件依赖的纯内核组件——响亮失败的呈现绝不能依赖它所要报告的那个系统。它订阅三个内核信号:settledstatus(每入口 fiber 状态)与 error(启动拒绝信息)。落定前它渲染一张加载卡(HARNESS 标识 + 转圈);失败时它罗列每个 failed 的入口 fiber 与清扫错误信息,并留在加载页——绝无半成品 UI。

成功路径是一次切换:settled 翻转时 AppRoot 渲染 props.renderApp(),启动闭包经 ctx.appShell.renderApp() 转路由。因为 app-shell 本身就是一个 loader 入口(唯一的 shell 自有模块),激活插件的同一轮清扫也激活了渲染它们的组装。

Vite 构建细节

apps/web/vite.config.ts 做了四件值得留意的事:

  1. 拒绝独立伺服——除非由 dsh web 伺服(它注入 window.__DSH_BOOT__),否则 rejectStandaloneServe 抛错,使裸 dev server 永远无法暴露一个没有启动清单的 shell。
  2. 给每个工作区模块打哈希——shell bundle 是 Vite 编译的唯一工作区代码:插件包绝不在此被打包(shell 自足);它们以运行时 ./client.js bundle 经模块系统到达。
  3. 手工 vendor 分块——数学(KaTeX)、语法高亮(shiki)与 markdown(micromark/mdast)进 vendor(懒语法进 assets/langs/、KaTeX 字体进 assets/fonts/);三个启动语法(typescriptshellscriptjson)骑在 vendor 上,让初始加载保持小巧。
  4. 源码别名解析——工作区包解析到 src,使 CSS 流经 Vite(而非 CSS 外置的 lib bundle);@deepseek-ai/dsh-client-webpackages/client/web/src/boot.tsx,再加上 web-reactui-slotsui-primitivesui-attachmentschema-formmodules/clientnode:module 被一个会抛错的浏览器替身 stub 掉,process.versions.node/process.execArgv/CORDIS_SHARED 被钉死,让 vendored loader 的 Node 探测在浏览器里失效。

因此构建出的 dist/ 里含 index.htmlassets/index-*.jsassets/vendor-*.jsassets/langs/*.js(懒 Shiki 语法)、assets/fonts/*.{woff2,woff,ttf}(KaTeX 字体面)以及 public/ 直通 manifest.webmanifest + favicon.svg。由于只有 index 分块会因 shell 源码变更而重哈希,回访客户端能保留缓存的 vendor 分块——手工分块拆分既是一种缓存纪律,也是一种代码组织。

插件 bundle 则相反,由 @deepseek-ai/dsh-client-modules(Node 面)在 /plugins/<id>/client.js?rev=<sha1> 下以 cache-control: no-cache 伺服。rev 查询串就是缓存炸弹:重构建的 bundle(经 pnpm run dev:web + HMR 链)得到新哈希,浏览器绝不停在陈旧插件上。rev 经 clientModules.rebuilt(id) 收敛,并经 HMR SSE 通道级联到页面——见客户端运行时

主题

@deepseek-ai/dsh-client-ui-theme 拥有一个持久化 ui-theme 设置区(preference = light | dark | system,默认 system)以及浏览器 ThemeRuntime,后者通过 matchMedia('(prefers-color-scheme: dark)') 解析 system。基础调色板以 token CSS 自定义属性形式存在(--dsw-alias-*),位于 packages/client/ui-theme/src/styles/

在插件树激活之前,宿主会在每次 index.html 响应中注入启动主题脚本(boot-theme.ts):它从宿主读取持久化偏好,在浏览器里解析 system,并写 document.documentElement.style.colorSchemebody[data-ds-dark-theme]——即选择暗色调色板的属性。

客户端起来后,ui-layoutThemePresentertheme-presenter.ts)把每个解析出的 ThemeSnapshot 投影到文档上:根 color-scheme、取自 active.colorScheme(绝非 id)的暗色属性、alias token 覆盖作为 body 上的内联 CSS 变量,以及一个 presenter 自有的 meta[name="theme-color"]。第三方主题可注册 { id, colorScheme, tokens },或通过 overrideTokens(source, {token:{light,dark}}) 叠层。外观行(AppearanceRow.tsx)是 settings.general.item 的一个入口。

token 模型与暗色模式

基础变量表位于 packages/client/ui-theme/src/styles/base.cssdesign-platform.cssscrollbar.cssshiki.cssgradient-shadow-text.css)。设计系统是双调色板 token CSS:明暗两套调色板携带同一批 --dsw-alias-* token 名,body[data-ds-dark-theme] 选中暗色值,无需类名重写。覆盖层(主题包或模型作者式换肤)在其上加第三轴——body 上的内联 --dsw-alias-* 变量,其在 {light, dark} 对里按当前 colorScheme 选取。因为 token 是唯一风格货币,重绘整个应用的主题绝不触碰组件 CSS——它只注册 token。

启动 <script> 保证首屏就已正确上色(插件树解析 system 前绝无亮色闪烁),而 ThemeRuntimeprefers-color-scheme 监听器会在偏好为 system 时随操作系统配色方案翻转而重发。持久化 ui-theme 区(z.object({ preference }))让外观行跨会话持久化并重载选择。

本页涉及的包

@deepseek-ai/dsh-web-frontend(apps/web)
@deepseek-ai/dsh-client-web
@deepseek-ai/dsh-client-web-react
@deepseek-ai/dsh-client-ui-theme
@deepseek-ai/dsh-client-ui-layout
@deepseek-ai/dsh-web-app(bundle/web-app)
@deepseek-ai/dsh-host-frontend-static
@deepseek-ai/dsh-host-webserver
@deepseek-ai/cordis(vendored)

延伸阅读