dsh-client-ui-mc-skin
给 DeepSeek Harness Web 客户端用的 Minecraft 风格主题:像素立体按钮、MC GUI 调色板、on/off 开关、可持久化的开关,以及可选的 Minecraft AE 像素字体。
PNG 素材已内联进 bundle(5 张 / 2552 字节);字体不内联——16 MB 的 assets/MinecraftAE-Pixel.ttf 由本包的 Host 半边按需发货,只在开关打开时才下载。所以本包现在两半都有:lib/index.js(Host,字体路由 + 设置命名空间)+ lib/client.js(浏览器)。
1.2.0 变更:① 移除「MC 字体颜色」十六色调色板(用不到),默认文字配色改为固定的四层递进; ② 两个开关接上 Host 用户设置文档,重启后保留;③ Host 半边新增设置命名空间注册。 从 1.1.x 升级 必须重启
dsh web—— Host 半边只在进程启动时 import 一次。
安装
前提:你已经跑起来过 dsh web,也就是 ~/.dsh/profiles/web/ 存在(Windows 是 C:\Users\<你>\.dsh\profiles\web\)。
1. 解压到你喜欢的任意目录,例如 D:\plugins\dsh-client-ui-mc-skin。
2. 装进 profile
dsh plugin --profile web add link:D:\plugins\dsh-client-ui-mc-skin
这条命令把参数转发给 profile 目录里的 pnpm,等价于在 ~/.dsh/profiles/web/ 下执行 pnpm add <你解压的路径>。
用 link: 前缀会把 profile 的 node_modules 指向你解压的目录本身——之后 node build.mjs 重新构建,重启即生效。想装成独立副本就去掉 link:(走 file: 语义,会拷一份)。
3. 登记进 bundles —— 已自动,无需手工
本包 package.json 里声明了:
"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }
并自带 cordis.patch.yml(一行 insert)。dsh plugin add 结束时会按"已装状态"重算 dsh.profile.bundles,看到这个声明就自动把包名追加进去,所以不用再手工编辑 package.json。
如果第 2 步没带
link:/没装成功,或者你手动把包解压到别处而不是走dsh plugin add,那就还得自己把它加进~/.dsh/profiles/web/package.json的dsh.profile.bundles。profile 的cordis.yml是空数组,整棵插件树是由bundles里每一项逐层叠加出来的——只装上而不登记,插件不会被加载。
4. 重启 dsh web。
bundle 层是启动时读取的,所以第一次装完必须重启;之后改代码才走热重载。
这次重启不只是为了 bundle 层:字体路由是 Host 半边注册的,而 Host 半边的模块只在进程启动时 import 一次。1.1.0 起本包有了 Host 半边,所以从旧版升级上来也必须重启一次,否则浏览器那边一切正常、字体开关却会显示"加载失败"。
验证装好了
重启后:
- 侧边栏左上角的标志变成 24×24 的像素图(
minecraft24x.png),右边的MINECRAFT也是像素字体 - 整个界面变成 MC 深色:工作区
#313233、对话区#6B6B6B、按钮是#C6C6C6立体斜角、分页是绿板 - 界面文字变成 Minecraft AE 像素字体
- 设置 → 通用里多出两行:MC 主题 / MC 像素字体
- 设置 → 模型 → 点「编辑」展开的面板是黑底 + 亮边框(见下文《浅色模式漏白底》)
想确认字体路由本身通了,可以直接打这条 URL:
curl -I http://127.0.0.1:3080/dsh-mc-skin/font.ttf
# 期望 200 + content-type: font/ttf + 一个 ETag
卸载
dsh plugin --profile web remove dsh-client-ui-mc-skin
这一步会顺带把它从 bundles 里摘掉(同样是自动 reconcile)。重启生效。
或者不卸载,直接去 设置 → 通用。两个开关完全独立:
- MC 主题 关掉 → 配色、标志图、主题样式表还原成原生;像素字体如果开着,仍然继续生效
- MC 像素字体 关掉 → 只还原字体,配色和标志图保持 MC 皮肤
- 两个都关掉 →
<style>元素整个移除,和没装一样
分享给别人
本包没有任何构建期依赖,lib/ 是提交进仓库的成品,所以别人拿到就能装,不需要在他们机器上跑构建。
包体积主要是字体:assets/MinecraftAE-Pixel.ttf 16 MB,zip 压完大约 8 MB。不带字体也能跑(字体开关会显示"加载失败",界面用系统字体)——只要把 assets/MinecraftAE-Pixel.ttf 删掉再打包即可,别的都不用改。
方式一:发 zip
对方解压后(路径随意,比如 D:\plugins\dsh-client-ui-mc-skin):
dsh plugin --profile web add D:\plugins\dsh-client-ui-mc-skin
这里有个要提前说清楚的差别(两种写法实测行为不同):
| 写法 | pnpm 实际记录 | 解压目录删掉/挪走后 |
|---|---|---|
add <目录> |
link:(SymbolicLink 指向解压目录) |
插件失效 |
add file:<目录> |
真实副本(拷进 profile) | 仍然可用 |
- 想让对方自己改图重构建:用第一种(
link:),解压目录就是生效的源,改完node build.mjs重启即生效。 - 想发出去就不管了:用
add file:<目录>,装完对方可以把解压目录删掉。
两种写法都不需要联网,也都不需要对方装构建工具。装完重启 dsh web(bundle 层是启动时读的)。
方式二:GitHub
仓库根目录就是包根目录(package.json / cordis.patch.yml / lib/ 都在根上),然后:
dsh plugin --profile web add github:<用户>/<仓库>
生产上建议钉到 tag 或 commit,否则别人拿到的是默认分支的浮动最新版:
dsh plugin --profile web add github:<用户>/<仓库>#v1.0.0
上 GitHub 前的检查清单:
-
lib/client.js和lib/index.js都必须提交,别写进.gitignore——git 安装不会跑构建 -
assets/MinecraftAE-Pixel.ttf也是,别被*.ttf之类的规则误伤(本包.gitignore只忽略node_modules/和编辑器杂物) -
cordis.patch.yml在仓库根,否则dsh不会把它登记进bundles - 不要加
prepare脚本:pnpm 会把它当成需要审批的构建脚本(allowBuilds)拦下来。本包只有build/prepack,git 安装时都不会触发 -
package.json里补上repository/homepage/bugs(本包目前没有填) - 发版时改
version并打 tag
GitHub 单文件上限是 100 MB,16 MB 的字体没问题;但注意 git 历史是不可变的,以后换字体会让仓库持续变大。
三个坑
- npm registry 装不了。 本包没有发布到 npm,
dsh plugin add dsh-client-ui-mc-skin这种裸包名会失败。只能用本地路径或 GitHub。 - 依赖 DSH 内部结构。 槽位名、主题 token 名、CSS Module 局部类名都是跟着当前 DSH 版本走的,见下文《已知限制》。
- 升级到 1.1.0 要重启。 1.1.0 新增了 Host 半边(字体路由),而 Host 模块只在进程启动时 import。
它改了什么
分五块,都通过 cordis 的标准接口,停止时会干净回滚:
| 动作 | 机制 |
|---|---|
| 覆盖主题颜色 | theme.overrideTokens(包名, tokens),返回 disposer |
| 注入样式表 | document.head 插一个带 data-plugin-css 的 <style>,卸载时移除 |
| 三个槽位 | slots.register({ name, priority: -1 }, 组件) 到 sidebar.brand.mark / sidebar.brand.name / conversation.hero.brand.mark |
| 两行设置 | slots.register 到 settings.general.item(列表槽位,order 13 / 14) |
| 字体路由 | Host 半边 webServer.register({ kind: 'exact', path: '/dsh-mc-skin/font.ttf', ... }) |
| 开关持久化 | Host 半边 settings.register('ui-mc-skin', schema);客户端 ctx.settingsScope.bind({ namespace }) 读写 |
开关持久化
两个开关(主题、像素字体)现在存在 Host 的用户设置文档里 —— 也就是
$DSH_HOME/settings.yaml(Windows 是 C:\Users\<你>\.dsh\settings.yaml)。
关掉主题再重启 dsh web,它仍然是关着的。
写进去的形状就是普通一节 YAML:
ui-mc-skin:
themeEnabled: false
pixelFontEnabled: true
两边各一半,字段名必须对齐:
| 位置 | 作用 | |
|---|---|---|
| Host | src/index.js |
settings.register('ui-mc-skin', schema) 声明命名空间 |
| Client | src/client.js |
ctx.settingsScope.bind({ namespace: 'ui-mc-skin' }) 读写同一节 |
常量在两边各有一份(SETTINGS_NAMESPACE / SETTINGS_FIELD_*),
改一处必须改另一处 —— 这是两个 bundle 共享不了模块的老问题,
和 FONT_ROUTE 一样。
几个刻意的取舍:
settingsScope是可选依赖。 拿不到时(连接处于 memory 模式、或 ui-settings 没装配)自动退化成纯内存行为:开关照常能用,只是不跨会话保留。插件不会因为 持久化能力缺席而变砖。- 写失败不回滚界面。 设置文档只读、断连、revision 冲突都只记一条日志, 本次会话内开关仍按用户点的样子生效,下次启动回到上一次成功落盘的值。 让一次写入失败把用户刚点的那一下弹回去,比不持久化更糟。
- 采纳时不回写。 启动读到 Host 的值只用来 set 本地 store,不再写回去,
否则会自己触发一次无意义的写入 —— 和
ui-theme的adopt()一致。 - 先采纳再渲染。
apply()里 bind + 采纳排在applySkin()之前, 否则会出现"先按默认值渲染一帧、再跳到存的值"的闪烁。
schemastery 已经不再使用 —— 见下节。本包现在零运行时依赖。
⚠ Host 半边不要 import 任何外部包(link: 装法的致命细节)
Host 半边曾经 import z from '@deepseek-ai/schemastery'。在 web profile 上能跑,
在桌面端(tauri profile)上直接 ERR_MODULE_NOT_FOUND —— 整个 Host 半边加载失败,
字体路由和设置命名空间一起消失,而且 Host 侧只报一行模块错误,界面看不出所以然。
原因不是"没装",而是Node 解析软链时用的是真实路径:
profile/node_modules/dsh-client-ui-mc-skin → 软链 → D:\12501\Documents\deepseek\dsh-client-ui-mc-skin
└─ Node 从这里向上找 node_modules
只到 D:\12501\Documents\deepseek\node_modules
← 够不到 dsh 自带的依赖树
两个 profile 的差异就在这里:
node_modules/@deepseek-ai/ |
结果 | |
|---|---|---|
| web profile | 摊着 schemastery、cosmokit |
解析成功(侥幸) |
| tauri / 桌面端 profile | 空目录,依赖全在桌面端自带的 dsh 树里 | 解析失败 |
web profile 能跑纯粹是因为 pnpm 恰好把 schemastery 提到了那一层。换个 profile、
换个 pnpm 版本、换个 nodeLinker 设置,就会静默炸掉。
所以本包的 Host 半边只 import Node 内置模块(node:fs/promises、node:url)。
需要 dsh 的能力(webServer、settings)一律通过 ctx 拿,那是运行时的服务查找,
不走 Node 的模块解析。
如果你要给自己写的插件加 Host 半边,且打算用
link:分发: 先在一个和开发环境不同的 profile 上装一遍再发布。 判据很简单 ——lib/index.js顶部的import只能是node:*。
为什么要自己写 schema
dsh-settings 对 schema 的用法只有两处(见 dsh-settings/lib/index.js):
// resolve():把「composition base + 用户层」合并后喂进来,取回补过默认值的对象
const value = schema(mergeLayers(base, section))
// describe():给配置界面看的 JSON Schema
registration.schema.toJSON()
所以只要「可调用 + 有 toJSON」就够,不需要完整 schemastery。
src/index.js 里那 30 行自包含实现满足这个契约,且对脏数据安全:
| 输入 | 结果 |
|---|---|
{} |
{themeEnabled:true, pixelFontEnabled:true} |
{themeEnabled:false} |
{themeEnabled:false, pixelFontEnabled:true} |
{themeEnabled:'yes', pixelFontEnabled:0} |
两个都回落默认(不做类型强转) |
null |
两个都回落默认 |
坏字段不会让整个命名空间解析失败 —— 那会导致插件加载不出来。
选择器核对:类名是猜的,token 是真的
主题里那批 [class*="_xxx"] 选择器当初是照截图猜的。拿 deepseek-harness-master
源码核对后(脚本 .dsh-tools/verify-selectors.mjs),58 条里有 37 条是死规则——
类名在源码里根本不存在,规则一直静默失效,不报错。
原因是这些面板的类名跟猜的完全不是一个形状:
| 面板 | 真实实现 | 真实类名 | 当初猜的 |
|---|---|---|---|
| 代码块 | ui-primitives/markdown/CodeBlock.module.css |
.block .body .banner |
_codeViewer _codeBlock |
| 文件差异 | ui-primitives/DiffBlock.module.css |
.block .line .add .del |
_diffHeader _lineAdd _diffDel |
| 读取面板 | ui-primitives/ReadBlock.module.css |
.block .gutter .line |
_codeViewer _lineNumber |
| 终端输出 | ui-primitives/TerminalBlock.module.css |
.block .output .prompt |
_terminal _logView |
都是短名(.block、.line、.add),根本没有 _diff* 这种长前缀。
所以那 8 条规则从来没有命中过任何东西。
修法是不再猜类名,改走 token。这四个面板的底色只来自一个 token:
CodeBlock.module.css :16 background: var(--dsl-code-block-background)
└─ --dsl-code-block-background: var(--dsw-alias-markdown-code-block)
DiffBlock.module.css :8 background: var(--dsw-alias-markdown-code-block)
ReadBlock.module.css :11 background: var(--dsw-alias-markdown-code-block)
TerminalBlock.module.css:21 background: var(--dsw-alias-markdown-code-block)
--dsw-alias-markdown-code-block 浅色模式 = bluish-50 = rgb(249,250,251)(近白),
文字早被主题染成浅色 ⇒ 白底白字。现在把这一对 token 钉成 MC 深色即可,
一处生效、四个面板同时跟随,不再依赖任何类名。
同时修掉的还有:
_tab_/_tabActive_—— 多写了个尾下划线。CSS Module 产出的是_tab_<hash>, 尾字符是 hash 首字符而不是下划线,所以这两条也是死的。去掉尾下划线即可。[class*="Close"]—— CSS Module 不会把类名首字母大写;真实类名是ui-primitives/Modal.module.css的.close(全小写),编译成_close_<hash>。--dsw-alias-bg-layer-4——design-platform.css里只有layer-1..3, 没有layer-4,覆盖它等于写一个没人读的变量。
改完复跑核对脚本:58 条 → 19 条,死规则 37 → 0(剩下 2 处是记录历史的注释文字); 24 个被覆盖的 token 全部真实存在。
保留的
[class*="_rowCard"] > [class*="_editor"]是有效的,别误删:rowCard在ui-settings-models/ModelsSection.module.css:55确实存在, 只是它以styles['rowCard']下标形式引用。核对脚本必须同时认styles.foo和styles['foo']两种写法,否则会把这条误判成死规则 (第一版脚本就犯了这个错)。
为什么品牌槽位必须写 priority: -1
sidebar.brand.mark / sidebar.brand.name 是 single 槽位:同一优先级只允许一条注册,
官方品牌插件(dsh-client-ui-brand-official)已经占在 priority: 0 上。规则是最低者渲染
(lowest renders),所以要么用更低的优先级遮蔽它,要么就直接抛错:
Error: single slot "sidebar.brand.mark" already has a registration at priority 0
(registered by Ba) — register at a different priority to shadow it (lowest renders)
这个错误只出现在浏览器控制台,Host 侧毫无反应(插件照常加载、主题色照常生效),
表现为"主题变了但左上角还是鲸鱼"。删掉 priority: -1 就会复现。
为什么字体走 Host 半边,而不是像 PNG 那样内联
因为客户端插件没有静态资源路由——dsh-client-modules 只发 /plugins/<包名>/client.js(和它的 .map)。
要把字体送进浏览器只有两条路:
| 方案 | 代价 |
|---|---|
base64 内联进 lib/client.js |
产物从 30 KB 变成 22 MB,每次打开界面都要下载并解析这 22 MB |
| Host 半边注册一条路由 | 字体留在磁盘上,只在开关打开时才下载,之后走 ETag 复验 |
所以选了后者。实测数据(loopback,Chrome headless):
| 项目 | 值 |
|---|---|
| 字体文件 | 16 162 252 字节(63 449 个字形,覆盖整个 BMP,含中日韩) |
| 首次传输(走了 webServer 的 gzip) | 3 853 326 字节,约 0.43 s |
第二次打开(If-None-Match) |
304,0 字节 |
lib/client.js 产物 |
35 936 字节(和加字体前同一量级) |
@font-face 的 src 指向 /dsh-mc-skin/font.ttf,这个常量在 src/index.js 和 src/client.js
里各有一份(两个 bundle 共享不了模块),改一处必须改另一处。
配套的 CSS 细节:
font-synthesis-weight:none—— 像素字体不能合成粗体,伪粗体会把 1px 笔画糊成 2pxhtml body,html body button,...{ font-family:"Minecraft AE",... !important }—— 这是给 那些自己不声明 font-family 的元素用的(下面这条说明它为什么不够)
行内 font-family 盖不住,加 !important 也没用
品牌名(MINECRAFT)一开始漏掉了像素字体,原因值得单独记一笔:
BrandName 这个 span 自带行内 font-family:"Courier New",...。样式表里那条
html body{font-family:"Minecraft AE" !important} 的主体是 body,span 只是
继承了 body 的值 —— 而继承是层叠里的最后一名,永远输给元素自己的任何声明,
哪怕那条声明不带 !important,也哪怕继承过来的那条带 !important。
两边根本不是同一条声明在竞争,所以堆多少个 !important 都没用。
要覆盖它只有两条路:让规则直接匹配到这个 span(如 html body span{...!important}),
或者让组件自己知道该用哪套字体。这里选了后者 —— BrandName 订阅字体开关,自己算 font stack。
字体要覆写变量,不是给元素设字体
同一个坑后来以更大的规模又踩了一次:品牌名修好之后,输入框里打的字、对话里的字 仍然不是像素字体。原因还是"元素自己的声明赢过继承",但这次没法靠匹配元素解决:
/* 应用自己的规则 */
body {font-family:var(--dsw-font-family, -apple-system, …)}
.xxx_input {font-family:var(--dsw-font-family)} /* ← 输入区,一个 contenteditable 的 div */
.xxx_input 是元素自己的声明,而 html body input{font-family:…!important} 匹配的是
html 和 body,对这个 div 只是继承 —— 继承永远输,!important 也救不了。
更要命的是输入区根本不是 <input>/<textarea>,是 contenteditable 的 <div>
(打字后里面才生成 <p>),所以连 input 这个类型选择器都命中不了。
正确的做法是覆写变量本身,让应用自己那条声明去解析:
html:root body{--dsw-font-family:"Minecraft AE",… !important;
--ds-font-family-code:"Minecraft AE",… !important;}
--dsw-font-family被整套排版 token 引用(--dsw-font-base-16、--dsw-font-markdown-*、--dsw-font-s-14…共 40 多处),所以这一条就能把界面文字整体换掉。--ds-font-family-code是行内代码和代码块单独走的那条,不改的话代码区仍是 SF Mono。- 选择器用
html:root body而不是body:应用把 token 定义在body上, 而html[data-ds-dark-theme] body这类选择器比html body更具体,得抬一级才稳。
原来的 html body button,input,…{font-family:…} 保留成兜底(万一某个组件把字体写成字面量
而不是 var()),但真正起作用的是上面这条变量覆写。
透明度做层次,在浅底上会糊
文字层次原来是用半透明白递降做的:primary #FFFFFF / secondary .74 / tertiary .56 /
caption .44。在侧边栏 #313233 上勉强能看,但对话区的底是 #6B6B6B 这种中灰,
半透明白压上去会直接糊掉:
| 层级 | 侧边栏 #313233 |
对话区 #6B6B6B |
|---|---|---|
secondary .74 |
8.5:1 | 3.5:1 |
tertiary .56 |
5.2:1 | 2.4:1 |
caption .44 |
3.6:1 | 1.8:1 |
数字上"及格"不等于看着清楚 —— 用户报的「黑背景地方字体变灰看不清」就是这个。
1.2.0 起这套值按"最差底色"定。对比度算的是相对亮度,所以瓶颈不是最暗的
侧边栏,而是最亮的对话区 —— 基准取对话区,侧边栏自然满足。主色也从纯白
#FFFFFF 降到 #F2F2F2:纯白压在中灰底上会过曝,长文阅读发涩。
| 层级 | 值 | 对话区 #6B6B6B |
侧边栏 #313233 |
|---|---|---|---|
primary |
#F2F2F2 |
7.4:1 | 15.1:1 |
secondary |
.90 |
5.4:1 | 11.0:1 |
tertiary |
.80 |
4.1:1 | 8.4:1 |
caption |
.72 |
3.3:1 | 6.8:1 |
层级之间靠 8~10 个百分点的透明度差拉开,而不是靠"调暗到快看不见"。 层次感要靠字号和字重。
浅色模式漏白底
DSH 的设计 token 分两套:body{...} 是浅色值,body[data-ds-dark-theme]{...} 是深色值。
本主题用 theme.overrideTokens 把值写成 body 的行内样式,所以两种模式下都是同一个值 ——
但前提是这个 token 得在覆盖表里。
一旦漏掉,浅色模式下那个 token 就还是近白色,而文字早被主题染成浅色 ⇒ 白底白字。
设置 → 模型 →「编辑」展开的面板踩的就是这个:组件写的是
.editor{background:var(--dsw-alias-bg-module-platform)},而 bg-module-platform
浅色值是 #f9fafb。
排查办法:把 DSH 的设计表整个解出来,逐 token 比对浅色值和深色值——凡是"浅色近白、 深色才深"的面板 token 就是可疑的。按用户实际报过的位置,目前钉了 10 个:
| token | 浅色时是 | 现在 | 用户在哪看到的 |
|---|---|---|---|
--dsw-alias-bg-module-platform |
#f9fafb |
#1B1C1D(黑底) |
设置 → 模型 →「编辑」展开的面板 |
--dsw-specific-tip |
#f5f6f7 |
#353638 |
任务面板(展开的待办列表) |
--dsw-alias-bg-multi-select |
#f9fafb |
#2A2B2C |
|
--dsw-alias-bg-mask-drop |
#ffffffb3 |
#000000B3 |
|
--dsw-alias-button-elevated-fill |
#fff |
#3A3B3C |
|
--dsw-alias-button-floating-fill |
#fff |
#3A3B3C |
|
--dsw-alias-button-ghost-active-fill |
#ebeef2 |
#3A3B3C |
|
--dsw-alias-interactive-bg-hover-solid |
#f1f3f5 |
#3A3B3C |
|
--dsw-alias-scrollbar-bg-l1 |
#e5e5e5 |
#313233 |
|
--dsw-alias-scrollbar-bg-l2 |
#e5e5e5 |
#474848 |
--dsw-specific-tip 的深色值是 #353638,正好等于本主题的 --dsw-alias-bg-layer-3,
所以这不是自己配色,是把这个面钉回设计系统里它本该有的深色。
⚠ 范围纪律(踩过的坑)。 上面这个"逐 token 比对"的办法一旦用过头就会出事: 全套设计表里"浅色近白、深色才深"的 token 有 44 个。曾经照单全钉了 29 个, 结果把菜单、用户气泡、侧边栏选中态等用户根本没提过的颜色一起重刷了。
判定一个面要不要改,得同时满足三条:
- 它在浅色模式下确实近白;
- 它承载文字(纯装饰的底色改了也白改);
- 用户明确报过这个位置。
只满足第 1 条的,一律不动。枚举工具留在
.dsh-tools/token-leak-truth.mjs。顺带一个反例:
token 浅色近白 ≠ 就该深色化。有些 token 的语义是"反色文字" (--dsw-alias-label-primary-foreground配button-primary-fill、--dsw-alias-label-primary-inverted配button-contrast-fill),它们的浅色值就是#fff, 深色化会让"深底配深字",正好反过来。
顺带扫了前端自带的 CSS:整个 dist 里只有一个浅色字面量
(body{background:var(--dsw-alias-bg-base,#fff)},而 bg-base 本来就被覆盖),
所以这类漏底只会来自 alias token,不会来自字面量。
那块面板另外加了一条规则补亮边框(就是"白框黑底"):
[class*="_rowCard"] > [class*="_editor"]{border:1px solid #C6C6C6 !important;}
限定在 _rowCard 里那一层是有原因的:组件在 addCard / setupCard 里复用了同一个
_editor 类,但故意写成 background:0 0。不加限定的话,那些地方也会被套上一圈框,出现双重边框。
调色板来自一组 16×16 的模板图(button_template / page_template / text_template / background_template_left / background_template_right / 滑轮模板),全部按像素复刻成 CSS。
换字体
字体放在 assets/MinecraftAE-Pixel.ttf,直接替换这个文件即可,不用重新构建——Host 半边按
大小 + mtime 算 ETag,换了文件下次请求就是新的。
要注意的是 CSS 里写的族名是 "Minecraft AE"(src/client.js 的 FONT_FAMILY),
所以换的字体要么内部 family 就叫这个,要么同步改 FONT_FAMILY 后 node build.mjs。
想换成别的路径、或者不打包字体而是用用户自己的文件,改 src/index.js 里的 FONT_PATH。
改图 / 改代码重新构建
assets/ 里放两类东西,处理方式不同:
| 文件 | 用途 | 是否内联 |
|---|---|---|
minecraft24x.png |
侧边栏标志,24×24(1:1 渲染) | 是 |
minecraft34x.png |
新会话首屏标志,34×34(1:1 渲染) | 是 |
on.png / off.png |
开关两态,30×16 | 是 |
close.png |
关闭按钮,15×15 | 是 |
MinecraftAE-Pixel.ttf |
像素字体,16 MB | 否,走 Host 路由 |
换掉图之后:
node build.mjs
lib/ 是纯产物目录,两个半边都由这一步生成:
src/client.js→lib/client.js:把assets/*.png转成 data URL 注入__ASSETS__占位符src/index.js→lib/index.js:Host 半边原样搬运(只加一条"勿手改"横幅)
不需要任何依赖,只用 Node 内置模块,也不联网。
渲染端会读 naturalWidth,取不大于目标尺寸的最大整数倍——所以 32×32 的图放进 34 的框里会按 32 渲染(1:1),而不是拉伸。
已知限制
- 开关状态已持久化(1.2.0 起),但只在 Host 提供 settings 服务时。连接处于 memory 模式或 ui-settings 未装配时退化为内存态,重启回到默认。设置行末尾会自动据此显示或隐藏那句持久化说明。
- 主题是深色单套。 浅色/深色模式目前是同一组值,切换不改变外观——因为模板图本身就是深色体系。本主题会把上表那 10 个面板 token 也钉成深色,所以即使应用处于浅色模式也不会漏白底(只限用户报过的那几个面,见《浅色模式漏白底》里的范围纪律)。
- 第三方插件的自绘界面不在覆盖范围内。 本主题只负责 DSH 自己的 token 和类名;别的插件如果写死了浅色背景,那部分仍是它自己的样子。
- 字体文件 16 MB。 首次开启要下 3.85 MB(gzip 后)。本机 loopback 无感,但如果哪天把 DSH 挂到网络上,这就是一笔真实流量。想变小只能做字体子集化——实测 ASCII+拉丁 602 个字形只占 60 KB,但中日韩统一表意区就要 6.2 MB,所以"只留常用字"可以做到 ~1 MB,代价是生僻字回落到系统字体。
- 像素字体在小字号下是设计尺寸。 这份字体是 16×16 点阵风格,12–15 px 下最锐利;界面缩放(Ctrl +/-)到非整数倍时笔画会糊。
- 依赖内部类名后缀。 按钮/分页/卡片等规则按
[class*="_xxx"]匹配 DSH 的 CSS Module 局部名。DSH 若把这些名字改掉,对应规则会静默失效(不会报错,只是那部分回到原生样式)。别靠肉眼判断是否失效 —— 跑node tools/verify-selectors.mjs,它会拿真实源码核对每一条。 - 依赖内部槽位名。
sidebar.brand.mark/sidebar.brand.name/conversation.hero.brand.mark/settings.general.item同理,改了就少渲染一部分。 - 文字色不可调。 十六色调色板已移除;想改主色就改
src/client.js的TEXT_PRIMARY再node build.mjs。
关于"像应用程序那样的界面"
存在两类桌面端,别混淆 —— 它们的插件兼容性不同:
A. 官方桌面端(apps/desktop,Electron)
就在 DSH 源码树里(@deepseek-ai/dsh-desktop,Electron 44)。它不是把网页塞进浏览器窗口:
- 自带上游 Node.js 子进程跑 dsh,不开监听端口
dsh-app://提供客户端资源,分帧字节管道承载 Fetch 与流式响应- 自带 dsh 生产依赖树 + pnpm;签名、自动更新、单实例锁齐全
- Electron 独占
$DSH_HOME/profiles/desktop,与 CLI 共享 workspace/settings/credentials, 但不共享可执行包、插件激活状态、lockfile
pnpm run package:desktop:win:x64:unsigned # 本机免签名测试包
产物在 apps/desktop/.desktop-build/targets/win-x64/unsigned-artifacts/。
正式签名需要 EV 证书 + SafeNet token,见 apps/desktop/README.md。
⚠ 官方 Electron 版不提供
webServer(它的 README 明确写了 "Desktop does not provide awebServer")。所以在它上面本包的字体路由不会注册,像素字体开关会 显示"加载失败";主题配色不受影响(走theme.overrideTokens,不需要 HTTP)。 想在那里用像素字体,得把字体改成内联进lib/client.js(代价见《为什么字体走 Host 半边》)。
B. 第三方 Tauri 桌面端(本机在用的那个)
D:\AAAai\Deepseek Harness Desktop(deepseek-harness-desktop,Rust + Tauri + WebView2)。
它和 A 完全不是一回事,实测差异:
| A. 官方 Electron | B. 第三方 Tauri(本机) | |
|---|---|---|
| 端口 | 不开端口 | 开 HTTP 端口(实测 3080) |
webServer |
❌ 无 | ✅ 有 —— 所以字体路由正常工作 |
| profile 目录 | profiles/desktop(Electron 独占) |
用 active_profile 指向任意 profile(本机是 web) |
| 原生菜单栏 | 无 | 有(见下) |
所以本包在 B 上两半都完全生效:配色、字体、持久化都正常。
B 的原生菜单栏改不了
B 的顶部有 文件 / 帮助 这类菜单,那是 Tauri 原生菜单,不在网页 DOM 里。
二进制里能直接找到编译进去的菜单项与 API:
文件 File · 帮助 Help · 关于 Desktop About Desktop · 退出 Quit
全屏幕 Full Screen · 运行日志 Run Logs · 检查更新 Check for Updates
plugin:menu| · set_as_app_menu · MenuItem · Submenu
判定规则很简单:Windows 原生控件画的,CSS 够不到;theme.overrideTokens 也不管它。
插件跑在浏览器沙箱里(只能用 React / ctx / host.call / styles),
没有任何接口能重绘宿主窗口的菜单栏。
桌面端内置的 dsh-tauri-* 插件(dsh-tauri-rightclick 等)看起来能碰原生,
是因为桌面端为它们写死了 bridge 通道,只开放会话/工作区/宠物/右键菜单这些,
其中不含菜单栏定制。那条通道不是可复用的公开接口。
能改 / 不能改的分界:
| 区域 | 谁画的 | 本主题能改吗 |
|---|---|---|
| 侧边栏、对话区、输入框、按钮、分页 | 网页 DOM | ✅ |
顶部 文件/配置/帮助 |
Windows 原生菜单 | ❌ |
| 窗口标题栏、最小化/最大化/关闭 | Windows 原生 | ❌ |
| 宠物、右键菜单 | 桌面端插件的网页层 | ⚠️ 能改,但那是别人的插件 |
安装到桌面端(B)时踩过的坑
- profile 不是
desktop—— 读%APPDATA%\io.github.hairyf.deepseek-harness-desktop\.store.dat里的active_profile才知道实际用的是哪个(本机先是tauri,后手动切成了web)。 - 桌面端会切到兜底 profile —— 装完插件后它新建过
safeprofile 并切了过去 (safe只含 9 个内置dsh-tauri-*插件,没有第三方插件), 表现是"插件装好了但界面毫无变化"。日志里会先出现:dsh-client-ui-mc-skin 的 bundle patch 含复杂结构,无法热挂载(需重启一次) Profile manifest created with the official web template: ...\profiles\safe\package.json - Host 半边改动必须重启 —— 它只在进程启动时 import 一次。
许可
MIT。素材(assets/*.png)与字体(assets/MinecraftAE-Pixel.ttf)由使用者提供,字体版权归其原作者,本仓库只做打包与接线。
No comments yet. Be the first to write one.