dsh-deepworks
DeepWorks 对 DeepSeek Harness 的适配层。这是一个可安装的组合包(bundle):装上就生效,不需要 --patch,也不需要改 harness 源码。
dsh plugin --profile web add dsh-deepworks
dsh web
现在这一层只有品牌:Web UI 里露出的 DeepSeek Harness 标识换成 DeepWorks 的。模型配置与登录认证是计划中的下两层,骨架已经留好,见后续两层。
现在换掉了什么
| 位置 | 换成 |
|---|
侧边栏左上角的横幅(展开态)——鲸鱼 + deepseek 字标 + HARNESS 徽章 | assets/deepworks-wordmark.png(横向字标) |
| 侧边栏收起态、空态大图里的鱼形标记 | assets/deepworks-logo.png(方形标记) |
| 浏览器标签页图标 | 同上那张方形标记 |
浏览器标签页标题 DeepSeek Harness | DeepWorks |
两张图取自 fastagi-web-obs.deepexi.com 的 deepworks-wordmark@2x.png 与 deepworks-logo.png,已落盘进包内。
安装
三种交付方式,效果一样,代价不同:
| 方式 | 命令 | 说明 |
|---|
| npm | dsh plugin --profile web add dsh-deepworks | 发布时已构建好 lib/,安装零摩擦 |
| tarball | dsh plugin --profile web add ./dsh-deepworks-0.1.0.tgz | pnpm pack 产出,同样是预构建产物 |
| GitHub | dsh plugin --profile web add github:Dbraum/dsh-deepworks#<sha> | 拉的是源码,靠 prepare 脚本现场构建,见下 |
从 git 安装时 pnpm 拉到的是源码不是构建产物,本包的 prepare 脚本会在安装后跑 tsdown 补上 lib/。但 pnpm 10+ 在得到显式允许前拒绝执行 git 依赖的 prepare,所以首次 add 会失败;把 pnpm 打印的包键写进该 profile 的 pnpm-workspace.yaml 再重试:
allowBuilds:
dsh-deepworks: true
请如实看待这项授权:它允许本包的代码在安装时于你的机器上执行,且不在 agent 的任何沙箱之内。锁定 commit(#<sha>)能让后续推送无法悄悄改变实际运行的内容。不想让使用者做这项授权,就用 npm 或 tarball 分发。
卸载:dsh plugin --profile web remove dsh-deepworks,依赖和对应的层一起消失。
使用
两种用法,取决于你有没有 dsh:当组合包用是本包的本职,装上即生效、不写代码;当普通 npm 包用是给自建 cordis 应用的,需要自己挂载。
一、当 dsh 组合包用(主路径)
从零到看见效果,三条命令:
dsh plugin --profile web add dsh-deepworks # 装包,patch 层自动并入 profile
dsh web # 起 Web UI
# 浏览器打开输出的地址,侧边栏 logo、标签页图标和标题都已是 DeepWorks
生效不需要额外配置——图和标题都内置在包里。装上之后 $DSH_HOME/profiles/web/ 下会多出这一层,位置在 harness 自带两层之后、你自己那层之前(见原理的第二张图)。
想确认到底装没装上:dsh plugin 是把参数透传给 pnpm 的(dsh plugin --profile <name> <pnpm args>),所以 pnpm 的命令都能用:
dsh plugin --profile web list # 透传成 pnpm list,应能看到 dsh-deepworks
要换成自己的图和标题,不改本包,在自己 profile 的 cordis.patch.yml 里按 id 覆盖,详见换成自己的图和标题。改完不用重启,刷新浏览器即可。
二、当普通 npm 包用(编程式)
如果你不是在 dsh 里,而是自己搭的 cordis 应用,可以直接装:
npm install dsh-deepworks # 或 pnpm add / yarn add
前提两条:Node ^22.19.0 || >=24,以及本包是 纯 ESM("type": "module"),CommonJS 里只能用动态 import()。宿主还必须提供 webServer 服务——品牌层的 inject = ['webServer'] 声明了这个依赖,没有它插件不会启动。
包有两个 subpath 导出,各管一摊:
| 导出 | 内容 |
|---|
dsh-deepworks | 品牌事实常量:PRODUCT_NAME、ASSETS(两张内置图的绝对路径) |
dsh-deepworks/branding | 品牌层插件本体:apply、inject、name、Config,外加几个纯函数 |
挂载插件:
import { Context } from '@deepseek-ai/cordis'
import WebServer from '@deepseek-ai/dsh-host-webserver'
import * as branding from 'dsh-deepworks/branding'
const ctx = new Context()
await ctx.plugin(WebServer, { host: '127.0.0.1', port: 5140 }) // 必须先有 webServer
await ctx.plugin(branding, {}) // 用内置的 DeepWorks 图和标题
await 一个 fiber 表示等它加载完成。品牌层的 inject 会让它自己等 webServer 就绪,所以两句的先后不影响正确性,但 await 能让配置校验的报错当场抛出来,而不是变成一个没人接的 rejection。
传配置就是第二个参数,字段与默认值见配置项:
await ctx.plugin(branding, {
images: {
brand: '/absolute/path/to/my-wordmark.png',
mark: '/absolute/path/to/my-logo.png',
},
title: '我的产品',
targets: ['brand', 'mark'],
})
注意 images 只吃本地绝对路径或远程地址(https:// / // / data:),相对路径不行。配置在加载期校验:文件不存在、routePath 形状不合法、地址里有会越出 CSS 引号的字符,都会当场让插件启动失败并指名字段,不会拖到浏览器请求时才 404。
只用内置的品牌常量,不挂插件也行——比如你想在自己的页面里引用同一张图:
import { PRODUCT_NAME, ASSETS } from 'dsh-deepworks'
console.log(PRODUCT_NAME) // 'DeepWorks'
console.log(ASSETS.wordmark) // …/node_modules/dsh-deepworks/assets/deepworks-wordmark.png
console.log(ASSETS.logo) // …/node_modules/dsh-deepworks/assets/deepworks-logo.png
ASSETS 给的是文件系统绝对路径(靠 import.meta.url 算出来的),不是 URL——可以直接 readFile,但要在浏览器里显示得你自己挂路由或复制到静态目录。
卸载:插件的每处注册都走 ctx.effect(),所以停掉 fiber 就全部回卷,路由变 404、index.html 的注入撤干净:
const fiber = await ctx.plugin(branding, {})
await fiber.dispose()
三、离线 / 内网安装
拿不到 npm registry 时用 tarball,产物完全一样:
pnpm pack # 本仓库里产出 dsh-deepworks-0.1.0.tgz
dsh plugin --profile web add ./dsh-deepworks-0.1.0.tgz # 或 npm install ./dsh-deepworks-0.1.0.tgz
tarball 里是预构建产物,装的时候不跑构建、不需要联网,也不需要 allowBuilds 授权——这点和 git 安装相反(git 装的是源码,见安装)。
包的形状
一个包、一个 patch 层、一个版本;每一层是一个 subpath 导出,patch 里按 dsh-deepworks/<层> 引用。loader 拿 name: 当普通模块 specifier 去 import,所以子路径和包名一样能解析。
dsh-deepworks/
cordis.patch.yml # DeepWorks 适配层:现在一行 branding,以后加行
assets/ # 品牌资产
src/
index.ts # 各层共用的品牌事实(产品名、两张图的路径)→ 包的 "." 导出
branding/index.ts # 品牌层插件 → "./branding"
加一层就是:src/<层>/index.ts 写插件、package.json 的 exports 加一条、tsdown.config.ts 的 entry 加一条、cordis.patch.yml 加一行。src/index.ts 必须保持独立入口而不是被打进各层——品牌资产的路径靠 import.meta.url 算,它得稳定落在 lib/ 下一层深。
换成自己的图和标题
不要改本包,在自己 profile 的 cordis.patch.yml 里按 id 覆盖即可($DSH_HOME/profiles/<name>/cordis.patch.yml):
- id: deepworks-branding
config:
images:
brand: '/absolute/path/to/my-wordmark.png'
mark: '/absolute/path/to/my-logo.png'
title: '我的产品'
targets: ['brand', 'mark']
images 的值也可以直接写 https:// / // / data: 地址,那样插件不挂路由,浏览器自己去源站取图——图放在 OBS/CDN 上时省掉一次落盘:
- id: deepworks-branding
config:
images:
brand: 'https://fastagi-web-obs.deepexi.com/deepworks/assets/deepworks-wordmark%402x.png'
mark: 'https://fastagi-web-obs.deepexi.com/deepworks/assets/deepworks-logo.png'
targets: ['brand', 'mark']
代价是浏览器要能连到那个域名(离线、内网隔离、CSP 收紧时就取不到,页面会退回空白框),而本地文件由 dsh 自己吐字节,没有这层依赖。
patch 是整块替换 config,不是按键深合并,所以要把想保留的字段一并重述——images 这一层同样是整块替换,只写 brand 时 mark 会退回包内自带的图。改完不用重启:patch 层被监听,进程会重新组合,刷新浏览器即可。换图片文件本身更简单——插件每次请求重新读盘,直接覆盖文件再刷新就行。
配置项
| 字段 | 默认 | 说明 |
|---|
images.brand | 包内 assets/deepworks-wordmark.png | 侧边栏左上角那条横幅用的图 |
images.mark | 包内 assets/deepworks-logo.png | 方形标记用的图,默认也是标签页图标 |
routePath | /deepworks/logo | 本地图片挂载的路由前缀,每张落在 <前缀>/<目标>;需与站内已有路由错开 |
replaceFavicon | true | 是否连浏览器标签页图标一起换 |
faviconSource | 'mark' | 标签页图标取哪个目标的图 |
title | 'DeepWorks' | 浏览器标签页标题;给空串表示不动上游的 DeepSeek Harness |
targets | ['brand', 'mark'] | 换哪几处品牌图形,见下表 |
images 的值是本地绝对路径或远程地址二选一。本地路径指向不存在的文件、routePath 形状不合法、地址里含引号括号一类会越出 CSS 引号的字符,都在加载期直接让插件启动失败并指名是哪个字段——不留到浏览器请求时才 404。标题走的是另一条路:它是正常会出现 & 的用户文本,所以转义而不是拒绝。
支持 png / jpg / gif / webp / svg / ico,按扩展名给 Content-Type。图片以 background-size: contain 贴进原 svg 的框里,等比缩放不裁切,所以任何比例的图都不会变形。
targets 的两个取值对应两处互不重叠的内置组件:
| 取值 | 换掉的东西 | 出现位置 | 适合放 |
|---|
brand | 品牌整块:鲸鱼 + deepseek 字标 + HARNESS 徽章画在同一个 svg 里 | 侧边栏左上角(展开态) | 横向字标 |
mark | 单独的鱼形标记 | 侧边栏收起态、空态大图 | 方形标记 |
侧边栏展开时画的是 brand,它自带鲸鱼,所以只给 mark 不会影响侧边栏;要全站换干净就两个都写。faviconSource 那张图不受 targets 约束:写 targets: ['brand'] 只换左上角,标签页图标照样能用方形标记,插件会单独为它挂上路由。
原理
品牌层只有 Node 半,不声明 dsh.client,所以不需要浏览器打包。它只用了 ctx.webServer 的两个扩展点,每处注册都走 ctx.effect(),插件卸载时自动撤销:
flowchart LR
PKG["package.json<br/>dsh.bundle.patch"] --> PATCH["cordis.patch.yml<br/>insert dsh-deepworks/branding"]
PATCH --> APPLY["apply(ctx, config)<br/>inject: ['webServer']"]
APPLY -->|"ctx.effect + register()"| ROUTE["每张本地图一条 HTTP 路由<br/>/deepworks/logo/brand、…/mark<br/>每次请求重新读盘"]
APPLY -->|"ctx.effect + tapIndex()"| TAP["index.html 改写<br/>每个目标一组规则、换 favicon、换 title"]
TAP --> HTML["浏览器拿到的 index.html"]
ROUTE --> IMG["浏览器加载图片"]
REMOTE["https:// 的图<br/>不挂路由"] --> IMG
HTML -->|"CSS 选择器 svg[viewBox=…]"| SWAP["内置品牌图形被背景图顶替"]
IMG --> SWAP
装上以后,本包这一层落在 profile 组合里 harness 自带的两层之后、使用者自己那层之前:
flowchart TB
ROOT["空的 profile 根"] --> B1["@deepseek-ai/dsh-base"]
B1 --> B2["@deepseek-ai/dsh-web-app"]
B2 --> B3["dsh-deepworks<br/>本包的 cordis.patch.yml"]
B3 --> P["profile 的 cordis.patch.yml<br/>← 使用者在这里覆盖 images / title"]
P --> H["$DSH_HOME/cordis.patch.yml"]
H --> O["--patch overlay"]
关键取舍:为什么是 CSS 覆盖,而不是替换组件。Web 客户端的插槽表里没有「品牌 logo」这个槽位,最近的是整块 sidebar——为了换一张图去接管整个侧边栏,代价远大于收益。两个品牌 svg 各带一个稳定且全站唯一的 viewBox(0 0 182 24 与 0 0 23.16 17.04),用它当选择器是耦合最小的做法。
规则本身只有两条:svg 自己贴背景图,svg[viewBox=…]>* 把原有图形藏掉。藏子元素而不是藏 svg,是为了让 svg 的宽高继续为布局占位,换图时周围元素不跳动;选 >* 而不是 >path,是因为品牌整块由 path / g / rect 多种元素拼成。品牌整块是一条 182×24 的横幅,自定义图片几乎不可能是这个比例,所以它用 background-position: left center 靠左对齐——右侧多出的空白落在 .brand 这个 flex: 1 的按钮里,不会挤动右边的折叠按钮;鱼形标记那处的框近乎方形,图片居中最自然,用的是 center。这也是「两处各给一张图」比「一张图管两处」好的原因:一条横幅塞进方形框里会缩得很小,一个方形标记铺进横幅框里则只占左侧一小截。
后续两层
这一节记录的是已经查清的落点,不是承诺的排期。两层的性质完全不同:模型配置几乎不需要代码,认证则被上游的扩展点挡住。
模型配置:纯声明,不写代码
dsh 的模型接线全在 patch 行里,DeepWorks 这一层要做的只是覆盖 base 层已有的几行——所以它不会是 insert: 的新插件,而是 cordis.patch.yml 顶层的几条覆盖:
| 行 id | base 层的现状 | DeepWorks 要定的 |
|---|
agent-default-model | provider: deepseek-official / model: deepseek-v4-flash | 新会话默认落在哪个 provider 与模型 |
llm-deepseek | 原生 DeepSeek adapter,key 与 endpoint 不内联,每次请求从 settings 解析 | 走 DeepWorks 网关时改 endpoint |
llm-pi-ai | 多 provider 孪生,空载挂着:没有 provider profile 就零路由 | 如果 DeepWorks 网关是 OpenAI 兼容的,provider profile 写在这里 |
要紧的是分清两个层次:patch 层是组合期的默认,$DSH_HOME/settings.yaml 才是运行期的真相(热重载,Web 的 Models 页写的就是它),而密钥属于第三层——credentials 行的 dsh-credentials-local 按「继承的环境变量 > $DSH_HOME/.credentials.yaml > 项目/用户 .env」解析,adapter 每次请求现取。所以本包可以定 endpoint 和默认模型,但不该把任何 key 写进包,只该声明它从哪个环境变量读。
登录认证:目前没有 seam
先说结论:这一层不能靠装一个插件解决。ctx.webServer 只有四个扩展点——register(具名路由)、registerUpgrade、registerFallback(只能注册一次,已被 SPA 静态服务占用)、tapIndex——没有中间件链。插件能注册自己的路由,但拦不住别人的:/api 的 HTTP 桥接与下行 WebSocket 属于 connection 插件的路由,dist 属于 fallback 持有者。webserver 自己的文档也把这点写在了「已知限制」里:不提供 TLS、认证或来源策略,绑非回环地址就是把服务暴露给对应网络。上游目前也没有账户概念——identity/ 那一层明说「这些值不表示经过身份验证的账户」,匿名 id 只是遥测关联用的 UUID。
于是只有两条真路径:
- 前置反向代理(推荐,零上游改动):dsh 只绑
127.0.0.1,nginx / oauth2-proxy 之类在前面做认证与 TLS,认证通过才转发。本包在这条路径上能做的是可选的锦上添花——比如把代理透出的用户名渲染进页面。
- 给上游加 seam:在 webserver 上开一个中间件/路由包装的扩展点,再由
dsh-deepworks/auth 注册。这是改上游,需要走 harness 那边的评审,好处是认证成为组合的一部分而不是部署的一部分。
一条看起来可行但别单独用的岔路:本包自己注册 /login 页面和签发 cookie,却没法校验 /api 上的每个请求——那是半套认证,比没有更危险。
开发
pnpm install # 装依赖,并通过 prepare 跑一次构建
pnpm run build # tsdown:两个入口 → lib/index.js + lib/branding/index.js(各带 .d.ts)
pnpm run typecheck
pnpm run test # 冒烟 + 集成,都不需要浏览器和 API key
pnpm pack # 产出可 dsh plugin add 的 tarball
tests/smoke.ts 用一个假的 ctx(只提供 webServer、logger、effect)直接跑 apply,快且不起进程,覆盖两张图各走各的路由、只换 brand 时图标仍取 mark、远程地址不挂路由、标题替换与转义、以及四类加载期校验;tests/integration.ts 则 new Context() 挂上真的 @deepseek-ai/dsh-host-webserver 和品牌层,用 fetch 打真实端口逐条比对字节,并在卸载 fiber 后确认两条路由都变成 404、index taps 也撤干净了。这份可测性来自「插件只依赖 ctx 上的服务」。
依赖只有三样,各有各的角色:@deepseek-ai/schemastery 是唯一的运行时依赖(配置 schema 真的会执行);@deepseek-ai/cordis 和 @deepseek-ai/dsh-host-webserver 都只被 import type,运行期不产生 import,所以是 devDependency——运行期的依赖声明是插件导出的 inject = ['webServer'],由宿主满足。
本包由 dsh-logo-swap 0.2.0 改名重组而来:那个包是通用的「换掉 logo」,这个包是「DeepWorks 的适配层」,图与标题都已内置,版本号因此重新从 0.1.0 起算。
发布到 npm
包名不带 scope,就叫 dsh-deepworks。
pnpm run typecheck && pnpm run test # 发布是不可撤销的,先让两道闸都绿
npm version patch # 或 minor / major:改 package.json 并打一个 git tag
npm login # 一次即可,凭据存在 ~/.npmrc
pnpm publish --access public # prepare 会在打包前自动跑一次 tsdown
pnpm publish 打包前会跑 prepare,所以发出去的一定是当次源码构建的产物,不会是本地残留的旧 lib/。tarball 里只有 files 列的 lib/、assets/、cordis.patch.yml,加上 npm 总会带上的 package.json、README.md、LICENSE——src/、tests/ 和构建配置都不在内。想先确认,用 pnpm publish --dry-run --no-git-checks,或 pnpm pack 出 tarball 后装到一个空目录里 import 一遍。
两件容易忘的事:npm 上的版本不能覆盖,发错了只能再发一个版本号(npm unpublish 有 72 小时窗口且会留下墓碑);发布的 package.json 里会原样带着那条指向本地 checkout 的 link: devDependency,消费者不装 devDependencies 所以无害,但 clone 仓库的人会踩到,见「已知限制」第一条。
已知限制
- 和 harness 的版本是硬耦合的。 npm 上目前发布的
@deepseek-ai/dsh-host-webserver@0.0.1-rc.1 提供的服务名还是 httpServer,而本包按当前 harness 的 webServer 写。所以 package.json 里那条 devDependency 指向本地 checkout(link:../deepseek-harness/packages/host/webserver),克隆本仓库的人需要把 harness 放在同级目录,或改成一个已发布且服务名匹配的版本。harness 处于 pre-release,明确不承诺兼容旧格式,分发本包时要说明它对应哪个 dsh 版本。
viewBox 是与上游组件的唯一耦合点。 上游若调整品牌图形的画布尺寸,src/branding/index.ts 里的两个常量要跟着改,否则选择器静默失配——插件照常加载,只是什么都没换掉。
- 只换图,不能换成自定义组件。 那需要
packages/client 侧真有一个品牌插槽(ctx.slots.register),属于要改上游的改动。
- 品牌只覆盖到 index.html 这一层。 PWA 的
manifest.webmanifest(名称与图标)、favicon.svg 本体、以及界面里出现的产品名文案都还是上游的;manifest 可以用一条同名 exact 路由顶掉——具名路由胜过 dist 的 fallback——但那是下一步,现在没做。
- 远程地址不在插件的掌控内。 写
https:// 的图由浏览器直接去源站取,源站挂了、断网、或站点将来收紧 CSP 的 img-src,图就加载不出来,而插件这边一切正常、无从报错。要确定性就用本地文件。