← Back to home@XGUIMAX

dsh-wrongbook

DSH Tavern 错题库:按人物卡分类记下调试中踩过的坑,先查本卡、再跨卡。 / Per-card defect ledger for DSH Tavern.

Stars
3
Language
JavaScript
Created
Sep 23, 2026
Updated
Oct 5, 2026
GitHub repo

Introduction

dsh-wrongbook

DSH Tavern 的错题库:按人物卡分类记下调试中踩过的坑,下次先查本卡、再跨卡。

调一张卡踩过的坑,隔一周换一张卡会再踩一遍 —— 因为是同一类问题:同名的状态字段、同一段正则、同一个脚本钩子。这个插件把每个坑记在出问题的那张卡名下,并在调试时给出一个固定顺序:先查出问题那张卡自己的错题库,再跨卡查。

license platform deps

可以做什么

  • 按卡分类:卡片目录里有几张卡就自动建几项,带卡名与头像。原版卡和它的 MVU版本 互相认得,能把一条结论一键复制过去。
  • 固定的检索顺序:① 本卡错题库 → ② 其它错题 → ③ 跨卡查询。本卡命中永远排在前面 —— 同卡的问题复发概率最高,先看它是为了避免把已知答案重查一遍。
  • 其它错题:不挂在人物卡上的问题单独一组,预置了「卡内故障」「卡片更新器」「通用 / 未归类」,也可以自己建分类。记一个谁也没听说过的分类名时会自动建一个新分类,而不是默默塞进兜底。
  • Agent 能查:注册了三个工具,卡片 Agent 在调试时按同一个顺序翻 —— 和面板上看到的是同一套打分与排序。
  • 备份与还原:每次写盘前自动备份,独立页面里按份数清理、删单份、还原;备份目录可以自己选。
  • 插件自检:面板右上角标着版本号,点一下「检测更新」比对插件自身文件的指纹与版本。
  • 通用脚本:一份跨卡共用的脚本库(异步助手、状态栏渲染器之类)。库里有什么、每张卡装没装、装哪些卡 —— 一个页面里看完;可以按分类筛、勾选多张卡批量装卸。
  • 卡型标记:像「米吧」那种自带 MVU 的卡,可以打一个显式标记,让面板的"可装脚本的卡"与统计认得它。见「卡型标记」。
  • 左侧跟随筛选:顶部选了状态或范围,左侧卡片列表只留下还有命中条目的卡 —— 条目一多时,一眼看出哪几张还有活儿没干完。

界面

两个入口,指向同一个页面:

  • 设置 → 错题库 —— 设置面板里的一个页面。
  • 左下角侧栏底部 —— 与「插件市场 / 卡片更新器」同一层的一个按钮,点了弹出覆盖层显示同一个面板。

(早先只有前者,那是当时的取舍;后来按"和卡片更新器保持一致"补了侧栏入口。)

页面里六个页签:

  • 人物卡错题:卡片分类列表 + 本卡错题库 + 跨卡查询。分类由卡片目录自动生成,左边每行显示头像、卡名、未解决/观察中/已修复的条数。
  • 其它错题:不挂在人物卡上的问题。这一组的分类是手建的,可以新建、改名、删除;删分类时它下面的记录会迁到「通用 / 未归类」,不会跟着一起消失。记一条不存在的分类名会当场建出来,所以「某个插件出的问题」天然就能各占一格。
  • 卡脚本:卡内自带的自定义脚本与机制条目。见下一节。
  • 宿主补丁:宿主源码上打的补丁清单与重打入口。宿主升级会覆盖 lib/,这里用来核对补丁还在不在、一键重打。
  • 备份与还原:当前数据概览、备份目录设置、备份列表(每条带时间和条目数)、还原、删除单份、按份数清理、导入导出。
  • 通用脚本:跨卡共用的脚本库。见「通用脚本」。

顶部筛选会同时作用于左侧列表:选了「未解决」或某个范围之后,左侧只留下还有命中条目的分类。没选筛选时不过滤(否则一进页面就像库读坏了)。

错题页顶部还有一个**「回流到 Skill」**按钮:把调出来的结论写进某个 skill 的参考资料,下次做同类事情时 Agent 会读到。见「回流到 Skill」。

与卡片更新器联动

设置页右上角有一个 「● 卡片更新器 · 已连接」 按钮,用来显示另一个插件在不在。

它有三种状态:

  • 绿点 + 已连接 —— 卡片更新器装着,而且它的宿主半正在跑
  • 灰点 + 检测中 —— 刚打开面板,第一次探测还没回来。单独留这一档是为了不把「还没测完」误报成「未连接」
  • 红点 + 未连接 —— 没装,或者被停用了

任何时候点它都会打开卡片更新器的仓库页(https://github.com/XGUIMAX/dsh-card-updater):未连接时那是补装入口,已连接时那是查更新和看文档的地方。鼠标悬停会写明当前状态。

探测方式是往对方插件自己的路由发一次 GET /dsh-card-updater/state,每 15 秒复查一次。这用的是对方的公开接口,不需要两边事先约定协议,也不要求两个插件同时升级 —— 对方没装、版本老、或者路由还没就绪,这里只会显示未连接,不会有报错弹出来。

对端状态

面板右上角有一个对端状态按钮:显示卡片更新器的连接状态(绿点「已连接」/ 未连接), 并在它挂着「专属预设」时把预设名带过来。两个插件互不读写对方的数据文件,只走 HTTP。

「专属预设」标记

卡片更新器认得出作者在帖子里写的「另附专用预设」,错题库把这一条读过来显示:人物卡列表里,带专属预设的卡会多一个虚线边框的「专属预设」小标签,鼠标悬停能看到来源链接。

判据是卡片更新器状态里的 config.cards[].primary.gates 含 preset 项 —— 也就是它的面板上那个「另附专用预设」条件。能不能对上号取决于这张卡有没有配原卡路径;对不上就安静地不显示,不会报错、也不会卡住。

这里只做展示,不往错题库里写任何东西。 专属预设是卡片的客观属性,不是「踩过的坑」;自动写成条目会把病历本变成资料库,查故障时反而更吵。需要记录时「回流到 Skill」那条路更适合。

回流到 Skill

错题库是病历,skill 是诊疗手册。这一栏搭的就是两者的桥:错题页顶部点**「回流到 Skill」**,把选中的条目写进某个 skill 的 references/ 下。

只写你自己的 skill —— 这条写在面板的提示行里,不用点进来才知道。内置 skill(card-to-mvu、debug-card 这些)在程序目录里,Tavern 更新会整份覆盖它们,写了也留不住,所以弹窗里只列能写的;要往那个语境里沉淀,先新建一个自己的。

弹窗里填三件事:目标 skill、范围(全部 / 当前分类 / 未解决的)、目标文件(默认 references/错题库回流.md)。选中某个 skill 时会显示它的简介、参考资料和条目数 —— 建重了、或者建完一直没用(条目数 0)一眼就能看出来。旁边的「删除这个 skill」删之前会把整个目录复制到 <数据根>/skill-backups/,备份不成功就不删。

每条按现象 / 根因 / 修法 / 证据铺开,带上分类、范围和状态。同一个目标可以反复回流,不会写出重复的东西。

条目会跟着错题库更新,但手工改过的段落归你。 每段标题下面有一行 <!-- wrongbook:… --> 标记,记着它来自哪条错题、写的时候什么状态、以及正文的指纹。同步时按它分三种:

  • 有标记、指纹对得上 → 是我们写的、没人动过 → 状态变了原地更新(错题库里标了「已修复」,这里也跟着变);
  • 有标记、指纹对不上 → 你改过 → 不动,并在回执里报「另有 N 条你手工改过、已跳过」;
  • 没有标记 → 不是我们写的 → 一律不动。

所以想给某条加上自己的注解,直接改那一段就行;从改的那一刻起,那一段就不再被同步碰。

老文件里那些没有标记的段落会被认领一次:结构对得上(有元信息行 + 至少一个小标题)就补上标记、纳入管理;对不上的当手工段落护起来。认领之后照上面的规则走。

新建 skill 时把简介写实。 description 是 Agent 判断「要不要自动加载这个 skill」的唯一依据 —— 光有名字加一句"这是个错题库",它不知道自己该在什么时候想起来。三个要件:是什么 + 什么时候用 + 症状词。

不知道怎么下笔就点「示例」:它按当前范围和错题库里的实际条目算一句真的出来,不是留个空模板。全部范围会生成类似这样一句,可以直接用或改:

已解决故障库:DSH Tavern 调试中踩过的 40 个坑(工具链、MVU、前端UI、正则、世界书),涉及 Tavern 本体、龙娘回廊!5.3 MVU版本、卡片更新器、卡内故障。用户排查卡片异常、转 MVU、或调试插件与宿主时,先翻一遍看有没有同一类 —— 每条含现象、根因、修法与实测证据。

两条硬边界:目标文件不许跑出 skill 目录(.. 会被拦),skill 名也不许带路径。

自动同步

手动点回流的结果通常是:记完错题就忘了点,skill 停在几天前,下次调试读到的还是旧的那份。所以在弹窗里勾上**「以后新条目自动同步到这里」**之后,每次记错题、改错题、批量导入都会顺手跟一次。

回流只改「我们自己写的、且没人动过」的段落,所以自动跑是安全的 —— 没变化时是 0 条写入、文件一个字节不动。实测:打开自动同步时 unchanged 48、文件大小不变;之后改一条错题的状态,只有那一段被重写。

是否真的在跑要能看见,所以:

  • 面板上「回流到 Skill」按钮前面有个绿点,说明自动同步开着;
  • 那一行的提示写着上次什么时候跑的、写进去几条:自动同步已开 → ziyongskill(上次 2026-09-25 17:43,写入 0 条);
  • 记完一条错题回来,时间戳会变 —— 这就是「生效了」的证据,不用点进弹窗;
  • 万一某次没跑成,提示行直接显示失败原因(skill 被删了、文件被占用……),而不是安静地不工作。

弹窗里同样能看到这套状态,复选框就在目标 skill 下面。

和宿主「改卡记忆」的分工

DSH Tavern v2.3 起内置了自己的改卡记忆(data/card-memory/,配 tavern_memory_search / _preference / _experience 三个工具,底层是 memon 记忆引擎)。它和错题库记的是同一类东西 —— 改卡时踩过的坑 —— 但两者的长处不同,所以并存、按长处分工,而不是合并成一个。

宿主改卡记忆 → 跨卡、可复用的经验。

它的 scope: shared 语义明确:一条经验要么属于当前这张卡、要么是全库通用,没有含糊地带。而且带强制约束 —— status 只要不是 unverified(即 static-validated / runtime-verified / user-confirmed),就必须填 evidence,不填直接报错。这让「声称这条已验证」这件事留得下凭据,比错题库那套纯靠自觉的 status 有力。

错题库 → 按卡归因的具体故障。

per-card 分桶让它天然回答「这张卡出过什么问题」;面板能一览全部条目、按状态过滤、原版卡与 MVU版本 互认;而且回流出的 skill 能在别的会话里被搜到 —— 这是目前唯一一条能跨会话把结论带走的链路。

检索时两处都查。 两个系统互不读取:只查错题库会漏掉跨卡经验和存档的用户偏好,只查宿主记忆会漏掉按卡归因的存量。任何一处查不到,都不代表「没踩过这个坑」。

一处有意的例外。 v2.3 的说明里写着「将错误经验写入改卡记忆,不再默认生成 Skill」,而错题库保留自动回流 —— 这不是没跟上更新。回流是错题库的核心机制,它的产物能被别的会话检索到,这条链路目前没有替代品。所以这条分歧是刻意留的。

卡脚本

作者经常随卡附带自定义脚本 —— 悬浮状态栏、地理数据加载、变量更新器、自动化流程 —— 它们不在任何通用清单里,却决定了卡能不能正常跑。DSH 又没有全局脚本槽:脚本随卡加载,换一张卡就换一套。所以排查任何一张卡之前,先看清它带了什么。

「卡脚本」页签把这件事摊开。这一页是懒加载的,切过去才扫,不拖慢打开面板:

  • 每张卡一块,标题上直接标出「N 个脚本 / 特化 N / 控制器 N」,带特化内容的不用挨个点开就知道;
  • 块内逐条列脚本:名字、字符数、启用还是停用、import 指向的外链、脚本自己的 data 键。常见的几个(外置状态栏、小手机脚本、创意工坊、格式修复、辅助计算脚本)算通用,压暗显示,不跟特化脚本混在一起;
  • 另外挑出两类会左右卡行为的东西:控制器条目(世界推演、副本推进那类要求模型输出特定协议的)和含 update / json_patch 的条目;
  • 页面下方单独列 data/tools/ 下的脚本 —— 那些不属于任何一张卡,是从卡里导出来的、或者为卡自制待命的那批,卡出问题时就靠它们适配。

脚本的判据是从 data/tools/card-scripts/scan.mjs 搬进来的:读 data.extensions.tavern_helper.scripts,并且要先剥掉卡文件外层可能套着的那层 raw —— 少这一层,脚本数会永远是 0。

通用脚本

一份跨卡共用的脚本库。这些脚本不挑卡,装到哪张卡就在哪张卡用;库目录是 data/tools/wrongbook/common-scripts/,不属于任何一张卡。

典型用途:让副 API 在后台生成资料、把状态栏渲染交给脚本、跑回归测试、做结果回填。

装到哪些卡

面板只列「可装脚本的卡」,判据是三条任一:

  1. 文件名是 … MVU版本;
  2. 卡自己声明了 dsh_card_marker.kind = hand-tuned-mvu(自带 MVU,见「卡型标记」);
  3. 已经装了库里的任何脚本 —— 装了的当然要统计。

只作参照的原版卡不在此列,也不提供装卸入口。

怎么用

库里每个脚本有三个动作:导入脚本(在页面右上角,进新脚本)、更新(换掉这一份,并自动推到已经装了它的卡上)、删除(只删库文件,不动已装到卡上的)。

  • 分类筛选:库项读脚本自带的 dsh_meta.category(没有就归「未分类」),标题行可以按分类筛。
  • 卡列表用脚本名当标签:点 助手agent_v0.15 只看装了它的卡。脚本本身就是分类单位,不再另外给卡设一套分类。
  • 批量装卸:每张卡前面有勾选框,配「全选 / 清空 / 装上选中 / 卸下选中」。没选中时批量按钮是灰的 —— 否则点下去会退化成全量操作。

三个建议入口

库里每个脚本右侧的按钮,按"精准度"分三层:

按钮做什么快慢
检测适合的卡即时关键词匹配:从脚本内容认出它调用了哪些宿主能力,与每张卡的特征对着看,对得上就给一条理由秒出
生成建议生成一段文本(脚本路径 + 全部候选卡的 @ 引用 + 要求逐张读卡、点名具体功能),复制到剪贴板,对话页开着就顺便填进输入框秒出
后台分析逐张读卡的静态分析,带进度条慢,但关界面也继续

为什么「生成建议」不直接发出去:它会让工作台那边的模型逐张读卡、说清"这张卡的哪个功能能被脚本接管"(比如「龙娘回廊的橱窗」)。 这一步要消耗一次完整的全卡分析,所以做成"生成 + 给你看 + 你决定发不发"。

关于"精准"的边界:宿主没有给插件暴露模型调用接口,所以插件自己做不到"模型读完整卡再判断"。 「检测适合的卡」和「后台分析」给的是技术层面的匹配(脚本用到的能力 × 卡具备的特征), 理由全部引用卡里实际存在的东西(正则名、脚本名、世界书条数),可以照着核对 —— 但它们判断不了题材是否合适,那需要真的读过卡。

后台分析

逐张读卡的深度分析:读 description、正则名、脚本名、世界书标题、MVU 机制,然后和脚本能力做匹配。 刻意不读全文(有卡 11MB)。

关掉面板也会继续跑,靠四点:

  • 任务表在宿主进程的模块作用域里,不在前端组件 state 里;
  • 每张卡处理后立刻落盘(data/tools/wrongbook/fit-<脚本名>.json),所以中途重启也能看到进度到哪了;
  • 启动请求不 await 任务本身 —— 否则请求会挂住、前端拿不到 id;
  • 循环里让出事件循环(setTimeout(r, 0)),不然会占满宿主进程、界面卡死。

前端只负责启动和轮询,并用 localStorage 记住上次看的脚本名,重开面板接着显示进度。

装卸只动 scripts 数组

装/卸只改卡文件里的 extensions.tavern_helper.scripts,其余字段逐字节不变(做过逐字段实测)。

注意:脚本本身可能用 indexedDB / localStorage、挂 setInterval、往 window 上挂东西、 用 eventOn 订阅事件 —— 卸下只是停止注入,运行期已经写下的缓存不会自动清掉。

更新

库里的脚本用「更新」换新版。它和「导入」是两个动作,不是同一个:

导入更新
用于新脚本进库换掉库里已有的那份
同名时静默覆盖明确提示"库里没有就请用导入"
dsh_meta按新文件重算(没带就生成草稿)保留库里的 —— 新文件显式带了才用它的
备份无更新前把旧版拷进 common-script-backups/<时间戳>/
已装的卡不动自动同步到新版(同一批卡,走同一个安装流程)

为什么 dsh_meta 要保住:分类 / 用途 / 建议可能是你在界面上改过、或者插件攒下来的, 不能因为新版文件里没带这一段就被一份自动草稿盖掉。

内容与库里那份完全相同时直接返回「没有改动」,不白写一次盘。

更新会自动同步到卡上

更新库里的脚本时,已经装了它的卡会一起更新 —— 这一步是自动的,不需要再点一次。

理由是「库里是新的、卡上还是旧的」属于最容易漏掉的一种不一致: 它不会报错,只会让某张卡在某个时刻行为与预期不符,而人很难想到去核对"卡上那份是什么时候装的"。

同步走的是同一个安装流程(installCommon),所以备份、写盘、只动 scripts 数组这几件事都一致; 用的内容就是刚落盘的新版。更新完面板会列出同步了哪几张卡。

同步失败不影响库的更新:库已经换好了(那是主操作),卡这边没跟上属于部分完成 —— 面板会把失败原因写出来,让人知道哪一步没成。

脚本自带的持久状态

有的脚本自己会写 localStorage / indexedDB / 世界书条目 / MVU 变量。 换掉库里的文件不会清掉那些数据 —— 新版可能读到一份旧版留下的状态。

所以更新时会先认一遍这个脚本会写什么,命中就在面板上列出来:

注意:这个脚本自己会写 localStorage · indexedDB · 世界书 ——
换掉库里的文件不会清掉那些数据,新版可能读到旧版留下的状态。

顺带会对比新旧两份的版本常量(脚本自己写了 VERSION 之类的话)并显示 旧 → 新, 降级时能一眼看出来。

这一条不阻止更新,只是提示 —— 判断该不该清状态需要知道脚本具体怎么用那些数据,那是读过它才说得准的事。

导入时自动生成说明

导入脚本时,后端会扫一遍内容,生成一份 dsh_meta 草稿(分类 / 用途 / 建议)写进库, 免得回头再补。已经带了 purpose 的不会被覆盖 —— 作者的说明优先。

它给的是能力清单,不是内容判断:

分类   助手 / 后台(调 generateRaw / worldbook_profile)
       变量 / MVU · 聊天 / 记录 · 界面 / 渲染 · 未分类
用途   「这个脚本会用到的宿主能力:读/写 MVU 变量、世界书读写、监听消息事件…」

刻意不写"建议装到哪些卡":脚本能不能装跟卡无关,硬编一个卡名单只会误导。

卡型标记

有一条例外值得单独说。「来当小男友爆管人的米吧!」是作者原制、自带 MVU的卡, 不是 DSH 转换出来的 —— 转 MVU 会丢失大量内容(21 条正则含 583K 字正文美化、7 个脚本、170 条世界书), 代价大于收益。

所以它不走文件名约定,而是在卡里写一个显式标记:

dsh_card_marker: {
  kind: "hand-tuned-mvu",
  label: "已手改 MVU",
  conversionBlocked: true,
  ...
}

面板读这个标记,把它的每张卡都当 MVU 卡对待(可装通用脚本、进统计)。 标记是手动打的,只用于这类例外 —— 正常情况下文件名 … MVU版本 就够了。

调试时的检索顺序

这是这个插件存在的理由,所以单独说。

① 本卡错题库。 同一张卡上的问题是复发概率最高的一类:还是那个状态字段、还是那段正则。先看它,是为了避免把已经调出来的结论重新调一遍。

② 其它错题。 本卡没有答案时先来这一段:归档位置可能有偏差 —— 卡的问题被记进了「其它错题」、或者插件上的坑其实是某张卡的坑 —— 而归类偏了不该让检索漏掉它。它排在跨卡之前,因为插件与工具链上的坑影响的往往不止一张卡。

③ 跨卡查询。 最后才是别的卡片。这时候「这也是个新坑」本身就是信息,而别处的相似记录开始有参考价值。

三段的范围是固定的:① 当前分类,② 「其它错题」那一组(当前就在这一组时是组内其余分类),③ 其余卡片。从哪一头进来顺序都一样。

三段共用同一套打分:标题 > 标签 > 现象 > 根因/修法 > 引用,越靠前权重越高。没给关键词时,工具输出里的 ② ③ 只印前 8 条并标出总数 —— 全库几十条铺开会把 ① 淹掉;带上关键词就是有目的地在找,那时整段列出来。

给卡片 Agent 的三个工具

工具在插件加载时注册到宿主,同时会往 system prompt 注入一段说明。只有前者不够:工具列表里多几个名字,模型不会因此去用它们 —— 真发生过一次,让 Agent 归档时它把错题写进了 materials/错题库.md,因为它只知道那儿。

注入的说明要求 Agent:

  1. 动手前先 wrongbook_lookup,按三段顺序看;
  2. 解决后用 wrongbook_record 归档,现象、根因、证据、处理、防坑都写进去;
  3. 库里已有同一条就 wrongbook_update 补根因或标记已修复;
  4. 归档完在回复里写明「已归档到错题库」+分类名和条目标题 —— 用户靠这句话确认归档真的发生了;
  5. 不要再把错题写进 md 文件。
工具用途
wrongbook_lookup按固定顺序返回三段:「① 本分类」→「② 其它错题」→「③ 跨卡查询」。参数 card(卡名、cards/xxx.json,或其它错题里的主题名)、query、status、scope、limit
wrongbook_record把新发现的问题记进对应分类;card 填一个还不存在的名字会当场建出这个分类。落库到 Tavern 数据根下
wrongbook_update标记已修复、补写根因或修法

card 的解析是分层的:完整路径 → 文件基名 → 分类显示名完全相等 → 名字包含。模糊命中多个分类时不猜,落到「通用 / 未归类」并把候选带回来。

这条对原版卡和它的 MVU版本 同样成立 —— 它们是两个不同的分类,坑往往只出在其中一张上(调试对象通常是 MVU 版)。所以 card: "道渊" 会同时命中两张,此时:

  • wrongbook_lookup 照常返回三段,但在开头警告「对上了 2 个分类:…」,让你补全名字;
  • wrongbook_record 直接拒绝,要求写全名字。

替你在两者之间挑一个,就是把结论记到不该记的地方,比让你多写几个字贵得多。面板上这两类也各有标记:原版卡标「原版」,MVU 版标「MVU」。

安装

前置条件

先装 DSH Tavern。 这个插件是它的伴生工具:人物卡、卡片工作台对话都由 Tavern 提供,插件只负责把这些坑记下来、并在调试时按顺序翻出来。

Tavern 地址:https://github.com/flizzywine/dsh-tavern

此外需要已经在用的 DSH,且有一个 profile(本说明中默认叫 tavern,Tavern 项目用的就是它)。插件是 DSH 插件,不是独立程序。

用 dsh 命令安装

dsh plugin 是 DSH 自带的插件管理命令:把参数转给 profile 目录下的 pnpm,装完之后再按已安装状态核对 dsh.profile.bundles —— 声明了 dsh.bundle 的依赖自动进入层栈。所以不必手工改 profile 的 package.json。

从 GitHub 装,不必先 clone:

dsh plugin --profile tavern add github:XGUIMAX/dsh-wrongbook

在项目目录里装本地副本(改源码立刻生效):

cd ~/.dsh/plugins/dsh-wrongbook
dsh plugin --profile tavern add .

装完重启 DSH,设置 → 错题库 出现即装好。

在 DSH Desktop 的命令行工具里,默认 profile 就是 tavern,写 dsh plugin add . 就够了,--profile 可以省略。

卸载

dsh plugin --profile tavern remove dsh-wrongbook

重启 DSH 后入口消失。数据留在 ~/.dsh/profile-data/tavern/data/tools/wrongbook,要一并清掉就手动删该目录。

更新

GitHub 装的:

dsh plugin --profile tavern update dsh-wrongbook

本地目录装的:在项目目录里 git pull,重启 DSH。

数据与备份

单文件 JSON:~/.dsh/profile-data/tavern/data/tools/wrongbook/data.json,结构是 { version, cards, entries, config }。

每次写盘前把旧文件复制到备份目录,文件名形如 <毫秒>__<起因>__data.json,一眼能看出这份备份是哪个动作之前留下的。自动清理保留最近 60 份;面板上可以按份数手动清理,也可以删掉指定的某一份。还原之前会先把当前内容备份一次,所以还原本身也是可回退的。

备份目录可以自己选

默认在 tools/wrongbook/backups,想换盘或丢进同步盘就在备份页上点「选择文件夹」。

选择目录走的是自己的浏览弹窗,不依赖宿主的目录选择器 —— 那个能力在不在、能不能用都说不定,而「点一下什么也没发生」是最难查的一类故障。做法参照卡片更新器:host 出一个接口列一层目录,面板渲染一层。空路径列出所有驱动器(逐个探 A:\ 到 Z:\,Windows 上 Node 没有列盘的 API),所以换到别的盘也走得到;路径框也接得住粘贴进来的东西(两边带引号、正反斜杠、D: 只写盘符、相对名都认)。

保存时会先建目录、再写一个探针文件试一下,路径有问题在按下去的那一刻就报出来 —— 而不是等某次备份静默失败、回头看才发现这几天的都没落地。

切换只影响之后写入的备份:旧目录里已有的文件不搬动也不删除,所以换回去随时还能看到。选空路径则恢复默认。

清理只动自己写的文件

自定义目录很可能是你挑的一个通用备份文件夹,里面躺着别人的 JSON。保留策略是会删东西的,所以列表、清理、删除都先按文件名认人:只有 <毫秒>__<起因>__data.json 这种形状才算本插件的备份,别的一律不列、不动、也删不掉。

插件自检

面板右上角的「检测更新」比对两件事:

  • 本地指纹:package.json、lib/index.js、client.js 三个文件的 sha256,跟上次检查的记录比。手改过源码就会被标成「本地有改动」。
  • 远端清单(可选):在数据文件里配 config.remoteUrl 指向一个返回 { "version": "..." } 的地址,有配置时会去比版本号,没配置就只报本地指纹结论。

结果落在 tools/wrongbook/selfcheck.json,保留最近 20 次。

不受 DSH 更新影响

  • 插件源码在 ~/.dsh/plugins/dsh-wrongbook,不在程序安装目录里。
  • 数据在 <Tavern dataRoot>/tools/wrongbook。
  • profile 侧只做两件事:dependencies 里一条 link:(或 GitHub 装的版本号),dsh.profile.bundles 里一条名字。

这两处为什么动不到,不是猜的。Tavern 更新 profile 清单走 bin/profile-configuration.mjs 的 mergeProfileManifest,是读-改-写加精确排除:

const userBundles = currentProfile.bundles.filter((n) => !previousManagedBundleSet.has(n))
const bundles = uniqueStrings(sourceBundles.concat(userBundles))

const dependencies = { ...currentDependencies }
for (const n of [...previousManagedDependencies, ...excludedMobileBundles]) delete dependencies[n]
for (const n of managedDependencies) dependencies[n] = sourceDependencies[n]

它只删自己上次托管的、只覆盖自己这次托管的,用户自己加的条目原样留在里面。这个插件既不在这份 managedBundles / managedDependencies 里,也不在 previousManaged* 里,所以一个分支都碰不到它。

面板上有一行实时自检(备份与还原页底部),查三件事:dependencies 里那条还在不在、dsh.profile.bundles 里那条还在不在、node_modules 的链接是否指回插件目录。全绿即正常;缺了会直接给出补回命令(dsh plugin add <插件目录>,或手工补那两处),不用去翻这份 README 猜。

性能

数据量上去以后,最常走的几步都该是一次线性扫描。verify/perf.mjs 用 300 张卡 + 8000 条记录实测(同一台机器上的数字,量级参考):

动作耗时
打开面板(GET /state)~31 ms
带关键词检索~24 ms
不带关键词检索~16 ms
靠名字模糊匹配检索~12 ms
记一条新错题~56 ms
导入 500 条~67 ms
一次手动备份~43 ms

有几处曾经会随规模恶化,都改成了单遍:

  • 分类计数原来按卡各扫一遍全部条目,O(卡 × 条);现在一次遍历算完所有分类。
  • 导入去重原来对每条待导入项扫一遍现有条目,O(n × m);现在靠一张 Set。
  • 导入合并原来逐条 unshift,是 O(n²);现在一次拼好再赋值。
  • 头像解析原来每张卡依次试 5 个路径(几百张卡就是上千次同步 stat);现在两个目录各列一次建索引,之后全是查表。
  • 卡片扫描不再逐个 statSync 取尺寸和时间 —— 那两样没人用,只是顺手拿的。

写入路径的开销主要在备份:每次写盘前把旧文件整份复制走,这是拿磁盘换「随时能回退」。8000 条时一次约 43 ms,换来的是任何一次误操作都能找回上一份。

常见问题

「选择文件夹」点下去什么也没发生? 不会。它走的是插件自己的浏览弹窗,只依赖插件自己的 HTTP 路由,跟宿主提供什么能力无关。

「打开数据目录 / 打开备份目录」没反应? 两级依次尝试,每一级的失败原因都留着,全失败时连同 PATH 一起报回面板:宿主解析器 / SystemRoot 拼出的绝对路径 → cmd /c start。成功时消息末尾会带上走通的层级(比如 · C:\WINDOWS\explorer.exe),所以这台机器上到底是哪种方式有效不用猜。

面板里为什么没有系统弹窗? window.prompt 在 Electron 的渲染进程里没有实现,调用后直接返回 null 而且不报错 —— 用它做的「导入 JSON」「改分类名」会表现成按钮点了没反应。window.confirm 的行为在各版本之间也不一致。所以导入用面板内的文本框、改名用内联输入框、删除和还原用二次确认按钮。验收脚本里有一条静态检查,防止这两个 API 再被引进来。

卡片改名或被移走了,记在上面的错题会丢吗? 不会。已经不在卡片目录里的分类只标成「文件已不在卡片目录」,记录保留。删分类也一样,记录会先迁到「通用 / 未归类」。

为什么有两组分类? 分类带 group 字段:card 来自卡片目录扫描,other 是手建的。两组共用同一套列表与条目区渲染,只是换数据源。

开发

lib/index.js   Host 半边:数据、卡片扫描、检索、HTTP 路由、Agent 工具
client.js      浏览器半边:设置页的三个页签与目录浏览弹窗
verify/        两套验收脚本

验收

node verify/host.mjs      # 259 项:扫描、分组、归类、卡名解析、三段检索、卡内脚本、安装自检、skill 回流与删除、路由、工具、备份
node verify/client.mjs    # 110 项:注册接线、字典一致性、四个页签与两个弹窗、三段渲染、禁用 API 静态检查
node verify/perf.mjs      # 9 项预算:300 张卡 + 8000 条记录下量一遍最常走的几条路
node verify/live.mjs      # 只读核对**真实数据**:错题库 ↔ 回流过的 skill 还对不对得上

verify/host.mjs 在系统临时目录里现造一个沙盒数据根,不碰真实数据;verify/client.mjs 用最小 React 运行时渲染面板,不发网络请求。

套件不打开任何外部程序。 曾经有一条实跑 openBackupDir 的用例,已经删掉:它会真的弹出一个资源管理器窗口,而窗口指向的目录一旦在事后被删(沙盒会),资源管理器就弹「位置不可用」——比不测还烦。「打开目录」是人工验证项:重启后在面板上点一下,看窗口落到哪儿、via 是哪一级回退。

发版

改 package.json 的 version,打同名 tag:

git tag v1.0.1 && git push origin main --tags

面板右上角的版本号读的就是 package.json。

许可

MIT