dsh-reversed-chat
0.7.0 更名:原名
dsh-composer-top。旧名只写了「输入框置顶」,新名字覆盖插件做的两件事——「对话栏倒序阅读」+「输入框置顶」。内部包名 / Loader id / 开关属性(data-dsh-reversed-chat)同步更名,功能不变。
自实现的极简 DSH 客户端插件(参考 dsh-layout-control 的思路)。为「程序员职业病(颈椎)」定制阅读布局:
- 输入框:新对话时居中(hero 相,原样);对话开始后(active 相)固定在会话视口顶部(替代默认贴底)。
- 倒序阅读(0.2 新增):转写列表最新一轮渲染在最上方(紧贴输入框)——模型的每次新回复都出现在你正在看的地方;向下滚动即回溯历史。
- Turn 导航条倒序(0.3 新增):右侧圆点导航条同样最新在最上,与转写阅读方向一致;点击跳转、悬停预览、居中跟随全部照常。
原理
宿主用相属性控制输入框位置(@deepseek-ai/dsh-client-ui-conversation / ConversationRoot.module.css):
hero→justify-content: center→ 居中(不碰)active→[data-composer-seat] { position: sticky; bottom: 0 }→ 贴底(覆盖为贴顶)
apply(ctx) 做六件事:
<html>打开关属性data-dsh-reversed-chat(停用即消失,布局零残留);- 注入一张样式表,只用稳定锚点(
[data-content-phase]/[data-composer-seat]/[data-slot=…]/[data-chat-flow],不碰 CSS Module 哈希类名); - 400ms 轮询给「最外层会话」滚动口打
data-dsh-top-main标记 —— 子代理面板、右栏会话等嵌入对话永不误伤; - 同一轮询按结构给库存「回到底部」槽打
data-dsh-to-bottom-slot标记并隐藏(唯一子元素是纯图标<button>的浮动容器,且有 flow/composer/nav/tooltip 四道排除)——该槽在库存 DOM 里没有稳定属性、只有哈希类名,所以这里用结构识别而不是[class*=…];DOM 若变化,最坏结果是该按钮重新出现(纯装饰); - 在主滚动口元素实例上遮蔽
scrollTop/scrollTo:宿主所有「跳到底部(最新)」的写入(打开会话、跟随流式输出)目标是metrics.floor(最大滚动值),一律改写为跳到顶部(倒序后最新在scrollTop 0)。其余程序化滚动(导航条跳转、锚点恢复)按几何原样放行。 - 同一轮询维护导航条「当前回合」的几何补偿:在宿主的阅读线探测(
readVisibleTurn)执行期间遮蔽 flow 行的getBoundingClientRect,让倒序布局下探测落在阅读线所在回合(默认最新),详见「0.6.0 修复」。
倒序为什么安全
- 转写不是虚拟化渲染(
@tanstack/react-virtual只用于右侧 turn 导航条);flow 项就是[data-slot="conversation.chat.flow"]的直接子元素,flex-direction: column-reverse只翻转视觉顺序,DOM 顺序与宿主全部测量逻辑原样。 - 新回复在 DOM 末尾追加 → 视觉上出现在容器顶部(输入框下方)→ 视口钉在
scrollTop 0即时可见,overflow-anchor: none防止滚动锚定把阅读位置拖走。 - 「查看更早消息」按钮翻到最旧一端(底部),语义不变。
导航条倒序为什么安全(0.3,0.5.1 修正镜像方式)
导航条是 TanStack 虚拟化的点列表(transform 定位),不能动它的滚动数学,所以在 nav.frame 级做整帧镜像:transform: translateY(-50%) scaleY(-1) —— 库存的 translateY(-50%)(居中)留在镜像外层,帧的位置分毫不动;点、悬停浮泡随帧一起镜像。稳定锚点:nav:has(button[data-index])、[role="tooltip"],零哈希类名。
两个坑(0.5.1 修复):CSS scale 属性是独立变换,组合在 transform 之后,所以
- 用
scale: 1 -1直接镜像帧时,帧自己的translateY(-50%)也被翻转 → 整帧下沉自身高度(最多 420px,半截出屏); - 再对每个点
button[data-index] { scale: 1 -1 }时,点的定位就是它自己的行内transform: translate3d(0, start, 0),被翻转后每个点落到槽位的镜像一侧(滚动口的overflow裁剪之外)→ 12 个回合的画面里只剩 1 个点可见,即「用户发了多条对话,导航条只有 1 个跳转点」。
现在的做法:帧用 transform: translateY(-50%) scaleY(-1) 镜像;点不做反向翻转(点的字形是 20×2 的水平条 .mark::before,上下对称,本来不需要翻正);只有浮泡里的文字子元素反向翻转保持可读(两行上下次序对调,纯装饰取舍)。
输入框置顶的视觉细节(0.7.1)
置顶后卡片若直接贴着 tab 栏下沿,会同时有两个观感问题:太挤,以及卡片自带的柔和阴影(--dsw-elevation-soft)被滚动口在顶部裁掉,看起来像“贴边”。0.7.1 的处理:
- 顶部留白 12px:sticky 座位加
padding-top,卡片浮在横线下方,阴影有呼吸空间;留白区用页面底色(--dsw-alias-bg-base)铺满,转写内容不会从缝里透出来; - 底部渐隐 20px:座位底部内边距 + 背景渐变到透明,向下滚动的内容在卡片下沿柔和淡出,而不是硬切;不滚动时该区域就是页面底色,无副作用;
- 三个尺寸都是 CSS 变量,可直接覆盖微调:
--dsh-reversed-chat-top-gap(默认 12px)、--dsh-reversed-chat-bottom-gap(8px)、--dsh-reversed-chat-fade(20px)。
只在 active 相生效;hero(新对话居中)与 settling(切换过渡)保持库存行为。
文件
package.json dsh.client 声明(platform web, inject [])+ dsh.bundle.patch 声明
+ locale / icon 的 exports(管理器显示元数据)
cordis.patch.yml - insert: 一条 Loader 条目 → 管理器列表/开关的数据源(唯一插入处)
locale/en.json 英文显示元数据:meta.title / meta.description
locale/zh.json 中文显示元数据
icon.svg 插件图标(exports["./icon"],≤256 KiB)
lib/index.js 宿主半边占位(空 apply,供 Loader 条目挂载)
lib/client.js 浏览器半边:window.__ModuleLoader__.load({ id, factory }),导出 { inject, apply }
为什么需要 cordis.patch.yml(插件管理器可见性)
插件管理器的列表读的是 Loader(cordis 加载树)条目(dsh-host-plugin-inventory 的 readPluginInventory() 遍历 ctx.loader.entries()),而 dsh.client 声明只驱动客户端扫描器加载 lib/client.js —— 两条通道互不相干。没有 - insert 行 = 功能生效但管理器里没有名字和开关。dsh.bundle.patch 声明让 dsh plugin add 在安装时自动应用该补丁。
insert 只能存在一处。 bundle 通道下(dsh.profile.bundles 含本包)这一行由插件自带的 cordis.patch.yml 提供;profile 的 cordis.patch.yml 只允许写 id 覆盖行(例如默认关的 disabled: true)。若 profile 再镜像一条同样的 insert,合成结果会出现两行同 id:管理器的 listPlugins() 找不到"唯一"补丁行,该行变成只读(界面提示「当前 profile 的 patch 无法唯一定位这一项」),开关失效;同时 Loader 按 id 后写覆盖,默认开关状态还会随两行先后顺序翻转(disable 行在 insert 之前就会变成默认开启)。dsh-better-sidebar 的 bundle patch 注释里也警告过 manual mount line 必须删除。
安装 / 更新
从 npm 安装(推荐)
dsh plugin --profile desktop add "dsh-reversed-chat"
从 GitHub 安装(公开仓库)
dsh plugin --profile desktop add "github:lingr7/dsh-reversed-chat"
本地开发(link 安装)
先完全退出 DSH,然后:
:: 首次
dsh plugin --profile desktop add "link:<plugin-dir>"
:: 更新(link 安装指向本目录,拉新代码后重启 DSH 即可;若被复制安装则先 remove 再 add)
启动 DSH → Ctrl+F5 → 验收:
- 启用状态由开关决定(0.4 起):本机 profile 补丁层带
disabled: true覆盖行 → 默认不生效;到「插件管理」打开dsh-reversed-chat,然后 Ctrl+F5,以下效果才出现。开关切换后都需要 Ctrl+F5(客户端模块在页面启动时装配)。该覆盖行就是 profile 层唯一允许的条目,不要再写insert(见上一节); - 新建对话:输入框居中;
- 发送消息:输入框固定顶部,回复内容出现在顶部(紧贴输入框),旧内容被推向下方;
- 向下滚动读历史(越往下越旧),「查看更早消息」在底部;
- 切走再切回会话:落点在顶部(最新);
- 右侧 turn 导航条:点的数量 = 会话回合数(来自
turnOutline投影,与已加载窗口无关;0.5.1 修),最新回合的点在最上,点击跳转正确,悬停预览跟随点位且文字可读; - 右栏 / 子代理的嵌入会话不受影响。
Bundle patch 纪律与默认关(0.4 / 0.4.1 / 0.5)
宿主硬性要求:「bundle patch 含配置/表达式, 热挂载仅支持纯 insert; 重启后由 bundle 层生效」。因此:
- 插件自带的
cordis.patch.yml永远是纯 insert(只有id+name,无配置字段、无!!js表达式)→dsh plugin add可以热挂载,安装即时生效(启用状态下);这也是该行唯一的插入处; - 默认状态不属于 bundle patch:本机「默认关」由 profile 补丁层的 id 覆盖行承载(
- id: dsh-reversed-chat+disabled: true,与 better-sidebar 禁用行同形态)。客户端扫描器跳过被禁用的 Loader 条目(processOne()的entry.disabled检查),lib/client.js不装配,布局零改动; - profile 层只写覆盖行,绝不重复 insert(0.5 修正):此前 profile 里镜像了一条 insert,合成出两行同 id,管理器把该行标为
unaddressable(「当前 profile 的 patch 无法唯一定位这一项」)而无法开关。排查/修复口径见上一节; - 管理器开关 =
setPluginEnabled→writePluginEnabled增删/改写 profile 层的 id 覆盖行并热重载 Loader 条目(composeEntries统一合成有效状态)→ Ctrl+F5 后客户端装配清单随之变化; - 首次开关操作后,状态完全由管理器接管:第一次启用会删掉覆盖行(bundle 层本身就是 enabled),之后再停用会重新追加
- id: dsh-reversed-chat+disabled: true。
0.5.1 修复:导航条只剩一个跳转点
现象:会话有多个回合,右侧导航条却只显示 1 个点;回合越多越明显。
根因(0.3 引入):scale: 1 -1 是独立变换属性,按规范组合在 transform 之后。
- 帧(
nav.frame)自己的transform: translateY(-50%)被翻转 → 帧下沉自身高度; - 每个点(
button[data-index])的定位就是它自己的行内transform: translate3d(0, start, 0),被scale: 1 -1翻转后点落到槽位的镜像一侧,超出滚动口(.scroller的overflow-y: auto)的裁剪范围;结果只有最靠近裁剪边界的那 1 个点可见。DOM 里点一个不少——TanStack 的count/getVirtualItems完全正常,丢的是渲染位置。
修法:镜像折进帧自己的 transform(translateY(-50%) scaleY(-1),居中项留在镜像外层);点不再反向翻转(字形是水平条,对称);浮泡文字子元素保留反向翻转。只改 2 条 CSS / 1 条 transform,不动虚拟化、不碰点击与滚动数学。
验证(Chromium + 真机同款客户端模块,非模拟):12 回合会话 12 个点全部可见、最新(turn 12)在最上;2 / 6 回合同理;1 回合会话仍不显示导航条(库存 items.length < 2 逻辑);悬停浮泡中心与点位对齐;点击第 7 个点落到 turn 7 与 turn 6 的视觉分界(上方 turn 7、下方 turn 6,符合倒序阅读)。
0.6.0 修复:导航条默认高亮最早回合
现象:会话有多个回合时,右侧导航条的「当前回合」实心长条(.markActive)总是落在最早一回合(画面最下方那个点);倒序阅读下视线所在的是最新回合(最上方),预期应是最新回合实心。
根因(宿主 @deepseek-ai/dsh-client-ui-chat 的 readVisibleTurn()):宿主用二分查找求「阅读线所在回合」,前提是 DOM 顺序 = 视觉上下顺序(row.getBoundingClientRect().top 随 DOM 索引单调递增)。flex-direction: column-reverse 只翻转视觉顺序、DOM 仍旧→新,于是:
- 行的
top随 DOM 索引递减,二分判据方向相反; - 查找只会往左收,永远够不到 DOM 末尾的最新行,
reading保持初值turns[0]—— 最早回合; activeTurn恒为最早回合 → 轨道实心高亮、自动居中跟随(scrollToIndex(activeIndex))、aria-current一起错。
修法(不改宿主、不动 DOM 顺序):只在宿主探测调用期间补偿几何。插件在 flow 行元素实例上遮蔽 getBoundingClientRect,用调用栈识别当前是否为 readVisibleTurn 在问(栈帧含 readVisibleTurn),是则:
- 按宿主同一公式算阅读线:
scrollport.top + min(96, clientHeight * 0.2); - 在真实几何里找阅读线所在行:倒序下 = 行
top不大于阅读线的行里top最大者;若没有(阅读线在转写之上)取最上面的行; - 该行及之前的行返回
top = line - 1、之后的返回top = line + 2—— 二分查看到单调递增的假顺序,精确落回该行; - 非探测调用(锚点捕获/恢复、导航条点击跳转、
align())一律拿到原样 rect,几何跳转分毫不动。
行元素增删由 MutationObserver 即时补挂;切换会话时随主滚动口重挂;插件停用时全部还原(删除实例属性,原型行为恢复)。
验证:6 回合会话默认最新回合为实心长条、位于导航条最上;向下滚动读史时长条跟随阅读线;点击跳转、悬停预览、导航条居中跟随照常;aria-current 同步正确。
已知取舍(倒序与宿主 JS 假设的偏差)
- 宿主
readVisibleTurn()的二分查找假设「DOM 靠后 = 视觉靠下」:turn 高亮已在 0.6 用「探测期几何补偿」修正(依赖宿主函数名readVisibleTurn;若未来宿主改名/内联导致栈帧缺失,补偿静默失效,回退为 0.5 的「恒最早回合」表现,不会崩溃)。阅读位置捕获/恢复仍按原假设工作,加载更早消息后落点可能轻微偏移。纯体验损耗,不影响功能。 - 库存「回到底部」按钮的可见性/方向逻辑失效,已按结构打标隐藏(
data-dsh-to-bottom-slot,不依赖哈希类名);回到最新 = 滚回顶部。若库存 DOM 结构调整导致识别失效,唯一后果是该按钮重新出现(纯装饰)。 - 展开折叠历史后的滚动位置还原不精确(同第一条)。
- 正在运行的「思考中」分隔线的
nth-last-child判定按 DOM 序,反转后出现位置可能不符设计(纯装饰)。 - 导航条:滚动口两端的淡出渐变方向可能对调(纯装饰);在导航条内滚轮的「向下 = 更旧」手感与视觉移动方向相反(条短时无感);悬停预览的两行文字(提问/回复)上下次序对调。
- 与
dsh-layout-control的「停靠四角」功能重叠会打架:二选一,或其输入栏保持「完整」。
显示元数据与打包(0.5)
- 管理器 / bundle 详情里的标题与说明来自
locale/<lang>.json的meta.title/meta.description(本包提供locale/en.json、locale/zh.json,经exports["./locale/*.json"]暴露); - 图标来自
exports["./icon"]→icon.svg(宿主支持 SVG/PNG/JPEG/WebP,≤256 KiB,data:内联后交给界面); package.json的files必须同时包含lib、cordis.patch.yml、locale/*.json、icon.svg:漏掉cordis.patch.yml时打包产物会缺 bundle patch,dsh plugin add直接失败(本地link:安装不受影响);- 这些元数据只用稳定的
dsh/exports约定,不读运行时状态,因此管理器不激活插件也能显示。
参考
dsh-layout-control(思路来源:overlay + 稳定锚点 + 属性开关):%USERPROFILE%\.dsh\profiles\desktop\node_modules\dsh-layout-control,GitHubdsh-better-sidebar(client 插件写法范本):GitHub- 宿主侧代码:asar 内
dsh/node_modules/@deepseek-ai/dsh-client-ui-chat/lib/client.js(ChatView、ScrollFollow、flow slot 注册)与dsh-client-ui-conversation/lib/client.js(ConversationContent、conversationPhase())
No comments yet. Be the first to write one.