# 设计说明 `dsh-chat-locator` 为什么长成这样:三条实现约束、长度渐变的调参记录、验证状态,以及你在依赖它之前应该知道的边界。

语言: English | 简体中文

--- ## 📋 目录 - [问题所在](#-问题所在) - [认领样式,而不是重画](#-认领样式而不是重画) - [宿主半侧与它的模块缓存](#-宿主半侧与它的模块缓存) - [预览保持纯文本](#-预览保持纯文本) - [长度渐变曲线](#-长度渐变曲线) - [验证状态](#-验证状态) - [已知边界](#-已知边界) - [版本记录](#-版本记录) --- ## 🔍 问题所在 DSH Web 会沿着对话区右缘画一条轮次轨道 —— 每轮对话一枚刻度。它由 `@deepseek-ai/dsh-client-ui-chat` 的 `ChatView` 直接渲染进对话滚动容器(`TurnNavigator`),Slot 系统没有给它留座位,也没有对外暴露「粗细 / 轨道侧」这类配置。 摆在面前的路有两条:自己重画一条,或者认领已有的那条的样式。本插件走第二条。重画一条就得重新实现内置的跳转轮次、未加载轮分页与活跃轮跟随 —— 这三个功能很容易做错一点点,而用户立刻就会发现。叠加样式则把它们全部原样保住。 代价是耦合。插件必须知道轨道当前用的是哪个类名,而这个类名由构建器生成、随版本变化。所以插件在运行时发现它,而不是写死。 ## 🔧 认领样式,而不是重画 `discoverRail()` 在活动 DOM 上按模式发现 CSS Module 的真实类名前缀:找 `[class*="_frame"]`,并要求它同时含 `.<前缀>_mark` 子节点。实测前缀是 `eGxaPq`,但没有任何东西依赖这个具体值。 `railStyleText(prefix, config)` 再按用户配置生成覆盖规则。每条声明都带 `!important`,这是刻意的:插件在页面启动期就注入样式表,可能早于 `ui-chat` 自己的样式表,所以规则不能靠插入顺序取胜。 轨道还没渲染时 —— 不足两轮、窄容器、其它会话视图 —— 插件不抛错,也不密集轮询。一个 400ms 节流的 `MutationObserver` 会在轨道出现后补上规则,前缀变化时重新计算。 ## 🧠 宿主半侧与它的模块缓存 客户端设置作用域只能派生自宿主**已注册**的命名空间,所以插件有两个半侧。宿主半侧 `index.js` 只做一件事:`ctx.settings.register('chat-locator', schema)`,schema 里带默认值与范围校验。浏览器半侧再用 `ctx.settingsScope.bind({ namespace: 'chat-locator' })` 绑定上去。 ### 为什么不写裸导入 `index.js` **刻意不写** `import z from '@deepseek-ai/schemastery'`。从工作区目录安装时,`plugin_manager` 把依赖记成 `link:<工作区目录>`,Node 会按包在磁盘上的真实路径解析它的依赖,于是裸导入必然 `ERR_MODULE_NOT_FOUND` —— 实测如此,而且失败方式很重:整行插件都挂不上,不只是设置注册失败。 解法是 `loadSchemastery(ctx)`:从配置树基址(profile 目录,`ctx.baseUrl`)按 Node 自己的查找顺序 `createRequire(anchor)('@deepseek-ai/schemastery')`。`link:` 安装与普通拷贝安装都成立。解析不到时插件照常挂载,只是设置段未注册,设置页会如实提示「宿主设置文档当前不可用」,而不是安静地失效。 ### 模块缓存,以及它的代价 **宿主半侧的改动不会自动生效。** Node 缓存已成功导入的模块,而这里的宿主行是 `link:` 安装、工作区目录不在 HMR 的监视根里。这一点是实测出来的,不是猜的:用「文件写入探针」加「有标记文件就抛错」的探针验证过,改完 `index.js` 之后,HMR 与 bundle 开关都不会让它重新导入 —— 运行中的宿主进程依旧拿着旧 schema。 插件不在这件事上装死,而是分三处处理: 1. **宿主 schema 里没有的字段,先留在本次会话。** 新版加的设置如果运行中的旧宿主还没注册,界面照常生效,也不会被旧 schema 悄悄丢掉。 2. **设置页如实提示。** 它会显示:重启一次 DSH 宿主(`dsh web`)后,这些值会自动写进设置文档并长期保留。 3. **`adopt()` 自我修复。** 宿主重启、字段真的出现之后,`adopt()` 会发现它,并把会话里攒下的选择自动补写一次。 ## 💬 预览保持纯文本 悬停预览的正文完全来自内置的轮次大纲 —— `turnOutline` 投影加上已加载窗口的导航项。插件不引入任何其它文本来源。具体来说: * 只取文本块。思考内容不是 text 块,所以它是**从结构上**进不了预览,而不是靠过滤拦掉的。 * 所有空白串(`\s+`)折叠成单空格,所以预览里不可能出现空白行。 * 按固定的字符预算加省略号截断(提示词 50、回复 120)。 插件只决定这份预览开不开、显示几行、字号与宽度多大、卡片多高、往哪一侧展开。`white-space: normal` 与 `overflow-wrap: anywhere` 把两个本会复发的失效方式钉死:换行产生的空行,以及横向溢出。 ## 📐 长度渐变曲线 预览开启时,指针下的那根刻度最长,两侧依次收回内置宽度: | 距悬停处 | 0 | ±1 | ±2 | ≥3 | | :--- | :--- | :--- | :--- | :--- | | 宽度 | 32px | 21px | 14px | 12px | | 每格递减 | — | 11px | 7px | 2px | 这张表由 `buildGradientWidths()` 按幂曲线算出: ```js 12 + 20 * Math.pow(1 - d / 3, 2) // d = 距悬停处的格数,0..2 ``` 得到的是一条**凸向轨道的弯钩**:紧挨悬停那格掉得最多,之后越来越缓,最后贴着轨道收尾。两个互相独立的旋钮各管一件事:`GRADIENT_REACH` 是档数,同时是曲线的分母 —— 所以少一档时中间档位会跟着重算,而不是简单砍掉最后一档;`GRADIENT_CURVE_EXPONENT` 管弯曲程度,`1` 是直线、`2.5` 是断崖。两者当前都是 `2`,这是巧合,不是耦合。 锚点是**悬停(预览)那根**(`_markPreview`),不是选中的轮次。当前轮的刻度(`_markActive`)宽度永不被改写。 ### 调参记录 幂次是照着真实截图调出来的,每个值都记下来,让推理过程能活下来: | 幂次 | 邻居宽度(±1、±2) | 结论 | | :--- | :--- | :--- | | 2.5 | 24、17 | 首格掉 15px —— 像断崖,不像曲线。 | | 1.5 | 23、16 | 平缓,但邻居太长,悬停那根不够突出。 | | **2.0** | **21、14** | 当前这版。首格 11px —— 仍落在「有弯但不陡」的 9–12px 区间,而悬停那根是邻居的 1.52 倍。 | ### 裁剪盒 有很长一段时间,悬停那根看起来一点都不突出,而根源不在曲线。内置轨道框架宽 28px,而框架里的滚动容器带 `overflow-y: auto`;按 CSS 规范,另一轴的 `visible` 在这种情形下会折算成 `auto` —— **框架就是刻度的裁剪盒**。任何超过 28px 的尖端都会被裁掉,曲线于是退化成一截很短的斜线。 所以插件把框架加宽到「最长刻度 + 4px」(当前 32 + 4 = 36px)。刻度是右对齐的,所以位置一点没动,多出来的宽度只是裁剪余量。框架同时也是轨道的悬停与点击面 —— 这是唯一的可见副作用,见[已知边界](#-已知边界)。 ## ✅ 验证状态 `node test/verify-client.mjs` 跑 **121 项断言**,全绿,不需要联网、不需要浏览器、不需要安装依赖。覆盖范围: * **轨道发现** —— 含诱饵框架、无轨道、无 `document`。 * **长度渐变** —— 宽度表 `32 / 21 / 14`、递减量 `11 / 7 / 2` 及其单调性、**首格钉在「有弯但不陡」的 9–12px 区间**、最长刻度不超过框架宽度、框架宽度 = 峰值 + 4 = 36px、每侧影响 2 格、第 3 格起沿用 `12px`、`_markActive` 不被改写。 * **覆盖样式生成** —— 粗细、左右侧、预览开关、高度变量随行数与字号变化、`line-clamp`、字号与行高成对改写、容器夹取宽度、关闭预览时的规则收敛、花括号配平,以及不出现 `undefined` / `NaN`。 * **配置归一化** —— 越界夹取、非法值回落、默认值判定。 * **用桩服务跑完整 `apply()`** —— 字典注册、设置作用域绑定、覆盖样式挂载与随配置刷新、设置页注册(含 `order`)、写入回执、**旧版宿主的会话暂存与提示**、宿主补上字段后的自动补写、「恢复默认」背后的 7 次 `unset` 与置灰状态,以及清理。 * **直接把设置页组件跑一遍** —— 小样 7 根刻度、长度序列 `12 / 14 / 21 / 32 / 21 / 14 / 12`、预览卡让开最长刻度 48px、卡片高度 144px / 198px / 252px,以及字号、宽度、行数跟随配置。 `index.js` 在真实 profile 上跑过:能解析 schemastery 并构造 schema(默认值与越界拒绝手工验证通过),注册调用用桩 settings 服务验证。 **未验证,而且你应该知道:** 像素级的观感 —— 曲线渐变的实际弧度、字号与宽度的手感 —— 机器没有检查过,因为开发环境里没有浏览器自动化能力。这些请你用眼睛判断。此外,写这份说明时,运行中的宿主进程可能仍拿着旧的 5 字段 schema,见[宿主半侧与它的模块缓存](#-宿主半侧与它的模块缓存)。 ## 🚧 已知边界 * **与上游的耦合。** 唯一的结构约定是「`<前缀>_frame` 里有 `<前缀>_mark`」,加上状态类名 `_markPreview`(悬停)与 `_markPosition`(每根刻度的包裹层)。若上游改掉这些名字,长度渐变会静默失效 —— 不报错,也不影响对话本身。 * **长度渐变依赖 CSS `:has()`**(Chromium 105+ 起支持)。不支持的浏览器只是这几条不生效,长度回到内置值。 * **容器宽度 ≤ 900px 时轨道自己隐藏**,这是 shipped 的 `@container` 规则。本插件不强行夺回。 * **粗细上限 8px**,因为刻度行高固定 10px,再粗会互相压住。 * **加宽框架有副作用。** 为了让 32px 的尖端不被裁掉,框架从 28px 变成 36px。框架本身就是轨道的悬停/点击面,所以对话区右侧那块**隐形交互带**跟着宽了 8px:在那一带里悬停会浮出预览卡,点击会跳到对应轮次。 * **预览字号/宽度只作用在预览卡上。** 字体不是内置轨道的字体,宽度也不改轨道本身占用的 28px 布局空间,所以调大不会挤到正文。 * **距悬停处第 3 格起不改长度。** 保持内置宽度 —— 普通轮 12px、未加载轮 8px、当前轮 20px —— 这样轨道整体观感不变。 * **宿主半侧的模块缓存。** 改 `index.js` 之后必须重启 `dsh web`。这是 Node 模块缓存加 `link:` 安装的组合结果,插件绕不过去。 ## 📚 版本记录 ### 1.2.0 1. **长度渐变加长、改成曲线,并把裁剪盒打开。** 渐变从 4 档(24 / 20 / 16 / 12)扩到 6 档,再按两轮「减少一级」收到现在的 4 档(32 / 21 / 14 / 12),每侧影响 2 格。档位由幂曲线算出,框架宽度从 28px 覆盖成 36px 以免峰值被裁掉。锚点是悬停(预览)那根,当前轮的宽度不被改写。 2. **加了预览字号与预览框宽度。** 字号与行高成对改写,因为内置行高是固定 px,只改字号会把行挤在一起。卡片高度按 `46px + 行高 × 行数` 计算:12px / 3 行正好等于内置的 100px(默认外观不变),18px / 6 行是 208px。宽度沿用内置写法 `width: min(Npx, 100cqw - 120px)`,所以窄窗口不会把卡片顶出可视区域。 3. **加了「恢复默认」。** 七项逐字段 `unset` —— 清掉用户层、退回 schema 默认值,而不是把默认值再写一遍。当各项都已是默认值时,按钮置灰并如实说明。 ### 1.1.0 * 预览行数真的生效了 —— 高度变量随行数与字号一起计算。 * 长度渐变锚在预览态,而不是选中轮。 --- ## 📄 许可证 [MIT](../../LICENSE)