# dsh-notebook **DSH 官方右侧栏里的记事本。** `+` 新建 → 写标题与正文 → 贴图 → 点**完成**,条目以标题陈列。 点标题复制正文 · 打 `@` 引用记事 · 点**编辑**复用同一个容器。 [![CI](https://github.com/wyzh0117/dsh-notebook/actions/workflows/ci.yml/badge.svg)](https://github.com/wyzh0117/dsh-notebook/actions/workflows/ci.yml) [![license](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE) [![node](https://img.shields.io/badge/node-%3E%3D20-3c873a.svg)](https://nodejs.org) [![dsh](https://img.shields.io/badge/dsh-%3E%3D0.2.0--rc.2-6b4fbb.svg)](#兼容性) [![tests](https://img.shields.io/badge/tests-vitest-6da55f.svg)](./test) `dsh-plugin` · `deepseek-harness` · `notebook` · `notes` · `sidebar`
[English](./README.md) · **中文** **本次更新** - **v0.3.0** —— 页面现在只长在 DSH 的**官方右侧栏**里,别处没有:`dsh-better-sidebar` 载体和插件自绘的兜底外壳都已删除,`openOnStart` 由官方侧栏兑现;而 peer 范围必须升到 `0.2.0-rc.2`,bundle 才肯被加载。 - **v0.2.0** —— 会话也能往记事本里写:选中文字浮现「进记事本」动作;每条回答末尾的记事本图标可把整条回复存成一条记事。两个都默认开启。 - **v0.2.1** —— 正文框随内容自动缩放:写着变高、删掉变矮,最高到窗口高度的 60%。 - **v0.2.2** —— 去掉所有原生确认框(原生模态可能把嵌入式宿主卡死);所有文案跟随 shell 语言。 - **v0.2.3** —— 「新会话自动打开记事本」现在真的会打开侧边栏。 **快速跳转** · [功能](#功能) · [安装](#安装) · [使用](#使用) · [兼容性](#兼容性) · [设置项](#设置项) · [实现细节](#实现细节) · [已知限制](#已知限制) · [FAQ](#faq) · [开发](#开发) > **GitHub topics(仓库设置里加):** `dsh-plugin` `deepseek-harness` `notebook` `notes` `sidebar` --- ## 这是什么 `dsh-notebook` 是 [DSH(DeepSeek Harness)](https://github.com/deepseek-ai/dsh) 的 Web 插件,在 **DSH 自己的右侧栏**里放一个**记事本**——一个通过官方 tab 注册表登记的 tab,不需要任何配套插件。 它只解决一件事:随手记一条带标题、正文和图片的短笔记,然后在输入框里用起来——点一下标题就把正文送进剪贴板,打 `@` 能引用某条记事。 不引入富文本编辑器、不做云同步、不做版本历史。笔记是**全局共享**的(不按会话隔离),图片以**文件形式落在宿主磁盘**上。 ## 功能 | 功能 | 说明 | |---|---| | **`+` 新建条目** | 面板右上角的 `+`,点击后在**面板内**弹出编辑器——不新开窗口、不新开 tab | | **标题 + 正文 + 图片** | 单行标题、随内容缩放的正文、图片缩略图区,共用一个可滚动容器 | | **可放图片,不可放视频** | 粘贴、拖放、「插入图片」三种入口走同一条校验:`video/*` 与任何已知视频扩展名(`mp4/mov/webm/mkv/avi/m4v/ogv/mpg/mpeg/3gp/flv/wmv`)一律行内拒收 | | **「完成」后以标题陈列** | 列表一条一条以**标题**为单位显示,最新在上,次要信息是「时间 · N 张图片」 | | **点标题复制正文** | 复制的是**正文本身**、**不含标题**;图片还原成 `[图片: 文件名]` 一行,并 toast「已复制正文(N 字)」。它**永远不往输入框里写东西** | | **`@` 引用记事** | 输入框里打 `@`,在本地化的「记事本」分组下多出记事条目(标题 + 摘要,最新在前,最多 8 条);选中插入原子 chip,发送时只有**正文**交给模型 | | **行内「对话引用」按钮** | 作用同 `@` 选中,从列表行里直接插;够不到输入框时如实提示,不假装成功 | | **选中文字 →「进记事本」**(v0.2.0,默认开) | 选区旁的浮动动作,把选区**原文**存成一条记事,标题是 `未命名1`、`未命名2`……输入框内与面板自身的选区有意不提供 | | **每条回答 →「存入记事本」**(v0.2.0,默认开) | 回答动作行末尾的一个图标,一点把整条回复存成一条记事,标题用**该会话自己的标题** | | **两个捕获功能都可开关** | 设置里的 `selectionToNotebook` / `messageToNotebook`,下一次渲染即生效,不需重新加载 | | **新会话自动打开**(默认关闭) | 每有会话变成当前就打开 Notebook——包括页面加载恢复出来的那个 | | **「编辑」复用同一个容器** | DOM 里编辑器始终只有 1 个 | | **图片落盘** | `dataURL` 上传,host 解码写入 `$DSH_HOME/storages/notebook-attachments//`;删除记事时一并删除 | | **原子写,不静默丢数据** | 临时文件 → `fsync` → `.bak` → `rename`,并由 mutex 串行化。`$DSH_HOME` 不可写时降级为内存态,每个响应带 `degraded: true`,界面顶部显示非阻断提示 | | **键盘** | `Cmd/Ctrl+Enter` = 完成,`Esc` = 取消(草稿有改动时先问一句) | 更深入的设计说明见〈[实现细节](#实现细节)〉。 ## 截图 > **这一节暂时没有截图。** 原先放在这里的几张图都是在 0.3 之前的外壳里截的,而那些外壳已经不存在了;迁移到官方右侧栏之后还没有重新截图。在那之前,〈[功能](#功能)〉表就是准绳:面板就是带 `+` 表头的记事列表,加上一个可复用的编辑容器,里面是标题、正文、图片缩略图和行内的视频拒收提示。 ## 安装 | | | |---|---| | DSH | `>=0.2.0-rc.2` | | Node | `>=20` | | 包管理器 | **pnpm**——本仓库不支持 npm | | 配套插件 | 无——页面渲染在 DSH 自带的那条右侧栏里 | **从仓库安装:** ```sh dsh plugin --profile web add github:wyzh0117/dsh-notebook ``` **从源码本地挂载(开发用):** ```sh git clone https://github.com/wyzh0117/dsh-notebook.git cd dsh-notebook pnpm install pnpm build # 产出 lib/index.js、lib/client.js、lib/types/** dsh plugin --profile web add "link:$PWD" ``` 等价的纯手工做法——在 `~/.dsh/profiles/web/package.json` 里: ```jsonc { "dependencies": { "dsh-notebook": "link:/abs/path/to/dsh-notebook" }, "dsh": { "profile": { "bundles": [ /* … */, "dsh-notebook" ] } } } ``` 然后在 profile 目录里 `pnpm install`。`dsh.profile.bundles` 必须包含 `dsh-notebook`,否则插件不会被加载。如果它已经列在里面、插件行却仍显示为已禁用,那是版本闸门跳过了整个 bundle——见〈[插件根本不加载时](#插件根本不加载时)〉。 > **本地开发时不要重启你正在用的那个 DSH**(比如 3080 端口的 Web GUI,重启会杀掉当前会话)。需要真机验收时,另起一个隔离环境: > ```sh > DSH_HOME=/tmp/dshnb-home npx -y --package @deepseek-ai/dsh dsh web --port 3099 > ``` ## 使用 1. 展开右侧栏,从它的 `+`(新建 tab)列表里选 **Notebook**——那一行带着插件自己的标题、说明和图标。右侧栏按会话各存一份界面,新建的会话里它默认是空的,所以要么每次手动加,要么打开「新会话自动打开记事本」。 2. 点右上角 **`+`** → 编辑器在面板内弹出。 3. 写**标题**和**正文**。正文框随内容缩放(最高到窗口高度的 60%,再长就在框内滚动)。配图可以**粘贴 / 拖入图片**,或点「插入图片」——视频会被拒绝;单图上限 10 MB,单条上限 20 张(都可在设置里调)。 4. 点「完成」(或 `Cmd/Ctrl+Enter`)→ 条目以**标题**陈列。 5. 点**标题文字** → 正文进剪贴板。 6. 想让模型读某条记事:输入框里打 **`@`** 选它,或点该行末尾的 **`对话引用`**。发送时只有**正文**交给模型;页面刷新后需要重新插入引用。 7. 点行尾 **`编辑`** → **同一个容器**载入该条;点 **`删除`** 会先用面板自己的对话框问一次。 8. **让会话替你写一条记事**(v0.2.0): - **在会话里选中文字** → 点选区旁浮现的 **「进记事本」**。 - **点回答末尾的记事本图标** → 整条回复按会话标题存成一条记事。 两者都用同一个 toast 确认,也都可以在设置里关掉。 ## 兼容性 | | | |---|---| | DSH | `>=0.2.0-rc.2`——只有这条版本线带上本插件要注册进去的**官方右侧栏**(`ctx.sidebarRightTabs`) | | Node | `>=20` | | 配套插件 | 没有。host 半侧只需要 `webServer`、cordis 与 schemastery,tab 要出现不需要再装任何东西 | | DSH 侧可选接缝 | 缺 `conversation`、`inputTriggers` 或 `sessions` 时,对应的联动能力自动关闭,记事本本体照常工作 | | 旧版 shell | 每个功能都注册在可选接缝上,只会降级不会坏:槽位不存在则捕获入口根本不出现;读不到 locale 就用插件自带的 `zh` 字典;面板不依赖 `window.confirm` 或任何 dialog API | ### 页面住在哪儿——官方右侧栏 只有一个载体:DSH 自己那条右侧栏。tab 分**两阶段**注册,先本体后类型: 1. tab **本体**进 keyed 槽 `sidebar.right.pane.tab`,`key: 'dsh-notebook'`; 2. tab **类型**进 `ctx.sidebarRightTabs.register({ id: 'dsh-notebook', kind: 'dsh-notebook', priority: 'extension', title, guide: [{ id, order: 60, title, description, icon }] })`。 三条由注册表说了算、不容商量的约束: - 槽位的 `key` **必须等于**第一阶段的 `id`——席位是拿「当前 `kind` 对应类型的 `id`」去派发条目的。在 keyed 槽上使用列表形状的 `{ id, order }` 不是「差一点就对」,而是**一次失败的注册**,而侧栏给出的回应是一个永远画不出来的 Notebook tab; - `guide` 条目必须自带稳定的 `id`,否则注册表抛 `duplicate guide entry id`; - 先本体后类型,于是一个只注册了一半的宿主绝不会显示一个画不出来的 tab——任何阶段失败,同批注册一起拆掉。 tab 本体通过槽位注入的 `useTabInfo()` 钩子读自己的可见性,因为席位渲染 tab 时给的是**空的 owner share**,`tab.visible` 根本不会作为普通 prop 到达。这正是「隐藏的 tab 真的不加载、不轮询」得以成立的原因。 `src/client/hosts/controller.ts` 先探测 `sidebarRightTabs`;若这个服务还没到(cordis 按依赖顺序派发插件,不是侧栏优先),就用 `ctx.inject(['sidebarRightTabs'], …)` **等一次**。没有兜底外壳:服务始终不出现就什么都不注册,并把原因写进日志。所有注册都包在 `ctx.effect(() => { …; return dispose })` 里,HMR / 禁用安全。 **「打开」本身就是「展开」。** `ctx.sidebarRight.openTab(kind)` 在打开 tab 的同一次意图里就展开了这一列,而在屏幕上没有 Session 时它会抛。`isExpanded()` 回答的是席位**上一次已提交渲染**时绑定的 surface——所以处在成功打开的那个同步块里时它依然报 `false`——而 `toggleExpanded()` 翻的是**实时**值。读前者、再用后者去「纠正」,就会把刚被打开揭示出来的那一列收回去;这就是 v0.2.2 的 bug,所以这个调用之后什么也不能再接。 ### 插件根本不加载时 dsh `0.2.0-rc.2` 的 `dsh-app-boot` 会读 `package.json` 里每一个名为 `@deepseek-ai/dsh` 或以 `@deepseek-ai/dsh-` 开头的 `peerDependency`,然后跑 `semver.satisfies(runtimeVersion, range, { includePrerelease: true })`。任何一条不匹配,整个 bundle 就被跳过:模块根本不会被 import,插件行显示为已禁用,日志里是 ``` dsh: skipping profile bundle "dsh-notebook": … is incompatible with dsh 0.2.0-rc.2: peerDependencies { … } ``` 由此引出两件会让人意外的事: - **rc.2 并不校验 `dsh.plugin.json` 的 `engines.dsh`**——只有 peer 范围说话,所以 `engines` 写得再对也救不了一个过期的 peer; - 服务端那半不是原因:本插件从来只需要 `webServer`、cordis 和 schemastery。把它挡在 0.2 外面的是 `package.json` 里的版本闸门,不是代码。 逃生门是 `dsh plugin allow-version`,它把一条精确版本的豁免写进 `/compatibility.json`;对应的 disallow 命令可以撤销。用它去看清一次不匹配,而不是长期带着它——peer 不满足通常意味着真实存在的 API 差异。 ## 设置项 一份定义,一个席位。真值存在 host 的 `NotebookDoc.prefs`——读写一律走 `PATCH /notebook/api/prefs`——而**八个开关全部**出现在 DSH 的全局设置页里,因为官方侧栏没有 per-tab 的设置页。 | 键 | 类型 | 默认 | 说明 | |---|---|---|---| | `sortOrder` | `'updated' \| 'created' \| 'title'` | `'updated'` | 列表排序 | | `copyImagesAsName` | boolean | `true` | 复制正文时把图片写成 `[图片: <文件名>]` 一行 | | `maxImagesPerNote` | number | `20` | 单条记事图片数上限(1–100) | | `confirmDelete` | boolean | `true` | 删除前用面板自己的对话框确认;关闭则点一下直接删 | | `openOnStart` | boolean | `false` | DSH 启动即展开右侧栏并打开记事本 | | `autoOpenOnNewSession` | boolean | `false` | 每当有会话变成当前就打开 Notebook | | `selectionToNotebook` | boolean | `true` | 在会话里选中文字时显示浮动的「进记事本」动作 | | `messageToNotebook` | boolean | `true` | 在每条回答的动作行末尾显示「存入记事本」图标 | 这个设置区由侧栏宿主自己注册(`hosts/settingsSeat.ts` → `settings.section`,id `dsh-notebook`,order 100),而槽位没被声明时注册就是个 no-op——所以一个没有设置对话框的组合永远不算错误。两个自动打开的开关走的是和用户点 tab 完全相同的手势:`openOnStart` 在宿主注册好时触发一次,偏好从 host 读回来时再触发一次,而在屏幕上没有会话时它是个被安全吞掉的 no-op。 ## 实现细节 以下内容面向读代码或改代码的人。只想用插件的话可以直接跳到〈[已知限制](#已知限制)〉。 ### 架构 ``` ┌──────────────────────── 浏览器 ────────────────────────┐ │ lib/client.js (CJS 模块表工厂, id = "dsh-notebook") │ │ controller:探测 sidebarRightTabs → 原生宿主 │ │ NotebookView ─ NotebookEditor(唯一实例) ─ clipboard │ │ capture (v0.2.0):选区浮动条 + 回答图标 → capture.ts │ │ 正文缩放 (v0.2.1):textarea → autoGrow.ts │ │ 确认框 (v0.2.2):面板内对话框 → locales.ts │ └───────────────────────────┬───────────────────────────┘ fetch JSON │ ┌───────────────────────────┴───────────────────────────┐ │ lib/index.js (cordis 插件, ctx.webServer prefix) │ │ routes.ts ─ store.ts (原子写 + mutex) ─ attachments.ts│ └───────────────────────────┬───────────────────────────┘ │ $DSH_HOME/storages/notebook.json $DSH_HOME/storages/notebook.json.bak $DSH_HOME/storages/notebook-attachments//. ``` - `src/client/autoGrow.ts` 是正文框几何唯一的决定处:它测量 `