dsh-model-flatmap 1.0.2 → 1.1.0
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/README.md +47 -12
- package/README.zh.md +33 -11
- package/client.js +1537 -102
- package/docs/DETAIL.md +201 -19
- package/package.json +1 -1
package/docs/DETAIL.md
CHANGED
|
@@ -47,16 +47,23 @@ and the agent teamwork switch are all visible at once.
|
|
|
47
47
|
`acme-7b` are one `Acme`), and dictionary families are never split by
|
|
48
48
|
generation. A provider prefix does not hide the brand (`codebuddy-hy3` →
|
|
49
49
|
`HY3`).
|
|
50
|
-
- **Family-grouped rows are provider-primary
|
|
51
|
-
models *are*, so the family name is not repeated in a
|
|
52
|
-
text is the provider name — the one thing a heading
|
|
53
|
-
family spans providers. The full model name and
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
50
|
+
- **Family-grouped rows are provider-primary, with a model tail.** A family
|
|
51
|
+
heading says what the models *are*, so the family name is not repeated in a
|
|
52
|
+
row. The row's primary text is the provider name — the one thing a heading
|
|
53
|
+
cannot tell you when a family spans providers. The full model name and
|
|
54
|
+
description stay in the row tooltip. What the heading *and* the provider
|
|
55
|
+
cannot say — which model of the family this row is, and the catalog's rate
|
|
56
|
+
multiplier — is drawn as a quieter tail at the END of the row. The tail is
|
|
57
|
+
the model name with the family heading and the provider's own label removed,
|
|
58
|
+
so `Qwen3.8-Flash-Next` under `Qwen` reads `3.8-Flash-Next`, `Qwen3.8-27B`
|
|
59
|
+
reads `3.8-27B`, `Deepseek-V4.1-Flash · x0.00` under `DeepSeek` reads
|
|
60
|
+
`V4.1-Flash · x0.00`, and `豆包 2.1 Turbo` under provider `豆包(doubao)` reads
|
|
61
|
+
`2.1 Turbo`. Version numbers are KEPT (they are the型号 the heading lacks),
|
|
62
|
+
and a multiplier inside the tail is drawn two sizes smaller and bold, exactly
|
|
63
|
+
as it is inside a model name under *By provider*. Two rows of one provider
|
|
64
|
+
that still read alike fall back to the unique model id; a model whose name
|
|
65
|
+
adds nothing draws no tail. Under *By provider* the heading already is the
|
|
66
|
+
provider, so the primary text stays the model name and no tail is drawn.
|
|
60
67
|
- **Adaptive columns.** The panel measures one group title and one model row,
|
|
61
68
|
subtracts the fixed sections from the 320 px cap, and picks the smallest
|
|
62
69
|
column count from 1 to 3 that fits. If even three columns overflow, it keeps
|
|
@@ -97,9 +104,10 @@ and the agent teamwork switch are all visible at once.
|
|
|
97
104
|
translucent white chip so it stays legible on the blue fill.
|
|
98
105
|
- **Out-of-balance models are struck through.** When a model call fails because
|
|
99
106
|
the account has no usable balance, credit, or quota left, that model's row
|
|
100
|
-
text is drawn with a `line-through` decoration
|
|
107
|
+
text is drawn with a `line-through` decoration. The strike stays until a
|
|
101
108
|
later call for the same model returns something other than an
|
|
102
|
-
insufficient-balance failure
|
|
109
|
+
insufficient-balance failure, **or** until it has gone 24 hours without a new
|
|
110
|
+
attempt — whichever comes first. The row is not recoloured, so the strike reads
|
|
103
111
|
the same on the selected row's blue band and in both themes, and it does not
|
|
104
112
|
compete with the error strip's red. Only the row's text (the name and a
|
|
105
113
|
family variant tag) is struck; the badge chip and the pending spinner are
|
|
@@ -137,6 +145,21 @@ and the agent teamwork switch are all visible at once.
|
|
|
137
145
|
that failed for a different reason. A cancellation (`aborted`), a `blocked`
|
|
138
146
|
turn, and the synthetic `interrupted` / `forked` closers clear nothing —
|
|
139
147
|
the model never answered, so they say nothing about the account.
|
|
148
|
+
- **How long it is drawn without one.** Every record keeps the wall-clock
|
|
149
|
+
time of the observation that made it, which is what separates "this model is
|
|
150
|
+
out of balance" from "this model has not been tried since that verdict". A
|
|
151
|
+
mark older than 24 hours is not drawn: an exhausted balance gets topped up
|
|
152
|
+
and a quota resets daily, so a strike nobody refreshed has stopped being
|
|
153
|
+
information. A retry restamps the record and restarts the window, so the
|
|
154
|
+
rule is "24 hours since the newest thing we learned about this route", not
|
|
155
|
+
"24 hours since the row was painted". Retiring a strike is the one transition
|
|
156
|
+
no event reports, so the store watches the clock itself: on every fold, when
|
|
157
|
+
a seat subscribes, when the panel is opened, and on a timer set for the
|
|
158
|
+
soonest deadline (that handle is `unref`'d, so a day-long wait never holds
|
|
159
|
+
the host process). The record SURVIVES its own expiry, as the watermark for
|
|
160
|
+
the route — which is why re-folding the very failure that made it, after a
|
|
161
|
+
reload or when a second Session's older history is walked, cannot put the
|
|
162
|
+
decoration back.
|
|
140
163
|
- **The marks are global, not per conversation.** Whether a model has quota
|
|
141
164
|
left is a fact about the model route and its account, so the state is NOT
|
|
142
165
|
scoped to the Session a panel happens to render for. One module-level store
|
|
@@ -147,8 +170,12 @@ and the agent teamwork switch are all visible at once.
|
|
|
147
170
|
- **It survives a reload.** The marks are persisted to `localStorage`
|
|
148
171
|
(origin-scoped, like the grouping choice), so a fresh page load starts from
|
|
149
172
|
the last known state instead of an empty one. Reads and writes degrade
|
|
150
|
-
silently when storage is unavailable
|
|
151
|
-
|
|
173
|
+
silently when storage is unavailable. Two lifetimes apply to that record and
|
|
174
|
+
they are not the same one: a mark stops being DRAWN after 24 hours without a
|
|
175
|
+
new observation, while the record itself is kept for 7 days, because it is
|
|
176
|
+
also the watermark that keeps an older replayed failure from restamping the
|
|
177
|
+
route. So a reload of a day-old verdict restores the row as plain text, and
|
|
178
|
+
the fold that re-reads the failure that produced it changes nothing.
|
|
152
179
|
- **The newest observation wins.** A Session feed carries no cross-Session
|
|
153
180
|
sequence number, but every durable event keeps the Host's wall-clock `time`.
|
|
154
181
|
Each route keeps the time of its last decision, and an observation older
|
|
@@ -168,6 +195,126 @@ and the agent teamwork switch are all visible at once.
|
|
|
168
195
|
the shared footer line described under *Reasoning levels*, immediately right
|
|
169
196
|
of the reasoning group when both fit on one line.
|
|
170
197
|
|
|
198
|
+
## The trigger
|
|
199
|
+
|
|
200
|
+
The trigger is the same chip the shipped picker draws (13/20 regular, 28 px,
|
|
201
|
+
rounded-sm), and it shows the same *content* — which is more than a model name.
|
|
202
|
+
|
|
203
|
+
**The name is a slot, not a string.** The shipped `ModelSelect` does not paint
|
|
204
|
+
`model.name` on its trigger: it renders `conversation.input.model.label` with
|
|
205
|
+
`placement: 'trigger'` and lets the winning contributor decorate the name. That
|
|
206
|
+
is where a connection plugin puts things the catalog does not carry: a promotion
|
|
207
|
+
chip (`夜间折扣`), a rate, a "new on this machine" marker, and — in the case of
|
|
208
|
+
`dsh-connect-workbuddy` — a load mark: one, two or three queued people, or a
|
|
209
|
+
rocket when nothing is waiting. A bare name therefore shows strictly less than
|
|
210
|
+
the native trigger, which is what this plugin used to do.
|
|
211
|
+
|
|
212
|
+
**This plugin renders that slot's winner.** It cannot *declare* the child slot
|
|
213
|
+
(the shipped entry already did, and a second declaration throws
|
|
214
|
+
`already declared`) — but declaring is not needed to read it. The declaration
|
|
215
|
+
outlives the shadowing entry, so `slots.entriesOfSlot('conversation.input.model.label')`
|
|
216
|
+
still names the winning entry, and its `component` can be rendered with the
|
|
217
|
+
seats the renderer would have supplied:
|
|
218
|
+
|
|
219
|
+
- `t` — bound from the entry's own `locale` namespace through `locale.bind(ns)`;
|
|
220
|
+
- the business face — `entry.inject(sessionId)`, the renderer's own contract for
|
|
221
|
+
a session-scoped entry;
|
|
222
|
+
- the owner props — `{ provider, model, placement: 'trigger', selected: true }`,
|
|
223
|
+
exactly the share the shipped picker passes.
|
|
224
|
+
|
|
225
|
+
**The occupant is third-party code, so it is contained.** It runs without the
|
|
226
|
+
renderer's per-entry error boundary, so a separate `LabelBoundary` wraps it. A
|
|
227
|
+
crash costs the decoration and never the seat: the boundary answers `undefined`
|
|
228
|
+
and the trigger falls through to the catalog name. The failure is logged once.
|
|
229
|
+
|
|
230
|
+
**No occupant still gets the catalog badge.** When the cell is empty — or the
|
|
231
|
+
occupant is absent, crashed, or on a Host without `entriesOfSlot` — the trigger
|
|
232
|
+
draws the shipped fallback: the model's own `model.badge` as a chip before the
|
|
233
|
+
name. This is reproduced rather than imported (the host's `Tag` lives in a
|
|
234
|
+
primitives package this plugin does not depend on), and it matters even with an
|
|
235
|
+
occupant installed: an occupant that does not reproduce a sibling's `model.badge`
|
|
236
|
+
would otherwise lose the promotion marker.
|
|
237
|
+
|
|
238
|
+
**Registrations that land late still appear.** The trigger subscribes to the
|
|
239
|
+
slot through `slots.subscribe(key, fn)` / `slots.getVersion(key)`, so a
|
|
240
|
+
connection plugin that registers a moment after boot gets its chip on the
|
|
241
|
+
trigger without a page reload. A Host without those accessors keeps the first
|
|
242
|
+
read and never notifies.
|
|
243
|
+
|
|
244
|
+
Because native mode (below) hands the seat to the Host, which renders the same
|
|
245
|
+
slot, the trigger is now *one* implementation in both modes — the consistency
|
|
246
|
+
this feature exists for.
|
|
247
|
+
|
|
248
|
+
## Native mode
|
|
249
|
+
|
|
250
|
+
The footer's trailing `默认样式` / `Default style` button hands the composer's model seat
|
|
251
|
+
back to whatever the Host ships in `conversation.input.model`, and back again.
|
|
252
|
+
|
|
253
|
+
**How the switch works.** The plugin does NOT render the host's control — it
|
|
254
|
+
stops shadowing it. This entry registers at a negative priority, so it wins the
|
|
255
|
+
`single` slot; a `single` slot keeps every registration and renders only the
|
|
256
|
+
highest-priority live one. Native mode therefore `dispose()`s this plugin's
|
|
257
|
+
entry, which elects the shipped one underneath: the host mounts its own
|
|
258
|
+
`ModelSelect`, whatever that is in the installed version. Nothing is imported,
|
|
259
|
+
copied, or re-mounted, so an upgraded DSH shows its own newest picker and this
|
|
260
|
+
mode structurally cannot drift out of date.
|
|
261
|
+
|
|
262
|
+
The alternative — pulling the shipped component into our own tree — is not
|
|
263
|
+
available: the `model-selection` package exports no component, and every
|
|
264
|
+
`renderSlot` child (`conversation.input.model.label` / `.options`) is bound to
|
|
265
|
+
the entry that declares it, so another entry calling it throws
|
|
266
|
+
`StaleAuthorizationError` / `SlotOwnershipError`. See DEC-027.
|
|
267
|
+
|
|
268
|
+
**Stepping aside is verified, never assumed.** Before disposing, the plugin asks
|
|
269
|
+
`slots.entries('conversation.input.model')` for the registrations other than its
|
|
270
|
+
own. If there is none — the shipped package disabled, uninstalled, or a Host too
|
|
271
|
+
old to enumerate entries — the panel stays mounted and the button reads disabled
|
|
272
|
+
with a tooltip explaining why. A persisted `native` mode from a previous install
|
|
273
|
+
therefore lands on the flat panel instead of an empty composer.
|
|
274
|
+
|
|
275
|
+
**The way back.** Native mode disposes the entry that owns the footer button, so
|
|
276
|
+
the button cannot switch back. The return trip instead rides the HOST picker's
|
|
277
|
+
own panel: `conversation.input.model.options` is the list child slot the shipped
|
|
278
|
+
`ModelSelect` declares and renders directly after its Model / Effort rows, so our
|
|
279
|
+
control appears at the bottom of the native menu itself. It belongs to the
|
|
280
|
+
shipped entry, not to ours, so it outlives the disposal; and it renders `null` in
|
|
281
|
+
flat mode, so the shipped menu looks exactly as shipped while the panel is on
|
|
282
|
+
screen. Clicking it dismisses the host menu first (the entry that owns the menu
|
|
283
|
+
is about to be disposed) and then flips the mode back.
|
|
284
|
+
|
|
285
|
+
**It does not look like a menu row.** Every row above it answers "which value?";
|
|
286
|
+
this one performs an action. So it is drawn as a separated bottom band — a 1px
|
|
287
|
+
`--dsw-alias-border-l2` rule above, then a full-width 30 px outlined button
|
|
288
|
+
(`--dsw-alias-border-l3`, `--dsw-radius-sm`, 12/18 medium label, 14 px icon) with
|
|
289
|
+
a hover fill and a focus ring. The slot gives occupants only a `display: contents`
|
|
290
|
+
anchor, so that whole box is ours; nothing about the host menu's own rows has to
|
|
291
|
+
be imitated. The distinction is pinned by a test that asserts the button carries
|
|
292
|
+
no `role="menuitem"`.
|
|
293
|
+
|
|
294
|
+
Nothing here is a copy of the host control: the row is one entry in a slot the
|
|
295
|
+
installed package publicly declares. The former placement — a chip in
|
|
296
|
+
`conversation.input.right`, beside the native
|
|
297
|
+
trigger — was dropped because it read as a toolbar control belonging to nothing
|
|
298
|
+
in particular, and because it forced the mode control to sit outside the panel
|
|
299
|
+
it acts on.
|
|
300
|
+
|
|
301
|
+
**One shared store.** Both controls read one page-wide mode store, so a flip made
|
|
302
|
+
from either is what the other sees. The store persists the choice to
|
|
303
|
+
`localStorage` (`dshmf:mode`, the same origin-scoped mechanism as the grouping
|
|
304
|
+
choice) and degrades to `flat` whenever storage is unavailable — a missing store
|
|
305
|
+
must never leave the composer without a model control.
|
|
306
|
+
|
|
307
|
+
**Reported state is the visible state.** The panel only renders while it owns the
|
|
308
|
+
cell, so the stored mode and the on-screen mode can disagree in exactly one case:
|
|
309
|
+
`native` persisted with nothing to inherit the cell. The button's `aria-pressed`
|
|
310
|
+
then reports flat, because it describes what is on screen, not what is in
|
|
311
|
+
storage.
|
|
312
|
+
|
|
313
|
+
**What changes across a switch.** Coming back from native mode remounts the
|
|
314
|
+
panel, so it opens closed with an empty query. Focus follows the disposed
|
|
315
|
+
button down to the document; cross-plugin DOM queries for focus are not allowed,
|
|
316
|
+
so it is not chased.
|
|
317
|
+
|
|
171
318
|
## Data and interaction
|
|
172
319
|
|
|
173
320
|
Everything goes through public Client APIs:
|
|
@@ -182,10 +329,20 @@ Everything goes through public Client APIs:
|
|
|
182
329
|
Session the Session list names (`ctx.sessions.list`) to derive the global
|
|
183
330
|
per-model insufficient-balance marks, persists them to `localStorage`, and
|
|
184
331
|
every seat subscribes through `useSyncExternalStore` so a mark transition
|
|
185
|
-
repaints the panel while every other event stays quiet.
|
|
332
|
+
repaints the panel while every other event stays quiet. The same store also
|
|
333
|
+
re-reads the clock on subscribe, on panel open and on a self-armed timer,
|
|
334
|
+
because a strike reaching its 24-hour age is a transition no feed will ever
|
|
335
|
+
report. A snapshot is replaced only when the set of drawn marks actually
|
|
336
|
+
changed, so those extra reads cannot churn the panel.
|
|
186
337
|
- `ctx.get('remote').agentTeams.setEnabled()` writes the teamwork selection.
|
|
187
338
|
- `ctx.locale` supplies the copy, resolves catalog badge text, and prefers the
|
|
188
339
|
host's own `agent-team` wording when that namespace is registered.
|
|
340
|
+
- `slots.register()` / its disposer and `slots.entries()` / `slots.subscribe()`
|
|
341
|
+
drive native mode: disposing this plugin's `conversation.input.model` entry
|
|
342
|
+
elects whatever the Host registered underneath, and `entries()` is what makes
|
|
343
|
+
that verifiable before the switch. `slots.subscribe()` re-runs the reconcile,
|
|
344
|
+
so a shipped picker that appears (or disappears) later enables or disables the
|
|
345
|
+
button without a reload.
|
|
189
346
|
|
|
190
347
|
No host component, stylesheet, or DOM is read or restyled.
|
|
191
348
|
|
|
@@ -231,13 +388,38 @@ The band also carries a low-key frame: a hairline border
|
|
|
231
388
|
group start clearly without boxing the group's rows — a group's rows may flow
|
|
232
389
|
across columns, so only its label is framed (see DEC-003).
|
|
233
390
|
|
|
391
|
+
### Long text: one-pass hover marquee
|
|
392
|
+
|
|
393
|
+
A model name, or a group heading, that does not fit its column is ellipsised.
|
|
394
|
+
Resting the pointer on it for 0.5 s starts a single, eased, leftward pass that
|
|
395
|
+
stops when the last glyph reaches the window's right edge; leaving resets it
|
|
396
|
+
immediately with no animation. The pass runs ONCE, is `ease-in-out` rather than
|
|
397
|
+
`linear`, and honours `prefers-reduced-motion: reduce` by not running at all.
|
|
398
|
+
|
|
399
|
+
Both a model row and a group title use the same structure, so only the text
|
|
400
|
+
slides and the frame never does: a clipped **window** (`overflow: hidden` +
|
|
401
|
+
`text-overflow: ellipsis`) wraps a **track** (`width: max-content`) that is
|
|
402
|
+
allowed to be wider than the window. For a row the frame is the row itself, so
|
|
403
|
+
the right-aligned tail, badge (`限时免费`, `x0.1`) and spinner never move; for a
|
|
404
|
+
title the frame is the band, so its fill, frame, radius and elevation stay put.
|
|
405
|
+
One state machine drives both — keyed by the row's route or the title's block
|
|
406
|
+
key — so only one thing in the panel scrolls at a time. The travel distance is
|
|
407
|
+
the measured overflow, and the duration is that distance divided by 50 px/s
|
|
408
|
+
(see DEC-021, DEC-022, DEC-023).
|
|
409
|
+
|
|
234
410
|
## Limitations
|
|
235
411
|
|
|
236
412
|
- The panel replaces the composer seat only; the `/model` popup is untouched.
|
|
237
|
-
- The panel does not host the `conversation.input.model.label`
|
|
238
|
-
`
|
|
239
|
-
|
|
240
|
-
|
|
413
|
+
- The panel's GRID does not host the `conversation.input.model.label` child
|
|
414
|
+
slot: rows draw the catalog name plus `model.badge`. Only the TRIGGER renders
|
|
415
|
+
that slot's winner, because that is what the native trigger does. The
|
|
416
|
+
`conversation.input.model.options` slot is not drawn anywhere in the panel.
|
|
417
|
+
The shipped registration keeps both declarations alive, so other plugins'
|
|
418
|
+
contributions stay valid either way.
|
|
419
|
+
- The trigger's label cell renders ANOTHER PLUGIN'S component. It is written
|
|
420
|
+
against the documented `ModelLabelOwner` props, but nothing guarantees every
|
|
421
|
+
future contributor stays compatible; `LabelBoundary` degrades a crash to the
|
|
422
|
+
catalog name, which is the same fallback the host uses for an empty cell.
|
|
241
423
|
- Addressed subagent sessions hide the seat, matching the shipped selector.
|
|
242
424
|
- The badge comes from the catalog entry, so a provider that publishes no badge
|
|
243
425
|
shows none.
|