dsh-ai-workbench · AI 工作台
一句话: 在 DSH 里说一句大白话,AI 就给你造一个能长期存在的窗口——然后你继续跟它聊,它继续改。
▲ 35 秒介绍视频(1.5× 节奏,含配乐):点这里播放(1080p,2.5 MB)
▲ 图 1 · 中央面板:一句需求 → 几个问题 → 一个窗口
你在侧边栏的「AI 工作台」里写下「我要一个记录每天喝水的打卡表」。工作台先问你几个 小白也能答的问题(要不要每周自动总结?想要什么风格?主要用来干什么?),你确认之后, 模型把一张自包含的单文件 HTML 写进项目文件夹,插件自动把它挂成一个可缩放、可拖动的 窗口。窗口右边有一个绑定的对话窗,你可以在那儿继续让它改;窗口里点了关键按钮,这条 动作会被记进工作台日志,你在对话里提一句,模型就知道你刚做了什么。
不用写代码,不用装构建工具,也不用离开 DSH。
▲ 图 2 · 浮层窗口:产物本身就是一个窗口,标题栏能刷新、调不透明度、最小化、全屏
| 想做什么 | 去哪一节 |
|---|---|
| 30 秒装上跑起来 | 安装 |
| 看它长什么样、能干什么 | 四个区域 + 上面的图 |
| 一句话 → 一个窗口 是怎么闭环的 | 动作上报:只发关键动作 |
| 装别人做好的工作台 | 模板市场 |
给模型配的 workbench_window 工具 |
给模型用的工具 workbench_window |
| 所有可改的配置项 | 配置 |
| 为什么这样设计、踩过哪些坑 | 设计取舍 · 踩坑清单 |
它解决什么问题
DSH 里已经有对话,但对话是线性的。你想要的是一个长期存在的界面:一个看板、 一个打卡表、一个小计算器、一个能盯着某件事的面板。这类东西让模型每次用文字回答都 很别扭——它会重复输出同一段 HTML,你还得自己另存、自己打开。
这个插件把那条路打通了:
- 你说一句需求。
- 工作台问几个具体问题(可选,可跳过),你确认。
- 模型在项目文件夹里写一个 HTML 文件。
- 插件读同目录的
_workbench.json,知道入口文件和哪些动作值得上报。 - 窗口以 iframe 挂进 DSH 的浮层,跟着主题走,可拖动、可缩放、可全屏。
- 窗口右侧的对话窗绑定同一个会话,你可以说「把颜色改成蓝色」。
- 你在窗口里点了一个被声明过的按钮 → 动作记进工作台日志 → 你在对话里提一句, 模型读得到这条记录并改文件 → 窗口刷新。
第 7 步是这个插件和「让模型输出一段 HTML」最大的区别:模型不需要你复述刚才点了什么。 第 2 步是「小白能用」的关键:绝大多数人说不出自己想要什么,但能回答「你主要用它记录 什么」。
▲ 图 3 · 先问后建:5–7 个带推荐选项的问题,可跳过
四个区域
侧边栏「AI 工作台」行
sidebar.panellist 上的一个图标行(order 56,排在已装插件之后)。点它,
中央面板切换成工作台;再点一次会话图标就回到对话。
中央面板(创作面)
- 需求输入框:唯一必须填的东西。Enter 发送,Shift+Enter 换行。
- 提问区(intake):写完需求后,面板先列出 5–7 个问题,每题带几个推荐选项, 也可以自己补一句话。确认后才发给模型。点「就按描述做」可以跳过,让模型在构建时 自己问。
- 项目列表:一个项目 = 一个文件夹 = 一个模型会话。换项目就是换上下文。
- 窗口列表:每个窗口显示标题、入口文件、声明的动作、状态徽章、💬 对话按钮。
- 导入 / 导出:把整个工作台导出成一个
.dshwb.json文件(页面已内联,可直接 发给别人),或从文件导入。同一个文件导入两次会得到两个工作台。 - 模板市场:拉
market.dshdesktop.com的目录,一键装别人做好的工作台。 - 动态:Host 记的日志,实时推送(SSE),显示「已发出 / 已送达」计数(见动作上报一节的说明)。
- 底部状态:SSE 是否连着、当前项目绑的是哪个会话。
浮层窗口(产物面 + 对话面)
shell.overlay 上的浮层。每个窗口是一张可拖动的卡片,里面左边是 iframe,右边
(点 💬 展开)是绑定的对话窗。标题栏有对话、最小化、全屏、重新加载、关闭。右下角
可拖拽改尺寸。关掉窗口只是不显示,文档还在账本里,随时能再打开。
对话窗绑的是项目而不是窗口:一个项目一份文件夹、一个会话、一份 manifest,所以 同项目的两个窗口共享同一份转录——这是诚实的模型,不是偷懒。
窗口标题栏的「打开方式」有两种,鼠标悬停在芯片上会显示各自适合的场景:
| 打开方式 | 悬停说明 | 什么时候用 |
|---|---|---|
| 应用内浮层 | 浮在本面板上方显示;适合边聊边看的窗口 | 要盯着它同时继续聊天的看板、通知板 |
| 独立标签页 | 在浏览器独立标签页打开;适合要全屏用一阵的窗口 | 表格、编辑器、要专心用十分钟的工具 |
内置作者技能
插件启动时会往 DSH 的技能注册表里注册一个内置技能 ai-workbench-authoring。它包含
产物契约、主题变量白名单、沙箱陷阱、动作上报协议、响应式要求、前端设计原则、反模式
清单和 10 个可查的资源站点。模型在写工作台页面时能直接读到它,不需要你单独安装
任何技能。
模板市场
除了自己造,还能一键装别人做好的工作台。面板上的「市场」拉
market.dshdesktop.com/index.json,把每条列成一张卡片:标题、作者、版本、体积、
md5/sha256、有没有能直接跑的静态页。点「安装」走的是和「导入 .tgz」完全相同的
解包路径:内置零依赖 tar 解包器解开,按排名挑出真正的页面入口,落成本地项目。
▲ 图 4 · 模板市场:标题、作者、版本、能否直接运行一目了然
安装和删除都会立即刷新市场列表——卡片上的「已安装」徽章、可运行标记跟着账本实 时重算,不需要刷新页面,也不会出现「删了还显示已装」的旧状态。
市场包分三种形态,插件不猜、不装样子,卡片上直接标出来:
| 形态 | 卡片显示 | 装完得到 |
|---|---|---|
| 自带构建产物的静态页 | 可直接运行 |
一个能立刻打开的窗口 |
纯 dsh-desktop 组件(需要 desktopWorkbenches 服务) |
仅描述页 |
一页「这是谁做的、怎么装回去」+ 「让 AI 搭建」按钮 |
| 页面壳引用缺失的构建产物 | 仅描述页 |
同上——这种比上一种更危险,因为它假装 ready |
第三种是真实存在的(比如 standalone/index.html 引用 /assets/standalone.js,而那个
文件要 npm run build 才会有)。插件用 referencesMissingAssets() 把这种壳降级成描述
页路线,而不是端给用户一个卡死的加载页。「让 AI 搭建」按钮就是它的出路:模型读包源
码,在你的项目文件夹里写出一个自包含的 index.html。
网络说明: 市场包托管在 GitHub Releases。如果
github.com不通(常见于国内网 络),插件会自动改用镜像重试,并把镜像的耗时放宽到 180 秒——直连仍保持 30 秒快速失 败,不会让面板卡住。镜像偶发截断时,sha256 校验会拒绝而不是给你装一个坏工作台。
动作上报:只发关键动作
这是刻意做窄的一条通道。页面里写:
<button onclick="parent.postMessage({
source: 'dsh-ai-workbench',
action: 'check-in',
data: { day: '2026-10-18' }
}, '*')">今天喝了</button>
并在 _workbench.json 里声明:
{
"title": "喝水打卡",
"entry": "index.html",
"ready": true,
"actions": [
{ "name": "check-in", "label": "打卡", "description": "用户记录今天喝水" }
]
}
动作是记录,不是自动提问。 点击只会往工作台日志里写一条带 payload 的记录,并推进 面板上的「已发出」计数;它不会自己唤醒模型。你什么时候想让 AI 处理,就在对话窗里说一句 「刚打卡了」「把这个月的数据汇总一下」——模型发消息时,摘要里会带上最近的几条交互记录, 所以它知道你点了什么,不用你复述。
这条边界是故意的:如果每次点击都自动变成一条发给模型的提示,工作台里每个按钮都会成了 隐形触发器——你按一下「同步」,模型就自作主张开始回答,哪怕你根本没想让它开口。现在的 规则是只有你亲自在对话里打的字才会到模型那里。
页面里其他所有交互——展开、切换标签、翻页、输入——都留在本地,不写日志,不消耗 token。
未声明的动作会被丢弃,并且记一条 warn 日志。这个判断在父窗口做,不在 iframe
里做:iframe 是不可信内容,它自己说自己「已声明」不算数。声明列表每次都从账本
重读,所以模型改了 _workbench.json 之后不需要刷新页面就生效。
消息里的 source 字段用于区分来源,父窗口还会校验 event.source 确实是某个已知
窗口的 contentWindow——别的 iframe 或上层页面伪造不了。
面板上的计数器是「已发出 / 已送达」。第二位数的是对话消息成功送进模型会话的条数, 动作点击只会让第一个数字增长。
文件放在哪
| 东西 | 路径 |
|---|---|
| 账本(项目、窗口、日志、对话) | <DSH home>/dsh-ai-workbench.json |
| 产物 | <DSH home>/ai-workbench/<项目名 slug>/ |
| 页面清单 | 产物文件夹里的 _workbench.json |
| 导出的工作台 | 你自己选的位置,后缀 .dshwb.json |
| 路由 | /apps/ai-workbench/api/*、/apps/ai-workbench/events |
| iframe 源 | /apps/ai-workbench/api/site/<projectId>/<相对路径> |
<DSH home> 的解析顺序:配置里的 dshHome → $DSH_HOME → ~/.dsh。
产物文件夹是你的:用编辑器、用 git、用别的工具直接改都行,插件下次读页面时 就会看到。账本只记「这个窗口指向哪个文件」,不记文件内容。
从上一版升级: 账本文件名从 dsh-window-forge.json 改成了
dsh-ai-workbench.json。首次启动时如果新文件不存在、旧文件存在,会读旧文件继续
用(旧文件保留不删),所以已有项目不会丢。清单文件名同理:先找 _workbench.json,
找不到再找 _forge.json。
安装
# 1. 打包(在项目根目录)
pnpm pack --pack-destination .
# 2. 装进某个 profile
dsh plugin --profile <profile> add ./dsh-ai-workbench-0.8.1.tgz
桌面版(Electron)不能用 CLI 装。 它对 --profile desktop 会直接报:
error: profile "desktop" is managed exclusively by the Electron application
桌面场景请走应用内的插件安装入口,选择上面那个 .tgz。
升级注意: 这个包改过名字(原
dsh-window-forge)。如果旧包还装着,请先卸 旧再装新。两个包同时启用会各自注册/apps/*前缀路由,虽然前缀不同不会撞车, 但会同时出现两个侧边栏图标、两套账本。见踩坑第 9 条。
想先在隔离环境里试,可以造一个一次性 profile:
dsh workbench-lab --from-default-profile web --dump-config
dsh plugin --profile workbench-lab add ./dsh-ai-workbench-0.8.1.tgz
dsh --profile workbench-lab --port 0 --no-open
最后一行会打印 http://127.0.0.1:<port>/?token=<token>,浏览器打开就能看到。
装完重启(或刷新页面),侧边栏底部会多出一个「AI 工作台」图标。
构建
node scripts/clean.mjs
node scripts/css-types.mjs # 生成 CSS Module 的类型 sidecar
node node_modules/typescript/bin/tsc -p tsconfig.json
node node_modules/tsdown/dist/run.mjs
node scripts/verify.mjs
tsc 只产出 lib/types/**(声明文件);tsdown 产出 lib/index.js(Host,ESM)
和 lib/client.js(浏览器端,CJS 包装成 __ModuleLoader__ 握手)。
scripts/css-types.mjs --check 只比对不写盘,适合 CI。scripts/verify.mjs 是静态
契约检查:文件树、manifest、patch 守卫、客户端 bundle 纯净度、三个席位都走
registry.inject 注册、协议常量、内置技能是否进包、两份字典的键对齐、CSS 类名对账。
它不构建、不安装、不启动任何东西。
本机的坑
pnpm 的全局 shim 是坏的(pnpm: the global target of the pnpm shim points back at the shim)。所以:
- 构建脚本一律用直接调 node 的方式,不要
npx/pnpm run。 dsh plugin需要 pnpm,把真实 pnpm 目录放在 PATH 最前面即可:
$env:PATH = 'C:\Users\<you>\AppData\Local\pnpm\global\v11\<hash>\node_modules\pnpm;' + $env:PATH
桌面应用内的插件管理器不受影响——它用自己的 pnpm,不走 PATH。
配置
Config 会被 DSH 自动投影成设置页,不需要自己写界面。面板上的开关状态由 Host 通过
GET /state 的 features 字段下发,不在浏览器里存第二份——你在设置页改完,刷新面板
就生效。
| 键 | 默认 | 说明 |
|---|---|---|
dshHome |
'' |
覆盖 DSH 家目录;空则按上面的顺序解析 |
ledgerFile |
dsh-ai-workbench.json |
账本文件名 |
serveHttp |
true |
关掉就不注册任何 HTTP 路由 |
registerTool |
true |
关掉就不注册 workbench_window 工具 |
registerSkill |
true |
关掉就不注册内置作者技能 |
interviewFirst |
true |
关掉则需求直接进构建,不先提问 |
chatPane |
true |
关掉则不显示窗口右侧的对话窗 |
marketUrl |
https://market.dshdesktop.com/index.json |
模板市场目录地址 |
journalActions |
true |
是否记录动作日志 |
maxJournalEntries |
200 |
日志上限(0–2000) |
forwardActionsToSession |
true |
保留的旧行为开关:置 false 时连这条显式投递请求也会被拒绝(当前界面上的动作点击本来就走记账路径,不受它影响) |
maxProjects |
20 |
项目上限(1–200) |
maxWindowsPerProject |
12 |
单项目窗口上限(1–100) |
defaultProjectName |
工作台项目 |
自动建项目时的名字 |
给模型用的工具:workbench_window
模型可以主动调用它,在你还没打开面板时就把工作台拉起来:
[workbench-window]
status=opened
projectId=project-...
project=喝水打卡
folder=C:\Users\you\.dsh\ai-workbench\喝水打卡
reused=false
windows=1
listeners=1
manifest=_workbench.json
ledger=C:\Users\you\.dsh\dsh-ai-workbench.json
revision=12
next=...
action 有五个值:
| action | 作用 |
|---|---|
open(默认) |
打开面板、绑定会话、返回项目信息 |
create |
同上,但允许新建项目 |
ask |
把 question 放进工作台的对话窗,然后停下等你回答 |
building |
把最新窗口标成「生成中」,告诉你它开始动手了 |
export |
把项目导出成 .dshwb.json 写到项目文件夹,返回路径 |
reused=true 表示那个文件夹本来就是一个项目——模型此时应该编辑现有文件,而不是
推倒重来。listeners 是诚实的订阅者数量——0 表示没人在看,面板没开。工具调用时
会把调用方的 sessionId 绑到这个项目上,这是插件唯一可靠地知道「用户在看哪个会话」的
途径(shell.overlay 是 root 作用域的席位,拿不到会话上下文)。
工具不接收页面内容。页面由模型自己用文件工具写进 folder,这样:
一份内容不会在上下文里出现两次;文件是你的,可以改、可以提交、可以复用。export
同理——它写盘并只返回路径,不把 bundle 内容塞进工具结果。
和 dataelement/dsh-desktop 工作台的关系
dsh-desktop 定义了一套工作台包规范(docs/workbench-standard.zh.md):一个工作台
是一个 npm 包,带 dsh.bundle.patch、exports["./client"],客户端在 apply 里调
ctx.desktopWorkbenches.register({title, repository, …}, BusinessPanel) 把自己注册进
桌面壳的分栏里。
本插件跑在官方 DSH 0.2.1-rc.2 上,这个 build 没有 desktopWorkbenches 服务,所以
做不到运行时注册。做的是格式级兼容:
- 能读
dsh-desktop的包:POST /apps/ai-workbench/api/import/package下载.tgz(或.tar.gz),用内置的零依赖 tar 解包器解开,取出里面的 HTML 入口和资源文件, 落成一个本插件的工作台。包里没有 HTML 时,会生成一页「这是谁做的、怎么装回去」的 描述页,而不是假装成功。 - 能读市场目录:
GET /api/market拉market.dshdesktop.com/index.json,校验schemaVersion: 2、id === owner/repository、url、distribution.version等字段 (fail closed,任何一条不符就整份目录作废),面板上逐条列出,点「安装」走同一 条解包路径。 - 能导出成自包含的
.dshwb.json,可以发给别人、也可以用文件工具手动塞进任何项目。
做不到的部分会如实标注:市场卡片上 runnable: false 的条目会显示「仅描述页」
徽章,因为本机没有那个运行时。README 不宣称「已兼容 dsh-desktop 的注册协议」。
设计取舍(为什么是这样)
iframe 而不是自己渲染。 模型产出的是 HTML/CSS/JS,用自己的 DOM 渲染等于写一个 浏览器。iframe 天然隔离样式和脚本,也让「模型写的代码出错」不会拖垮 DSH 本身。
单文件 HTML。 没有构建步骤,没有依赖解析,没有 CDN。模型能一次写对的东西,
就不要引入一个工具链。需要多文件也行(entry 指向任意相对路径),只是默认推荐
单文件。
先问后建。 直接拿一句模糊描述去生成,产出的是「基于猜测的通用页面」,用户丢掉
重来的概率很高。先问 5–7 个具体问题,多一次往返,但答案是真的。这一层在面板里
做而不是让模型做,因为面板不需要会话就能用、每次问题一致,而且用户能看见自己在填
什么——这是表单和对话的区别。用户如果跳过了,buildInterviewText 会让模型在构建前
自己问一次。
项目 = 文件夹 = 会话。 这三者绑定,是因为模型需要一个稳定的工作目录来放产物, 而人需要一个稳定的上下文来追问。换项目 = 换文件夹 = 换会话,语义一致。
对话窗绑项目,不绑窗口。 一个项目一份文件夹、一个会话、一份 manifest,所以同一 项目的两个窗口本来就共享上下文。假装每个窗口独立反而会让人以为它们互不影响。
动作通道做窄。 每个声明过的动作都会往会话里塞一段文字,消耗 token。如果所有 交互都上报,一个翻页就能烧掉半轮上下文。所以默认什么都不发,页面自己声明;而且就算 发出去也只是记进日志,什么时候真要模型动手,由你在对话里决定。
账本单文件 + 原子写。 项目和窗口的元数据很小(几十 KB),一个 JSON 文件够用,
比引入数据库简单得多。写入走临时文件 + rename,读坏了就隔离成
<name>.corrupt-<timestamp> 再重建,不阻塞启动。
客户端不 import 任何 harness 包。 浏览器端的模块表是冻结的九项
(react、react/jsx-runtime、react-dom、react-dom/client、@deepseek-ai/cordis、
@deepseek-ai/dsh-client-store、@deepseek-ai/dsh-client-ui-slots、
@deepseek-ai/dsh-client-ui-primitives、@deepseek-ai/dsh-client-ui-dockkit)。服务
通过结构化接口 + 运行时类型守卫获取,@deepseek-ai/* 只做 import type,编译后
不留任何引用。tsdown.config.ts 里的 purityGate() 会在构建期拒绝任何越界的值导入。
tar 和 market 自己写,不引依赖。 node:zlib 已经给了 gunzip,tar 的 512 字节头
格式四十年没变过,写一个只读解包器(src/tar.ts,328 行,带路径逃逸和 gzip bomb
防护)比引一个依赖更可控。市场目录的校验同理——宁可自己写 200 行 fail-closed 的
schema 检查,也不让一份坏目录把面板搞崩。
踩坑清单
这些都是真的踩过、并且已经在代码或检查里处理掉的。
dsh.client.inject必须写有./client导出的包。@deepseek-ai/dsh-client-ui-slots在浏览器的模块表里,require()能解析,但它 没有./client导出,所以启动清单不会为它建行,写进inject等于什么都没 排序——静默无效,不报错。slots这个服务真正的提供者是@deepseek-ai/dsh-client-ui-renderer。scripts/verify.mjs会逐个检查这件事。别人的席位必须用
ctx.slots.inject(name, () => register(...))注册。main、sidebar.panellist、shell.overlay全是 shell 自己声明的席位,而第三方 client half 的apply()跑在 shell 半体组合之前——裸调registry.register()直接 抛slot "…" is not declared(只出现在 console 里,容易漏看)。inject会等声明 到达再执行注册,声明重建时自动重挂。另外sidebar.panellist和main要成对: shell 会拒绝选中一个「面板还不存在」的侧边栏行。main是keyed槽,replaceRisk是shadows-shipped-ui。 它的 key 域里已经占了conversation。用ai-workbench这样的自有 key,绝不 复用别人的。shell.overlay是list槽,而且.layer必须pointer-events: none。 整层是覆盖全屏的 fixed 容器,只有.window和.launcher重新打开指针事件。 忘了这一条,工作台一挂载整个应用就点不动了。SSE 的每个事件都有名字,
onmessage永远不触发。 Host 发的是event: state\ndata: {...},客户端必须对hello/state/project/window/action/log/chat/open逐个addEventListener。新增事件时最容易 忘的就是这个数组。CSS Module 的类型要 sidecar。
declare module '*.module.css'给的是Record<string, string>,在noUncheckedIndexedAccess下每个styles.x都读成string | undefined,赋给className就报错。scripts/css-types.mjs生成的styles.module.css.d.ts用显式键覆盖掉通配声明——既修类型,又让拼错类名变成 编译错误。加类名之后记得重跑。客户端 bundle 的
id必须等于包名。 启动清单按包名建图,对不上就是加载了 一个谁也解析不到的 id。tsdown.config.ts的const ID是唯一来源。Host 的
inject故意为空。 硬 inject 是激活门:声明了但没被提供的服务会 把 fiber 永久 park,apply()永远不跑,整个页面还会报web boot: N entries did not activate。这个插件的每个服务都有降级路径(没有webServer就只做账本, 没有tools就不注册工具,没有skills就不注册技能),所以一个都不硬依赖, 全走ctx.inject([...], (scope) => ...)。重复挂载会让整个应用起不来。 一个 profile 里如果有两行指向同一个包名,两条 路由前缀会在组合期撞上,重复的
(kind, path)注册是直接抛错,不是跳过。所以cordis.patch.yml里那一行带了disabled: !!js守卫,并且guardRegistration()把注册期的冲突降级成一条 warning。改包名(本版从dsh-window-forge改过来)时, 务必先卸旧包再装新包,否则两个包各注册一套前缀路由、各开一套账本。ctx.sessionController.prompt()只接受非空内容。 空字符串会被gateway/bad-request拒掉,而且requestId是幂等键——重试用同一个 id 不会 产生第二次投递。resolveArtifactPath()做两次包含性检查。 一次是词法上的(挡..),一次 是realpath之后的(挡符号链接)。只做前者,一个指向外部的软链接就能读到整 个磁盘。另外.exe/.dll/.bat/.cmd/.ps1/.sh/.node/.env一律拒绝。iframe 的 CSP 要放开
frame-ancestors。 产物是从同源的另一条路由加载的, 但它要嵌在 DSH 页面里。默认的frame-ancestors 'none'会让它白屏。账本写入要防抖 + 原子。 拖窗口会一秒触发几十次
PATCH,每次都写盘既慢又 容易写坏。flush()合并写入,落盘走临时文件 + rename。拖动窗口的事件监听要挂在
window上。 指针一动就会离开 38px 高的标题栏, 挂在元素上会中途丢事件。pnpm全局 shim 坏了(见上面的「本机的坑」)。这是本机环境问题,不是插件 的问题,但会让dsh plugin add以一个和插件无关的错误失败。客户端服务在
apply()时还没就绪,必须惰性解析。 shell 的客户端服务 (sessions/conversation/uiWorkspace)比第三方 client half 晚约半秒组合。 在apply()里ctx.get()一次存下来,拿到的永远是undefined——而且不报错。 存的必须是 resolver(() => ctx.get(name)),用的时候再读。conversation.sendSession返回{kind:'success'|'error'},不是RemoteResult。 按ok===false判失败会把真失败当成成功。SessionFace.prompt才是{ok:true,value}/{ok:false,error}——两个返回形状不同,别混。模型写完文件不会产生任何账本写入,所以不会有 SSE 事件。 客户端会永远停在 「生成中」。修法是读时对账(
src/reconcile.ts):GET /state时 Host 读一遍_workbench.json和 entry 文件,与账本有差异才写(只写真差异,避免轮询自激); 客户端在building期间每 2 秒轮询一次load()。任务文本里绝不能让模型裸用 localStorage。 沙箱 iframe 故意不给
allow-same-origin,origin 是不透明的,任何 localStorage 访问都抛 SecurityError。最恶性的形态是异常发生在complete()内部、把postAction炸断在动作发出去之前——页面看着正常,闭环就是不通。task-text.ts和内置技能 都明确要求:状态存内存;要持久化必须整段 try/catch 静默退回内存,绝不打断主循环。一个文件夹只能是一个项目。 模型收到建窗任务后会自己调
workbench_window {action:'create', folder:<任务里的文件夹>},工具若无条件建项目,账本里就会出现 第二个指向同一文件夹的空壳,manifest 查询从此有歧义。查重在账本层做 (normalizeFolder归一化后查重复用),HTTP 面板和模型工具两条路都过这里。客户端这一代没有
sessions.create()。 想新建会话只有两条路:uiWorkspace的 workspace 入口(会把用户拖进一个新工作区),或者让 Host 建。sessionController.create({cwd})在 Host 侧可用,所以POST /session由 Host 建会话、把 id 写回项目行,客户端只负责调用。cwd是关键:不传它,模型会把产物 写到进程工作目录,而不是项目文件夹。对话窗里模型的回复只能从会话事件日志里读。 没有「列出这条会话的聊天记录」 的客户端 API,但 Host 侧
sessionController.inspect(id)能在不激活 agent 的前提 下读全量事件(它不会开启新回合)。从assistant/message事件里取text块, 时间戳用事件自带的time而不是Date.now()——这样轮询第二次时按(role, at, text)找到同一行,不会把同一条回复每轮追加一遍。
22b. 别把用户在 DSH 里的正常对话吸进工作台。 项目绑的会话就是用户平时聊天的那
一个,所以「把最近的 assistant 消息都当工作台回复」会把用户和模型的私聊抄进
对话窗——0.3.3 实测抓回来两段无关对话。修法是按出站标记配对:只有以
[workbench-chat] / [workbench-action] / 【工作台 · 开头的用户消息才算
工作台提问,其后第一条 assistant 消息才是回答。这里的 armed 标志只能被
带标记的用户消息置位、只能被消费掉的那条回复清除,不能被「无标记用户消息」清掉:
harness 会在真实 prompt 之后追加 3 条注入的 user/message(runtime context、
技能目录、时间戳),第一版实现把标志清掉了,结果每条回复都取不到(added=0)。
排查方法:会话存储是 <DSH home>/sessions/<cwd-slug>/<sessionId>/session.v4.jsonl.zstd,
多帧 zstd,要按 magic 28 b5 2f fd 切帧再逐帧 zlib.zstdDecompressSync
(整文件一次解只出第一帧,容易误判成「日志里没有这条消息」)。
面板上的开关不要在浏览器里存第二份。
interviewFirst/chatPane来自 用户改的 Config,Host 通过GET /state的features下发。客户端自己存一份的 后果是:用户在设置页关掉了,面板还在按旧值工作,而且没有任何报错。descriptorPage里的rows要显式标注类型。 在noUncheckedIndexedAccess下,Object.entries解构出来的元素是[string, string | undefined],直接传给模板会报 TS2345。改名不能只改包名。 真正要一起动的是:
package.json的name、cordis.patch.yml的name与id、tsdown.config.ts的const ID、protocol.ts里的ROUTE_API/ROUTE_EVENTS/LEDGER_FILE_NAME/PANEL_ID/SEAT_ID/ACTION_SOURCE、locale/*.json的命名空间、messages.ts的LOCALE_NS、verify.mjs里的断言字符串。漏一个的表现各不相同(有的是静默 不加载,有的是 404),所以verify.mjs把这些常量都钉住了。有页面、没窗口行的项目会永远看不见。 这是 0.3.4 之前真实发生的:模型把
index.html和_workbench.json写进了文件夹,但窗口行只在「用户从面板提交 需求」时才创建,所以从工具路径建出来的项目(以及从旧版迁移过来的项目)有项目行 却没有任何窗口,面板里列着项目、点进去什么都不显示。根因是reconcileWindows原本对没有窗口行的项目直接continue——它只修补已存在的窗口,从不补建窗口。 现在的做法是:没有窗口行、但 entry 文件真的能解析出来的项目,由对账收养 (adoptProject)建一行并记一条adopted "…"日志。条件是「页面真的存在」, 空文件夹和缺失文件夹保持无窗口,因为那才是诚实状态。收养必须能被删除按钮否决。 上面那条自愈逻辑有个直接的反作用:用户删掉窗口, 下一次轮询发现文件夹里还有页面,就会把它收养回来——删除按钮看起来是坏的。所以 删除走
dismissWindow()而不是removeWindow(),它同时在项目行上写windowDismissed: true,收养路径见到这个标记就不动;而显式新建窗口会清掉标记, 于是「删掉、再造一个新的」仍然成立。这个字段只认字面量true(手改账本不能用 truthy 字符串压制收养),清除时删字段而不是写false,好让内存形状和落盘形状一致。描述页 v1 的自愈必须发生在 serve 时刻,而不是 reconcile 时刻。 reconcile 的 自愈只写账本里的
inlineHtml,但handleSite是磁盘优先的——磁盘上那一份 v1 描述 页(带desktopWorkbenches标记、没有 wb-build 按钮)会继续被端给用户。用户的 文件夹不该被插件重写(那是模型的领地),所以 serve 侧在「磁盘命中 + text/html +isDescriptorPath(entry)+ 内容含 v1 标记」四个条件同时成立时,现场生成 v2 页返回; 磁盘文件保持原样。'no such file' 是 missing,不是 forbidden。
resolveArtifactPath对 ENOENT 返回{reason:'no such file'},而旧版readArtifact把一切 resolve 失败都归为 forbidden——inline 描述页回退只对 missing 生效,于是「磁盘无根 index.html」的 包(如 beianbai,真页面在standalone/且缺构建产物)显式请求/site/:id/index.html时拿到 403 而不是描述页。判别标准是原因而非状态码: 只有越权(路径逃出项目文件夹)和扩展名拒绝才是真正的 forbidden,文件不存在 统一归 missing。判断市场包能不能独立运行,看的是包内容,不是愿望。 dsh-desktop 市场的包有 三种形态:纯 React 组件(需要
desktopWorkbenches服务,我们跑不了)、自带构建 产物的静态页(能直接开)、页面壳引用缺失构建产物(开了也是卡死的加载页—— beianbai 的standalone/index.html引用/assets/standalone.js,而lib/standalone.js要npm run build才有)。第三种比第一种更危险,因为它 假 ready。所以导入路径用referencesMissingAssets()把坏壳降级成描述页路线, 收养路径用磁盘版referencesMissingAssetsOnDisk()做同一件事;「让 AI 搭建」 按钮就是为这两种包准备的出路——模型读包源码,在项目文件夹里写出自包含的index.html。
更新日志
0.8.1(当前)
模板市场相关的五个问题一次修完,都是用户实际撞到的:
| # | 问题 | 根因 | 修法 |
|---|---|---|---|
| 1 | 装完市场包,卡片还显示「未安装」 | installFromMarket() 置了 marketBusy=true 后调 loadMarket(),而 loadMarket() 第一行就是 if (state.marketBusy) return——刷新被自己挡死 |
finally 里先清 marketBusy,再 await loadMarket() |
| 2 | 删掉工作台,卡片还显示「已安装」 | deleteProject() 只刷新项目列表,从不刷新市场,徽标用的是账本里的旧 installedProjectId |
删除后 await load() 再 await loadMarket() |
| 3 | 某些市场工作台装完白屏/半屏 | 市场页的 localStorage 调用在外部 JS 模块里,而 storage shim 的门禁只在入口 HTML 文本里搜 localStorage 字样——搜不到就不注入。沙箱 iframe 是不透明 origin,setItem 直接抛 SecurityError,模块顶层炸掉,页面半绘 |
去掉文本门禁,shim 注入到每一个 served HTML 文档 |
| 4 | 「打开方式」两个芯片没有任何说明 | 只有 aria-pressed,没有 title,字典里也没有解释文案 |
PLACEMENT_CHIPS 加 descKey,悬停显示「浮在本面板上方…」「在浏览器独立标签页…」 |
| 5 | 部分市场包装不上(did not finish within 30000ms / fetch failed) |
国内网络到 github.com 超时,插件有镜像兜底,但镜像复用直连的 30 秒预算,10–90 秒的任务被直接掐断 |
新增 PROXY_FETCH_TIMEOUT_MS = 180_000,镜像单独计时;两个都超时时报错区分是谁超时 |
顺带修掉的两个账本健壮性问题:flush() 遇到 EPERM: operation not permitted, rename
(杀毒软件/OneDrive 瞬时占用)会重试 3 次并退回原地写;重复安装同一个市场包现在复用它
的文件夹并覆盖写入,而不是生成 <name> 2 再报 409。
文档修正(0.8.1 之后,未发版)
把「动作回传给模型」的描述改成与实际实现一致:窗口里点击声明的动作只会记进工作台日志,
不会自动唤醒模型;要模型动手,由你在对话窗里开口。改动的段落:开头的流程说明、第 7 步
的闭环描述、「动作回传:只发关键动作」整节(改名「动作上报」)、forwardActionsToSession
配置项说明、设计取舍里的「动作通道做窄」。代码未改。
目录
dsh-ai-workbench/
├── package.json manifest:入口、dsh.bundle.patch、dsh.client
├── cordis.patch.yml bundle patch(带重复挂载守卫)
├── tsconfig.json 严格模式 + noUncheckedIndexedAccess
├── tsdown.config.ts Host(ESM)+ Client(CJS 握手)两套配置
├── locale/
│ ├── en.json 安装器读 meta.title / meta.description
│ └── zh.json
├── scripts/
│ ├── clean.mjs 删 lib/(tsdown 是 clean:false,必须先跑)
│ ├── css-types.mjs 从 stylesheet 生成 CSS Module 类型 sidecar
│ └── verify.mjs 静态契约检查
└── src/
├── index.ts Host 装配:账本、路由、工具、技能、设置
├── config.ts schemastery Config schema
├── protocol.ts 两半共享的线上契约(常量 + 类型)
├── ledger.ts 单文件账本(原子写、损坏隔离、旧账本接管)
├── artifacts.ts 产物读取 + 路径安全边界 + 内联页回退
├── reconcile.ts 读时对账:manifest/产物 → 账本(building→ready)
├── summary.ts 给模型的工作台上下文摘要 + 回复抽取
├── bundle.ts 工作台 ↔ .dshwb.json ↔ dsh-desktop 包 的双向映射
├── market.ts 市场目录拉取 + fail-closed 校验 + 分发地址推导
├── tar.ts 零依赖只读 tar 解包(防路径逃逸、防 gzip bomb)
├── skill.ts 内置作者技能正文 + 资源清单 + 精简摘要
├── routes/
│ ├── api.ts JSON 路由(mini-router,23 条)
│ └── sse.ts 事件推送
├── tools/
│ └── workbench_window.ts 模型可调用的工具(5 个 action)
└── client/
├── index.tsx 注册 main / sidebar.panellist / shell.overlay
├── Panel.tsx 中央面板(需求 + 提问 + 项目 + 窗口 + 导入导出 + 市场)
├── Intake.tsx 先问后建的问题表单
├── Chat.tsx 窗口右侧的绑定对话窗
├── Market.tsx 模板市场列表
├── Windows.tsx 浮层窗口(iframe + postMessage 桥 + 对话分栏)
├── store.ts 客户端状态容器 + SSE + 会话发送
├── task-text.ts 一句话 → 发给模型的提示词(建窗 / 追问 / 精修)
├── messages.ts en/zh 字典 + t()
├── use-store.ts useSyncExternalStore 绑定
├── styles.module.css
└── css-modules.d.ts
许可
MIT
界面截图
全部截图来自真实运行环境(Windows 11 + DSH 桌面端 0.8.1),未做任何修饰:
| 文件 | 展示内容 |
|---|---|
docs/screenshots/01-panel.png |
中央面板全景:需求输入框、布局/打开方式选择、项目与窗口列表、模板市场、底部状态栏 |
docs/screenshots/02-window.png |
一个真实工作台窗口(浮层):标题栏含状态徽标、刷新、不透明度、最小化、对话、全屏、关闭 |
docs/screenshots/03-intake.png |
「先问后建」的入口:一句大白话输入框 + 7 种布局预设 + 两种打开方式 |
docs/screenshots/04-market.png |
模板市场列表:版本号、分类、仅说明页 徽章、许可证、安装按钮 |
截图目录的说明见 docs/screenshots/README.md。深色/浅色各截一套更好:把
01-panel.png 存成 01-panel-dark.png,在 README 里换源即可(当前是单图,换图不影响文字)。
No comments yet. Be the first to write one.