dsh-model-flatmap 0.0.0-stage → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +119 -2
- package/README.zh.md +102 -0
- package/client.js +2698 -0
- package/cordis.patch.yml +3 -0
- package/docs/DETAIL.md +243 -0
- package/docs/model-picker.jpeg +0 -0
- package/icon.svg +11 -0
- package/index.js +13 -0
- package/locale/en.json +7 -0
- package/locale/zh.json +7 -0
- package/package.json +26 -4
package/client.js
ADDED
|
@@ -0,0 +1,2698 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Flat model picker — browser half.
|
|
3
|
+
*
|
|
4
|
+
* Replaces the composer's `conversation.input.model` seat with ONE flat panel:
|
|
5
|
+
* search, a provider/family grouping switch, the whole model grid, the current
|
|
6
|
+
* model's reasoning levels, and the agent-teamwork switch — no second-level
|
|
7
|
+
* menus anywhere.
|
|
8
|
+
*
|
|
9
|
+
* Everything is read and written through public Client APIs only:
|
|
10
|
+
* - `ctx.modelDirectories.directoryFor(sessionId)` → the per-Session directory
|
|
11
|
+
* (`store` for data + change subscription, `load()`, `select()`);
|
|
12
|
+
* - `ctx.get('remote').agentTeams.setEnabled()` → the teamwork selection;
|
|
13
|
+
* - `ctx.locale` → copy, catalog badge text resolution, and the host's own
|
|
14
|
+
* wording for the teamwork row;
|
|
15
|
+
* - the slot's standard props (`sessionId`, `useProjection`, `useSession`,
|
|
16
|
+
* `useSessions`) → Session state.
|
|
17
|
+
*
|
|
18
|
+
* Registration uses a negative priority so this entry shadows the shipped
|
|
19
|
+
* `ModelSelect` while it is active; disabling the plugin restores the native
|
|
20
|
+
* control with no residue. The shipped entry keeps its own child-slot
|
|
21
|
+
* declarations (`conversation.input.model.label` / `.options`) alive, so no
|
|
22
|
+
* other plugin's registration is invalidated by this replacement; this panel
|
|
23
|
+
* simply does not host them, and draws the catalog's own model badge instead.
|
|
24
|
+
* No host component, stylesheet or DOM is read.
|
|
25
|
+
*/
|
|
26
|
+
window.__ModuleLoader__.load({
|
|
27
|
+
id: 'dsh-model-flatmap',
|
|
28
|
+
factory(require) {
|
|
29
|
+
const React = require('react')
|
|
30
|
+
const ReactDOM = require('react-dom')
|
|
31
|
+
const h = React.createElement
|
|
32
|
+
const { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } = React
|
|
33
|
+
const useSyncExternalStore = React.useSyncExternalStore
|
|
34
|
+
|
|
35
|
+
// ---------------------------------------------------------------------
|
|
36
|
+
// Stylesheet — theme tokens only. Inserted once per module generation with
|
|
37
|
+
// the same data-plugin tags the host uses, so disposal is owned upstream.
|
|
38
|
+
// ---------------------------------------------------------------------
|
|
39
|
+
const CSS = `
|
|
40
|
+
.dshmf-root { position: relative; min-width: 0; }
|
|
41
|
+
|
|
42
|
+
/* Trigger: the shipped composer seat's chrome (13/20 regular, 28px chip,
|
|
43
|
+
rounded-sm), so the control reads as the button it replaces. */
|
|
44
|
+
.dshmf-trigger {
|
|
45
|
+
display: flex; align-items: center; gap: 4px; min-width: 0; max-width: 360px;
|
|
46
|
+
height: 28px; padding: 0 4px 0 8px; border: none; border-radius: var(--dsw-radius-sm);
|
|
47
|
+
outline: none; background: transparent; color: var(--dsw-alias-label-secondary);
|
|
48
|
+
font-size: 13px; line-height: 20px; font-weight: 400; cursor: pointer;
|
|
49
|
+
}
|
|
50
|
+
@media (hover: hover) { .dshmf-trigger:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover); } }
|
|
51
|
+
.dshmf-trigger:focus-visible:not([data-selection-focus]) {
|
|
52
|
+
box-shadow: 0 0 0 2px var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
|
53
|
+
}
|
|
54
|
+
.dshmf-trigger:disabled { color: var(--dsw-alias-label-dimmed); cursor: default; }
|
|
55
|
+
.dshmf-triggerIcon { display: var(--dsh-composer-model-icon-display, none); flex: 0 0 auto; }
|
|
56
|
+
.dshmf-triggerText { display: var(--dsh-composer-model-text-display, block); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
57
|
+
.dshmf-triggerEffort { flex-shrink: 1000; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--dsw-alias-label-caption); }
|
|
58
|
+
.dshmf-chevron { flex: 0 0 auto; color: var(--dsw-alias-label-caption); transition: transform 120ms ease; }
|
|
59
|
+
.dshmf-chevronOpen { transform: rotate(180deg); }
|
|
60
|
+
|
|
61
|
+
/* Panel: the host menu surface (menu fill + blur + prominent elevation + lg
|
|
62
|
+
corners), capped at 320px; only the grid scrolls. */
|
|
63
|
+
.dshmf-panel {
|
|
64
|
+
box-sizing: border-box; position: fixed; z-index: 1100;
|
|
65
|
+
display: flex; flex-direction: column; gap: 4px; padding: 6px;
|
|
66
|
+
max-height: 320px; overflow: hidden;
|
|
67
|
+
border: 0; border-radius: var(--dsw-radius-lg);
|
|
68
|
+
background: var(--dsw-menu-surface-fill);
|
|
69
|
+
-webkit-backdrop-filter: var(--dsw-menu-backdrop-filter, none);
|
|
70
|
+
backdrop-filter: var(--dsw-menu-backdrop-filter, none);
|
|
71
|
+
--dsw-elevation-stroke-color: var(--dsw-alias-border-l1);
|
|
72
|
+
box-shadow: var(--dsw-elevation-prominent);
|
|
73
|
+
color: var(--dsw-alias-label-primary);
|
|
74
|
+
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
|
|
75
|
+
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/* Fixed sections. */
|
|
79
|
+
.dshmf-head { flex: 0 0 auto; display: flex; align-items: center; gap: 6px; height: 28px; }
|
|
80
|
+
.dshmf-search { flex: 1 1 70%; display: flex; align-items: center; gap: 4px; min-width: 0; height: 24px; padding: 0 6px; border-radius: var(--dsw-radius-sm); }
|
|
81
|
+
.dshmf-search:focus-within { background: var(--dsw-alias-interactive-bg-hover); }
|
|
82
|
+
.dshmf-input {
|
|
83
|
+
flex: 1 1 auto; min-width: 0; height: 100%; padding: 0; border: 0; outline: 0;
|
|
84
|
+
background: transparent; color: var(--dsw-alias-label-primary);
|
|
85
|
+
font-family: inherit; font-size: 12px; line-height: 16px;
|
|
86
|
+
}
|
|
87
|
+
.dshmf-input::placeholder { color: var(--dsw-alias-label-caption); }
|
|
88
|
+
@media (pointer: coarse) { .dshmf-input { font-size: 16px; } }
|
|
89
|
+
.dshmf-clear {
|
|
90
|
+
flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center;
|
|
91
|
+
width: 18px; height: 18px; padding: 0; border: 0; border-radius: 50%; corner-shape: round;
|
|
92
|
+
background: transparent; color: var(--dsw-alias-label-secondary); cursor: pointer;
|
|
93
|
+
}
|
|
94
|
+
.dshmf-clear:hover { background: var(--dsw-alias-interactive-bg-hover); }
|
|
95
|
+
.dshmf-clear:focus-visible { outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); outline-offset: -1px; }
|
|
96
|
+
|
|
97
|
+
.dshmf-spin {
|
|
98
|
+
width: 12px; height: 12px; flex: 0 0 auto; border-radius: 50%; corner-shape: round;
|
|
99
|
+
border: 1.5px solid var(--dsw-alias-border-l3); border-top-color: var(--dsw-alias-label-tertiary);
|
|
100
|
+
animation: dshmf-spin 700ms linear infinite;
|
|
101
|
+
}
|
|
102
|
+
@keyframes dshmf-spin { to { transform: rotate(360deg); } }
|
|
103
|
+
|
|
104
|
+
.dshmf-seg { flex: 0 0 auto; display: inline-flex; align-items: center; gap: 2px; padding: 2px; border-radius: var(--dsw-radius-sm); background: var(--dsw-alias-interactive-bg-hover); }
|
|
105
|
+
.dshmf-segBtn {
|
|
106
|
+
height: 22px; padding: 0 8px; border: 0; border-radius: var(--dsw-radius-xs);
|
|
107
|
+
background: transparent; color: var(--dsw-alias-label-secondary);
|
|
108
|
+
font-family: inherit; font-size: 11px; line-height: 16px; white-space: nowrap; cursor: pointer;
|
|
109
|
+
transition: color 120ms ease, background 120ms ease;
|
|
110
|
+
}
|
|
111
|
+
.dshmf-segBtn[aria-pressed="true"] { background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-primary); box-shadow: var(--dsw-elevation-soft); }
|
|
112
|
+
@media (hover: hover) { .dshmf-segBtn:hover:not([aria-pressed="true"]) { color: var(--dsw-alias-label-primary); } }
|
|
113
|
+
.dshmf-segBtn:focus-visible { outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); outline-offset: -2px; }
|
|
114
|
+
|
|
115
|
+
.dshmf-error {
|
|
116
|
+
flex: 0 0 auto; display: flex; align-items: flex-start; gap: 6px; padding: 4px 7px;
|
|
117
|
+
border-radius: var(--dsw-radius-sm); background: var(--dsw-alias-interactive-bg-hover-danger);
|
|
118
|
+
color: var(--dsw-alias-state-error-primary); font-size: 11px; line-height: 16px; overflow-wrap: anywhere;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/* The only scrolling region. Its "align-items: start" keeps the text columns
|
|
122
|
+
at their natural height; a divider laid out beside them opts back into
|
|
123
|
+
stretch so it spans the tallest column instead (see .dshmf-colDivider).
|
|
124
|
+
Every child is placed explicitly by grid-column (and the divider by
|
|
125
|
+
grid-row) — see the inline style on each one. Auto-placement must NOT be
|
|
126
|
+
relied on here: a divider that pins its row but not its column is placed
|
|
127
|
+
first, stealing column 1, and the columns then overflow to columns 2 and 3,
|
|
128
|
+
squashing the first text column. */
|
|
129
|
+
.dshmf-grid {
|
|
130
|
+
flex: 1 1 auto; min-height: 0; overflow-x: hidden; overflow-y: auto;
|
|
131
|
+
display: grid; align-items: start;
|
|
132
|
+
}
|
|
133
|
+
.dshmf-col { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
|
134
|
+
/* When more than one column is laid out, separate adjacent columns with a
|
|
135
|
+
faint vertical hairline so the eye can tell the dense model flows apart.
|
|
136
|
+
The hairline is its OWN grid item, a sibling of the columns rather than a
|
|
137
|
+
border on one of them: as a border it could only ever be as tall as that
|
|
138
|
+
column's content. Here it sits in a dedicated gutter track and stretches to
|
|
139
|
+
the grid's full height, with a small margin top and bottom.
|
|
140
|
+
Only gutters that actually sit between two columns get one (see the grid
|
|
141
|
+
children), so a single-column layout stays borderless. */
|
|
142
|
+
.dshmf-colDivider {
|
|
143
|
+
grid-row: 1; align-self: stretch; justify-self: center;
|
|
144
|
+
width: 1px; margin: 6px 0;
|
|
145
|
+
background: var(--dsw-alias-border-l3);
|
|
146
|
+
}
|
|
147
|
+
/* Provider / family label: a filled, rounded band that reads as a section
|
|
148
|
+
header, clearly separated from the model rows below it. Bare host
|
|
149
|
+
group-header fill is only a few levels from the translucent panel surface.
|
|
150
|
+
The band floats on an opaque grey overlay surface with soft elevation — the
|
|
151
|
+
same recipe the active segment uses. It deliberately steps AWAY from the
|
|
152
|
+
transparent panel surface in both themes: in light it resolves to a soft
|
|
153
|
+
grey (#e9ecf2) that is visibly darker than the white panel, and in dark it
|
|
154
|
+
resolves to a light grey (#61666b) that is visibly lighter than the
|
|
155
|
+
near-black surface — so the band reads as a distinct section header with
|
|
156
|
+
no hard-coded colour and no second theme branch (see DEC-005). Smaller but
|
|
157
|
+
bold type (and the primary label colour) sets it apart from the 13px
|
|
158
|
+
regular row text without borrowing from the row's interactive states.
|
|
159
|
+
A hairline frame plus a short grey (border-level-3) left edge marks each
|
|
160
|
+
group start: visible, never loud, and theme-neutral — no colour accent. The rows themselves
|
|
161
|
+
stay borderless, so the dense flow across columns is kept (see DEC-003,
|
|
162
|
+
DEC-005). */
|
|
163
|
+
.dshmf-groupTitle {
|
|
164
|
+
align-self: flex-start; max-width: 100%;
|
|
165
|
+
box-sizing: border-box; height: 20px; padding: 0 8px 0 6px;
|
|
166
|
+
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
167
|
+
border: 1px solid var(--dsw-alias-border-l2);
|
|
168
|
+
border-left: 3px solid var(--dsw-alias-border-l3);
|
|
169
|
+
border-radius: var(--dsw-radius-xs);
|
|
170
|
+
background: var(--dsw-alias-bg-overlay);
|
|
171
|
+
box-shadow: var(--dsw-elevation-soft);
|
|
172
|
+
color: var(--dsw-alias-label-primary);
|
|
173
|
+
font-size: 10px; line-height: 18px; font-weight: 700; letter-spacing: .02em;
|
|
174
|
+
}
|
|
175
|
+
.dshmf-row {
|
|
176
|
+
box-sizing: border-box; display: flex; align-items: center; gap: 6px; width: 100%;
|
|
177
|
+
height: 26px; padding: 0 6px; border: 0; border-radius: var(--dsw-radius-sm);
|
|
178
|
+
background: transparent; color: var(--dsw-alias-label-primary);
|
|
179
|
+
font-family: inherit; font-size: 13px; line-height: 18px; font-weight: 400; text-align: left;
|
|
180
|
+
cursor: pointer; min-width: 0;
|
|
181
|
+
}
|
|
182
|
+
@media (hover: hover) { .dshmf-row:hover:not(:disabled):not([aria-pressed="true"]) { background: var(--dsw-alias-interactive-bg-hover); } }
|
|
183
|
+
.dshmf-row:focus-visible { background: var(--dsw-alias-interactive-bg-hover); outline: none; }
|
|
184
|
+
.dshmf-row:disabled { color: var(--dsw-alias-label-dimmed); cursor: default; }
|
|
185
|
+
/* Selected model: a solid deep-blue band with white bold text — the row
|
|
186
|
+
itself says "this one is active", so no checkmark and no reserved tail
|
|
187
|
+
column is needed (that 14px sat empty on every unselected row).
|
|
188
|
+
The pairing is pinned to the static deepseek-600 + white rather than the
|
|
189
|
+
alias --dsw-alias-state-business-primary, because that alias flips to a
|
|
190
|
+
LIGHT blue with dark text in the dark theme; the request is a deep blue
|
|
191
|
+
with white text in both themes, and these two static tokens are the same
|
|
192
|
+
in light and dark. */
|
|
193
|
+
.dshmf-row[aria-pressed="true"],
|
|
194
|
+
.dshmf-row[aria-pressed="true"]:focus-visible {
|
|
195
|
+
background: var(--dsw-static-deepseek-600);
|
|
196
|
+
color: var(--dsw-static-neutral-bluish-00);
|
|
197
|
+
font-weight: 600;
|
|
198
|
+
}
|
|
199
|
+
.dshmf-row[aria-pressed="true"] .dshmf-rowProvider { color: inherit; opacity: .82; }
|
|
200
|
+
.dshmf-row[aria-pressed="true"] .dshmf-badge {
|
|
201
|
+
background: color-mix(in srgb, var(--dsw-static-neutral-bluish-00) 22%, transparent);
|
|
202
|
+
color: inherit;
|
|
203
|
+
}
|
|
204
|
+
.dshmf-rowName { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
205
|
+
/* The model's last call failed on insufficient balance / credit / quota, and no
|
|
206
|
+
later call for the same model has cleared it. The row text is struck through
|
|
207
|
+
rather than recoloured: the strike survives both themes and the selected
|
|
208
|
+
row's blue band, and it does not compete with the error strip's own red.
|
|
209
|
+
The decoration is applied to the row's text only (name + variant tag), not
|
|
210
|
+
to the badge chip or the spinner. */
|
|
211
|
+
.dshmf-row[data-insufficient="true"] .dshmf-rowName,
|
|
212
|
+
.dshmf-row[data-insufficient="true"] .dshmf-rowProvider {
|
|
213
|
+
text-decoration: line-through;
|
|
214
|
+
text-decoration-thickness: 1px;
|
|
215
|
+
}
|
|
216
|
+
/* A multiplier marker lifted out of the model name (rate/size tags like
|
|
217
|
+
"x0.01", "x1.5", "X 2.2"). The characters stay in place and the full name is
|
|
218
|
+
unchanged; only this run is drawn two sizes smaller and bold, so the
|
|
219
|
+
multiplier reads as an aside without disturbing the row's text rhythm
|
|
220
|
+
(see renderRow's nameRuns). */
|
|
221
|
+
.dshmf-rowNameMark { font-size: 11px; line-height: 18px; font-weight: 700; letter-spacing: normal; }
|
|
222
|
+
/* Secondary text in a family-grouped row. The primary text is the provider,
|
|
223
|
+
so this is only a short variant tail shown when one provider offers several
|
|
224
|
+
models in the same family. Quieter than the primary text (caption colour,
|
|
225
|
+
11px) and shrinks before it does. No width cap: a cap truncates a perfectly
|
|
226
|
+
readable label ("CodeBuddy …") in a column that had room for it. */
|
|
227
|
+
.dshmf-rowProvider {
|
|
228
|
+
flex: 0 1 auto; min-width: 0;
|
|
229
|
+
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
230
|
+
color: var(--dsw-alias-label-caption);
|
|
231
|
+
font-size: 11px; line-height: 16px; font-weight: 400;
|
|
232
|
+
}
|
|
233
|
+
.dshmf-rowTail { display: grid; place-items: center; flex: 0 0 14px; color: inherit; }
|
|
234
|
+
|
|
235
|
+
/* Catalog badge (for example a third-party rate multiplier). Rendered only
|
|
236
|
+
when the catalog publishes text, so a badge-less model keeps no gap. */
|
|
237
|
+
.dshmf-badge {
|
|
238
|
+
flex: none; display: inline-flex; align-items: center; padding: 0 6px;
|
|
239
|
+
border-radius: 999px; corner-shape: round;
|
|
240
|
+
font-size: 10px; line-height: 16px; font-weight: 500; white-space: nowrap;
|
|
241
|
+
background: var(--dsw-alias-bg-module-platform); color: var(--dsw-alias-label-secondary);
|
|
242
|
+
}
|
|
243
|
+
.dshmf-badge[data-tone="info"] { background: color-mix(in srgb, var(--dsw-alias-state-business-primary) 10%, transparent); color: var(--dsw-alias-state-business-primary); }
|
|
244
|
+
.dshmf-badge[data-tone="success"] { background: color-mix(in srgb, var(--dsw-alias-state-success-primary) 10%, transparent); color: var(--dsw-alias-state-success-primary); }
|
|
245
|
+
.dshmf-badge[data-tone="warning"] { background: color-mix(in srgb, var(--dsw-alias-state-warn-primary) 12%, transparent); color: var(--dsw-alias-state-warn-primary); }
|
|
246
|
+
|
|
247
|
+
.dshmf-empty { grid-column: 1 / -1; padding: 10px 8px; color: var(--dsw-alias-label-tertiary); font-size: 12px; line-height: 18px; }
|
|
248
|
+
.dshmf-failure { grid-column: 1 / -1; padding: 2px 6px; color: var(--dsw-alias-state-warn-label); font-size: 11px; line-height: 16px; overflow-wrap: anywhere; }
|
|
249
|
+
|
|
250
|
+
/* Adaptive footer: the reasoning group and the teamwork group share one line
|
|
251
|
+
when BOTH fit side by side with a guaranteed 30px gap, and otherwise fall
|
|
252
|
+
back to one row each (the original two-line look). The decision is pure CSS:
|
|
253
|
+
the footer stretches to the panel width, each child keeps its natural
|
|
254
|
+
max-content width (flex 0 0 auto), and flex-wrap plus a 30px column-gap
|
|
255
|
+
puts them on one line exactly when reason + 30 + team <= panel width.
|
|
256
|
+
No JS width guessing, so it stays correct across panel resize, theme and
|
|
257
|
+
font changes. A max-width of 100% lets a child that ends up alone on a line
|
|
258
|
+
cap to the panel width and wrap internally instead of overflowing.
|
|
259
|
+
The divider sits on this container, not on a child (DEC-016): the two groups
|
|
260
|
+
can share a line or stack, and either way the rule belongs above the whole
|
|
261
|
+
footer block, never between them, so no child carries a border. Host border
|
|
262
|
+
tokens are faint by design, so the line uses explicit greys: medium grey on
|
|
263
|
+
the light panel, a lighter near-white grey on the dark panel. */
|
|
264
|
+
.dshmf-footer {
|
|
265
|
+
flex: 0 0 auto; display: flex; align-items: center; flex-wrap: wrap;
|
|
266
|
+
column-gap: 30px; row-gap: 4px;
|
|
267
|
+
border-top: 1px solid #9CA3AF;
|
|
268
|
+
padding-top: 6px;
|
|
269
|
+
}
|
|
270
|
+
body[data-ds-dark-theme] .dshmf-footer,
|
|
271
|
+
html[data-ds-dark-theme] .dshmf-footer { border-top-color: #6B7280; }
|
|
272
|
+
|
|
273
|
+
/* Reasoning levels: a caption and a multi-segmentation control on one line;
|
|
274
|
+
the track wraps below the caption only when the row cannot fit. The
|
|
275
|
+
segment visual mirrors the header's grouping control, so the panel has
|
|
276
|
+
one visual language for "one of many, exactly one active". */
|
|
277
|
+
.dshmf-reason {
|
|
278
|
+
flex: 0 0 auto; display: flex; align-items: center; flex-wrap: wrap;
|
|
279
|
+
gap: 2px 6px; max-width: 100%;
|
|
280
|
+
}
|
|
281
|
+
.dshmf-reasonLabel {
|
|
282
|
+
flex: 0 0 auto; display: inline-flex; align-items: center; gap: 4px;
|
|
283
|
+
white-space: nowrap;
|
|
284
|
+
color: var(--dsw-alias-label-tertiary);
|
|
285
|
+
font-size: 11px; line-height: 16px; font-weight: 500;
|
|
286
|
+
}
|
|
287
|
+
.dshmf-reasonLabel svg { flex: 0 0 auto; }
|
|
288
|
+
.dshmf-pills {
|
|
289
|
+
flex: 0 0 auto; max-width: 100%;
|
|
290
|
+
display: inline-flex; align-items: center; gap: 2px;
|
|
291
|
+
padding: 2px;
|
|
292
|
+
border-radius: var(--dsw-radius-sm);
|
|
293
|
+
background: var(--dsw-alias-interactive-bg-hover);
|
|
294
|
+
overflow-x: auto; overflow-y: hidden; scrollbar-width: none;
|
|
295
|
+
}
|
|
296
|
+
.dshmf-pills::-webkit-scrollbar { height: 0; }
|
|
297
|
+
.dshmf-pill {
|
|
298
|
+
flex: 0 0 auto; height: 22px; padding: 0 8px; border: 0;
|
|
299
|
+
border-radius: var(--dsw-radius-xs);
|
|
300
|
+
background: transparent; color: var(--dsw-alias-label-secondary);
|
|
301
|
+
font-family: inherit; font-size: 11px; line-height: 16px; font-weight: 400; white-space: nowrap; cursor: pointer;
|
|
302
|
+
transition: color 120ms ease, background 120ms ease;
|
|
303
|
+
}
|
|
304
|
+
@media (hover: hover) { .dshmf-pill:hover:not(:disabled):not([aria-checked="true"]) { color: var(--dsw-alias-label-primary); } }
|
|
305
|
+
.dshmf-pill[aria-checked="true"] {
|
|
306
|
+
background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-primary);
|
|
307
|
+
box-shadow: var(--dsw-elevation-soft); font-weight: 500;
|
|
308
|
+
}
|
|
309
|
+
.dshmf-pill:disabled { color: var(--dsw-alias-label-dimmed); cursor: default; }
|
|
310
|
+
.dshmf-pill:disabled[aria-checked="true"] { opacity: .5; }
|
|
311
|
+
.dshmf-pill:focus-visible { outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); outline-offset: -2px; }
|
|
312
|
+
|
|
313
|
+
/* Teamwork row: label, info button and switch all left-aligned on one line,
|
|
314
|
+
so the switch sits immediately to the right of the title. */
|
|
315
|
+
.dshmf-team {
|
|
316
|
+
flex: 0 0 auto; display: flex; align-items: center; justify-content: flex-start; gap: 8px;
|
|
317
|
+
max-width: 100%;
|
|
318
|
+
min-height: 30px; padding: 0 6px;
|
|
319
|
+
color: var(--dsw-alias-label-primary); font-size: 12px; line-height: 18px;
|
|
320
|
+
}
|
|
321
|
+
.dshmf-teamLabel { display: inline-flex; align-items: center; gap: 4px; min-width: 0; }
|
|
322
|
+
.dshmf-teamName { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
323
|
+
.dshmf-info {
|
|
324
|
+
flex: none; display: inline-flex; align-items: center; justify-content: center;
|
|
325
|
+
width: 16px; height: 16px; color: var(--dsw-alias-label-caption);
|
|
326
|
+
}
|
|
327
|
+
.dshmf-switch {
|
|
328
|
+
box-sizing: border-box; position: relative; flex: 0 0 auto; width: 36px; height: 20px; padding: 2px;
|
|
329
|
+
border: 0; border-radius: 999px; corner-shape: round; background: var(--dsw-alias-border-l3); cursor: pointer;
|
|
330
|
+
}
|
|
331
|
+
.dshmf-switch[aria-checked="true"] { background: var(--dsw-alias-brand-primary); }
|
|
332
|
+
.dshmf-switch:disabled { cursor: default; opacity: .5; }
|
|
333
|
+
.dshmf-switch:focus-visible { outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); outline-offset: 2px; }
|
|
334
|
+
.dshmf-thumb { display: block; width: 16px; height: 16px; border-radius: 50%; corner-shape: round; background: var(--dsw-alias-label-primary-foreground); transition: transform .12s ease; }
|
|
335
|
+
.dshmf-switch[aria-checked="false"] .dshmf-thumb { background: var(--dsw-alias-switch-thumb); }
|
|
336
|
+
.dshmf-switch[aria-checked="true"] .dshmf-thumb { transform: translate(16px); }
|
|
337
|
+
|
|
338
|
+
/* Inline disable confirmation, with the host's confirmation copy. */
|
|
339
|
+
.dshmf-confirm { flex: 0 0 auto; display: flex; flex-direction: column; gap: 3px; padding: 6px 8px; border-radius: var(--dsw-radius-sm); background: var(--dsw-alias-bg-module-platform); }
|
|
340
|
+
.dshmf-confirmTitle { color: var(--dsw-alias-label-primary); font-size: 12px; line-height: 18px; font-weight: 500; }
|
|
341
|
+
.dshmf-confirmBody { color: var(--dsw-alias-label-secondary); font-size: 11px; line-height: 16px; }
|
|
342
|
+
.dshmf-confirmActions { display: flex; justify-content: flex-end; gap: 6px; }
|
|
343
|
+
.dshmf-confirmBtn { height: 22px; padding: 0 9px; border: 0; border-radius: var(--dsw-radius-xs); background: transparent; color: var(--dsw-alias-label-secondary); font-family: inherit; font-size: 11px; line-height: 16px; cursor: pointer; }
|
|
344
|
+
.dshmf-confirmBtn:hover { background: var(--dsw-alias-interactive-bg-hover); }
|
|
345
|
+
.dshmf-confirmBtn[data-primary="true"] { background: var(--dsw-alias-interactive-bg-active); color: var(--dsw-alias-label-primary); }
|
|
346
|
+
.dshmf-confirmBtn:focus-visible { outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); outline-offset: 1px; }
|
|
347
|
+
|
|
348
|
+
/* Off-screen probe: measures one group title and one model row at the real
|
|
349
|
+
font scale, so column fit uses this browser's metrics, not a guess. */
|
|
350
|
+
.dshmf-probe { position: absolute; top: 0; left: 0; width: 160px; height: 0; overflow: hidden; visibility: hidden; pointer-events: none; }
|
|
351
|
+
|
|
352
|
+
@media (prefers-reduced-motion: reduce) {
|
|
353
|
+
.dshmf-spin { animation-duration: 2s; }
|
|
354
|
+
.dshmf-thumb, .dshmf-chevron, .dshmf-pill, .dshmf-segBtn { transition: none; }
|
|
355
|
+
}
|
|
356
|
+
`
|
|
357
|
+
const STYLE_TAG = 'dsh-model-flatmap/panel.css'
|
|
358
|
+
const STYLE_OWNER = 'dsh-model-flatmap'
|
|
359
|
+
if (typeof document !== 'undefined'
|
|
360
|
+
&& document.querySelector(`style[data-plugin-css=${JSON.stringify(STYLE_TAG)}]`) === null) {
|
|
361
|
+
const tag = document.createElement('style')
|
|
362
|
+
tag.dataset.plugin = STYLE_OWNER
|
|
363
|
+
tag.dataset.pluginCss = STYLE_TAG
|
|
364
|
+
tag.textContent = CSS
|
|
365
|
+
document.head.append(tag)
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// ---------------------------------------------------------------------
|
|
369
|
+
// Constants and helpers
|
|
370
|
+
// ---------------------------------------------------------------------
|
|
371
|
+
const PANEL_MAX = 320
|
|
372
|
+
const PANEL_PAD = 6
|
|
373
|
+
const PANEL_GAP = 4
|
|
374
|
+
const COL_GAP = 10
|
|
375
|
+
const ROW_GAP = 2
|
|
376
|
+
const BALANCE_THRESHOLD = 24
|
|
377
|
+
|
|
378
|
+
const PATH_CHEVRON = 'M3.2 5.6 7 9.4 10.8 5.6'
|
|
379
|
+
const PATH_CLOSE = 'M4 4 10 10M10 4 4 10'
|
|
380
|
+
const PATH_INFO = 'M7 6.4v4.1M7 3.7v.8'
|
|
381
|
+
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'
|
|
382
|
+
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'
|
|
383
|
+
|
|
384
|
+
function svg(path, size, extra) {
|
|
385
|
+
return h('svg', {
|
|
386
|
+
width: size, height: size, viewBox: '0 0 14 14', fill: 'none',
|
|
387
|
+
stroke: 'currentColor', strokeWidth: 1.5, strokeLinecap: 'round', strokeLinejoin: 'round',
|
|
388
|
+
'aria-hidden': 'true', focusable: 'false', ...extra,
|
|
389
|
+
}, h('path', { d: path }))
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** Resolve provider-authored catalog text without the locale service. */
|
|
393
|
+
function fallbackText(value) {
|
|
394
|
+
return typeof value === 'string' ? value : (value?.en ?? '')
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function fill(t, key, values) {
|
|
398
|
+
const template = String(t(key))
|
|
399
|
+
if (values === undefined) return template
|
|
400
|
+
return template.replace(/\{(\w+)\}/g, (_, name) => String(values[name] ?? ''))
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Split a raw query into lowercase search tokens. Whitespace separates
|
|
405
|
+
* tokens, so `mod inte` asks for `mod` AND `inte` rather than for that
|
|
406
|
+
* literal string — which is what lets it find `ModelScope International`,
|
|
407
|
+
* a name the two words never appear in contiguously.
|
|
408
|
+
* @param {string} query - raw search text.
|
|
409
|
+
* @returns {string[]} lowercase, non-empty tokens.
|
|
410
|
+
*/
|
|
411
|
+
function searchTokens(query) {
|
|
412
|
+
const raw = typeof query === 'string' ? query : ''
|
|
413
|
+
return raw.trim().toLowerCase().split(/\s+/).filter(token => token !== '')
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Rank ONE token against one name: prefix beats substring beats ordered
|
|
418
|
+
* subsequence. This is the original single-term ranking, unchanged.
|
|
419
|
+
* @param {string} name - lowercase haystack.
|
|
420
|
+
* @param {string} token - lowercase needle.
|
|
421
|
+
* @returns {0|1|2|null} the rank, or null when the token is absent.
|
|
422
|
+
*/
|
|
423
|
+
function tokenScore(name, token) {
|
|
424
|
+
if (name.startsWith(token)) return 0
|
|
425
|
+
if (name.includes(token)) return 1
|
|
426
|
+
let at = 0
|
|
427
|
+
for (const ch of token) {
|
|
428
|
+
at = name.indexOf(ch, at)
|
|
429
|
+
if (at === -1) return null
|
|
430
|
+
at += 1
|
|
431
|
+
}
|
|
432
|
+
return 2
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Case-insensitive match: prefix beats substring beats ordered subsequence.
|
|
437
|
+
*
|
|
438
|
+
* A query may hold several whitespace-separated tokens. Every token then
|
|
439
|
+
* has to land somewhere in the name, and the result takes the weakest of
|
|
440
|
+
* them — so `mod inte` finds `ModelScope International`, while `mod zzz`
|
|
441
|
+
* still finds nothing. A single token keeps the original ranking exactly,
|
|
442
|
+
* so existing ordering does not shift.
|
|
443
|
+
*/
|
|
444
|
+
function matchScore(name, query) {
|
|
445
|
+
const tokens = searchTokens(query)
|
|
446
|
+
if (tokens.length === 0) return 0
|
|
447
|
+
const n = String(name ?? '').toLowerCase()
|
|
448
|
+
if (tokens.length === 1) return tokenScore(n, tokens[0])
|
|
449
|
+
let weakest = 0
|
|
450
|
+
for (const token of tokens) {
|
|
451
|
+
const score = tokenScore(n, token)
|
|
452
|
+
if (score === null) return null
|
|
453
|
+
if (score > weakest) weakest = score
|
|
454
|
+
}
|
|
455
|
+
return weakest
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Rank of a model that is shown because its PROVIDER matched the query
|
|
460
|
+
* rather than its own name. Sorts after every name hit; the sort is stable,
|
|
461
|
+
* so models pulled in this way keep their catalog order.
|
|
462
|
+
*/
|
|
463
|
+
const PROVIDER_MATCH = 3
|
|
464
|
+
|
|
465
|
+
// ---------------------------------------------------------------------
|
|
466
|
+
// Insufficient balance / credit / quota — the per-model strike state.
|
|
467
|
+
//
|
|
468
|
+
// The Host already classifies an exhausted account (rather than a
|
|
469
|
+
// transient rate limit) into the provider-neutral `QUOTA` and
|
|
470
|
+
// `ACCOUNT_QUOTA` failure codes, and a provider HTTP 402 maps to `QUOTA`
|
|
471
|
+
// (dsh-llm/src/error.ts `isQuotaExceededError`, dsh-llm-deepseek
|
|
472
|
+
// `providerError`). Those facts reach the Client on the durable
|
|
473
|
+
// `turn/end` event as `reason.error` = `{ code, message, status? }`, so the
|
|
474
|
+
// panel does not need a Host change: it folds the session event feed.
|
|
475
|
+
//
|
|
476
|
+
// The wording pass exists because a failure can be flattened to a
|
|
477
|
+
// transient-looking code with the provider's text kept in `message`. The
|
|
478
|
+
// provider-status code is NOT authoritative for the account state: several
|
|
479
|
+
// providers answer an exhausted balance with **HTTP 429** rather than 402 —
|
|
480
|
+
// e.g. `429: {"message":"insufficient balance","request_id":"…"}` — and the
|
|
481
|
+
// Host classifies that as `RATE_LIMIT`, which is normally transient. The
|
|
482
|
+
// message therefore has to decide in that case, so a 429 with balance
|
|
483
|
+
// wording strikes while a 429 that is a real rate limit does not.
|
|
484
|
+
//
|
|
485
|
+
// The wording pass is deliberately narrow: a bare "402"/"429" only counts
|
|
486
|
+
// when it reads as the leading status, never as a number inside another
|
|
487
|
+
// message, and every example below is taken from a real recorded session.
|
|
488
|
+
// ---------------------------------------------------------------------
|
|
489
|
+
|
|
490
|
+
/** Failure codes that mean "this account has no usable balance left". */
|
|
491
|
+
const INSUFFICIENT_CODES = new Set([
|
|
492
|
+
'QUOTA', 'ACCOUNT_QUOTA', 'INSUFFICIENT_QUOTA', 'INSUFFICIENT_BALANCE', 'BUDGET_EXHAUSTED',
|
|
493
|
+
])
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Provider wording for an exhausted balance, credit, or account quota.
|
|
497
|
+
* Mirrors the Host's own classifier so the panel and the Chat's quota notice
|
|
498
|
+
* agree about what counts, plus the provider-specific shapes seen in real
|
|
499
|
+
* sessions (the Host's regexes are not importable from a browser bundle).
|
|
500
|
+
*/
|
|
501
|
+
const INSUFFICIENT_TEXT = [
|
|
502
|
+
// "insufficient balance" / "insufficient_balance_error" / "insufficient credits".
|
|
503
|
+
/\binsufficient[\s_-]+(?:quota|balance|credits?|funds?|user[\s_-]+quota)\b/i,
|
|
504
|
+
// "quota exceeded" / "usage limit reached" / "quota_exceeded".
|
|
505
|
+
/\b(?:quota|usage[\s_-]+limit)[\s_-]+(?:exceeded|exhausted|reached)\b/i,
|
|
506
|
+
// "exceeded your current quota".
|
|
507
|
+
/\bexceed(?:ed|s)?[\s_-]+(?:(?:your|the)[\s_-]+)?(?:current[\s_-]+)?quota\b/i,
|
|
508
|
+
// "balance exhausted" / "credits depleted" / "credit too low".
|
|
509
|
+
/\b(?:balance|credits?)[\s_-]+(?:exhausted|depleted|too[\s_-]+low)\b/i,
|
|
510
|
+
// "out of credits" / "out of budget".
|
|
511
|
+
/\bout[\s_-]+of[\s_-]+(?:credits?|budget|balance)\b/i,
|
|
512
|
+
// "no credits remaining" (OpenAI) / "no available balance".
|
|
513
|
+
/\bno[\s_-]+(?:available[\s_-]+)?(?:credits?|balance|quota)\b/i,
|
|
514
|
+
/\bno[\s_-]+credits?[\s_-]+remaining\b/i,
|
|
515
|
+
// "Not enough quota to pre-consume" (new-api gateways).
|
|
516
|
+
/\bnot[\s_-]+enough[\s_-]+quota\b/i,
|
|
517
|
+
// "doesn't have any credits or licenses yet" (xAI).
|
|
518
|
+
/\b(?:any|no)[\s_-]+credits?[\s_-]+or[\s_-]+licen[cs]es?\b/i,
|
|
519
|
+
// "Billing daily count exceeded" / billing hard limit.
|
|
520
|
+
/\bbilling[\s_-]+(?:daily[\s_-]+count[\s_-]+exceeded|hard[\s_-]+limit)\b/i,
|
|
521
|
+
// "Your requests have exceeded the quota" / "quota is exhausted" (Trae).
|
|
522
|
+
/\bquota[\s_-]+is[\s_-]+exhausted\b/i,
|
|
523
|
+
/\brequests?[\s_-]+have[\s_-]+exceeded[\s_-]+the[\s_-]+quota\b/i,
|
|
524
|
+
/\bpayment[\s_-]+required\b/i,
|
|
525
|
+
// Chinese provider wording: 额度已用尽 / 余额不足 / 预算已用完 / 欠费.
|
|
526
|
+
/额度不足|余额不足|额度已用尽|余额已用尽|欠费|账户余额|额度已耗尽/,
|
|
527
|
+
/(?:预算|额度)[^。;;]{0,12}(?:已用完|已用尽|不足|耗尽)/,
|
|
528
|
+
]
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* Decide whether one recorded failure means the account ran out of
|
|
532
|
+
* balance rather than hitting a transient limit.
|
|
533
|
+
*
|
|
534
|
+
* A structured balance code is authoritative. A provider status of `402`
|
|
535
|
+
* ("payment required") is too. **`429` is NOT authoritative either way:**
|
|
536
|
+
* some providers report an exhausted balance with 429 and a body that says
|
|
537
|
+
* so (`429: {"message":"insufficient balance",…}`), which the Host
|
|
538
|
+
* classifies as `RATE_LIMIT`. For every other case the message decides, so
|
|
539
|
+
* a 429 carrying balance wording strikes while a genuine rate limit does
|
|
540
|
+
* not.
|
|
541
|
+
* @param {unknown} error - a `turn/end` `reason.error` (`{ code, message, status }`), or a thrown value.
|
|
542
|
+
* @returns {boolean} true only for an exhausted balance, credit, or quota.
|
|
543
|
+
*/
|
|
544
|
+
function isInsufficientBalance(error) {
|
|
545
|
+
if (error === null || typeof error !== 'object') {
|
|
546
|
+
return typeof error === 'string' ? INSUFFICIENT_TEXT.some(pattern => pattern.test(error)) : false
|
|
547
|
+
}
|
|
548
|
+
const code = typeof error.code === 'string' ? error.code.toUpperCase() : ''
|
|
549
|
+
if (INSUFFICIENT_CODES.has(code)) return true
|
|
550
|
+
// A provider status of 402 means "payment required", which in practice is
|
|
551
|
+
// the account running dry. The structured `status` field is preferred; a
|
|
552
|
+
// code shaped `HTTP_402` carries the same fact.
|
|
553
|
+
if (error.status === 402 || code === 'HTTP_402') return true
|
|
554
|
+
// A 429 (or any other status) is decided by the text: providers use 429
|
|
555
|
+
// for BOTH a transient rate limit and an exhausted balance.
|
|
556
|
+
const message = typeof error.message === 'string' ? error.message : ''
|
|
557
|
+
if (INSUFFICIENT_TEXT.some(pattern => pattern.test(message))) return true
|
|
558
|
+
// `insufficient_user_quota` and friends can arrive only as a structured
|
|
559
|
+
// code inside the message body rather than as the failure code.
|
|
560
|
+
return /\binsufficient[\s_-]+user[\s_-]+quota\b/i.test(message)
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* Stable identity of one model route. The strike state is keyed by route,
|
|
565
|
+
* not by row, because the same model appears under both grouping modes and
|
|
566
|
+
* a provider may publish the same id under two groups.
|
|
567
|
+
* @param {unknown} provider - provider id.
|
|
568
|
+
* @param {unknown} model - provider-owned model id.
|
|
569
|
+
* @returns {string} the composite key.
|
|
570
|
+
*/
|
|
571
|
+
function routeKeyOf(provider, model) {
|
|
572
|
+
return `${String(provider ?? '')}/${String(model ?? '')}`
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* Turn endings that PROVE the model route answered, so a standing
|
|
577
|
+
* insufficient-balance mark must end. A cancellation (`aborted`) or a turn
|
|
578
|
+
* closed before any request ran (`blocked`, and the synthetic
|
|
579
|
+
* `interrupted` / `forked` closers) proves nothing about the account — the
|
|
580
|
+
* model never answered — so those leave the mark alone.
|
|
581
|
+
*/
|
|
582
|
+
const CLEARING_TURN_ENDS = new Set(['completed', 'max-tokens', 'error'])
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* Wall-clock time one durable event was recorded at. The Client feed keeps
|
|
586
|
+
* the Host's `time` (`Date.now()` at append), which is what lets marks
|
|
587
|
+
* gathered from DIFFERENT Sessions be ordered against each other — a Session
|
|
588
|
+
* feed has no cross-Session sequence number.
|
|
589
|
+
* @param {unknown} event - one durable Session event.
|
|
590
|
+
* @returns {number} the event time, or 0 when the feed carried none.
|
|
591
|
+
*/
|
|
592
|
+
function eventTimeOf(event) {
|
|
593
|
+
const time = event?.time
|
|
594
|
+
return typeof time === 'number' && Number.isFinite(time) ? time : 0
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* Decide what ONE durable event says about a model route's balance, with no
|
|
599
|
+
* state involved. This is the whole semantic core of the feature, kept
|
|
600
|
+
* separate from the bookkeeping so it can be pinned directly.
|
|
601
|
+
*
|
|
602
|
+
* - A `turn/end` that failed on insufficient balance → `true` (mark).
|
|
603
|
+
* - A `turn/end` that proves the model answered otherwise → `false`
|
|
604
|
+
* (clear): a completed turn, a `max-tokens` turn, or a turn that failed
|
|
605
|
+
* for a different reason.
|
|
606
|
+
* - A cancellation (`aborted`), a `blocked` turn, and the synthetic
|
|
607
|
+
* `interrupted` / `forked` closers → `null` (no decision), because the
|
|
608
|
+
* model never answered and the account state is unknown.
|
|
609
|
+
* - Anything that is not a `turn/end`, or has no route → `null`.
|
|
610
|
+
* @param {unknown} event - one durable Session event.
|
|
611
|
+
* @param {{ provider: string, model: string } | null} route - the route that request used.
|
|
612
|
+
* @returns {boolean | null} mark, clear, or no decision.
|
|
613
|
+
*/
|
|
614
|
+
function decideMark(event, route) {
|
|
615
|
+
if (event === null || typeof event !== 'object') return null
|
|
616
|
+
if (event.type !== 'turn/end' || route === null || route === undefined) return null
|
|
617
|
+
const reason = event.data?.reason
|
|
618
|
+
if (reason === null || typeof reason !== 'object') return null
|
|
619
|
+
if (reason.kind === 'error') return isInsufficientBalance(reason.error)
|
|
620
|
+
return CLEARING_TURN_ENDS.has(reason.kind) ? false : null
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* Fold ONE decision into the mark map, last-writer-wins by event time.
|
|
625
|
+
*
|
|
626
|
+
* The time watermark is what makes the state GLOBAL and replay-safe: marks
|
|
627
|
+
* arrive from every Session's feed (and from storage written by an earlier
|
|
628
|
+
* page load), so the newest observation wins no matter which Session it came
|
|
629
|
+
* from. Re-walking an old window therefore cannot resurrect a mark that a
|
|
630
|
+
* later call already cleared, and a cleared route keeps a watermark so a
|
|
631
|
+
* stale replay stays a no-op.
|
|
632
|
+
* @param {Map<string, {marked: boolean, at: number}>} entries - state, mutated in place.
|
|
633
|
+
* @param {string} key - the route key.
|
|
634
|
+
* @param {boolean} marked - the decision this event carries.
|
|
635
|
+
* @param {number} at - the event's time.
|
|
636
|
+
* @returns {{ visible: boolean, stored: boolean }} whether the visible mark
|
|
637
|
+
* set changed, and whether the underlying record changed.
|
|
638
|
+
*/
|
|
639
|
+
function foldMarkEntry(entries, key, marked, at) {
|
|
640
|
+
const previous = entries.get(key)
|
|
641
|
+
if (previous !== undefined && at < previous.at) {
|
|
642
|
+
return { visible: false, stored: false }
|
|
643
|
+
}
|
|
644
|
+
if (previous !== undefined && previous.marked === marked && previous.at === at) {
|
|
645
|
+
return { visible: false, stored: false }
|
|
646
|
+
}
|
|
647
|
+
entries.set(key, { marked, at })
|
|
648
|
+
return { visible: previous === undefined ? marked : previous.marked !== marked, stored: true }
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* Fold one durable event into the mark map. Pure helper kept for the tests
|
|
653
|
+
* and for callers that hold their own map; the live store uses
|
|
654
|
+
* {@link decideMark} + {@link foldMarkEntry} so it can batch persistence.
|
|
655
|
+
* @param {Map<string, {marked: boolean, at: number}>} state - state, mutated in place.
|
|
656
|
+
* @param {unknown} event - one durable Session event.
|
|
657
|
+
* @param {{ provider: string, model: string } | null} route - the route that request used.
|
|
658
|
+
* @param {number} [at] - the event's time; defaults to `event.time`.
|
|
659
|
+
* @returns {boolean} whether the visible mark set changed.
|
|
660
|
+
*/
|
|
661
|
+
function foldInsufficient(state, event, route, at) {
|
|
662
|
+
const marked = decideMark(event, route)
|
|
663
|
+
if (marked === null) return false
|
|
664
|
+
const key = routeKeyOf(route.provider, route.model)
|
|
665
|
+
const time = at === undefined ? eventTimeOf(event) : at
|
|
666
|
+
return foldMarkEntry(state, key, marked, time).visible
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* Resolve the model route a request header names. The header's `config` is
|
|
671
|
+
* an `LlmCallConfig` (`{ provider, model, ... }`).
|
|
672
|
+
* @param {unknown} event - one durable Session event.
|
|
673
|
+
* @returns {{ provider: string, model: string } | null} the route, or null when this event is not a usable header.
|
|
674
|
+
*/
|
|
675
|
+
function routeOfHeader(event) {
|
|
676
|
+
if (event === null || typeof event !== 'object' || event.type !== 'request/header') return null
|
|
677
|
+
const config = event.data?.header?.config
|
|
678
|
+
if (config === null || typeof config !== 'object') return null
|
|
679
|
+
if (typeof config.provider !== 'string' || typeof config.model !== 'string') return null
|
|
680
|
+
return { provider: config.provider, model: config.model }
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/**
|
|
684
|
+
* Every searchable face of a provider-authored text: a plain string is one
|
|
685
|
+
* face, a localized object contributes each language it carries. Provider
|
|
686
|
+
* names arrive localized (`{ zh: '豆包(doubao)', en: 'Doubao' }`), so the
|
|
687
|
+
* search has to see all of them — matching only the English face would make
|
|
688
|
+
* the Chinese name unsearchable.
|
|
689
|
+
* @param {unknown} value - a string, or a localized record of strings.
|
|
690
|
+
* @returns {string[]} lowercased, non-empty faces.
|
|
691
|
+
*/
|
|
692
|
+
function textFaces(value) {
|
|
693
|
+
if (typeof value === 'string') return [value.toLowerCase()].filter(text => text !== '')
|
|
694
|
+
if (value === null || typeof value !== 'object') return []
|
|
695
|
+
const faces = []
|
|
696
|
+
for (const face of Object.values(value)) {
|
|
697
|
+
if (typeof face !== 'string') continue
|
|
698
|
+
const text = face.toLowerCase()
|
|
699
|
+
if (text !== '') faces.push(text)
|
|
700
|
+
}
|
|
701
|
+
return faces
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/**
|
|
705
|
+
* Match the query against the provider itself — its id, its catalog name,
|
|
706
|
+
* and the label its section draws. A hit means "this vendor was asked for",
|
|
707
|
+
* so the whole catalog of that provider is listed: typing `dou` finds the
|
|
708
|
+
* `豆包(doubao)` provider and every model under it, including the ones whose
|
|
709
|
+
* own names never contain the query.
|
|
710
|
+
*
|
|
711
|
+
* A multi-token query matches the provider when every token lands in SOME
|
|
712
|
+
* face of the same provider, so `mod inte` finds a provider named
|
|
713
|
+
* `ModelScope International` even though those words are never contiguous.
|
|
714
|
+
* Tokens may land in different faces — the id answers `mod`, the localized
|
|
715
|
+
* name answers `inte`.
|
|
716
|
+
*
|
|
717
|
+
* Only prefix and substring are considered per token. A subsequence pass —
|
|
718
|
+
* which `matchScore` allows for model names — would let a two-letter query
|
|
719
|
+
* light up every provider in the directory, which is noise rather than a
|
|
720
|
+
* match.
|
|
721
|
+
* @param {{id?: string, name?: unknown}} group - one provider entry.
|
|
722
|
+
* @param {string} query - raw search text.
|
|
723
|
+
* @param {unknown} [title] - the label this provider's section draws.
|
|
724
|
+
* @returns {0|1|null} 0 for a prefix hit, 1 for a substring hit, else null.
|
|
725
|
+
*/
|
|
726
|
+
function providerScore(group, query, title) {
|
|
727
|
+
const tokens = searchTokens(query)
|
|
728
|
+
if (tokens.length === 0) return null
|
|
729
|
+
const texts = [...textFaces(group?.id), ...textFaces(group?.name), ...textFaces(title)]
|
|
730
|
+
if (texts.length === 0) return null
|
|
731
|
+
/** The best rank one token reaches across every face. */
|
|
732
|
+
const rankOf = token => {
|
|
733
|
+
let best = null
|
|
734
|
+
for (const text of texts) {
|
|
735
|
+
const rank = text.startsWith(token) ? 0 : text.includes(token) ? 1 : null
|
|
736
|
+
if (rank === null) continue
|
|
737
|
+
if (best === null || rank < best) best = rank
|
|
738
|
+
if (best === 0) return 0
|
|
739
|
+
}
|
|
740
|
+
return best
|
|
741
|
+
}
|
|
742
|
+
let weakest = 0
|
|
743
|
+
for (const token of tokens) {
|
|
744
|
+
const rank = rankOf(token)
|
|
745
|
+
if (rank === null) return null
|
|
746
|
+
if (rank > weakest) weakest = rank
|
|
747
|
+
}
|
|
748
|
+
return weakest
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// ---------------------------------------------------------------------
|
|
752
|
+
// Model families: hard-coded dictionary → id-derived guess → Others.
|
|
753
|
+
// Families stay flat; Others always sorts last. A family is a BRAND, and
|
|
754
|
+
// a *registered* brand keeps its GENERATIONS apart: `hy3` is `HY3`,
|
|
755
|
+
// `hy4-preview`/`hy4free`/`hy4std` are `HY4`, while a bare `hy` is `HY`.
|
|
756
|
+
// Unregistered brands still collapse their version tail (`acme4-turbo`
|
|
757
|
+
// and `acme-7b` are one `Acme`). Dictionary families never split.
|
|
758
|
+
// ---------------------------------------------------------------------
|
|
759
|
+
const FAMILY_DICTIONARY = [
|
|
760
|
+
['DeepSeek', ['deepseek']],
|
|
761
|
+
['Grok', ['grok']],
|
|
762
|
+
['Opus', ['opus']],
|
|
763
|
+
['Haiku', ['haiku']],
|
|
764
|
+
['Sonnet', ['sonnet']],
|
|
765
|
+
['Claude', ['claude']],
|
|
766
|
+
['Qwen', ['qwen', 'qwq']],
|
|
767
|
+
['GLM', ['glm', 'chatglm']],
|
|
768
|
+
['Kimi', ['kimi', 'moonshot']],
|
|
769
|
+
['GPT', ['gpt', 'chatgpt', 'o1-', 'o3-', 'o4-']],
|
|
770
|
+
['Gemini', ['gemini', 'gemma']],
|
|
771
|
+
['Llama', ['llama']],
|
|
772
|
+
['Mistral', ['mistral', 'mixtral', 'codestral']],
|
|
773
|
+
['Doubao', ['doubao', 'seed-']],
|
|
774
|
+
['MiniMax', ['minimax', 'abab']],
|
|
775
|
+
['Hunyuan', ['hunyuan']],
|
|
776
|
+
['ERNIE', ['ernie']],
|
|
777
|
+
['Yi', ['yi-', '01-ai']],
|
|
778
|
+
['Phi', ['phi-']],
|
|
779
|
+
['Command', ['command-r']],
|
|
780
|
+
['Nova', ['nova-']],
|
|
781
|
+
['Step', ['step-']],
|
|
782
|
+
]
|
|
783
|
+
const OTHERS = 'Others'
|
|
784
|
+
const FAMILY_ALIASES = new Map([
|
|
785
|
+
['gpt', 'GPT'], ['glm', 'GLM'], ['qwen', 'Qwen'], ['kimi', 'Kimi'],
|
|
786
|
+
])
|
|
787
|
+
const VERSION_ONLY = /^(v|ver|r)?\d+([.-]\d+)*$/i
|
|
788
|
+
/**
|
|
789
|
+
* A brand token plus its version tail: `hy3`, `hy4-preview`, `hy4free`,
|
|
790
|
+
* `qwen2.5`. Group 1 is the brand, group 2 the leading generation digit(s)
|
|
791
|
+
* (captured so registered brands can keep generations apart), group 3 the
|
|
792
|
+
* rest of the variant tail.
|
|
793
|
+
*/
|
|
794
|
+
const BRAND_WITH_VERSION = /^([a-z][a-z]*?)(\d+)([\w.-]*)$/i
|
|
795
|
+
/**
|
|
796
|
+
* Registered brands, mapped to their exact heading casing. Sort with the
|
|
797
|
+
* dictionary families instead of after them. `HY` is the case that
|
|
798
|
+
* prompted this: its ids (`hy3`, `hy4-preview`, `hy4free`) share no name
|
|
799
|
+
* needle, and the brand is an acronym the generic title-casing would draw
|
|
800
|
+
* as `Hy`. A registered brand's family is `BRAND + generation` when the id
|
|
801
|
+
* carries a generation digit (so `hy3` → `HY3`, `hy4free` → `HY4`), and
|
|
802
|
+
* the bare brand otherwise (`hy` → `HY`). Dictionary families are never
|
|
803
|
+
* split by generation.
|
|
804
|
+
*/
|
|
805
|
+
const FAMILY_BRANDS = new Map([['hy', 'HY']])
|
|
806
|
+
const FAMILY_ORDER = [
|
|
807
|
+
...FAMILY_DICTIONARY.map(entry => entry[0]),
|
|
808
|
+
...FAMILY_BRANDS.values(),
|
|
809
|
+
]
|
|
810
|
+
|
|
811
|
+
/**
|
|
812
|
+
* Split one versioned id token into its brand / generation / variant tail.
|
|
813
|
+
* @param {string} token - one lowercased id segment.
|
|
814
|
+
* @returns {{brand: string, generation: string, variant: string} | null}
|
|
815
|
+
*/
|
|
816
|
+
function versionParts(token) {
|
|
817
|
+
const hit = BRAND_WITH_VERSION.exec(token)
|
|
818
|
+
if (hit === null) return null
|
|
819
|
+
const brand = hit[1]
|
|
820
|
+
return brand.length < 2 ? null : { brand, generation: hit[2], variant: hit[3] }
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
/**
|
|
824
|
+
* The brand part of one versioned id token, or '' when the token carries
|
|
825
|
+
* no version tail.
|
|
826
|
+
* @param {string} token - one lowercased id segment.
|
|
827
|
+
* @returns {string} the lowercased brand, or ''.
|
|
828
|
+
*/
|
|
829
|
+
function brandOf(token) {
|
|
830
|
+
return versionParts(token)?.brand ?? ''
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/**
|
|
834
|
+
* Resolve one model's family.
|
|
835
|
+
* @param {{id: string, name: string}} model - catalog entry.
|
|
836
|
+
* @returns {string} a flat family name; never empty.
|
|
837
|
+
*/
|
|
838
|
+
function familyOf(model) {
|
|
839
|
+
const haystack = `${model.id} ${model.name}`.toLowerCase()
|
|
840
|
+
for (const entry of FAMILY_DICTIONARY) {
|
|
841
|
+
if (entry[1].some(needle => haystack.includes(needle))) return entry[0]
|
|
842
|
+
}
|
|
843
|
+
// Id-derived guess. The provider prefix is dropped, then every remaining
|
|
844
|
+
// segment is checked for a registered brand, so an id that prefixes the
|
|
845
|
+
// brand with its provider (`codebuddy-hy3`) still lands in `HY3`, and a
|
|
846
|
+
// bare brand id (`hy`) resolves to `HY` rather than the generic casing.
|
|
847
|
+
const segments = String(model.id).split(/[/:]/).pop().split(/[-_.]/).filter(Boolean)
|
|
848
|
+
if (segments.length === 0) return OTHERS
|
|
849
|
+
for (const segment of segments) {
|
|
850
|
+
const lower = segment.toLowerCase()
|
|
851
|
+
const parts = versionParts(lower)
|
|
852
|
+
const known = FAMILY_BRANDS.get(lower)
|
|
853
|
+
?? FAMILY_BRANDS.get(parts?.brand ?? '')
|
|
854
|
+
if (known !== undefined) {
|
|
855
|
+
// A registered brand keeps its generations distinct: hy3 → HY3,
|
|
856
|
+
// hy4free → HY4. A bare brand with no generation stays `HY`.
|
|
857
|
+
return parts === null ? known : `${known}${parts.generation}`
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
const token = segments[0].toLowerCase()
|
|
861
|
+
if (token.length < 2 || VERSION_ONLY.test(token)) return OTHERS
|
|
862
|
+
const alias = FAMILY_ALIASES.get(token)
|
|
863
|
+
if (alias !== undefined) return alias
|
|
864
|
+
// An unregistered versioned segment collapses onto its brand, so
|
|
865
|
+
// `acme4-turbo` and `acme-7b` share one heading instead of two.
|
|
866
|
+
const brand = brandOf(token)
|
|
867
|
+
const stem = brand === '' ? token : brand
|
|
868
|
+
return stem.charAt(0).toUpperCase() + stem.slice(1)
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
/**
|
|
872
|
+
* Sort rank of a family. Registered-brand generations (`HY3`, `HY4`, …)
|
|
873
|
+
* rank with their bare brand (`HY`) and order by generation number, so a
|
|
874
|
+
* new generation lands next to its siblings instead of at the end.
|
|
875
|
+
*/
|
|
876
|
+
function familyRank(family) {
|
|
877
|
+
if (family === OTHERS) return Number.MAX_SAFE_INTEGER
|
|
878
|
+
const known = FAMILY_ORDER.indexOf(family)
|
|
879
|
+
if (known !== -1) return known
|
|
880
|
+
for (const [brand, heading] of FAMILY_BRANDS) {
|
|
881
|
+
if (!family.startsWith(heading) || family.length === heading.length) continue
|
|
882
|
+
const generation = family.slice(heading.length)
|
|
883
|
+
if (!/^\d+$/.test(generation)) continue
|
|
884
|
+
return FAMILY_ORDER.indexOf(heading) + Number(generation) / 1000
|
|
885
|
+
}
|
|
886
|
+
return FAMILY_ORDER.length
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
/**
|
|
890
|
+
* A short variant label taken from a model id, used only to tell several
|
|
891
|
+
* models of ONE provider inside ONE family apart. The family prefix is
|
|
892
|
+
* stripped from every id segment, so hy4free → "free", hy4std → "std",
|
|
893
|
+
* hy4-preview → "preview", hy4flash-exp1 → "flash exp1", hy3-x → "x",
|
|
894
|
+
* and a bare hy3 → "". Pure version numbers are dropped. Returns '' when
|
|
895
|
+
* the id carries nothing beyond the family heading itself.
|
|
896
|
+
* @param {{id: string}} model - catalog entry.
|
|
897
|
+
* @param {string} family - the resolved family heading.
|
|
898
|
+
* @returns {string} a short variant label, or ''.
|
|
899
|
+
*/
|
|
900
|
+
function idVariantOf(model, family) {
|
|
901
|
+
const familyLower = family.toLowerCase()
|
|
902
|
+
const segments = String(model.id).split(/[/:]/).pop().split(/[-_.]/).filter(Boolean)
|
|
903
|
+
const words = []
|
|
904
|
+
for (const raw of segments) {
|
|
905
|
+
const segment = raw.toLowerCase()
|
|
906
|
+
// Remove the family heading from inside a glued segment: hy4free →
|
|
907
|
+
// free, hy4flash → flash. A segment equal to the family drops to ''.
|
|
908
|
+
let word = segment.startsWith(familyLower) ? segment.slice(familyLower.length) : segment
|
|
909
|
+
word = word.replace(/^[-._]+/, '')
|
|
910
|
+
if (word === '' || /^\d+(\.\d+)?$/.test(word)) continue
|
|
911
|
+
words.push(...word.split(/[-._]+/).filter(Boolean))
|
|
912
|
+
}
|
|
913
|
+
return words.filter(word => !/^\d+$/.test(word)).join(' ')
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
/**
|
|
917
|
+
* The distinguishing part of one model for a family-grouped row: the id's
|
|
918
|
+
* variant tail first, then any catalog-name words that are not just the
|
|
919
|
+
* family heading or a bare version number. Returns '' when the model adds
|
|
920
|
+
* nothing beyond what the family heading already says.
|
|
921
|
+
* @param {{id: string, name: string}} model - catalog entry.
|
|
922
|
+
* @param {string} family - the resolved family heading.
|
|
923
|
+
* @returns {string} a short, non-family supplement; possibly ''.
|
|
924
|
+
*/
|
|
925
|
+
function rowVariantOf(model, family) {
|
|
926
|
+
const fromId = idVariantOf(model, family)
|
|
927
|
+
if (fromId !== '') return fromId
|
|
928
|
+
const familyWords = new Set(family.toLowerCase().split(/[\s-]+/).filter(Boolean))
|
|
929
|
+
return String(model.name ?? '').split(/[\s._-]+/).filter(word => {
|
|
930
|
+
const lower = word.toLowerCase()
|
|
931
|
+
return word !== '' && !familyWords.has(lower) && !/^\d+(\.\d+)?$/.test(lower)
|
|
932
|
+
}).join(' ')
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
// ---------------------------------------------------------------------
|
|
936
|
+
// Column planning
|
|
937
|
+
// ---------------------------------------------------------------------
|
|
938
|
+
/** One indivisible placement unit: a group title with its first model, or a lone model row. */
|
|
939
|
+
function buildUnits(sections, rowH, headH) {
|
|
940
|
+
const units = []
|
|
941
|
+
for (const section of sections) {
|
|
942
|
+
if (section.models.length === 0) continue
|
|
943
|
+
units.push({
|
|
944
|
+
height: headH + ROW_GAP + rowH,
|
|
945
|
+
blocks: [
|
|
946
|
+
{ kind: 'title', key: `t:${section.key}`, label: section.title },
|
|
947
|
+
{ kind: 'row', key: `r:${section.key}:0`, entry: section.models[0] },
|
|
948
|
+
],
|
|
949
|
+
})
|
|
950
|
+
for (let index = 1; index < section.models.length; index += 1) {
|
|
951
|
+
units.push({
|
|
952
|
+
height: rowH,
|
|
953
|
+
blocks: [{ kind: 'row', key: `r:${section.key}:${index}`, entry: section.models[index] }],
|
|
954
|
+
})
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
return units
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
function columnHeight(units) {
|
|
961
|
+
if (units.length === 0) return 0
|
|
962
|
+
return units.reduce((sum, unit) => sum + unit.height, 0) + ROW_GAP * (units.length - 1)
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
/**
|
|
966
|
+
* Flow units into `count` columns, then migrate the last column's deficit
|
|
967
|
+
* back into its neighbour. A group title never separates from its first
|
|
968
|
+
* model because both live in one unit.
|
|
969
|
+
*/
|
|
970
|
+
function packColumns(units, count) {
|
|
971
|
+
const columns = Array.from({ length: count }, () => [])
|
|
972
|
+
const target = columnHeight(units) / count
|
|
973
|
+
let index = 0
|
|
974
|
+
for (let column = 0; column < count && index < units.length; column += 1) {
|
|
975
|
+
const isLast = column === count - 1
|
|
976
|
+
let used = 0
|
|
977
|
+
while (index < units.length) {
|
|
978
|
+
const unit = units[index]
|
|
979
|
+
const add = used === 0 ? unit.height : unit.height + ROW_GAP
|
|
980
|
+
if (!isLast && used > 0 && used + add > target) break
|
|
981
|
+
columns[column].push(unit)
|
|
982
|
+
used += add
|
|
983
|
+
index += 1
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
while (index < units.length) {
|
|
987
|
+
columns[count - 1].push(units[index])
|
|
988
|
+
index += 1
|
|
989
|
+
}
|
|
990
|
+
// Balance the final pair only when it actually narrows the spread.
|
|
991
|
+
if (count > 1) {
|
|
992
|
+
for (let guard = 0; guard < units.length; guard += 1) {
|
|
993
|
+
const left = columns[count - 2]
|
|
994
|
+
const right = columns[count - 1]
|
|
995
|
+
if (left.length <= 1) break
|
|
996
|
+
const leftHeight = columnHeight(left)
|
|
997
|
+
const rightHeight = columnHeight(right)
|
|
998
|
+
if (leftHeight - rightHeight < BALANCE_THRESHOLD) break
|
|
999
|
+
const unit = left[left.length - 1]
|
|
1000
|
+
const before = Math.max(leftHeight, rightHeight)
|
|
1001
|
+
const after = Math.max(columnHeight(left.slice(0, -1)), columnHeight([...right, unit]))
|
|
1002
|
+
if (after >= before) break
|
|
1003
|
+
left.pop()
|
|
1004
|
+
right.push(unit)
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
return columns
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
/** Smallest column count (1–3) whose tallest column fits; otherwise 3 + scroll. */
|
|
1011
|
+
function planColumns(units, available) {
|
|
1012
|
+
if (units.length === 0) return { count: 1, columns: [[]] }
|
|
1013
|
+
for (let count = 1; count <= 3; count += 1) {
|
|
1014
|
+
const columns = packColumns(units, count)
|
|
1015
|
+
if (Math.max(...columns.map(columnHeight)) <= available) return { count, columns }
|
|
1016
|
+
}
|
|
1017
|
+
return { count: 3, columns: packColumns(units, 3) }
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
// ---------------------------------------------------------------------
|
|
1021
|
+
// Dictionaries (this namespace owns the panel's own copy; the teamwork
|
|
1022
|
+
// row prefers the host's `agent-team` wording when it is registered).
|
|
1023
|
+
//
|
|
1024
|
+
// The namespace id is minted per module generation. The locale registry
|
|
1025
|
+
// keeps a namespace's slot after its disposer runs, and a re-registration
|
|
1026
|
+
// of the same id throws — which would cost the seat its copy on the
|
|
1027
|
+
// enable/disable/enable path. A fresh id makes every generation's
|
|
1028
|
+
// registration independent, and the previous one is dropped with its own
|
|
1029
|
+
// disposer.
|
|
1030
|
+
// ---------------------------------------------------------------------
|
|
1031
|
+
const NS = `modelFlatmap-${Math.random().toString(36).slice(2, 10)}`
|
|
1032
|
+
|
|
1033
|
+
// ---------------------------------------------------------------------
|
|
1034
|
+
// Grouping persistence — across sessions and workgroups.
|
|
1035
|
+
//
|
|
1036
|
+
// The `按 Provider / 按模型家族` choice is a user preference that must
|
|
1037
|
+
// survive a reload, a new conversation, and a workgroup switch. On DSH
|
|
1038
|
+
// Desktop the Web GUI runs on a stable origin (a fixed bundled origin in
|
|
1039
|
+
// production, `http://127.0.0.1:56668` in development), so `localStorage`
|
|
1040
|
+
// — origin-scoped, not session-scoped — is exactly the right store: it
|
|
1041
|
+
// persists across every one of those boundaries. DSH's own client store
|
|
1042
|
+
// (`@deepseek-ai/dsh-client-store`) is the same `localStorage` wrapper
|
|
1043
|
+
// under the hood, but importing an internal Harness client package is
|
|
1044
|
+
// forbidden by the UI rules (it changes without notice and a throw blanks
|
|
1045
|
+
// the seat). We therefore use `localStorage` directly — the public,
|
|
1046
|
+
// self-contained mechanism the host endorses underneath.
|
|
1047
|
+
//
|
|
1048
|
+
// The store is read and written lazily, and every access degrades: when
|
|
1049
|
+
// `localStorage` is unavailable (private mode, sandboxed frame) the choice
|
|
1050
|
+
// still works for the lifetime of the tab, falling back to the in-memory
|
|
1051
|
+
// default rather than throwing.
|
|
1052
|
+
// ---------------------------------------------------------------------
|
|
1053
|
+
const GROUPING_KEY = 'dshmf:grouping'
|
|
1054
|
+
const GROUPING_DEFAULT = 'provider'
|
|
1055
|
+
/**
|
|
1056
|
+
* Read the persisted grouping, or the default when nothing valid was stored.
|
|
1057
|
+
* @returns `'provider'` or `'family'`.
|
|
1058
|
+
*/
|
|
1059
|
+
function readGrouping() {
|
|
1060
|
+
try {
|
|
1061
|
+
if (typeof localStorage === 'undefined') return GROUPING_DEFAULT
|
|
1062
|
+
const value = localStorage.getItem(GROUPING_KEY)
|
|
1063
|
+
return value === 'provider' || value === 'family' ? value : GROUPING_DEFAULT
|
|
1064
|
+
} catch {
|
|
1065
|
+
return GROUPING_DEFAULT
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
/**
|
|
1069
|
+
* Persist one grouping choice. A write failure (quota, disabled storage)
|
|
1070
|
+
* is swallowed: the in-memory state still drives the current tab.
|
|
1071
|
+
* @param {'provider'|'family'} value - the choice to persist.
|
|
1072
|
+
*/
|
|
1073
|
+
function writeGrouping(value) {
|
|
1074
|
+
try {
|
|
1075
|
+
if (typeof localStorage === 'undefined') return
|
|
1076
|
+
localStorage.setItem(GROUPING_KEY, value)
|
|
1077
|
+
} catch {
|
|
1078
|
+
/* storage unavailable — keep the in-memory choice only */
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
// ---------------------------------------------------------------------
|
|
1083
|
+
// Insufficient-balance marks are GLOBAL, not Session-scoped.
|
|
1084
|
+
//
|
|
1085
|
+
// Whether a model has any quota left is a fact about the model route (and
|
|
1086
|
+
// the account behind it), not about one conversation: a failure observed in
|
|
1087
|
+
// one Session must strike the same model everywhere — another Session,
|
|
1088
|
+
// another workspace, and after a reload. So the marks live in ONE
|
|
1089
|
+
// module-level store shared by every seat of this plugin generation, and
|
|
1090
|
+
// are persisted to `localStorage` (origin-scoped, exactly like the grouping
|
|
1091
|
+
// choice) so a fresh page load starts from the last known state.
|
|
1092
|
+
//
|
|
1093
|
+
// Ordering across Sessions is by wall-clock time: each durable event keeps
|
|
1094
|
+
// the Host's `time`, and the newest observation of a route wins. That is
|
|
1095
|
+
// what lets a replayed window (or a stored entry) never override a newer
|
|
1096
|
+
// fact from another Session.
|
|
1097
|
+
// ---------------------------------------------------------------------
|
|
1098
|
+
const MARKS_KEY = 'dshmf:insufficient'
|
|
1099
|
+
/** Marks older than this are dropped on read, so a stale strike cannot live forever. */
|
|
1100
|
+
const MARKS_TTL = 7 * 24 * 60 * 60 * 1000
|
|
1101
|
+
|
|
1102
|
+
/**
|
|
1103
|
+
* Parse a persisted mark record into the in-memory shape, dropping anything
|
|
1104
|
+
* malformed or older than {@link MARKS_TTL}. Pure, so the storage format is
|
|
1105
|
+
* testable without a `localStorage` shim.
|
|
1106
|
+
* @param {unknown} raw - the stored JSON value (already parsed).
|
|
1107
|
+
* @param {number} now - current time, injected for testability.
|
|
1108
|
+
* @returns {Map<string, {marked: boolean, at: number}>} the parsed entries.
|
|
1109
|
+
*/
|
|
1110
|
+
function parseMarks(raw, now) {
|
|
1111
|
+
const entries = new Map()
|
|
1112
|
+
if (raw === null || typeof raw !== 'object') return entries
|
|
1113
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
1114
|
+
if (typeof key !== 'string' || key === '') continue
|
|
1115
|
+
if (value === null || typeof value !== 'object') continue
|
|
1116
|
+
const at = typeof value.at === 'number' && Number.isFinite(value.at) ? value.at : 0
|
|
1117
|
+
if (typeof value.marked !== 'boolean') continue
|
|
1118
|
+
if (now - at > MARKS_TTL) continue
|
|
1119
|
+
entries.set(key, { marked: value.marked, at })
|
|
1120
|
+
}
|
|
1121
|
+
return entries
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
/**
|
|
1125
|
+
* Serialise the in-memory entries for storage, dropping anything older than
|
|
1126
|
+
* {@link MARKS_TTL} so a long-lived page cannot grow the record forever. A
|
|
1127
|
+
* cleared route keeps its watermark only while it can still protect against
|
|
1128
|
+
* a replayed older failure, which is the same window.
|
|
1129
|
+
* @param {Map<string, {marked: boolean, at: number}>} entries - the state.
|
|
1130
|
+
* @param {number} [now] - current time, injected for testability.
|
|
1131
|
+
* @returns {Record<string, {marked: boolean, at: number}>} a JSON-ready record.
|
|
1132
|
+
*/
|
|
1133
|
+
function serializeMarks(entries, now) {
|
|
1134
|
+
const time = now === undefined ? Date.now() : now
|
|
1135
|
+
const out = {}
|
|
1136
|
+
for (const [key, value] of entries) {
|
|
1137
|
+
if (time - value.at > MARKS_TTL) continue
|
|
1138
|
+
out[key] = { marked: value.marked, at: value.at }
|
|
1139
|
+
}
|
|
1140
|
+
return out
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
/**
|
|
1144
|
+
* Read the persisted marks, or an empty map when storage is unavailable,
|
|
1145
|
+
* holds garbage, or throws (private mode, sandboxed frame, quota).
|
|
1146
|
+
* @returns {Map<string, {marked: boolean, at: number}>} the stored entries.
|
|
1147
|
+
*/
|
|
1148
|
+
function readMarks() {
|
|
1149
|
+
try {
|
|
1150
|
+
if (typeof localStorage === 'undefined') return new Map()
|
|
1151
|
+
const text = localStorage.getItem(MARKS_KEY)
|
|
1152
|
+
if (text === null || text === '') return new Map()
|
|
1153
|
+
return parseMarks(JSON.parse(text), Date.now())
|
|
1154
|
+
} catch {
|
|
1155
|
+
return new Map()
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
/**
|
|
1160
|
+
* Persist the marks. A write failure is swallowed: the in-memory store still
|
|
1161
|
+
* drives every open panel in this page.
|
|
1162
|
+
* @param {Map<string, {marked: boolean, at: number}>} entries - the state.
|
|
1163
|
+
*/
|
|
1164
|
+
function writeMarks(entries) {
|
|
1165
|
+
try {
|
|
1166
|
+
if (typeof localStorage === 'undefined') return
|
|
1167
|
+
localStorage.setItem(MARKS_KEY, JSON.stringify(serializeMarks(entries)))
|
|
1168
|
+
} catch {
|
|
1169
|
+
/* storage unavailable — keep the in-memory state only */
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
/** Whether two visible mark maps hold the same entries. */
|
|
1174
|
+
function sameMarks(left, right) {
|
|
1175
|
+
if (left.size !== right.size) return false
|
|
1176
|
+
for (const key of left) {
|
|
1177
|
+
if (!right.has(key)) return false
|
|
1178
|
+
}
|
|
1179
|
+
return true
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
/**
|
|
1183
|
+
* Create the one global mark store for this module generation.
|
|
1184
|
+
*
|
|
1185
|
+
* It owns the state, the listeners, and the Session-feed subscriptions; the
|
|
1186
|
+
* seats only read it. Observing EVERY Session (not just the one a seat
|
|
1187
|
+
* renders for) is what makes a failure recorded anywhere visible
|
|
1188
|
+
* everywhere, including in a Session whose own feed was never opened in this
|
|
1189
|
+
* page load (its marks come from storage).
|
|
1190
|
+
*
|
|
1191
|
+
* @param {object} deps - resolution hooks.
|
|
1192
|
+
* @param {() => object | undefined} deps.sessionsOf - read the `sessions` service, per call.
|
|
1193
|
+
* @param {() => Map<string, {marked: boolean, at: number}>} deps.readStored - initial/persisted state.
|
|
1194
|
+
* @param {(entries: Map<string, {marked: boolean, at: number}>) => void} deps.writeStored - persistence sink.
|
|
1195
|
+
* @returns {object} the store: `subscribe`, `getSnapshot`, `dispose`, `sync`.
|
|
1196
|
+
*/
|
|
1197
|
+
function createMarkStore(deps) {
|
|
1198
|
+
/** Internal truth: route key → last decision and when it was made. */
|
|
1199
|
+
const entries = new Map(deps.readStored())
|
|
1200
|
+
/** Visible subset the rows read: route key → true. */
|
|
1201
|
+
let visible = new Map()
|
|
1202
|
+
let snapshot = { marks: visible }
|
|
1203
|
+
const listeners = new Set()
|
|
1204
|
+
/** Per-Session route of the latest request header (a route is Session-local). */
|
|
1205
|
+
const routes = new Map()
|
|
1206
|
+
/** Per-Session stop handle for its event feed. */
|
|
1207
|
+
const stops = new Map()
|
|
1208
|
+
/** Sessions attached on behalf of a mounted seat; never pruned by a list sync. */
|
|
1209
|
+
const pinned = new Set()
|
|
1210
|
+
/** Last `sessions` service seen, so a reloaded service is re-attached. */
|
|
1211
|
+
let attachedSessions
|
|
1212
|
+
let stopList
|
|
1213
|
+
let disposed = false
|
|
1214
|
+
|
|
1215
|
+
/** Rebuild the visible map; report whether the rendered set changed. */
|
|
1216
|
+
const recompute = () => {
|
|
1217
|
+
const next = new Map()
|
|
1218
|
+
for (const [key, entry] of entries) {
|
|
1219
|
+
if (entry.marked) next.set(key, true)
|
|
1220
|
+
}
|
|
1221
|
+
if (sameMarks(visible, next)) return false
|
|
1222
|
+
visible = next
|
|
1223
|
+
snapshot = { marks: visible }
|
|
1224
|
+
return true
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
recompute()
|
|
1228
|
+
|
|
1229
|
+
const publish = () => {
|
|
1230
|
+
for (const listener of listeners) listener()
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
/** Fold one batch of durable entries for one Session. */
|
|
1234
|
+
const absorb = (sessionId, windowEntries) => {
|
|
1235
|
+
let stored = false
|
|
1236
|
+
let changed = false
|
|
1237
|
+
for (const entry of windowEntries) {
|
|
1238
|
+
const event = entry?.event
|
|
1239
|
+
if (event === null || event === undefined) continue
|
|
1240
|
+
const header = routeOfHeader(event)
|
|
1241
|
+
if (header !== null) {
|
|
1242
|
+
routes.set(sessionId, header)
|
|
1243
|
+
continue
|
|
1244
|
+
}
|
|
1245
|
+
if (event.type !== 'turn/end') continue
|
|
1246
|
+
const route = routes.get(sessionId) ?? null
|
|
1247
|
+
const marked = decideMark(event, route)
|
|
1248
|
+
if (marked === null) continue
|
|
1249
|
+
const result = foldMarkEntry(
|
|
1250
|
+
entries, routeKeyOf(route.provider, route.model), marked, eventTimeOf(event),
|
|
1251
|
+
)
|
|
1252
|
+
if (result.stored) stored = true
|
|
1253
|
+
if (result.visible) changed = true
|
|
1254
|
+
}
|
|
1255
|
+
return { stored, changed }
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
/** Read a window's entries defensively. */
|
|
1259
|
+
const entriesOf = window => (Array.isArray(window?.entries) ? window.entries : [])
|
|
1260
|
+
|
|
1261
|
+
/** Apply one feed window for one Session. */
|
|
1262
|
+
const applyWindow = (sessionId, window, full) => {
|
|
1263
|
+
const change = window?.change
|
|
1264
|
+
let result
|
|
1265
|
+
if (full || change?.kind === 'replace') {
|
|
1266
|
+
// The window is that Session's whole truth, so its route restarts and
|
|
1267
|
+
// its entries are re-walked. Global watermarks keep a newer fact from
|
|
1268
|
+
// another Session from being overwritten by this replay.
|
|
1269
|
+
routes.delete(sessionId)
|
|
1270
|
+
result = absorb(sessionId, entriesOf(window))
|
|
1271
|
+
} else if (change?.kind === 'append') {
|
|
1272
|
+
result = absorb(sessionId, change.entries)
|
|
1273
|
+
} else {
|
|
1274
|
+
// `prepend` pages in OLDER history and `settle-assistant` only swaps a
|
|
1275
|
+
// live attempt for its durable form; neither can change a mark.
|
|
1276
|
+
return
|
|
1277
|
+
}
|
|
1278
|
+
if (result.stored) deps.writeStored(entries)
|
|
1279
|
+
if (result.changed && recompute()) publish()
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
/**
|
|
1283
|
+
* Attach to one Session's durable feed. Idempotent, and a no-op when the
|
|
1284
|
+
* Session has no retained binding (an unopened Session has no feed; its
|
|
1285
|
+
* marks can only come from storage).
|
|
1286
|
+
*/
|
|
1287
|
+
const attachSession = sessionId => {
|
|
1288
|
+
if (disposed || stops.has(sessionId)) return
|
|
1289
|
+
const sessions = deps.sessionsOf()
|
|
1290
|
+
let binding
|
|
1291
|
+
try {
|
|
1292
|
+
binding = typeof sessions?.binding === 'function' ? sessions.binding(sessionId) : undefined
|
|
1293
|
+
} catch {
|
|
1294
|
+
binding = undefined
|
|
1295
|
+
}
|
|
1296
|
+
const source = binding?.eventSource
|
|
1297
|
+
if (source === undefined || typeof source.subscribe !== 'function'
|
|
1298
|
+
|| typeof source.getSnapshot !== 'function') return
|
|
1299
|
+
try {
|
|
1300
|
+
applyWindow(sessionId, source.getSnapshot(), true)
|
|
1301
|
+
} catch {
|
|
1302
|
+
/* an unreadable feed leaves the store as it was */
|
|
1303
|
+
}
|
|
1304
|
+
const listener = () => {
|
|
1305
|
+
try {
|
|
1306
|
+
applyWindow(sessionId, source.getSnapshot(), false)
|
|
1307
|
+
} catch {
|
|
1308
|
+
/* a fold failure must not escape into the slot */
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
try {
|
|
1312
|
+
const stop = source.subscribe(listener)
|
|
1313
|
+
stops.set(sessionId, typeof stop === 'function' ? stop : () => {})
|
|
1314
|
+
} catch {
|
|
1315
|
+
/* subscription refused: leave this Session unattached */
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
/** Attach to every Session the list currently names, and drop the rest. */
|
|
1320
|
+
const sync = () => {
|
|
1321
|
+
if (disposed) return
|
|
1322
|
+
const sessions = deps.sessionsOf()
|
|
1323
|
+
if (sessions === undefined) return
|
|
1324
|
+
// A client reload replaces the service; re-attach from the new one.
|
|
1325
|
+
if (sessions !== attachedSessions) {
|
|
1326
|
+
for (const stop of stops.values()) stop()
|
|
1327
|
+
stops.clear()
|
|
1328
|
+
routes.clear()
|
|
1329
|
+
if (typeof stopList === 'function') stopList()
|
|
1330
|
+
stopList = undefined
|
|
1331
|
+
attachedSessions = sessions
|
|
1332
|
+
try {
|
|
1333
|
+
if (sessions.list !== undefined && typeof sessions.list.subscribe === 'function') {
|
|
1334
|
+
stopList = sessions.list.subscribe(() => { sync() })
|
|
1335
|
+
}
|
|
1336
|
+
} catch {
|
|
1337
|
+
stopList = undefined
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
let ids = []
|
|
1341
|
+
try {
|
|
1342
|
+
const list = sessions.list?.getSnapshot?.()
|
|
1343
|
+
ids = Array.isArray(list?.ids) ? list.ids : []
|
|
1344
|
+
} catch {
|
|
1345
|
+
ids = []
|
|
1346
|
+
}
|
|
1347
|
+
for (const id of ids) attachSession(id)
|
|
1348
|
+
// Drop feeds for Sessions the list no longer names, unless a mounted
|
|
1349
|
+
// seat is currently reading that Session (it may not be listed yet).
|
|
1350
|
+
for (const id of [...stops.keys()]) {
|
|
1351
|
+
if (pinned.has(id)) continue
|
|
1352
|
+
if (!ids.includes(id)) {
|
|
1353
|
+
stops.get(id)?.()
|
|
1354
|
+
stops.delete(id)
|
|
1355
|
+
routes.delete(id)
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
return {
|
|
1361
|
+
/** The seat's `useSyncExternalStore` pair. */
|
|
1362
|
+
subscribe: listener => {
|
|
1363
|
+
listeners.add(listener)
|
|
1364
|
+
sync()
|
|
1365
|
+
return () => { listeners.delete(listener) }
|
|
1366
|
+
},
|
|
1367
|
+
getSnapshot: () => snapshot,
|
|
1368
|
+
/** Re-attach (services may bind after the plugin activated). */
|
|
1369
|
+
sync,
|
|
1370
|
+
/**
|
|
1371
|
+
* Pin and immediately attach one Session's feed, for a mounted seat.
|
|
1372
|
+
* The list may not name this Session yet (or at all), so it is attached
|
|
1373
|
+
* directly instead of waiting for a list sync.
|
|
1374
|
+
*/
|
|
1375
|
+
watch: sessionId => {
|
|
1376
|
+
if (disposed) return
|
|
1377
|
+
pinned.add(sessionId)
|
|
1378
|
+
sync()
|
|
1379
|
+
attachSession(sessionId)
|
|
1380
|
+
},
|
|
1381
|
+
/** Release every feed subscription. The marks themselves stay persisted. */
|
|
1382
|
+
dispose: () => {
|
|
1383
|
+
if (disposed) return
|
|
1384
|
+
disposed = true
|
|
1385
|
+
for (const stop of stops.values()) stop()
|
|
1386
|
+
stops.clear()
|
|
1387
|
+
routes.clear()
|
|
1388
|
+
pinned.clear()
|
|
1389
|
+
if (typeof stopList === 'function') stopList()
|
|
1390
|
+
stopList = undefined
|
|
1391
|
+
listeners.clear()
|
|
1392
|
+
},
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
const dict = {
|
|
1397
|
+
zh: {
|
|
1398
|
+
'provider.account': 'DeepSeek 账号',
|
|
1399
|
+
'search.placeholder': '搜索模型…',
|
|
1400
|
+
'search.clear': '清除搜索',
|
|
1401
|
+
'group.provider': '按 Provider',
|
|
1402
|
+
'group.family': '按模型家族',
|
|
1403
|
+
'group.aria': '模型分组方式',
|
|
1404
|
+
'empty.models': '暂无可用模型',
|
|
1405
|
+
'empty.search': '无匹配模型',
|
|
1406
|
+
'status.loading': '正在刷新模型列表…',
|
|
1407
|
+
'trigger.fallback': '请选择模型',
|
|
1408
|
+
'trigger.loading': '正在加载模型…',
|
|
1409
|
+
'trigger.aria': '选择模型,当前 {model}',
|
|
1410
|
+
'trigger.ariaEffort': '选择模型,当前 {model},推理等级 {effort}',
|
|
1411
|
+
'panel.aria': '模型与推理等级',
|
|
1412
|
+
'effort.default': 'Default',
|
|
1413
|
+
'effort.label': '推理等级',
|
|
1414
|
+
'effort.hint': '调整模型的思考强度:档位越高,思考越深入,但速度更慢、token 开销更大。',
|
|
1415
|
+
'team.label': '启用智能体团队协作',
|
|
1416
|
+
'team.description': '通过共享任务板协调多个智能体的分工与进度,复杂任务表现更佳,token 开销也相应更高。',
|
|
1417
|
+
'team.failed': '无法确认智能体团队协作设置已保存,请重试:{message}',
|
|
1418
|
+
'team.disable.title': '确认停用智能体团队协作?',
|
|
1419
|
+
'team.disable.description': '对话过程中停用智能体团队协作,将停止共享任务工具和团队协调指令。',
|
|
1420
|
+
'team.disable.warning': '停用后可能影响缓存复用、增加后续 token 用量与开销,并影响当前任务的协作效果。',
|
|
1421
|
+
'team.disable.cancel': '取消',
|
|
1422
|
+
'team.disable.confirm': '确认停用',
|
|
1423
|
+
'failure.group': '{name} 加载失败:{message}',
|
|
1424
|
+
'error.action': '模型操作失败:{message}',
|
|
1425
|
+
'error.sessionInUse': '当前会话已被占用,可能是其他正在运行的 DSH 导致的,请退出其他正在运行的 DSH 后重试。',
|
|
1426
|
+
},
|
|
1427
|
+
en: {
|
|
1428
|
+
'provider.account': 'DeepSeek Account',
|
|
1429
|
+
'search.placeholder': 'Search models…',
|
|
1430
|
+
'search.clear': 'Clear search',
|
|
1431
|
+
'group.provider': 'By provider',
|
|
1432
|
+
'group.family': 'By family',
|
|
1433
|
+
'group.aria': 'Group models by',
|
|
1434
|
+
'empty.models': 'No models available',
|
|
1435
|
+
'empty.search': 'No matching models',
|
|
1436
|
+
'status.loading': 'Refreshing model list…',
|
|
1437
|
+
'trigger.fallback': 'Select model',
|
|
1438
|
+
'trigger.loading': 'Loading models…',
|
|
1439
|
+
'trigger.aria': 'Select model, current {model}',
|
|
1440
|
+
'trigger.ariaEffort': 'Select model, current {model}, reasoning effort {effort}',
|
|
1441
|
+
'panel.aria': 'Model and reasoning effort',
|
|
1442
|
+
'effort.default': 'Default',
|
|
1443
|
+
'effort.label': 'Reasoning effort',
|
|
1444
|
+
'effort.hint': 'Adjusts how deeply the model thinks: higher levels reason more deeply, but are slower and cost more tokens.',
|
|
1445
|
+
'team.label': 'Enable agent teamwork',
|
|
1446
|
+
'team.description': 'Coordinate work and progress across agents with a shared task board for better results on complex tasks, with correspondingly higher token costs.',
|
|
1447
|
+
'team.failed': 'Could not confirm the agent teamwork setting was saved. Try again: {message}',
|
|
1448
|
+
'team.disable.title': 'Disable agent teamwork?',
|
|
1449
|
+
'team.disable.description': 'Disabling agent teamwork during a conversation stops the use of shared task tools and team coordination instructions.',
|
|
1450
|
+
'team.disable.warning': 'Disabling it may affect cache reuse, increase subsequent token usage and costs, and affect collaboration on the current task.',
|
|
1451
|
+
'team.disable.cancel': 'Cancel',
|
|
1452
|
+
'team.disable.confirm': 'Disable',
|
|
1453
|
+
'failure.group': '{name} failed to load: {message}',
|
|
1454
|
+
'error.action': 'Model operation failed: {message}',
|
|
1455
|
+
'error.sessionInUse': 'This session is already in use, possibly by another running DSH instance. Quit other running DSH instances and try again.',
|
|
1456
|
+
},
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
const EMPTY_STATE = {
|
|
1460
|
+
current: null,
|
|
1461
|
+
retainedEffort: undefined,
|
|
1462
|
+
routable: null,
|
|
1463
|
+
groups: [],
|
|
1464
|
+
failures: [],
|
|
1465
|
+
status: 'idle',
|
|
1466
|
+
pending: null,
|
|
1467
|
+
error: null,
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
/**
|
|
1471
|
+
* Stand-in store used before a Session's directory resolves. It never
|
|
1472
|
+
* notifies, so `useSyncExternalStore` keeps a constant hook shape while the
|
|
1473
|
+
* real store is swapped in — the component therefore always renders a
|
|
1474
|
+
* trigger, never `null`.
|
|
1475
|
+
*/
|
|
1476
|
+
const EMPTY_STORE = {
|
|
1477
|
+
subscribe: () => () => {},
|
|
1478
|
+
getSnapshot: () => EMPTY_STATE,
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
/**
|
|
1482
|
+
* Shared empty mark map. One frozen instance keeps `getSnapshot` referentially
|
|
1483
|
+
* stable for every seat that has no store, so an unmarked panel never
|
|
1484
|
+
* re-renders because of this feature.
|
|
1485
|
+
*/
|
|
1486
|
+
const EMPTY_INSUFFICIENT_MARKS = new Map()
|
|
1487
|
+
|
|
1488
|
+
/**
|
|
1489
|
+
* The one empty snapshot object. It MUST be a single shared instance:
|
|
1490
|
+
* `useSyncExternalStore` compares snapshots by identity, so returning a
|
|
1491
|
+
* fresh `{ marks }` on every call would look like a change on every render
|
|
1492
|
+
* and loop.
|
|
1493
|
+
*/
|
|
1494
|
+
const EMPTY_INSUFFICIENT_SNAPSHOT = { marks: EMPTY_INSUFFICIENT_MARKS }
|
|
1495
|
+
|
|
1496
|
+
/**
|
|
1497
|
+
* Stand-in mark face used when the store degraded. It never notifies and
|
|
1498
|
+
* always answers the same empty snapshot.
|
|
1499
|
+
*/
|
|
1500
|
+
const EMPTY_INSUFFICIENT = {
|
|
1501
|
+
subscribe: () => () => {},
|
|
1502
|
+
getSnapshot: () => EMPTY_INSUFFICIENT_SNAPSHOT,
|
|
1503
|
+
watch: () => {},
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
// ---------------------------------------------------------------------
|
|
1507
|
+
// Component
|
|
1508
|
+
// ---------------------------------------------------------------------
|
|
1509
|
+
/**
|
|
1510
|
+
* Render the flat model picker seat.
|
|
1511
|
+
* @param {object} props - slot standard props + owner `locked` + injected face.
|
|
1512
|
+
*/
|
|
1513
|
+
function ModelFlatMap(props) {
|
|
1514
|
+
const {
|
|
1515
|
+
locked, sessionId, t,
|
|
1516
|
+
available, directory, load, select, resolveText, resolveDirectory,
|
|
1517
|
+
teamAvailable, setTeamMode, useProjection, useSession, useSessions,
|
|
1518
|
+
insufficient,
|
|
1519
|
+
} = props
|
|
1520
|
+
|
|
1521
|
+
// The store may arrive after the first render (the Session binds later),
|
|
1522
|
+
// so resolve it on each render and fall back to a stable empty store.
|
|
1523
|
+
const resolved = directory === undefined && typeof resolveDirectory === 'function'
|
|
1524
|
+
? resolveDirectory()
|
|
1525
|
+
: undefined
|
|
1526
|
+
const store = directory ?? resolved?.store ?? EMPTY_STORE
|
|
1527
|
+
|
|
1528
|
+
const subscribe = useCallback(listener => store.subscribe(listener), [store])
|
|
1529
|
+
const getSnapshot = useCallback(() => store.getSnapshot(), [store])
|
|
1530
|
+
const state = useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
|
|
1531
|
+
|
|
1532
|
+
// Per-model insufficient-balance marks, read from the ONE global store so
|
|
1533
|
+
// a failure recorded in any Session or workspace strikes the model here
|
|
1534
|
+
// too. The fallback keeps the hook shape stable when the face is omitted.
|
|
1535
|
+
const markFace = insufficient ?? EMPTY_INSUFFICIENT
|
|
1536
|
+
const subscribeMarks = useCallback(listener => markFace.subscribe(listener), [markFace])
|
|
1537
|
+
const getMarks = useCallback(() => markFace.getSnapshot(), [markFace])
|
|
1538
|
+
const marks = useSyncExternalStore(subscribeMarks, getMarks, getMarks)
|
|
1539
|
+
|
|
1540
|
+
// Keep the store attached to this Session's feed while the seat is mounted.
|
|
1541
|
+
// It is shared, so unmounting must NOT dispose it — the marks belong to the
|
|
1542
|
+
// page, not to this seat.
|
|
1543
|
+
useEffect(() => {
|
|
1544
|
+
if (typeof markFace.watch === 'function') markFace.watch(sessionId)
|
|
1545
|
+
}, [markFace, sessionId])
|
|
1546
|
+
|
|
1547
|
+
const [open, setOpen] = useState(false)
|
|
1548
|
+
const [query, setQuery] = useState('')
|
|
1549
|
+
// The grouping choice is restored from storage so it persists across
|
|
1550
|
+
// sessions and workgroups, not just within one panel mount.
|
|
1551
|
+
const [grouping, setGrouping] = useState(readGrouping())
|
|
1552
|
+
const [selectionFocus, setSelectionFocus] = useState(false)
|
|
1553
|
+
const [metrics, setMetrics] = useState({ rowH: 26, headH: 20, fixed: 0, fixedCount: 0, box: null })
|
|
1554
|
+
const [teamPending, setTeamPending] = useState(false)
|
|
1555
|
+
const [teamFailure, setTeamFailure] = useState(null)
|
|
1556
|
+
const [confirming, setConfirming] = useState(false)
|
|
1557
|
+
const [replan, setReplan] = useState(0)
|
|
1558
|
+
|
|
1559
|
+
const rootRef = useRef(null)
|
|
1560
|
+
const triggerRef = useRef(null)
|
|
1561
|
+
const panelRef = useRef(null)
|
|
1562
|
+
const searchRef = useRef(null)
|
|
1563
|
+
const probeRowRef = useRef(null)
|
|
1564
|
+
const probeHeadRef = useRef(null)
|
|
1565
|
+
const headRef = useRef(null)
|
|
1566
|
+
const errorRef = useRef(null)
|
|
1567
|
+
const reasonRef = useRef(null)
|
|
1568
|
+
const teamRef = useRef(null)
|
|
1569
|
+
const footerRef = useRef(null)
|
|
1570
|
+
const teamErrorRef = useRef(null)
|
|
1571
|
+
const confirmRef = useRef(null)
|
|
1572
|
+
const id = useId()
|
|
1573
|
+
|
|
1574
|
+
// Standard props always carry these three hooks for a session-scope slot;
|
|
1575
|
+
// the fallbacks keep the component renderable (rather than throwing) in a
|
|
1576
|
+
// composition that omits one, and they are plain values so the hook order
|
|
1577
|
+
// below never changes.
|
|
1578
|
+
const useProjectionSafe = typeof useProjection === 'function' ? useProjection : () => undefined
|
|
1579
|
+
const useSessionSafe = typeof useSession === 'function' ? useSession : () => null
|
|
1580
|
+
const useSessionsSafe = typeof useSessions === 'function' ? useSessions : () => undefined
|
|
1581
|
+
|
|
1582
|
+
const teamMode = useProjectionSafe('agentTeam')
|
|
1583
|
+
const modelSelection = useProjectionSafe('modelSelection')
|
|
1584
|
+
// Both hooks may hand back `undefined` before the Session binds, so the
|
|
1585
|
+
// selectors never assume an object.
|
|
1586
|
+
const addressedChild = useSessionSafe(
|
|
1587
|
+
snapshot => snapshot?.subagent != null,
|
|
1588
|
+
) === true
|
|
1589
|
+
const listedChild = useSessionsSafe(
|
|
1590
|
+
listener => listener?.byId?.[sessionId]?.origin === 'subagent',
|
|
1591
|
+
) === true
|
|
1592
|
+
|
|
1593
|
+
/**
|
|
1594
|
+
* Run a pure derivation and degrade instead of throwing.
|
|
1595
|
+
*
|
|
1596
|
+
* A render-time throw leaves the cell to the boundary, and a shadowing
|
|
1597
|
+
* entry that throws twice is worth avoiding: one malformed catalog field
|
|
1598
|
+
* must not cost the whole control. The first failure per label is logged.
|
|
1599
|
+
* @param label - which derivation failed, for the console.
|
|
1600
|
+
* @param run - the derivation.
|
|
1601
|
+
* @param fallback - the value to use when it throws.
|
|
1602
|
+
* @returns the derived value, or the fallback.
|
|
1603
|
+
*/
|
|
1604
|
+
const derive = (label, run, fallback) => guarded(label, run, fallback)
|
|
1605
|
+
|
|
1606
|
+
// Provider order matches the shipped picker: account, official, catalog order.
|
|
1607
|
+
const groups = useMemo(() => derive('grouping providers', () => {
|
|
1608
|
+
const ranked = state.groups.map((group, index) => ({ group, index }))
|
|
1609
|
+
const rankOf = value => (value.group.id === 'deepseek-account' ? 0
|
|
1610
|
+
: value.group.id === 'deepseek-official' ? 1 : 2)
|
|
1611
|
+
ranked.sort((left, right) => rankOf(left) - rankOf(right) || left.index - right.index)
|
|
1612
|
+
return ranked.map(entry => entry.group)
|
|
1613
|
+
}, []), [state.groups])
|
|
1614
|
+
|
|
1615
|
+
/** Provider or family sections, search-filtered; empty groups never appear. */
|
|
1616
|
+
const sections = useMemo(() => derive('grouping models', () => {
|
|
1617
|
+
const out = []
|
|
1618
|
+
/**
|
|
1619
|
+
* The provider's display label. Catalog names arrive localized
|
|
1620
|
+
* (`{ zh: '豆包(doubao)', en: 'Doubao' }`), so the label is resolved
|
|
1621
|
+
* once here and reused as the section title, the search face, and the
|
|
1622
|
+
* per-row provider tag. The account provider has no catalog name of
|
|
1623
|
+
* its own and borrows the localized "DeepSeek Account" wording.
|
|
1624
|
+
* @param {{id: string, name: unknown}} group - one provider entry.
|
|
1625
|
+
* @returns {string} a display-ready label; never empty.
|
|
1626
|
+
*/
|
|
1627
|
+
const providerLabelOf = group => {
|
|
1628
|
+
if (group.id === 'deepseek-account') return t('provider.account')
|
|
1629
|
+
const resolved = resolveText === undefined ? undefined : resolveText(group.name)
|
|
1630
|
+
if (typeof resolved === 'string' && resolved.trim() !== '') return resolved
|
|
1631
|
+
const fallback = fallbackText(group.name)
|
|
1632
|
+
return fallback.trim() === '' ? group.id : fallback
|
|
1633
|
+
}
|
|
1634
|
+
/**
|
|
1635
|
+
* A provider whose own name or id matches the query keeps its whole
|
|
1636
|
+
* catalog: `dou` shows the `豆包(doubao)` provider with every model it
|
|
1637
|
+
* offers. Models matched by their own name still rank first, so a
|
|
1638
|
+
* provider-wide hit only widens the result, never reorders it.
|
|
1639
|
+
*/
|
|
1640
|
+
const providerHitOf = group => providerScore(group, query, providerLabelOf(group))
|
|
1641
|
+
/** One model's rank, or null when neither it nor its provider matched. */
|
|
1642
|
+
const scoreOf = (model, providerHit) => {
|
|
1643
|
+
const byName = matchScore(model.name, query)
|
|
1644
|
+
if (byName !== null) return byName
|
|
1645
|
+
return providerHit ? PROVIDER_MATCH : null
|
|
1646
|
+
}
|
|
1647
|
+
if (grouping === 'provider') {
|
|
1648
|
+
for (const group of groups) {
|
|
1649
|
+
const providerHit = providerHitOf(group) !== null
|
|
1650
|
+
const providerLabel = providerLabelOf(group)
|
|
1651
|
+
const scored = []
|
|
1652
|
+
for (const model of group.models) {
|
|
1653
|
+
const score = scoreOf(model, providerHit)
|
|
1654
|
+
if (score !== null) {
|
|
1655
|
+
scored.push({ entry: { model, providerId: group.id, providerLabel }, score })
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
if (scored.length === 0) continue
|
|
1659
|
+
scored.sort((left, right) => left.score - right.score)
|
|
1660
|
+
out.push({
|
|
1661
|
+
key: group.id,
|
|
1662
|
+
title: providerLabel,
|
|
1663
|
+
models: scored.map(item => item.entry),
|
|
1664
|
+
})
|
|
1665
|
+
}
|
|
1666
|
+
return out
|
|
1667
|
+
}
|
|
1668
|
+
const buckets = new Map()
|
|
1669
|
+
for (const group of groups) {
|
|
1670
|
+
const providerHit = providerHitOf(group) !== null
|
|
1671
|
+
const providerLabel = providerLabelOf(group)
|
|
1672
|
+
for (const model of group.models) {
|
|
1673
|
+
const score = scoreOf(model, providerHit)
|
|
1674
|
+
if (score === null) continue
|
|
1675
|
+
const family = familyOf(model)
|
|
1676
|
+
let bucket = buckets.get(family)
|
|
1677
|
+
if (bucket === undefined) {
|
|
1678
|
+
bucket = { key: family, title: family, scored: [] }
|
|
1679
|
+
buckets.set(family, bucket)
|
|
1680
|
+
}
|
|
1681
|
+
bucket.scored.push({ entry: { model, providerId: group.id, providerLabel }, score })
|
|
1682
|
+
}
|
|
1683
|
+
}
|
|
1684
|
+
const families = [...buckets.values()]
|
|
1685
|
+
families.sort((left, right) => familyRank(left.title) - familyRank(right.title)
|
|
1686
|
+
|| left.title.localeCompare(right.title))
|
|
1687
|
+
for (const family of families) {
|
|
1688
|
+
family.scored.sort((left, right) => left.score - right.score)
|
|
1689
|
+
// Under family grouping each row's primary text is its provider and
|
|
1690
|
+
// the family name is NOT repeated (the heading already says it). Only
|
|
1691
|
+
// when ONE provider offers several models in this family do the rows
|
|
1692
|
+
// need a short distinguishing tail, so the same provider name is not
|
|
1693
|
+
// printed twice with no way to tell the models apart.
|
|
1694
|
+
const byProvider = new Map()
|
|
1695
|
+
for (const item of family.scored) {
|
|
1696
|
+
const list = byProvider.get(item.entry.providerId) ?? []
|
|
1697
|
+
list.push(item)
|
|
1698
|
+
byProvider.set(item.entry.providerId, list)
|
|
1699
|
+
}
|
|
1700
|
+
for (const list of byProvider.values()) {
|
|
1701
|
+
if (list.length <= 1) continue
|
|
1702
|
+
const used = new Map()
|
|
1703
|
+
for (const item of list) {
|
|
1704
|
+
// An empty variant is intentional: the plain/base model of this
|
|
1705
|
+
// family carries no tag at all. Only a real collision (two rows
|
|
1706
|
+
// with the same non-empty variant, or two rows both empty) needs
|
|
1707
|
+
// a distinguishing fallback — the model id, which is unique.
|
|
1708
|
+
const variant = rowVariantOf(item.entry.model, family.title)
|
|
1709
|
+
item.entry.rowVariant = variant
|
|
1710
|
+
used.set(variant, (used.get(variant) ?? 0) + 1)
|
|
1711
|
+
}
|
|
1712
|
+
if ([...used.values()].some(count => count > 1)) {
|
|
1713
|
+
for (const item of list) {
|
|
1714
|
+
if (used.get(item.entry.rowVariant) > 1) {
|
|
1715
|
+
item.entry.rowVariant = String(item.entry.model.id ?? '')
|
|
1716
|
+
}
|
|
1717
|
+
}
|
|
1718
|
+
}
|
|
1719
|
+
}
|
|
1720
|
+
out.push({ key: family.key, title: family.title, models: family.scored.map(item => item.entry) })
|
|
1721
|
+
}
|
|
1722
|
+
return out
|
|
1723
|
+
}, []), [grouping, groups, query, t, resolveText])
|
|
1724
|
+
|
|
1725
|
+
const totalModels = useMemo(
|
|
1726
|
+
() => groups.reduce((sum, group) => sum + group.models.length, 0),
|
|
1727
|
+
[groups],
|
|
1728
|
+
)
|
|
1729
|
+
|
|
1730
|
+
const currentModel = useMemo(() => derive('resolving the current model', () => {
|
|
1731
|
+
if (state.current === null) return null
|
|
1732
|
+
for (const group of groups) {
|
|
1733
|
+
if (group.id !== state.current.provider) continue
|
|
1734
|
+
for (const model of group.models) {
|
|
1735
|
+
if (model.id === state.current.model) return { group, model }
|
|
1736
|
+
}
|
|
1737
|
+
}
|
|
1738
|
+
return null
|
|
1739
|
+
}, null), [groups, state.current])
|
|
1740
|
+
|
|
1741
|
+
const reasoning = currentModel === undefined || currentModel === null
|
|
1742
|
+
? undefined
|
|
1743
|
+
: currentModel.model.reasoning
|
|
1744
|
+
const effectiveEffort = state.current?.reasoningEffort ?? reasoning?.defaultEffort
|
|
1745
|
+
const effortLabel = reasoning === undefined
|
|
1746
|
+
? state.retainedEffort
|
|
1747
|
+
: effectiveEffort === undefined
|
|
1748
|
+
? t('effort.default')
|
|
1749
|
+
: reasoning.efforts.find(level => level.id === effectiveEffort)?.name ?? effectiveEffort
|
|
1750
|
+
const effortChoices = reasoning === undefined ? [] : reasoning.efforts
|
|
1751
|
+
const busy = state.pending !== null
|
|
1752
|
+
|
|
1753
|
+
// ---- measurement: probe metrics + fixed-section heights
|
|
1754
|
+
// The reasoning group and the teamwork row now share ONE footer flex
|
|
1755
|
+
// container, so measure that container once instead of the two children
|
|
1756
|
+
// separately: when they sit on the same line the two heights overlap,
|
|
1757
|
+
// and summing them would over-subtract from the grid's budget.
|
|
1758
|
+
useLayoutEffect(() => {
|
|
1759
|
+
if (!open) return
|
|
1760
|
+
const rowH = probeRowRef.current?.getBoundingClientRect().height ?? 26
|
|
1761
|
+
const headH = probeHeadRef.current?.getBoundingClientRect().height ?? 20
|
|
1762
|
+
const heights = [headRef, errorRef, footerRef, confirmRef, teamErrorRef]
|
|
1763
|
+
.map(ref => ref.current?.offsetHeight ?? 0)
|
|
1764
|
+
.filter(height => height > 0)
|
|
1765
|
+
const fixed = heights.reduce((sum, height) => sum + height, 0)
|
|
1766
|
+
const fixedCount = heights.length
|
|
1767
|
+
setMetrics(previous => (previous.rowH === rowH && previous.headH === headH
|
|
1768
|
+
&& previous.fixed === fixed && previous.fixedCount === fixedCount
|
|
1769
|
+
? previous
|
|
1770
|
+
: { ...previous, rowH, headH, fixed, fixedCount }))
|
|
1771
|
+
})
|
|
1772
|
+
|
|
1773
|
+
// ---- placement: above the trigger, kept inside the viewport
|
|
1774
|
+
useLayoutEffect(() => {
|
|
1775
|
+
if (!open) {
|
|
1776
|
+
setMetrics(previous => (previous.box === null ? previous : { ...previous, box: null }))
|
|
1777
|
+
return undefined
|
|
1778
|
+
}
|
|
1779
|
+
const place = () => {
|
|
1780
|
+
const trigger = triggerRef.current
|
|
1781
|
+
const panel = panelRef.current
|
|
1782
|
+
if (trigger === null || panel === null) return
|
|
1783
|
+
const rect = trigger.getBoundingClientRect()
|
|
1784
|
+
const vw = window.innerWidth
|
|
1785
|
+
const vh = window.innerHeight
|
|
1786
|
+
const width = Math.max(280, Math.min(560, vw - 24))
|
|
1787
|
+
const height = panel.offsetHeight
|
|
1788
|
+
const left = Math.max(12, Math.min(rect.right - width, vw - 12 - width))
|
|
1789
|
+
let top = rect.top - 8 - height
|
|
1790
|
+
if (top < 12) top = Math.min(rect.bottom + 8, Math.max(12, vh - 12 - height))
|
|
1791
|
+
setMetrics(previous => {
|
|
1792
|
+
const box = previous.box
|
|
1793
|
+
if (box !== null && Math.abs(box.left - left) < 0.5
|
|
1794
|
+
&& Math.abs(box.top - top) < 0.5 && Math.abs(box.width - width) < 0.5) return previous
|
|
1795
|
+
return { ...previous, box: { left, top, width } }
|
|
1796
|
+
})
|
|
1797
|
+
}
|
|
1798
|
+
place()
|
|
1799
|
+
window.addEventListener('resize', place)
|
|
1800
|
+
window.addEventListener('scroll', place, true)
|
|
1801
|
+
return () => {
|
|
1802
|
+
window.removeEventListener('resize', place)
|
|
1803
|
+
window.removeEventListener('scroll', place, true)
|
|
1804
|
+
}
|
|
1805
|
+
})
|
|
1806
|
+
|
|
1807
|
+
// Re-plan once fonts settle and on panel resize (debounced).
|
|
1808
|
+
useEffect(() => {
|
|
1809
|
+
if (!open) return undefined
|
|
1810
|
+
let timer = 0
|
|
1811
|
+
let cancelled = false
|
|
1812
|
+
const bump = () => {
|
|
1813
|
+
window.clearTimeout(timer)
|
|
1814
|
+
timer = window.setTimeout(() => { setReplan(value => value + 1) }, 80)
|
|
1815
|
+
}
|
|
1816
|
+
const observer = typeof ResizeObserver === 'function' ? new ResizeObserver(bump) : null
|
|
1817
|
+
if (observer !== null && panelRef.current !== null) observer.observe(panelRef.current)
|
|
1818
|
+
window.addEventListener('resize', bump)
|
|
1819
|
+
if (typeof document !== 'undefined' && document.fonts !== undefined) {
|
|
1820
|
+
document.fonts.ready.then(() => { if (!cancelled) setReplan(value => value + 1) })
|
|
1821
|
+
document.fonts.addEventListener('loadingdone', bump)
|
|
1822
|
+
}
|
|
1823
|
+
return () => {
|
|
1824
|
+
cancelled = true
|
|
1825
|
+
window.clearTimeout(timer)
|
|
1826
|
+
observer?.disconnect()
|
|
1827
|
+
window.removeEventListener('resize', bump)
|
|
1828
|
+
if (typeof document !== 'undefined' && document.fonts !== undefined) {
|
|
1829
|
+
document.fonts.removeEventListener('loadingdone', bump)
|
|
1830
|
+
}
|
|
1831
|
+
}
|
|
1832
|
+
}, [open])
|
|
1833
|
+
|
|
1834
|
+
// Dismissal by outside press.
|
|
1835
|
+
useEffect(() => {
|
|
1836
|
+
if (!open) return undefined
|
|
1837
|
+
const onPointerDown = event => {
|
|
1838
|
+
if (rootRef.current?.contains(event.target) === true) return
|
|
1839
|
+
if (panelRef.current?.contains(event.target) === true) return
|
|
1840
|
+
setOpen(false)
|
|
1841
|
+
}
|
|
1842
|
+
document.addEventListener('mousedown', onPointerDown)
|
|
1843
|
+
return () => { document.removeEventListener('mousedown', onPointerDown) }
|
|
1844
|
+
}, [open])
|
|
1845
|
+
|
|
1846
|
+
// Opening: reset transient state, refresh the directory, focus search.
|
|
1847
|
+
useEffect(() => {
|
|
1848
|
+
if (!open) return undefined
|
|
1849
|
+
setQuery('')
|
|
1850
|
+
setConfirming(false)
|
|
1851
|
+
setTeamFailure(null)
|
|
1852
|
+
load()
|
|
1853
|
+
const timer = window.setTimeout(() => { searchRef.current?.focus() }, 0)
|
|
1854
|
+
return () => { window.clearTimeout(timer) }
|
|
1855
|
+
}, [open, load])
|
|
1856
|
+
|
|
1857
|
+
const close = useCallback((restoreFocus = false) => {
|
|
1858
|
+
setOpen(false)
|
|
1859
|
+
setConfirming(false)
|
|
1860
|
+
if (restoreFocus) queueMicrotask(() => { triggerRef.current?.focus() })
|
|
1861
|
+
}, [])
|
|
1862
|
+
|
|
1863
|
+
const submit = useCallback(selection => {
|
|
1864
|
+
setSelectionFocus(true)
|
|
1865
|
+
triggerRef.current?.focus()
|
|
1866
|
+
try {
|
|
1867
|
+
void Promise.resolve(select(selection)).catch(() => { /* surfaced on the store */ })
|
|
1868
|
+
} catch {
|
|
1869
|
+
/* a session that cannot select keeps its previous selection */
|
|
1870
|
+
}
|
|
1871
|
+
}, [select])
|
|
1872
|
+
|
|
1873
|
+
const chooseModel = useCallback((providerId, model) => {
|
|
1874
|
+
if (state.current?.provider === providerId && state.current.model === model.id) {
|
|
1875
|
+
close(true)
|
|
1876
|
+
return
|
|
1877
|
+
}
|
|
1878
|
+
submit({ provider: providerId, model: model.id })
|
|
1879
|
+
}, [close, state.current, submit])
|
|
1880
|
+
|
|
1881
|
+
const chooseEffort = useCallback(level => {
|
|
1882
|
+
if (state.current === null || effectiveEffort === level) return
|
|
1883
|
+
submit({
|
|
1884
|
+
provider: state.current.provider,
|
|
1885
|
+
model: state.current.model,
|
|
1886
|
+
reasoningEffort: level,
|
|
1887
|
+
})
|
|
1888
|
+
}, [effectiveEffort, state.current, submit])
|
|
1889
|
+
|
|
1890
|
+
const applyTeamMode = useCallback(enabled => {
|
|
1891
|
+
setTeamPending(true)
|
|
1892
|
+
setTeamFailure(null)
|
|
1893
|
+
void setTeamMode(enabled).then(result => {
|
|
1894
|
+
if (result !== undefined && result.ok === false) {
|
|
1895
|
+
const message = result.error?.code === 'session/writer-held'
|
|
1896
|
+
? t('error.sessionInUse')
|
|
1897
|
+
: `${result.error?.code ?? 'error'}: ${result.error?.message ?? ''}`
|
|
1898
|
+
setTeamFailure(fill(t, 'team.failed', { message }))
|
|
1899
|
+
}
|
|
1900
|
+
}, error => {
|
|
1901
|
+
setTeamFailure(fill(t, 'team.failed', {
|
|
1902
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1903
|
+
}))
|
|
1904
|
+
}).finally(() => { setTeamPending(false) })
|
|
1905
|
+
}, [setTeamMode, t])
|
|
1906
|
+
|
|
1907
|
+
const changeTeamMode = useCallback(enabled => {
|
|
1908
|
+
if (!enabled && modelSelection !== undefined && modelSelection.lastUsed !== null) {
|
|
1909
|
+
setConfirming(true)
|
|
1910
|
+
return
|
|
1911
|
+
}
|
|
1912
|
+
applyTeamMode(enabled)
|
|
1913
|
+
}, [applyTeamMode, modelSelection])
|
|
1914
|
+
|
|
1915
|
+
// ---- column planning (the whole point of the layout)
|
|
1916
|
+
// The grid is the only flexible child, so the space it may occupy is the
|
|
1917
|
+
// panel cap minus padding, every present fixed section, and the flex gaps
|
|
1918
|
+
// between them (one gap per fixed section, because the grid always sits
|
|
1919
|
+
// between them).
|
|
1920
|
+
const availableHeight = PANEL_MAX - PANEL_PAD * 2 - metrics.fixed
|
|
1921
|
+
- PANEL_GAP * metrics.fixedCount
|
|
1922
|
+
const units = useMemo(
|
|
1923
|
+
() => buildUnits(sections, metrics.rowH, metrics.headH),
|
|
1924
|
+
[sections, metrics.rowH, metrics.headH],
|
|
1925
|
+
)
|
|
1926
|
+
const plan = useMemo(
|
|
1927
|
+
() => planColumns(units, Math.max(0, availableHeight)),
|
|
1928
|
+
[units, availableHeight, replan],
|
|
1929
|
+
)
|
|
1930
|
+
|
|
1931
|
+
if (!available) return null
|
|
1932
|
+
if (addressedChild || listedChild) return null
|
|
1933
|
+
|
|
1934
|
+
const triggerModel = currentModel === null ? undefined : currentModel.model
|
|
1935
|
+
const waiting = currentModel === null && (state.status === 'loading' || state.status === 'idle')
|
|
1936
|
+
const modelLabel = waiting
|
|
1937
|
+
? t('trigger.loading')
|
|
1938
|
+
: triggerModel?.name ?? t('trigger.fallback')
|
|
1939
|
+
const triggerText = effortLabel === undefined ? modelLabel : `${modelLabel} · ${effortLabel}`
|
|
1940
|
+
const triggerAria = waiting
|
|
1941
|
+
? t('trigger.loading')
|
|
1942
|
+
: triggerModel === undefined
|
|
1943
|
+
? t('trigger.fallback')
|
|
1944
|
+
: effortLabel === undefined
|
|
1945
|
+
? fill(t, 'trigger.aria', { model: modelLabel })
|
|
1946
|
+
: fill(t, 'trigger.ariaEffort', { model: modelLabel, effort: effortLabel })
|
|
1947
|
+
|
|
1948
|
+
const badgeOf = model => {
|
|
1949
|
+
const badge = model.badge
|
|
1950
|
+
if (badge === undefined || badge === null) return null
|
|
1951
|
+
const label = resolveText === undefined ? undefined : resolveText(badge.text)
|
|
1952
|
+
if (typeof label !== 'string' || label.trim() === '') return null
|
|
1953
|
+
return { label, tone: badge.tone ?? 'neutral' }
|
|
1954
|
+
}
|
|
1955
|
+
|
|
1956
|
+
/**
|
|
1957
|
+
* Split a model name into React children, lifting any "xN" multiplier
|
|
1958
|
+
* marker (rate/size tags like "x0.01", "x1.5", "X 2.2") into a smaller,
|
|
1959
|
+
* bold span so it reads as an aside while the full name and its order are
|
|
1960
|
+
* preserved. Matches an x (any case, or the multiplication sign) that
|
|
1961
|
+
* STARTS a token — preceded by start-of-string or a non-word character,
|
|
1962
|
+
* so it is not embedded in a word like "GenX5" — followed by optional
|
|
1963
|
+
* whitespace and a number (integer or decimal, optional leading +).
|
|
1964
|
+
* "Claude x3", "GPT x0.1", "Model X 2.2" all get the run; a plain
|
|
1965
|
+
* "Model X" (no number) or "GenX5" (x inside a word) do not.
|
|
1966
|
+
*/
|
|
1967
|
+
const MULT_MARKER = /(?<![\p{L}\p{N}])[xX×]\s*[+]?\d+(?:\.\d+)?/gu
|
|
1968
|
+
const nameRuns = rawName => {
|
|
1969
|
+
const name = String(rawName ?? '')
|
|
1970
|
+
const out = []
|
|
1971
|
+
let cursor = 0
|
|
1972
|
+
for (const m of name.matchAll(MULT_MARKER)) {
|
|
1973
|
+
if (m.index > cursor) out.push(name.slice(cursor, m.index))
|
|
1974
|
+
out.push(h('span', { className: 'dshmf-rowNameMark' }, m[0]))
|
|
1975
|
+
cursor = m.index + m[0].length
|
|
1976
|
+
}
|
|
1977
|
+
if (cursor < name.length) out.push(name.slice(cursor))
|
|
1978
|
+
return out
|
|
1979
|
+
}
|
|
1980
|
+
|
|
1981
|
+
const renderRow = entry => {
|
|
1982
|
+
const { model, providerId, providerLabel, rowVariant } = entry
|
|
1983
|
+
const selected = state.current?.provider === providerId && state.current.model === model.id
|
|
1984
|
+
const pendingThis = state.pending?.provider === providerId && state.pending.model === model.id
|
|
1985
|
+
const badge = badgeOf(model)
|
|
1986
|
+
/**
|
|
1987
|
+
* Under family grouping the heading already says what the models ARE,
|
|
1988
|
+
* so the family name is not repeated in a row. The row's primary text
|
|
1989
|
+
* is the PROVIDER — the one thing a heading cannot tell you when a
|
|
1990
|
+
* family spans providers. Only when one provider offers several models
|
|
1991
|
+
* in this family does a short variant tail differentiate them. Under
|
|
1992
|
+
* provider grouping the heading IS the provider, so the primary text
|
|
1993
|
+
* stays the model name and no secondary tag is drawn.
|
|
1994
|
+
*/
|
|
1995
|
+
const familyMode = grouping === 'family'
|
|
1996
|
+
const primary = familyMode && typeof providerLabel === 'string' && providerLabel !== ''
|
|
1997
|
+
? providerLabel : String(model.name ?? '')
|
|
1998
|
+
const showVariant = familyMode && typeof rowVariant === 'string' && rowVariant !== ''
|
|
1999
|
+
const rowTitle = model.description === undefined
|
|
2000
|
+
? `${model.name}\n${providerLabel}`
|
|
2001
|
+
: `${model.name}\n${model.description}\n${providerLabel}`
|
|
2002
|
+
// The model's last call ran out of balance and nothing has cleared it
|
|
2003
|
+
// yet: the row text is struck through until a later call for the same
|
|
2004
|
+
// model route returns anything else.
|
|
2005
|
+
const insufficientRoute = marks.marks.get(routeKeyOf(providerId, model.id)) === true
|
|
2006
|
+
return h('button', {
|
|
2007
|
+
key: `${providerId}/${model.id}`,
|
|
2008
|
+
type: 'button',
|
|
2009
|
+
className: 'dshmf-row',
|
|
2010
|
+
'aria-pressed': selected,
|
|
2011
|
+
'data-insufficient': insufficientRoute ? 'true' : undefined,
|
|
2012
|
+
title: rowTitle,
|
|
2013
|
+
disabled: busy,
|
|
2014
|
+
onClick: () => { chooseModel(providerId, model) },
|
|
2015
|
+
},
|
|
2016
|
+
// `nameRuns` (xN multiplier markers) is applied to whatever the
|
|
2017
|
+
// primary text is: the model name under provider grouping, or the
|
|
2018
|
+
// provider name under family grouping.
|
|
2019
|
+
h('span', { className: 'dshmf-rowName' }, ...nameRuns(primary)),
|
|
2020
|
+
showVariant ? h('span', {
|
|
2021
|
+
className: 'dshmf-rowProvider',
|
|
2022
|
+
title: rowVariant,
|
|
2023
|
+
}, rowVariant) : null,
|
|
2024
|
+
badge === null ? null : h('span', {
|
|
2025
|
+
className: 'dshmf-badge',
|
|
2026
|
+
'data-tone': badge.tone,
|
|
2027
|
+
}, badge.label),
|
|
2028
|
+
pendingThis ? h('span', { className: 'dshmf-rowTail' }, h('span', { className: 'dshmf-spin' })) : null,
|
|
2029
|
+
)
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
const showEmptyState = sections.length === 0 && !busy
|
|
2033
|
+
const emptyKey = state.status === 'loading' && totalModels === 0
|
|
2034
|
+
? 'status.loading'
|
|
2035
|
+
: totalModels === 0 ? 'empty.models' : 'empty.search'
|
|
2036
|
+
|
|
2037
|
+
const renderBlock = block => (block.kind === 'title'
|
|
2038
|
+
? h('div', {
|
|
2039
|
+
className: 'dshmf-groupTitle',
|
|
2040
|
+
key: block.key,
|
|
2041
|
+
title: block.label,
|
|
2042
|
+
}, block.label)
|
|
2043
|
+
: renderRow(block.entry))
|
|
2044
|
+
|
|
2045
|
+
// Each child is placed EXPLICITLY (`grid-column` below), never by
|
|
2046
|
+
// auto-placement: a divider that pinned its row but not its column was
|
|
2047
|
+
// laid out first, took column 1, and pushed the columns into columns 2
|
|
2048
|
+
// and 3 — which squashed the first text column to 1px and stacked the
|
|
2049
|
+
// columns on top of each other. Track 2k+1 holds column k (0-based) and
|
|
2050
|
+
// track 2k+2 the gutter that follows it, so a 3-column layout is
|
|
2051
|
+
// col gutter col gutter col.
|
|
2052
|
+
const renderColumn = (column, columnIndex) => h('div', {
|
|
2053
|
+
className: 'dshmf-col',
|
|
2054
|
+
key: `col-${columnIndex}`,
|
|
2055
|
+
style: { gridColumn: String(columnIndex * 2 + 1), gridRow: '1' },
|
|
2056
|
+
}, column.map(unit => unit.blocks.map(renderBlock)))
|
|
2057
|
+
|
|
2058
|
+
// Vertical separators are siblings of the columns, not a border on one
|
|
2059
|
+
// of them: a border can only ever be as tall as its own column, while a
|
|
2060
|
+
// grid item can stretch to the whole grid height (see .dshmf-colDivider).
|
|
2061
|
+
// Called with the index of the column that PRECEDES the gutter.
|
|
2062
|
+
const renderDivider = dividerIndex => h('div', {
|
|
2063
|
+
className: 'dshmf-colDivider',
|
|
2064
|
+
role: 'presentation',
|
|
2065
|
+
key: `gutter-${dividerIndex}`,
|
|
2066
|
+
style: { gridColumn: String(dividerIndex * 2), gridRow: '1' },
|
|
2067
|
+
})
|
|
2068
|
+
|
|
2069
|
+
const renderColumns = () => {
|
|
2070
|
+
const children = []
|
|
2071
|
+
plan.columns.forEach((column, columnIndex) => {
|
|
2072
|
+
if (columnIndex > 0) children.push(renderDivider(columnIndex))
|
|
2073
|
+
children.push(renderColumn(column, columnIndex))
|
|
2074
|
+
})
|
|
2075
|
+
return children
|
|
2076
|
+
}
|
|
2077
|
+
|
|
2078
|
+
// `minmax(0, 1fr)` per column, a hairline track per gutter between them.
|
|
2079
|
+
const gridTemplateColumns = plan.columns
|
|
2080
|
+
.map(() => 'minmax(0, 1fr)')
|
|
2081
|
+
.join(' 1px ')
|
|
2082
|
+
|
|
2083
|
+
const renderFailure = failure => h('div', {
|
|
2084
|
+
className: 'dshmf-failure',
|
|
2085
|
+
key: `failure-${failure.id}`,
|
|
2086
|
+
role: 'status',
|
|
2087
|
+
}, fill(t, 'failure.group', {
|
|
2088
|
+
name: failure.id === 'deepseek-account' ? t('provider.account') : failure.name,
|
|
2089
|
+
message: failure.message,
|
|
2090
|
+
}))
|
|
2091
|
+
|
|
2092
|
+
const onPanelKeyDown = event => {
|
|
2093
|
+
if (event.key !== 'Escape') return
|
|
2094
|
+
event.preventDefault()
|
|
2095
|
+
if (confirming) {
|
|
2096
|
+
setConfirming(false)
|
|
2097
|
+
return
|
|
2098
|
+
}
|
|
2099
|
+
close(true)
|
|
2100
|
+
}
|
|
2101
|
+
|
|
2102
|
+
// Radio-group keyboard: arrows move the exclusive selection to the
|
|
2103
|
+
// neighbouring enabled level, so the whole group is reachable by keys.
|
|
2104
|
+
const onEffortKeyDown = event => {
|
|
2105
|
+
if (event.key !== 'ArrowRight' && event.key !== 'ArrowDown'
|
|
2106
|
+
&& event.key !== 'ArrowLeft' && event.key !== 'ArrowUp') return
|
|
2107
|
+
const pills = reasonRef.current
|
|
2108
|
+
? [...reasonRef.current.querySelectorAll('button.dshmf-pill:not(:disabled)')]
|
|
2109
|
+
: []
|
|
2110
|
+
if (pills.length === 0) return
|
|
2111
|
+
event.preventDefault()
|
|
2112
|
+
const forward = event.key === 'ArrowRight' || event.key === 'ArrowDown'
|
|
2113
|
+
const at = event.target instanceof Element ? pills.indexOf(event.target) : -1
|
|
2114
|
+
const next = at === -1
|
|
2115
|
+
? pills[forward ? 0 : pills.length - 1]
|
|
2116
|
+
: pills[(at + (forward ? 1 : -1) + pills.length) % pills.length]
|
|
2117
|
+
next?.focus()
|
|
2118
|
+
next?.click()
|
|
2119
|
+
}
|
|
2120
|
+
|
|
2121
|
+
return h('div', {
|
|
2122
|
+
className: 'dshmf-root',
|
|
2123
|
+
ref: rootRef,
|
|
2124
|
+
onMouseDown: event => {
|
|
2125
|
+
// WebKit blurs a focused row before click unless mousedown keeps focus.
|
|
2126
|
+
if (event.target instanceof Element && event.target.closest('button') !== null) event.preventDefault()
|
|
2127
|
+
},
|
|
2128
|
+
},
|
|
2129
|
+
h('button', {
|
|
2130
|
+
ref: triggerRef,
|
|
2131
|
+
type: 'button',
|
|
2132
|
+
className: 'dshmf-trigger',
|
|
2133
|
+
'aria-label': triggerAria,
|
|
2134
|
+
'aria-haspopup': 'dialog',
|
|
2135
|
+
'aria-expanded': open,
|
|
2136
|
+
'aria-controls': open ? `${id}-panel` : undefined,
|
|
2137
|
+
title: triggerText,
|
|
2138
|
+
'aria-busy': busy,
|
|
2139
|
+
'data-selection-focus': selectionFocus ? '' : undefined,
|
|
2140
|
+
disabled: locked,
|
|
2141
|
+
onBlur: () => { setSelectionFocus(false) },
|
|
2142
|
+
onClick: () => {
|
|
2143
|
+
if (open) {
|
|
2144
|
+
close(true)
|
|
2145
|
+
return
|
|
2146
|
+
}
|
|
2147
|
+
setSelectionFocus(false)
|
|
2148
|
+
triggerRef.current?.focus()
|
|
2149
|
+
setOpen(true)
|
|
2150
|
+
},
|
|
2151
|
+
},
|
|
2152
|
+
svg(PATH_MODELS, 16, { className: 'dshmf-triggerIcon' }),
|
|
2153
|
+
h('span', { className: 'dshmf-triggerText' }, modelLabel),
|
|
2154
|
+
effortLabel === undefined ? null : h('span', { className: 'dshmf-triggerEffort' }, effortLabel),
|
|
2155
|
+
busy
|
|
2156
|
+
? h('span', { className: 'dshmf-spin' })
|
|
2157
|
+
: svg(PATH_CHEVRON, 12, { className: `dshmf-chevron${open ? ' dshmf-chevronOpen' : ''}` }),
|
|
2158
|
+
),
|
|
2159
|
+
|
|
2160
|
+
open ? ReactDOM.createPortal(
|
|
2161
|
+
h('div', {
|
|
2162
|
+
ref: panelRef,
|
|
2163
|
+
id: `${id}-panel`,
|
|
2164
|
+
className: 'dshmf-panel',
|
|
2165
|
+
style: {
|
|
2166
|
+
left: `${metrics.box?.left ?? 12}px`,
|
|
2167
|
+
top: `${metrics.box?.top ?? 12}px`,
|
|
2168
|
+
width: `${metrics.box?.width ?? 320}px`,
|
|
2169
|
+
},
|
|
2170
|
+
role: 'dialog',
|
|
2171
|
+
'aria-label': t('panel.aria'),
|
|
2172
|
+
onKeyDown: onPanelKeyDown,
|
|
2173
|
+
},
|
|
2174
|
+
// ---- fixed: search + grouping segment
|
|
2175
|
+
h('div', { className: 'dshmf-head', ref: headRef },
|
|
2176
|
+
h('div', { className: 'dshmf-search' },
|
|
2177
|
+
h('input', {
|
|
2178
|
+
ref: searchRef,
|
|
2179
|
+
className: 'dshmf-input',
|
|
2180
|
+
type: 'text',
|
|
2181
|
+
role: 'searchbox',
|
|
2182
|
+
'aria-label': t('search.placeholder'),
|
|
2183
|
+
placeholder: t('search.placeholder'),
|
|
2184
|
+
value: query,
|
|
2185
|
+
readOnly: busy,
|
|
2186
|
+
onChange: event => { setQuery(event.target.value) },
|
|
2187
|
+
}),
|
|
2188
|
+
state.status === 'loading'
|
|
2189
|
+
? h('span', { className: 'dshmf-spin', title: t('status.loading') })
|
|
2190
|
+
: null,
|
|
2191
|
+
query === '' ? null : h('button', {
|
|
2192
|
+
type: 'button',
|
|
2193
|
+
className: 'dshmf-clear',
|
|
2194
|
+
'aria-label': t('search.clear'),
|
|
2195
|
+
title: t('search.clear'),
|
|
2196
|
+
onClick: () => {
|
|
2197
|
+
setQuery('')
|
|
2198
|
+
searchRef.current?.focus()
|
|
2199
|
+
},
|
|
2200
|
+
}, svg(PATH_CLOSE, 10)),
|
|
2201
|
+
),
|
|
2202
|
+
h('div', { className: 'dshmf-seg', role: 'group', 'aria-label': t('group.aria') },
|
|
2203
|
+
h('button', {
|
|
2204
|
+
type: 'button',
|
|
2205
|
+
className: 'dshmf-segBtn',
|
|
2206
|
+
'aria-pressed': grouping === 'provider',
|
|
2207
|
+
title: t('group.provider'),
|
|
2208
|
+
onClick: () => { setGrouping('provider'); writeGrouping('provider') },
|
|
2209
|
+
}, t('group.provider')),
|
|
2210
|
+
h('button', {
|
|
2211
|
+
type: 'button',
|
|
2212
|
+
className: 'dshmf-segBtn',
|
|
2213
|
+
'aria-pressed': grouping === 'family',
|
|
2214
|
+
title: t('group.family'),
|
|
2215
|
+
onClick: () => { setGrouping('family'); writeGrouping('family') },
|
|
2216
|
+
}, t('group.family')),
|
|
2217
|
+
),
|
|
2218
|
+
),
|
|
2219
|
+
|
|
2220
|
+
// ---- fixed: failure strip above the grid, so it never competes
|
|
2221
|
+
// with model rows for the scroll area
|
|
2222
|
+
state.error === null ? null : h('div', {
|
|
2223
|
+
className: 'dshmf-error',
|
|
2224
|
+
ref: errorRef,
|
|
2225
|
+
role: 'status',
|
|
2226
|
+
}, fill(t, 'error.action', {
|
|
2227
|
+
message: state.error.includes('session/writer-held')
|
|
2228
|
+
? t('error.sessionInUse')
|
|
2229
|
+
: state.error,
|
|
2230
|
+
})),
|
|
2231
|
+
|
|
2232
|
+
// ---- the only scrolling region
|
|
2233
|
+
h('div', {
|
|
2234
|
+
className: 'dshmf-grid',
|
|
2235
|
+
style: {
|
|
2236
|
+
gridTemplateColumns: showEmptyState
|
|
2237
|
+
? 'minmax(0, 1fr)'
|
|
2238
|
+
: gridTemplateColumns,
|
|
2239
|
+
columnGap: `${COL_GAP}px`,
|
|
2240
|
+
},
|
|
2241
|
+
},
|
|
2242
|
+
showEmptyState
|
|
2243
|
+
? h('div', { className: 'dshmf-empty', role: 'status' }, t(emptyKey))
|
|
2244
|
+
: renderColumns(),
|
|
2245
|
+
state.failures.length === 0 ? null : state.failures.map(renderFailure),
|
|
2246
|
+
),
|
|
2247
|
+
|
|
2248
|
+
// ---- fixed: reasoning levels of the current model, rendered as
|
|
2249
|
+
// one labelled group of mutually exclusive choices
|
|
2250
|
+
// ---- fixed, bottom: the agent teamwork switch. `teamAvailable`
|
|
2251
|
+
// is evaluated per render because the remote scope may bind late.
|
|
2252
|
+
// Both live in ONE footer flex container: they share a line when
|
|
2253
|
+
// they fit with the 30px gap, otherwise each takes its own line.
|
|
2254
|
+
(effortChoices.length === 0 && (teamMode === undefined || !teamAvailable()))
|
|
2255
|
+
? null
|
|
2256
|
+
: h('div', { className: 'dshmf-footer', ref: footerRef },
|
|
2257
|
+
effortChoices.length === 0 ? null : h('div', {
|
|
2258
|
+
className: 'dshmf-reason',
|
|
2259
|
+
ref: reasonRef,
|
|
2260
|
+
role: 'radiogroup',
|
|
2261
|
+
'aria-labelledby': `${id}-effort-label`,
|
|
2262
|
+
onKeyDown: onEffortKeyDown,
|
|
2263
|
+
},
|
|
2264
|
+
h('span', {
|
|
2265
|
+
className: 'dshmf-reasonLabel',
|
|
2266
|
+
id: `${id}-effort-label`,
|
|
2267
|
+
title: t('effort.hint'),
|
|
2268
|
+
}, svg(PATH_SPARK, 12), t('effort.label')),
|
|
2269
|
+
h('div', { className: 'dshmf-pills' }, effortChoices.map(level => {
|
|
2270
|
+
const selected = effectiveEffort === level.id
|
|
2271
|
+
const pendingThis = state.pending !== null
|
|
2272
|
+
&& state.pending.provider === state.current?.provider
|
|
2273
|
+
&& state.pending.model === state.current.model
|
|
2274
|
+
&& state.pending.reasoningEffort === level.id
|
|
2275
|
+
return h('button', {
|
|
2276
|
+
key: level.id,
|
|
2277
|
+
type: 'button',
|
|
2278
|
+
role: 'radio',
|
|
2279
|
+
className: 'dshmf-pill',
|
|
2280
|
+
'aria-checked': selected,
|
|
2281
|
+
title: level.description ?? level.name,
|
|
2282
|
+
disabled: busy || state.current === null,
|
|
2283
|
+
onClick: () => { chooseEffort(level.id) },
|
|
2284
|
+
}, pendingThis
|
|
2285
|
+
? h('span', { className: 'dshmf-spin' })
|
|
2286
|
+
: h('span', { className: 'dshmf-pillName' }, level.name))
|
|
2287
|
+
})),
|
|
2288
|
+
),
|
|
2289
|
+
teamMode === undefined || !teamAvailable()
|
|
2290
|
+
? null
|
|
2291
|
+
: h('div', { className: 'dshmf-team', ref: teamRef },
|
|
2292
|
+
h('span', { className: 'dshmf-teamLabel' },
|
|
2293
|
+
h('span', { className: 'dshmf-teamName' }, t('team.label')),
|
|
2294
|
+
h('span', {
|
|
2295
|
+
className: 'dshmf-info',
|
|
2296
|
+
title: t('team.description'),
|
|
2297
|
+
'aria-hidden': 'true',
|
|
2298
|
+
}, svg(PATH_INFO, 12)),
|
|
2299
|
+
),
|
|
2300
|
+
h('button', {
|
|
2301
|
+
type: 'button',
|
|
2302
|
+
role: 'switch',
|
|
2303
|
+
className: 'dshmf-switch',
|
|
2304
|
+
'aria-checked': teamMode.enabled,
|
|
2305
|
+
'aria-label': t('team.label'),
|
|
2306
|
+
title: t('team.description'),
|
|
2307
|
+
disabled: locked || busy || teamPending || modelSelection === undefined,
|
|
2308
|
+
onClick: () => { changeTeamMode(!teamMode.enabled) },
|
|
2309
|
+
}, h('span', { className: 'dshmf-thumb' })),
|
|
2310
|
+
),
|
|
2311
|
+
),
|
|
2312
|
+
|
|
2313
|
+
// ---- fixed: teamwork disable confirmation
|
|
2314
|
+
confirming ? h('div', {
|
|
2315
|
+
className: 'dshmf-confirm',
|
|
2316
|
+
ref: confirmRef,
|
|
2317
|
+
role: 'alertdialog',
|
|
2318
|
+
'aria-label': t('team.disable.title'),
|
|
2319
|
+
},
|
|
2320
|
+
h('span', { className: 'dshmf-confirmTitle' }, t('team.disable.title')),
|
|
2321
|
+
h('span', { className: 'dshmf-confirmBody' },
|
|
2322
|
+
`${t('team.disable.description')} ${t('team.disable.warning')}`),
|
|
2323
|
+
h('div', { className: 'dshmf-confirmActions' },
|
|
2324
|
+
h('button', {
|
|
2325
|
+
type: 'button',
|
|
2326
|
+
className: 'dshmf-confirmBtn',
|
|
2327
|
+
onClick: () => { setConfirming(false) },
|
|
2328
|
+
}, t('team.disable.cancel')),
|
|
2329
|
+
h('button', {
|
|
2330
|
+
type: 'button',
|
|
2331
|
+
className: 'dshmf-confirmBtn',
|
|
2332
|
+
'data-primary': 'true',
|
|
2333
|
+
onClick: () => {
|
|
2334
|
+
setConfirming(false)
|
|
2335
|
+
applyTeamMode(false)
|
|
2336
|
+
},
|
|
2337
|
+
}, t('team.disable.confirm')),
|
|
2338
|
+
),
|
|
2339
|
+
) : null,
|
|
2340
|
+
|
|
2341
|
+
// ---- fixed: teamwork failure strip
|
|
2342
|
+
teamFailure === null ? null : h('div', {
|
|
2343
|
+
className: 'dshmf-error',
|
|
2344
|
+
ref: teamErrorRef,
|
|
2345
|
+
role: 'status',
|
|
2346
|
+
}, teamFailure),
|
|
2347
|
+
|
|
2348
|
+
// ---- measurement probe (never painted). It mirrors a family-
|
|
2349
|
+
// grouped row (provider primary + variant tag), the widest row
|
|
2350
|
+
// shape, so the measured row height covers every mode. It carries
|
|
2351
|
+
// no trailing tail: the selected row is a filled band now and the
|
|
2352
|
+
// spinner is only drawn for a pending selection.
|
|
2353
|
+
h('div', { className: 'dshmf-probe', 'aria-hidden': 'true' },
|
|
2354
|
+
h('div', { ref: probeHeadRef, className: 'dshmf-groupTitle' }, 'X'),
|
|
2355
|
+
h('div', { ref: probeRowRef, className: 'dshmf-row' },
|
|
2356
|
+
h('span', { className: 'dshmf-rowName' }, 'X'),
|
|
2357
|
+
h('span', { className: 'dshmf-rowProvider' }, 'X'),
|
|
2358
|
+
),
|
|
2359
|
+
),
|
|
2360
|
+
),
|
|
2361
|
+
document.body,
|
|
2362
|
+
) : null,
|
|
2363
|
+
)
|
|
2364
|
+
}
|
|
2365
|
+
|
|
2366
|
+
// ---------------------------------------------------------------------
|
|
2367
|
+
// Seat boundary
|
|
2368
|
+
// ---------------------------------------------------------------------
|
|
2369
|
+
/**
|
|
2370
|
+
* Keep the composer seat alive when its content fails.
|
|
2371
|
+
*
|
|
2372
|
+
* A shadowing slot entry that throws during render abdicates, and the cell
|
|
2373
|
+
* falls to the next registration — the native picker reappears and this
|
|
2374
|
+
* plugin looks like it never installed. Catching here turns any failure into
|
|
2375
|
+
* a small visible chip, so the seat never disappears and the reason stays in
|
|
2376
|
+
* the console.
|
|
2377
|
+
*/
|
|
2378
|
+
class SeatBoundary extends React.Component {
|
|
2379
|
+
constructor(props) {
|
|
2380
|
+
super(props)
|
|
2381
|
+
this.state = { failure: null }
|
|
2382
|
+
}
|
|
2383
|
+
|
|
2384
|
+
static getDerivedStateFromError(error) {
|
|
2385
|
+
return { failure: error instanceof Error ? error : new Error(String(error)) }
|
|
2386
|
+
}
|
|
2387
|
+
|
|
2388
|
+
componentDidCatch(error, info) {
|
|
2389
|
+
console.error('dsh-model-flatmap: the model seat failed and fell back to a minimal control', error, info)
|
|
2390
|
+
}
|
|
2391
|
+
|
|
2392
|
+
render() {
|
|
2393
|
+
const { failure } = this.state
|
|
2394
|
+
if (failure === null) return this.props.children
|
|
2395
|
+
const label = this.props.label ?? 'Model'
|
|
2396
|
+
// The reason is shown, not just logged: without browser console access a
|
|
2397
|
+
// silent fallback is indistinguishable from "the plugin never installed".
|
|
2398
|
+
const reason = String(failure && failure.message ? failure.message : failure)
|
|
2399
|
+
return h('div', { className: 'dshmf-root' },
|
|
2400
|
+
h('button', {
|
|
2401
|
+
type: 'button',
|
|
2402
|
+
className: 'dshmf-trigger',
|
|
2403
|
+
disabled: true,
|
|
2404
|
+
title: `${label}: ${reason}`,
|
|
2405
|
+
'aria-label': `${label} — ${reason}`,
|
|
2406
|
+
},
|
|
2407
|
+
svg(PATH_MODELS, 16, { className: 'dshmf-triggerIcon' }),
|
|
2408
|
+
h('span', { className: 'dshmf-triggerText' }, `${label}: ${reason.slice(0, 90)}`),
|
|
2409
|
+
),
|
|
2410
|
+
)
|
|
2411
|
+
}
|
|
2412
|
+
}
|
|
2413
|
+
|
|
2414
|
+
/** Labels whose derivation failure has already been reported. */
|
|
2415
|
+
const reportedDerivations = new Set()
|
|
2416
|
+
|
|
2417
|
+
/**
|
|
2418
|
+
* Run a pure derivation and degrade instead of throwing.
|
|
2419
|
+
* @param label - which derivation failed, for the console.
|
|
2420
|
+
* @param run - the derivation.
|
|
2421
|
+
* @param fallback - the value to use when it throws.
|
|
2422
|
+
* @returns the derived value, or the fallback.
|
|
2423
|
+
*/
|
|
2424
|
+
function guarded(label, run, fallback) {
|
|
2425
|
+
try {
|
|
2426
|
+
return run()
|
|
2427
|
+
} catch (error) {
|
|
2428
|
+
if (!reportedDerivations.has(label)) {
|
|
2429
|
+
reportedDerivations.add(label)
|
|
2430
|
+
console.error(`dsh-model-flatmap: ${label} failed; the panel degrades`, error)
|
|
2431
|
+
}
|
|
2432
|
+
return fallback
|
|
2433
|
+
}
|
|
2434
|
+
}
|
|
2435
|
+
|
|
2436
|
+
/**
|
|
2437
|
+
* The registered seat: a boundary around the panel, so a content failure
|
|
2438
|
+
* cannot remove the entry from the slot.
|
|
2439
|
+
* @param props - the slot's standard props plus the injected face.
|
|
2440
|
+
*/
|
|
2441
|
+
function Seat(props) {
|
|
2442
|
+
const translate = typeof props.t === 'function' ? props.t : key => key
|
|
2443
|
+
return h(SeatBoundary, { label: translate('trigger.fallback') },
|
|
2444
|
+
h(ModelFlatMap, props))
|
|
2445
|
+
}
|
|
2446
|
+
|
|
2447
|
+
// ---------------------------------------------------------------------
|
|
2448
|
+
// Plugin
|
|
2449
|
+
// ---------------------------------------------------------------------
|
|
2450
|
+
return {
|
|
2451
|
+
// `remote.agentTeams` is a SEPARATE client service (the gateway installs
|
|
2452
|
+
// each remote namespace as its own service). Reading it through a bare
|
|
2453
|
+
// `ctx.get('remote')` property lookup is refused by the guarded client
|
|
2454
|
+
// context unless both names are declared here, and a swallowed refusal
|
|
2455
|
+
// used to leave `teamAvailable` permanently false — i.e. the host's
|
|
2456
|
+
// teamwork switch never rendered at all.
|
|
2457
|
+
inject: ['slots', 'locale', 'remote', 'remote.agentTeams', 'sessions'],
|
|
2458
|
+
// Pure layout and grouping derivations plus the component itself,
|
|
2459
|
+
// exposed for `node --test` so grouping, filtering and the column
|
|
2460
|
+
// 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 },
|
|
2462
|
+
__component: ModelFlatMap,
|
|
2463
|
+
__seat: Seat,
|
|
2464
|
+
apply(ctx) {
|
|
2465
|
+
const read = name => {
|
|
2466
|
+
try {
|
|
2467
|
+
return typeof ctx.get === 'function' ? ctx.get(name) : undefined
|
|
2468
|
+
} catch {
|
|
2469
|
+
return undefined
|
|
2470
|
+
}
|
|
2471
|
+
}
|
|
2472
|
+
|
|
2473
|
+
// Dictionary registration must never be able to take the seat down: a
|
|
2474
|
+
// namespace left behind by a previous module generation throws on
|
|
2475
|
+
// re-registration, and losing the copy is far better than losing the
|
|
2476
|
+
// control.
|
|
2477
|
+
try {
|
|
2478
|
+
const locale = read('locale')
|
|
2479
|
+
if (locale !== undefined && typeof locale.register === 'function') {
|
|
2480
|
+
ctx.effect(() => locale.register(NS, dict), 'dsh-model-flatmap: dictionaries')
|
|
2481
|
+
}
|
|
2482
|
+
} catch (error) {
|
|
2483
|
+
console.error('dsh-model-flatmap: dictionaries were not registered', error)
|
|
2484
|
+
}
|
|
2485
|
+
|
|
2486
|
+
const resolveText = value => {
|
|
2487
|
+
try {
|
|
2488
|
+
const locale = read('locale')
|
|
2489
|
+
return locale === undefined ? fallbackText(value) : locale.resolveText(value)
|
|
2490
|
+
} catch {
|
|
2491
|
+
return fallbackText(value)
|
|
2492
|
+
}
|
|
2493
|
+
}
|
|
2494
|
+
|
|
2495
|
+
// Services are read at CALL time, never captured once: a client reload
|
|
2496
|
+
// replaces them, and a value captured during activation then throws
|
|
2497
|
+
// forever. `slots.inject` additionally waits for the declaration, so the
|
|
2498
|
+
// registration below runs exactly when the seat exists.
|
|
2499
|
+
const slots = read('slots')
|
|
2500
|
+
if (slots === undefined || typeof slots.inject !== 'function') return
|
|
2501
|
+
|
|
2502
|
+
// The teamwork switch is driven by the Host's own `remote.agentTeams`
|
|
2503
|
+
// namespace service. The gateway installs each remote namespace as its
|
|
2504
|
+
// OWN service, so it is read by full name through `ctx.get`, never as a
|
|
2505
|
+
// property of the `remote` service (a bare `ctx.remote` lookup is
|
|
2506
|
+
// refused by the guarded client context, and the old try/catch turned
|
|
2507
|
+
// that refusal into a silent `undefined` — which hid the switch
|
|
2508
|
+
// forever). `ctx.inject` is not among the client facade's verbs, so the
|
|
2509
|
+
// handle is resolved lazily on every use instead of once at activation.
|
|
2510
|
+
const readTeamService = () => {
|
|
2511
|
+
try {
|
|
2512
|
+
return typeof ctx.get === 'function' ? ctx.get('remote.agentTeams') : undefined
|
|
2513
|
+
} catch {
|
|
2514
|
+
return undefined
|
|
2515
|
+
}
|
|
2516
|
+
}
|
|
2517
|
+
|
|
2518
|
+
// ---- the GLOBAL insufficient-balance store
|
|
2519
|
+
//
|
|
2520
|
+
// Created once per activation and shared by every seat, because the
|
|
2521
|
+
// balance state belongs to the model route (and its account), not to one
|
|
2522
|
+
// conversation or workspace. It observes every Session the list names,
|
|
2523
|
+
// persists to `localStorage`, and starts from what the last page load
|
|
2524
|
+
// recorded. A failure to build it degrades to an empty face rather than
|
|
2525
|
+
// costing the control.
|
|
2526
|
+
let markStore
|
|
2527
|
+
try {
|
|
2528
|
+
markStore = createMarkStore({
|
|
2529
|
+
sessionsOf: () => {
|
|
2530
|
+
try {
|
|
2531
|
+
return typeof ctx.get === 'function' ? ctx.get('sessions') : undefined
|
|
2532
|
+
} catch {
|
|
2533
|
+
return undefined
|
|
2534
|
+
}
|
|
2535
|
+
},
|
|
2536
|
+
readStored: readMarks,
|
|
2537
|
+
writeStored: writeMarks,
|
|
2538
|
+
})
|
|
2539
|
+
ctx.effect(() => () => { markStore.dispose() }, 'dsh-model-flatmap: balance marks')
|
|
2540
|
+
} catch (error) {
|
|
2541
|
+
console.error('dsh-model-flatmap: the balance mark store failed to start', error)
|
|
2542
|
+
markStore = undefined
|
|
2543
|
+
}
|
|
2544
|
+
|
|
2545
|
+
/**
|
|
2546
|
+
* Build the injected face for one Session.
|
|
2547
|
+
* @param sessionId - the Session this seat renders for.
|
|
2548
|
+
* @returns the face the seat component receives.
|
|
2549
|
+
*/
|
|
2550
|
+
const buildFace = sessionId => {
|
|
2551
|
+
// Every capability is resolved per call, so a Session that binds late,
|
|
2552
|
+
// or a reloaded service, is picked up without tearing the seat down.
|
|
2553
|
+
const safeRead = name => {
|
|
2554
|
+
try {
|
|
2555
|
+
return read(name)
|
|
2556
|
+
} catch {
|
|
2557
|
+
return undefined
|
|
2558
|
+
}
|
|
2559
|
+
}
|
|
2560
|
+
const models = safeRead('modelDirectories')
|
|
2561
|
+
const sessions = safeRead('sessions')
|
|
2562
|
+
|
|
2563
|
+
const resolve = () => {
|
|
2564
|
+
try {
|
|
2565
|
+
// `subagentAddress` is the documented way to tell an addressed
|
|
2566
|
+
// child from an ordinary Session; without it, treat the Session as
|
|
2567
|
+
// selectable rather than blanking the composer.
|
|
2568
|
+
if (typeof sessions?.subagentAddress === 'function'
|
|
2569
|
+
&& sessions.subagentAddress(sessionId) !== undefined) {
|
|
2570
|
+
return { addressed: true }
|
|
2571
|
+
}
|
|
2572
|
+
if (models === undefined || typeof models.directoryFor !== 'function') {
|
|
2573
|
+
return { addressed: false, pending: 'modelDirectories unavailable' }
|
|
2574
|
+
}
|
|
2575
|
+
const directory = models.directoryFor(sessionId)
|
|
2576
|
+
return { addressed: false, directory, store: directory.store }
|
|
2577
|
+
} catch (error) {
|
|
2578
|
+
return { addressed: false, pending: error instanceof Error ? error.message : String(error) }
|
|
2579
|
+
}
|
|
2580
|
+
}
|
|
2581
|
+
const first = resolve()
|
|
2582
|
+
|
|
2583
|
+
// Attach this Session's feed as soon as its seat exists, not only
|
|
2584
|
+
// when a subscriber appears: the seat always subscribes, but the
|
|
2585
|
+
// face is built before the first render, and a feed that is already
|
|
2586
|
+
// live should contribute its history immediately.
|
|
2587
|
+
if (markStore !== undefined) {
|
|
2588
|
+
try {
|
|
2589
|
+
markStore.watch(sessionId)
|
|
2590
|
+
} catch {
|
|
2591
|
+
/* the store degrades to storage-only state */
|
|
2592
|
+
}
|
|
2593
|
+
}
|
|
2594
|
+
|
|
2595
|
+
return {
|
|
2596
|
+
sessionId,
|
|
2597
|
+
resolveDirectory: resolve,
|
|
2598
|
+
safeRead,
|
|
2599
|
+
// The GLOBAL mark store, shared by every seat of this plugin
|
|
2600
|
+
// generation. `subscribe` additionally attaches the store to this
|
|
2601
|
+
// Session's feed, so a Session that binds late (or after the page
|
|
2602
|
+
// loaded) still contributes its events.
|
|
2603
|
+
insufficient: markStore ?? EMPTY_INSUFFICIENT,
|
|
2604
|
+
// `available: false` only for an addressed subagent Session, which
|
|
2605
|
+
// the shipped seat also hides.
|
|
2606
|
+
available: first.addressed !== true,
|
|
2607
|
+
directory: first.store,
|
|
2608
|
+
load: () => {
|
|
2609
|
+
const current = resolve()
|
|
2610
|
+
if (current.directory !== undefined) {
|
|
2611
|
+
current.directory.load().catch(() => { /* surfaced on the store */ })
|
|
2612
|
+
}
|
|
2613
|
+
},
|
|
2614
|
+
select: selection => {
|
|
2615
|
+
const current = resolve()
|
|
2616
|
+
return current.directory === undefined
|
|
2617
|
+
? Promise.resolve(undefined)
|
|
2618
|
+
: current.directory.select(selection)
|
|
2619
|
+
},
|
|
2620
|
+
resolveText,
|
|
2621
|
+
// Lazy, not snapshotted: the namespace service may still be
|
|
2622
|
+
// mounting when this face is built, and the seat is registered
|
|
2623
|
+
// once. Re-reading on each use keeps the switch from being frozen
|
|
2624
|
+
// out by an early `undefined`.
|
|
2625
|
+
teamAvailable: () => readTeamService() !== undefined,
|
|
2626
|
+
setTeamMode: enabled => {
|
|
2627
|
+
const service = readTeamService()
|
|
2628
|
+
return service === undefined
|
|
2629
|
+
? Promise.resolve(undefined)
|
|
2630
|
+
: service.setEnabled(sessionId, enabled)
|
|
2631
|
+
},
|
|
2632
|
+
}
|
|
2633
|
+
}
|
|
2634
|
+
|
|
2635
|
+
/**
|
|
2636
|
+
* Call the face builder and never let a throw escape into slot assembly,
|
|
2637
|
+
* where it would abdicate the entry and hand the cell back to the native
|
|
2638
|
+
* picker. A failed face still renders: the seat reports that it is
|
|
2639
|
+
* unavailable instead of disappearing.
|
|
2640
|
+
* @param sessionId - the Session this seat renders for.
|
|
2641
|
+
* @param build - the face builder.
|
|
2642
|
+
* @returns an injected face, degraded when building failed.
|
|
2643
|
+
*/
|
|
2644
|
+
const safeFace = (sessionId, build) => {
|
|
2645
|
+
try {
|
|
2646
|
+
return build(sessionId)
|
|
2647
|
+
} catch (error) {
|
|
2648
|
+
console.error('dsh-model-flatmap: the seat face failed to build', error)
|
|
2649
|
+
return {
|
|
2650
|
+
sessionId,
|
|
2651
|
+
available: true,
|
|
2652
|
+
directory: undefined,
|
|
2653
|
+
resolveDirectory: () => ({ addressed: false, pending: 'seat unavailable' }),
|
|
2654
|
+
load: () => {},
|
|
2655
|
+
select: () => Promise.resolve(undefined),
|
|
2656
|
+
resolveText,
|
|
2657
|
+
teamAvailable: () => false,
|
|
2658
|
+
setTeamMode: () => Promise.resolve(undefined),
|
|
2659
|
+
// A face that failed to build still answers the mark face, so the
|
|
2660
|
+
// component's hook shape never changes and the rows simply stay
|
|
2661
|
+
// unstruck. It shares the module-level empty snapshot so the
|
|
2662
|
+
// identity stays stable across renders.
|
|
2663
|
+
insufficient: EMPTY_INSUFFICIENT,
|
|
2664
|
+
}
|
|
2665
|
+
}
|
|
2666
|
+
}
|
|
2667
|
+
|
|
2668
|
+
/**
|
|
2669
|
+
* Register the seat, escalating priority when a stale generation still
|
|
2670
|
+
* holds the previous entry.
|
|
2671
|
+
*/
|
|
2672
|
+
const registerSeat = () => {
|
|
2673
|
+
let lastError
|
|
2674
|
+
for (const priority of [-1, -2, -3, -4, -5]) {
|
|
2675
|
+
try {
|
|
2676
|
+
return slots.register({
|
|
2677
|
+
name: 'conversation.input.model',
|
|
2678
|
+
priority,
|
|
2679
|
+
locale: NS,
|
|
2680
|
+
inject: sessionId => safeFace(sessionId, buildFace),
|
|
2681
|
+
}, Seat)
|
|
2682
|
+
} catch (error) {
|
|
2683
|
+
lastError = error
|
|
2684
|
+
}
|
|
2685
|
+
}
|
|
2686
|
+
console.error('dsh-model-flatmap: the model seat was not registered', lastError)
|
|
2687
|
+
return () => {}
|
|
2688
|
+
}
|
|
2689
|
+
|
|
2690
|
+
try {
|
|
2691
|
+
slots.inject('conversation.input.model', registerSeat)
|
|
2692
|
+
} catch (error) {
|
|
2693
|
+
console.error('dsh-model-flatmap: slots.inject failed for the model seat', error)
|
|
2694
|
+
}
|
|
2695
|
+
},
|
|
2696
|
+
}
|
|
2697
|
+
},
|
|
2698
|
+
})
|