乐于分享
好东西不私藏

dsh插件开发实战:给AI客户端换皮的全部经验

dsh插件开发实战:给AI客户端换皮的全部经验

昨天有个朋友看我电脑,愣了一下:你这 AI 客户端怎么长得跟 QQ 似的?

DeepSeek Harness(就是 dsh)开源之后,我的工作界面就换成了它的 Web 客户端,一直开着。那天它确实被我换了一身皮:亮色白净,暗色蓝灰,用户气泡是浅蓝色的还带个小尾巴,助手消息旁边挂着一枚企鹅头像,底下输入区做成了胶囊。

朋友问这得动多少源码。其实在 dsh 里不存在这回事:它的架构是一切皆组件,皮肤插件就是往里加一个组件,走的是官方通道。整件事拆开就两层,一层管颜色,一层管结构,加起来不到 400 行 TypeScript,卸载一条命令,界面原样回来。

这篇文章就把这套东西怎么想、怎么写、踩了哪些坑,从头讲一遍。想自己动手给 AI 客户端换皮的,看完基本能上路。

先想清楚一件事:dsh 的一切皆组件,皮肤是一等公民

dsh 基于 cordis 插件体系,客户端的每一项能力都是一个服务:渲染聊天流是一个服务,主题是一个服务,你打开客户端时看到的整个界面,本质上就是一堆组件拼装出来的。往这套体系里加东西,正确的姿势从来不是去动组件内部,而是写一个插件,声明它要依赖哪些服务,然后在插件里叠加自己的行为。

这是整个方案的出发点,也决定了皮肤该怎么做。

刚开始我也走过弯路,想的是笨办法:写死类名、用 !important 硬怼样式、甚至想过改完组件重新打包。在组件化架构下全是死路。类名是 CSS Modules 的哈希,客户端每次构建都变,写死必死;重打包的客户端跟官方升级直接冲突。

正确的路是理解体系提供的接口。dsh 的主题服务管理着一套语义 token,整个界面的颜色都从这些 token 取值,前缀是 --dsw-。皮肤要做的事情只有一件:写一个插件,往这套 token 上叠一层自己的值。token 没覆盖到的,保持产品默认。这就是插件式开发,不是侵入式改造。

想通这一层,后面所有的设计都顺了。

第一层:颜色,跟着 token 走

QQ 的辨识度一大半来自那抹天蓝,官方叫法是 #12B7F5。但这里有个审美上的取舍:如果整片界面都是蓝,会腻。QQ NT 自己的设计语言其实是克制的,亮色下表面近白,边框和 hover 用中性色,蓝色只出现在品牌、气泡、主按钮这些关键点。

我照抄了这套逻辑。每个 token 给一组双色值,亮色一套暗色一套,皮肤跟着系统主题走,不跟它打架:

'--dsw-alias-brand-primary': { light: '#12B7F5', dark: '#4CB8FF' },'--dsw-alias-bg-base': { light: '#F7F9FB', dark: '#101822' },'--dsw-specific-bubble': { light: '#A8E3FF', dark: '#1D4E6E' },

亮色白净,暗色是 #101822 的蓝灰基座,蓝色照样只做点缀。加上 QQ 的绿 #07C160、红 #FA5151、琥珀 #FFC300,状态色也顺手换上。

怎么知道要覆盖哪些 token?方法很笨但有效:开着控制台,看界面上哪个颜色不对,点开它背后的 CSS 变量,前缀是 --dsw- 就进覆盖表。我最后覆盖了 120 多个,剩下的保持默认。

第二层:结构,让界面长得像聊天窗口

颜色解决了观感,但 QQ 的感觉一半来自几何:聊天流要居中收窄,用户气泡要带尾巴,助手消息要有头像和卡片,输入区要胶囊化。

dsh 客户端没有构建期的 CSS 管线,最直接的做法是把一段 CSS 写成字符串,运行时注入一个 <style> 标签,卸载时移除。跟官方主题模块自己的做法是同一个路子。

这里有个硬性分工:布局层只管几何和结构,颜色一律回 token 层取。这条线必须守住,不然亮暗切换会出各种鬼颜色。

两个坑,值得单独说

第一个坑在颜色层。大部分组件老老实实从 alias token 取色,但有少数几个,比如聊天区渐变、状态点、上下文仪表,直接引用静态色板 --dsw-static-*。dsh 默认的静态蓝色是深蓝紫 #416EE6,跟 QQ 天蓝完全不搭。于是我把整条静态蓝色刻度重映射了一遍,从浅到深全换成天蓝系。不这么做,界面里就会漏出几处原厂蓝紫,一眼穿帮。

第二个坑在结构层,也是最费劲的地方。dsh 的类名是 CSS Modules 的哈希格式,构建一次变一次,写死必死。但本地名保留在类名尾部,所以用后缀匹配能稳定命中。另外组件自带 data-* 钩子,这些比类名可靠,优先用。

最深的坑是选择器穿透。助手的消息卡片外面包着一层渲染插槽的 wrapper,直接用子选择器永远命不中,圆角和内边距死活不生效。折腾了半天才明白要穿透一层,这个细节不做,整个助手卡片就是塌的。

头像还有个细节:我用的企鹅图形是腾讯官方的,转成了 53×64 的 data URI 塞进 CSS 里,零网络依赖,暗色下照样清晰。

为什么这么设计,值得展开说说

可逆。两层都挂在插件的生命周期清理上,卸载就是移除 token 覆盖加移除 <style> 标签,一步不剩。

解耦。皮肤是一个独立插件,跟主程序之间只隔一层主题服务的覆盖接口。官方升级客户端,插件不用跟着改;接口稳定,皮肤就稳定。

好验证。皮肤这种纯声明式的东西,回归风险全在 token 形状和选择器上。我写了三组测试:一组遍历覆盖表,要求每个 token 都有非空的亮暗双值;一组检查静态色板有没有漏网的原厂蓝;一组模拟插件的挂载和卸载,确认只叠一次、只清一次、从不碰用户主题偏好。每次改完跑一遍,心里踏实。

如果你想动手,几条实在的建议

先别急着写代码。这个皮肤已经发到 npm 上了,一条命令就能装进你自己的客户端:

dsh plugin --profile web add dsh-qq-skin

装完刷新,界面立刻变 QQ。先感受一下成品长什么样,再回来读下面的建议,更容易理解我为什么这么设计。本地开发可以装当前目录,link: 前缀保持热链接:

dsh plugin --profile web add link:../dsh-qq-skin

卸载同样一条命令,皮肤跟 bundle 层一起拆掉,界面还原:

dsh plugin --profile web remove dsh-qq-skin

体验完了再动手写自己的。先调颜色,再动结构。开着控制台把界面调成你想要的样子,每改一处就记下背后的 --dsw- 变量,按语义分组写进覆盖表。颜色全对了,再去碰结构。

结构层宁可少写选择器,也不要图省事写死类名。哈希一变全盘失效的教训,我不想你再踩一遍。

发布之前跑一遍测试。我踩过的坑:改了一个 token 的颜色,顺手把另一个不该动的改没了,是测试先喊出来的。

最后提醒一句,QQ 的企鹅图形标志版权归腾讯,皮肤里做辨识度点缀没问题,别拿去商业分发。

结尾

做这个皮肤最大的收获不是界面变好看了,而是想清楚了一个道理:在一个一切皆组件的体系里,定制不是绕过系统,而是顺着架构走。主题 token、组件钩子、bundle 加载机制,这些都是 dsh 留好的正式通道,皮肤只是选了一条通道,把自己的组件加进去。

你的 AI 客户端现在长什么样?如果也要换皮,想换成什么风格,评论区聊聊。

dsh QQ皮肤源码:https://github.com/lispking/dsh-qq-skin