尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

给 DeepSeek Harness 写了个 web UI 插件

给 DeepSeek Harness 写了个 web UI 插件 摘要AI 编程工具的插件生态大家都在写工具型插件。但想给界面换个皮肤、加只悬浮宠物得写另一类——客户端 UI 插件。本文以我自己开源的 DSH 咸鱼宠物插件为例拆开它的两层入口、槽位注入以及几个踩出来的前端工程坑背景透出、毛玻璃的包含块陷阱、穿透宿主 DOM。图服务端工具插件藏在背后供模型调用客户端 UI 插件浮在界面上给人看——后者通过shell.overlay槽位注入踩过背景透出、毛玻璃陷阱、穿透 DOM 三个坑。你每天盯着 AI 编程工具的界面八小时界面的样子却是别人定死的。想换张壁纸、加只悬浮宠物翻遍设置项找不到入口——因为这类皮肤层的改动工具厂商压根没留。DeepSeek Harness下称 dsh留了入口一切皆插件。但多数教程讲到插件都在教你怎么注册一个工具给模型调用。给界面动刀子的另一种插件——客户端 UI 插件讲的人少。本文以我自己开源的 dsh 咸鱼宠物插件为例把它从入口、槽位到几个前端坑完整拆一遍。插件跑在哪决定它是哪一类dsh 的插件分两类区别不在功能在代码跑在哪里、服务谁服务端工具插件默认形态跑在 Node 侧。它注册一个工具比如greet模型通过描述知道它存在、通过入参 schema 知道怎么调。我 8-17 那篇写的就是这种。客户端 UI 插件声明platform: web跑在浏览器里往 UI 槽位塞一个 React 组件。它不注册工具、不消费任何 Cordis 服务纯粹在界面上做文章。一句话区分前者给模型加能力后者给界面加 personality。咸鱼宠物是后者——它不替你写代码只在你写代码时陪你摸鱼。一个插件为什么要拆两层入口最反直觉的地方这个插件有两份入口文件。src/index.ts是宿主Node入口全文就一行exportfunctionapply():void{}空的。它存在只为了让 Cordis Loader 认它是个合法插件——能进 host 的 cordis.yml 插件树。它不干任何服务端的事。真正的逻辑在src/client/index.ts通过package.json的 exports 映射暴露出去{exports:{.:{default:./lib/index.js},./client:{default:./lib/client.js}},dsh:{client:{inject:[deepseek-ai/dsh-client-runtime,deepseek-ai/dsh-client-ui-slots],platform:web}}}exports[.]指向 host 那份空applyexports[./client]指向浏览器侧。dsh 的客户端运行时读到dsh.client.platform: web就知道该加载./client这份、并把它要注入的客户端服务dsh-client-runtime、dsh-client-ui-slots准备好。这个拆分的收益很实在同一个 npm 包在 Node 侧零成本空 apply 不引入 React在浏览器侧才真正加载组件。你不会因为装了个 UI 插件就给服务端进程塞进一堆前端依赖。槽位把组件挂进界面UI 不是随便往document.body一扔就完事。dsh 把界面切成若干槽位slot插件往指定槽位注册组件。咸鱼插件用的是shell.overlay——一个挂在 root 作用域的list型槽位多个插件都能往里加条目。插件先声明它需要slots服务再注入组件exportconstinject[slots]asconstdeclaremoduledeepseek-ai/dsh-client-ui-slots{interfaceSlotMap{shell.overlay:{kind:list;scope:root}}}exportfunctionapply(ctx:ClientContext){ctx.inject([slots],(scope){constdisposescope.slots.inject(shell.overlay,()scope.slots.register({name:shell.overlay,id:uiPet},SaltedFishPet),)return()dispose()})}slots.inject拿到槽位注册表slots.register把一个 React 组件绑到uiPet这个 id 上。咸鱼和壁纸选择器是两个独立的槽位条目uiPet和wallpaperPicker各自挂各自的组件。注册进 bundle一行命令背后开发时你 clone 源码、跑install.sh普通用户则是一行npx deepseek-ai/dsh plugin--profilewebaddtangyuewei/dsh-client-ui-pet这条命令本质是在持久化的 profile 目录~/.dsh/profiles/web里pnpm add这个包并把它的cordis.patch.yml追加进 bundle 清单。那个 patch 文件极简-insert:-id:ui-petname:tangyuewei/dsh-client-ui-petdsh 启动时按 bundle 顺序叠加各插件的 patch在shell.overlay里注入咸鱼。profile 目录与 npx 缓存无关重开终端、重跑 npx 依然生效——这是它比每次手动 patch 源码稳的地方。真实运行效果默认yu7毛玻璃背景与咸鱼 → 打开壁纸面板 → 切换 Porsche 718 → 置顶背景图全屏置顶→ 取消置顶 → 切换 Macan S深色系壁纸亦可选深浅色不限制。跨槽位状态两块 UI 怎么联动咸鱼和背景是两个独立槽位条目意味着它们不在同一棵 React 组件树里不能直接传 props、不能共享 context。但点一下隐藏咸鱼要把背景也收起来这个联动必须成立。解法是抽一个模块级共享 storevisibility.tslethiddenfalseconstlistenersnewSet()void()exportfunctionsetPetHidden(next:boolean):void{if(hiddennext)returnhiddennextfor(constlisteneroflisteners)listener()}exportfunctionsubscribePetHidden(listener:()void){listeners.add(listener)return()listeners.delete(listener)}一个布尔 一个监听器集合。宠物按钮调用setPetHidden背景模块的订阅者收到通知后同步收起。宠物组件本身用useSyncExternalStore接这个 store所以即使槽位被重新挂载读到的仍是同一份持久化的可见状态不会宠物没了、背景还在。代价我说在前面召唤按钮目前靠查找页面上含 “Session log” 文字的按钮来定位自己Shell 结构一变就可能失效。这是已知限制不是特性。壁纸 API丢图即生效壁纸不区分深浅主题用户随便挑。源图丢进src/client/wallpapers/构建脚本build-wallpapers.mjs自动缩放到 1920px、base64 编码进bg-images.generated.tsgitignored每次构建重生于是加一张壁纸等于丢一个文件不用手敲 base64。选择态用localStoragekeydsh-ui-pet.wallpaper持久化并通过window上的CustomEvent广播exportfunctionsetCurrentWallpaperId(id:string):void{localStorage.setItem(STORAGE_KEY,id)window.dispatchEvent(newCustomEvent(CHANGE_EVENT,{detail:{id}}))}背景模块和选择器都订阅这个事件换壁纸时两边同时刷新刷新和重启后选择还在。几个前端坑架构落地的代价插件能跑起来靠几处对症的前端技巧。它们不性感但少一个界面就废。背景透出。主题背景默认不透明会盖住 body 上的壁纸。解法是把主题背景底色强行透明让 body 的壁纸透出来body.dsh-bg-glow, body.dsh-bg-glow #root, body.dsh-bg-glow #root *{--dsw-alias-bg-base:transparent!important;}一个 CSS 变量加!important覆盖掉主题对background简写设的不透明底色。毛玻璃的包含块陷阱。侧栏想做毛玻璃直觉是给列加backdrop-filter。但backdrop-filter会让那个元素变成其内部position: fixed后代的包含块——设置面板是 portal 渲染的会被困在 280px 宽的列里。改用isolation: isolate也不行它会创建层叠上下文Chrome 会裁剪上下文内fixed后代到自身overflow。正解是滤镜放::before伪元素、z-index: -1逃逸到 body 背景层body.dsh-bg-glow [class$sidebarCol]::before{content:;position:absolute;inset:0;z-index:-1;-webkit-backdrop-filter:blur(26px)saturate(160%);backdrop-filter:blur(26px)saturate(160%);}这俩雷在 MDN 的backdrop-filter条目里都有记载但只有真写一遍才知道它会咬人。穿透宿主 DOM。宿主的列用了 CSS Modules类名是带哈希的sidebarCol__abc123。我没法 import 宿主组件去加样式只能用后缀属性选择器命中本地名body.dsh-bg-glow [class$sidebarCol]{background:rgba(255,255,255,0.55)!important;}[class$sidebarCol]匹配以 sidebarCol 结尾的类名不依赖哈希前缀。代价是选择器写得宽未来宿主改本地名规则就得跟着改。主题与光晕。MutationObserver监听body的data-ds-dark-theme属性切深浅色鼠标跟随光晕只往--bg-mx/--bg-my两个 CSS 变量写坐标由合成器compositor处理不触发重绘——mousemove用passive监听不阻塞滚动。已知限制收益和代价一起给这个插件不完美我把它写进 README 的已知限制零持久化宠物位置、饱腹度、心情都是会话级内存刷新即重置无配置面板所有参数衰减速度、边距、尺寸要改源码重新构建没暴露给用户召唤按钮依赖 DOM 查找Shell 结构变更可能失效壁纸内嵌 base64无外部请求想动态加载得自己改。这些不是 bug 清单是纯前端展示插件这个定位下的取舍。它本来就不打算做服务端、不做账号体系、不做云同步。收尾写这个插件最大的收获不是多了只咸鱼而是被迫把 dsh 的宿主/浏览器边界、槽位契约、宿主 DOM 穿透摸清了一遍。插件化的另一种形态就在这它不只想给模型加能力也想给界面加 personality。当 AI 编程工具成为你每天盯八小时的东西能不能把它改顺手本身就是工程问题。客户端 UI 插件是这块拼图里被讲得最少的一块——希望这篇把它补上。参考资料开源仓库 tangyuewei/dsh-client-ui-pet2026 年 8 月。MDN Web Docsbackdrop-filter。作者唐悦玮 | 从后端出发用 AI 拓展到全栈的工程师。
返回列表