dsh-think-intensity · 思考强度滑块
把 DSH 的「推理等级(reasoning effort)」变成模型选择器旁边一颗可展开的科幻滑块。
一个 DSH 客户端插件:纯 CSS 动画 + 零运行时依赖,
常驻全屏氛围光、跨档冲击波、四类可独立开关的粒子特效。
快速开始
不需要 git(推荐):
npm install https://codeload.github.com/Cyanthracene/dsh-think-intensity/tar.gz/refs/heads/main
或者 clone 下来改(需要 git):
git clone https://github.com/Cyanthracene/dsh-think-intensity.git
dsh plugin --profile desktop add <克隆目录的绝对路径>
装完完全退出并重启 DSH(不是刷新页面)。详见 安装 / 卸载。
自测(零依赖):node test/smoke.mjs → OK: smoke test passed
许可:MIT
它长什么样
- 位置:输入框工具行里、官方模型选择器左侧那颗 chip(座位
conversation.input.right)。
不再在输入框上方放整条面板 —— 那个座位(conversation.input.dock)是故意不挂的。
- chip:
强度 high ●●●●○ —— 档位名 + 一条点阵(当前档位发光)。
点它向上弹出一个 320px 宽的紧凑面板,里面才是真正的轨道。
- 滑块:冷青 → 紫的发光轨道(
width: 100%,高 30px、行高 44px —— 短而厚),
光泽沿线扫过,刻度线来自模型自己声明的推理等级;拇指是 30×26 的椭圆把手,厚实好抓。
横向刻意收窄(面板从 460px 收到 320px),纵向加厚,不再"摊"在屏幕上。
- 光点特效:轨道左侧悬着一片会缓慢上浮、明灭的蓝色光尘;档位越高,亮起来的光点越多、光柱越长。
- 每跨过一个推理等级:全屏冲击波(见下节)。
- 拉满(最高档)时的额外流彩(都是"体块"而不是细线):
- 填充条本身变成流动的彩虹带(4 倍宽渐变,1.4s 一圈);
- 两束方向相反的横扫光带(
ti-overdrive + 它的 ::after,1.8s / 2.4s 反向);
- 面板外围随呼吸缩放的彩色光晕(
ti-grid-glow);
- 拇指处不断炸开的彩虹光环(
ti-flare,1.1s);
- 文字(标签 / 读数 / MAX 徽标)变成流动的彩虹渐变;
- 连整个输入框卡片都会获得一圈 2.2px 的旋转彩虹描边 + 三色柔光(
ti-card-overlay,仅最高档挂载)。
- 早先那条"面板底部的极光细线"(
ti-aurora)已删除:它看起来像一条杂线。
全屏常驻氛围光(ti-amb)
某个档位被选中时,屏幕上常驻一层氛围光 —— 不看控件也能一眼看出当前思考强度。
它与冲击波是两条不同的设计轴:
| 冲击波 ti-ion | 氛围光 ti-amb |
|---|
| 性质 | 事件:跨档瞬间 | 状态:一直存在 |
| 层级 | 浮在最上层(z-index: 2147482000) | 压在最后层(z-index: 0,<body> 首个子节点) |
| 位置 | 从指针炸开 | 铺满全屏、在所有面板之后 |
| 时间 | 0.4 ~ 2.1s | 几秒到几十秒一个循环 |
| 形态 | 同心冲击环 | 离散形状:微粒 / 光环 / 光带 / 极光幕 |
每一档解锁一层新特效,逐级叠加
| 档位 | 解锁 | 光尘 | 光环 | 光带 | 极光幕 |
|---|
| 0(最低) | 无 | 0 | 0 | 0 | 0 |
| 1 | 漂浮光尘:微粒自下而上飘、逐颗明灭 | 40 | — | — | — |
| 2 | + 扩散光环:错开起跑的圆环向外扩散 | 52 | 3 | — | — |
| 3 | + 流光横扫:细飘带上下起伏地扫过 | 64 | 4 | 2 | — |
| 4 | + 极光幕:倾斜的彩色飘带横向流动 | 76 | 5 | 3 | 3 |
| 5 | 全部加强 | 88 | 6 | 4 | 4 |
特效开关(面板底部)
两个主键,三态互斥 —— 每个模式只有一个决定组合的来源,互不叠加:
默认特效 → 供给 = 档位配方,副键状态完全忽略(灯只是"该档供给什么"的读数)
自定义 → 供给 = 满档强度,副键决定最终组合
特效关闭 → 全灭
| 控件 | 角色 |
|---|
默认特效 / 自定义特效 / 特效关闭 | 主键:点击在三个状态间循环 |
光尘 光环 流光 极光 | 副键:自定义模式下决定组合;默认模式下点击不改变画面,但会预先设置自定义要用的组合 |
冲击波 | 独立开关:任何模式下都可单独关 |
为什么默认模式必须完全无视副键
曾经让默认模式把副键与档位供给 AND 起来,结果是:
你在自定义模式里取消过某个勾选 → 它写进 localStorage → 切回默认模式后
那个元素仍然被取消,默认供给被静默削掉,最坏情况整屏全黑。
而灯读的是档位供给,所以面板上一切"正常",屏幕却什么都没有。这正是
on=false lv=3 但主键显示「默认特效」的原因,也是"默认特效又没了"的根因。
现在默认模式原样返回档位配方(连副本都不建),所以这是结构性保证:
任何残留勾选都不可能影响默认模式。自测直接断言
「四个副键全部取消勾选 + 默认模式 → 仍然完整渲染」。
副键在默认模式下保持可点(不再锁定):点击不改变画面,但会预先设置自定义要用的组合。
锁定它们曾经是错的 —— 锁住的键无法"预设",而且还把这个 bug 掩盖成"反正点了也没用"。
副键的灯
档位 光尘 光环 流光 极光
0 ○ ○ ○ ○
1 ● ○ ○ ○ ← low:只亮光尘
2 ● ● ○ ○
3 ● ● ● ○
4 ● ● ● ● ← max:四个全亮
- 默认模式:读档位供给,与副键状态无关;
- 自定义 / 关闭:读勾选框状态(关闭时画成空心,表示选择被保留)。
自测断言:默认模式下即使四个勾选全被取消,四个灯仍然全亮(因为档位供给它们);
灯只会随档位递增、绝不倒退;max 档极光灯必须点亮。
自定义模式在任何档位都与 max 档一致(含最低档)
最低档(off)默认模式下本来就什么都不供给,但自定义模式在这里必须照常满载。
三个轴的来源必须全部换掉,缺一个都会出问题:
| 轴 | 从档位取会怎样 | 自定义模式改为 |
|---|
| 密度 | low 的光环/流光/极光本来就是 0,min(0,勾选) 恒为 0 → 那三个键永远点不动 | 固定 88/6/4/4 |
| 亮度 | 层不透明度随档位衰减(.9 / 只在解锁后点亮) | data-custom 让任何非空池 opacity:1 |
| 配色 | 最低档的 alpha: 0 会被写进 --ti-glow,即所有形状的光晕变成全透明 | 取表格顶层的配色 |
第三行是这次真正的元凶:形状数量一直是对的,但在最低档下每个形状都没有光晕,
于是只剩几乎没有对比度的白点/淡描边 —— 看起来就像"特效被关掉了"。
custom per level : 88/6/4/4 88/6/4/4 88/6/4/4 88/6/4/4 88/6/4/4 ← 密度恒定
low rung custom : 0/6/4/4 | low default 40/0/0/0 ← 最低档也满载
为什么密度不能四个元素都拉到 88:光环是 220px 圆 + 22px 光晕、极光幕是 34vh 模糊条,
88 个 = 352 个重度元素,会把合成器压垮(表现为画面紊乱与闪烁,不是"变慢")。
所以每个元素保留各自的量级,靠亮度与配色满载达到"满档"观感。
亮度满载由三件事共同保证,全部有断言:
.ti-amb[data-custom='true'] .ti-amb-layer:not(:empty){opacity:1} —— 自定义模式下
任何非空池都是全不透明,与滑块停在哪一档无关(:not(:empty) 保证取消勾选后仍会变暗);
- 档位阶梯里没有任何
.9 之类的衰减,每一档都是 opacity:1;
- 逐形状亮度与数量解耦(
shapeBrightness):原先 --mo/--ro/--so 取自共享随机流,
于是 6 个和 88 个会采到不同的段落、平均亮度不同 —— 这是"没有满载"最隐蔽的那一层。
现在按索引取固定环值,族内平均亮度与数量无关,同时保留逐形状差异。
三态仍然保留:默认特效 → 自定义特效 → 特效关闭。只有"特效关闭"会全灭,
而且它必须是被主动选中的,绝不会因为滑块停在低档就顺带发生。
自测断言 EFFECT_MODES 仍是三态、off 仍能全灭、且每一档的自定义配色都非零。
-
主键绝不改动副键:切换模式只改 mode,所以切到自定义再切回来,勾选原样保留。
-
"关掉 == off 档"是结构保证:关闭时配方塌成 0/0/0/0、on: false,与最低档同构。
自测同时断言计划形状与实际 DOM(在真实宿主节点上跑 applyAmbient 后逐个读节点数)。
-
开关是端到端测过的:弹层主体抽成 IntensityPanel,自测直接渲染它并点每一个键。
-
档位指示条走同一个过滤器,所以它不可能宣传一个没在屏幕上的特效。
-
颜色编码:主键边框按状态变色(默认=中性 / 自定义=绿 / 关闭=虚线)。
-
持久化:写入 localStorage(键 dsh-think-intensity/effects),刷新与重启后保持;
旧版本存档里的布尔 all 会自动迁移成 all:true → 默认、all:false → 关闭。
存储不可用(隐私模式、配额)时静默退回默认模式。
面板底部的状态行
面板最下面一行是一句紧凑状态,因为宿主是应用的兄弟节点,从控件里看不见:
氛围光:全效(过载) · 档 4 · 已开启 · 模式 默认特效
它回答"当前这一档到底有没有特效、处于哪个模式"。曾经还有第二行"宿主实测"
(逐个池的形状数与计算后 opacity),用于定位"特效没生成"还是"被 CSS 关掉",
按用户要求已删除 —— 面板只需要表达状态,不需要暴露实现细节。
调试时的替代手段仍在:
-
宿主元素的属性本身就是完整状态:data-on / data-lv / data-peak / data-custom /
data-painted(形状总数),可直接在 DevTools 里读;
-
dsh-think-intensity:ambient 事件带 detail(rung / key / on / alpha / dust /
mounted / layers),可监听;
-
不写控制台:插件没有理由向宿主应用的 console 输出内容。
-
递进是"结构量"而不是"亮度":自测断言每一档的每一种形状数量都不减少、总数严格增加,
并且断言第 1 档只有光尘、第 2 档解锁光环、第 3 档解锁光带、第 4 档解锁极光幕 ——
也就是"每一档都必须新增一种以前没有的东西"。
-
为什么放弃"氛围洗色":一层大范围低 alpha 渐变在深色主题上无论 alpha 调到多高都读不出来,
只会变成盖在壁纸上的一层膜,既不够亮又挡背景。能读出来的是"在动的结构",
所以现在每一层都是离散形状(微粒、圆环、光带、光幕),而不是一层纱。
-
不挡视线:微粒与圆环都是小尺寸、稀疏分布;光带是细飘带(10~16vh)、极光幕是窄条;
整个宿主 pointer-events:none,屏幕中部不做任何堆积。
-
模糊是稀释,不是"柔化":同样亮度摊在大面积上,峰值会按半径的平方掉下去。
光带一开始用 26px、极光幕整层用 34px,结果"看着有但读不出形状";现在光带 5px、
每张幕自带 14px,光带中心还加了近白色核心 —— 边缘依然柔和,但主体立得住。
-
形状要"曲",不要"直":矩形 + 纯水平位移读起来就是一根硬邦邦的横条。所以每条光带:
- 细:10~16vh(之前是 32vh 的板子);
- 透镜形:
border-radius:50% 12% 50% 12%,两端收成尖的;
- 倾斜并在行进中摆动:关键帧在
-tilt / -0.45tilt / +tilt 三个角度之间摆动;
- 上下起伏(水波):水平位移之外再叠一条 Y 轴近似正弦(
--samp 为振幅,
每条带 2.5~6.5vh 各不相同),一圈内经过 6 个不同的 Y 停点,所以它像水面一样起伏,
而不是沿导轨平移;倾角与起伏的周期数不同,两者叠加后动作永不显周期;
- 填充也斜:
linear-gradient(100deg, …)。
-
倾角与振幅必须走变量:transform 归关键帧所有,内联 transform 会被关键帧覆盖(静默失效)。
所以极光幕用 --atilt、光带用 --st0/--stm/--st1 与 --samp,全部在关键帧里参与运算。
自测会从已安装的样式表里解析关键帧,断言 Y 轴确实有 ≥5 个停点、≥4 个不同取值,
并且确实消费了振幅变量 —— 也就是说"起伏有没有真的写进去"是可验证的,不靠肉眼。
三条踩过坑的样式事实(都在自测里钉住了)
z-index: 1,不是 0 也不是负值。
0 会让特效落在 shell 那些不透明面板之下 —— 结果是被盖住,而不是"在后面";
负值更糟,会落到应用自身背景之后。往上走一格才是"在背景之上、面板之下"。
宿主同时永远是 <body> 的第一个子节点(React portal 会往 body 追加节点,所以每次切档都重新沉底一次)。
- 正常合成,不要
mix-blend-mode。
把 screen 叠到近黑表面上,结果会被乘上 1 - dst,也就是约 0.05 —— 再怎么加 alpha 都救不回来。
- 强度不能用
rgba(var(--x), calc(var(--y,.12) * k)) 表达。
当 var() 内部含 calc() 且变量在计算值时无效,回退值会替换整个 calc() 表达式,
于是每一档都按那个 0.12 回退值上色 —— 这就是"alpha 从 0.10 调到 0.90 却毫无变化"的真正原因。
现在所有与强度有关的数值都在 JS 里算好、以字面量写进样式,并且可以在测试里读回来断言。
data-on='false' → 宿主 opacity:0,最低档等于不存在。
- 可访问性:
prefers-reduced-motion: reduce 时停止位移与循环,但形状保留
(在媒体查询里显式重申各层的透明度与静止缩放)。
实现上与冲击波同构:每个文档一个宿主(模块级缓存,跨渲染与卸载存活),
四个形状池全部预建,切档只写 data-on / data-lv / data-peak;
每个池只在其数量真的变化时才重建,且形状的随机参数用确定性种子,重建结果完全一致。
全屏等级冲击波(ti-ion)
每跨过一个推理等级的临界点(不是每次移动),全屏从指针位置炸开一次冲击:
| 层 | 作用 |
|---|
ti-ion-flashring | 白热锋面环:纯白粗边 + 内外双向白光,始终最先冲出画面 |
ti-ion-ring…4 | 彩色圆环,每环是三层叠出来的:白热前缘(::before)+ 饱和色体 + 受光内腔(::after) |
ti-ion-core | 白热核心:中心 97% 白 + 饱和外晕(blur 6px,带真实光溢出) |
ti-ion-beam | 沿指针方向的射线,白心彩尾 |
ti-ion-splash | 全屏色晕 + 指针处的硬高光点(这是"打击感"的来源) |
力度是档位的函数(递进感)
一组 keyframes + 每次触发写入的自定义属性,圈数 / 半径 / 亮度 / 时长 / 色晕 / 高光 / 锋面粗细全部随档位递增。
BURST_FORCE 每行就是一个完整配方,实测值(5 档模型):
档位 圈数 时长 扩散倍率 核心亮度 色晕 高光 锋面
0 1 0.40s 10 0.50 0.16 0.22 3.0px
1 1 0.52s 16 0.62 0.26 0.36 3.5px
2 2 0.64s 22 0.72 0.36 0.50 4.0px
3 3 0.80s 30 0.82 0.48 0.62 4.6px
4 3 0.96s 38 0.90 0.60 0.74 5.2px
5 4 1.08s 48 1.00 0.72 0.86 6.0px
- 圈数:0 档只有 1 圈,最高档 4 圈,而且每圈依次更慢、更远、更细,读起来是"一波接一波"而不是一圈更粗;
- 扩散:末端倍率 10 → 48,最外圈直径从约 840px 涨到 4000px;
- 射线:从第 2 档开始解锁,沿指针方向射出一道线;
- 色晕:0.16 → 0.72,低档是一记亮闪,最高档是整屏染色 + 硬高光;
- 颜色也随档位:
burstColors(level, max) 在调色板上按档位比例取色,冷青 → 洋红。
- 自测直接断言这些单调性(圈数不减、时长更长、半径更大、核心更亮、色晕更强),
并且断言最高档布防的环数严格多于最低档(1 → 4)。
反复来回拖可以无限释放
- 只建一次 DOM:一个 fixed 宿主 + 固定的环槽池;触发时只切换
[data-arm]、写几个自定义属性、
把 data-run 摘掉再挂上(强制一次 reflow)—— 动画即重放。没有节点增删、没有布局计算。
环槽数量固定为最宽配方所需的 4 个,所以永远不会有第 5 个节点。
fire 必须是稳定引用(踩过坑):如果每次渲染都返回新的 fire,每次调用就会各自建一个宿主,
重放标志落在没人看的节点上。chip 每次换档都会重渲染,而换档正是触发冲击波的时刻 —— 所以用 ref 固定它。
- 每个层都必须声明"静止态"(踩过坑):所有层都写死了
opacity:0 + 起始 transform,
几何与透明度只出现在 keyframes 里是不够的。曾经的写法把 opacity 只放在动画里,
于是"动画没在跑"的瞬间,一个 280px 高斯模糊核心就以全尺寸、全不透明停在输入框上。
现在:静止态 → 不可见;data-run 在时才播;生命周期到了属性被移除。
自测断言每个层都带 opacity:0,且 data-run 是存在性标志而不是 ='true'。
- 节点必须活过渲染周期(踩过坑,导致过"完全没有冲击波"):宿主节点缓存在模块级变量里,
卸载时不删节点、不清 ref。曾经的清理函数会
removeChild + hostRef.current = null,
于是下一次触发新建了一个浏览器从未绘制过的节点、在它身上重放动画,而旧节点已经脱离文档 ——
动画确实"启动了",但屏幕上什么都没有。重放动画的前提是节点已经被绘制过。
自测会渲染 → 卸载 → 再渲染,然后断言两次 fire 落在同一个节点上、且它仍然挂在文档里。
- 生命周期必须长过最长的那条动画:
ring4 是 1.75 倍基础时长,所以
burstLifetime = dur * 1750 + 260ms。早先用 dur * 1250 + 160,最高档 1.08s 的配方会算出
1510ms,而 ring4 要跑 1890ms —— 标志位会在最后一圈还没扩完时被摘掉。
- 速率保护:两次触发之间至少隔 60ms,快速划过不会叠成一坨。
- 无坐标输入也有效:键盘(
←/→/Home/End)与原生 range 的 input 事件没有指针坐标,
此时冲击波以屏幕为中心释放。
- 可访问性:
prefers-reduced-motion: reduce 时整个 ti-ion 直接 display: none。
它做什么
- 读取当前会话的「模型目录」(
ctx.get('modelDirectories')),拿到当前模型自己声明的推理等级列表(例如 low / medium / high / max,不同 provider 不一样)。
- 拖动滑块 → 调用与模型选择器同一个入口
directory.select({ provider, model, reasoningEffort })。
- 该入口在宿主侧走
session.selectModel,既把等级装到下一个请求上,也会把整份选择保存为以后新会话的默认值——所以这个滑块同时是「本次会话」和「默认档位」。
拖动位置是连续值(拇指完全跟手,不逐格顿挫),松手才吸附到最近的档位并立即写入;
拖动过程中每 260ms 最多写一次远端,避免一路拖过去打出一串请求。
安装 / 卸载
方式 A:不需要 git(推荐给只想用的人)
包管理器直接抓 GitHub 的 tarball,完全不依赖 git:
npm install https://codeload.github.com/Cyanthracene/dsh-think-intensity/tar.gz/refs/heads/main
也可以让 DSH 用这个地址安装(<地址> 换成上面那条 URL):
dsh plugin --profile desktop add https://codeload.github.com/Cyanthracene/dsh-think-intensity/tar.gz/refs/heads/main
方式 B:先 clone(想改代码、想跟着更新)
git clone https://github.com/Cyanthracene/dsh-think-intensity.git
dsh plugin --profile desktop add <克隆下来的目录绝对路径>
让它以 link: 方式挂进 profile 后,直接改那个目录里的文件即可继续同步,不需要重装。
⚠️ 方式选择的关键:git 是不是必须的
用 git 地址(git+https://... 或 github:用户/仓库)安装时,包管理器需要调用系统里的 git。
Windows 默认不带 git,所以这条会直接失败,报错长这样:
npm error syscall spawn git
npm error path git
npm error enoent This is related to npm not being able to find a file.
遇到这个报错,就用方式 A 的 tarball 地址,或者先装 git(winget install Git.Git)。
装完必须重启
完全退出并重启 DSH(不是刷新页面)。原因:
⚠️ 桌面端的 __DSH_BOOT__ 注入表是宿主启动时收集一次、之后一直用内存里那份的,
所以新装 / 新删 / 新禁用插件后必须重启才会生效。
它在 profile 的 package.json 里体现为:
dependencies:"dsh-think-intensity": "link:<该目录>"(方式 B)或版本/tarball 记录(方式 A)
dsh.profile.bundles:追加 dsh-think-intensity
(该 profile 用 bundles 列表启用插件,所以 cordis.patch.yml 里不会出现插入行;
本包自带的 cordis.patch.yml 是给"把本包当作 bundle patch 层"的宿主用的。)
卸载
把 dsh-think-intensity 从 dsh.profile.bundles 与 dependencies 中移除,重启 DSH。
开发时的一条省事规则
⚠️ 入口 lib/client.js 由宿主以组合 URL(combo URL)下发且会被持久缓存,
所以改入口必须重启;而 lib/client.view.js 是通过带 ?rev= 的 chunk 通道加载的,
改视图刷新页面即可。这条差异能省掉大量无谓重启。
自测
零依赖,不需要安装任何东西:
node test/smoke.mjs # 期望输出:OK: smoke test passed
test/smoke.mjs 用一个很小的 React 替身跑真实的入口 + 视图 chunk,共 260 余条断言。
除了入口注册、chunk 通道、初始档位、禁用态这些基础项,它还钉住了几条踩过坑才立下的约定:
- 档位映射不得把两个档位塌到同一层(曾经的
round(i/max*span) 在五档下得到 0,1,1,0,0);
- 小数档位 /
NaN / undefined 输入不得让控件崩掉(26 组敌意输入);
- 氛围光的依赖键必须覆盖每一个进入 DOM 的字段(漏掉三个数量会导致"某些特效应关不掉");
- 自定义模式的密度、亮度、配色三个轴都不随档位变化(最低档也要与 max 档一致);
- 默认模式原样返回档位配方,残留勾选不得影响它(否则会整屏全黑);
- 轨道几何(
track ≥ 24px、rail ≥ 36px)与"插件不得写控制台"。
指针拖拽路径(level → 去抖 → directory.select → 投影回读)不在自测范围内:
替身的 effect/ref 调度只是对 React 的近似,把它掰成 React 的代价大于收益,
这条路径只在真实浏览器里验证。
兼容与降级
- 依赖
@deepseek-ai/dsh-client-ui-model-selection 提供的 modelDirectories 服务:该服务不在时,条目会明确报错而不是静默失效。
- 子代理寻址会话不提供模型选择,此时滑块显示为禁用态。
- 当前模型没有推理等级时,滑块禁用并显示「当前模型不提供推理等级」。
- 尊重
prefers-reduced-motion:系统开启减少动效时,全部特效动画关闭。
- 关键布局同时写在内联样式里:即使样式表完全没生效,也会看到一个带边框的可见控件,
而不是一片空白(另有
mainStylesheetLive() 探针 + 兜底样式表)。
结构
| 文件 | 作用 |
|---|
lib/index.js | 宿主半区:只为让本包成为一条可挂载的 Loader 行(无服务、无路由、无状态) |
lib/client.js | 浏览器半区(入口):样式、conversation.input.dock 这一行的注册、以及注入面 |
lib/client.view.js | 浏览器半区(视图 chunk):折叠/展开、指针拖拽、光点、最高档流彩 |
cordis.patch.yml | bundle 补丁:插入 id: think-intensity 这一行 |
package.json | dsh.bundle.patch + dsh.client(platform: web,exports["./client"])声明 |
test/smoke.mjs | 本地自测:用 React 替身跑真实 chunk,断言初始档位/禁用态/锚点/折叠态结构 |
为什么视图要拆成 chunk
桌面壳把每个客户端插件通过组合 URL(/plugins/??<id>/client.js&rev=…)一次性下发,
而 Chromium 对这个 URL 用的是持久磁盘缓存:只要该包的 client.js 修订号不变,
它的字节永远不会刷新 —— 连完全重启应用也一样。
包内 chunk 走的是 /plugins/<id>/client.view.js?rev=…,带查询串、必然回源。
所以在"重装插件换版本"不方便的前提下,它是唯一能保证改动生效的通道。
因此:改动只放视图 chunk,入口尽量不动。
chunk 通道的两条硬性约定(踩过坑)
- 调用点必须用单引号:
require.async('./client.view.js')。
写双引号会让引号本身进 URL(...%22client.view.js%22),
而 chunk 通道的校验正则是 /^client\.[A-Za-z0-9][A-Za-z0-9._-]*\.js$/ —— 直接不匹配。
- chunk 必须自己注册:它是普通的 bundle 脚本,要调用
window.__ModuleLoader__.load({ id: "<所属包>/<文件名>" }),
即 id: "dsh-think-intensity/client.view.js"。
只写 module.exports 不会进模块表,加载会被判为
loaded without registering … via __ModuleLoader__.load。
座位数据形状(踩过坑)
座位注入面的形状必须和 ui-model-selection 自己的一致(内核源码里就是这么写的):
const directory = models.directoryFor(sessionId);
return {
store: directory.store, // 快照存储:subscribe / getSnapshot
select: (s) => directory.select(s), // 动词在控制器上
load: () => directory.load(),
};
把 控制器本身当成快照存储传进去(我犯过)→ 在其上找不到 subscribe/getSnapshot,
控件要么崩、要么退化成"模型目录服务不可用"。快照里的字段:
current{provider,model,reasoningEffort}、groups[].models[].reasoning.{defaultEffort,efforts[]}。
交互设计
- 入口:chip 默认收起(只有一个按钮 + 档位点阵);点击向上弹出面板,点面板外或按
Esc 关闭。
面板贴到窗口右缘时会自动回缩(margin-right 收边),不溢出视口。
- 拖拽:指针事件挂在
.ti-pointer 这层上(pointerdown 取 setPointerCapture,
pointermove 连续跟手,pointerup/cancel/lostpointercapture 提交)。
原生 <input type="range"> 只保留键盘(←/→/Home/End)与可访问名(clip-path 隐藏、pointer-events:none),
不参与指针路径 —— 早先"只能点不能拖"就是那层隐形 input 抢走了拖动。
- 手感:拖动位置是连续值(
ratio * maxIndex,浮点),拇指完全跟指针;拖动中 data-dragging
关掉填充/光点/拇指的过渡,只留按下的轻微放大;指针层上下各外扩 8px,更容易抓住。
- 写入节流(这是"切换会卡一下"的修法):轨道每次移动都会先判断四舍五入后的档位有没有变,
只有变了才排队;一次手势里第一档立即发送(单击也要瞬时),后续档位在 120ms 窗口内合并成一次;
松手时清空队列并把最终档位写出去。这样一次拖动最多几个请求,而不是几十个。
- 过期响应:拖动可能让多个
select 同时在途,只有最新那个的返回值允许回写本地档位,
否则旧响应会把拇指拽回用户已经离开的位置。