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/client.js
CHANGED
|
@@ -54,6 +54,31 @@ window.__ModuleLoader__.load({
|
|
|
54
54
|
.dshmf-trigger:disabled { color: var(--dsw-alias-label-dimmed); cursor: default; }
|
|
55
55
|
.dshmf-triggerIcon { display: var(--dsh-composer-model-icon-display, none); flex: 0 0 auto; }
|
|
56
56
|
.dshmf-triggerText { display: var(--dsh-composer-model-text-display, block); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
57
|
+
/* The trigger's label cell, whatever fills it: the shipped picker's own label
|
|
58
|
+
slot (a provider's chip, its rate, its load mark) or this plugin's fallback.
|
|
59
|
+
An inline-flex box keeps a chip on the text baseline instead of riding high,
|
|
60
|
+
and a full-width cap lets the occupant ellipsise inside the trigger rather
|
|
61
|
+
than widening it. */
|
|
62
|
+
.dshmf-triggerLabel { display: inline-flex; align-items: center; gap: 4px; min-width: 0; max-width: 100%; vertical-align: baseline; }
|
|
63
|
+
/* Fallback chip, matching the shipped Tag's geometry (11/17, 1px 8px, 999px)
|
|
64
|
+
so a model the catalog badges looks the same whether a label occupant drew
|
|
65
|
+
it or this plugin did. */
|
|
66
|
+
.dshmf-triggerBadge {
|
|
67
|
+
flex: none; display: inline-flex; align-items: center; padding: 1px 8px;
|
|
68
|
+
border-radius: 999px; corner-shape: round;
|
|
69
|
+
font-size: 11px; line-height: 17px; font-weight: 500; white-space: nowrap;
|
|
70
|
+
background: var(--dsw-alias-bg-module-platform); color: var(--dsw-alias-label-secondary);
|
|
71
|
+
}
|
|
72
|
+
.dshmf-triggerBadge[data-tone="info"] { background: color-mix(in srgb, var(--dsw-alias-state-business-primary) 10%, transparent); color: var(--dsw-alias-state-business-primary); }
|
|
73
|
+
.dshmf-triggerBadge[data-tone="success"] { background: color-mix(in srgb, var(--dsw-alias-state-success-primary) 10%, transparent); color: var(--dsw-alias-state-success-primary); }
|
|
74
|
+
.dshmf-triggerBadge[data-tone="warning"] { background: color-mix(in srgb, var(--dsw-alias-state-warn-primary) 12%, transparent); color: var(--dsw-alias-state-warn-primary); }
|
|
75
|
+
.dshmf-triggerBadge[data-tone="danger"] { background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent); color: var(--dsw-alias-state-error-primary); }
|
|
76
|
+
.dshmf-triggerBadge[data-tone="solid"] { background: var(--dsw-alias-label-primary); color: var(--dsw-alias-bg-layer-3); }
|
|
77
|
+
.dshmf-triggerBadge[data-tone="outline"] { border: 0.5px solid var(--dsw-alias-border-l4); color: var(--dsw-alias-label-tertiary); }
|
|
78
|
+
.dshmf-triggerBadge[data-tone="quiet"] { background: transparent; color: var(--dsw-alias-label-tertiary); }
|
|
79
|
+
/* A model name inside the label cell: ellipsised like the shipped trigger's,
|
|
80
|
+
so a long name truncates instead of pushing the chevron out. */
|
|
81
|
+
.dshmf-triggerName { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
57
82
|
.dshmf-triggerEffort { flex-shrink: 1000; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--dsw-alias-label-caption); }
|
|
58
83
|
.dshmf-chevron { flex: 0 0 auto; color: var(--dsw-alias-label-caption); transition: transform 120ms ease; }
|
|
59
84
|
.dshmf-chevronOpen { transform: rotate(180deg); }
|
|
@@ -169,9 +194,10 @@ html[data-ds-dark-theme] .dshmf-panel { border-color: #6B7280; }
|
|
|
169
194
|
stay borderless, so the dense flow across columns is kept (see DEC-003,
|
|
170
195
|
DEC-005). */
|
|
171
196
|
.dshmf-groupTitle {
|
|
172
|
-
align-self: flex-start; max-width: 100%;
|
|
197
|
+
align-self: flex-start; max-width: 100%; min-width: 0;
|
|
173
198
|
box-sizing: border-box; height: 20px; padding: 0 8px 0 6px;
|
|
174
|
-
|
|
199
|
+
display: flex; align-items: center;
|
|
200
|
+
overflow: hidden; white-space: nowrap;
|
|
175
201
|
border: 1px solid var(--dsw-alias-border-l2);
|
|
176
202
|
border-left: 3px solid var(--dsw-alias-border-l3);
|
|
177
203
|
border-radius: var(--dsw-radius-xs);
|
|
@@ -180,6 +206,20 @@ html[data-ds-dark-theme] .dshmf-panel { border-color: #6B7280; }
|
|
|
180
206
|
color: var(--dsw-alias-label-primary);
|
|
181
207
|
font-size: 10px; line-height: 18px; font-weight: 700; letter-spacing: .02em;
|
|
182
208
|
}
|
|
209
|
+
/* The label gets the SAME window/track split as a model row (DEC-021): the
|
|
210
|
+
band is the frame, and the text inside it is a clipped window holding a
|
|
211
|
+
track that can be wider than the window. That is what lets a long heading
|
|
212
|
+
("ModelScope Token Plan") run the same one-pass hover marquee as a long
|
|
213
|
+
model name, while the band's frame, fill and shadow stay put (DEC-023). */
|
|
214
|
+
.dshmf-groupTitleWindow {
|
|
215
|
+
flex: 1 1 auto; min-width: 0; display: block;
|
|
216
|
+
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
217
|
+
}
|
|
218
|
+
.dshmf-groupTitleTrack { width: max-content; max-width: none; white-space: nowrap; }
|
|
219
|
+
.dshmf-groupTitleTrack[data-running="true"] {
|
|
220
|
+
display: inline-block;
|
|
221
|
+
animation: dshmf-rowNameMarquee var(--dshmf-marquee-duration, 3000ms) ease-in-out 1 forwards;
|
|
222
|
+
}
|
|
183
223
|
.dshmf-row {
|
|
184
224
|
box-sizing: border-box; display: flex; align-items: center; gap: 6px; width: 100%;
|
|
185
225
|
height: 26px; padding: 0 6px; border: 0; border-radius: var(--dsw-radius-sm);
|
|
@@ -209,9 +249,42 @@ html[data-ds-dark-theme] .dshmf-panel { border-color: #6B7280; }
|
|
|
209
249
|
background: color-mix(in srgb, var(--dsw-static-neutral-bluish-00) 22%, transparent);
|
|
210
250
|
color: inherit;
|
|
211
251
|
}
|
|
212
|
-
.dshmf-
|
|
252
|
+
.dshmf-rowNameWindow {
|
|
253
|
+
flex: 1 1 auto; min-width: 0; display: block;
|
|
254
|
+
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
255
|
+
}
|
|
256
|
+
/* The name itself never ellipsises on its own: the window clips it, and the
|
|
257
|
+
track is what slides when the pointer rests on a truncated row (DEC-021).
|
|
258
|
+
width: max-content is what lets the track be wider than the window, which is
|
|
259
|
+
both the overflow signal (scrollWidth) and what the marquee reveals. */
|
|
260
|
+
.dshmf-rowName {
|
|
261
|
+
width: max-content; max-width: none;
|
|
262
|
+
white-space: nowrap;
|
|
263
|
+
}
|
|
264
|
+
/* Resting the pointer on a row whose name does not fit starts ONE marquee
|
|
265
|
+
pass: the name glides left until its last glyph reaches the window, then
|
|
266
|
+
STOPS there. The easing is ease-in-out, so the start and the arrival are
|
|
267
|
+
soft rather than a constant crawl. Only the name TRACK moves — the window
|
|
268
|
+
clips it, so the right-aligned badge (限时免费, ×0.1) and the pending
|
|
269
|
+
spinner, which sit as siblings AFTER the window, never shift. There is no
|
|
270
|
+
return trip and no iteration: fill-mode forwards holds the end state, and
|
|
271
|
+
leaving the row drops the animation entirely, which snaps back with no
|
|
272
|
+
transition. */
|
|
273
|
+
@keyframes dshmf-rowNameMarquee {
|
|
274
|
+
from { transform: translateX(0); }
|
|
275
|
+
to { transform: translateX(var(--dshmf-marquee-shift)); }
|
|
276
|
+
}
|
|
277
|
+
.dshmf-rowNameTrack[data-running="true"] {
|
|
278
|
+
display: inline-block;
|
|
279
|
+
animation: dshmf-rowNameMarquee var(--dshmf-marquee-duration, 3000ms) ease-in-out 1 forwards;
|
|
280
|
+
}
|
|
281
|
+
@media (prefers-reduced-motion: reduce) {
|
|
282
|
+
.dshmf-rowNameTrack[data-running="true"],
|
|
283
|
+
.dshmf-groupTitleTrack[data-running="true"] { animation: none; }
|
|
284
|
+
}
|
|
213
285
|
/* The model's last call failed on insufficient balance / credit / quota, and no
|
|
214
|
-
later call for the same model has cleared it
|
|
286
|
+
later call for the same model has cleared it, nor has the mark aged past
|
|
287
|
+
STRIKE_TTL (24h) without a fresh attempt. The row text is struck through
|
|
215
288
|
rather than recoloured: the strike survives both themes and the selected
|
|
216
289
|
row's blue band, and it does not compete with the error strip's own red.
|
|
217
290
|
The decoration is applied to the row's text only (name + variant tag), not
|
|
@@ -227,17 +300,25 @@ html[data-ds-dark-theme] .dshmf-panel { border-color: #6B7280; }
|
|
|
227
300
|
multiplier reads as an aside without disturbing the row's text rhythm
|
|
228
301
|
(see renderRow's nameRuns). */
|
|
229
302
|
.dshmf-rowNameMark { font-size: 11px; line-height: 18px; font-weight: 700; letter-spacing: normal; }
|
|
230
|
-
/*
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
303
|
+
/* The model-specific tail of a family-grouped row, e.g. "3.8-Flash-Next",
|
|
304
|
+
"V4.1-Flash · x0.01", "2.1 Turbo". The primary text is the provider and the
|
|
305
|
+
heading names the family, so this is what is LEFT after both are removed —
|
|
306
|
+
the型号 and the multiplier the heading cannot carry (DEC-023). It sits at
|
|
307
|
+
the END of the row and is quieter than the primary text (caption colour,
|
|
308
|
+
11px), shrinks before it, and never wraps. No width cap: a cap truncates a
|
|
309
|
+
perfectly readable label ("CodeBuddy …") in a column that had room for it. */
|
|
235
310
|
.dshmf-rowProvider {
|
|
236
311
|
flex: 0 1 auto; min-width: 0;
|
|
237
312
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
238
313
|
color: var(--dsw-alias-label-caption);
|
|
239
314
|
font-size: 11px; line-height: 16px; font-weight: 400;
|
|
240
315
|
}
|
|
316
|
+
/* A multiplier inside that tail ("x0.01") keeps the same "smaller and bolder"
|
|
317
|
+
treatment it gets inside a model name in *By provider* (see
|
|
318
|
+
.dshmf-rowNameMark and renderRow's nameRuns), so the rate reads identically
|
|
319
|
+
in both grouping modes. The base rule already makes it bold and 11px; inside
|
|
320
|
+
the 11px tail it steps down once more, to 10px, to stay visibly apart. */
|
|
321
|
+
.dshmf-rowProvider .dshmf-rowNameMark { font-size: 10px; line-height: 16px; }
|
|
241
322
|
.dshmf-rowTail { display: grid; place-items: center; flex: 0 0 14px; color: inherit; }
|
|
242
323
|
|
|
243
324
|
/* Catalog badge (for example a third-party rate multiplier). Rendered only
|
|
@@ -278,6 +359,66 @@ html[data-ds-dark-theme] .dshmf-panel { border-color: #6B7280; }
|
|
|
278
359
|
body[data-ds-dark-theme] .dshmf-footer,
|
|
279
360
|
html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
|
|
280
361
|
|
|
362
|
+
/* Display-mode button: the footer's trailing member, pinned to the panel's
|
|
363
|
+
bottom-right by an auto left margin. Drawn with the panel's own segment
|
|
364
|
+
recipe (toned track, 2px inset, radius-xs) so it reads as the same design
|
|
365
|
+
system as the header grouping control and the reasoning levels, and so the
|
|
366
|
+
pressed state is unmistakable in both themes. The auto margin also survives
|
|
367
|
+
the wrap case: alone on its line it still sits right. */
|
|
368
|
+
.dshmf-modeBtn {
|
|
369
|
+
flex: 0 0 auto; margin-left: auto;
|
|
370
|
+
height: 22px; padding: 0 8px; border: 0; border-radius: var(--dsw-radius-xs);
|
|
371
|
+
background: var(--dsw-alias-interactive-bg-hover);
|
|
372
|
+
color: var(--dsw-alias-label-secondary);
|
|
373
|
+
font-family: inherit; font-size: 11px; line-height: 16px; font-weight: 400;
|
|
374
|
+
white-space: nowrap; cursor: pointer;
|
|
375
|
+
transition: color 120ms ease, background 120ms ease;
|
|
376
|
+
}
|
|
377
|
+
@media (hover: hover) { .dshmf-modeBtn:hover:not(:disabled):not([aria-pressed="true"]) { color: var(--dsw-alias-label-primary); } }
|
|
378
|
+
.dshmf-modeBtn[aria-pressed="true"] {
|
|
379
|
+
background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-primary);
|
|
380
|
+
box-shadow: var(--dsw-elevation-soft); font-weight: 500;
|
|
381
|
+
}
|
|
382
|
+
.dshmf-modeBtn:disabled { color: var(--dsw-alias-label-dimmed); cursor: default; }
|
|
383
|
+
.dshmf-modeBtn:focus-visible { outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); outline-offset: -2px; }
|
|
384
|
+
|
|
385
|
+
/* The bottom action that brings the panel back from native mode. It occupies
|
|
386
|
+
the HOST picker's own root-menu slot (conversation.input.model.options), which
|
|
387
|
+
renders its occupants after the Model / Effort rows. The slot hands us a bare
|
|
388
|
+
display: contents anchor, so the box below is entirely ours to draw: it is
|
|
389
|
+
deliberately NOT a menu-row clone. A row in this menu means "one of a set of
|
|
390
|
+
values, pick one" — this control does something else, so it separates itself
|
|
391
|
+
with a rule above and presents as a compact, out-lined push button.
|
|
392
|
+
The band spans the FULL row width (the menu already pads 4px, and a row's
|
|
393
|
+
hover fill spans that same width), so it carries no horizontal padding of its
|
|
394
|
+
own: the button's edges line up with the rows above it instead of sitting a
|
|
395
|
+
few pixels inside them. */
|
|
396
|
+
.dshmf-modeAction {
|
|
397
|
+
box-sizing: border-box; display: flex; align-items: center;
|
|
398
|
+
width: auto; min-width: 100%; margin-top: 2px; padding: 7px 0 1px;
|
|
399
|
+
border-top: 1px solid var(--dsw-alias-border-l2);
|
|
400
|
+
}
|
|
401
|
+
.dshmf-modeBtnRow {
|
|
402
|
+
box-sizing: border-box; display: flex; align-items: center; justify-content: center;
|
|
403
|
+
gap: 6px; width: 100%; height: 30px; padding: 0 10px;
|
|
404
|
+
border: 1px solid var(--dsw-alias-border-l3); border-radius: var(--dsw-radius-sm);
|
|
405
|
+
outline: none; background: transparent; color: var(--dsw-alias-label-primary);
|
|
406
|
+
font-family: inherit; font-size: 12px; line-height: 18px; font-weight: 500;
|
|
407
|
+
white-space: nowrap; cursor: pointer;
|
|
408
|
+
transition: background 120ms ease, border-color 120ms ease;
|
|
409
|
+
}
|
|
410
|
+
.dshmf-modeBtnRowIcon { flex: 0 0 auto; color: var(--dsw-alias-label-secondary); }
|
|
411
|
+
.dshmf-modeBtnRowLabel { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
|
412
|
+
.dshmf-modeBtnRow:focus-visible {
|
|
413
|
+
box-shadow: 0 0 0 2px var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
|
414
|
+
}
|
|
415
|
+
@media (hover: hover) {
|
|
416
|
+
.dshmf-modeBtnRow:hover {
|
|
417
|
+
background: var(--dsw-alias-interactive-bg-hover);
|
|
418
|
+
border-color: var(--dsw-alias-border-l4);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
281
422
|
/* Reasoning levels: a caption and a multi-segmentation control on one line;
|
|
282
423
|
the track wraps below the caption only when the row cannot fit. The
|
|
283
424
|
segment visual mirrors the header's grouping control, so the panel has
|
|
@@ -383,11 +524,64 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
|
|
|
383
524
|
const ROW_GAP = 2
|
|
384
525
|
const BALANCE_THRESHOLD = 24
|
|
385
526
|
|
|
527
|
+
/**
|
|
528
|
+
* The band a re-measurement must leave before it counts as a real layout
|
|
529
|
+
* change (DEC-026). Two physical pixels: smaller than anything the column
|
|
530
|
+
* plan acts on (its smallest unit is a 26px row), larger than the
|
|
531
|
+
* scrollbar / flex-wrap jitter that would otherwise re-plan the panel from
|
|
532
|
+
* its own output.
|
|
533
|
+
*/
|
|
534
|
+
const METRIC_HYSTERESIS = 2
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* The height quantum a measurement is snapped to (DEC-026).
|
|
538
|
+
*
|
|
539
|
+
* `getBoundingClientRect().height` and `offsetHeight` are physical pixels:
|
|
540
|
+
* a web font swapping in, a scrollbar appearing, or a flex container
|
|
541
|
+
* re-rounding can move them by a fraction of a pixel between two frames
|
|
542
|
+
* that render the same visual layout. Because the measured value feeds back
|
|
543
|
+
* into the panel's own column plan, taking such noise literally makes the
|
|
544
|
+
* panel oscillate. Half a pixel is below anything the layout acts on
|
|
545
|
+
* (the smallest thing it decides is a 26px row), so snapping both the
|
|
546
|
+
* comparison and the stored value to it absorbs the noise while keeping
|
|
547
|
+
* every real change — a wrapped footer, a shown error strip.
|
|
548
|
+
* @param {number} value - a raw measured height in px.
|
|
549
|
+
* @returns {number} the height snapped to the nearest half pixel.
|
|
550
|
+
*/
|
|
551
|
+
const quantizeMetric = value => {
|
|
552
|
+
const numeric = Number(value)
|
|
553
|
+
if (!Number.isFinite(numeric) || numeric <= 0) return 0
|
|
554
|
+
return Math.round(numeric * 2) / 2
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Is a new measurement meaningfully different from the stored one (DEC-026)?
|
|
559
|
+
*
|
|
560
|
+
* Measurement feeds the panel's own column plan, and the plan feeds the
|
|
561
|
+
* panel's layout, so this comparison is the loop's control point: it must
|
|
562
|
+
* answer "no" for anything that is not a real change. A 2px band absorbs
|
|
563
|
+
* the one thing quantization cannot — the footer's CSS flex wrapping
|
|
564
|
+
* flipping back and forth across a scrollbar boundary, which moves the
|
|
565
|
+
* measured block by a couple of pixels in either direction but draws the
|
|
566
|
+
* same panel. Real transitions (a footer wrapping at all, an error strip
|
|
567
|
+
* appearing) move the value far outside the band.
|
|
568
|
+
* @param {number} previous - the stored measurement.
|
|
569
|
+
* @param {number} next - the freshly measured one.
|
|
570
|
+
* @returns {boolean} true when the difference is worth a re-plan.
|
|
571
|
+
*/
|
|
572
|
+
const metricChanged = (previous, next) => {
|
|
573
|
+
if (previous === next) return false
|
|
574
|
+
return Math.abs(next - previous) >= METRIC_HYSTERESIS
|
|
575
|
+
}
|
|
576
|
+
|
|
386
577
|
const PATH_CHEVRON = 'M3.2 5.6 7 9.4 10.8 5.6'
|
|
387
578
|
const PATH_CLOSE = 'M4 4 10 10M10 4 4 10'
|
|
388
579
|
const PATH_INFO = 'M7 6.4v4.1M7 3.7v.8'
|
|
389
580
|
const PATH_SPARK = 'M7 1.6 8.3 5.7 12.4 7 8.3 8.3 7 12.4 5.7 8.3 1.6 7 5.7 5.7 7 1.6Z'
|
|
390
581
|
const PATH_MODELS = 'M7 2.1 11.9 4.7 7 7.3 2.1 4.7 7 2.1Zm-4.9 5.1L7 9.8l4.9-2.6M2.1 10.3 7 12.9l4.9-2.6'
|
|
582
|
+
/* Four tiles: the flat panel this control returns to, so the icon names the
|
|
583
|
+
DESTINATION rather than repeating the model glyph above it. */
|
|
584
|
+
const PATH_GRID = 'M2.4 2.4h3.6v3.6H2.4zM8 2.4h3.6v3.6H8zM2.4 8h3.6v3.6H2.4zM8 8h3.6v3.6H8z'
|
|
391
585
|
|
|
392
586
|
function svg(path, size, extra) {
|
|
393
587
|
return h('svg', {
|
|
@@ -408,6 +602,26 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
|
|
|
408
602
|
return template.replace(/\{(\w+)\}/g, (_, name) => String(values[name] ?? ''))
|
|
409
603
|
}
|
|
410
604
|
|
|
605
|
+
/**
|
|
606
|
+
* A provider's display label, resolved from the catalog's own name.
|
|
607
|
+
*
|
|
608
|
+
* Extracted so the trigger's label seat and the panel's group headings
|
|
609
|
+
* cannot drift: both hand the label slot the same `provider.name`, and a
|
|
610
|
+
* heading that said "豆包(doubao)" while the trigger said "Doubao" would be
|
|
611
|
+
* two spellings of one provider on one screen.
|
|
612
|
+
* @param {{id: string, name: unknown}} group - one provider entry.
|
|
613
|
+
* @param {Function} t - the panel's own translate seat.
|
|
614
|
+
* @param {Function|undefined} resolveText - the host's catalog-text resolver.
|
|
615
|
+
* @returns {string} a display-ready label; never empty.
|
|
616
|
+
*/
|
|
617
|
+
function providerLabel(group, t, resolveText) {
|
|
618
|
+
if (group.id === 'deepseek-account') return t('provider.account')
|
|
619
|
+
const resolved = resolveText === undefined ? undefined : resolveText(group.name)
|
|
620
|
+
if (typeof resolved === 'string' && resolved.trim() !== '') return resolved
|
|
621
|
+
const fallback = fallbackText(group.name)
|
|
622
|
+
return fallback.trim() === '' ? group.id : fallback
|
|
623
|
+
}
|
|
624
|
+
|
|
411
625
|
/**
|
|
412
626
|
* Split a raw query into lowercase search tokens. Whitespace separates
|
|
413
627
|
* tokens, so `mod inte` asks for `mod` AND `inte` rather than for that
|
|
@@ -493,6 +707,15 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
|
|
|
493
707
|
// The wording pass is deliberately narrow: a bare "402"/"429" only counts
|
|
494
708
|
// when it reads as the leading status, never as a number inside another
|
|
495
709
|
// message, and every example below is taken from a real recorded session.
|
|
710
|
+
//
|
|
711
|
+
// A strike also EXPIRES. Every record keeps the wall-clock moment the
|
|
712
|
+
// observation was made, which is what lets the panel tell "this model is out
|
|
713
|
+
// of balance" apart from "this model has not been tried since that verdict".
|
|
714
|
+
// Quotas reset daily and balances get topped up, so a strike that nobody
|
|
715
|
+
// refreshed stops being drawn after STRIKE_TTL; a retry that fails again
|
|
716
|
+
// re-stamps the record and restarts the window, and a retry that answers
|
|
717
|
+
// clears it outright. The window is therefore always "24h since the newest
|
|
718
|
+
// thing we learned about this route", not "24h since the row was drawn".
|
|
496
719
|
// ---------------------------------------------------------------------
|
|
497
720
|
|
|
498
721
|
/** Failure codes that mean "this account has no usable balance left". */
|
|
@@ -593,13 +816,18 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
|
|
|
593
816
|
* Wall-clock time one durable event was recorded at. The Client feed keeps
|
|
594
817
|
* the Host's `time` (`Date.now()` at append), which is what lets marks
|
|
595
818
|
* gathered from DIFFERENT Sessions be ordered against each other — a Session
|
|
596
|
-
* feed has no cross-Session sequence number
|
|
819
|
+
* feed has no cross-Session sequence number — and it is the same clock the
|
|
820
|
+
* strike lifetime is measured against.
|
|
821
|
+
*
|
|
822
|
+
* An event with no stamp is an observation made NOW, not one made at the
|
|
823
|
+
* epoch: folding it as `0` would file a fact we just learned as 56 years old
|
|
824
|
+
* and a strike could never be drawn for it.
|
|
597
825
|
* @param {unknown} event - one durable Session event.
|
|
598
|
-
* @returns {number} the event time, or
|
|
826
|
+
* @returns {number} the event time, or the current time when the feed carried none.
|
|
599
827
|
*/
|
|
600
828
|
function eventTimeOf(event) {
|
|
601
829
|
const time = event?.time
|
|
602
|
-
return typeof time === 'number' && Number.isFinite(time) ? time :
|
|
830
|
+
return typeof time === 'number' && Number.isFinite(time) ? time : Date.now()
|
|
603
831
|
}
|
|
604
832
|
|
|
605
833
|
/**
|
|
@@ -922,22 +1150,86 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
|
|
|
922
1150
|
}
|
|
923
1151
|
|
|
924
1152
|
/**
|
|
925
|
-
*
|
|
926
|
-
*
|
|
927
|
-
|
|
928
|
-
|
|
1153
|
+
* Separators a strip leaves dangling at either end of a tail. `·` is the
|
|
1154
|
+
* catalog's own "name · multiplier" separator (`Hy4 · x0.00`).
|
|
1155
|
+
*/
|
|
1156
|
+
const TAIL_EDGE = /^[\s·・.,:;|/\\_-]+|[\s·・.,:;|/\\_-]+$/g
|
|
1157
|
+
|
|
1158
|
+
/**
|
|
1159
|
+
* Remove every occurrence of a family heading from a text, as a SUBSTRING.
|
|
1160
|
+
*
|
|
1161
|
+
* A family is glued to its version tail in real ids and names — `Qwen3.8-…`,
|
|
1162
|
+
* `Deepseek-V4.1-…`, `MiniMax-M3` — so a whole-word match would miss the
|
|
1163
|
+
* very case this exists for. The gap left behind becomes a space, so
|
|
1164
|
+
* removing a prefix never glues two words together.
|
|
1165
|
+
* @param {string} text - the model name.
|
|
1166
|
+
* @param {string} needle - the family heading.
|
|
1167
|
+
* @returns {string} the text with the heading removed.
|
|
1168
|
+
*/
|
|
1169
|
+
function dropSubstring(text, needle) {
|
|
1170
|
+
const lower = needle.toLowerCase()
|
|
1171
|
+
if (lower.length < 2) return text
|
|
1172
|
+
let out = text
|
|
1173
|
+
for (;;) {
|
|
1174
|
+
const at = out.toLowerCase().indexOf(lower)
|
|
1175
|
+
if (at === -1) return out
|
|
1176
|
+
out = `${out.slice(0, at)} ${out.slice(at + lower.length)}`
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
/**
|
|
1181
|
+
* Remove whole words from a text (case-insensitive).
|
|
1182
|
+
*
|
|
1183
|
+
* Used for the provider's own label, which is the row's PRIMARY text — so a
|
|
1184
|
+
* tail that repeats it would print the same word twice. Whole-word matching
|
|
1185
|
+
* is what keeps a short token from punching a hole in an unrelated word
|
|
1186
|
+
* (provider `AI` must not turn `Rain` into `Rn`). Word edges are any
|
|
1187
|
+
* non-letter/digit, so the CJK provider label `豆包` still matches inside
|
|
1188
|
+
* `豆包 2.1 Turbo`.
|
|
1189
|
+
* @param {string} text - the model name.
|
|
1190
|
+
* @param {string[]} words - provider label words; each needs 2+ characters.
|
|
1191
|
+
* @returns {string} the text with those words removed.
|
|
1192
|
+
*/
|
|
1193
|
+
function dropWords(text, words) {
|
|
1194
|
+
let out = text
|
|
1195
|
+
for (const word of words) {
|
|
1196
|
+
if (word.length < 2) continue
|
|
1197
|
+
const escaped = word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
1198
|
+
out = out.replace(
|
|
1199
|
+
new RegExp(`(?<![\\p{L}\\p{N}])${escaped}(?![\\p{L}\\p{N}])`, 'giu'),
|
|
1200
|
+
' ',
|
|
1201
|
+
)
|
|
1202
|
+
}
|
|
1203
|
+
return out
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
/**
|
|
1207
|
+
* The model-specific part of a family-grouped row: the model name with the
|
|
1208
|
+
* family heading and the provider's own label removed, so only what the
|
|
1209
|
+
* heading does NOT already say is left.
|
|
1210
|
+
*
|
|
1211
|
+
* `Qwen3.8-Flash-Next` under `Qwen` → `3.8-Flash-Next`, `Qwen3.8-27B` →
|
|
1212
|
+
* `3.8-27B`; `Deepseek-V4.1-Flash · x0.00` under `DeepSeek` →
|
|
1213
|
+
* `V4.1-Flash · x0.00`; `豆包 2.1 Turbo` under provider `豆包(doubao)` →
|
|
1214
|
+
* `2.1 Turbo`. Version numbers are KEPT — they are the型号 the heading
|
|
1215
|
+
* lacks, unlike the id-derived tail that drops them.
|
|
1216
|
+
*
|
|
1217
|
+
* When the name adds nothing (`Hy4` under `HY4`), the id tail is used so a
|
|
1218
|
+
* variant like `hy4free` still reads `free`. An empty result is valid: the
|
|
1219
|
+
* row then draws no tail at all.
|
|
929
1220
|
* @param {{id: string, name: string}} model - catalog entry.
|
|
930
1221
|
* @param {string} family - the resolved family heading.
|
|
931
|
-
* @
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
const
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
1222
|
+
* @param {string} [providerLabel] - the row's primary text; stripped when it repeats.
|
|
1223
|
+
* @returns {string} the model-specific supplement; possibly ''.
|
|
1224
|
+
*/
|
|
1225
|
+
function rowVariantOf(model, family, providerLabel = '') {
|
|
1226
|
+
const name = String(model.name ?? '')
|
|
1227
|
+
const providerWords = String(providerLabel).split(/[^\p{L}\p{N}]+/u).filter(Boolean)
|
|
1228
|
+
const tail = dropWords(dropSubstring(name, family), providerWords)
|
|
1229
|
+
.replace(/\s+/g, ' ')
|
|
1230
|
+
.replace(TAIL_EDGE, '')
|
|
1231
|
+
if (tail !== '') return tail
|
|
1232
|
+
return idVariantOf(model, family)
|
|
941
1233
|
}
|
|
942
1234
|
|
|
943
1235
|
// ---------------------------------------------------------------------
|
|
@@ -1087,6 +1379,120 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
|
|
|
1087
1379
|
}
|
|
1088
1380
|
}
|
|
1089
1381
|
|
|
1382
|
+
// ---------------------------------------------------------------------
|
|
1383
|
+
// Display mode — `flat` (this panel) or `native` (the Host's own picker).
|
|
1384
|
+
//
|
|
1385
|
+
// The panel can hand the composer's model seat back to whatever the Host
|
|
1386
|
+
// ships in `conversation.input.model`. It does that by DISPOSING its own
|
|
1387
|
+
// slot entry. A `single` slot keeps EVERY registration and renders only the
|
|
1388
|
+
// highest-priority live one, so the shipped entry underneath is elected and
|
|
1389
|
+
// the real, current Host component appears. Nothing is copied, imported or
|
|
1390
|
+
// re-mounted — which is the whole point: an upgraded DSH shows its own
|
|
1391
|
+
// newest picker, so this mode cannot drift out of date (DEC-027).
|
|
1392
|
+
//
|
|
1393
|
+
// The choice persists exactly like the grouping choice (origin-scoped
|
|
1394
|
+
// `localStorage`), and every access degrades to `flat` — a missing store
|
|
1395
|
+
// must never be able to leave the composer without a model control.
|
|
1396
|
+
// ---------------------------------------------------------------------
|
|
1397
|
+
const DISPLAY_MODE_KEY = 'dshmf:mode'
|
|
1398
|
+
const DISPLAY_MODE_DEFAULT = 'flat'
|
|
1399
|
+
/**
|
|
1400
|
+
* Read the persisted display mode, or the default when nothing valid was
|
|
1401
|
+
* stored (or storage is unavailable).
|
|
1402
|
+
* @returns `'flat'` or `'native'`.
|
|
1403
|
+
*/
|
|
1404
|
+
function readDisplayMode() {
|
|
1405
|
+
try {
|
|
1406
|
+
if (typeof localStorage === 'undefined') return DISPLAY_MODE_DEFAULT
|
|
1407
|
+
const value = localStorage.getItem(DISPLAY_MODE_KEY)
|
|
1408
|
+
return value === 'flat' || value === 'native' ? value : DISPLAY_MODE_DEFAULT
|
|
1409
|
+
} catch {
|
|
1410
|
+
return DISPLAY_MODE_DEFAULT
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
/**
|
|
1414
|
+
* Persist one display mode. A write failure is swallowed: the in-memory
|
|
1415
|
+
* value still drives the current tab.
|
|
1416
|
+
* @param {'flat'|'native'} value - the mode to persist.
|
|
1417
|
+
*/
|
|
1418
|
+
function writeDisplayMode(value) {
|
|
1419
|
+
try {
|
|
1420
|
+
if (typeof localStorage === 'undefined') return
|
|
1421
|
+
localStorage.setItem(DISPLAY_MODE_KEY, value)
|
|
1422
|
+
} catch {
|
|
1423
|
+
/* storage unavailable — keep the in-memory choice only */
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
/** Schedule a callback outside the current event dispatch. */
|
|
1427
|
+
const scheduleMicrotask = typeof queueMicrotask === 'function'
|
|
1428
|
+
? queueMicrotask
|
|
1429
|
+
: callback => { Promise.resolve().then(callback) }
|
|
1430
|
+
|
|
1431
|
+
/**
|
|
1432
|
+
* The ONE display-mode store per plugin generation: a uSES-safe snapshot
|
|
1433
|
+
* (`getSnapshot` keeps referential identity until the value changes) shared
|
|
1434
|
+
* by the panel's mode button and the composer-row chip.
|
|
1435
|
+
*
|
|
1436
|
+
* `set` notifies `queueMicrotask`-late on purpose. The click that switches
|
|
1437
|
+
* the mode DISPOSES the slot entry that owns the button, so notifying
|
|
1438
|
+
* synchronously would mutate the slot registry in the middle of React's
|
|
1439
|
+
* event dispatch — the entry would vanish while its own handler is still on
|
|
1440
|
+
* the stack.
|
|
1441
|
+
* @param deps - storage accessors, injected for testability.
|
|
1442
|
+
* @returns the store plus an idempotent `set`.
|
|
1443
|
+
*/
|
|
1444
|
+
function createDisplayModeStore(deps) {
|
|
1445
|
+
const normalize = value => (value === 'native' ? 'native' : DISPLAY_MODE_DEFAULT)
|
|
1446
|
+
let value = normalize(typeof deps.readStored === 'function' ? deps.readStored() : DISPLAY_MODE_DEFAULT)
|
|
1447
|
+
const listeners = new Set()
|
|
1448
|
+
let scheduled = false
|
|
1449
|
+
const flush = () => {
|
|
1450
|
+
scheduled = false
|
|
1451
|
+
for (const listener of [...listeners]) {
|
|
1452
|
+
try {
|
|
1453
|
+
listener()
|
|
1454
|
+
} catch {
|
|
1455
|
+
/* one bad subscriber must not starve the others */
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
return {
|
|
1460
|
+
getSnapshot: () => value,
|
|
1461
|
+
subscribe: listener => {
|
|
1462
|
+
listeners.add(listener)
|
|
1463
|
+
return () => { listeners.delete(listener) }
|
|
1464
|
+
},
|
|
1465
|
+
set: next => {
|
|
1466
|
+
const normalized = normalize(next)
|
|
1467
|
+
if (normalized === value) return
|
|
1468
|
+
value = normalized
|
|
1469
|
+
try {
|
|
1470
|
+
if (typeof deps.writeStored === 'function') deps.writeStored(value)
|
|
1471
|
+
} catch {
|
|
1472
|
+
/* storage unavailable — the in-memory value still applies */
|
|
1473
|
+
}
|
|
1474
|
+
if (scheduled) return
|
|
1475
|
+
scheduled = true
|
|
1476
|
+
scheduleMicrotask(flush)
|
|
1477
|
+
},
|
|
1478
|
+
/** Drop every subscriber (activation teardown). */
|
|
1479
|
+
dispose: () => {
|
|
1480
|
+
listeners.clear()
|
|
1481
|
+
scheduled = false
|
|
1482
|
+
},
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
/**
|
|
1486
|
+
* Frozen stand-in for a screen that has no store (a degraded activation, or
|
|
1487
|
+
* a test double). One shared instance keeps `getSnapshot` stable, so a uSES
|
|
1488
|
+
* consumer never re-renders in a loop.
|
|
1489
|
+
*/
|
|
1490
|
+
const FLAT_DISPLAY_MODE = {
|
|
1491
|
+
getSnapshot: () => DISPLAY_MODE_DEFAULT,
|
|
1492
|
+
subscribe: () => () => {},
|
|
1493
|
+
set: () => {},
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1090
1496
|
// ---------------------------------------------------------------------
|
|
1091
1497
|
// Insufficient-balance marks are GLOBAL, not Session-scoped.
|
|
1092
1498
|
//
|
|
@@ -1102,10 +1508,47 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
|
|
|
1102
1508
|
// the Host's `time`, and the newest observation of a route wins. That is
|
|
1103
1509
|
// what lets a replayed window (or a stored entry) never override a newer
|
|
1104
1510
|
// fact from another Session.
|
|
1511
|
+
//
|
|
1512
|
+
// The same stamp is what makes a strike go STALE: it is the time the verdict
|
|
1513
|
+
// was made, so a mark nobody refreshed stops being drawn after STRIKE_TTL
|
|
1514
|
+
// while the record stays behind as the watermark. See markIsLive.
|
|
1105
1515
|
// ---------------------------------------------------------------------
|
|
1106
1516
|
const MARKS_KEY = 'dshmf:insufficient'
|
|
1107
|
-
/**
|
|
1517
|
+
/**
|
|
1518
|
+
* How long a RECORD is kept. Records older than this are dropped on read,
|
|
1519
|
+
* so a stale verdict cannot live forever — and so a cleared route's
|
|
1520
|
+
* watermark (which shields it from a replayed older failure) has an end.
|
|
1521
|
+
*/
|
|
1108
1522
|
const MARKS_TTL = 7 * 24 * 60 * 60 * 1000
|
|
1523
|
+
/**
|
|
1524
|
+
* How long a MARK is DRAWN. This is the one the user sees: if nothing new has
|
|
1525
|
+
* been observed for a route for a day, the user never retried it, and the
|
|
1526
|
+
* panel stops claiming the model is unusable — an exhausted balance gets
|
|
1527
|
+
* topped up and a quota resets daily, so a strike that outlives a whole day
|
|
1528
|
+
* of silence is stale information, not a warning. The record itself survives
|
|
1529
|
+
* (up to {@link MARKS_TTL}) as the watermark that keeps a replayed older
|
|
1530
|
+
* failure from re-stamping it.
|
|
1531
|
+
*/
|
|
1532
|
+
const STRIKE_TTL = 24 * 60 * 60 * 1000
|
|
1533
|
+
|
|
1534
|
+
/**
|
|
1535
|
+
* Whether one record still justifies drawing a strike at `now`.
|
|
1536
|
+
*
|
|
1537
|
+
* `at` is the time of the LAST observation for the route, so the answer to
|
|
1538
|
+
* "has the user tried this model again?" is already in it: a retry that
|
|
1539
|
+
* failed on balance restamps `at` and restarts the window, a retry that
|
|
1540
|
+
* answered flips `marked`, and a route nobody touched just ages out.
|
|
1541
|
+
* @param {{ marked: boolean, at: number } | undefined} entry - one record.
|
|
1542
|
+
* @param {number} now - the current wall-clock time.
|
|
1543
|
+
* @param {number} [strikeTtl] - how long a mark is drawn; defaults to {@link STRIKE_TTL}.
|
|
1544
|
+
* @returns {boolean} true only for a mark that is set AND still fresh.
|
|
1545
|
+
*/
|
|
1546
|
+
function markIsLive(entry, now, strikeTtl) {
|
|
1547
|
+
if (entry === undefined || entry.marked !== true) return false
|
|
1548
|
+
const life = typeof strikeTtl === 'number' && Number.isFinite(strikeTtl) ? strikeTtl : STRIKE_TTL
|
|
1549
|
+
// A stamp from the future (a clock that ran ahead) is fresh, never expired.
|
|
1550
|
+
return now - entry.at < life
|
|
1551
|
+
}
|
|
1109
1552
|
|
|
1110
1553
|
/**
|
|
1111
1554
|
* Parse a persisted mark record into the in-memory shape, dropping anything
|
|
@@ -1178,10 +1621,17 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
|
|
|
1178
1621
|
}
|
|
1179
1622
|
}
|
|
1180
1623
|
|
|
1181
|
-
/**
|
|
1624
|
+
/**
|
|
1625
|
+
* Whether two visible mark maps hold the same entries.
|
|
1626
|
+
*
|
|
1627
|
+
* Iterating a Map yields `[key, value]` PAIRS, so this walks `keys()` — a
|
|
1628
|
+
* `for (const key of map)` here compares a pair against `has()` and always
|
|
1629
|
+
* answers "different", which hands the seats a brand-new snapshot object on
|
|
1630
|
+
* every recompute and puts `useSyncExternalStore` into a render loop.
|
|
1631
|
+
*/
|
|
1182
1632
|
function sameMarks(left, right) {
|
|
1183
1633
|
if (left.size !== right.size) return false
|
|
1184
|
-
for (const key of left) {
|
|
1634
|
+
for (const key of left.keys()) {
|
|
1185
1635
|
if (!right.has(key)) return false
|
|
1186
1636
|
}
|
|
1187
1637
|
return true
|
|
@@ -1196,22 +1646,43 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
|
|
|
1196
1646
|
* everywhere, including in a Session whose own feed was never opened in this
|
|
1197
1647
|
* page load (its marks come from storage).
|
|
1198
1648
|
*
|
|
1649
|
+
* The store is also the thing that ages strikes out. A mark retiring itself
|
|
1650
|
+
* is a state change no event announces, so the store watches the clock on its
|
|
1651
|
+
* own behalf — on every fold, on every seat mounting, and on a timer set for
|
|
1652
|
+
* the soonest deadline — instead of waiting for a fold that may never come.
|
|
1653
|
+
*
|
|
1199
1654
|
* @param {object} deps - resolution hooks.
|
|
1200
1655
|
* @param {() => object | undefined} deps.sessionsOf - read the `sessions` service, per call.
|
|
1201
1656
|
* @param {() => Map<string, {marked: boolean, at: number}>} deps.readStored - initial/persisted state.
|
|
1202
1657
|
* @param {(entries: Map<string, {marked: boolean, at: number}>) => void} deps.writeStored - persistence sink.
|
|
1203
|
-
* @
|
|
1658
|
+
* @param {() => number} [deps.now] - the clock; defaults to `Date.now`.
|
|
1659
|
+
* @param {number} [deps.strikeTtl] - strike lifetime; defaults to {@link STRIKE_TTL}.
|
|
1660
|
+
* @returns {object} the store: `subscribe`, `getSnapshot`, `dispose`, `sync`, `watch`, `refresh`.
|
|
1204
1661
|
*/
|
|
1205
1662
|
function createMarkStore(deps) {
|
|
1663
|
+
/**
|
|
1664
|
+
* The clock strikes age against. Injectable so a test does not have to sit
|
|
1665
|
+
* through a day; every read of the state takes it fresh, because a page
|
|
1666
|
+
* lives long enough for the wall clock to matter.
|
|
1667
|
+
*/
|
|
1668
|
+
const nowFn = typeof deps.now === 'function' ? deps.now : () => Date.now()
|
|
1669
|
+
const strikeTtl = typeof deps.strikeTtl === 'number' && Number.isFinite(deps.strikeTtl)
|
|
1670
|
+
? deps.strikeTtl
|
|
1671
|
+
: STRIKE_TTL
|
|
1206
1672
|
/** Internal truth: route key → last decision and when it was made. */
|
|
1207
1673
|
const entries = new Map(deps.readStored())
|
|
1208
|
-
/** Visible subset the rows read: route key → true. */
|
|
1674
|
+
/** Visible subset the rows read: route key → true, the LIVE marks only. */
|
|
1209
1675
|
let visible = new Map()
|
|
1210
1676
|
let snapshot = { marks: visible }
|
|
1211
1677
|
const listeners = new Set()
|
|
1212
1678
|
/** Per-Session route of the latest request header (a route is Session-local). */
|
|
1213
1679
|
const routes = new Map()
|
|
1214
|
-
/**
|
|
1680
|
+
/**
|
|
1681
|
+
* Per-Session event feed subscription: `{ source, stop }`. The source is
|
|
1682
|
+
* kept beside its unsubscribe so a re-materialised Session (same id, new
|
|
1683
|
+
* `eventSource`) is detected and re-attached instead of silently staying
|
|
1684
|
+
* bound to the retired feed.
|
|
1685
|
+
*/
|
|
1215
1686
|
const stops = new Map()
|
|
1216
1687
|
/** Sessions attached on behalf of a mounted seat; never pruned by a list sync. */
|
|
1217
1688
|
const pinned = new Set()
|
|
@@ -1219,12 +1690,19 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
|
|
|
1219
1690
|
let attachedSessions
|
|
1220
1691
|
let stopList
|
|
1221
1692
|
let disposed = false
|
|
1693
|
+
/** Timer for the soonest strike that is still being drawn. */
|
|
1694
|
+
let expiryTimer = null
|
|
1695
|
+
|
|
1696
|
+
const publish = () => {
|
|
1697
|
+
for (const listener of listeners) listener()
|
|
1698
|
+
}
|
|
1222
1699
|
|
|
1223
|
-
/** Rebuild the visible map; report whether
|
|
1700
|
+
/** Rebuild the visible map against the clock; report whether it changed. */
|
|
1224
1701
|
const recompute = () => {
|
|
1702
|
+
const now = nowFn()
|
|
1225
1703
|
const next = new Map()
|
|
1226
1704
|
for (const [key, entry] of entries) {
|
|
1227
|
-
if (entry
|
|
1705
|
+
if (markIsLive(entry, now, strikeTtl)) next.set(key, true)
|
|
1228
1706
|
}
|
|
1229
1707
|
if (sameMarks(visible, next)) return false
|
|
1230
1708
|
visible = next
|
|
@@ -1232,12 +1710,56 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
|
|
|
1232
1710
|
return true
|
|
1233
1711
|
}
|
|
1234
1712
|
|
|
1235
|
-
|
|
1713
|
+
/**
|
|
1714
|
+
* Arm the clock for the soonest live strike.
|
|
1715
|
+
*
|
|
1716
|
+
* A strike retiring is the one transition no event reports: the user
|
|
1717
|
+
* simply never came back to this model. A page left open across the
|
|
1718
|
+
* deadline therefore has to wake itself, or a stale line-through would sit
|
|
1719
|
+
* on the row until some unrelated fold happened to recompute it.
|
|
1720
|
+
*/
|
|
1721
|
+
const scheduleExpiry = () => {
|
|
1722
|
+
if (expiryTimer !== null) {
|
|
1723
|
+
clearTimeout(expiryTimer)
|
|
1724
|
+
expiryTimer = null
|
|
1725
|
+
}
|
|
1726
|
+
if (disposed || typeof setTimeout !== 'function') return
|
|
1727
|
+
const now = nowFn()
|
|
1728
|
+
let earliest = Number.POSITIVE_INFINITY
|
|
1729
|
+
for (const entry of entries.values()) {
|
|
1730
|
+
if (!markIsLive(entry, now, strikeTtl)) continue
|
|
1731
|
+
const deadline = entry.at + strikeTtl
|
|
1732
|
+
if (deadline < earliest) earliest = deadline
|
|
1733
|
+
}
|
|
1734
|
+
if (!Number.isFinite(earliest)) return
|
|
1735
|
+
const handle = setTimeout(() => {
|
|
1736
|
+
expiryTimer = null
|
|
1737
|
+
refresh()
|
|
1738
|
+
}, Math.max(0, earliest - now))
|
|
1739
|
+
// A day-long timer must never be the reason a process stays alive (a
|
|
1740
|
+
// Node host, a test run): `unref` lets it fire while anything else is
|
|
1741
|
+
// running without holding the loop. A browser handle is a number and has
|
|
1742
|
+
// no `unref`, and there the page keeps its own clock.
|
|
1743
|
+
if (typeof handle?.unref === 'function') handle.unref()
|
|
1744
|
+
expiryTimer = handle
|
|
1745
|
+
}
|
|
1236
1746
|
|
|
1237
|
-
|
|
1238
|
-
|
|
1747
|
+
/**
|
|
1748
|
+
* Re-read the clock and tell the seats if anything aged out.
|
|
1749
|
+
* @returns {boolean} whether the rendered set changed.
|
|
1750
|
+
*/
|
|
1751
|
+
const refresh = () => {
|
|
1752
|
+
if (disposed) return false
|
|
1753
|
+
const changed = recompute()
|
|
1754
|
+
// Re-arm first: a strike that just aged out may leave a later one live.
|
|
1755
|
+
scheduleExpiry()
|
|
1756
|
+
if (changed) publish()
|
|
1757
|
+
return changed
|
|
1239
1758
|
}
|
|
1240
1759
|
|
|
1760
|
+
recompute()
|
|
1761
|
+
scheduleExpiry()
|
|
1762
|
+
|
|
1241
1763
|
/** Fold one batch of durable entries for one Session. */
|
|
1242
1764
|
const absorb = (sessionId, windowEntries) => {
|
|
1243
1765
|
let stored = false
|
|
@@ -1270,30 +1792,54 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
|
|
|
1270
1792
|
const applyWindow = (sessionId, window, full) => {
|
|
1271
1793
|
const change = window?.change
|
|
1272
1794
|
let result
|
|
1273
|
-
if (full || change?.kind === 'replace') {
|
|
1274
|
-
//
|
|
1795
|
+
if (full || change?.kind === 'replace' || change?.kind === 'prepend') {
|
|
1796
|
+
// `replace` is that Session's whole truth, so its route restarts and
|
|
1275
1797
|
// its entries are re-walked. Global watermarks keep a newer fact from
|
|
1276
1798
|
// another Session from being overwritten by this replay.
|
|
1799
|
+
//
|
|
1800
|
+
// `prepend` (scrolling up) must be re-walked for the SAME reason, and
|
|
1801
|
+
// this is not an optimisation to skip: older history is exactly where
|
|
1802
|
+
// a failure lives once the turn that hit it has scrolled out of the
|
|
1803
|
+
// first page. Folding only the appended tail left the store blind to
|
|
1804
|
+
// every failure that happened before the window's oldest entry, which
|
|
1805
|
+
// is the common case on a long Session — the strike then never
|
|
1806
|
+
// appeared for the model that actually ran out. The route map is
|
|
1807
|
+
// Session-local and positional, so the prepended page is folded with
|
|
1808
|
+
// the whole window (oldest entry first) to attribute each failure to
|
|
1809
|
+
// the header that really preceded it.
|
|
1277
1810
|
routes.delete(sessionId)
|
|
1278
1811
|
result = absorb(sessionId, entriesOf(window))
|
|
1279
1812
|
} else if (change?.kind === 'append') {
|
|
1280
1813
|
result = absorb(sessionId, change.entries)
|
|
1281
1814
|
} else {
|
|
1282
|
-
// `
|
|
1283
|
-
//
|
|
1815
|
+
// `settle-assistant` only swaps a live attempt for its durable form —
|
|
1816
|
+
// an Assistant message — so it cannot carry a turn/end or a header.
|
|
1284
1817
|
return
|
|
1285
1818
|
}
|
|
1286
1819
|
if (result.stored) deps.writeStored(entries)
|
|
1287
|
-
|
|
1820
|
+
// The clock — not the raw record — decides what the rows draw, and a new
|
|
1821
|
+
// record moves the soonest deadline, so re-read both after any fold.
|
|
1822
|
+
if (result.stored || result.changed) refresh()
|
|
1288
1823
|
}
|
|
1289
1824
|
|
|
1290
1825
|
/**
|
|
1291
|
-
* Attach to one Session's durable feed. Idempotent, and a
|
|
1292
|
-
* Session has no retained binding (an unopened Session has
|
|
1293
|
-
* marks can only come from storage).
|
|
1826
|
+
* Attach to one Session's durable feed. Idempotent for one feed, and a
|
|
1827
|
+
* no-op when the Session has no retained binding (an unopened Session has
|
|
1828
|
+
* no feed; its marks can only come from storage).
|
|
1829
|
+
*
|
|
1830
|
+
* "Already attached" has to mean "attached to THIS feed", not "attached to
|
|
1831
|
+
* this Session id". The Client materialises a Session scope on demand and
|
|
1832
|
+
* retires it when nothing retains it any more — switching to another
|
|
1833
|
+
* conversation and coming back, or opening a second one, drops the old
|
|
1834
|
+
* scope and builds a NEW Session object with a NEW `eventSource`. Keying
|
|
1835
|
+
* the subscription by id alone left the store listening to the retired
|
|
1836
|
+
* feed forever: the live failure was never folded (no strike appeared)
|
|
1837
|
+
* while the dead feed could still inject stale facts. So the subscription
|
|
1838
|
+
* is remembered together with the source it came from and replaced when
|
|
1839
|
+
* the identity changes.
|
|
1294
1840
|
*/
|
|
1295
1841
|
const attachSession = sessionId => {
|
|
1296
|
-
if (disposed
|
|
1842
|
+
if (disposed) return
|
|
1297
1843
|
const sessions = deps.sessionsOf()
|
|
1298
1844
|
let binding
|
|
1299
1845
|
try {
|
|
@@ -1304,6 +1850,19 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
|
|
|
1304
1850
|
const source = binding?.eventSource
|
|
1305
1851
|
if (source === undefined || typeof source.subscribe !== 'function'
|
|
1306
1852
|
|| typeof source.getSnapshot !== 'function') return
|
|
1853
|
+
const attached = stops.get(sessionId)
|
|
1854
|
+
if (attached !== undefined && attached.source === source) return
|
|
1855
|
+
// A different feed for the same id: drop the dead subscription and
|
|
1856
|
+
// forget the route map, which was derived from the retired window.
|
|
1857
|
+
if (attached !== undefined) {
|
|
1858
|
+
try {
|
|
1859
|
+
attached.stop()
|
|
1860
|
+
} catch {
|
|
1861
|
+
/* a refused unsubscribe must not keep the new feed out */
|
|
1862
|
+
}
|
|
1863
|
+
stops.delete(sessionId)
|
|
1864
|
+
routes.delete(sessionId)
|
|
1865
|
+
}
|
|
1307
1866
|
try {
|
|
1308
1867
|
applyWindow(sessionId, source.getSnapshot(), true)
|
|
1309
1868
|
} catch {
|
|
@@ -1318,7 +1877,10 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
|
|
|
1318
1877
|
}
|
|
1319
1878
|
try {
|
|
1320
1879
|
const stop = source.subscribe(listener)
|
|
1321
|
-
stops.set(sessionId,
|
|
1880
|
+
stops.set(sessionId, {
|
|
1881
|
+
source,
|
|
1882
|
+
stop: typeof stop === 'function' ? stop : () => {},
|
|
1883
|
+
})
|
|
1322
1884
|
} catch {
|
|
1323
1885
|
/* subscription refused: leave this Session unattached */
|
|
1324
1886
|
}
|
|
@@ -1331,7 +1893,7 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
|
|
|
1331
1893
|
if (sessions === undefined) return
|
|
1332
1894
|
// A client reload replaces the service; re-attach from the new one.
|
|
1333
1895
|
if (sessions !== attachedSessions) {
|
|
1334
|
-
for (const
|
|
1896
|
+
for (const attached of stops.values()) attached.stop()
|
|
1335
1897
|
stops.clear()
|
|
1336
1898
|
routes.clear()
|
|
1337
1899
|
if (typeof stopList === 'function') stopList()
|
|
@@ -1358,7 +1920,7 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
|
|
|
1358
1920
|
for (const id of [...stops.keys()]) {
|
|
1359
1921
|
if (pinned.has(id)) continue
|
|
1360
1922
|
if (!ids.includes(id)) {
|
|
1361
|
-
stops.get(id)?.()
|
|
1923
|
+
stops.get(id)?.stop()
|
|
1362
1924
|
stops.delete(id)
|
|
1363
1925
|
routes.delete(id)
|
|
1364
1926
|
}
|
|
@@ -1368,6 +1930,10 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
|
|
|
1368
1930
|
return {
|
|
1369
1931
|
/** The seat's `useSyncExternalStore` pair. */
|
|
1370
1932
|
subscribe: listener => {
|
|
1933
|
+
// A seat mounting IS the model list being displayed, so this is where a
|
|
1934
|
+
// strike that aged out while nobody was looking gets retired — before
|
|
1935
|
+
// React reads the snapshot it is about to render from.
|
|
1936
|
+
refresh()
|
|
1371
1937
|
listeners.add(listener)
|
|
1372
1938
|
sync()
|
|
1373
1939
|
return () => { listeners.delete(listener) }
|
|
@@ -1385,12 +1951,23 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
|
|
|
1385
1951
|
pinned.add(sessionId)
|
|
1386
1952
|
sync()
|
|
1387
1953
|
attachSession(sessionId)
|
|
1954
|
+
refresh()
|
|
1388
1955
|
},
|
|
1956
|
+
/**
|
|
1957
|
+
* Re-read the clock: retire any mark older than the strike lifetime and
|
|
1958
|
+
* notify the seats that drew it. Call it whenever the list is displayed,
|
|
1959
|
+
* because that is the moment the answer must be current.
|
|
1960
|
+
*/
|
|
1961
|
+
refresh,
|
|
1389
1962
|
/** Release every feed subscription. The marks themselves stay persisted. */
|
|
1390
1963
|
dispose: () => {
|
|
1391
1964
|
if (disposed) return
|
|
1392
1965
|
disposed = true
|
|
1393
|
-
|
|
1966
|
+
if (expiryTimer !== null) {
|
|
1967
|
+
clearTimeout(expiryTimer)
|
|
1968
|
+
expiryTimer = null
|
|
1969
|
+
}
|
|
1970
|
+
for (const attached of stops.values()) attached.stop()
|
|
1394
1971
|
stops.clear()
|
|
1395
1972
|
routes.clear()
|
|
1396
1973
|
pinned.clear()
|
|
@@ -1431,6 +2008,10 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
|
|
|
1431
2008
|
'failure.group': '{name} 加载失败:{message}',
|
|
1432
2009
|
'error.action': '模型操作失败:{message}',
|
|
1433
2010
|
'error.sessionInUse': '当前会话已被占用,可能是其他正在运行的 DSH 导致的,请退出其他正在运行的 DSH 后重试。',
|
|
2011
|
+
'native.label': '默认样式',
|
|
2012
|
+
'native.enter': '切换到默认样式',
|
|
2013
|
+
'native.back': '回到平铺模型面板',
|
|
2014
|
+
'native.unavailable': '默认样式当前不可用',
|
|
1434
2015
|
},
|
|
1435
2016
|
en: {
|
|
1436
2017
|
'provider.account': 'DeepSeek Account',
|
|
@@ -1461,6 +2042,10 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
|
|
|
1461
2042
|
'failure.group': '{name} failed to load: {message}',
|
|
1462
2043
|
'error.action': 'Model operation failed: {message}',
|
|
1463
2044
|
'error.sessionInUse': 'This session is already in use, possibly by another running DSH instance. Quit other running DSH instances and try again.',
|
|
2045
|
+
'native.label': 'Default style',
|
|
2046
|
+
'native.enter': 'Switch to the default style',
|
|
2047
|
+
'native.back': 'Back to the flat model panel',
|
|
2048
|
+
'native.unavailable': 'The default style is unavailable right now',
|
|
1464
2049
|
},
|
|
1465
2050
|
}
|
|
1466
2051
|
|
|
@@ -1509,6 +2094,249 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
|
|
|
1509
2094
|
subscribe: () => () => {},
|
|
1510
2095
|
getSnapshot: () => EMPTY_INSUFFICIENT_SNAPSHOT,
|
|
1511
2096
|
watch: () => {},
|
|
2097
|
+
refresh: () => {},
|
|
2098
|
+
}
|
|
2099
|
+
|
|
2100
|
+
/**
|
|
2101
|
+
* The slot whose occupant decorates a model name in the shipped picker.
|
|
2102
|
+
*
|
|
2103
|
+
* It is a `single`, session-scoped child slot declared by the shipped
|
|
2104
|
+
* `ModelSelect` entry. This plugin shadows that entry but never disposes it,
|
|
2105
|
+
* so the declaration and every contribution to it stay alive — which is
|
|
2106
|
+
* exactly why the trigger can draw the same occupant the native picker
|
|
2107
|
+
* would (DEC-030).
|
|
2108
|
+
*/
|
|
2109
|
+
const LABEL_SLOT = 'conversation.input.model.label'
|
|
2110
|
+
|
|
2111
|
+
/**
|
|
2112
|
+
* Read the entry that currently OWNS the label cell, or undefined.
|
|
2113
|
+
*
|
|
2114
|
+
* `entriesOfSlot` is the documented "winner per cell" accessor (the same
|
|
2115
|
+
* one the renderer's outlet uses), so this asks the host the same question
|
|
2116
|
+
* the native trigger does rather than guessing at priorities. Anything
|
|
2117
|
+
* unexpected — an older host without the accessor, a thrown service call, a
|
|
2118
|
+
* slot that lost its declaration — answers undefined and the trigger falls
|
|
2119
|
+
* back to the catalog name, which is the shipped fallback's own behaviour.
|
|
2120
|
+
* @param {object|undefined} slots - the `slots` client service.
|
|
2121
|
+
* @returns {object|undefined} the winning StoredEntry.
|
|
2122
|
+
*/
|
|
2123
|
+
function readLabelEntry(slots) {
|
|
2124
|
+
if (slots === undefined || slots === null || typeof slots.entriesOfSlot !== 'function') return undefined
|
|
2125
|
+
try {
|
|
2126
|
+
const winners = slots.entriesOfSlot(LABEL_SLOT)
|
|
2127
|
+
const entry = Array.isArray(winners) ? winners[0] : undefined
|
|
2128
|
+
// A component is the whole point; an entry without one (or a cell that
|
|
2129
|
+
// abdicated after a crash) is the same as no occupant at all.
|
|
2130
|
+
return entry !== undefined && typeof entry.component === 'function' ? entry : undefined
|
|
2131
|
+
} catch {
|
|
2132
|
+
return undefined
|
|
2133
|
+
}
|
|
2134
|
+
}
|
|
2135
|
+
|
|
2136
|
+
/** One shared passthrough, so a degraded seat is not a new reference per render. */
|
|
2137
|
+
const identityTranslate = key => String(key)
|
|
2138
|
+
|
|
2139
|
+
/** Shared empty business face, for the same reason as `identityTranslate`. */
|
|
2140
|
+
const EMPTY_LABEL_FACE = {}
|
|
2141
|
+
|
|
2142
|
+
/** Per (locale face, namespace, revision) translate seats, mirroring the renderer. */
|
|
2143
|
+
const labelSeatCache = new WeakMap()
|
|
2144
|
+
|
|
2145
|
+
/**
|
|
2146
|
+
* Build the locale seat a label occupant expects.
|
|
2147
|
+
*
|
|
2148
|
+
* A registered entry's `t` is synthesized by the renderer from its declared
|
|
2149
|
+
* `locale` namespace (`entry.locale`) through the installed locale face's
|
|
2150
|
+
* `bind()`. Rendering that component ourselves means synthesizing the same
|
|
2151
|
+
* seat; a contribution that declares no namespace gets a passthrough that
|
|
2152
|
+
* answers its own key, which is what the renderer's kit would omit anyway.
|
|
2153
|
+
* @param {object|undefined} locale - the `locale` client service.
|
|
2154
|
+
* @param {string|undefined} namespace - the occupant's declared namespace.
|
|
2155
|
+
* @returns {Function} a translate seat.
|
|
2156
|
+
*/
|
|
2157
|
+
function labelSeat(locale, namespace) {
|
|
2158
|
+
if (typeof namespace !== 'string' || namespace === '') return identityTranslate
|
|
2159
|
+
if (locale === undefined || typeof locale.bind !== 'function') return identityTranslate
|
|
2160
|
+
try {
|
|
2161
|
+
// Cached per (face, namespace, revision) exactly as the renderer's own
|
|
2162
|
+
// `localeSeat` does. The identity matters: the occupant is a React
|
|
2163
|
+
// component, and a fresh `t` on every render would look like new props
|
|
2164
|
+
// on every paint — a memoized chip would re-render forever, and an
|
|
2165
|
+
// effect keyed on `t` would re-run forever.
|
|
2166
|
+
const revision = typeof locale.getSnapshot === 'function'
|
|
2167
|
+
? locale.getSnapshot()?.revision
|
|
2168
|
+
: undefined
|
|
2169
|
+
let perNamespace = labelSeatCache.get(locale)
|
|
2170
|
+
if (perNamespace === undefined) {
|
|
2171
|
+
perNamespace = new Map()
|
|
2172
|
+
labelSeatCache.set(locale, perNamespace)
|
|
2173
|
+
}
|
|
2174
|
+
const cached = perNamespace.get(namespace)
|
|
2175
|
+
if (cached !== undefined && cached.revision === revision) return cached.t
|
|
2176
|
+
const bound = locale.bind(namespace)
|
|
2177
|
+
const t = typeof bound === 'function'
|
|
2178
|
+
? (key, params) => bound(key, params)
|
|
2179
|
+
: identityTranslate
|
|
2180
|
+
perNamespace.set(namespace, { revision, t })
|
|
2181
|
+
return t
|
|
2182
|
+
} catch {
|
|
2183
|
+
return identityTranslate
|
|
2184
|
+
}
|
|
2185
|
+
}
|
|
2186
|
+
|
|
2187
|
+
/**
|
|
2188
|
+
* A `useSyncExternalStore` source over "who owns the label cell".
|
|
2189
|
+
*
|
|
2190
|
+
* The trigger has to re-read the winner when a connection plugin registers
|
|
2191
|
+
* or disposes — otherwise a plugin that loads a second after boot keeps its
|
|
2192
|
+
* chip off the trigger until a page reload. The host already publishes the
|
|
2193
|
+
* change channel: `slots.subscribe(key, fn)` plus a monotonic `getVersion`,
|
|
2194
|
+
* which is the documented uSES pairing (`SlotRegistry.getVersion`).
|
|
2195
|
+
*
|
|
2196
|
+
* A host without those accessors yields a source that never notifies and
|
|
2197
|
+
* always answers 0 — the trigger then simply renders whatever it read on
|
|
2198
|
+
* its first paint.
|
|
2199
|
+
* @param {object|undefined} slots - the `slots` client service.
|
|
2200
|
+
* @returns {{subscribe: Function, getSnapshot: Function}} a stable source.
|
|
2201
|
+
*/
|
|
2202
|
+
const labelSlotSources = new WeakMap()
|
|
2203
|
+
function labelSlotSource(slots) {
|
|
2204
|
+
const noop = { subscribe: () => () => {}, getSnapshot: () => 0 }
|
|
2205
|
+
if (slots === undefined || slots === null || typeof slots !== 'object') return noop
|
|
2206
|
+
if (typeof slots.subscribe !== 'function' || typeof slots.getVersion !== 'function') return noop
|
|
2207
|
+
const cached = labelSlotSources.get(slots)
|
|
2208
|
+
if (cached !== undefined) return cached
|
|
2209
|
+
const source = {
|
|
2210
|
+
subscribe: listener => slots.subscribe(LABEL_SLOT, listener),
|
|
2211
|
+
getSnapshot: () => slots.getVersion(LABEL_SLOT),
|
|
2212
|
+
}
|
|
2213
|
+
labelSlotSources.set(slots, source)
|
|
2214
|
+
return source
|
|
2215
|
+
}
|
|
2216
|
+
|
|
2217
|
+
/** The source a seat uses when the face was degraded or omitted. */
|
|
2218
|
+
const NO_LABEL_SLOT = { subscribe: () => () => {}, getSnapshot: () => 0 }
|
|
2219
|
+
|
|
2220
|
+
/**
|
|
2221
|
+
* How long the pointer must rest on a truncated model name before the
|
|
2222
|
+
* marquee starts. Long enough that sweeping across the grid never starts a
|
|
2223
|
+
* scroll, short enough to feel like a direct response (DEC-021).
|
|
2224
|
+
*/
|
|
2225
|
+
const MARQUEE_DELAY = 500
|
|
2226
|
+
/**
|
|
2227
|
+
* One CSS pixel per 20ms — 50px/s. The trailing end of a long name scrolls
|
|
2228
|
+
* at the same pace, so a 300px overflow still finishes in ~6s instead of
|
|
2229
|
+
* crawling, and the speed reads as a considered crawl rather than a ticker.
|
|
2230
|
+
*/
|
|
2231
|
+
const MARQUEE_PX_PER_SECOND = 50
|
|
2232
|
+
/** Shortest pass, so a barely-truncated name does not blink past. */
|
|
2233
|
+
const MARQUEE_MIN_MS = 900
|
|
2234
|
+
|
|
2235
|
+
/**
|
|
2236
|
+
* Does this element actually clip its text?
|
|
2237
|
+
*
|
|
2238
|
+
* The measured element is the row's name WINDOW, a plain block-level flex
|
|
2239
|
+
* item with `overflow: hidden; white-space: nowrap`, so `scrollWidth` is the
|
|
2240
|
+
* full width of the name and `clientWidth` the visible part. A one-pixel
|
|
2241
|
+
* slack keeps sub-pixel rounding from starting a marquee on a name that
|
|
2242
|
+
* fits exactly.
|
|
2243
|
+
* @param {Element | null | undefined} element - the name window.
|
|
2244
|
+
* @returns {number} the clipped amount in px; 0 when nothing is cut off.
|
|
2245
|
+
*/
|
|
2246
|
+
const nameClippedBy = element => {
|
|
2247
|
+
if (element === null || element === undefined) return 0
|
|
2248
|
+
const clientWidth = Number(element.clientWidth) || 0
|
|
2249
|
+
if (clientWidth <= 0) return 0
|
|
2250
|
+
const clipped = (Number(element.scrollWidth) || 0) - clientWidth
|
|
2251
|
+
return clipped > 1 ? clipped : 0
|
|
2252
|
+
}
|
|
2253
|
+
|
|
2254
|
+
/**
|
|
2255
|
+
* The geometry of the single marquee pass (DEC-021).
|
|
2256
|
+
*
|
|
2257
|
+
* `shift` is how far the name travels for its LAST glyph to reach the
|
|
2258
|
+
* right edge of the window — exactly the clipped amount, so nothing scrolls
|
|
2259
|
+
* past the point of being readable. The pass is one-way and eased
|
|
2260
|
+
* (ease-in-out), which is symmetric, so its AVERAGE velocity is still the
|
|
2261
|
+
* pinned MARQUEE_PX_PER_SECOND: the duration is simply
|
|
2262
|
+
* `shift / MARQUEE_PX_PER_SECOND`. Easing makes the ends soften around that
|
|
2263
|
+
* average instead of changing it.
|
|
2264
|
+
* @param {number} clipped - pixels of the name hidden to the right.
|
|
2265
|
+
* @returns {{shift: number, duration: number}} geometry in px / ms.
|
|
2266
|
+
*/
|
|
2267
|
+
const marqueePassGeometry = clipped => {
|
|
2268
|
+
const shift = Number.isFinite(clipped) && clipped > 0 ? Math.ceil(clipped) : 0
|
|
2269
|
+
if (shift === 0) return { shift: 0, duration: 0 }
|
|
2270
|
+
const duration = Math.max(MARQUEE_MIN_MS, Math.round((shift / MARQUEE_PX_PER_SECOND) * 1000))
|
|
2271
|
+
return { shift, duration }
|
|
2272
|
+
}
|
|
2273
|
+
|
|
2274
|
+
/**
|
|
2275
|
+
* Does the user ask the OS to reduce motion? The marquee is decorative, so
|
|
2276
|
+
* it is skipped entirely under that preference — the ellipsis and the row
|
|
2277
|
+
* tooltip already carry the full name.
|
|
2278
|
+
* @returns {boolean} true when motion should be suppressed.
|
|
2279
|
+
*/
|
|
2280
|
+
const prefersReducedMotion = () => {
|
|
2281
|
+
try {
|
|
2282
|
+
return typeof window !== 'undefined'
|
|
2283
|
+
&& typeof window.matchMedia === 'function'
|
|
2284
|
+
&& window.matchMedia('(prefers-reduced-motion: reduce)').matches === true
|
|
2285
|
+
} catch {
|
|
2286
|
+
return false
|
|
2287
|
+
}
|
|
2288
|
+
}
|
|
2289
|
+
|
|
2290
|
+
/**
|
|
2291
|
+
* The sliding part of a row's primary text.
|
|
2292
|
+
*
|
|
2293
|
+
* The row wraps it in the clipped window, so the pass runs on the track
|
|
2294
|
+
* alone: everything AFTER the window (the variant tail, the catalog badge,
|
|
2295
|
+
* the pending spinner) is right-aligned and never pushed along.
|
|
2296
|
+
* `shift`/`duration` arrive as inline values because they depend on this
|
|
2297
|
+
* row's measurement.
|
|
2298
|
+
* @param {object} props - `shift` (px), `duration` (ms), `children`.
|
|
2299
|
+
* @returns {object} the track element.
|
|
2300
|
+
*/
|
|
2301
|
+
const renderNameTrack = props => {
|
|
2302
|
+
const { shift, duration, children } = props
|
|
2303
|
+
const running = shift > 0
|
|
2304
|
+
return h('span', {
|
|
2305
|
+
className: 'dshmf-rowNameTrack dshmf-rowName',
|
|
2306
|
+
'data-running': running ? 'true' : undefined,
|
|
2307
|
+
style: running
|
|
2308
|
+
? {
|
|
2309
|
+
'--dshmf-marquee-shift': `-${shift}px`,
|
|
2310
|
+
'--dshmf-marquee-duration': `${duration}ms`,
|
|
2311
|
+
}
|
|
2312
|
+
: undefined,
|
|
2313
|
+
}, children)
|
|
2314
|
+
}
|
|
2315
|
+
|
|
2316
|
+
/**
|
|
2317
|
+
* The sliding part of a group title (DEC-023).
|
|
2318
|
+
*
|
|
2319
|
+
* The band plays the role the row plays for a model name: it is the frame,
|
|
2320
|
+
* and it never moves. The text inside is a clipped window holding a track,
|
|
2321
|
+
* so a heading too long for its column (`ModelScope Token Plan`) runs the
|
|
2322
|
+
* SAME one-pass eased marquee a truncated model name does, while the band's
|
|
2323
|
+
* fill, frame, radius and elevation stay put.
|
|
2324
|
+
* @param {object} props - `shift` (px), `duration` (ms), `children`.
|
|
2325
|
+
* @returns {object} the track element.
|
|
2326
|
+
*/
|
|
2327
|
+
const renderTitleTrack = props => {
|
|
2328
|
+
const { shift, duration, children } = props
|
|
2329
|
+
const running = shift > 0
|
|
2330
|
+
return h('span', {
|
|
2331
|
+
className: 'dshmf-groupTitleTrack',
|
|
2332
|
+
'data-running': running ? 'true' : undefined,
|
|
2333
|
+
style: running
|
|
2334
|
+
? {
|
|
2335
|
+
'--dshmf-marquee-shift': `-${shift}px`,
|
|
2336
|
+
'--dshmf-marquee-duration': `${duration}ms`,
|
|
2337
|
+
}
|
|
2338
|
+
: undefined,
|
|
2339
|
+
}, children)
|
|
1512
2340
|
}
|
|
1513
2341
|
|
|
1514
2342
|
// ---------------------------------------------------------------------
|
|
@@ -1523,7 +2351,7 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
|
|
|
1523
2351
|
locked, sessionId, t,
|
|
1524
2352
|
available, directory, load, select, resolveText, resolveDirectory,
|
|
1525
2353
|
teamAvailable, setTeamMode, useProjection, useSession, useSessions,
|
|
1526
|
-
insufficient,
|
|
2354
|
+
insufficient, displayMode, setDisplayMode, nativeAvailable,
|
|
1527
2355
|
} = props
|
|
1528
2356
|
|
|
1529
2357
|
// The store may arrive after the first render (the Session binds later),
|
|
@@ -1545,6 +2373,29 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
|
|
|
1545
2373
|
const getMarks = useCallback(() => markFace.getSnapshot(), [markFace])
|
|
1546
2374
|
const marks = useSyncExternalStore(subscribeMarks, getMarks, getMarks)
|
|
1547
2375
|
|
|
2376
|
+
// The display mode is shared page state, not panel state: the composer-row
|
|
2377
|
+
// chip that brings the panel back reads the SAME store while this seat is
|
|
2378
|
+
// disposed. The fallback keeps the hook shape stable when the face was
|
|
2379
|
+
// degraded.
|
|
2380
|
+
const modeFace = displayMode ?? FLAT_DISPLAY_MODE
|
|
2381
|
+
const subscribeMode = useCallback(listener => modeFace.subscribe(listener), [modeFace])
|
|
2382
|
+
const getMode = useCallback(() => modeFace.getSnapshot(), [modeFace])
|
|
2383
|
+
const mode = useSyncExternalStore(subscribeMode, getMode, getMode)
|
|
2384
|
+
// Resolved per render: the shipped entry may register after this face was
|
|
2385
|
+
// built, and the button has to enable itself without a reload.
|
|
2386
|
+
const canGoNative = typeof nativeAvailable === 'function' && nativeAvailable() === true
|
|
2387
|
+
// The panel only ever renders while it OWNS the cell, so the stored mode
|
|
2388
|
+
// and the visible mode can disagree in exactly one case: `native` was
|
|
2389
|
+
// persisted but there is nothing to inherit the cell, so the controller
|
|
2390
|
+
// refused to step aside and this panel is still on screen. Reporting the
|
|
2391
|
+
// stored value then would paint the button pressed while the panel shows —
|
|
2392
|
+
// so report the mode that is actually in effect (DEC-027).
|
|
2393
|
+
const modeIsNative = mode === 'native' && canGoNative
|
|
2394
|
+
const switchMode = next => {
|
|
2395
|
+
if (typeof setDisplayMode === 'function') setDisplayMode(next)
|
|
2396
|
+
else modeFace.set(next)
|
|
2397
|
+
}
|
|
2398
|
+
|
|
1548
2399
|
// Keep the store attached to this Session's feed while the seat is mounted.
|
|
1549
2400
|
// It is shared, so unmounting must NOT dispose it — the marks belong to the
|
|
1550
2401
|
// page, not to this seat.
|
|
@@ -1579,6 +2430,70 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
|
|
|
1579
2430
|
const confirmRef = useRef(null)
|
|
1580
2431
|
const id = useId()
|
|
1581
2432
|
|
|
2433
|
+
// ---- Model-name marquee (DEC-021)
|
|
2434
|
+
// A name wider than its row is ellipsised ("DeepSeek Reasone…"). Resting
|
|
2435
|
+
// the pointer on such a row for MARQUEE_DELAY starts the marquee; moving
|
|
2436
|
+
// away stops it. Only one row can run at a time, and the sliding element
|
|
2437
|
+
// is the name TRACK only, so right-aligned trailing content (the catalog
|
|
2438
|
+
// badge — 限时免费, ×0.1 — and the pending spinner) never moves.
|
|
2439
|
+
// A group title is the same shape one level up (DEC-023): the band is the
|
|
2440
|
+
// frame and its text slides inside it, so a truncated heading runs the
|
|
2441
|
+
// same pass. Rows and titles therefore share this ONE state machine and
|
|
2442
|
+
// are told apart only by their key prefix.
|
|
2443
|
+
const [marquee, setMarquee] = useState({ key: '', shift: 0, duration: 0 })
|
|
2444
|
+
const marqueeTimer = useRef(null)
|
|
2445
|
+
/** The row whose delay is still running; '' when nothing is armed. */
|
|
2446
|
+
const marqueeArmed = useRef('')
|
|
2447
|
+
const idleMarquee = { key: '', shift: 0, duration: 0 }
|
|
2448
|
+
const clearMarqueeTimer = () => {
|
|
2449
|
+
if (marqueeTimer.current !== null) {
|
|
2450
|
+
clearTimeout(marqueeTimer.current)
|
|
2451
|
+
marqueeTimer.current = null
|
|
2452
|
+
}
|
|
2453
|
+
}
|
|
2454
|
+
/** Drop an armed delay (the pointer left before it fired). */
|
|
2455
|
+
const disarmMarquee = () => {
|
|
2456
|
+
clearMarqueeTimer()
|
|
2457
|
+
marqueeArmed.current = ''
|
|
2458
|
+
}
|
|
2459
|
+
/**
|
|
2460
|
+
* Pointer arrived on a marquee-able element. Leave any other one first,
|
|
2461
|
+
* then arm the delay. The measurement happens when it fires — the element
|
|
2462
|
+
* is laid out by then, and a name that fits never moves.
|
|
2463
|
+
* @param {string} key - the row's route key or the title's block key (one runner at a time).
|
|
2464
|
+
* @param {Element | null} element - the row (or band) the pointer entered.
|
|
2465
|
+
* @param {string} [selector] - the clipped window inside it.
|
|
2466
|
+
*/
|
|
2467
|
+
const enterRow = (key, element, selector = '.dshmf-rowNameWindow') => {
|
|
2468
|
+
disarmMarquee()
|
|
2469
|
+
marqueeArmed.current = key
|
|
2470
|
+
// Leaving the previous row stops its pass immediately.
|
|
2471
|
+
setMarquee(previous => (previous.key === '' ? previous : idleMarquee))
|
|
2472
|
+
marqueeTimer.current = setTimeout(() => {
|
|
2473
|
+
marqueeTimer.current = null
|
|
2474
|
+
if (marqueeArmed.current !== key) return
|
|
2475
|
+
marqueeArmed.current = ''
|
|
2476
|
+
const nameWindow = typeof element?.querySelector === 'function'
|
|
2477
|
+
? element.querySelector(selector)
|
|
2478
|
+
: null
|
|
2479
|
+
if (prefersReducedMotion()) return
|
|
2480
|
+
const geometry = marqueePassGeometry(nameClippedBy(nameWindow))
|
|
2481
|
+
if (geometry.shift === 0) return
|
|
2482
|
+
setMarquee({ key, shift: geometry.shift, duration: geometry.duration })
|
|
2483
|
+
}, MARQUEE_DELAY)
|
|
2484
|
+
}
|
|
2485
|
+
/**
|
|
2486
|
+
* Pointer left a row. An armed delay for THAT row is cancelled (so a
|
|
2487
|
+
* quick sweep never starts anything), and a running pass is dropped.
|
|
2488
|
+
* @param {string} key - the row the pointer left.
|
|
2489
|
+
*/
|
|
2490
|
+
const leaveRow = key => {
|
|
2491
|
+
if (marqueeArmed.current === key) disarmMarquee()
|
|
2492
|
+
setMarquee(previous => (previous.key === key ? idleMarquee : previous))
|
|
2493
|
+
}
|
|
2494
|
+
// Unmount safety: a pending timer must not outlive the seat.
|
|
2495
|
+
useEffect(() => () => { clearTimeout(marqueeTimer.current) }, [])
|
|
2496
|
+
|
|
1582
2497
|
// Standard props always carry these three hooks for a session-scope slot;
|
|
1583
2498
|
// the fallbacks keep the component renderable (rather than throwing) in a
|
|
1584
2499
|
// composition that omits one, and they are plain values so the hook order
|
|
@@ -1632,13 +2547,7 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
|
|
|
1632
2547
|
* @param {{id: string, name: unknown}} group - one provider entry.
|
|
1633
2548
|
* @returns {string} a display-ready label; never empty.
|
|
1634
2549
|
*/
|
|
1635
|
-
const providerLabelOf = group =>
|
|
1636
|
-
if (group.id === 'deepseek-account') return t('provider.account')
|
|
1637
|
-
const resolved = resolveText === undefined ? undefined : resolveText(group.name)
|
|
1638
|
-
if (typeof resolved === 'string' && resolved.trim() !== '') return resolved
|
|
1639
|
-
const fallback = fallbackText(group.name)
|
|
1640
|
-
return fallback.trim() === '' ? group.id : fallback
|
|
1641
|
-
}
|
|
2550
|
+
const providerLabelOf = group => providerLabel(group, t, resolveText)
|
|
1642
2551
|
/**
|
|
1643
2552
|
* A provider whose own name or id matches the query keeps its whole
|
|
1644
2553
|
* catalog: `dou` shows the `豆包(doubao)` provider with every model it
|
|
@@ -1695,10 +2604,12 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
|
|
|
1695
2604
|
for (const family of families) {
|
|
1696
2605
|
family.scored.sort((left, right) => left.score - right.score)
|
|
1697
2606
|
// Under family grouping each row's primary text is its provider and
|
|
1698
|
-
// the family name is NOT repeated (the heading already says it).
|
|
1699
|
-
//
|
|
1700
|
-
//
|
|
1701
|
-
//
|
|
2607
|
+
// the family name is NOT repeated (the heading already says it). The
|
|
2608
|
+
// heading is only half the picture though: it cannot say WHICH model
|
|
2609
|
+
// the row stands for (`Qwen` holds 3.8-Flash-Next and 3.8-27B), nor
|
|
2610
|
+
// carry the catalog's rate multiplier (`Deepseek-V4.1-Flash · x0.00`).
|
|
2611
|
+
// Both move into a tail at the END of the row, drawn from the model
|
|
2612
|
+
// name with the family heading and the provider's own label removed.
|
|
1702
2613
|
const byProvider = new Map()
|
|
1703
2614
|
for (const item of family.scored) {
|
|
1704
2615
|
const list = byProvider.get(item.entry.providerId) ?? []
|
|
@@ -1706,17 +2617,21 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
|
|
|
1706
2617
|
byProvider.set(item.entry.providerId, list)
|
|
1707
2618
|
}
|
|
1708
2619
|
for (const list of byProvider.values()) {
|
|
1709
|
-
if (list.length <= 1) continue
|
|
1710
2620
|
const used = new Map()
|
|
1711
2621
|
for (const item of list) {
|
|
1712
|
-
// An empty
|
|
1713
|
-
// family
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
2622
|
+
// An empty tail is valid: a model whose name says nothing beyond
|
|
2623
|
+
// the family and the provider draws no tail at all.
|
|
2624
|
+
const variant = rowVariantOf(
|
|
2625
|
+
item.entry.model,
|
|
2626
|
+
family.title,
|
|
2627
|
+
item.entry.providerLabel,
|
|
2628
|
+
)
|
|
1717
2629
|
item.entry.rowVariant = variant
|
|
1718
2630
|
used.set(variant, (used.get(variant) ?? 0) + 1)
|
|
1719
2631
|
}
|
|
2632
|
+
// Two rows of one provider that still read alike (the same name
|
|
2633
|
+
// under different ids) fall back to the unique model id, so no two
|
|
2634
|
+
// rows in the grid are indistinguishable.
|
|
1720
2635
|
if ([...used.values()].some(count => count > 1)) {
|
|
1721
2636
|
for (const item of list) {
|
|
1722
2637
|
if (used.get(item.entry.rowVariant) > 1) {
|
|
@@ -1758,27 +2673,66 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
|
|
|
1758
2673
|
const effortChoices = reasoning === undefined ? [] : reasoning.efforts
|
|
1759
2674
|
const busy = state.pending !== null
|
|
1760
2675
|
|
|
2676
|
+
// What decides which fixed sections EXIST — the measurement effect's
|
|
2677
|
+
// dependency set (DEC-026). The derived members are reduced to stable
|
|
2678
|
+
// keys so a new array/object identity per render cannot re-arm it.
|
|
2679
|
+
//
|
|
2680
|
+
// The footer itself is ALWAYS rendered now (it carries the display-mode
|
|
2681
|
+
// button), so what can move its height is which of its dynamic members
|
|
2682
|
+
// are present — the reasoning group and the teamwork row — not whether
|
|
2683
|
+
// the footer exists. Naming the key after those members keeps a footer
|
|
2684
|
+
// that merely gains or loses one of them a real measurement, while a
|
|
2685
|
+
// footer whose content is unchanged stays quiet.
|
|
2686
|
+
const teamRowShown = teamMode !== undefined && teamAvailable()
|
|
2687
|
+
const sectionCount = sections.length
|
|
2688
|
+
const footerKey = `${effortChoices.length > 0 ? 'reason' : 'none'}:${teamRowShown ? 'team' : 'none'}`
|
|
2689
|
+
const errorKey = state.error === null ? '' : state.error
|
|
2690
|
+
const confirmOpen = confirming
|
|
2691
|
+
const teamErrorKey = teamFailure === null ? '' : teamFailure
|
|
2692
|
+
|
|
1761
2693
|
// ---- measurement: probe metrics + fixed-section heights
|
|
1762
2694
|
// The reasoning group and the teamwork row now share ONE footer flex
|
|
1763
2695
|
// container, so measure that container once instead of the two children
|
|
1764
2696
|
// separately: when they sit on the same line the two heights overlap,
|
|
1765
2697
|
// and summing them would over-subtract from the grid's budget.
|
|
2698
|
+
//
|
|
2699
|
+
// This effect MUST NOT run on every render (DEC-026). `metrics.fixed`
|
|
2700
|
+
// feeds `availableHeight`, which decides the column plan, which changes
|
|
2701
|
+
// the panel's own height, which changes the next measurement: an
|
|
2702
|
+
// unconditional measure/setMetrics pass closes that into a feedback loop
|
|
2703
|
+
// and React aborts it with "Maximum update depth exceeded" (error #185).
|
|
2704
|
+
// A browser reaches it on its own — a web font swapping in, a scrollbar
|
|
2705
|
+
// appearing, a sub-pixel height drift — which is why the crash looked
|
|
2706
|
+
// spontaneous. Measuring is therefore tied to the inputs that can
|
|
2707
|
+
// actually move a section (the panel opening, the section list, the
|
|
2708
|
+
// locale, and the debounced replan), and the compared values are
|
|
2709
|
+
// QUANTIZED so sub-pixel noise can never be mistaken for a new layout.
|
|
1766
2710
|
useLayoutEffect(() => {
|
|
1767
2711
|
if (!open) return
|
|
1768
|
-
const rowH = probeRowRef.current?.getBoundingClientRect().height ?? 26
|
|
1769
|
-
const headH = probeHeadRef.current?.getBoundingClientRect().height ?? 20
|
|
2712
|
+
const rowH = quantizeMetric(probeRowRef.current?.getBoundingClientRect().height ?? 26)
|
|
2713
|
+
const headH = quantizeMetric(probeHeadRef.current?.getBoundingClientRect().height ?? 20)
|
|
1770
2714
|
const heights = [headRef, errorRef, footerRef, confirmRef, teamErrorRef]
|
|
1771
|
-
.map(ref => ref.current?.offsetHeight ?? 0)
|
|
2715
|
+
.map(ref => quantizeMetric(ref.current?.offsetHeight ?? 0))
|
|
1772
2716
|
.filter(height => height > 0)
|
|
1773
2717
|
const fixed = heights.reduce((sum, height) => sum + height, 0)
|
|
1774
2718
|
const fixedCount = heights.length
|
|
1775
|
-
setMetrics(previous => (previous.rowH
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
2719
|
+
setMetrics(previous => (metricChanged(previous.rowH, rowH)
|
|
2720
|
+
|| metricChanged(previous.headH, headH)
|
|
2721
|
+
|| metricChanged(previous.fixed, fixed)
|
|
2722
|
+
|| previous.fixedCount !== fixedCount
|
|
2723
|
+
? { ...previous, rowH, headH, fixed, fixedCount }
|
|
2724
|
+
: previous))
|
|
2725
|
+
}, [open, sectionCount, footerKey, errorKey, confirmOpen, teamErrorKey, replan])
|
|
1780
2726
|
|
|
1781
2727
|
// ---- placement: above the trigger, kept inside the viewport
|
|
2728
|
+
//
|
|
2729
|
+
// Like the measurement above, this must NOT re-run on every render
|
|
2730
|
+
// (DEC-026): `place()` writes `metrics.box`, and the panel's height —
|
|
2731
|
+
// which `place()` reads — is derived from `metrics`. Left unconditional,
|
|
2732
|
+
// a panel whose height rounds differently at the new position re-places
|
|
2733
|
+
// itself forever, which is the same #185 abort the measure loop caused.
|
|
2734
|
+
// It is therefore armed on the inputs that move the panel (opening, the
|
|
2735
|
+
// plan, the viewport) and observes resize/scroll directly.
|
|
1782
2736
|
useLayoutEffect(() => {
|
|
1783
2737
|
if (!open) {
|
|
1784
2738
|
setMetrics(previous => (previous.box === null ? previous : { ...previous, box: null }))
|
|
@@ -1810,7 +2764,10 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
|
|
|
1810
2764
|
window.removeEventListener('resize', place)
|
|
1811
2765
|
window.removeEventListener('scroll', place, true)
|
|
1812
2766
|
}
|
|
1813
|
-
|
|
2767
|
+
// `plan` is derived from these metrics, so re-placing on the metrics is
|
|
2768
|
+
// the same trigger one level up — and it avoids reading `plan` before
|
|
2769
|
+
// it is computed.
|
|
2770
|
+
}, [open, metrics.fixed, metrics.rowH, metrics.headH, metrics.fixedCount])
|
|
1814
2771
|
|
|
1815
2772
|
// Re-plan once fonts settle and on panel resize (debounced).
|
|
1816
2773
|
useEffect(() => {
|
|
@@ -1961,6 +2918,101 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
|
|
|
1961
2918
|
return { label, tone: badge.tone ?? 'neutral' }
|
|
1962
2919
|
}
|
|
1963
2920
|
|
|
2921
|
+
// ---- the trigger's label cell (DEC-030)
|
|
2922
|
+
//
|
|
2923
|
+
// The shipped picker does not paint a model name on its trigger: it
|
|
2924
|
+
// renders the `conversation.input.model.label` slot and lets the winner
|
|
2925
|
+
// decorate the name. Connection plugins use that seam for things the
|
|
2926
|
+
// catalog does not carry — a promotion chip, a rate, and workbuddy's
|
|
2927
|
+
// load mark (one/two/three queued people, or a rocket when idle). Drawing
|
|
2928
|
+
// a bare name here therefore loses decoration the native trigger shows.
|
|
2929
|
+
//
|
|
2930
|
+
// This plugin cannot DECLARE that slot (the shipped entry already did, and
|
|
2931
|
+
// a second declaration throws), but it does not need to: the declaration
|
|
2932
|
+
// outlives our shadowing entry, so `entriesOfSlot` still names its owner
|
|
2933
|
+
// and that owner's component can be rendered directly with the seats it
|
|
2934
|
+
// expects. The occupant is third-party code, so the whole cell sits behind
|
|
2935
|
+
// its own error boundary — a crashing chip must cost the chip, never the
|
|
2936
|
+
// seat.
|
|
2937
|
+
const labelSlot = props.labelSlot ?? NO_LABEL_SLOT
|
|
2938
|
+
const subscribeLabel = useCallback(listener => labelSlot.subscribe(listener), [labelSlot])
|
|
2939
|
+
const getLabelVersion = useCallback(() => labelSlot.getSnapshot(), [labelSlot])
|
|
2940
|
+
useSyncExternalStore(subscribeLabel, getLabelVersion, getLabelVersion)
|
|
2941
|
+
// Re-read on every render: the winner can change without the version
|
|
2942
|
+
// changing (a disposed entry re-elected to its predecessor in the same
|
|
2943
|
+
// microtask batch), and the read is a map lookup.
|
|
2944
|
+
const labelEntry = typeof props.labelEntry === 'function'
|
|
2945
|
+
? derive('reading the model label slot', () => props.labelEntry(), undefined)
|
|
2946
|
+
: undefined
|
|
2947
|
+
/**
|
|
2948
|
+
* The occupant's own props: the shipped owner share, exactly as the native
|
|
2949
|
+
* picker passes it. Memoized on the identity of what it names, so the
|
|
2950
|
+
* occupant does not see a "new" provider/model object on every paint.
|
|
2951
|
+
*/
|
|
2952
|
+
const labelOwnerProps = useMemo(() => {
|
|
2953
|
+
if (currentModel === null || currentModel === undefined) return undefined
|
|
2954
|
+
const group = currentModel.group
|
|
2955
|
+
return {
|
|
2956
|
+
provider: {
|
|
2957
|
+
id: group.id,
|
|
2958
|
+
// The catalog's own name, unresolved: the occupant resolves it with
|
|
2959
|
+
// its own locale seat, exactly as it does inside the native picker.
|
|
2960
|
+
name: group.id === 'deepseek-account' ? t('provider.account') : group.name,
|
|
2961
|
+
},
|
|
2962
|
+
model: currentModel.model,
|
|
2963
|
+
placement: 'trigger',
|
|
2964
|
+
selected: true,
|
|
2965
|
+
}
|
|
2966
|
+
}, [currentModel, t])
|
|
2967
|
+
/**
|
|
2968
|
+
* The occupant's props, assembled once per (entry, model, label text).
|
|
2969
|
+
*
|
|
2970
|
+
* The identity has to be stable across renders: the occupant is an
|
|
2971
|
+
* ordinary React component, and a fresh props object on every paint makes
|
|
2972
|
+
* a memoized one re-render forever. `entry.inject(sessionId)` is the
|
|
2973
|
+
* expensive half (it reads services and builds a face), so it is part of
|
|
2974
|
+
* the same memo rather than a separate call per render.
|
|
2975
|
+
*/
|
|
2976
|
+
const labelNode = useMemo(() => {
|
|
2977
|
+
if (labelEntry === undefined || labelOwnerProps === undefined) return undefined
|
|
2978
|
+
const Occupant = labelEntry.component
|
|
2979
|
+
const face = typeof props.labelFace === 'function'
|
|
2980
|
+
? derive('building the model label face', () => props.labelFace(labelEntry), {})
|
|
2981
|
+
: EMPTY_LABEL_FACE
|
|
2982
|
+
const translate = typeof props.labelSeatFor === 'function'
|
|
2983
|
+
? derive('binding the model label locale', () => props.labelSeatFor(labelEntry.locale), identityTranslate)
|
|
2984
|
+
: identityTranslate
|
|
2985
|
+
return h(LabelBoundary, {
|
|
2986
|
+
// Keyed by the winner, so a change of occupant remounts rather than
|
|
2987
|
+
// handing one plugin's component another plugin's state.
|
|
2988
|
+
key: `label-${labelEntry.options?.id ?? labelEntry.registrant ?? 'entry'}`,
|
|
2989
|
+
fallback: undefined,
|
|
2990
|
+
}, h(Occupant, { ...face, t: translate, ...labelOwnerProps }))
|
|
2991
|
+
}, [labelEntry, labelOwnerProps, props.labelFace, props.labelSeatFor])
|
|
2992
|
+
|
|
2993
|
+
/**
|
|
2994
|
+
* What the trigger draws when no label occupant answered: the catalog
|
|
2995
|
+
* name, plus the model's own `badge` chip.
|
|
2996
|
+
*
|
|
2997
|
+
* This is the shipped picker's own fallback (name + `Tag`), reproduced
|
|
2998
|
+
* rather than imported — the host's `Tag` lives in a primitives package
|
|
2999
|
+
* this plugin does not depend on, and the chip's geometry is four
|
|
3000
|
+
* declarations. Drawing the badge here matters even with an occupant
|
|
3001
|
+
* installed: an occupant that wins the cell but does not reproduce a
|
|
3002
|
+
* sibling's `model.badge` (or no occupant at all) would otherwise lose the
|
|
3003
|
+
* promotion marker the native trigger shows.
|
|
3004
|
+
*/
|
|
3005
|
+
const triggerFallbackLabel = () => {
|
|
3006
|
+
const badge = triggerModel === undefined ? null : badgeOf(triggerModel)
|
|
3007
|
+
if (badge === null) return h('span', { className: 'dshmf-triggerName' }, modelLabel)
|
|
3008
|
+
return h('span', { className: 'dshmf-triggerLabel' },
|
|
3009
|
+
// The shipped fallback puts the badge BEFORE the name on the trigger
|
|
3010
|
+
// and after it in menus; the trigger is what this draws.
|
|
3011
|
+
h('span', { className: 'dshmf-triggerBadge', 'data-tone': badge.tone }, badge.label),
|
|
3012
|
+
h('span', { className: 'dshmf-triggerName' }, modelLabel),
|
|
3013
|
+
)
|
|
3014
|
+
}
|
|
3015
|
+
|
|
1964
3016
|
/**
|
|
1965
3017
|
* Split a model name into React children, lifting any "xN" multiplier
|
|
1966
3018
|
* marker (rate/size tags like "x0.01", "x1.5", "X 2.2") into a smaller,
|
|
@@ -2011,8 +3063,12 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
|
|
|
2011
3063
|
// yet: the row text is struck through until a later call for the same
|
|
2012
3064
|
// model route returns anything else.
|
|
2013
3065
|
const insufficientRoute = marks.marks.get(routeKeyOf(providerId, model.id)) === true
|
|
3066
|
+
// The marquee runs one row at a time, keyed by the model route so a
|
|
3067
|
+
// re-render cannot start the wrong row (DEC-021).
|
|
3068
|
+
const routeKey = `${providerId}/${model.id}`
|
|
3069
|
+
const running = marquee.key === routeKey
|
|
2014
3070
|
return h('button', {
|
|
2015
|
-
key:
|
|
3071
|
+
key: routeKey,
|
|
2016
3072
|
type: 'button',
|
|
2017
3073
|
className: 'dshmf-row',
|
|
2018
3074
|
'aria-pressed': selected,
|
|
@@ -2020,15 +3076,37 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
|
|
|
2020
3076
|
title: rowTitle,
|
|
2021
3077
|
disabled: busy,
|
|
2022
3078
|
onClick: () => { chooseModel(providerId, model) },
|
|
3079
|
+
// The pointer must REST for MARQUEE_DELAY before anything moves, so
|
|
3080
|
+
// sweeping across the grid never triggers it. Measuring happens in
|
|
3081
|
+
// the timeout, when the row is laid out and the panel is settled.
|
|
3082
|
+
onMouseEnter: event => { enterRow(routeKey, event?.currentTarget) },
|
|
3083
|
+
onMouseLeave: () => { leaveRow(routeKey) },
|
|
3084
|
+
// Keyboard focus counts as "resting on the row" for the same reason
|
|
3085
|
+
// the tooltip does: a focused row is one the user is looking at.
|
|
3086
|
+
onFocus: event => { enterRow(routeKey, event?.currentTarget) },
|
|
3087
|
+
onBlur: () => { leaveRow(routeKey) },
|
|
2023
3088
|
},
|
|
2024
3089
|
// `nameRuns` (xN multiplier markers) is applied to whatever the
|
|
2025
3090
|
// primary text is: the model name under provider grouping, or the
|
|
2026
|
-
// provider name under family grouping.
|
|
2027
|
-
|
|
3091
|
+
// provider name under family grouping. Only the track (inside the
|
|
3092
|
+
// clipped window) moves; the variant tail, the badge and the spinner
|
|
3093
|
+
// are siblings AFTER the window and stay put on the right.
|
|
3094
|
+
h('span', { className: 'dshmf-rowNameWindow' },
|
|
3095
|
+
running
|
|
3096
|
+
? renderNameTrack({ shift: marquee.shift, duration: marquee.duration, children: nameRuns(primary) })
|
|
3097
|
+
: h('span', { className: 'dshmf-rowNameTrack dshmf-rowName' }, ...nameRuns(primary)),
|
|
3098
|
+
),
|
|
3099
|
+
// The model-specific tail sits at the END of the row, after the
|
|
3100
|
+
// primary text: the heading already names the family, and the
|
|
3101
|
+
// provider is the primary text, so what is LEFT is the型号 and the
|
|
3102
|
+
// multiplier (`3.8-Flash-Next`, `V4.1-Flash · x0.01`). It runs
|
|
3103
|
+
// through `nameRuns` too, so a multiplier in the tail keeps the
|
|
3104
|
+
// same two-sizes-smaller treatment it gets inside a model name.
|
|
3105
|
+
// It shrinks before the primary text and never wraps.
|
|
2028
3106
|
showVariant ? h('span', {
|
|
2029
3107
|
className: 'dshmf-rowProvider',
|
|
2030
3108
|
title: rowVariant,
|
|
2031
|
-
}, rowVariant) : null,
|
|
3109
|
+
}, ...nameRuns(rowVariant)) : null,
|
|
2032
3110
|
badge === null ? null : h('span', {
|
|
2033
3111
|
className: 'dshmf-badge',
|
|
2034
3112
|
'data-tone': badge.tone,
|
|
@@ -2042,12 +3120,34 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
|
|
|
2042
3120
|
? 'status.loading'
|
|
2043
3121
|
: totalModels === 0 ? 'empty.models' : 'empty.search'
|
|
2044
3122
|
|
|
2045
|
-
|
|
2046
|
-
|
|
3123
|
+
// A group title is a marquee-able band like a model row (DEC-023): the
|
|
3124
|
+
// band itself is the frame and never moves, while its text sits in a
|
|
3125
|
+
// clipped window around a track that slides on hover when the heading
|
|
3126
|
+
// does not fit its column. Titles and rows share one state machine, keyed
|
|
3127
|
+
// by `block.key` (`t:…`) versus a row's route key, so only one thing in
|
|
3128
|
+
// the whole panel scrolls at a time. The band keeps its `title` tooltip
|
|
3129
|
+
// for the hover case the OS tooltip covers.
|
|
3130
|
+
const renderTitle = block => {
|
|
3131
|
+
const running = marquee.key === block.key
|
|
3132
|
+
return h('div', {
|
|
2047
3133
|
className: 'dshmf-groupTitle',
|
|
2048
3134
|
key: block.key,
|
|
2049
3135
|
title: block.label,
|
|
2050
|
-
|
|
3136
|
+
// A heading has no action of its own: these handlers only arm the
|
|
3137
|
+
// marquee, so keyboard focus is deliberately NOT a trigger.
|
|
3138
|
+
onMouseEnter: event => { enterRow(block.key, event?.currentTarget, '.dshmf-groupTitleWindow') },
|
|
3139
|
+
onMouseLeave: () => { leaveRow(block.key) },
|
|
3140
|
+
},
|
|
3141
|
+
h('span', { className: 'dshmf-groupTitleWindow' },
|
|
3142
|
+
running
|
|
3143
|
+
? renderTitleTrack({ shift: marquee.shift, duration: marquee.duration, children: block.label })
|
|
3144
|
+
: h('span', { className: 'dshmf-groupTitleTrack' }, block.label),
|
|
3145
|
+
),
|
|
3146
|
+
)
|
|
3147
|
+
}
|
|
3148
|
+
|
|
3149
|
+
const renderBlock = block => (block.kind === 'title'
|
|
3150
|
+
? renderTitle(block)
|
|
2051
3151
|
: renderRow(block.entry))
|
|
2052
3152
|
|
|
2053
3153
|
// Each child is placed EXPLICITLY (`grid-column` below), never by
|
|
@@ -2154,11 +3254,16 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
|
|
|
2154
3254
|
}
|
|
2155
3255
|
setSelectionFocus(false)
|
|
2156
3256
|
triggerRef.current?.focus()
|
|
3257
|
+
// Opening the panel IS showing the models: ask the store to re-read
|
|
3258
|
+
// the clock first, so a strike that went stale while the panel was
|
|
3259
|
+
// closed is gone from the rows this click draws (DEC-024).
|
|
3260
|
+
if (typeof markFace.refresh === 'function') markFace.refresh()
|
|
2157
3261
|
setOpen(true)
|
|
2158
3262
|
},
|
|
2159
3263
|
},
|
|
2160
3264
|
svg(PATH_MODELS, 16, { className: 'dshmf-triggerIcon' }),
|
|
2161
|
-
h('span', { className: 'dshmf-triggerText' },
|
|
3265
|
+
h('span', { className: 'dshmf-triggerText' },
|
|
3266
|
+
labelNode === undefined ? triggerFallbackLabel() : labelNode),
|
|
2162
3267
|
effortLabel === undefined ? null : h('span', { className: 'dshmf-triggerEffort' }, effortLabel),
|
|
2163
3268
|
busy
|
|
2164
3269
|
? h('span', { className: 'dshmf-spin' })
|
|
@@ -2253,15 +3358,14 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
|
|
|
2253
3358
|
state.failures.length === 0 ? null : state.failures.map(renderFailure),
|
|
2254
3359
|
),
|
|
2255
3360
|
|
|
2256
|
-
// ---- fixed:
|
|
2257
|
-
//
|
|
2258
|
-
//
|
|
2259
|
-
// is
|
|
2260
|
-
//
|
|
2261
|
-
// they fit with the 30px gap, otherwise each takes its own
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
: h('div', { className: 'dshmf-footer', ref: footerRef },
|
|
3361
|
+
// ---- fixed, bottom: the adaptive footer.
|
|
3362
|
+
// It ALWAYS renders now: the reasoning group, the teamwork row and
|
|
3363
|
+
// the display-mode button live in ONE flex container, and the mode
|
|
3364
|
+
// button is the one member that is always present (the other two
|
|
3365
|
+
// come and go with the model and the remote scope). They share a
|
|
3366
|
+
// line when they fit with the 30px gap, otherwise each takes its own
|
|
3367
|
+
// line; the mode button is pushed to the trailing edge.
|
|
3368
|
+
h('div', { className: 'dshmf-footer', ref: footerRef },
|
|
2265
3369
|
effortChoices.length === 0 ? null : h('div', {
|
|
2266
3370
|
className: 'dshmf-reason',
|
|
2267
3371
|
ref: reasonRef,
|
|
@@ -2316,6 +3420,30 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
|
|
|
2316
3420
|
onClick: () => { changeTeamMode(!teamMode.enabled) },
|
|
2317
3421
|
}, h('span', { className: 'dshmf-thumb' })),
|
|
2318
3422
|
),
|
|
3423
|
+
|
|
3424
|
+
// ---- display mode: hand the seat back to the Host's own
|
|
3425
|
+
// picker, or take it back. One button, `aria-pressed` carrying
|
|
3426
|
+
// the state. It is the footer's trailing member, so an auto left
|
|
3427
|
+
// margin pins it to the panel's bottom-right and the wrap rule
|
|
3428
|
+
// keeps every other member left-aligned.
|
|
3429
|
+
// Disabled while the Host ships no picker to hand over to — a
|
|
3430
|
+
// seat nobody would take must never empty the composer.
|
|
3431
|
+
// Clicking TOGGLES: a pressed button means native mode is in
|
|
3432
|
+
// effect, so it must offer the way back rather than re-assert
|
|
3433
|
+
// the mode that is already on.
|
|
3434
|
+
h('button', {
|
|
3435
|
+
type: 'button',
|
|
3436
|
+
className: 'dshmf-modeBtn',
|
|
3437
|
+
'aria-pressed': modeIsNative,
|
|
3438
|
+
'aria-label': modeIsNative
|
|
3439
|
+
? t('native.back')
|
|
3440
|
+
: (canGoNative ? t('native.enter') : t('native.unavailable')),
|
|
3441
|
+
title: modeIsNative
|
|
3442
|
+
? t('native.back')
|
|
3443
|
+
: (canGoNative ? t('native.enter') : t('native.unavailable')),
|
|
3444
|
+
disabled: !canGoNative,
|
|
3445
|
+
onClick: () => { switchMode(modeIsNative ? 'flat' : 'native') },
|
|
3446
|
+
}, t('native.label')),
|
|
2319
3447
|
),
|
|
2320
3448
|
|
|
2321
3449
|
// ---- fixed: teamwork disable confirmation
|
|
@@ -2354,14 +3482,20 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
|
|
|
2354
3482
|
}, teamFailure),
|
|
2355
3483
|
|
|
2356
3484
|
// ---- measurement probe (never painted). It mirrors a family-
|
|
2357
|
-
// grouped row (provider primary +
|
|
2358
|
-
//
|
|
2359
|
-
//
|
|
2360
|
-
//
|
|
3485
|
+
// grouped row (provider primary + model-specific tail) and a group
|
|
3486
|
+
// title, the two shapes the grid can draw, so the measured row and
|
|
3487
|
+
// head heights cover every mode. Both now use the window/track
|
|
3488
|
+
// split (DEC-021, DEC-023), so the probe mirrors that too — a track
|
|
3489
|
+
// inside a clipped window is what the grid actually sizes. It
|
|
3490
|
+
// carries no badge and no spinner: the selected row is a filled band
|
|
3491
|
+
// now and the spinner is only drawn for a pending selection.
|
|
2361
3492
|
h('div', { className: 'dshmf-probe', 'aria-hidden': 'true' },
|
|
2362
|
-
h('div', { ref: probeHeadRef, className: 'dshmf-groupTitle' },
|
|
3493
|
+
h('div', { ref: probeHeadRef, className: 'dshmf-groupTitle' },
|
|
3494
|
+
h('span', { className: 'dshmf-groupTitleWindow' },
|
|
3495
|
+
h('span', { className: 'dshmf-groupTitleTrack' }, 'X'))),
|
|
2363
3496
|
h('div', { ref: probeRowRef, className: 'dshmf-row' },
|
|
2364
|
-
h('span', { className: 'dshmf-
|
|
3497
|
+
h('span', { className: 'dshmf-rowNameWindow' },
|
|
3498
|
+
h('span', { className: 'dshmf-rowNameTrack dshmf-rowName' }, 'X')),
|
|
2365
3499
|
h('span', { className: 'dshmf-rowProvider' }, 'X'),
|
|
2366
3500
|
),
|
|
2367
3501
|
),
|
|
@@ -2371,6 +3505,43 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
|
|
|
2371
3505
|
)
|
|
2372
3506
|
}
|
|
2373
3507
|
|
|
3508
|
+
// ---------------------------------------------------------------------
|
|
3509
|
+
// Label boundary
|
|
3510
|
+
// ---------------------------------------------------------------------
|
|
3511
|
+
/**
|
|
3512
|
+
* Keep the trigger alive when the label cell's occupant fails.
|
|
3513
|
+
*
|
|
3514
|
+
* The occupant is another plugin's component, rendered here on the
|
|
3515
|
+
* documented owner-props contract but without the renderer's own per-entry
|
|
3516
|
+
* error boundary. It can therefore throw on a host whose props differ from
|
|
3517
|
+
* the ones it was written for, or on data it did not expect. Catching it
|
|
3518
|
+
* here costs the decoration, never the seat: the caller's `fallback` is
|
|
3519
|
+
* `undefined`, which makes the trigger draw the catalog name exactly as the
|
|
3520
|
+
* shipped picker does when the cell has no occupant.
|
|
3521
|
+
*
|
|
3522
|
+
* The report is once per failure, not once per render — a chip that throws
|
|
3523
|
+
* on every paint would otherwise flood the console.
|
|
3524
|
+
*/
|
|
3525
|
+
class LabelBoundary extends React.Component {
|
|
3526
|
+
constructor(props) {
|
|
3527
|
+
super(props)
|
|
3528
|
+
this.state = { failed: false }
|
|
3529
|
+
}
|
|
3530
|
+
|
|
3531
|
+
static getDerivedStateFromError() {
|
|
3532
|
+
return { failed: true }
|
|
3533
|
+
}
|
|
3534
|
+
|
|
3535
|
+
componentDidCatch(error, info) {
|
|
3536
|
+
console.error('dsh-model-flatmap: the model label slot failed; the trigger falls back to the catalog name', error, info)
|
|
3537
|
+
}
|
|
3538
|
+
|
|
3539
|
+
render() {
|
|
3540
|
+
if (this.state.failed) return this.props.fallback ?? null
|
|
3541
|
+
return this.props.children
|
|
3542
|
+
}
|
|
3543
|
+
}
|
|
3544
|
+
|
|
2374
3545
|
// ---------------------------------------------------------------------
|
|
2375
3546
|
// Seat boundary
|
|
2376
3547
|
// ---------------------------------------------------------------------
|
|
@@ -2452,6 +3623,68 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
|
|
|
2452
3623
|
h(ModelFlatMap, props))
|
|
2453
3624
|
}
|
|
2454
3625
|
|
|
3626
|
+
/**
|
|
3627
|
+
* The way back out of native mode.
|
|
3628
|
+
*
|
|
3629
|
+
* Native mode DISPOSES this plugin's `conversation.input.model` entry, so
|
|
3630
|
+
* the footer button that switched it disappears with the panel. This row
|
|
3631
|
+
* rides the HOST picker's own root menu instead: `conversation.input.model.
|
|
3632
|
+
* options` is the list child slot the shipped `ModelSelect` declares and
|
|
3633
|
+
* renders right after its Model / Effort rows, so the control appears at
|
|
3634
|
+
* the bottom of the native picker's own panel — the one place that both
|
|
3635
|
+
* survives the seat's disposal AND belongs to the control it acts on. No
|
|
3636
|
+
* host component is rendered, imported or copied; the slot is a public
|
|
3637
|
+
* declaration of the installed package.
|
|
3638
|
+
*
|
|
3639
|
+
* It renders NOTHING unless native mode is in effect, so the shipped menu
|
|
3640
|
+
* looks exactly as shipped while the flat panel is the one on screen.
|
|
3641
|
+
*
|
|
3642
|
+
* Visually it is NOT a menu row. Every row above it answers "which value?";
|
|
3643
|
+
* this one performs an action, so it sits below a separator rule and is
|
|
3644
|
+
* drawn as an outlined push button. The slot gives us only a
|
|
3645
|
+
* `display: contents` anchor, so the whole box is ours to style.
|
|
3646
|
+
*
|
|
3647
|
+
* It reads the same page-wide store as the panel's footer button, so a
|
|
3648
|
+
* switch made here is the switch the panel sees when it comes back.
|
|
3649
|
+
* @param props - the slot's standard props plus the injected mode face.
|
|
3650
|
+
*/
|
|
3651
|
+
function NativeMenuRow(props) {
|
|
3652
|
+
const {
|
|
3653
|
+
t, displayMode, setDisplayMode, nativeAvailable, dismiss,
|
|
3654
|
+
} = props
|
|
3655
|
+
const modeFace = displayMode ?? FLAT_DISPLAY_MODE
|
|
3656
|
+
const subscribeMode = useCallback(listener => modeFace.subscribe(listener), [modeFace])
|
|
3657
|
+
const getMode = useCallback(() => modeFace.getSnapshot(), [modeFace])
|
|
3658
|
+
const mode = useSyncExternalStore(subscribeMode, getMode, getMode)
|
|
3659
|
+
// The panel's own business: this row exists only while the Host's picker
|
|
3660
|
+
// is the seat on screen.
|
|
3661
|
+
if (mode !== 'native') return null
|
|
3662
|
+
const canGoFlat = typeof nativeAvailable !== 'function' || nativeAvailable() === true
|
|
3663
|
+
if (!canGoFlat) return null
|
|
3664
|
+
const translate = typeof t === 'function' ? t : key => key
|
|
3665
|
+
// A band whose top rule separates an ACTION from the value rows above it,
|
|
3666
|
+
// then a full-width outlined button inside. Keeping the anchor full-width
|
|
3667
|
+
// is what makes the rule span the menu; the button is what reads as
|
|
3668
|
+
// something to press rather than something to choose.
|
|
3669
|
+
return h('div', { className: 'dshmf-modeAction' },
|
|
3670
|
+
h('button', {
|
|
3671
|
+
type: 'button',
|
|
3672
|
+
className: 'dshmf-modeBtnRow',
|
|
3673
|
+
title: translate('native.back'),
|
|
3674
|
+
onClick: () => {
|
|
3675
|
+
// Let the host close its own menu before switching: the switch
|
|
3676
|
+
// disposes the entry that owns this menu, and closing on the host's
|
|
3677
|
+
// own terms (its state, its listener cleanup, its focus handoff)
|
|
3678
|
+
// beats having a portaled menu unmount underneath the click.
|
|
3679
|
+
if (typeof dismiss === 'function') dismiss()
|
|
3680
|
+
if (typeof setDisplayMode === 'function') setDisplayMode('flat')
|
|
3681
|
+
else modeFace.set('flat')
|
|
3682
|
+
},
|
|
3683
|
+
},
|
|
3684
|
+
svg(PATH_GRID, 13, { className: 'dshmf-modeBtnRowIcon' }),
|
|
3685
|
+
h('span', { className: 'dshmf-modeBtnRowLabel' }, translate('native.back'))))
|
|
3686
|
+
}
|
|
3687
|
+
|
|
2455
3688
|
// ---------------------------------------------------------------------
|
|
2456
3689
|
// Plugin
|
|
2457
3690
|
// ---------------------------------------------------------------------
|
|
@@ -2466,9 +3699,10 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
|
|
|
2466
3699
|
// Pure layout and grouping derivations plus the component itself,
|
|
2467
3700
|
// exposed for `node --test` so grouping, filtering and the column
|
|
2468
3701
|
// algorithm are pinned without a browser. The shell never reads this key.
|
|
2469
|
-
__test: { familyOf, familyRank, familyVariant: rowVariantOf, idVariantOf, matchScore, providerScore, searchTokens, buildUnits, columnHeight, packColumns, planColumns, readGrouping, writeGrouping, GROUPING_KEY, GROUPING_DEFAULT, isInsufficientBalance, decideMark, eventTimeOf, foldInsufficient, foldMarkEntry, routeOfHeader, routeKeyOf, MARKS_KEY, MARKS_TTL, parseMarks, serializeMarks, readMarks, writeMarks, createMarkStore },
|
|
3702
|
+
__test: { marqueePassGeometry, nameClippedBy, MARQUEE_DELAY, MARQUEE_PX_PER_SECOND, MARQUEE_MIN_MS, familyOf, familyRank, familyVariant: rowVariantOf, idVariantOf, matchScore, providerScore, searchTokens, buildUnits, columnHeight, packColumns, planColumns, readGrouping, writeGrouping, GROUPING_KEY, GROUPING_DEFAULT, readDisplayMode, writeDisplayMode, createDisplayModeStore, DISPLAY_MODE_KEY, DISPLAY_MODE_DEFAULT, FLAT_DISPLAY_MODE, isInsufficientBalance, decideMark, eventTimeOf, foldInsufficient, foldMarkEntry, routeOfHeader, routeKeyOf, MARKS_KEY, MARKS_TTL, STRIKE_TTL, markIsLive, parseMarks, serializeMarks, readMarks, writeMarks, createMarkStore, quantizeMetric, metricChanged, METRIC_HYSTERESIS, LabelBoundary, readLabelEntry, labelSeat, labelSlotSource, LABEL_SLOT, providerLabel },
|
|
2470
3703
|
__component: ModelFlatMap,
|
|
2471
3704
|
__seat: Seat,
|
|
3705
|
+
__nativeMenuRow: NativeMenuRow,
|
|
2472
3706
|
apply(ctx) {
|
|
2473
3707
|
const read = name => {
|
|
2474
3708
|
try {
|
|
@@ -2550,6 +3784,72 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
|
|
|
2550
3784
|
markStore = undefined
|
|
2551
3785
|
}
|
|
2552
3786
|
|
|
3787
|
+
// ---- the display-mode store (flat panel vs the Host's own picker)
|
|
3788
|
+
//
|
|
3789
|
+
// One store per activation, shared by the panel's footer button and the
|
|
3790
|
+
// composer-row chip that outlives it. `readDisplayMode` already returned
|
|
3791
|
+
// `flat` for a missing or unreadable store, so a persistence failure
|
|
3792
|
+
// costs the memory of the choice, never the control.
|
|
3793
|
+
let modeStore
|
|
3794
|
+
try {
|
|
3795
|
+
modeStore = createDisplayModeStore({
|
|
3796
|
+
readStored: readDisplayMode,
|
|
3797
|
+
writeStored: writeDisplayMode,
|
|
3798
|
+
})
|
|
3799
|
+
ctx.effect(() => () => { modeStore.dispose?.() }, 'dsh-model-flatmap: display mode')
|
|
3800
|
+
} catch (error) {
|
|
3801
|
+
console.error('dsh-model-flatmap: the display mode store failed to start', error)
|
|
3802
|
+
modeStore = createDisplayModeStore({ readStored: () => DISPLAY_MODE_DEFAULT })
|
|
3803
|
+
}
|
|
3804
|
+
|
|
3805
|
+
/**
|
|
3806
|
+
* Every OTHER live registration in the model seat.
|
|
3807
|
+
*
|
|
3808
|
+
* A `single` slot keeps every registration and elects the highest
|
|
3809
|
+
* priority; this plugin only owns the cell because it registers at a
|
|
3810
|
+
* negative priority. Disposing our own entry therefore hands the cell to
|
|
3811
|
+
* whatever is underneath — normally the shipped `ModelSelect` — with no
|
|
3812
|
+
* copy, import or re-mount, so an upgraded DSH shows its own newest
|
|
3813
|
+
* picker (DEC-027).
|
|
3814
|
+
* @returns the competing entries, or `undefined` when the Host cannot
|
|
3815
|
+
* enumerate them (an older version): unverifiable means "do not step
|
|
3816
|
+
* aside", never a blank composer.
|
|
3817
|
+
*/
|
|
3818
|
+
const otherEntries = () => {
|
|
3819
|
+
try {
|
|
3820
|
+
if (typeof slots.entries !== 'function') return undefined
|
|
3821
|
+
const all = slots.entries('conversation.input.model')
|
|
3822
|
+
if (!Array.isArray(all)) return undefined
|
|
3823
|
+
return all.filter(entry => entry?.component !== Seat)
|
|
3824
|
+
} catch {
|
|
3825
|
+
return undefined
|
|
3826
|
+
}
|
|
3827
|
+
}
|
|
3828
|
+
/**
|
|
3829
|
+
* May the panel hand the seat over? Only when another live entry exists
|
|
3830
|
+
* to inherit it. Deliberately does NOT require the shipped package name:
|
|
3831
|
+
* a composed profile may legitimately elect another plugin's seat, and
|
|
3832
|
+
* that is exactly what the user asked to see.
|
|
3833
|
+
* @returns whether disposing this entry leaves a model control behind.
|
|
3834
|
+
*/
|
|
3835
|
+
const canStepAside = () => {
|
|
3836
|
+
const others = otherEntries()
|
|
3837
|
+
return others !== undefined && others.length > 0
|
|
3838
|
+
}
|
|
3839
|
+
/** Report the inheriting entry once, so a support log names the target. */
|
|
3840
|
+
let inheritReported = false
|
|
3841
|
+
const reportInherit = () => {
|
|
3842
|
+
if (inheritReported) return
|
|
3843
|
+
inheritReported = true
|
|
3844
|
+
try {
|
|
3845
|
+
const others = otherEntries()
|
|
3846
|
+
const names = (others ?? []).map(entry => entry?.registrant ?? entry?.options?.id ?? 'unknown')
|
|
3847
|
+
console.info(`dsh-model-flatmap: the model seat is handed to ${names.join(', ') || 'no entry'}`)
|
|
3848
|
+
} catch {
|
|
3849
|
+
/* diagnostics only */
|
|
3850
|
+
}
|
|
3851
|
+
}
|
|
3852
|
+
|
|
2553
3853
|
/**
|
|
2554
3854
|
* Build the injected face for one Session.
|
|
2555
3855
|
* @param sessionId - the Session this seat renders for.
|
|
@@ -2637,6 +3937,37 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
|
|
|
2637
3937
|
? Promise.resolve(undefined)
|
|
2638
3938
|
: service.setEnabled(sessionId, enabled)
|
|
2639
3939
|
},
|
|
3940
|
+
// Display mode: the ONE page-wide store, read by both this seat's
|
|
3941
|
+
// footer button and the composer-row chip that takes over while
|
|
3942
|
+
// this seat is disposed.
|
|
3943
|
+
displayMode: modeStore,
|
|
3944
|
+
setDisplayMode: value => { modeStore.set(value) },
|
|
3945
|
+
// Lazy for the same reason as `teamAvailable`: the shipped entry can
|
|
3946
|
+
// register AFTER this face is built, and the button has to enable
|
|
3947
|
+
// itself without a reload.
|
|
3948
|
+
nativeAvailable: canStepAside,
|
|
3949
|
+
// The trigger's label: the entry that owns the shipped picker's
|
|
3950
|
+
// model-label cell, plus the two seats it needs to render (its own
|
|
3951
|
+
// locale namespace and its business inject). Resolved per call so a
|
|
3952
|
+
// connection plugin that registers a moment after boot still gets
|
|
3953
|
+
// its chip on the trigger without a reload (DEC-030).
|
|
3954
|
+
labelEntry: () => readLabelEntry(safeRead('slots')),
|
|
3955
|
+
labelSeatFor: namespace => labelSeat(safeRead('locale'), namespace),
|
|
3956
|
+
labelFace: entry => {
|
|
3957
|
+
if (entry === undefined || typeof entry.inject !== 'function') return {}
|
|
3958
|
+
try {
|
|
3959
|
+
// The renderer's own contract: a session-scoped entry's inject
|
|
3960
|
+
// receives the Session id and is merged UNDER the owner props.
|
|
3961
|
+
const face = entry.inject(sessionId)
|
|
3962
|
+
return face !== null && typeof face === 'object' ? face : {}
|
|
3963
|
+
} catch (error) {
|
|
3964
|
+
console.error('dsh-model-flatmap: the label slot face failed to build', error)
|
|
3965
|
+
return {}
|
|
3966
|
+
}
|
|
3967
|
+
},
|
|
3968
|
+
// A stable subscribe/getSnapshot pair so the trigger re-reads the
|
|
3969
|
+
// winning entry when a connection plugin registers or disposes.
|
|
3970
|
+
labelSlot: labelSlotSource(safeRead('slots')),
|
|
2640
3971
|
}
|
|
2641
3972
|
}
|
|
2642
3973
|
|
|
@@ -2669,6 +4000,13 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
|
|
|
2669
4000
|
// unstruck. It shares the module-level empty snapshot so the
|
|
2670
4001
|
// identity stays stable across renders.
|
|
2671
4002
|
insufficient: EMPTY_INSUFFICIENT,
|
|
4003
|
+
// Same principle for the mode: a stable flat snapshot and a
|
|
4004
|
+
// no-op setter keep the footer's hook shape intact. Stepping aside
|
|
4005
|
+
// is refused outright — a face this broken must not hand the
|
|
4006
|
+
// composer to anything.
|
|
4007
|
+
displayMode: FLAT_DISPLAY_MODE,
|
|
4008
|
+
setDisplayMode: () => {},
|
|
4009
|
+
nativeAvailable: () => false,
|
|
2672
4010
|
}
|
|
2673
4011
|
}
|
|
2674
4012
|
}
|
|
@@ -2695,11 +4033,108 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
|
|
|
2695
4033
|
return () => {}
|
|
2696
4034
|
}
|
|
2697
4035
|
|
|
4036
|
+
// ---- the mode controller
|
|
4037
|
+
//
|
|
4038
|
+
// Flat mode keeps this plugin's entry registered (it shadows the
|
|
4039
|
+
// shipped one at a negative priority). Native mode DISPOSES that entry,
|
|
4040
|
+
// which elects the shipped picker underneath. Mount and unmount are
|
|
4041
|
+
// therefore the two halves of one switch, and both are idempotent: the
|
|
4042
|
+
// notify path re-enters `syncMode` through our own slot mutations.
|
|
4043
|
+
let seatDispose
|
|
4044
|
+
/** Guard against re-entry from our own registration changes. */
|
|
4045
|
+
let syncing = false
|
|
4046
|
+
const mountSeat = () => {
|
|
4047
|
+
if (seatDispose !== undefined) return
|
|
4048
|
+
seatDispose = registerSeat()
|
|
4049
|
+
}
|
|
4050
|
+
const unmountSeat = () => {
|
|
4051
|
+
const dispose = seatDispose
|
|
4052
|
+
if (dispose === undefined) return
|
|
4053
|
+
seatDispose = undefined
|
|
4054
|
+
try {
|
|
4055
|
+
dispose()
|
|
4056
|
+
} catch (error) {
|
|
4057
|
+
console.error('dsh-model-flatmap: the model seat did not dispose cleanly', error)
|
|
4058
|
+
}
|
|
4059
|
+
}
|
|
4060
|
+
/**
|
|
4061
|
+
* Reconcile the registration with the store. Native mode only takes
|
|
4062
|
+
* effect when another entry can inherit the cell — otherwise the panel
|
|
4063
|
+
* stays, so a profile without the shipped picker never loses its model
|
|
4064
|
+
* control.
|
|
4065
|
+
*/
|
|
4066
|
+
const syncMode = () => {
|
|
4067
|
+
if (syncing) return
|
|
4068
|
+
syncing = true
|
|
4069
|
+
try {
|
|
4070
|
+
const wantsNative = modeStore.getSnapshot() === 'native'
|
|
4071
|
+
if (wantsNative && canStepAside()) {
|
|
4072
|
+
reportInherit()
|
|
4073
|
+
unmountSeat()
|
|
4074
|
+
} else {
|
|
4075
|
+
mountSeat()
|
|
4076
|
+
}
|
|
4077
|
+
} finally {
|
|
4078
|
+
syncing = false
|
|
4079
|
+
}
|
|
4080
|
+
}
|
|
4081
|
+
|
|
2698
4082
|
try {
|
|
2699
|
-
slots.inject('conversation.input.model',
|
|
4083
|
+
slots.inject('conversation.input.model', () => {
|
|
4084
|
+
syncMode()
|
|
4085
|
+
const offMode = modeStore.subscribe(syncMode)
|
|
4086
|
+
// The shipped entry may register after ours, and a competing entry
|
|
4087
|
+
// may unload: both change whether stepping aside is possible, so the
|
|
4088
|
+
// button's availability rides the same reconcile.
|
|
4089
|
+
const offSlot = typeof slots.subscribe === 'function'
|
|
4090
|
+
? slots.subscribe('conversation.input.model', syncMode)
|
|
4091
|
+
: undefined
|
|
4092
|
+
return () => {
|
|
4093
|
+
if (typeof offMode === 'function') offMode()
|
|
4094
|
+
if (typeof offSlot === 'function') offSlot()
|
|
4095
|
+
unmountSeat()
|
|
4096
|
+
}
|
|
4097
|
+
})
|
|
2700
4098
|
} catch (error) {
|
|
2701
4099
|
console.error('dsh-model-flatmap: slots.inject failed for the model seat', error)
|
|
2702
4100
|
}
|
|
4101
|
+
|
|
4102
|
+
// ---- the chip that brings the panel back from native mode
|
|
4103
|
+
//
|
|
4104
|
+
// Native mode disposes the seat, and with it the footer button that
|
|
4105
|
+
// switched. The way back rides the HOST picker's OWN root menu instead:
|
|
4106
|
+
// `conversation.input.model.options` is the list child slot the shipped
|
|
4107
|
+
// `ModelSelect` declares and renders after its Model / Effort rows, so
|
|
4108
|
+
// our row appears at the bottom of the native panel itself. It survives
|
|
4109
|
+
// the seat's disposal (it belongs to the shipped entry, not to ours) and
|
|
4110
|
+
// renders nothing while the flat panel is the seat on screen.
|
|
4111
|
+
try {
|
|
4112
|
+
if (typeof slots.inject === 'function') {
|
|
4113
|
+
slots.inject('conversation.input.model.options', () => {
|
|
4114
|
+
if (typeof slots.register !== 'function') return () => {}
|
|
4115
|
+
try {
|
|
4116
|
+
return slots.register({
|
|
4117
|
+
name: 'conversation.input.model.options',
|
|
4118
|
+
id: 'dsh-model-flatmap.native',
|
|
4119
|
+
order: 50,
|
|
4120
|
+
locale: NS,
|
|
4121
|
+
inject: () => ({
|
|
4122
|
+
displayMode: modeStore,
|
|
4123
|
+
setDisplayMode: value => { modeStore.set(value) },
|
|
4124
|
+
nativeAvailable: canStepAside,
|
|
4125
|
+
}),
|
|
4126
|
+
}, NativeMenuRow)
|
|
4127
|
+
} catch (error) {
|
|
4128
|
+
console.error('dsh-model-flatmap: the native-mode menu row was not registered', error)
|
|
4129
|
+
return () => {}
|
|
4130
|
+
}
|
|
4131
|
+
})
|
|
4132
|
+
}
|
|
4133
|
+
} catch (error) {
|
|
4134
|
+
// Losing the row costs the way BACK, never the way in: the panel and
|
|
4135
|
+
// its footer button still work, and a reload restores flat mode.
|
|
4136
|
+
console.error('dsh-model-flatmap: slots.inject failed for the native-mode menu row', error)
|
|
4137
|
+
}
|
|
2703
4138
|
},
|
|
2704
4139
|
}
|
|
2705
4140
|
},
|