dsh-model-flatmap 1.0.1 → 1.0.3
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 +25 -12
- package/README.zh.md +19 -11
- package/client.js +583 -62
- package/docs/DETAIL.md +65 -15
- package/docs/model-picker.jpeg +0 -0
- package/icon.svg +8 -8
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -48,29 +48,42 @@ levels, and the agent-teamwork switch on one surface:
|
|
|
48
48
|
`hy3` → `HY3`, `hy4free` / `hy4std` / `hy4-preview` → `HY4`, and a bare `hy`
|
|
49
49
|
→ `HY`. Unregistered brands still collapse their version tail; dictionary
|
|
50
50
|
families (GPT, Qwen…) are never split.
|
|
51
|
-
- **Rows are provider-primary.** Under *By family* the
|
|
52
|
-
the models are, so the family name is not repeated
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
51
|
+
- **Rows are provider-primary, with a model tail.** Under *By family* the
|
|
52
|
+
heading already says what the models are, so the family name is not repeated
|
|
53
|
+
in a row: the primary text is the provider name. What neither the heading nor
|
|
54
|
+
the provider can say — which model of the family the row is, and the
|
|
55
|
+
catalog's rate multiplier — is drawn as a quieter tail at the end of the row,
|
|
56
|
+
with the family name and the provider label stripped off: `Qwen3.8-27B` under
|
|
57
|
+
*Qwen* reads `3.8-27B`, and `Deepseek-V4.1-Flash · x0.01` under *DeepSeek*
|
|
58
|
+
reads `V4.1-Flash · x0.01` with the multiplier drawn smaller and bold. The
|
|
59
|
+
full model name and description stay in the tooltip.
|
|
60
|
+
- **Long names and long headings scroll on hover.** A clipped model name or
|
|
61
|
+
group heading runs one eased leftward pass after the pointer rests on it for
|
|
62
|
+
0.5 s, then stops; leaving resets it instantly. Only the text moves — the
|
|
63
|
+
trailing badge and the title's band never do.
|
|
56
64
|
- **Reasoning effort.** The current model's reasoning levels render as an
|
|
57
65
|
exclusive, keyboard-operable group ("推理等级" / "Reasoning effort") driven by
|
|
58
66
|
radio semantics.
|
|
59
67
|
- **Adaptive footer.** The reasoning group and the agent-teamwork switch share
|
|
60
68
|
one line when both fit with a 30 px gap, and fall back to one row each when
|
|
61
69
|
they do not — the break is CSS flex wrapping, not a measured pixel threshold.
|
|
62
|
-
- **Out-of-balance models are struck through — everywhere.** A model
|
|
63
|
-
call failed on insufficient balance, credit, or quota keeps a
|
|
64
|
-
on its row text until a later call for that same model returns
|
|
65
|
-
|
|
70
|
+
- **Out-of-balance models are struck through — everywhere, for a day.** A model
|
|
71
|
+
whose last call failed on insufficient balance, credit, or quota keeps a
|
|
72
|
+
`line-through` on its row text until a later call for that same model returns
|
|
73
|
+
anything else, and until the mark has gone 24 hours without a new attempt —
|
|
74
|
+
whichever comes first. A balance is topped up and a quota resets daily, so a
|
|
75
|
+
strike nobody refreshed is stale information, not a warning. The strike
|
|
76
|
+
follows the model the failing request actually used, and covers the
|
|
66
77
|
Host's `QUOTA` / `ACCOUNT_QUOTA` codes, a provider `402`, and a `429` whose
|
|
67
78
|
body reports an empty balance — a plain rate limit never strikes. Because a
|
|
68
79
|
balance is a property of the model route and its account, **not of one
|
|
69
80
|
conversation**, the marks are global: a failure seen in any Session strikes
|
|
70
81
|
the model in every Session and workspace, and the state is persisted to
|
|
71
|
-
`localStorage` so it survives a reload.
|
|
72
|
-
|
|
73
|
-
|
|
82
|
+
`localStorage` so it survives a reload. Every mark keeps the time of the
|
|
83
|
+
observation that made it, so opening the list a day later draws that row
|
|
84
|
+
plain. Observations are ordered by that same time, so the newest one wins and
|
|
85
|
+
replaying older history cannot resurrect a strike that has already ended —
|
|
86
|
+
by a later call or by age.
|
|
74
87
|
- **Theme-aware.** Styled with host theme tokens only; looks correct in both
|
|
75
88
|
light and dark themes.
|
|
76
89
|
|
package/README.zh.md
CHANGED
|
@@ -41,21 +41,29 @@
|
|
|
41
41
|
- **generation 要区分开。** 已注册品牌按 generation 分节:`hy3` 归 `HY3`、`hy4free` /
|
|
42
42
|
`hy4std` / `hy4-preview` 归 `HY4`,裸 `hy` 仍是 `HY`;未注册品牌仍折叠版本后缀。
|
|
43
43
|
词典家族(GPT、Qwen…)不拆 generation。
|
|
44
|
-
- **row content 以 provider
|
|
45
|
-
|
|
46
|
-
|
|
44
|
+
- **row content 以 provider 为主,末尾补型号尾标。** *按 family* 下标题已经说明模型
|
|
45
|
+
是什么,所以行里不再重复家族名,主文本改为 provider name。标题和 provider 都说不出
|
|
46
|
+
的信息——这一行究竟是家族里的哪个型号、以及目录给的倍数——放在行**末尾**一个更安静的
|
|
47
|
+
尾标里:把家族名和 provider label 从模型名里剥掉,`Qwen` 下的 `Qwen3.8-27B` 显示为
|
|
48
|
+
`3.8-27B`,`DeepSeek` 下的 `Deepseek-V4.1-Flash · x0.01` 显示为
|
|
49
|
+
`V4.1-Flash · x0.01`,其中倍数照旧用更小更粗的字重。完整模型名/描述保留在 tooltip。
|
|
50
|
+
- **长名称与长标题 hover 跑马灯。** 显示不全的模型名或分组标题,在鼠标停留 0.5 s 后
|
|
51
|
+
向左跑**一趟**(ease-in-out)即停,移开立刻复位。只有文字在动——右侧的 badge 和
|
|
52
|
+
标题所在的色带本身都不动。
|
|
47
53
|
- **推理等级。** 当前模型的推理等级渲染为一个互斥、可键盘操作的分组("推理等级",
|
|
48
54
|
基于 radio 语义)。
|
|
49
55
|
- **自适应底栏。** 推理等级组与智能体协作开关在放得下(中间至少 30 px)时共用一行,
|
|
50
56
|
放不下时各自占一行回到原来的两行布局——换行由 CSS flex 换行决定,不靠 JS 量像素。
|
|
51
|
-
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
57
|
+
- **额度不足的模型加删除线,全局生效,且只生效一天。** 某个模型的调用因额度/余额/信用不足
|
|
58
|
+
而失败后,该模型的行文字会带 `line-through` 删除线,直到同一个模型的下一次调用返回非额度
|
|
59
|
+
不足的结果,或者这条标记已经 24 小时没有新尝试——以先到者为准。余额会被充值、额度会按日
|
|
60
|
+
重置,所以一条没人更新的划线是**过时的信息**,不是警告。标记跟随失败那次请求实际使用的
|
|
61
|
+
模型;覆盖宿主的 `QUOTA` / `ACCOUNT_QUOTA` 代码、provider 的 `402`,以及**正文说明余额
|
|
62
|
+
不足的 `429`**——纯限流的 429 不会划线。因为「还有没有额度」是模型路由(及其账号)的属性、
|
|
63
|
+
**与单个会话无关**,所以标记是全局的:在任意会话里观察到的失败,会在所有会话与工作区里
|
|
64
|
+
同样划线;状态持久化在 `localStorage` 中,重载后仍然保留。每条标记都记下「这次判定发生的
|
|
65
|
+
时间」,因此一天之后再打开列表,那一行就是干净的。观察按同一个时间排序,最新的一条说了算,
|
|
66
|
+
所以回放更早的历史既不能让一条已被后续调用清除的划线复活,也不能让一条已经过期的复活。
|
|
59
67
|
- **跟随主题。** 仅使用宿主主题令牌着色,在浅色与深色主题下都正确。
|
|
60
68
|
|
|
61
69
|
完整行为规格见 [`docs/DETAIL.md`](./docs/DETAIL.md),设计决策见
|
package/client.js
CHANGED
|
@@ -59,12 +59,15 @@ window.__ModuleLoader__.load({
|
|
|
59
59
|
.dshmf-chevronOpen { transform: rotate(180deg); }
|
|
60
60
|
|
|
61
61
|
/* Panel: the host menu surface (menu fill + blur + prominent elevation + lg
|
|
62
|
-
corners), capped at 320px; only the grid scrolls.
|
|
62
|
+
corners), capped at 320px; only the grid scrolls. A 1px light-grey frame
|
|
63
|
+
traces the panel edge in both themes — explicitly NOT a border token: the
|
|
64
|
+
border-* tokens are near-invisible hairlines (see DEC-011/DEC-012), and the
|
|
65
|
+
frame has to be readable on the blurred translucent surface. */
|
|
63
66
|
.dshmf-panel {
|
|
64
67
|
box-sizing: border-box; position: fixed; z-index: 1100;
|
|
65
68
|
display: flex; flex-direction: column; gap: 4px; padding: 6px;
|
|
66
69
|
max-height: 320px; overflow: hidden;
|
|
67
|
-
border:
|
|
70
|
+
border: 1px solid #D1D5DB; border-radius: var(--dsw-radius-lg);
|
|
68
71
|
background: var(--dsw-menu-surface-fill);
|
|
69
72
|
-webkit-backdrop-filter: var(--dsw-menu-backdrop-filter, none);
|
|
70
73
|
backdrop-filter: var(--dsw-menu-backdrop-filter, none);
|
|
@@ -74,6 +77,11 @@ window.__ModuleLoader__.load({
|
|
|
74
77
|
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
|
|
75
78
|
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
|
|
76
79
|
}
|
|
80
|
+
/* The same light-grey frame, stepped to the footer divider's dark-theme grey
|
|
81
|
+
(#6B7280, DEC-012) so it stays a light frame on the near-black surface
|
|
82
|
+
without glaring: the width stays 1px in both themes. */
|
|
83
|
+
body[data-ds-dark-theme] .dshmf-panel,
|
|
84
|
+
html[data-ds-dark-theme] .dshmf-panel { border-color: #6B7280; }
|
|
77
85
|
|
|
78
86
|
/* Fixed sections. */
|
|
79
87
|
.dshmf-head { flex: 0 0 auto; display: flex; align-items: center; gap: 6px; height: 28px; }
|
|
@@ -161,9 +169,10 @@ window.__ModuleLoader__.load({
|
|
|
161
169
|
stay borderless, so the dense flow across columns is kept (see DEC-003,
|
|
162
170
|
DEC-005). */
|
|
163
171
|
.dshmf-groupTitle {
|
|
164
|
-
align-self: flex-start; max-width: 100%;
|
|
172
|
+
align-self: flex-start; max-width: 100%; min-width: 0;
|
|
165
173
|
box-sizing: border-box; height: 20px; padding: 0 8px 0 6px;
|
|
166
|
-
|
|
174
|
+
display: flex; align-items: center;
|
|
175
|
+
overflow: hidden; white-space: nowrap;
|
|
167
176
|
border: 1px solid var(--dsw-alias-border-l2);
|
|
168
177
|
border-left: 3px solid var(--dsw-alias-border-l3);
|
|
169
178
|
border-radius: var(--dsw-radius-xs);
|
|
@@ -172,6 +181,20 @@ window.__ModuleLoader__.load({
|
|
|
172
181
|
color: var(--dsw-alias-label-primary);
|
|
173
182
|
font-size: 10px; line-height: 18px; font-weight: 700; letter-spacing: .02em;
|
|
174
183
|
}
|
|
184
|
+
/* The label gets the SAME window/track split as a model row (DEC-021): the
|
|
185
|
+
band is the frame, and the text inside it is a clipped window holding a
|
|
186
|
+
track that can be wider than the window. That is what lets a long heading
|
|
187
|
+
("ModelScope Token Plan") run the same one-pass hover marquee as a long
|
|
188
|
+
model name, while the band's frame, fill and shadow stay put (DEC-023). */
|
|
189
|
+
.dshmf-groupTitleWindow {
|
|
190
|
+
flex: 1 1 auto; min-width: 0; display: block;
|
|
191
|
+
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
192
|
+
}
|
|
193
|
+
.dshmf-groupTitleTrack { width: max-content; max-width: none; white-space: nowrap; }
|
|
194
|
+
.dshmf-groupTitleTrack[data-running="true"] {
|
|
195
|
+
display: inline-block;
|
|
196
|
+
animation: dshmf-rowNameMarquee var(--dshmf-marquee-duration, 3000ms) ease-in-out 1 forwards;
|
|
197
|
+
}
|
|
175
198
|
.dshmf-row {
|
|
176
199
|
box-sizing: border-box; display: flex; align-items: center; gap: 6px; width: 100%;
|
|
177
200
|
height: 26px; padding: 0 6px; border: 0; border-radius: var(--dsw-radius-sm);
|
|
@@ -201,9 +224,42 @@ window.__ModuleLoader__.load({
|
|
|
201
224
|
background: color-mix(in srgb, var(--dsw-static-neutral-bluish-00) 22%, transparent);
|
|
202
225
|
color: inherit;
|
|
203
226
|
}
|
|
204
|
-
.dshmf-
|
|
227
|
+
.dshmf-rowNameWindow {
|
|
228
|
+
flex: 1 1 auto; min-width: 0; display: block;
|
|
229
|
+
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
230
|
+
}
|
|
231
|
+
/* The name itself never ellipsises on its own: the window clips it, and the
|
|
232
|
+
track is what slides when the pointer rests on a truncated row (DEC-021).
|
|
233
|
+
width: max-content is what lets the track be wider than the window, which is
|
|
234
|
+
both the overflow signal (scrollWidth) and what the marquee reveals. */
|
|
235
|
+
.dshmf-rowName {
|
|
236
|
+
width: max-content; max-width: none;
|
|
237
|
+
white-space: nowrap;
|
|
238
|
+
}
|
|
239
|
+
/* Resting the pointer on a row whose name does not fit starts ONE marquee
|
|
240
|
+
pass: the name glides left until its last glyph reaches the window, then
|
|
241
|
+
STOPS there. The easing is ease-in-out, so the start and the arrival are
|
|
242
|
+
soft rather than a constant crawl. Only the name TRACK moves — the window
|
|
243
|
+
clips it, so the right-aligned badge (限时免费, ×0.1) and the pending
|
|
244
|
+
spinner, which sit as siblings AFTER the window, never shift. There is no
|
|
245
|
+
return trip and no iteration: fill-mode forwards holds the end state, and
|
|
246
|
+
leaving the row drops the animation entirely, which snaps back with no
|
|
247
|
+
transition. */
|
|
248
|
+
@keyframes dshmf-rowNameMarquee {
|
|
249
|
+
from { transform: translateX(0); }
|
|
250
|
+
to { transform: translateX(var(--dshmf-marquee-shift)); }
|
|
251
|
+
}
|
|
252
|
+
.dshmf-rowNameTrack[data-running="true"] {
|
|
253
|
+
display: inline-block;
|
|
254
|
+
animation: dshmf-rowNameMarquee var(--dshmf-marquee-duration, 3000ms) ease-in-out 1 forwards;
|
|
255
|
+
}
|
|
256
|
+
@media (prefers-reduced-motion: reduce) {
|
|
257
|
+
.dshmf-rowNameTrack[data-running="true"],
|
|
258
|
+
.dshmf-groupTitleTrack[data-running="true"] { animation: none; }
|
|
259
|
+
}
|
|
205
260
|
/* The model's last call failed on insufficient balance / credit / quota, and no
|
|
206
|
-
later call for the same model has cleared it
|
|
261
|
+
later call for the same model has cleared it, nor has the mark aged past
|
|
262
|
+
STRIKE_TTL (24h) without a fresh attempt. The row text is struck through
|
|
207
263
|
rather than recoloured: the strike survives both themes and the selected
|
|
208
264
|
row's blue band, and it does not compete with the error strip's own red.
|
|
209
265
|
The decoration is applied to the row's text only (name + variant tag), not
|
|
@@ -219,17 +275,25 @@ window.__ModuleLoader__.load({
|
|
|
219
275
|
multiplier reads as an aside without disturbing the row's text rhythm
|
|
220
276
|
(see renderRow's nameRuns). */
|
|
221
277
|
.dshmf-rowNameMark { font-size: 11px; line-height: 18px; font-weight: 700; letter-spacing: normal; }
|
|
222
|
-
/*
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
278
|
+
/* The model-specific tail of a family-grouped row, e.g. "3.8-Flash-Next",
|
|
279
|
+
"V4.1-Flash · x0.01", "2.1 Turbo". The primary text is the provider and the
|
|
280
|
+
heading names the family, so this is what is LEFT after both are removed —
|
|
281
|
+
the型号 and the multiplier the heading cannot carry (DEC-023). It sits at
|
|
282
|
+
the END of the row and is quieter than the primary text (caption colour,
|
|
283
|
+
11px), shrinks before it, and never wraps. No width cap: a cap truncates a
|
|
284
|
+
perfectly readable label ("CodeBuddy …") in a column that had room for it. */
|
|
227
285
|
.dshmf-rowProvider {
|
|
228
286
|
flex: 0 1 auto; min-width: 0;
|
|
229
287
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
230
288
|
color: var(--dsw-alias-label-caption);
|
|
231
289
|
font-size: 11px; line-height: 16px; font-weight: 400;
|
|
232
290
|
}
|
|
291
|
+
/* A multiplier inside that tail ("x0.01") keeps the same "smaller and bolder"
|
|
292
|
+
treatment it gets inside a model name in *By provider* (see
|
|
293
|
+
.dshmf-rowNameMark and renderRow's nameRuns), so the rate reads identically
|
|
294
|
+
in both grouping modes. The base rule already makes it bold and 11px; inside
|
|
295
|
+
the 11px tail it steps down once more, to 10px, to stay visibly apart. */
|
|
296
|
+
.dshmf-rowProvider .dshmf-rowNameMark { font-size: 10px; line-height: 16px; }
|
|
233
297
|
.dshmf-rowTail { display: grid; place-items: center; flex: 0 0 14px; color: inherit; }
|
|
234
298
|
|
|
235
299
|
/* Catalog badge (for example a third-party rate multiplier). Rendered only
|
|
@@ -485,6 +549,15 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
|
|
|
485
549
|
// The wording pass is deliberately narrow: a bare "402"/"429" only counts
|
|
486
550
|
// when it reads as the leading status, never as a number inside another
|
|
487
551
|
// message, and every example below is taken from a real recorded session.
|
|
552
|
+
//
|
|
553
|
+
// A strike also EXPIRES. Every record keeps the wall-clock moment the
|
|
554
|
+
// observation was made, which is what lets the panel tell "this model is out
|
|
555
|
+
// of balance" apart from "this model has not been tried since that verdict".
|
|
556
|
+
// Quotas reset daily and balances get topped up, so a strike that nobody
|
|
557
|
+
// refreshed stops being drawn after STRIKE_TTL; a retry that fails again
|
|
558
|
+
// re-stamps the record and restarts the window, and a retry that answers
|
|
559
|
+
// clears it outright. The window is therefore always "24h since the newest
|
|
560
|
+
// thing we learned about this route", not "24h since the row was drawn".
|
|
488
561
|
// ---------------------------------------------------------------------
|
|
489
562
|
|
|
490
563
|
/** Failure codes that mean "this account has no usable balance left". */
|
|
@@ -585,13 +658,18 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
|
|
|
585
658
|
* Wall-clock time one durable event was recorded at. The Client feed keeps
|
|
586
659
|
* the Host's `time` (`Date.now()` at append), which is what lets marks
|
|
587
660
|
* gathered from DIFFERENT Sessions be ordered against each other — a Session
|
|
588
|
-
* feed has no cross-Session sequence number
|
|
661
|
+
* feed has no cross-Session sequence number — and it is the same clock the
|
|
662
|
+
* strike lifetime is measured against.
|
|
663
|
+
*
|
|
664
|
+
* An event with no stamp is an observation made NOW, not one made at the
|
|
665
|
+
* epoch: folding it as `0` would file a fact we just learned as 56 years old
|
|
666
|
+
* and a strike could never be drawn for it.
|
|
589
667
|
* @param {unknown} event - one durable Session event.
|
|
590
|
-
* @returns {number} the event time, or
|
|
668
|
+
* @returns {number} the event time, or the current time when the feed carried none.
|
|
591
669
|
*/
|
|
592
670
|
function eventTimeOf(event) {
|
|
593
671
|
const time = event?.time
|
|
594
|
-
return typeof time === 'number' && Number.isFinite(time) ? time :
|
|
672
|
+
return typeof time === 'number' && Number.isFinite(time) ? time : Date.now()
|
|
595
673
|
}
|
|
596
674
|
|
|
597
675
|
/**
|
|
@@ -914,22 +992,86 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
|
|
|
914
992
|
}
|
|
915
993
|
|
|
916
994
|
/**
|
|
917
|
-
*
|
|
918
|
-
*
|
|
919
|
-
|
|
920
|
-
|
|
995
|
+
* Separators a strip leaves dangling at either end of a tail. `·` is the
|
|
996
|
+
* catalog's own "name · multiplier" separator (`Hy4 · x0.00`).
|
|
997
|
+
*/
|
|
998
|
+
const TAIL_EDGE = /^[\s·・.,:;|/\\_-]+|[\s·・.,:;|/\\_-]+$/g
|
|
999
|
+
|
|
1000
|
+
/**
|
|
1001
|
+
* Remove every occurrence of a family heading from a text, as a SUBSTRING.
|
|
1002
|
+
*
|
|
1003
|
+
* A family is glued to its version tail in real ids and names — `Qwen3.8-…`,
|
|
1004
|
+
* `Deepseek-V4.1-…`, `MiniMax-M3` — so a whole-word match would miss the
|
|
1005
|
+
* very case this exists for. The gap left behind becomes a space, so
|
|
1006
|
+
* removing a prefix never glues two words together.
|
|
1007
|
+
* @param {string} text - the model name.
|
|
1008
|
+
* @param {string} needle - the family heading.
|
|
1009
|
+
* @returns {string} the text with the heading removed.
|
|
1010
|
+
*/
|
|
1011
|
+
function dropSubstring(text, needle) {
|
|
1012
|
+
const lower = needle.toLowerCase()
|
|
1013
|
+
if (lower.length < 2) return text
|
|
1014
|
+
let out = text
|
|
1015
|
+
for (;;) {
|
|
1016
|
+
const at = out.toLowerCase().indexOf(lower)
|
|
1017
|
+
if (at === -1) return out
|
|
1018
|
+
out = `${out.slice(0, at)} ${out.slice(at + lower.length)}`
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
/**
|
|
1023
|
+
* Remove whole words from a text (case-insensitive).
|
|
1024
|
+
*
|
|
1025
|
+
* Used for the provider's own label, which is the row's PRIMARY text — so a
|
|
1026
|
+
* tail that repeats it would print the same word twice. Whole-word matching
|
|
1027
|
+
* is what keeps a short token from punching a hole in an unrelated word
|
|
1028
|
+
* (provider `AI` must not turn `Rain` into `Rn`). Word edges are any
|
|
1029
|
+
* non-letter/digit, so the CJK provider label `豆包` still matches inside
|
|
1030
|
+
* `豆包 2.1 Turbo`.
|
|
1031
|
+
* @param {string} text - the model name.
|
|
1032
|
+
* @param {string[]} words - provider label words; each needs 2+ characters.
|
|
1033
|
+
* @returns {string} the text with those words removed.
|
|
1034
|
+
*/
|
|
1035
|
+
function dropWords(text, words) {
|
|
1036
|
+
let out = text
|
|
1037
|
+
for (const word of words) {
|
|
1038
|
+
if (word.length < 2) continue
|
|
1039
|
+
const escaped = word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
1040
|
+
out = out.replace(
|
|
1041
|
+
new RegExp(`(?<![\\p{L}\\p{N}])${escaped}(?![\\p{L}\\p{N}])`, 'giu'),
|
|
1042
|
+
' ',
|
|
1043
|
+
)
|
|
1044
|
+
}
|
|
1045
|
+
return out
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
/**
|
|
1049
|
+
* The model-specific part of a family-grouped row: the model name with the
|
|
1050
|
+
* family heading and the provider's own label removed, so only what the
|
|
1051
|
+
* heading does NOT already say is left.
|
|
1052
|
+
*
|
|
1053
|
+
* `Qwen3.8-Flash-Next` under `Qwen` → `3.8-Flash-Next`, `Qwen3.8-27B` →
|
|
1054
|
+
* `3.8-27B`; `Deepseek-V4.1-Flash · x0.00` under `DeepSeek` →
|
|
1055
|
+
* `V4.1-Flash · x0.00`; `豆包 2.1 Turbo` under provider `豆包(doubao)` →
|
|
1056
|
+
* `2.1 Turbo`. Version numbers are KEPT — they are the型号 the heading
|
|
1057
|
+
* lacks, unlike the id-derived tail that drops them.
|
|
1058
|
+
*
|
|
1059
|
+
* When the name adds nothing (`Hy4` under `HY4`), the id tail is used so a
|
|
1060
|
+
* variant like `hy4free` still reads `free`. An empty result is valid: the
|
|
1061
|
+
* row then draws no tail at all.
|
|
921
1062
|
* @param {{id: string, name: string}} model - catalog entry.
|
|
922
1063
|
* @param {string} family - the resolved family heading.
|
|
923
|
-
* @
|
|
1064
|
+
* @param {string} [providerLabel] - the row's primary text; stripped when it repeats.
|
|
1065
|
+
* @returns {string} the model-specific supplement; possibly ''.
|
|
924
1066
|
*/
|
|
925
|
-
function rowVariantOf(model, family) {
|
|
926
|
-
const
|
|
927
|
-
|
|
928
|
-
const
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
1067
|
+
function rowVariantOf(model, family, providerLabel = '') {
|
|
1068
|
+
const name = String(model.name ?? '')
|
|
1069
|
+
const providerWords = String(providerLabel).split(/[^\p{L}\p{N}]+/u).filter(Boolean)
|
|
1070
|
+
const tail = dropWords(dropSubstring(name, family), providerWords)
|
|
1071
|
+
.replace(/\s+/g, ' ')
|
|
1072
|
+
.replace(TAIL_EDGE, '')
|
|
1073
|
+
if (tail !== '') return tail
|
|
1074
|
+
return idVariantOf(model, family)
|
|
933
1075
|
}
|
|
934
1076
|
|
|
935
1077
|
// ---------------------------------------------------------------------
|
|
@@ -1094,10 +1236,47 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
|
|
|
1094
1236
|
// the Host's `time`, and the newest observation of a route wins. That is
|
|
1095
1237
|
// what lets a replayed window (or a stored entry) never override a newer
|
|
1096
1238
|
// fact from another Session.
|
|
1239
|
+
//
|
|
1240
|
+
// The same stamp is what makes a strike go STALE: it is the time the verdict
|
|
1241
|
+
// was made, so a mark nobody refreshed stops being drawn after STRIKE_TTL
|
|
1242
|
+
// while the record stays behind as the watermark. See markIsLive.
|
|
1097
1243
|
// ---------------------------------------------------------------------
|
|
1098
1244
|
const MARKS_KEY = 'dshmf:insufficient'
|
|
1099
|
-
/**
|
|
1245
|
+
/**
|
|
1246
|
+
* How long a RECORD is kept. Records older than this are dropped on read,
|
|
1247
|
+
* so a stale verdict cannot live forever — and so a cleared route's
|
|
1248
|
+
* watermark (which shields it from a replayed older failure) has an end.
|
|
1249
|
+
*/
|
|
1100
1250
|
const MARKS_TTL = 7 * 24 * 60 * 60 * 1000
|
|
1251
|
+
/**
|
|
1252
|
+
* How long a MARK is DRAWN. This is the one the user sees: if nothing new has
|
|
1253
|
+
* been observed for a route for a day, the user never retried it, and the
|
|
1254
|
+
* panel stops claiming the model is unusable — an exhausted balance gets
|
|
1255
|
+
* topped up and a quota resets daily, so a strike that outlives a whole day
|
|
1256
|
+
* of silence is stale information, not a warning. The record itself survives
|
|
1257
|
+
* (up to {@link MARKS_TTL}) as the watermark that keeps a replayed older
|
|
1258
|
+
* failure from re-stamping it.
|
|
1259
|
+
*/
|
|
1260
|
+
const STRIKE_TTL = 24 * 60 * 60 * 1000
|
|
1261
|
+
|
|
1262
|
+
/**
|
|
1263
|
+
* Whether one record still justifies drawing a strike at `now`.
|
|
1264
|
+
*
|
|
1265
|
+
* `at` is the time of the LAST observation for the route, so the answer to
|
|
1266
|
+
* "has the user tried this model again?" is already in it: a retry that
|
|
1267
|
+
* failed on balance restamps `at` and restarts the window, a retry that
|
|
1268
|
+
* answered flips `marked`, and a route nobody touched just ages out.
|
|
1269
|
+
* @param {{ marked: boolean, at: number } | undefined} entry - one record.
|
|
1270
|
+
* @param {number} now - the current wall-clock time.
|
|
1271
|
+
* @param {number} [strikeTtl] - how long a mark is drawn; defaults to {@link STRIKE_TTL}.
|
|
1272
|
+
* @returns {boolean} true only for a mark that is set AND still fresh.
|
|
1273
|
+
*/
|
|
1274
|
+
function markIsLive(entry, now, strikeTtl) {
|
|
1275
|
+
if (entry === undefined || entry.marked !== true) return false
|
|
1276
|
+
const life = typeof strikeTtl === 'number' && Number.isFinite(strikeTtl) ? strikeTtl : STRIKE_TTL
|
|
1277
|
+
// A stamp from the future (a clock that ran ahead) is fresh, never expired.
|
|
1278
|
+
return now - entry.at < life
|
|
1279
|
+
}
|
|
1101
1280
|
|
|
1102
1281
|
/**
|
|
1103
1282
|
* Parse a persisted mark record into the in-memory shape, dropping anything
|
|
@@ -1170,10 +1349,17 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
|
|
|
1170
1349
|
}
|
|
1171
1350
|
}
|
|
1172
1351
|
|
|
1173
|
-
/**
|
|
1352
|
+
/**
|
|
1353
|
+
* Whether two visible mark maps hold the same entries.
|
|
1354
|
+
*
|
|
1355
|
+
* Iterating a Map yields `[key, value]` PAIRS, so this walks `keys()` — a
|
|
1356
|
+
* `for (const key of map)` here compares a pair against `has()` and always
|
|
1357
|
+
* answers "different", which hands the seats a brand-new snapshot object on
|
|
1358
|
+
* every recompute and puts `useSyncExternalStore` into a render loop.
|
|
1359
|
+
*/
|
|
1174
1360
|
function sameMarks(left, right) {
|
|
1175
1361
|
if (left.size !== right.size) return false
|
|
1176
|
-
for (const key of left) {
|
|
1362
|
+
for (const key of left.keys()) {
|
|
1177
1363
|
if (!right.has(key)) return false
|
|
1178
1364
|
}
|
|
1179
1365
|
return true
|
|
@@ -1188,16 +1374,32 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
|
|
|
1188
1374
|
* everywhere, including in a Session whose own feed was never opened in this
|
|
1189
1375
|
* page load (its marks come from storage).
|
|
1190
1376
|
*
|
|
1377
|
+
* The store is also the thing that ages strikes out. A mark retiring itself
|
|
1378
|
+
* is a state change no event announces, so the store watches the clock on its
|
|
1379
|
+
* own behalf — on every fold, on every seat mounting, and on a timer set for
|
|
1380
|
+
* the soonest deadline — instead of waiting for a fold that may never come.
|
|
1381
|
+
*
|
|
1191
1382
|
* @param {object} deps - resolution hooks.
|
|
1192
1383
|
* @param {() => object | undefined} deps.sessionsOf - read the `sessions` service, per call.
|
|
1193
1384
|
* @param {() => Map<string, {marked: boolean, at: number}>} deps.readStored - initial/persisted state.
|
|
1194
1385
|
* @param {(entries: Map<string, {marked: boolean, at: number}>) => void} deps.writeStored - persistence sink.
|
|
1195
|
-
* @
|
|
1386
|
+
* @param {() => number} [deps.now] - the clock; defaults to `Date.now`.
|
|
1387
|
+
* @param {number} [deps.strikeTtl] - strike lifetime; defaults to {@link STRIKE_TTL}.
|
|
1388
|
+
* @returns {object} the store: `subscribe`, `getSnapshot`, `dispose`, `sync`, `watch`, `refresh`.
|
|
1196
1389
|
*/
|
|
1197
1390
|
function createMarkStore(deps) {
|
|
1391
|
+
/**
|
|
1392
|
+
* The clock strikes age against. Injectable so a test does not have to sit
|
|
1393
|
+
* through a day; every read of the state takes it fresh, because a page
|
|
1394
|
+
* lives long enough for the wall clock to matter.
|
|
1395
|
+
*/
|
|
1396
|
+
const nowFn = typeof deps.now === 'function' ? deps.now : () => Date.now()
|
|
1397
|
+
const strikeTtl = typeof deps.strikeTtl === 'number' && Number.isFinite(deps.strikeTtl)
|
|
1398
|
+
? deps.strikeTtl
|
|
1399
|
+
: STRIKE_TTL
|
|
1198
1400
|
/** Internal truth: route key → last decision and when it was made. */
|
|
1199
1401
|
const entries = new Map(deps.readStored())
|
|
1200
|
-
/** Visible subset the rows read: route key → true. */
|
|
1402
|
+
/** Visible subset the rows read: route key → true, the LIVE marks only. */
|
|
1201
1403
|
let visible = new Map()
|
|
1202
1404
|
let snapshot = { marks: visible }
|
|
1203
1405
|
const listeners = new Set()
|
|
@@ -1211,12 +1413,19 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
|
|
|
1211
1413
|
let attachedSessions
|
|
1212
1414
|
let stopList
|
|
1213
1415
|
let disposed = false
|
|
1416
|
+
/** Timer for the soonest strike that is still being drawn. */
|
|
1417
|
+
let expiryTimer = null
|
|
1418
|
+
|
|
1419
|
+
const publish = () => {
|
|
1420
|
+
for (const listener of listeners) listener()
|
|
1421
|
+
}
|
|
1214
1422
|
|
|
1215
|
-
/** Rebuild the visible map; report whether
|
|
1423
|
+
/** Rebuild the visible map against the clock; report whether it changed. */
|
|
1216
1424
|
const recompute = () => {
|
|
1425
|
+
const now = nowFn()
|
|
1217
1426
|
const next = new Map()
|
|
1218
1427
|
for (const [key, entry] of entries) {
|
|
1219
|
-
if (entry
|
|
1428
|
+
if (markIsLive(entry, now, strikeTtl)) next.set(key, true)
|
|
1220
1429
|
}
|
|
1221
1430
|
if (sameMarks(visible, next)) return false
|
|
1222
1431
|
visible = next
|
|
@@ -1224,12 +1433,56 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
|
|
|
1224
1433
|
return true
|
|
1225
1434
|
}
|
|
1226
1435
|
|
|
1227
|
-
|
|
1436
|
+
/**
|
|
1437
|
+
* Arm the clock for the soonest live strike.
|
|
1438
|
+
*
|
|
1439
|
+
* A strike retiring is the one transition no event reports: the user
|
|
1440
|
+
* simply never came back to this model. A page left open across the
|
|
1441
|
+
* deadline therefore has to wake itself, or a stale line-through would sit
|
|
1442
|
+
* on the row until some unrelated fold happened to recompute it.
|
|
1443
|
+
*/
|
|
1444
|
+
const scheduleExpiry = () => {
|
|
1445
|
+
if (expiryTimer !== null) {
|
|
1446
|
+
clearTimeout(expiryTimer)
|
|
1447
|
+
expiryTimer = null
|
|
1448
|
+
}
|
|
1449
|
+
if (disposed || typeof setTimeout !== 'function') return
|
|
1450
|
+
const now = nowFn()
|
|
1451
|
+
let earliest = Number.POSITIVE_INFINITY
|
|
1452
|
+
for (const entry of entries.values()) {
|
|
1453
|
+
if (!markIsLive(entry, now, strikeTtl)) continue
|
|
1454
|
+
const deadline = entry.at + strikeTtl
|
|
1455
|
+
if (deadline < earliest) earliest = deadline
|
|
1456
|
+
}
|
|
1457
|
+
if (!Number.isFinite(earliest)) return
|
|
1458
|
+
const handle = setTimeout(() => {
|
|
1459
|
+
expiryTimer = null
|
|
1460
|
+
refresh()
|
|
1461
|
+
}, Math.max(0, earliest - now))
|
|
1462
|
+
// A day-long timer must never be the reason a process stays alive (a
|
|
1463
|
+
// Node host, a test run): `unref` lets it fire while anything else is
|
|
1464
|
+
// running without holding the loop. A browser handle is a number and has
|
|
1465
|
+
// no `unref`, and there the page keeps its own clock.
|
|
1466
|
+
if (typeof handle?.unref === 'function') handle.unref()
|
|
1467
|
+
expiryTimer = handle
|
|
1468
|
+
}
|
|
1228
1469
|
|
|
1229
|
-
|
|
1230
|
-
|
|
1470
|
+
/**
|
|
1471
|
+
* Re-read the clock and tell the seats if anything aged out.
|
|
1472
|
+
* @returns {boolean} whether the rendered set changed.
|
|
1473
|
+
*/
|
|
1474
|
+
const refresh = () => {
|
|
1475
|
+
if (disposed) return false
|
|
1476
|
+
const changed = recompute()
|
|
1477
|
+
// Re-arm first: a strike that just aged out may leave a later one live.
|
|
1478
|
+
scheduleExpiry()
|
|
1479
|
+
if (changed) publish()
|
|
1480
|
+
return changed
|
|
1231
1481
|
}
|
|
1232
1482
|
|
|
1483
|
+
recompute()
|
|
1484
|
+
scheduleExpiry()
|
|
1485
|
+
|
|
1233
1486
|
/** Fold one batch of durable entries for one Session. */
|
|
1234
1487
|
const absorb = (sessionId, windowEntries) => {
|
|
1235
1488
|
let stored = false
|
|
@@ -1276,7 +1529,9 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
|
|
|
1276
1529
|
return
|
|
1277
1530
|
}
|
|
1278
1531
|
if (result.stored) deps.writeStored(entries)
|
|
1279
|
-
|
|
1532
|
+
// The clock — not the raw record — decides what the rows draw, and a new
|
|
1533
|
+
// record moves the soonest deadline, so re-read both after any fold.
|
|
1534
|
+
if (result.stored || result.changed) refresh()
|
|
1280
1535
|
}
|
|
1281
1536
|
|
|
1282
1537
|
/**
|
|
@@ -1360,6 +1615,10 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
|
|
|
1360
1615
|
return {
|
|
1361
1616
|
/** The seat's `useSyncExternalStore` pair. */
|
|
1362
1617
|
subscribe: listener => {
|
|
1618
|
+
// A seat mounting IS the model list being displayed, so this is where a
|
|
1619
|
+
// strike that aged out while nobody was looking gets retired — before
|
|
1620
|
+
// React reads the snapshot it is about to render from.
|
|
1621
|
+
refresh()
|
|
1363
1622
|
listeners.add(listener)
|
|
1364
1623
|
sync()
|
|
1365
1624
|
return () => { listeners.delete(listener) }
|
|
@@ -1377,11 +1636,22 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
|
|
|
1377
1636
|
pinned.add(sessionId)
|
|
1378
1637
|
sync()
|
|
1379
1638
|
attachSession(sessionId)
|
|
1639
|
+
refresh()
|
|
1380
1640
|
},
|
|
1641
|
+
/**
|
|
1642
|
+
* Re-read the clock: retire any mark older than the strike lifetime and
|
|
1643
|
+
* notify the seats that drew it. Call it whenever the list is displayed,
|
|
1644
|
+
* because that is the moment the answer must be current.
|
|
1645
|
+
*/
|
|
1646
|
+
refresh,
|
|
1381
1647
|
/** Release every feed subscription. The marks themselves stay persisted. */
|
|
1382
1648
|
dispose: () => {
|
|
1383
1649
|
if (disposed) return
|
|
1384
1650
|
disposed = true
|
|
1651
|
+
if (expiryTimer !== null) {
|
|
1652
|
+
clearTimeout(expiryTimer)
|
|
1653
|
+
expiryTimer = null
|
|
1654
|
+
}
|
|
1385
1655
|
for (const stop of stops.values()) stop()
|
|
1386
1656
|
stops.clear()
|
|
1387
1657
|
routes.clear()
|
|
@@ -1501,6 +1771,129 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
|
|
|
1501
1771
|
subscribe: () => () => {},
|
|
1502
1772
|
getSnapshot: () => EMPTY_INSUFFICIENT_SNAPSHOT,
|
|
1503
1773
|
watch: () => {},
|
|
1774
|
+
refresh: () => {},
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
/**
|
|
1778
|
+
* How long the pointer must rest on a truncated model name before the
|
|
1779
|
+
* marquee starts. Long enough that sweeping across the grid never starts a
|
|
1780
|
+
* scroll, short enough to feel like a direct response (DEC-021).
|
|
1781
|
+
*/
|
|
1782
|
+
const MARQUEE_DELAY = 500
|
|
1783
|
+
/**
|
|
1784
|
+
* One CSS pixel per 20ms — 50px/s. The trailing end of a long name scrolls
|
|
1785
|
+
* at the same pace, so a 300px overflow still finishes in ~6s instead of
|
|
1786
|
+
* crawling, and the speed reads as a considered crawl rather than a ticker.
|
|
1787
|
+
*/
|
|
1788
|
+
const MARQUEE_PX_PER_SECOND = 50
|
|
1789
|
+
/** Shortest pass, so a barely-truncated name does not blink past. */
|
|
1790
|
+
const MARQUEE_MIN_MS = 900
|
|
1791
|
+
|
|
1792
|
+
/**
|
|
1793
|
+
* Does this element actually clip its text?
|
|
1794
|
+
*
|
|
1795
|
+
* The measured element is the row's name WINDOW, a plain block-level flex
|
|
1796
|
+
* item with `overflow: hidden; white-space: nowrap`, so `scrollWidth` is the
|
|
1797
|
+
* full width of the name and `clientWidth` the visible part. A one-pixel
|
|
1798
|
+
* slack keeps sub-pixel rounding from starting a marquee on a name that
|
|
1799
|
+
* fits exactly.
|
|
1800
|
+
* @param {Element | null | undefined} element - the name window.
|
|
1801
|
+
* @returns {number} the clipped amount in px; 0 when nothing is cut off.
|
|
1802
|
+
*/
|
|
1803
|
+
const nameClippedBy = element => {
|
|
1804
|
+
if (element === null || element === undefined) return 0
|
|
1805
|
+
const clientWidth = Number(element.clientWidth) || 0
|
|
1806
|
+
if (clientWidth <= 0) return 0
|
|
1807
|
+
const clipped = (Number(element.scrollWidth) || 0) - clientWidth
|
|
1808
|
+
return clipped > 1 ? clipped : 0
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1811
|
+
/**
|
|
1812
|
+
* The geometry of the single marquee pass (DEC-021).
|
|
1813
|
+
*
|
|
1814
|
+
* `shift` is how far the name travels for its LAST glyph to reach the
|
|
1815
|
+
* right edge of the window — exactly the clipped amount, so nothing scrolls
|
|
1816
|
+
* past the point of being readable. The pass is one-way and eased
|
|
1817
|
+
* (ease-in-out), which is symmetric, so its AVERAGE velocity is still the
|
|
1818
|
+
* pinned MARQUEE_PX_PER_SECOND: the duration is simply
|
|
1819
|
+
* `shift / MARQUEE_PX_PER_SECOND`. Easing makes the ends soften around that
|
|
1820
|
+
* average instead of changing it.
|
|
1821
|
+
* @param {number} clipped - pixels of the name hidden to the right.
|
|
1822
|
+
* @returns {{shift: number, duration: number}} geometry in px / ms.
|
|
1823
|
+
*/
|
|
1824
|
+
const marqueePassGeometry = clipped => {
|
|
1825
|
+
const shift = Number.isFinite(clipped) && clipped > 0 ? Math.ceil(clipped) : 0
|
|
1826
|
+
if (shift === 0) return { shift: 0, duration: 0 }
|
|
1827
|
+
const duration = Math.max(MARQUEE_MIN_MS, Math.round((shift / MARQUEE_PX_PER_SECOND) * 1000))
|
|
1828
|
+
return { shift, duration }
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1831
|
+
/**
|
|
1832
|
+
* Does the user ask the OS to reduce motion? The marquee is decorative, so
|
|
1833
|
+
* it is skipped entirely under that preference — the ellipsis and the row
|
|
1834
|
+
* tooltip already carry the full name.
|
|
1835
|
+
* @returns {boolean} true when motion should be suppressed.
|
|
1836
|
+
*/
|
|
1837
|
+
const prefersReducedMotion = () => {
|
|
1838
|
+
try {
|
|
1839
|
+
return typeof window !== 'undefined'
|
|
1840
|
+
&& typeof window.matchMedia === 'function'
|
|
1841
|
+
&& window.matchMedia('(prefers-reduced-motion: reduce)').matches === true
|
|
1842
|
+
} catch {
|
|
1843
|
+
return false
|
|
1844
|
+
}
|
|
1845
|
+
}
|
|
1846
|
+
|
|
1847
|
+
/**
|
|
1848
|
+
* The sliding part of a row's primary text.
|
|
1849
|
+
*
|
|
1850
|
+
* The row wraps it in the clipped window, so the pass runs on the track
|
|
1851
|
+
* alone: everything AFTER the window (the variant tail, the catalog badge,
|
|
1852
|
+
* the pending spinner) is right-aligned and never pushed along.
|
|
1853
|
+
* `shift`/`duration` arrive as inline values because they depend on this
|
|
1854
|
+
* row's measurement.
|
|
1855
|
+
* @param {object} props - `shift` (px), `duration` (ms), `children`.
|
|
1856
|
+
* @returns {object} the track element.
|
|
1857
|
+
*/
|
|
1858
|
+
const renderNameTrack = props => {
|
|
1859
|
+
const { shift, duration, children } = props
|
|
1860
|
+
const running = shift > 0
|
|
1861
|
+
return h('span', {
|
|
1862
|
+
className: 'dshmf-rowNameTrack dshmf-rowName',
|
|
1863
|
+
'data-running': running ? 'true' : undefined,
|
|
1864
|
+
style: running
|
|
1865
|
+
? {
|
|
1866
|
+
'--dshmf-marquee-shift': `-${shift}px`,
|
|
1867
|
+
'--dshmf-marquee-duration': `${duration}ms`,
|
|
1868
|
+
}
|
|
1869
|
+
: undefined,
|
|
1870
|
+
}, children)
|
|
1871
|
+
}
|
|
1872
|
+
|
|
1873
|
+
/**
|
|
1874
|
+
* The sliding part of a group title (DEC-023).
|
|
1875
|
+
*
|
|
1876
|
+
* The band plays the role the row plays for a model name: it is the frame,
|
|
1877
|
+
* and it never moves. The text inside is a clipped window holding a track,
|
|
1878
|
+
* so a heading too long for its column (`ModelScope Token Plan`) runs the
|
|
1879
|
+
* SAME one-pass eased marquee a truncated model name does, while the band's
|
|
1880
|
+
* fill, frame, radius and elevation stay put.
|
|
1881
|
+
* @param {object} props - `shift` (px), `duration` (ms), `children`.
|
|
1882
|
+
* @returns {object} the track element.
|
|
1883
|
+
*/
|
|
1884
|
+
const renderTitleTrack = props => {
|
|
1885
|
+
const { shift, duration, children } = props
|
|
1886
|
+
const running = shift > 0
|
|
1887
|
+
return h('span', {
|
|
1888
|
+
className: 'dshmf-groupTitleTrack',
|
|
1889
|
+
'data-running': running ? 'true' : undefined,
|
|
1890
|
+
style: running
|
|
1891
|
+
? {
|
|
1892
|
+
'--dshmf-marquee-shift': `-${shift}px`,
|
|
1893
|
+
'--dshmf-marquee-duration': `${duration}ms`,
|
|
1894
|
+
}
|
|
1895
|
+
: undefined,
|
|
1896
|
+
}, children)
|
|
1504
1897
|
}
|
|
1505
1898
|
|
|
1506
1899
|
// ---------------------------------------------------------------------
|
|
@@ -1571,6 +1964,70 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
|
|
|
1571
1964
|
const confirmRef = useRef(null)
|
|
1572
1965
|
const id = useId()
|
|
1573
1966
|
|
|
1967
|
+
// ---- Model-name marquee (DEC-021)
|
|
1968
|
+
// A name wider than its row is ellipsised ("DeepSeek Reasone…"). Resting
|
|
1969
|
+
// the pointer on such a row for MARQUEE_DELAY starts the marquee; moving
|
|
1970
|
+
// away stops it. Only one row can run at a time, and the sliding element
|
|
1971
|
+
// is the name TRACK only, so right-aligned trailing content (the catalog
|
|
1972
|
+
// badge — 限时免费, ×0.1 — and the pending spinner) never moves.
|
|
1973
|
+
// A group title is the same shape one level up (DEC-023): the band is the
|
|
1974
|
+
// frame and its text slides inside it, so a truncated heading runs the
|
|
1975
|
+
// same pass. Rows and titles therefore share this ONE state machine and
|
|
1976
|
+
// are told apart only by their key prefix.
|
|
1977
|
+
const [marquee, setMarquee] = useState({ key: '', shift: 0, duration: 0 })
|
|
1978
|
+
const marqueeTimer = useRef(null)
|
|
1979
|
+
/** The row whose delay is still running; '' when nothing is armed. */
|
|
1980
|
+
const marqueeArmed = useRef('')
|
|
1981
|
+
const idleMarquee = { key: '', shift: 0, duration: 0 }
|
|
1982
|
+
const clearMarqueeTimer = () => {
|
|
1983
|
+
if (marqueeTimer.current !== null) {
|
|
1984
|
+
clearTimeout(marqueeTimer.current)
|
|
1985
|
+
marqueeTimer.current = null
|
|
1986
|
+
}
|
|
1987
|
+
}
|
|
1988
|
+
/** Drop an armed delay (the pointer left before it fired). */
|
|
1989
|
+
const disarmMarquee = () => {
|
|
1990
|
+
clearMarqueeTimer()
|
|
1991
|
+
marqueeArmed.current = ''
|
|
1992
|
+
}
|
|
1993
|
+
/**
|
|
1994
|
+
* Pointer arrived on a marquee-able element. Leave any other one first,
|
|
1995
|
+
* then arm the delay. The measurement happens when it fires — the element
|
|
1996
|
+
* is laid out by then, and a name that fits never moves.
|
|
1997
|
+
* @param {string} key - the row's route key or the title's block key (one runner at a time).
|
|
1998
|
+
* @param {Element | null} element - the row (or band) the pointer entered.
|
|
1999
|
+
* @param {string} [selector] - the clipped window inside it.
|
|
2000
|
+
*/
|
|
2001
|
+
const enterRow = (key, element, selector = '.dshmf-rowNameWindow') => {
|
|
2002
|
+
disarmMarquee()
|
|
2003
|
+
marqueeArmed.current = key
|
|
2004
|
+
// Leaving the previous row stops its pass immediately.
|
|
2005
|
+
setMarquee(previous => (previous.key === '' ? previous : idleMarquee))
|
|
2006
|
+
marqueeTimer.current = setTimeout(() => {
|
|
2007
|
+
marqueeTimer.current = null
|
|
2008
|
+
if (marqueeArmed.current !== key) return
|
|
2009
|
+
marqueeArmed.current = ''
|
|
2010
|
+
const nameWindow = typeof element?.querySelector === 'function'
|
|
2011
|
+
? element.querySelector(selector)
|
|
2012
|
+
: null
|
|
2013
|
+
if (prefersReducedMotion()) return
|
|
2014
|
+
const geometry = marqueePassGeometry(nameClippedBy(nameWindow))
|
|
2015
|
+
if (geometry.shift === 0) return
|
|
2016
|
+
setMarquee({ key, shift: geometry.shift, duration: geometry.duration })
|
|
2017
|
+
}, MARQUEE_DELAY)
|
|
2018
|
+
}
|
|
2019
|
+
/**
|
|
2020
|
+
* Pointer left a row. An armed delay for THAT row is cancelled (so a
|
|
2021
|
+
* quick sweep never starts anything), and a running pass is dropped.
|
|
2022
|
+
* @param {string} key - the row the pointer left.
|
|
2023
|
+
*/
|
|
2024
|
+
const leaveRow = key => {
|
|
2025
|
+
if (marqueeArmed.current === key) disarmMarquee()
|
|
2026
|
+
setMarquee(previous => (previous.key === key ? idleMarquee : previous))
|
|
2027
|
+
}
|
|
2028
|
+
// Unmount safety: a pending timer must not outlive the seat.
|
|
2029
|
+
useEffect(() => () => { clearTimeout(marqueeTimer.current) }, [])
|
|
2030
|
+
|
|
1574
2031
|
// Standard props always carry these three hooks for a session-scope slot;
|
|
1575
2032
|
// the fallbacks keep the component renderable (rather than throwing) in a
|
|
1576
2033
|
// composition that omits one, and they are plain values so the hook order
|
|
@@ -1687,10 +2144,12 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
|
|
|
1687
2144
|
for (const family of families) {
|
|
1688
2145
|
family.scored.sort((left, right) => left.score - right.score)
|
|
1689
2146
|
// Under family grouping each row's primary text is its provider and
|
|
1690
|
-
// the family name is NOT repeated (the heading already says it).
|
|
1691
|
-
//
|
|
1692
|
-
//
|
|
1693
|
-
//
|
|
2147
|
+
// the family name is NOT repeated (the heading already says it). The
|
|
2148
|
+
// heading is only half the picture though: it cannot say WHICH model
|
|
2149
|
+
// the row stands for (`Qwen` holds 3.8-Flash-Next and 3.8-27B), nor
|
|
2150
|
+
// carry the catalog's rate multiplier (`Deepseek-V4.1-Flash · x0.00`).
|
|
2151
|
+
// Both move into a tail at the END of the row, drawn from the model
|
|
2152
|
+
// name with the family heading and the provider's own label removed.
|
|
1694
2153
|
const byProvider = new Map()
|
|
1695
2154
|
for (const item of family.scored) {
|
|
1696
2155
|
const list = byProvider.get(item.entry.providerId) ?? []
|
|
@@ -1698,17 +2157,21 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
|
|
|
1698
2157
|
byProvider.set(item.entry.providerId, list)
|
|
1699
2158
|
}
|
|
1700
2159
|
for (const list of byProvider.values()) {
|
|
1701
|
-
if (list.length <= 1) continue
|
|
1702
2160
|
const used = new Map()
|
|
1703
2161
|
for (const item of list) {
|
|
1704
|
-
// An empty
|
|
1705
|
-
// family
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
2162
|
+
// An empty tail is valid: a model whose name says nothing beyond
|
|
2163
|
+
// the family and the provider draws no tail at all.
|
|
2164
|
+
const variant = rowVariantOf(
|
|
2165
|
+
item.entry.model,
|
|
2166
|
+
family.title,
|
|
2167
|
+
item.entry.providerLabel,
|
|
2168
|
+
)
|
|
1709
2169
|
item.entry.rowVariant = variant
|
|
1710
2170
|
used.set(variant, (used.get(variant) ?? 0) + 1)
|
|
1711
2171
|
}
|
|
2172
|
+
// Two rows of one provider that still read alike (the same name
|
|
2173
|
+
// under different ids) fall back to the unique model id, so no two
|
|
2174
|
+
// rows in the grid are indistinguishable.
|
|
1712
2175
|
if ([...used.values()].some(count => count > 1)) {
|
|
1713
2176
|
for (const item of list) {
|
|
1714
2177
|
if (used.get(item.entry.rowVariant) > 1) {
|
|
@@ -2003,8 +2466,12 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
|
|
|
2003
2466
|
// yet: the row text is struck through until a later call for the same
|
|
2004
2467
|
// model route returns anything else.
|
|
2005
2468
|
const insufficientRoute = marks.marks.get(routeKeyOf(providerId, model.id)) === true
|
|
2469
|
+
// The marquee runs one row at a time, keyed by the model route so a
|
|
2470
|
+
// re-render cannot start the wrong row (DEC-021).
|
|
2471
|
+
const routeKey = `${providerId}/${model.id}`
|
|
2472
|
+
const running = marquee.key === routeKey
|
|
2006
2473
|
return h('button', {
|
|
2007
|
-
key:
|
|
2474
|
+
key: routeKey,
|
|
2008
2475
|
type: 'button',
|
|
2009
2476
|
className: 'dshmf-row',
|
|
2010
2477
|
'aria-pressed': selected,
|
|
@@ -2012,15 +2479,37 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
|
|
|
2012
2479
|
title: rowTitle,
|
|
2013
2480
|
disabled: busy,
|
|
2014
2481
|
onClick: () => { chooseModel(providerId, model) },
|
|
2482
|
+
// The pointer must REST for MARQUEE_DELAY before anything moves, so
|
|
2483
|
+
// sweeping across the grid never triggers it. Measuring happens in
|
|
2484
|
+
// the timeout, when the row is laid out and the panel is settled.
|
|
2485
|
+
onMouseEnter: event => { enterRow(routeKey, event?.currentTarget) },
|
|
2486
|
+
onMouseLeave: () => { leaveRow(routeKey) },
|
|
2487
|
+
// Keyboard focus counts as "resting on the row" for the same reason
|
|
2488
|
+
// the tooltip does: a focused row is one the user is looking at.
|
|
2489
|
+
onFocus: event => { enterRow(routeKey, event?.currentTarget) },
|
|
2490
|
+
onBlur: () => { leaveRow(routeKey) },
|
|
2015
2491
|
},
|
|
2016
2492
|
// `nameRuns` (xN multiplier markers) is applied to whatever the
|
|
2017
2493
|
// primary text is: the model name under provider grouping, or the
|
|
2018
|
-
// provider name under family grouping.
|
|
2019
|
-
|
|
2494
|
+
// provider name under family grouping. Only the track (inside the
|
|
2495
|
+
// clipped window) moves; the variant tail, the badge and the spinner
|
|
2496
|
+
// are siblings AFTER the window and stay put on the right.
|
|
2497
|
+
h('span', { className: 'dshmf-rowNameWindow' },
|
|
2498
|
+
running
|
|
2499
|
+
? renderNameTrack({ shift: marquee.shift, duration: marquee.duration, children: nameRuns(primary) })
|
|
2500
|
+
: h('span', { className: 'dshmf-rowNameTrack dshmf-rowName' }, ...nameRuns(primary)),
|
|
2501
|
+
),
|
|
2502
|
+
// The model-specific tail sits at the END of the row, after the
|
|
2503
|
+
// primary text: the heading already names the family, and the
|
|
2504
|
+
// provider is the primary text, so what is LEFT is the型号 and the
|
|
2505
|
+
// multiplier (`3.8-Flash-Next`, `V4.1-Flash · x0.01`). It runs
|
|
2506
|
+
// through `nameRuns` too, so a multiplier in the tail keeps the
|
|
2507
|
+
// same two-sizes-smaller treatment it gets inside a model name.
|
|
2508
|
+
// It shrinks before the primary text and never wraps.
|
|
2020
2509
|
showVariant ? h('span', {
|
|
2021
2510
|
className: 'dshmf-rowProvider',
|
|
2022
2511
|
title: rowVariant,
|
|
2023
|
-
}, rowVariant) : null,
|
|
2512
|
+
}, ...nameRuns(rowVariant)) : null,
|
|
2024
2513
|
badge === null ? null : h('span', {
|
|
2025
2514
|
className: 'dshmf-badge',
|
|
2026
2515
|
'data-tone': badge.tone,
|
|
@@ -2034,12 +2523,34 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
|
|
|
2034
2523
|
? 'status.loading'
|
|
2035
2524
|
: totalModels === 0 ? 'empty.models' : 'empty.search'
|
|
2036
2525
|
|
|
2037
|
-
|
|
2038
|
-
|
|
2526
|
+
// A group title is a marquee-able band like a model row (DEC-023): the
|
|
2527
|
+
// band itself is the frame and never moves, while its text sits in a
|
|
2528
|
+
// clipped window around a track that slides on hover when the heading
|
|
2529
|
+
// does not fit its column. Titles and rows share one state machine, keyed
|
|
2530
|
+
// by `block.key` (`t:…`) versus a row's route key, so only one thing in
|
|
2531
|
+
// the whole panel scrolls at a time. The band keeps its `title` tooltip
|
|
2532
|
+
// for the hover case the OS tooltip covers.
|
|
2533
|
+
const renderTitle = block => {
|
|
2534
|
+
const running = marquee.key === block.key
|
|
2535
|
+
return h('div', {
|
|
2039
2536
|
className: 'dshmf-groupTitle',
|
|
2040
2537
|
key: block.key,
|
|
2041
2538
|
title: block.label,
|
|
2042
|
-
|
|
2539
|
+
// A heading has no action of its own: these handlers only arm the
|
|
2540
|
+
// marquee, so keyboard focus is deliberately NOT a trigger.
|
|
2541
|
+
onMouseEnter: event => { enterRow(block.key, event?.currentTarget, '.dshmf-groupTitleWindow') },
|
|
2542
|
+
onMouseLeave: () => { leaveRow(block.key) },
|
|
2543
|
+
},
|
|
2544
|
+
h('span', { className: 'dshmf-groupTitleWindow' },
|
|
2545
|
+
running
|
|
2546
|
+
? renderTitleTrack({ shift: marquee.shift, duration: marquee.duration, children: block.label })
|
|
2547
|
+
: h('span', { className: 'dshmf-groupTitleTrack' }, block.label),
|
|
2548
|
+
),
|
|
2549
|
+
)
|
|
2550
|
+
}
|
|
2551
|
+
|
|
2552
|
+
const renderBlock = block => (block.kind === 'title'
|
|
2553
|
+
? renderTitle(block)
|
|
2043
2554
|
: renderRow(block.entry))
|
|
2044
2555
|
|
|
2045
2556
|
// Each child is placed EXPLICITLY (`grid-column` below), never by
|
|
@@ -2146,6 +2657,10 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
|
|
|
2146
2657
|
}
|
|
2147
2658
|
setSelectionFocus(false)
|
|
2148
2659
|
triggerRef.current?.focus()
|
|
2660
|
+
// Opening the panel IS showing the models: ask the store to re-read
|
|
2661
|
+
// the clock first, so a strike that went stale while the panel was
|
|
2662
|
+
// closed is gone from the rows this click draws (DEC-024).
|
|
2663
|
+
if (typeof markFace.refresh === 'function') markFace.refresh()
|
|
2149
2664
|
setOpen(true)
|
|
2150
2665
|
},
|
|
2151
2666
|
},
|
|
@@ -2346,14 +2861,20 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
|
|
|
2346
2861
|
}, teamFailure),
|
|
2347
2862
|
|
|
2348
2863
|
// ---- measurement probe (never painted). It mirrors a family-
|
|
2349
|
-
// grouped row (provider primary +
|
|
2350
|
-
//
|
|
2351
|
-
//
|
|
2352
|
-
//
|
|
2864
|
+
// grouped row (provider primary + model-specific tail) and a group
|
|
2865
|
+
// title, the two shapes the grid can draw, so the measured row and
|
|
2866
|
+
// head heights cover every mode. Both now use the window/track
|
|
2867
|
+
// split (DEC-021, DEC-023), so the probe mirrors that too — a track
|
|
2868
|
+
// inside a clipped window is what the grid actually sizes. It
|
|
2869
|
+
// carries no badge and no spinner: the selected row is a filled band
|
|
2870
|
+
// now and the spinner is only drawn for a pending selection.
|
|
2353
2871
|
h('div', { className: 'dshmf-probe', 'aria-hidden': 'true' },
|
|
2354
|
-
h('div', { ref: probeHeadRef, className: 'dshmf-groupTitle' },
|
|
2872
|
+
h('div', { ref: probeHeadRef, className: 'dshmf-groupTitle' },
|
|
2873
|
+
h('span', { className: 'dshmf-groupTitleWindow' },
|
|
2874
|
+
h('span', { className: 'dshmf-groupTitleTrack' }, 'X'))),
|
|
2355
2875
|
h('div', { ref: probeRowRef, className: 'dshmf-row' },
|
|
2356
|
-
h('span', { className: 'dshmf-
|
|
2876
|
+
h('span', { className: 'dshmf-rowNameWindow' },
|
|
2877
|
+
h('span', { className: 'dshmf-rowNameTrack dshmf-rowName' }, 'X')),
|
|
2357
2878
|
h('span', { className: 'dshmf-rowProvider' }, 'X'),
|
|
2358
2879
|
),
|
|
2359
2880
|
),
|
|
@@ -2458,7 +2979,7 @@ html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
|
|
|
2458
2979
|
// Pure layout and grouping derivations plus the component itself,
|
|
2459
2980
|
// exposed for `node --test` so grouping, filtering and the column
|
|
2460
2981
|
// algorithm are pinned without a browser. The shell never reads this key.
|
|
2461
|
-
__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 },
|
|
2982
|
+
__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, isInsufficientBalance, decideMark, eventTimeOf, foldInsufficient, foldMarkEntry, routeOfHeader, routeKeyOf, MARKS_KEY, MARKS_TTL, STRIKE_TTL, markIsLive, parseMarks, serializeMarks, readMarks, writeMarks, createMarkStore },
|
|
2462
2983
|
__component: ModelFlatMap,
|
|
2463
2984
|
__seat: Seat,
|
|
2464
2985
|
apply(ctx) {
|
package/docs/DETAIL.md
CHANGED
|
@@ -47,16 +47,23 @@ and the agent teamwork switch are all visible at once.
|
|
|
47
47
|
`acme-7b` are one `Acme`), and dictionary families are never split by
|
|
48
48
|
generation. A provider prefix does not hide the brand (`codebuddy-hy3` →
|
|
49
49
|
`HY3`).
|
|
50
|
-
- **Family-grouped rows are provider-primary
|
|
51
|
-
models *are*, so the family name is not repeated in a
|
|
52
|
-
text is the provider name — the one thing a heading
|
|
53
|
-
family spans providers. The full model name and
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
50
|
+
- **Family-grouped rows are provider-primary, with a model tail.** A family
|
|
51
|
+
heading says what the models *are*, so the family name is not repeated in a
|
|
52
|
+
row. The row's primary text is the provider name — the one thing a heading
|
|
53
|
+
cannot tell you when a family spans providers. The full model name and
|
|
54
|
+
description stay in the row tooltip. What the heading *and* the provider
|
|
55
|
+
cannot say — which model of the family this row is, and the catalog's rate
|
|
56
|
+
multiplier — is drawn as a quieter tail at the END of the row. The tail is
|
|
57
|
+
the model name with the family heading and the provider's own label removed,
|
|
58
|
+
so `Qwen3.8-Flash-Next` under `Qwen` reads `3.8-Flash-Next`, `Qwen3.8-27B`
|
|
59
|
+
reads `3.8-27B`, `Deepseek-V4.1-Flash · x0.00` under `DeepSeek` reads
|
|
60
|
+
`V4.1-Flash · x0.00`, and `豆包 2.1 Turbo` under provider `豆包(doubao)` reads
|
|
61
|
+
`2.1 Turbo`. Version numbers are KEPT (they are the型号 the heading lacks),
|
|
62
|
+
and a multiplier inside the tail is drawn two sizes smaller and bold, exactly
|
|
63
|
+
as it is inside a model name under *By provider*. Two rows of one provider
|
|
64
|
+
that still read alike fall back to the unique model id; a model whose name
|
|
65
|
+
adds nothing draws no tail. Under *By provider* the heading already is the
|
|
66
|
+
provider, so the primary text stays the model name and no tail is drawn.
|
|
60
67
|
- **Adaptive columns.** The panel measures one group title and one model row,
|
|
61
68
|
subtracts the fixed sections from the 320 px cap, and picks the smallest
|
|
62
69
|
column count from 1 to 3 that fits. If even three columns overflow, it keeps
|
|
@@ -97,9 +104,10 @@ and the agent teamwork switch are all visible at once.
|
|
|
97
104
|
translucent white chip so it stays legible on the blue fill.
|
|
98
105
|
- **Out-of-balance models are struck through.** When a model call fails because
|
|
99
106
|
the account has no usable balance, credit, or quota left, that model's row
|
|
100
|
-
text is drawn with a `line-through` decoration
|
|
107
|
+
text is drawn with a `line-through` decoration. The strike stays until a
|
|
101
108
|
later call for the same model returns something other than an
|
|
102
|
-
insufficient-balance failure
|
|
109
|
+
insufficient-balance failure, **or** until it has gone 24 hours without a new
|
|
110
|
+
attempt — whichever comes first. The row is not recoloured, so the strike reads
|
|
103
111
|
the same on the selected row's blue band and in both themes, and it does not
|
|
104
112
|
compete with the error strip's red. Only the row's text (the name and a
|
|
105
113
|
family variant tag) is struck; the badge chip and the pending spinner are
|
|
@@ -137,6 +145,21 @@ and the agent teamwork switch are all visible at once.
|
|
|
137
145
|
that failed for a different reason. A cancellation (`aborted`), a `blocked`
|
|
138
146
|
turn, and the synthetic `interrupted` / `forked` closers clear nothing —
|
|
139
147
|
the model never answered, so they say nothing about the account.
|
|
148
|
+
- **How long it is drawn without one.** Every record keeps the wall-clock
|
|
149
|
+
time of the observation that made it, which is what separates "this model is
|
|
150
|
+
out of balance" from "this model has not been tried since that verdict". A
|
|
151
|
+
mark older than 24 hours is not drawn: an exhausted balance gets topped up
|
|
152
|
+
and a quota resets daily, so a strike nobody refreshed has stopped being
|
|
153
|
+
information. A retry restamps the record and restarts the window, so the
|
|
154
|
+
rule is "24 hours since the newest thing we learned about this route", not
|
|
155
|
+
"24 hours since the row was painted". Retiring a strike is the one transition
|
|
156
|
+
no event reports, so the store watches the clock itself: on every fold, when
|
|
157
|
+
a seat subscribes, when the panel is opened, and on a timer set for the
|
|
158
|
+
soonest deadline (that handle is `unref`'d, so a day-long wait never holds
|
|
159
|
+
the host process). The record SURVIVES its own expiry, as the watermark for
|
|
160
|
+
the route — which is why re-folding the very failure that made it, after a
|
|
161
|
+
reload or when a second Session's older history is walked, cannot put the
|
|
162
|
+
decoration back.
|
|
140
163
|
- **The marks are global, not per conversation.** Whether a model has quota
|
|
141
164
|
left is a fact about the model route and its account, so the state is NOT
|
|
142
165
|
scoped to the Session a panel happens to render for. One module-level store
|
|
@@ -147,8 +170,12 @@ and the agent teamwork switch are all visible at once.
|
|
|
147
170
|
- **It survives a reload.** The marks are persisted to `localStorage`
|
|
148
171
|
(origin-scoped, like the grouping choice), so a fresh page load starts from
|
|
149
172
|
the last known state instead of an empty one. Reads and writes degrade
|
|
150
|
-
silently when storage is unavailable
|
|
151
|
-
|
|
173
|
+
silently when storage is unavailable. Two lifetimes apply to that record and
|
|
174
|
+
they are not the same one: a mark stops being DRAWN after 24 hours without a
|
|
175
|
+
new observation, while the record itself is kept for 7 days, because it is
|
|
176
|
+
also the watermark that keeps an older replayed failure from restamping the
|
|
177
|
+
route. So a reload of a day-old verdict restores the row as plain text, and
|
|
178
|
+
the fold that re-reads the failure that produced it changes nothing.
|
|
152
179
|
- **The newest observation wins.** A Session feed carries no cross-Session
|
|
153
180
|
sequence number, but every durable event keeps the Host's wall-clock `time`.
|
|
154
181
|
Each route keeps the time of its last decision, and an observation older
|
|
@@ -182,7 +209,11 @@ Everything goes through public Client APIs:
|
|
|
182
209
|
Session the Session list names (`ctx.sessions.list`) to derive the global
|
|
183
210
|
per-model insufficient-balance marks, persists them to `localStorage`, and
|
|
184
211
|
every seat subscribes through `useSyncExternalStore` so a mark transition
|
|
185
|
-
repaints the panel while every other event stays quiet.
|
|
212
|
+
repaints the panel while every other event stays quiet. The same store also
|
|
213
|
+
re-reads the clock on subscribe, on panel open and on a self-armed timer,
|
|
214
|
+
because a strike reaching its 24-hour age is a transition no feed will ever
|
|
215
|
+
report. A snapshot is replaced only when the set of drawn marks actually
|
|
216
|
+
changed, so those extra reads cannot churn the panel.
|
|
186
217
|
- `ctx.get('remote').agentTeams.setEnabled()` writes the teamwork selection.
|
|
187
218
|
- `ctx.locale` supplies the copy, resolves catalog badge text, and prefers the
|
|
188
219
|
host's own `agent-team` wording when that namespace is registered.
|
|
@@ -231,6 +262,25 @@ The band also carries a low-key frame: a hairline border
|
|
|
231
262
|
group start clearly without boxing the group's rows — a group's rows may flow
|
|
232
263
|
across columns, so only its label is framed (see DEC-003).
|
|
233
264
|
|
|
265
|
+
### Long text: one-pass hover marquee
|
|
266
|
+
|
|
267
|
+
A model name, or a group heading, that does not fit its column is ellipsised.
|
|
268
|
+
Resting the pointer on it for 0.5 s starts a single, eased, leftward pass that
|
|
269
|
+
stops when the last glyph reaches the window's right edge; leaving resets it
|
|
270
|
+
immediately with no animation. The pass runs ONCE, is `ease-in-out` rather than
|
|
271
|
+
`linear`, and honours `prefers-reduced-motion: reduce` by not running at all.
|
|
272
|
+
|
|
273
|
+
Both a model row and a group title use the same structure, so only the text
|
|
274
|
+
slides and the frame never does: a clipped **window** (`overflow: hidden` +
|
|
275
|
+
`text-overflow: ellipsis`) wraps a **track** (`width: max-content`) that is
|
|
276
|
+
allowed to be wider than the window. For a row the frame is the row itself, so
|
|
277
|
+
the right-aligned tail, badge (`限时免费`, `x0.1`) and spinner never move; for a
|
|
278
|
+
title the frame is the band, so its fill, frame, radius and elevation stay put.
|
|
279
|
+
One state machine drives both — keyed by the row's route or the title's block
|
|
280
|
+
key — so only one thing in the panel scrolls at a time. The travel distance is
|
|
281
|
+
the measured overflow, and the duration is that distance divided by 50 px/s
|
|
282
|
+
(see DEC-021, DEC-022, DEC-023).
|
|
283
|
+
|
|
234
284
|
## Limitations
|
|
235
285
|
|
|
236
286
|
- The panel replaces the composer seat only; the `/model` popup is untouched.
|
package/docs/model-picker.jpeg
CHANGED
|
Binary file
|
package/icon.svg
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="Flat model picker">
|
|
2
2
|
<title>Flat model picker</title>
|
|
3
|
-
<rect x="4" y="8" width="56" height="48" rx="10" fill="none" stroke="
|
|
4
|
-
<rect x="11" y="15" width="20" height="5" rx="2.5" fill="
|
|
5
|
-
<rect x="35" y="15" width="18" height="5" rx="2.5" fill="
|
|
6
|
-
<rect x="11" y="25" width="20" height="5" rx="2.5" fill="
|
|
7
|
-
<rect x="35" y="25" width="18" height="5" rx="2.5" fill="
|
|
8
|
-
<rect x="11" y="35" width="20" height="5" rx="2.5" fill="
|
|
9
|
-
<rect x="35" y="35" width="18" height="5" rx="2.5" fill="
|
|
10
|
-
<rect x="11" y="45" width="42" height="4" rx="2" fill="
|
|
3
|
+
<rect x="4" y="8" width="56" height="48" rx="10" fill="none" stroke="#3B82F6" stroke-width="3"/>
|
|
4
|
+
<rect x="11" y="15" width="20" height="5" rx="2.5" fill="#3B82F6"/>
|
|
5
|
+
<rect x="35" y="15" width="18" height="5" rx="2.5" fill="#60A5FA"/>
|
|
6
|
+
<rect x="11" y="25" width="20" height="5" rx="2.5" fill="#3B82F6"/>
|
|
7
|
+
<rect x="35" y="25" width="18" height="5" rx="2.5" fill="#60A5FA"/>
|
|
8
|
+
<rect x="11" y="35" width="20" height="5" rx="2.5" fill="#3B82F6"/>
|
|
9
|
+
<rect x="35" y="35" width="18" height="5" rx="2.5" fill="#60A5FA"/>
|
|
10
|
+
<rect x="11" y="45" width="42" height="4" rx="2" fill="#93C5FD"/>
|
|
11
11
|
</svg>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-model-flatmap",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.3",
|
|
4
4
|
"description": "Flat, two-level-free model picker for the DSH composer: every model and reasoning level on one surface.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -19,7 +19,9 @@
|
|
|
19
19
|
"icon.svg"
|
|
20
20
|
],
|
|
21
21
|
"dsh": {
|
|
22
|
-
"bundle": {
|
|
22
|
+
"bundle": {
|
|
23
|
+
"patch": "./cordis.patch.yml"
|
|
24
|
+
},
|
|
23
25
|
"client": {
|
|
24
26
|
"platform": "web",
|
|
25
27
|
"immediately": true
|