@lolannn/dsh-effort-slider 0.0.0-stage → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 suyunxing
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,198 @@
1
- # Temporary Holding Version
1
+ # dsh-effort-slider
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A reasoning-effort slider that **replaces the shipped model selector** in the dsh
4
+ web GUI composer. One control owns both halves of the model choice: the model
5
+ list and that model's reasoning effort, which becomes a glowing rail with a
6
+ starfield behind it.
7
+
8
+ **中文**:把 dsh 输入框里的模型选择器换成一条"星光滑轨"——模型列表与思考强度由同一个控件管,切到不支持当前档位的模型时自动落到最接近的一档,不再报错。
9
+
10
+ ```bash
11
+ dsh plugin add @lolannn/dsh-effort-slider # from npm
12
+ dsh plugin add git+https://github.com/suyunxing/dsh-effort-slider # from source
13
+ ```
14
+
15
+ ## Screenshots
16
+
17
+ ![Reasoning effort rail on deepseek-flash](https://raw.githubusercontent.com/suyunxing/dsh-effort-slider/main/docs/screenshots/effort-slider-deepseek-flash.png)
18
+
19
+ ![The same control on a model with a different effort ladder](https://raw.githubusercontent.com/suyunxing/dsh-effort-slider/main/docs/screenshots/effort-slider-gpt-xhigh.png)
20
+
21
+ The rail is drawn from the model's own `reasoning.efforts`, so the tick labels
22
+ change with the model — `off / low / high / max` on one, `low / medium / high /
23
+ xhigh / max` on another — and the knob lands on the level the session is
24
+ actually using.
25
+
26
+ ## What it does
27
+
28
+ - Takes over `conversation.input.model` (a single slot, so this shadows the
29
+ shipped selector rather than sitting next to it) at `priority: -1` — a single
30
+ slot renders the **lowest** priority first.
31
+ - Reads the model catalogue at runtime: the model list comes from
32
+ `catalog.groups` (grouped by provider) and the levels are exactly the
33
+ `reasoning.efforts` the current model declares (typically `off / low / high /
34
+ max`). No hard-coded list.
35
+ - Shows the Session's **real** selection, not the deployment default, by reading
36
+ the durable `modelSelection` projection.
37
+ - Picking a level or a model calls the session `selectModel` remote with the
38
+ explicit `{ provider, model, reasoningEffort }` triple, so the choice applies to
39
+ the Session and is saved as the default in the background.
40
+ - Switching to a model that does not declare the level in hand is resolved on the
41
+ client first: the closest level that model *does* declare is sent instead, and
42
+ the status line says which substitution happened. A model with no reasoning at
43
+ all is switched to with the field omitted, so the Host applies that model's own
44
+ default. Nothing errors, nothing is silently dropped.
45
+ - The rail is animated: drifting particles, soft ribbon waves, iridescent
46
+ four-point stars, a breathing star core, and a three-armed galaxy that
47
+ collapses out of the rail at the top level. Reaching the top fires a shockwave
48
+ burst, a star-warp starfield and a light-blade flash.
49
+ - Models without reasoning efforts are tagged `无档位` in the model list and
50
+ degrade gracefully once selected: the rail is dimmed and the status line says so
51
+ instead of pretending to switch.
52
+
53
+ ## Surfaces
54
+
55
+ | Slot | Content |
56
+ | --- | --- |
57
+ | `conversation.input.model` | the trigger (`模型 · 档位`) and, while open, the popup: model row, grouped model list, effort rail, level scale, status line |
58
+ | `conversation.input.model.label` | untouched — the shipped non-interactive label stays owner-managed |
59
+
60
+ The popup renders through `react-dom`'s `createPortal` into `document.body`,
61
+ because the composer row can live inside a transformed/overflowing ancestor that
62
+ would clip a `position: fixed` child.
63
+
64
+ ## Interaction
65
+
66
+ - Click the trigger, then drag the rail or press `←` `→` `↑` `↓` `Home` `End`.
67
+ - The rail snaps to the nearest level on release (elastic overshoot).
68
+ - Reaching **high** turns the starfield into warp: the stars stream, the rail
69
+ becomes a slow galaxy swirl, and it stays lit for as long as the dial is at
70
+ high or above — so `xhigh` and `max` keep the look instead of only the top.
71
+ Dropping back below high switches it off. The top level still adds the full
72
+ celebration (flash, quake, caption).
73
+ - The level names under the rail are buttons too — same edge math as the thumb,
74
+ so each label sits exactly under its stop.
75
+ - Click the 模型 row to expand the model list; picking a model carries the current
76
+ effort across, remapping it when that model lacks the level (see below).
77
+ - Outside click or `Esc` closes. Direction keys are ignored while a text field
78
+ has focus, so typing in the composer never changes the effort.
79
+
80
+ ## Files
81
+
82
+ - `index.js` — Host half. The bundle row exists so the browser half has an entry
83
+ to attach to; it declares no service, tool or config.
84
+ - `client.js` — the whole feature: stylesheet injection, catalogue + projection
85
+ mapping, canvas engine, controller and the slot registration.
86
+ - `icon.svg` — original artwork (pinched four-point star over a lit rail).
87
+
88
+ ## Notes
89
+
90
+ - All canvas work is `requestAnimationFrame`-driven and stops on unmount.
91
+ - `prefers-reduced-motion: reduce` disables the burst, the warp and the quake.
92
+ - Only transforms, `opacity` and canvas are animated; no layout thrash.
93
+ - The canvas is sized from the card (`getBoundingClientRect` + `ResizeObserver`),
94
+ not from the viewport.
95
+ - The warp gate is the level named `high`; a model that declares no `high`
96
+ warps from the last level at or below it on the shared ladder, and a model
97
+ with one level never warps.
98
+ - Opening the popup while already on the top level arms the burst guard instead of
99
+ firing it, so the celebration only plays when you actually move into the top
100
+ level.
101
+ - The popup opens upward, but flips downward and caps its height when the trigger
102
+ sits too low for that (landscape phone, small window).
103
+
104
+ ## Why a model switch remaps the effort
105
+
106
+ The Host validates the triple, it does not normalize it. In
107
+ `@deepseek-ai/dsh-llm` → `resolveCallWithInfo`:
108
+
109
+ ```js
110
+ const reasoning = info.reasoning;
111
+ const requested = defaulted.reasoningEffort;
112
+ if (reasoning === void 0) {
113
+ if (requested !== void 0) throw new LlmError(`... does not support reasoning effort "${requested}"`, "UNSUPPORTED_REASONING_EFFORT");
114
+ } else {
115
+ const effective = requested ?? reasoning.defaultEffort;
116
+ if (effective !== void 0 && !reasoning.efforts.some((e) => e.id === effective)) throw new LlmError(..., "UNSUPPORTED_REASONING_EFFORT");
117
+ }
118
+ ```
119
+
120
+ The comment above it is explicit: *"no clamping or aliasing is performed."* A
121
+ model with three rungs and a model with seven therefore reject each other's
122
+ levels, and the session remote wraps that rejection as
123
+ `RemoteError("session/model-unavailable", …)` — which used to surface as a red
124
+ `切换失败:provider "…" model "…" does not support reasoning effort "…"`.
125
+
126
+ So `resolveEffort(model, wantedId)` in `client.js` maps the level onto the target
127
+ model before the request is built:
128
+
129
+ | Case | What is sent | Status line |
130
+ | --- | --- | --- |
131
+ | target declares the level | it, unchanged | nothing |
132
+ | target declares other levels | the closest one | `该模型没有「medium」档,已自动切到最接近的「low」` |
133
+ | target declares no reasoning | the field is **omitted** | `「<model>」没有思考强度档位,已按模型默认运行` |
134
+
135
+ "Closest" is measured on one shared scale rather than per-provider lists, because
136
+ every adapter names its rungs from the same vocabulary:
137
+ `off · none · minimal · low · medium/med · high · xhigh · max · ultra`. A tie
138
+ resolves to the **stronger** rung — losing reasoning power is the worse of the two
139
+ surprises (`high → xhigh` rather than `high → medium` when both are one step
140
+ away). A rung whose id is not on that scale is placed by its position in its own
141
+ list, and when the id being carried is itself unknown the target's
142
+ `defaultEffort` (then its strongest rung) is used instead, because its distance
143
+ cannot be measured.
144
+
145
+ The last real choice is remembered in `lastEffort`, so
146
+ `max → 无档位模型 → back` returns to `max` instead of that model's default: the
147
+ intent survives the models that cannot express it.
148
+
149
+ ## Why every visible label is passed through `safeLabel`
150
+
151
+ dsh-pocket installs a document-level click guard for phones: any click on a
152
+ `<button>`/`<a>` whose **text content** looks like a file path is cancelled with
153
+ `preventDefault()` + `stopImmediatePropagation()` and replaced by a toast
154
+ ("手机上无法直接打开电脑上的文件"). Its matcher includes
155
+ `/[\w.\-]+\/[\w.\-]+\.\w{1,12}/`, which a model id like
156
+ `workbuddy-ai/deepseek-v4.1-flash` matches — so the trigger became unclickable on
157
+ mobile while working fine on the desktop.
158
+
159
+ Every label rendered as text therefore runs through `safeLabel()`, which turns
160
+ `/` and `\` into ` · `. `aria-label` and `title` keep the raw identifier, because
161
+ they are attributes and the guard only reads `textContent`.
162
+
163
+ ## Where the "current" model comes from
164
+
165
+ `modelCatalog().default` is the *deployment* default and can differ from what a
166
+ Session really runs (a Session-local override). Spreading it into a commit
167
+ silently switched the model while the user only meant to change the effort.
168
+
169
+ So the controller follows the Session's durable projection — the same source the
170
+ shipped selector reads:
171
+
172
+ ```js
173
+ sessions.binding(sessionId).session.projections.faceOf("modelSelection")
174
+ // -> ObservableSnapshot<{ lastUsed, next }>, next = pending ?? lastUsed
175
+ ```
176
+
177
+ `model/selection` carries the user's intent immediately (`pending`), and
178
+ `request/header` records what a request actually used (`lastUsed`). Two details
179
+ matter:
180
+
181
+ - The face is subscribed **before** the first `getSnapshot()`; reading a
182
+ projection nobody observes can return no value at all.
183
+ - The catalogue default is a fallback only, used until the projection has a value.
184
+
185
+ ## Verification
186
+
187
+ - `conversation.input.model` resolves to this plugin (`priority: -1`, active) and
188
+ the shipped registrant goes inactive.
189
+ - Trigger reads `选择模型与思考强度 — <model> · <effort>`; changing the level
190
+ propagates through `selectModel` into the projection and back into the label.
191
+ - The popup's model list exposes every routable model, grouped per provider.
192
+ - Switching `medium` (on a seven-rung model) to an `off/low/high/max` model sends
193
+ `low` and reports the substitution; switching to a no-reasoning model sends no
194
+ `reasoningEffort` at all; switching back restores `medium`.
195
+ - Checked in a real client at 360×780, 360×400 (short) and 1280×800: a real click
196
+ opens the popup, it stays inside the viewport, the level labels sit under the
197
+ thumb stops, the selected model is marked, and opening does not replay the
198
+ top-level burst.