# 开发笔记 / Engineering notes README 面向**使用者**;这里放**设计推演、根因分析与踩坑记录**。 保留它们的理由很简单:代码里很多"看起来奇怪"的地方——为什么卡片收起后还要挂在 DOM 里、 为什么 `pointerdown` 上不能 `preventDefault`、为什么父子关系要存一份而不是现算—— **理由都在这里**。删掉之后,下一个人(包括几个月后的作者)会把这些"冗余"顺手优化掉,然后 bug 复发。 ## 目录 - [代码结构](#代码结构) - [性能与成本](#性能与成本) - [渲染](#渲染) - [会话作用域生命周期](#会话作用域生命周期) - [位置规则:胶囊一定回到原文](#位置规则胶囊一定回到原文) - [拖拽](#拖拽) - [嵌套跟随:四个 bug 的根因](#嵌套跟随四个-bug-的根因) - [右键菜单为什么是自己画的](#右键菜单为什么是自己画的) - [表面配色:一次令牌取证](#表面配色一次令牌取证) - [原文标记](#原文标记) - [生效方式](#生效方式) - [给其他插件作者的一条经验](#给其他插件作者的一条经验) - [验证状态](#验证状态) ## 代码结构 | 文件 | 作用 | |---|---| | `package.json` | bundle 清单:`dsh.bundle.patch`(宿主行)+ `dsh.client`(浏览器半体) | | `cordis.patch.yml` | 向 Loader 插入宿主行 `explain-selection` | | `index.js` | 宿主半体:受鉴权的 `/api/explain.selection`,NDJSON 流式返回 | | `client.js` | 客户端半体:右键菜单、锚定卡片、流式渲染、递归解释、答案缓存 | 两半体走 DSH 官方的 Connection Fetch 通道,与内置 `/api/session.export` 同一套机制: 宿主 `ctx.connection.fetch.register({ path: '/api/…' })`,浏览器按**文档相对路径** `api/…` 发起 `fetch`。 这条通道受浏览器鉴权保护:无令牌的本地请求得到 `401 unauthorized`。 > 客户端半体的模块 id **必须等于包名**,`cordis.patch.yml` 里插入行的 `name` 也必须是包名。 > 三处(`package.json` / `cordis.patch.yml` / `client.js`)改一处漏两处 = 静默失效。 ## 性能与成本 ### 必须显式关闭推理 DeepSeek 适配器在 `purpose` 不是 `'session-title'` 时,推理强度**默认 `high`**: ```js // dsh-llm-deepseek/lib/index.js:1694 const effort = options.purpose === "session-title" ? "off" : options.reasoningEffort ?? connection.defaults.reasoningEffort ?? (connection.defaults.thinking === "disabled" ? "off" : "high"); ``` 而 `max_tokens` **同时覆盖推理与答案**(同文件 `max_tokens: options.maxTokens ?? …`,约 1706 行)。 所以不关推理时,512 的预算会被推理吃光 → 每张卡片都返回 `max-tokens` 结束原因、内容被截断,而且又慢又贵。 本插件显式传 `reasoningEffort: 'off'`(合法取值 `off | low | high | max`): 适配器随即发送 `thinking: { type: 'disabled' }` 且不带 `output_config`,**推理完全不发生**。 若要质量优先,改成 `'low'` 即可(一行)。 ### 输入输出双向收紧 | 参数 | 值 | 理由 | |---|---|---| | `reasoningEffort` | `'off'` | 去掉全部推理 token | | `maxTokens` | `256` | 只是安全上限;关掉推理后模型自己就会停 | | 输入上限 | `400` 字符 | 查一个术语或短句够用,超出部分截断 | | 上下文窗口 | 前后各 `120` 字 | 消歧用,约 +160 输入 token;`CONTEXT_RADIUS` 一个常量可调 | | 提示词硬约束 | 「两句话以内,最多 80 字」 | 宁短勿长 | | 系统提示词 | 约 110 token | 每次调用都要付,所以写得极短 | ### 答案缓存 同一片段再次解释**直接命中缓存**:不发请求、不耗 token、瞬时显示。嵌套追问会反复回到同一个词,这一层收益最大。 - 键是 `文本 \0 上下文 \0 父术语`——**上下文参与身份**,否则同一个词换一句话会命中上一个语境的(可能错误的)答案。 - 上限 200 条。淘汰是**按写入顺序**(写入时先 delete 再 set 把它移到末尾,淘汰取最先的);**命中不刷新热度**,所以严格说不是 LRU。 - 只缓存**完整且无错**的回答;中断或报错的部分结果不入缓存。 - 缓存只在内存里,且是**模块级**的——所以它不随会话切换清空,只随模块热更新/页面重载消失。 ### 上下文注入 两种来源: | 场景 | 上下文 | 是否读 DOM | |---|---|---| | 在正文里选中 | 选区所在散文块的**前后各 120 字** | 读 | | 在卡片里选中 / 点术语 | **父卡片自己的解释文本** + 父术语 | 不读 | 嵌套时不读 DOM:父卡片那段解释本身就是最好的语境,直接复用,更准也更省。 **窗口用 `Range` 的字符偏移定位,不用 `indexOf` 找选中文本。** `rawOffsetOf()` 用 `TreeWalker` 数出选区起点在散文块里的精确字符偏移。 若改用 `textContent.indexOf(选中的文字)`,同一个词在同一段里出现两次时会**永远取到第一处**的前后文—— 喂给模型的【原文片段】和真实选区错位,而且答案看起来毫无异常。 配套的坑:偏移量索引的是**原始文本**,所以必须切原始文本、只对切出来的片段做空白规范化。 先把空白折叠再按偏移切,每个偏移都会错位。 **定位失败时宁可不给上下文。** 若 `Range` 边界不是文本节点(如整块选择),窗口无法定位: 此时只有"散文块本身足够小、整块就是上下文"才安全,否则返回空—— 错误的上下文比没有上下文更糟,它会让模型自信地答错。 宿主收到的字段各自封顶(`MAX_CONTEXT_CHARS = 400`、`MAX_PARENT_CHARS = 120`),客户端传什么都越不过去。 ### 提示词里规则顺序是承重的 第一版把规则写成「2. 先说它是什么」+「5. 结合【原文片段】…」, 结果**第 2 条压过了第 5 条**——「先说它是什么」是一句*词典定义*指令, 模型即便拿到了原句,也会老老实实给一个通用释义。 现在上下文规则排在第 1 条,并且**删掉了任何要求"抽象地说它是什么"的措辞**。 ### 为非词编造含义(真实案例) **真实案例。** 选中「浮动工具条」中间的「动工」两个字(跨词边界的相邻字,本身不是词),当时的回答是: > 「动工」在这里指开始实际修改代码,也就是把重写方案真正落地跑起来。 这是**上下文注入引入的新错误**,而且比"给通用释义"更糟——它错得很自信、很贴题。 根因在提示词:当时第 1 条是「先判断**这个词**在【原文片段】里指什么」, **它预设了选中内容是个词**。模型于是被迫在代码语境里编一个自洽的含义。 修复:把"是不是词"的判断提到最前,并明确允许拒绝: ``` 1. 先判断选中的文字是不是一个独立的中文词或术语。若它只是跨词边界的片段、 代码标识符或不成词,直接说明它不是一个词,并指出它原本属于哪个词;严禁为它编造含义。 2. 是词时,有【原文片段】就按它判断它在这里指什么,只讲这个意思。 ``` **曾经试过、已被否决的做法:分词吸附。** 一度用 `Intl.Segmenter('zh', {granularity:'word'})` 把落在词中间的选区自动扩到完整词, 想从源头避免把跨词片段送出去。**已移除**——它违背了最基本的预期: **标记只应覆盖用户真正选中的字**。用户选了「动工」,屏幕上却在「浮动工具条」上高亮,这是不能接受的。 所以边界划在这里: | | 范围 | |---|---| | **被解释的文本**(卡片标题、原文标记、送给模型的 `text`) | **严格等于用户的选区**,绝不扩宽 | | **上下文**(`contextWindow`,送给模型的 `context`) | 前后各 120 字,**只作参考,不改变选区** | 非词怎么办?交给提示词第 1 条去拒绝,而不是靠改用户的选区来回避。 **一处有意的灰色地带**:取顶层选区窗口时会**读**(绝不写)选区所在散文块的文本。 官方 practices 禁止的是"读别人的 DOM **来估算定位**",而这里是为了消歧、只读不写、且有硬上限。 ## 渲染 ### 渲染节流 流式 token 不再逐个提交。每个 delta 只写入 `pendingRef` 缓冲,并用 rAF 合并成**每帧最多一次** `setCards`。 原因:原本每个 token 一次 `setCards` → overlay 全量重渲染 → 每张卡片一次 `getBoundingClientRect()` + 定位计算。 开 3~4 张卡快速流式时,那是**每 token 一轮强制布局**。 收尾时(正常结束 / 报错 / 中断)会先 `flushDeltas()` 再改状态,否则最后一帧缓冲的文本会被丢掉。 ### 锚点统一 卡片和胶囊都通过 `anchorRect(range, fallback)` 取**实时**位置,`fallback` 只在节点被替换后兜底。 锚点滚出视口时自然落到视口外(无需额外逻辑),滚回来即恢复。 ## 会话作用域生命周期 **曾经的严重 bug**:切换项目/会话后,旧会话的卡片仍浮在屏幕上。 根因是作用域错配:`shell.overlay` 是 **root 作用域**的全局浮动层,**不随会话卸载**,所以卡片状态一直活着。 旧会话的 DOM 被卸载后,它的 `Range` 变成游离节点,`getBoundingClientRect()` 返回零矩形, `anchorRect` 于是退回**选中时固化的那个视口坐标**——卡片就留在原地,压在新会话上面。 修法:**用 session 作用域的注册去驱动 root 作用域的浮层。** `SessionScopeProbe` 是个不渲染任何东西的组件,注册进 `conversation.input.dock`(**session 作用域**), 它同时提供两个信号: 1. **会话身份**——该 slot 的 props 里带 `sessionId: SessionId`(slot 目录里文档化的, 而 `shell.overlay` 的 standardProps 里**没有** sessionId,这正是浮层自己看不到会话变化的原因)。 2. **拆解时机**——会话切换时 slot 系统必然卸载该条目,`useEffect` 的 cleanup 报告"会话已消失"。 两者都汇入 `reportSession()`:**身份一变,销毁全部卡片**、原文标记与进行中的请求。 **卡片用完即销毁,不做任何保留。** 这是有意的选择,写在这里避免以后又被"顺手优化"回去: - 试过**按会话归档**(切走存内容、切回放进右下角抽屉)。已**否决并移除**。 理由是成本与复杂度不划算:对话 DOM 重建后 `Range` 失效,归档回来的卡片**既不能钉在原文上、 也不能有原文标记**,只能收进一个平行于卡片的第二显示面(抽屉)。多一套 UI、多一套状态, 换来的只是一份脱离原文的文字。 - 现在的语义是最简单的那个:**卡片属于"这次阅读",离开即销毁**。 **孤儿兜底**(`isAnchored(card)`):锚点全部游离时直接删除卡片(fork、切工作区、面板重建)。 只处理"全死"是因为单个锚点可能因 React 换节点而瞬时游离,为瞬时状态删掉用户正在读的卡片更糟。 ("部分游离"的场景由下面的**结构性级联**负责,不靠这个判据。) ## 位置规则:胶囊一定回到原文 **它曾经会站错位置。** 第一版里胶囊复用了卡片的位置来源(拖拽中 → 停靠位置 → 锚点), 于是**拖走一张卡片再收起它,胶囊会留在拖到的位置**,而不是回到选中文本旁边。 现在分三处处理: 1. **胶囊永远只用 `placeChipBeside(rect)` 锚定原文**,完全不读 `pinned` / 拖拽位置。 2. **收起时同时清除停靠位置**(`delete next.pinned`),所以从胶囊展开回来的卡片**也落在原文处**, 不会"胶囊在原文、卡片瞬移回拖到的角落"。 3. **不再用 260px 估算胶囊宽度。** 第一版调的是 `placeBeside(rect, 260, 30)`, 把胶囊的**上限**当成布局宽度预留:锚点靠近右边缘时,胶囊会被硬推到文本左边**最多约 250px**。 现在只预留一个最小值(`CHIP_MIN_WIDTH = 96`),上限**按剩余空间**算出来: ```js maxWidth: `min(260px, calc(100vw - ${left}px - 8px))` ``` 于是胶囊既能贴着文本左边缘,又不会溢出屏幕,而且**不需要测量元素**。 ## 拖拽 卡片头部左侧的 `⣿` 是手柄。**手柄是独立元素**,和收起/关闭按钮是**兄弟节点**—— 按钮不在手柄内,按按钮永远不会进入拖拽逻辑。正文仍然可选中,这是嵌套解释的入口。 ### 拖拽中滚轮滚页面 按住卡片不放时滚轮仍然滚动对话。这不是顺手就有的: 浮层在 `shell.overlay`,官方定义是 "Frame-wide floating layer, above every column and **outside their scroll containers**"。所以鼠标悬在卡片上滚轮时,事件沿浮层自己的 DOM 祖先链 冒泡到 `body`,**根本到不了对话的滚动容器**。 **解法**:拖拽期间给被拖的卡片加 `pointer-events: none`。 - 手势本来就在 `document` 上跟踪,**卡片不接收任何指针事件也能被拖动** - 滚轮**穿透**到下面的对话,浏览器**原生**滚动它 - **没有写任何 wheel 监听器**,也不去动别人的 `scrollTop` - 卡片是 `position: fixed`,滚轮不改变指针坐标,所以**卡片停在原地、内容在下面滚过** - 指向线跟着更新:滚动触发既有的 `onViewportChange`(capture 阶段监听,能收到嵌套容器的滚动) **外观代价**:拖拽期间指针落在下面的内容上,光标会变成文本光标而非 `move`。 要保住 `move` 就得往 `document.body` 写样式,属于动别人的 DOM,所以没做。 ### 指向线 卡片可以自由摆放后,屏幕上再没有东西说明"这张卡讲的是哪几个字"。拖三张就变成一堆匿名便签。 所以**拖拽时画一条较淡的虚线指回原文**,并在文字那端点一个 2.5px 圆点。 | 细节 | 取值 | 理由 | |---|---|---| | 线型 | `stroke-dasharray: 5 4`,`stroke-width: 1` | 虚线=引导,不与内容争视觉 | | 颜色 | `--dsw-alias-brand-primary`,`stroke-opacity: 0.4` | 较淡,跟随主题 | | 层级 | `z-index: 2`(卡片是 3) | 线从卡片边缘出发,且**永远不会横穿别的卡片** | | 端点 | `edgePoint()` 求"从矩形中心指向目标时与边界的交点" | 线首尾都停在边框上 | | 出现时机 | 指针位移 > 3px 之后 | 单纯点一下头部不该闪出一条线 | | 锚点已死 | 不画 | 孤儿卡片的 `rect` 是固化的旧视口坐标,指向虚空比不画更糟 | ### `pointerdown` 上不能 `preventDefault` `beginDrag` 里刻意**没有** `preventDefault`:它会抑制兼容鼠标事件、连带干掉 `dblclick` ("双击回到原文"的路径之一)。选中也不会因此发生——手柄已经 `user-select: none` + `touch-action: none`。 同时**不用 `setPointerCapture`**:捕获会把兼容鼠标事件重定向到捕获元素, 随后的 `click` 就落在手柄上而不是按钮上——**收起 / 关闭 / 回到原文会全部静默失效**。 改成在 `document` 上以 capture 阶段监听 `pointermove` / `pointerup` / `pointercancel`, 手势期间 `dragRef` **同步写入**(不依赖 React 提交时机),并补 `window.blur` 兜底清理。 ## 嵌套跟随:四个 bug 的根因 ### 1. 子卡片晚一拍 `anchorRect()` 在**渲染期间**读 `range.getBoundingClientRect()`, 而 React 的渲染**先于 DOM 提交**——父卡片这一帧已算出新位置,它的 DOM **还没动**。 子卡片在同一帧量到的是父卡片的**旧几何**,于是永远慢一个提交: 只有等"别的东西"再触发一次重渲染(**滚动、缩放**)它才追上。 **解法**:渲染时把这一帧实际用的矩形记进 `placedRectsRef`,再用 `useLayoutEffect` (**在绘制之前**)在提交后重新量一次;位移超过 0.5px 就再渲染一轮。 `useLayoutEffect` 在 paint 之前执行,所以这次额外渲染**不会被看见**。循环必然收敛。 被停靠的卡片跳过检查——它的位置不依赖锚点。 ### 2. 最小化会把子卡片的锚点永久弄坏(更隐蔽) 子卡片的 `Range` 指向**父卡片正文里的一个术语 span**。最早的最小化实现直接不渲染整张卡片—— **正文连同文本节点一起被卸载**: 1. `Range` 变成游离节点 → `isAnchored()` 为 false → `anchorRect()` 退化成**固化的旧坐标** 2. 父卡片重新展开时,React 建的是**全新的文本节点**,旧 `Range` 还指向被扔掉的那些 3. **子卡片再也回不来**:卡在陈旧位置,标记高亮一起消失 **解法**:只要一张卡片**内部有东西锚定**,它的正文就必须**保持挂载**,用 `visibility: hidden` + `pointer-events: none` 隐藏(`visibility: hidden` 仍报告真实几何)。 **关键不在 `visibility`,而在 key。** 卡片节点无论展开还是收起都保持 `key={card.id}`。 React 对带 key 的子节点是**按 key 复用并移动 DOM 节点**的,所以文本节点会原样活过"收起 → 展开"。 > 这里踩过一次坑:第一版给这个隐藏节点用了 `key={card.id + '-ghost'}`。 > 换 key = React 卸载旧节点、挂载新节点 = 文本节点照样重建,**整套努力全部作废**。 **这条规则必须对每一级都成立。** 第一版只给"被用户最小化的卡片"保正文,漏掉了 "**因祖先收起而隐藏的卡片**"。于是在 A→B→C 三级嵌套里:收起 A → B 被隐藏 → **B 的正文被卸载** → C 的锚点落在一片脱离文档的 DOM 上,照样是死路。 ### 3. 父子关系**记下来**,不要渲染时从 DOM 里猜 判断"谁在谁里面"最初是渲染时反查 DOM: ```js range.startContainer.closest('[data-explain-card]') ``` **这在最关键的两个场景下必然失效**——而它们恰好就是这套逻辑要处理的对象: - 祖先被**最小化** → 祖先的正文可能已被卸载 - 祖先被**关闭** → 整棵子树脱离文档 在 A→B→C 里,收起 A 时 B 也会被隐藏并卸载,于是 C 的锚点落在一棵**脱离文档的树**里, 反查要么走到游离节点、要么什么都找不到——**链条从中间断掉**,C 既不隐藏也不跟随。 现在改成**在卡片创建时就把父卡片 id 记进记录**(`parentId`),渲染时直接读。 `readSelection()` 本来就返回 `parentId`,之前只是没往下传。DOM 反查只作为**记录缺失时的兜底**。 这一处修正同时修好了三件事:隐藏的传递、关闭的级联、正文保留的传递。 ### 4. 关闭必须级联 | | 规则 | |---|---| | **位置** | 锚定的卡片跟随锚点;**停靠的卡片谁都不跟** | | **可见性** | 锚点落在被最小化的祖先里 → **隐藏**,无论是否停靠 | | **存在** | 祖先被**关闭** → 自己也**关闭**(不是隐藏) | 位置与可见性**必须分开**。第二版曾给停靠卡片开了可见性豁免(理由是"它不跟随锚点,所以不必跟着消失")—— 那是把**位置规则错用到了可见性上**,结果一张停靠的嵌套卡片会活得比它所解释的文字还久。 关闭的级联也不能靠孤儿守卫。它的判据是"**全部**卡片都游离时才清理": ```js if (cards.length > 0 && !cards.some(isAnchored)) ``` 只要还剩任意一张正常卡片,子卡片就会被留下:仍然画在屏幕上,拖拽时虚线指向一片空白。 (这个保守写法本身是为了避免误删,代价就是留下了这个洞。) 现在的判据是**结构性**的:沿每张卡片的包含链往上走,只要链上任何一级**已不在 state 里**, 这张卡片就被清理。好处是**确定性**(不用等下一次渲染)且**一趟传递闭包**(孙卡片一并处理)。 ## 右键菜单为什么是自己画的 **这不是偷懒,是平台约束。** DSH 的原生右键菜单由 Electron **主进程**构建, 在 `lib/main.js` 里是一个**硬编码数组**(`window.webContents.on("context-menu", …)` → `Menu.buildFromTemplate(items)`),**没有任何注册表或 IPC 扩展点**。 渲染进程(插件唯一能存在的地方)无法往里插入条目——架构决定的,不是难度问题。 但源码里有个细节让这件事没有代价: ```js else if (selectionText.length > 0) items.push({ role: "copy", enabled: editFlags.canCopy }); ``` **非编辑区选中文本时,那个原生菜单里只有 `复制` 一项。** 所以我们只接管这一种情形, 等于什么都没丢;编辑框(撤销/剪切/粘贴/全选)和无选中的情形一律不拦截。 ``` contextmenu 捕获 → 选中在编辑框内或为空?→ 放行,原生菜单照旧 → 否则 preventDefault,画我们自己的菜单 ``` 代价:这个菜单是 HTML 画的,**不会和系统原生菜单像素级一致**(原生菜单跟随系统主题与字体渲染)。 换来的是一致于 DSH 应用本身。菜单项目前没有方向键导航(只有 `Esc` 与鼠标), `role="menu"` / `role="menuitem"` 已就位。 ## 表面配色:一次令牌取证 第一版所有表面都用 `--dsw-alias-bg-overlay`,看起来"很土"。从 DSH 自己的主题文件里取出真实色值后,原因很清楚: | | `--dsw-alias-bg-overlay` | app 自己的层(`bg-base` / `layer-1..3`) | |---|---|---| | 亮色 | `rgb(233, 236, 242)` 冷灰 | 全是 **纯白** `rgb(255,255,255)` | | 暗色 | **`rgb(97, 102, 107)`** 中灰 | `rgb(35,35,36)` / `rgb(44,44,46)` / `rgb(53,54,56)` | **暗色下卡片比周围亮一倍**——一块惨白的灰板贴在界面上。`bg-overlay` 是给**转瞬即逝的浮层**用的 (系统给它配了 `--dsw-menu-backdrop-filter: blur(40px) saturate(150%)` 去透视背后的内容), 而我们的表面都浮在同一片**不透明**界面上,背后没有东西可透视——这个令牌从用途上就不对。 同时第一版的边框与阴影是**手挑的数值**,比系统最强档重约三倍: | | 第一版(手挑) | 设计系统实际定义 | |---|---|---| | 阴影 | `0 10px 28px rgba(0,0,0,.22)` | `--dsw-shadow-lv3` 最大一层只有 **`.08`** | | 边框 | `1px solid rgba(0,0,0,.1)` | `--dsw-elevation-stroke-color`,系统里用 **`.5px`** 发丝线 | 现在改用设计系统自己的高度配方: | 元素 | 背景 | 描边 | 阴影 | |---|---|---|---| | 卡片 | `--dsw-alias-bg-layer-3` | `.5px` `--dsw-elevation-stroke-color` | `--dsw-shadow-lv3` | | 收起后的胶囊 | 同上 | 同上 | `--dsw-shadow-lv2` | | 右键菜单 | 同上 | 同上 | `--dsw-shadow-lv3` | `bg-layer-3` 是 app 自己的顶层:亮色为白、暗色为 `rgb(53,54,56)`, 正好落在 `layer-1/2` 之上——**卡片、胶囊、菜单和它们所在的界面属于同一套高度**。 ### 菜单是漏网的 第一轮只改了卡片,菜单留着 `bg-overlay`,于是变成**白卡片旁边一个灰菜单**。 顺带暴露第二个问题:**菜单项的 hover 用的是 `--dsw-alias-bg-layer-2`,而它在亮色主题下是纯白**。 菜单还是灰底时 hover 只是"稍微变亮";一旦菜单也换成白底,**hover 就彻底看不见了**。 现在改用系统自己的交互令牌 `--dsw-alias-interactive-bg-hover`(取自 app 自己的 `themeCube:hover` 规则)。 ## 原文标记 被解释的片段在原文里留下**背景色标记**,一张卡片一种颜色。 **关闭卡片时标记消失**;收起卡片不取消标记(卡片还在,只是变成胶囊)。 实现用 **CSS Custom Highlight API**(`CSS.highlights` + `::highlight()`):按 `Range` 着色,**完全不改动 DOM**。 这是关键约束——官方 practices 明确禁止插件在自己的组件之外写 DOM,所以不能包成 ``。 - 颜色取自 `MARKER_COLORS`(6 色),`pickColorIndex()` 选**当前未被占用**的色号,保证同时打开的卡片颜色不重复。 - 颜色是**半透明字面量**而非主题令牌:`::highlight()` 只接受极少的属性集, 而且低透明度才能在浅色与深色主题下都保持可读。这是有意偏离「只用主题令牌」的一处。 - `Range` 用 `cloneRange()` 快照:创建卡片时会清空浏览器原生选区(好让标记立刻显形),克隆保证锚点与标记不受影响。 - 同步是**按我们的前缀过滤**的,不会碰到别的插件注册的高亮。 - 不支持该 API 的环境自动退化为「无标记」,不做 DOM 兜底。 ### 嵌套曾失效 第一版给**整张卡片容器**也打了 `data-explain-control` 标记,而选区处理器会跳过所有落在该标记内的事件: ```js if (target instanceof Element && target.closest(`[${CONTROL}]`) !== null) return; ``` 本意只想跳过卡片上的按钮,结果把卡片正文一起屏蔽,**卡片里选中文字永远不弹触发条**。 现在该标记只留在真正的控件上:胶囊、收起、关闭、菜单本身与「解释」「复制」两项。 另外两处配套加固:卡片容器显式声明 `user-select: text`(否则可能继承应用外壳的 `user-select: none`), 以及流式期间对未闭合的 `[[术语` 做抑制,避免方括号闪现。 ## 生效方式 **`client.js`(客户端半体)——自动生效。** `dsh-client-hmr` 每 **500ms** stat 轮询每个客户端 bundle 的 `mtimeMs/ctimeMs/size`, 变化时调 `clientModules.rebuilt(id)`,再通过 SSE 频道 `/plugins/events` 推送 `{type:'rebuilt', id, rev}`;页面侧的 `dsh-client-hmr` 收到后调 `ctx.modules.entries.reload(id, rev)` **热替换模块**。所以保存文件约 1 秒后新代码就在跑。 > ⚠️ 模块热替换会让 slot 条目重新注册、React 重新挂载,因此**当时打开的卡片会被清空**。 > 这是热更新的正常结果,不是 bug。 **`index.js`(宿主半体)——必须重启应用。** Cordis 加载器缓存着已求值的 ESM 模块,没有热路径能替换它。 ### DSH Desktop 里没有"刷新页面"这回事 这一条纠正早先的错误说法。Desktop 是 Electron 应用,不是浏览器: - `"刷新页面"`(`role: "reload"`)菜单项写在 `lib/main.js` 的 `...development ? [...] : []` 里, 而安装版 `development = !app.isPackaged` 为 **false** —— 该项根本不会被创建。 - Windows 上 `Menu.setApplicationMenu` 只挂了 `devToolsItems`(两个 `visible:false` 的 devtools 项 + `F12`)。 所以**没有 Ctrl+R / F5 / Ctrl+F5 可用**。真需要手动重载,唯一途径是 `F12` 打开 DevTools 再在其中重载。 ## 给其他插件作者的一条经验 **`plugin_manager` 的卸载不会清掉 `node_modules` 里的旧包名软链目录。** 改名重装后实测留下一个指向同一源码目录的 **junction**: ``` node_modules/@old-scope/ → 指向插件源码目录 ``` 它不会造成重复注册(profile 清单里的条目已被清掉,配置树只有一行),但它是残渣。 **删它要用 `cmd /c rmdir`,不要用 PowerShell 的 `Remove-Item -Recurse`** —— 后者对 junction 有连带删掉**目标内容**的恶名。删之前先确认 `LinkType` 是 `Junction` 且 `Target` 指向插件目录,删之后再核一次源码文件数量与字节数。 改包名时还要记住:它是**三处承重引用**(`package.json` 的 `name`、`cordis.patch.yml` 插入行的 `name`、 `client.js` 的模块 id),并且正确的顺序是 **先按旧名卸载 → 改名 → 按新名安装**。 反过来做会清不掉旧条目,可能出现两行注册同名插件。 ## 0.1.1 · 模型不支持 `reasoningEffort: 'off'` 时插件直接不可用 **症状**(用户反馈):换到某些模型后,卡片直接报错——模型不支持 reasoning effort `off`。 **根因**:`index.js` 把 `reasoningEffort: 'off'` **写死**在 `ctx.llm.stream()` 调用里。 这个硬编码本来是为解决另一个问题:DeepSeek 适配器对非 `session-title` 的用途默认给 `reasoningEffort: high`,而推理预算**计入 `max_tokens`**,于是 256 的输出上限被推理吃光, 卡片每次都被截断。当时用 `off` 是对的——但它**默认了每个模型都有 `off` 这一档**。 **关键在于 DSH 的校验时机。** `ctx.llm` 的运行时契约写得很明确: > 显式指定的 reasoning effort 会在**任何 provider I/O 之前**按该模型的精确能力校验, > 不做截断、也不做别名(no clamping or aliasing)。 也就是说,模型没有这个档位时**不会优雅降级,而是直接失败**。 **解法**:先查能力,再决定传什么。 ```js const info = await ctx.llm.resolveModelInfo(provider, model); const efforts = info?.reasoning?.efforts; // LlmReasoningEffortInfo[] ``` 按 `off → none → minimal → low → medium → high` 取**模型实际提供的最便宜一档**; 一档都没有(模型没有推理旋钮)或能力读不出来 → **不传该选项**,交回适配器默认值。 **第二道保险**:`resolveModelInfo` 对全新适配器未必可靠,所以保留一次重试——只在 **失败信息点名了 reasoning effort** 且**还一个字都没流出去**时才去掉该选项重来。 ("没流过字"这个前提很关键:这类拒绝发生在 provider I/O 之前,重试不会造成重复输出。) **教训**:**能力声明要在调用点查,不要在代码里假设。** `ctx.llm` 已经把能力查询和校验时机都摆出来了, 硬编码一个档位等于把"某个适配器的默认值"当成了平台契约。 **验证**:`node --check` 通过;`LlmResolvedModelInfo.reasoning.efforts` 的字段结构与 "pre-I/O 拒绝"的语义来自**运行时契约查询**(`cordis_inspect_query` → `ctx.llm` 服务契约),非文档推测。 ## 验证状态 **已确认**(通过运行时契约查询与源码核对,非推测): 1. `node --check` 与 `package.json` 解析通过。 2. 安装结果为 `application: applied`、`warnings: []`。 3. 宿主行与客户端 slot 占用者在**运行中的实时树**里(未经重启即已生效)。 4. 路由已注册且**鉴权生效**:无令牌 POST 返回 `401 unauthorized`。 5. 默认模型可解析。 6. 截断根因已由适配器源码确证(上文行号),修复参数已写入并通过常量核对。 **未能验证**(开发环境无浏览器控制权,不做模拟或猜测): 带令牌的浏览器链路(fetch → NDJSON → 卡片渲染)、工具条与卡片的视觉呈现、 流式速度手感、收起/展开/递归的实际体验、浅色深色观感。 按 DSH 官方 `verification.md` 的要求,这些必须由使用者在真实页面确认。