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 网关与 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-static(packages/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/startup(packages/bundle/web-app/src/startup.ts)用一个 commander 命令解析 --profile web 的标志家族(--host、--port、--trusted-host),并作为 WEB_STARTUP_SERVICE 提供不可变值。由标志配置的行会 inject 该服务(在它存在后解析)——例如 webserver 行的 host/port 默认与 connection 行的信任围栏:
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 变换:
client-modules的启动清单 tap——injectBootManifest在<head>的第一个子节点处插入<script>window.__DSH_BOOT__ = {…}\u003c…</script>,并转义<以防插件控制的字符串逃出 script 元素。这是内核在存在任何 Cordis 之前解析的线上数据。ui-theme的启动主题 tap——injectBootTheme恰在<body>之后放一个解析system并写colorScheme+data-ds-dark-theme的<script>,让首屏就带正确主题。
两条都在 SPA 兜底路径上运行(任何深路由都以带有这两条 tap 的 index.html 响应),因此在 /conversation/… 上刷新会重新注入同样的启动状态。
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 内核。各阶段:
- 解析
window.__DSH_BOOT__为双视图BootManifest(模块行 + 插件行)。 - 构建基于模块行的
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。 - 立即渲染加载页(
AppRoot)——一条「shell 自足」规则:插件加载期间页面必须照常可用。 new Context()、挂载 vendored 的 CordisLoader、把模块系统注入为loader.internal、为每个插件行以及app-shell组装入口各创建一个 loader 入口,然后loader.await()。- 全量 fiber 清扫(
assertEntriesActive)响亮失败,列出哪个入口还在 pending(等待缺失的服务)或 failed。 - 翻转
settled,让AppRoot一次性切换到真实 UI。
启动图里标记 immediately 的行会与 Loader 挂载并行预取(仅注册工厂),使无论哪个入口具现化之前都能解析跨包的同步 require 边;单行预取失败会静默,因为创建侧导入会重载并上报。
app-shell(packages/client/web/src/app-shell.ts)在其 inject 集合激活后才运行;它安装 createSlotRenderer()(仅启动一次)并提供 appShell.renderApp,后者调用唯一的 ctx 级 renderSlot('root', {}):
// app.tsx
return () => (<>
<SessionDocumentTitle />
{ctx.slots.renderSlot('root', {})}
</>)供测试用的 AppWebEntry 缝
AppWebEntry 接收一个可选的 BootSeams 对象(Pick<ClientModuleSystemOptions, 'loadBundle'>),让启用 jsdom 的测试用一个进程内替身替换 <script> 的 bundle 传输钩子。生产环境不传任何缝,使用默认的同源 <script src> 加载器(system.ts 里的 defaultLoadBundle)。内核其余部分——parseBootManifest、ClientModuleSystem、AppRoot、staticModules——都能对同一条 __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-layout 的 AppFrame,它随后渲染自己的 sidebar/conversation/details/shell.overlay 子项。因此「添加 UI」通常就是选一个已声明的座(例如一个新的 conversation.chat.node 键或某个 shell.overlay id)去注册——参见 UI 模块。
AppRoot 门与响亮失败的启动
AppRoot(packages/client/web/src/AppRoot.tsx)是一个零插件依赖的纯内核组件——响亮失败的呈现绝不能依赖它所要报告的那个系统。它订阅三个内核信号:settled、status(每入口 fiber 状态)与 error(启动拒绝信息)。落定前它渲染一张加载卡(HARNESS 标识 + 转圈);失败时它罗列每个 failed 的入口 fiber 与清扫错误信息,并留在加载页——绝无半成品 UI。
成功路径是一次切换:settled 翻转时 AppRoot 渲染 props.renderApp(),启动闭包经 ctx.appShell.renderApp() 转路由。因为 app-shell 本身就是一个 loader 入口(唯一的 shell 自有模块),激活插件的同一轮清扫也激活了渲染它们的组装。
Vite 构建细节
apps/web/vite.config.ts 做了四件值得留意的事:
- 拒绝独立伺服——除非由
dsh web伺服(它注入window.__DSH_BOOT__),否则rejectStandaloneServe抛错,使裸 dev server 永远无法暴露一个没有启动清单的 shell。 - 给每个工作区模块打哈希——shell bundle 是 Vite 编译的唯一工作区代码:插件包绝不在此被打包(shell 自足);它们以运行时
./client.jsbundle 经模块系统到达。 - 手工 vendor 分块——数学(KaTeX)、语法高亮(shiki)与 markdown(micromark/mdast)进
vendor(懒语法进assets/langs/、KaTeX 字体进assets/fonts/);三个启动语法(typescript、shellscript、json)骑在 vendor 上,让初始加载保持小巧。 - 源码别名解析——工作区包解析到
src,使 CSS 流经 Vite(而非 CSS 外置的 lib bundle);@deepseek-ai/dsh-client-web→packages/client/web/src/boot.tsx,再加上web-react、ui-slots、ui-primitives、ui-attachment、schema-form、modules/client。node:module被一个会抛错的浏览器替身 stub 掉,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 则相反,由 @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.colorScheme 与 body[data-ds-dark-theme]——即选择暗色调色板的属性。
客户端起来后,ui-layout 的 ThemePresenter(theme-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.css、design-platform.css、scrollbar.css、shiki.css、gradient-shadow-text.css)。设计系统是双调色板 token CSS:明暗两套调色板携带同一批 --dsw-alias-* token 名,body[data-ds-dark-theme] 选中暗色值,无需类名重写。覆盖层(主题包或模型作者式换肤)在其上加第三轴——body 上的内联 --dsw-alias-* 变量,其在 {light, dark} 对里按当前 colorScheme 选取。因为 token 是唯一风格货币,重绘整个应用的主题绝不触碰组件 CSS——它只注册 token。
启动 <script> 保证首屏就已正确上色(插件树解析 system 前绝无亮色闪烁),而 ThemeRuntime 的 prefers-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) |
延伸阅读
- 前端:客户端运行时与链路 — 这套启动之下的 RuntimeObject 层与
/api传输。 - 前端:UI 模块 — 框架声明的 ui-* 清单与 slot 座。
- 前端:本地化 — 主题/体验的另一半(语言)。另见 Schema 表单。
- LLM 与平台:宿主平台 — 宿主 webserver、frontend-static 与
/api信任面。 packages/client/web/src/boot.tsx—AppWebEntry,两阶段启动内核;apps/web/vite.config.ts(构建 +apps/web/index.html)。packages/host/frontend-static/src/index.ts— SPA 兜底服务器与穿越防护。