项目: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 }
存储层做了三个决定,都是围绕「不弄丢用户数据」:
- 单互斥链串行化读改写——所有操作排一条 promise 链,并发请求不会交错读写;
- 临时文件 + rename 原子落盘——进程崩在半路也不会留下写了一半的 JSON;
- 损坏文件大声报错(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 官方支持三种分发方式:
- npm(推荐):
npm publish后用户dsh plugin add dsh-prompt-deck,装的是预构建产物; - GitHub 直装:
dsh plugin add github:user/repo,拉源码、靠prepare脚本安装时构建——但 pnpm ≥10 需要用户显式allowBuilds授权; - 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 把它变基到自己的提交之下,而不是强推覆盖。
踩坑清单
--patch必须放在 app 参数之前:dsh web --patch ./p.yml --no-open正确,反过来报unknown option '--patch'(launcher 参数先于 app 参数解析)。- Host 半不热重载:改
src/index.ts必须重启服务;Browser 半改lib/client.js刷新页面即生效——开发时要分清哪半在改。 - 弹层会盖住 composer:测试脚本里要先输草稿再开面板,否则 Playwright 点击被拦截超时。
- 数据文件已存在时种子逻辑不触发:验证种子要先
rm文件——只在 ENOENT 时播种是设计,不是 bug。 - 后台任务停止≠子进程停止:TaskStop 杀了 bash 包装器,node 子进程还占着 3080 端口,需要手动
taskkill。
结语
一天下来,插件本身代码量不大(宿主半 300 行 + 浏览器半 500 行),真正花时间的是三件事:把契约读准(插槽种类、标准 props、样式令牌,全部从宿主源码里核实而不是猜)、每步闭环验证(五轮 Playwright 共 70+ 项断言)、以及两次基于真实使用反馈的形态迭代(弹层 → 右面板,列表 → 行内展开)。
DSH 的插槽系统把「东西该放哪」变成了查表题,而双半架构让本地数据存储和 UI 各归其位。如果你也想做一个插件,建议照着这个节奏:先跑通最小闭环,再让验证盯着每一次改动。
代码开源在 github.com/glon12138/dsh-prompt-deck,MIT License。