项目:dsh-prompt-deck(提示词卡组)— 一个本地提示词管理插件,输入框旁一键唤起,搜索、查看、编辑提示词卡片,点击即追加注入输入框。仓库:glon12138/dsh-prompt-deck

缘起

日常和 AI 对话,总有一批高频提示词:代码审查模板、翻译规范、总结格式……散落在备忘录里,用的时候靠复制粘贴。与其切窗口,不如直接在 DSH 输入框旁边做一个「提示词卡组」:

  • 输入框旁一个按钮,点开是可搜索的卡片列表
  • 点击卡片,内容追加进输入框——绝不覆盖已经打了一半的草稿
  • 支持新建、编辑、删除(带二次确认)
  • 数据只存本地:~/.dsh/prompt-deck.json

DSH 插件架构:双半结构

DSH 插件分两个半:

半边 运行环境 职责
Host 半(src/index.ts) Node,Cordis 容器 HTTP 路由、文件 IO、注入 ctx.webServer
Browser 半(lib/client.js) 浏览器,ModuleLoader UI、交互,window.__ModuleLoader__.load({ factory })

双半通过同源 HTTP 通讯:浏览器半 fetch('/__prompt-deck/...') → 宿主半路由 → JSON 文件。UI 不是想挂哪就哪,而是要挂进 DSH 的插槽(slots)系统——每个位置声明了种类(list/single/keyed)、作用域(root/session)和标准 props。

第一步:宿主半——三个路由加一个可信的 JSON 文件

GET  /__prompt-deck/list    → { ok, prompts }(文件缺失时用种子数据初始化)
POST /__prompt-deck/save    → upsert { id?, title, content }
POST /__prompt-deck/delete  → 删除 { id }

存储层做了三个决定,都是围绕「不弄丢用户数据」:

  1. 单互斥链串行化读改写——所有操作排一条 promise 链,并发请求不会交错读写;
  2. 临时文件 + rename 原子落盘——进程崩在半路也不会留下写了一半的 JSON;
  3. 损坏文件大声报错(500),绝不静默用空数据覆盖——读到非法 JSON 直接失败,保住现场。

首次运行播种也刻意收窄条件:只有文件不存在(ENOENT)时写入 4 张示例卡。文件存在就永远不播,删掉种子卡也不会「回种」。

const run = chain.then(task, task)
chain = run.catch(() => undefined)   // 失败也不断链

第二步:浏览器半——选缝、注入、样式契约

选缝:输入框旁按钮挂 conversation.input.left(list / session 级)。

注入的核心是三行——先读当前草稿,空则直接放,非空则换行追加:

const current = props.useInput((s) => s.draft)
const merged = current === ''
  ? text
  : (current.endsWith('\n') ? current + text : current + '\n' + text)
inputActions.setDraft(merged)

setDraft 是整体替换语义,所以必须自己拼接——这正是「不覆盖已有草稿」的全部秘密。

样式契约:所有颜色走 --dsw-alias-* 设计令牌(字面量只作老宿主兜底),不写任何按主题分支的选择器;同时打上 data-dsh-plugin / data-dsh-part / data-dsh-surface 语义属性——既是皮肤中心的兼容要求,也让自己的 E2E 测试选择器极其稳定。

验证方法:每步都跑「搜索 → 点击 → 注入」闭环

开发节奏是每完成一个功能立即用 Playwright 闭环验证(headless Edge + playwright-core):

阶段 用例 结果
路由与错误路径 curl 全路径 ✅
搜索→点击→注入 9 项(含「已有草稿ABC…」追加断言) 9/9
CRUD + 二次确认 10 项(取消不删、确认才删) 10/10
种子数据 + 错误/空态 23 项 23/23
右面板迁移 15 项 15/15
行内预览 14 项 14/14

测试中记下两个环境事实:composer 是 contenteditable 不是 textarea(断言用 innerText);DSH 里同名按钮不止一个,Playwright 严格模式下要 exact: true 并限定在自己的容器内。

第一次演进:面板搬进右侧面板

初始形态是输入框上方的弹出浮层。想让它成为常驻工作台,就迁移到 DSH 的右侧 Sidebar——它的挂载契约是「双注册」:

// 1. 注册 tab 类型(谁认领 sidebar://prompt-deck 这个地址)
ctx.sidebarRightTabs.register({
  id: 'dsh-prompt-deck',
  kind: 'prompt-deck',
  title: () => t('nav'),
  guide: [{ id: 'deck', order: 70, title: ..., description: ... }], // 引导页入口
})
// 2. 用同名 id 作为 key,把面板主体挂进 keyed 插槽
ctx.slots.inject('sidebar.right.pane.tab', () =>
  ctx.slots.register({ name: 'sidebar.right.pane.tab', key: 'dsh-prompt-deck' }, DeckPanel))

一个关键发现让这次迁移毫无阻力:useInput / inputActions 不是输入框插槽的私有 props,而是 SessionStandardProps——所有 session 级插槽的占用者都会被框架自动下发。右面板 tab 体天然拥有注入能力,不需要自己搭桥。

输入框旁按钮保留,职责变为启动器:sidebarRight.openTab('prompt-deck')(已打开则聚焦,不会重复开)。

第二次演进:列表里看得到全文

上线后收到的第一个反馈:列表只有标题和首行,想看内容得进编辑器——不方便,但交互要保持简单。

最终方案是行内展开:

  • 卡片左侧 ▸ 原地展开完整内容(保留换行,超长滚动)
  • 展开块里直接给一个「追加到输入框」主按钮,注入后自动收起
  • 点击卡片主体仍是直接注入——核心闭环不动
  • 多卡可同时展开,互不干扰

原则:看全文是「查看」的增强,不能让它夺走「点击即注入」的一击即达。

打包与发布:三条路,一个坑

DSH 官方支持三种分发方式:

  1. npm(推荐):npm publish 后用户 dsh plugin add dsh-prompt-deck,装的是预构建产物;
  2. GitHub 直装:dsh plugin add github:user/repo,拉源码、靠 prepare 脚本安装时构建——但 pnpm ≥10 需要用户显式 allowBuilds 授权;
  3. tarball:pnpm pack 出 .tgz,dsh plugin add ./xxx.tgz,无需任何构建授权。

这里踩了一个值得记录的坑:pnpm pack 不会自动执行 build——实测打出的 tarball 里没有 dist/,宿主半整个缺失。修复是加生命周期脚本:

"scripts": {
  "build": "tsc -p tsconfig.json",
  "prepare": "pnpm run build",   // git 安装后自动构建
  "prepack": "pnpm run build"    // pack/publish 前自动构建
}

另外,仓库推送时远端已有 GitHub 建仓生成的 LICENSE——用 git pull --rebase 把它变基到自己的提交之下,而不是强推覆盖。

踩坑清单

  1. --patch 必须放在 app 参数之前:dsh web --patch ./p.yml --no-open 正确,反过来报 unknown option '--patch'(launcher 参数先于 app 参数解析)。
  2. Host 半不热重载:改 src/index.ts 必须重启服务;Browser 半改 lib/client.js 刷新页面即生效——开发时要分清哪半在改。
  3. 弹层会盖住 composer:测试脚本里要先输草稿再开面板,否则 Playwright 点击被拦截超时。
  4. 数据文件已存在时种子逻辑不触发:验证种子要先 rm 文件——只在 ENOENT 时播种是设计,不是 bug。
  5. 后台任务停止≠子进程停止:TaskStop 杀了 bash 包装器,node 子进程还占着 3080 端口,需要手动 taskkill。

结语

一天下来,插件本身代码量不大(宿主半 300 行 + 浏览器半 500 行),真正花时间的是三件事:把契约读准(插槽种类、标准 props、样式令牌,全部从宿主源码里核实而不是猜)、每步闭环验证(五轮 Playwright 共 70+ 项断言)、以及两次基于真实使用反馈的形态迭代(弹层 → 右面板,列表 → 行内展开)。

DSH 的插槽系统把「东西该放哪」变成了查表题,而双半架构让本地数据存储和 UI 各归其位。如果你也想做一个插件,建议照着这个节奏:先跑通最小闭环,再让验证盯着每一次改动。


代码开源在 github.com/glon12138/dsh-prompt-deck,MIT License。