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

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,179 @@
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
+ ## What it does
16
+
17
+ - Takes over `conversation.input.model` (a single slot, so this shadows the
18
+ shipped selector rather than sitting next to it) at `priority: -1` — a single
19
+ slot renders the **lowest** priority first.
20
+ - Reads the model catalogue at runtime: the model list comes from
21
+ `catalog.groups` (grouped by provider) and the levels are exactly the
22
+ `reasoning.efforts` the current model declares (typically `off / low / high /
23
+ max`). No hard-coded list.
24
+ - Shows the Session's **real** selection, not the deployment default, by reading
25
+ the durable `modelSelection` projection.
26
+ - Picking a level or a model calls the session `selectModel` remote with the
27
+ explicit `{ provider, model, reasoningEffort }` triple, so the choice applies to
28
+ the Session and is saved as the default in the background.
29
+ - Switching to a model that does not declare the level in hand is resolved on the
30
+ client first: the closest level that model *does* declare is sent instead, and
31
+ the status line says which substitution happened. A model with no reasoning at
32
+ all is switched to with the field omitted, so the Host applies that model's own
33
+ default. Nothing errors, nothing is silently dropped.
34
+ - The rail is animated: drifting particles, soft ribbon waves, iridescent
35
+ four-point stars, a breathing star core, and a three-armed galaxy that
36
+ collapses out of the rail at the top level. Reaching the top fires a shockwave
37
+ burst, a star-warp starfield and a light-blade flash.
38
+ - Models without reasoning efforts are tagged `无档位` in the model list and
39
+ degrade gracefully once selected: the rail is dimmed and the status line says so
40
+ instead of pretending to switch.
41
+
42
+ ## Surfaces
43
+
44
+ | Slot | Content |
45
+ | --- | --- |
46
+ | `conversation.input.model` | the trigger (`模型 · 档位`) and, while open, the popup: model row, grouped model list, effort rail, level scale, status line |
47
+ | `conversation.input.model.label` | untouched — the shipped non-interactive label stays owner-managed |
48
+
49
+ The popup renders through `react-dom`'s `createPortal` into `document.body`,
50
+ because the composer row can live inside a transformed/overflowing ancestor that
51
+ would clip a `position: fixed` child.
52
+
53
+ ## Interaction
54
+
55
+ - Click the trigger, then drag the rail or press `←` `→` `↑` `↓` `Home` `End`.
56
+ - The rail snaps to the nearest level on release (elastic overshoot).
57
+ - The level names under the rail are buttons too — same edge math as the thumb,
58
+ so each label sits exactly under its stop.
59
+ - Click the 模型 row to expand the model list; picking a model carries the current
60
+ effort across, remapping it when that model lacks the level (see below).
61
+ - Outside click or `Esc` closes. Direction keys are ignored while a text field
62
+ has focus, so typing in the composer never changes the effort.
63
+
64
+ ## Files
65
+
66
+ - `index.js` — Host half. The bundle row exists so the browser half has an entry
67
+ to attach to; it declares no service, tool or config.
68
+ - `client.js` — the whole feature: stylesheet injection, catalogue + projection
69
+ mapping, canvas engine, controller and the slot registration.
70
+ - `icon.svg` — original artwork (pinched four-point star over a lit rail).
71
+
72
+ ## Notes
73
+
74
+ - All canvas work is `requestAnimationFrame`-driven and stops on unmount.
75
+ - `prefers-reduced-motion: reduce` disables the burst, the warp and the quake.
76
+ - Only transforms, `opacity` and canvas are animated; no layout thrash.
77
+ - The canvas is sized from the card (`getBoundingClientRect` + `ResizeObserver`),
78
+ not from the viewport.
79
+ - Opening the popup while already on the top level arms the burst guard instead of
80
+ firing it, so the celebration only plays when you actually move into the top
81
+ level.
82
+ - The popup opens upward, but flips downward and caps its height when the trigger
83
+ sits too low for that (landscape phone, small window).
84
+
85
+ ## Why a model switch remaps the effort
86
+
87
+ The Host validates the triple, it does not normalize it. In
88
+ `@deepseek-ai/dsh-llm` → `resolveCallWithInfo`:
89
+
90
+ ```js
91
+ const reasoning = info.reasoning;
92
+ const requested = defaulted.reasoningEffort;
93
+ if (reasoning === void 0) {
94
+ if (requested !== void 0) throw new LlmError(`... does not support reasoning effort "${requested}"`, "UNSUPPORTED_REASONING_EFFORT");
95
+ } else {
96
+ const effective = requested ?? reasoning.defaultEffort;
97
+ if (effective !== void 0 && !reasoning.efforts.some((e) => e.id === effective)) throw new LlmError(..., "UNSUPPORTED_REASONING_EFFORT");
98
+ }
99
+ ```
100
+
101
+ The comment above it is explicit: *"no clamping or aliasing is performed."* A
102
+ model with three rungs and a model with seven therefore reject each other's
103
+ levels, and the session remote wraps that rejection as
104
+ `RemoteError("session/model-unavailable", …)` — which used to surface as a red
105
+ `切换失败:provider "…" model "…" does not support reasoning effort "…"`.
106
+
107
+ So `resolveEffort(model, wantedId)` in `client.js` maps the level onto the target
108
+ model before the request is built:
109
+
110
+ | Case | What is sent | Status line |
111
+ | --- | --- | --- |
112
+ | target declares the level | it, unchanged | nothing |
113
+ | target declares other levels | the closest one | `该模型没有「medium」档,已自动切到最接近的「low」` |
114
+ | target declares no reasoning | the field is **omitted** | `「<model>」没有思考强度档位,已按模型默认运行` |
115
+
116
+ "Closest" is measured on one shared scale rather than per-provider lists, because
117
+ every adapter names its rungs from the same vocabulary:
118
+ `off · none · minimal · low · medium/med · high · xhigh · max · ultra`. A tie
119
+ resolves to the **stronger** rung — losing reasoning power is the worse of the two
120
+ surprises (`high → xhigh` rather than `high → medium` when both are one step
121
+ away). A rung whose id is not on that scale is placed by its position in its own
122
+ list, and when the id being carried is itself unknown the target's
123
+ `defaultEffort` (then its strongest rung) is used instead, because its distance
124
+ cannot be measured.
125
+
126
+ The last real choice is remembered in `lastEffort`, so
127
+ `max → 无档位模型 → back` returns to `max` instead of that model's default: the
128
+ intent survives the models that cannot express it.
129
+
130
+ ## Why every visible label is passed through `safeLabel`
131
+
132
+ dsh-pocket installs a document-level click guard for phones: any click on a
133
+ `<button>`/`<a>` whose **text content** looks like a file path is cancelled with
134
+ `preventDefault()` + `stopImmediatePropagation()` and replaced by a toast
135
+ ("手机上无法直接打开电脑上的文件"). Its matcher includes
136
+ `/[\w.\-]+\/[\w.\-]+\.\w{1,12}/`, which a model id like
137
+ `workbuddy-ai/deepseek-v4.1-flash` matches — so the trigger became unclickable on
138
+ mobile while working fine on the desktop.
139
+
140
+ Every label rendered as text therefore runs through `safeLabel()`, which turns
141
+ `/` and `\` into ` · `. `aria-label` and `title` keep the raw identifier, because
142
+ they are attributes and the guard only reads `textContent`.
143
+
144
+ ## Where the "current" model comes from
145
+
146
+ `modelCatalog().default` is the *deployment* default and can differ from what a
147
+ Session really runs (a Session-local override). Spreading it into a commit
148
+ silently switched the model while the user only meant to change the effort.
149
+
150
+ So the controller follows the Session's durable projection — the same source the
151
+ shipped selector reads:
152
+
153
+ ```js
154
+ sessions.binding(sessionId).session.projections.faceOf("modelSelection")
155
+ // -> ObservableSnapshot<{ lastUsed, next }>, next = pending ?? lastUsed
156
+ ```
157
+
158
+ `model/selection` carries the user's intent immediately (`pending`), and
159
+ `request/header` records what a request actually used (`lastUsed`). Two details
160
+ matter:
161
+
162
+ - The face is subscribed **before** the first `getSnapshot()`; reading a
163
+ projection nobody observes can return no value at all.
164
+ - The catalogue default is a fallback only, used until the projection has a value.
165
+
166
+ ## Verification
167
+
168
+ - `conversation.input.model` resolves to this plugin (`priority: -1`, active) and
169
+ the shipped registrant goes inactive.
170
+ - Trigger reads `选择模型与思考强度 — <model> · <effort>`; changing the level
171
+ propagates through `selectModel` into the projection and back into the label.
172
+ - The popup's model list exposes every routable model, grouped per provider.
173
+ - Switching `medium` (on a seven-rung model) to an `off/low/high/max` model sends
174
+ `low` and reports the substitution; switching to a no-reasoning model sends no
175
+ `reasoningEffort` at all; switching back restores `medium`.
176
+ - Checked in a real client at 360×780, 360×400 (short) and 1280×800: a real click
177
+ opens the popup, it stays inside the viewport, the level labels sit under the
178
+ thumb stops, the selected model is marked, and opening does not replay the
179
+ top-level burst.