@huai_xin/dsh-model-picker 0.1.3 → 0.2.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/README.md +19 -4
- package/client.js +1454 -836
- package/package.json +1 -1
package/client.js
CHANGED
|
@@ -5,561 +5,563 @@
|
|
|
5
5
|
* This bundle is what the Client module loader serves; it is the only browser
|
|
6
6
|
* entry the loader knows about for this package.
|
|
7
7
|
*/
|
|
8
|
-
//# module: src/core/
|
|
8
|
+
//# module: src/core/rank.js
|
|
9
9
|
/**
|
|
10
|
-
*
|
|
10
|
+
* Fuzzy ranking for the model search box.
|
|
11
11
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
12
|
+
* This is the shipped `rankByName` from `@deepseek-ai/dsh-client-ui-primitives`
|
|
13
|
+
* (the `/` menu's own search), vendored verbatim rather than required: the
|
|
14
|
+
* Client module table only carries `react`, and pulling a host package into the
|
|
15
|
+
* offline unit test would make it impossible to run without DSH installed. The
|
|
16
|
+
* algorithm is self-contained — no imports, no state — so copying it keeps the
|
|
17
|
+
* picker's results identical to the shipped menus while the source tree stays
|
|
18
|
+
* dependency-free.
|
|
17
19
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
+
* What it does, per the original doc comment: the query must be a
|
|
21
|
+
* case-insensitive ORDERED SUBSEQUENCE of the candidate's name (or its `label`
|
|
22
|
+
* when it carries one). Prefix hits rank first, then the strongest alignment
|
|
23
|
+
* score over either key, then the source order of the input.
|
|
24
|
+
*
|
|
25
|
+
* Upstream: `packages/client/ui-primitives/src/rank-by-name.ts`.
|
|
20
26
|
*/
|
|
21
|
-
function createStore(init) {
|
|
22
|
-
let state = init
|
|
23
|
-
const listeners = new Set()
|
|
24
|
-
|
|
25
|
-
const publish = (next) => {
|
|
26
|
-
state = next
|
|
27
|
-
for (const listener of [...listeners]) {
|
|
28
|
-
try {
|
|
29
|
-
listener()
|
|
30
|
-
} catch {
|
|
31
|
-
// One failing subscriber must not stop the others.
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
27
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
listeners.add(listener)
|
|
40
|
-
return () => { listeners.delete(listener) }
|
|
41
|
-
},
|
|
42
|
-
set: (next) => publish(next),
|
|
43
|
-
update: (mutate) => {
|
|
44
|
-
const draft = { ...state }
|
|
45
|
-
mutate(draft)
|
|
46
|
-
publish(draft)
|
|
47
|
-
}
|
|
48
|
-
}
|
|
28
|
+
/** Extra weight for name starts and separator boundaries. */
|
|
29
|
+
function boundaryBonus(name, index) {
|
|
30
|
+
return index === 0 || name.charAt(index - 1) === '-' || name.charAt(index - 1) === '_' ? 8 : 0
|
|
49
31
|
}
|
|
50
32
|
|
|
51
33
|
/**
|
|
52
|
-
*
|
|
34
|
+
* Score the strongest ordered-subsequence alignment in O(name × query).
|
|
53
35
|
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
* @
|
|
36
|
+
* Boundary and adjacent matches earn weight; skipped and leading characters
|
|
37
|
+
* cost weight.
|
|
38
|
+
*
|
|
39
|
+
* @param name - the lowercased candidate name.
|
|
40
|
+
* @param query - the lowercased query.
|
|
41
|
+
* @returns the score, or undefined when the query is not a subsequence.
|
|
58
42
|
*/
|
|
59
|
-
function
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
])
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* One Host-generation catalogue shared by every session.
|
|
92
|
-
*
|
|
93
|
-
* Mirrors the shipped `ModelCatalogDirectory`: a single value, one in-flight
|
|
94
|
-
* request shared by every reader, a generation counter that discards responses
|
|
95
|
-
* from a superseded generation, and a `reasoning` map that remembers each
|
|
96
|
-
* model's effort metadata *after* the model leaves the catalogue — so a level
|
|
97
|
-
* still shows its name while its model is gone.
|
|
98
|
-
*
|
|
99
|
-
* @param resolveRemote - returns the client remote face carrying `session.modelCatalog`.
|
|
100
|
-
* A THUNK, not the face itself: this catalogue is built in `apply`, where the
|
|
101
|
-
* `remote` service is often not visible yet (it arrives later in the client boot
|
|
102
|
-
* sequence). Capturing the value here froze `undefined` and every load then
|
|
103
|
-
* reported "modelCatalog is unavailable".
|
|
104
|
-
* @param onDiagnostic - optional sink for load-stage notes.
|
|
105
|
-
* @returns the catalogue controller.
|
|
106
|
-
*/
|
|
107
|
-
function createCatalog(resolveRemote, onDiagnostic) {
|
|
108
|
-
const store = createStore({ value: null, status: 'idle', error: null })
|
|
109
|
-
const reasoning = new Map()
|
|
110
|
-
// Bumped by every invalidation. A response that resolves under an older
|
|
111
|
-
// generation is dropped instead of publishing a catalogue the Host has
|
|
112
|
-
// already superseded.
|
|
113
|
-
let generation = 0
|
|
114
|
-
let inflight
|
|
115
|
-
|
|
116
|
-
const note = (message) => {
|
|
117
|
-
try { onDiagnostic?.(message) } catch { /* diagnostics never break the load */ }
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
/** The remote face as it is right now, or undefined while the service is absent. */
|
|
121
|
-
const currentRemote = () => {
|
|
122
|
-
try { return typeof resolveRemote === 'function' ? resolveRemote() : resolveRemote } catch { return undefined }
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
const reasoningFor = (selection) => selection === null || selection === undefined
|
|
126
|
-
? undefined
|
|
127
|
-
: reasoning.get(JSON.stringify([selection.provider, selection.model]))
|
|
128
|
-
|
|
129
|
-
const load = () => {
|
|
130
|
-
const state = store.getSnapshot()
|
|
131
|
-
if (state.status === 'ready' && state.value !== null) return Promise.resolve(state.value)
|
|
132
|
-
if (inflight !== undefined) return inflight
|
|
133
|
-
const remote = currentRemote()
|
|
134
|
-
const run = remote?.session?.modelCatalog
|
|
135
|
-
if (typeof run !== 'function') {
|
|
136
|
-
note('modelCatalog 不可用:remote 尚未就绪')
|
|
137
|
-
store.set({ value: state.value, status: 'error', error: 'modelCatalog is unavailable' })
|
|
138
|
-
return Promise.resolve(undefined)
|
|
139
|
-
}
|
|
140
|
-
const at = generation
|
|
141
|
-
// Keep the previous value visible while reloading: an invalidation is not an
|
|
142
|
-
// error, and blanking the list would flash on every provider edit.
|
|
143
|
-
store.update((draft) => { draft.status = 'loading'; draft.error = null })
|
|
144
|
-
const operation = Promise.resolve(run.call(remote.session)).then((result) => {
|
|
145
|
-
if (result?.ok !== true) {
|
|
146
|
-
note(`modelCatalog 失败:${result?.error?.code ?? '?'} ${result?.error?.message ?? ''}`)
|
|
147
|
-
if (at === generation) {
|
|
148
|
-
store.set({
|
|
149
|
-
value: store.getSnapshot().value,
|
|
150
|
-
status: 'error',
|
|
151
|
-
error: result?.error?.message ?? 'failed'
|
|
152
|
-
})
|
|
153
|
-
}
|
|
154
|
-
return undefined
|
|
155
|
-
}
|
|
156
|
-
const groups = Array.isArray(result.value?.groups) ? result.value.groups : []
|
|
157
|
-
note(`modelCatalog 成功:${groups.length} 组,gen ${at}/${generation}`)
|
|
158
|
-
if (at === generation) {
|
|
159
|
-
for (const group of groups) {
|
|
160
|
-
for (const model of group.models) {
|
|
161
|
-
reasoning.set(JSON.stringify([group.id, model.id]), model.reasoning)
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
store.set({ value: result.value, status: 'ready', error: null })
|
|
165
|
-
} else {
|
|
166
|
-
// The result was dropped: another load superseded it. Say so, because a
|
|
167
|
-
// silently discarded success is what leaves the store on `loading`.
|
|
168
|
-
note(`modelCatalog 结果被丢弃(gen ${at} != ${generation})`)
|
|
169
|
-
}
|
|
170
|
-
return result.value
|
|
171
|
-
}).catch((error) => {
|
|
172
|
-
note(`modelCatalog 抛错:${error instanceof Error ? error.message : String(error)}`)
|
|
173
|
-
if (at === generation) {
|
|
174
|
-
store.set({
|
|
175
|
-
value: store.getSnapshot().value,
|
|
176
|
-
status: 'error',
|
|
177
|
-
error: error instanceof Error ? error.message : String(error)
|
|
178
|
-
})
|
|
179
|
-
}
|
|
180
|
-
return undefined
|
|
181
|
-
}).finally(() => {
|
|
182
|
-
if (at === generation && inflight === operation) inflight = undefined
|
|
183
|
-
})
|
|
184
|
-
inflight = operation
|
|
185
|
-
return operation
|
|
186
|
-
}
|
|
187
|
-
const invalidate = (clear) => {
|
|
188
|
-
generation += 1
|
|
189
|
-
inflight = undefined
|
|
190
|
-
store.set({
|
|
191
|
-
value: clear === true ? null : store.getSnapshot().value,
|
|
192
|
-
status: 'idle',
|
|
193
|
-
error: null
|
|
194
|
-
})
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
/** Re-read on the next explicit load, keeping the stale value on screen. */
|
|
198
|
-
const refresh = () => {
|
|
199
|
-
note(`refresh 被调用(gen ${generation} → ${generation + 1})`)
|
|
200
|
-
invalidate(false)
|
|
201
|
-
load()
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
/** Drop everything: the value belonged to a Host generation that is gone. */
|
|
205
|
-
const resetGeneration = () => { reasoning.clear(); invalidate(true); load() }
|
|
206
|
-
|
|
207
|
-
/**
|
|
208
|
-
* Subscribe the invalidation events.
|
|
209
|
-
*
|
|
210
|
-
* Call this from the plugin's `apply` body, not from a scope or slot callback.
|
|
211
|
-
* The gateway registers a forwarded-event listener in the CALLING fiber's
|
|
212
|
-
* private event domain, and only the plugin's own `apply` fiber is in the
|
|
213
|
-
* domain the dispatcher publishes to — a subscription installed from a
|
|
214
|
-
* `ctx.inject` callback is accepted (it returns a disposer) but never fires.
|
|
215
|
-
*
|
|
216
|
-
* @param target - the client remote carrying `$on`.
|
|
217
|
-
* @param onEvent - optional observer called with each delivered event name.
|
|
218
|
-
* @returns a disposer removing every subscription.
|
|
219
|
-
*/
|
|
220
|
-
const attach = (target, onEvent) => {
|
|
221
|
-
const offs = []
|
|
222
|
-
const failed = []
|
|
223
|
-
for (const event of INVALIDATION_EVENTS) {
|
|
224
|
-
try {
|
|
225
|
-
if (typeof target?.$on !== 'function') {
|
|
226
|
-
failed.push(`${event}: 目标没有 $on`)
|
|
227
|
-
continue
|
|
228
|
-
}
|
|
229
|
-
const off = target.$on(event, () => {
|
|
230
|
-
onEvent?.(event)
|
|
231
|
-
refresh()
|
|
232
|
-
})
|
|
233
|
-
if (typeof off === 'function') offs.push(off)
|
|
234
|
-
else failed.push(`${event}: 返回的不是 disposer`)
|
|
235
|
-
} catch (error) {
|
|
236
|
-
failed.push(`${event}: ${error instanceof Error ? error.message : String(error)}`)
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
note(`订阅结果:成功 ${offs.length}/${INVALIDATION_EVENTS.length}${failed.length > 0 ? `;问题:${failed.join(' | ')}` : ''}`)
|
|
240
|
-
return () => { for (const off of offs) off() }
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
/** Whether this target can carry event subscriptions at all. */
|
|
244
|
-
const canAttach = (target) => typeof target?.$on === 'function'
|
|
245
|
-
|
|
246
|
-
return { store, reasoningFor, load, refresh, resetGeneration, attach, canAttach }
|
|
43
|
+
function alignmentScore(name, query) {
|
|
44
|
+
if (query.length > name.length) return undefined
|
|
45
|
+
const noMatch = Number.NEGATIVE_INFINITY
|
|
46
|
+
let previous = Array(name.length).fill(noMatch)
|
|
47
|
+
for (let index = 0; index < name.length; index += 1) {
|
|
48
|
+
if (name.charAt(index) === query.charAt(0)) previous[index] = 1 + boundaryBonus(name, index) - index
|
|
49
|
+
}
|
|
50
|
+
for (let queryIndex = 1; queryIndex < query.length; queryIndex += 1) {
|
|
51
|
+
const current = Array(name.length).fill(noMatch)
|
|
52
|
+
let left = noMatch
|
|
53
|
+
let leftLeft = noMatch
|
|
54
|
+
let bestGapped = noMatch
|
|
55
|
+
for (const [index, prior] of previous.entries()) {
|
|
56
|
+
if (leftLeft !== noMatch) bestGapped = Math.max(bestGapped, leftLeft + index - 2)
|
|
57
|
+
if (name.charAt(index) === query.charAt(queryIndex)) {
|
|
58
|
+
const bonus = 1 + boundaryBonus(name, index)
|
|
59
|
+
let score = noMatch
|
|
60
|
+
if (left !== noMatch) score = left + bonus + 4
|
|
61
|
+
if (bestGapped !== noMatch) score = Math.max(score, bestGapped + bonus + 1 - index)
|
|
62
|
+
current[index] = score
|
|
63
|
+
}
|
|
64
|
+
leftLeft = left
|
|
65
|
+
left = prior
|
|
66
|
+
}
|
|
67
|
+
previous = current
|
|
68
|
+
}
|
|
69
|
+
let best = noMatch
|
|
70
|
+
for (const score of previous) best = Math.max(best, score)
|
|
71
|
+
return best === noMatch ? undefined : best
|
|
247
72
|
}
|
|
248
73
|
|
|
249
|
-
//# module: src/core/selection.js
|
|
250
74
|
/**
|
|
251
|
-
*
|
|
75
|
+
* Rank named items by a search query.
|
|
252
76
|
*
|
|
253
|
-
*
|
|
254
|
-
*
|
|
255
|
-
*
|
|
256
|
-
*
|
|
257
|
-
*
|
|
258
|
-
* @param projection - a projection frame, a selection, or nothing.
|
|
259
|
-
* @returns `{ provider, model, reasoningEffort }`, or null when unreadable.
|
|
77
|
+
* @param items - candidates in source order; an item's optional `label` is a
|
|
78
|
+
* second search key beside its `name`.
|
|
79
|
+
* @param rawQuery - the text typed into the search box, matched case-insensitively.
|
|
80
|
+
* @returns the matching items: prefix hits first, then by alignment score, then
|
|
81
|
+
* in source order. The input list itself for an empty query.
|
|
260
82
|
*/
|
|
261
|
-
function
|
|
262
|
-
const
|
|
263
|
-
if (
|
|
264
|
-
const
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
83
|
+
function rankByName(items, rawQuery) {
|
|
84
|
+
const query = String(rawQuery ?? '').toLowerCase()
|
|
85
|
+
if (query === '') return items
|
|
86
|
+
const ranked = []
|
|
87
|
+
items.forEach((item, index) => {
|
|
88
|
+
const name = String(item?.name ?? item?.id ?? '')
|
|
89
|
+
const keys = item?.label === undefined ? [name] : [name, String(item.label)]
|
|
90
|
+
let prefix = false
|
|
91
|
+
let score
|
|
92
|
+
for (const key of keys) {
|
|
93
|
+
const lower = key.toLowerCase()
|
|
94
|
+
const keyScore = alignmentScore(lower, query)
|
|
95
|
+
if (keyScore === undefined) continue
|
|
96
|
+
prefix ||= lower.startsWith(query)
|
|
97
|
+
score = score === undefined ? keyScore : Math.max(score, keyScore)
|
|
98
|
+
}
|
|
99
|
+
if (score !== undefined) ranked.push({ item, index, prefix, score })
|
|
100
|
+
})
|
|
101
|
+
ranked.sort((left, right) => Number(right.prefix) - Number(left.prefix) ||
|
|
102
|
+
right.score - left.score ||
|
|
103
|
+
left.index - right.index)
|
|
104
|
+
return ranked.map((match) => match.item)
|
|
268
105
|
}
|
|
269
106
|
|
|
107
|
+
//# module: src/ui/Chevron.js
|
|
270
108
|
/**
|
|
271
|
-
*
|
|
272
|
-
*
|
|
273
|
-
* An addressed subagent session cannot: the Host rejects the RPC. The shipped
|
|
274
|
-
* picker renders nothing in that case rather than offering a control whose every
|
|
275
|
-
* click fails. `subagentAddress` is the same probe it uses; a client generation
|
|
276
|
-
* without it counts as available, so an older build keeps showing the control
|
|
277
|
-
* instead of silently losing it.
|
|
109
|
+
* The chevron that turns when its panel is open.
|
|
278
110
|
*
|
|
279
|
-
* @param
|
|
280
|
-
* @
|
|
281
|
-
* @returns whether the model control should be offered.
|
|
111
|
+
* @param props - `{ h, open }`: the createElement function and the open flag.
|
|
112
|
+
* @returns the svg element.
|
|
282
113
|
*/
|
|
283
|
-
function
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
|
|
114
|
+
function Chevron({ h, open }) {
|
|
115
|
+
return h('svg', {
|
|
116
|
+
className: `dmp-chevron${open ? ' dmp-chevron-open' : ''}`,
|
|
117
|
+
viewBox: '0 0 16 16', width: 14, height: 14, 'aria-hidden': true
|
|
118
|
+
}, h('path', {
|
|
119
|
+
d: 'M4 6.5L8 10.5L12 6.5', fill: 'none', stroke: 'currentColor', 'stroke-width': 1.6,
|
|
120
|
+
'stroke-linecap': 'round', 'stroke-linejoin': 'round'
|
|
121
|
+
}))
|
|
290
122
|
}
|
|
291
123
|
|
|
292
|
-
//# module: src/
|
|
293
|
-
|
|
294
|
-
|
|
124
|
+
//# module: src/ui/anchor.js
|
|
295
125
|
/**
|
|
296
|
-
*
|
|
126
|
+
* Anchor a popover to the centre of its trigger, clamped to the viewport.
|
|
127
|
+
*
|
|
128
|
+
* Both popovers in this picker open ABOVE their button, and both are wider than
|
|
129
|
+
* the button they belong to. Anchoring by a fixed side (`left: 0` or `right: 0`)
|
|
130
|
+
* makes the panel drift away from the control the user just pressed, and near
|
|
131
|
+
* either screen edge it runs off the window entirely.
|
|
297
132
|
*
|
|
298
|
-
* The
|
|
299
|
-
*
|
|
300
|
-
* provider groups failed to load. The shipped picker renders all four, and a
|
|
301
|
-
* replacement that collapsed them into "the catalogue plus the projection" loses
|
|
302
|
-
* the in-flight state (so a slow switch shows nothing) and the failure list (so a
|
|
303
|
-
* broken provider group is silently missing).
|
|
133
|
+
* The hook measures the trigger, centres the panel on it, then shifts the result
|
|
134
|
+
* so the panel stays inside the viewport with a small margin. It returns:
|
|
304
135
|
*
|
|
305
|
-
*
|
|
306
|
-
* `
|
|
136
|
+
* - `ref` — attach to the trigger element,
|
|
137
|
+
* - `panelRef`— attach to the panel element (its width depends on content),
|
|
138
|
+
* - `style` — spread onto the panel: centring transform, clamp offset, and
|
|
139
|
+
* `visibility` gated on the first measurement so the panel never
|
|
140
|
+
* paints at the wrong place for a frame.
|
|
307
141
|
*
|
|
308
|
-
* @param
|
|
309
|
-
* @param
|
|
310
|
-
* @
|
|
311
|
-
* @param sessionId - the owning session.
|
|
312
|
-
* @returns the directory controller.
|
|
142
|
+
* @param React - the React runtime.
|
|
143
|
+
* @param options - `{ open, fallbackWidth, deps }`.
|
|
144
|
+
* @returns `{ ref, panelRef, style }`.
|
|
313
145
|
*/
|
|
314
|
-
function
|
|
315
|
-
const
|
|
316
|
-
|
|
317
|
-
routable: null,
|
|
318
|
-
groups: [],
|
|
319
|
-
failures: [],
|
|
320
|
-
status: 'idle',
|
|
321
|
-
pending: null,
|
|
322
|
-
error: null
|
|
323
|
-
})
|
|
324
|
-
// Latest selection wins: an older response must never overwrite a newer one.
|
|
325
|
-
let generation = 0
|
|
326
|
-
let disposed = false
|
|
327
|
-
|
|
328
|
-
/**
|
|
329
|
-
* Recompute the effective selection from the two inputs that define it.
|
|
330
|
-
*
|
|
331
|
-
* `intended` is the session's explicit choice, else the catalogue default —
|
|
332
|
-
* exactly the shipped precedence. `routable` says whether the Host would still
|
|
333
|
-
* accept it, which is what lets the trigger distinguish "this model" from "a
|
|
334
|
-
* model that no longer exists".
|
|
335
|
-
*/
|
|
336
|
-
const sync = () => {
|
|
337
|
-
if (disposed) return
|
|
338
|
-
const catalogState = catalog.store.getSnapshot()
|
|
339
|
-
const projected = readSelection(projectedFace?.getSnapshot?.())
|
|
340
|
-
const intended = projected ?? readSelection(catalogState.value?.default)
|
|
341
|
-
const reasoning = catalog.reasoningFor(intended)
|
|
342
|
-
const effort = intended?.reasoningEffort ?? reasoning?.defaultEffort
|
|
343
|
-
const retainedEffort = effort === undefined
|
|
344
|
-
? undefined
|
|
345
|
-
: reasoning?.efforts?.find((level) => level.id === effort)?.name ?? effort
|
|
346
|
-
const ready = catalogState.status === 'ready' && catalogState.value !== null
|
|
347
|
-
if (!ready || intended === null) {
|
|
348
|
-
store.set({
|
|
349
|
-
current: catalogState.value === null ? null : store.getSnapshot().current,
|
|
350
|
-
...retainedEffort === undefined ? {} : { retainedEffort },
|
|
351
|
-
routable: null,
|
|
352
|
-
groups: catalogState.value?.groups ?? [],
|
|
353
|
-
failures: catalogState.value?.failures ?? [],
|
|
354
|
-
status: catalogState.status === 'error' ? 'error' : 'loading',
|
|
355
|
-
pending: store.getSnapshot().pending,
|
|
356
|
-
error: catalogState.error
|
|
357
|
-
})
|
|
358
|
-
return
|
|
359
|
-
}
|
|
360
|
-
const routable = (catalogState.value.groups ?? []).some((group) =>
|
|
361
|
-
group.id === intended.provider && (group.models ?? []).some((model) => model.id === intended.model))
|
|
362
|
-
store.set({
|
|
363
|
-
current: intended,
|
|
364
|
-
...retainedEffort === undefined ? {} : { retainedEffort },
|
|
365
|
-
routable,
|
|
366
|
-
groups: catalogState.value.groups,
|
|
367
|
-
failures: catalogState.value.failures ?? [],
|
|
368
|
-
status: store.getSnapshot().status === 'selecting' ? 'selecting' : 'ready',
|
|
369
|
-
pending: store.getSnapshot().pending,
|
|
370
|
-
error: null
|
|
371
|
-
})
|
|
372
|
-
}
|
|
146
|
+
function createAnchoredPanel(React, options) {
|
|
147
|
+
const { useState, useEffect, useRef } = React
|
|
148
|
+
const { open, fallbackWidth = 280, margin = 8, deps = [] } = options
|
|
373
149
|
|
|
374
|
-
const
|
|
375
|
-
const
|
|
376
|
-
|
|
150
|
+
const [anchor, setAnchor] = useState({ offset: 0, ready: false })
|
|
151
|
+
const triggerRef = useRef(null)
|
|
152
|
+
const panelRef = useRef(null)
|
|
377
153
|
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
* the trigger can show which row is settling. A response that arrives after a
|
|
383
|
-
* newer selection (or after disposal) is dropped rather than written, which is
|
|
384
|
-
* the bug a bare `busy` flag cannot avoid: two fast clicks would otherwise let
|
|
385
|
-
* the first reply overwrite the second choice.
|
|
386
|
-
*
|
|
387
|
-
* @param selection - provider, model, and optional reasoningEffort.
|
|
388
|
-
* @returns the Host result, or a synthesized failure.
|
|
389
|
-
*/
|
|
390
|
-
const select = async (selection) => {
|
|
391
|
-
// The adapter's default effort is part of the selection the shipped picker
|
|
392
|
-
// submits, so a model switch also settles the level instead of leaving the
|
|
393
|
-
// Host to guess on the next call.
|
|
394
|
-
const nextEffort = selection.reasoningEffort ?? catalog.reasoningFor(selection)?.defaultEffort
|
|
395
|
-
const next = { ...selection, ...nextEffort === undefined ? {} : { reasoningEffort: nextEffort } }
|
|
396
|
-
const generationAt = ++generation
|
|
397
|
-
store.update((draft) => {
|
|
398
|
-
draft.status = 'selecting'
|
|
399
|
-
draft.pending = next
|
|
400
|
-
draft.error = null
|
|
401
|
-
})
|
|
402
|
-
const run = remote?.session?.selectModel
|
|
403
|
-
if (typeof run !== 'function') {
|
|
404
|
-
store.update((draft) => {
|
|
405
|
-
draft.status = 'error'
|
|
406
|
-
draft.pending = null
|
|
407
|
-
draft.error = 'selectModel is unavailable'
|
|
408
|
-
})
|
|
409
|
-
return { ok: false, error: { code: 'unavailable', message: 'selectModel is unavailable' } }
|
|
154
|
+
useEffect(() => {
|
|
155
|
+
if (!open) {
|
|
156
|
+
setAnchor({ offset: 0, ready: false })
|
|
157
|
+
return undefined
|
|
410
158
|
}
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
159
|
+
const measure = () => {
|
|
160
|
+
const trigger = triggerRef.current
|
|
161
|
+
if (trigger === null || typeof trigger.getBoundingClientRect !== 'function') return
|
|
162
|
+
const rect = trigger.getBoundingClientRect()
|
|
163
|
+
const viewport = (typeof window !== 'undefined' && window.innerWidth)
|
|
164
|
+
|| (typeof document !== 'undefined' && document.documentElement?.clientWidth)
|
|
165
|
+
|| 0
|
|
166
|
+
if (viewport === 0) return
|
|
167
|
+
|
|
168
|
+
// Read the panel's real width: it depends on content (a five-level slider is
|
|
169
|
+
// wider than a three-level one). `visibility: hidden` does not affect
|
|
170
|
+
// `getBoundingClientRect`, so the width is available before the first paint.
|
|
171
|
+
// The ref is attached in the same commit the panel is rendered in, so it is
|
|
172
|
+
// set by the time this effect runs.
|
|
173
|
+
const panel = panelRef.current
|
|
174
|
+
const measured = panel !== null && panel !== undefined
|
|
175
|
+
&& typeof panel.getBoundingClientRect === 'function'
|
|
176
|
+
? panel.getBoundingClientRect().width
|
|
177
|
+
: 0
|
|
178
|
+
const width = measured > 0 ? measured : Math.min(fallbackWidth, viewport - 32)
|
|
179
|
+
|
|
180
|
+
const centre = rect.left + rect.width / 2
|
|
181
|
+
const centred = centre - width / 2
|
|
182
|
+
const maxLeft = viewport - width - margin
|
|
183
|
+
const clamped = Math.max(margin, Math.min(centred, Math.max(margin, maxLeft)))
|
|
184
|
+
setAnchor({ offset: clamped - centred, ready: true })
|
|
424
185
|
}
|
|
425
|
-
|
|
426
|
-
|
|
186
|
+
|
|
187
|
+
measure()
|
|
188
|
+
// Second pass after layout: the first can see the min-width instead of the
|
|
189
|
+
// real width when the content is wider than the minimum.
|
|
190
|
+
const raf = typeof requestAnimationFrame === 'function'
|
|
191
|
+
? requestAnimationFrame(measure)
|
|
192
|
+
: undefined
|
|
193
|
+
if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') {
|
|
194
|
+
window.addEventListener('resize', measure)
|
|
195
|
+
return () => {
|
|
196
|
+
window.removeEventListener('resize', measure)
|
|
197
|
+
if (raf !== undefined && typeof cancelAnimationFrame === 'function') cancelAnimationFrame(raf)
|
|
198
|
+
}
|
|
427
199
|
}
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
200
|
+
return undefined
|
|
201
|
+
// `deps` carries content-dependent values (level count, model count) that
|
|
202
|
+
// change the panel's width without changing `open`.
|
|
203
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
204
|
+
}, [open, ...deps])
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
ref: triggerRef,
|
|
208
|
+
panelRef,
|
|
209
|
+
style: {
|
|
210
|
+
left: '50%',
|
|
211
|
+
right: 'auto',
|
|
212
|
+
transform: `translateX(calc(-50% + ${anchor.offset}px))`,
|
|
213
|
+
visibility: anchor.ready ? 'visible' : 'hidden'
|
|
435
214
|
}
|
|
436
|
-
store.update((draft) => {
|
|
437
|
-
draft.status = 'ready'
|
|
438
|
-
draft.pending = null
|
|
439
|
-
draft.error = null
|
|
440
|
-
})
|
|
441
|
-
/**
|
|
442
|
-
* Do NOT re-project here.
|
|
443
|
-
*
|
|
444
|
-
* `sync()` recomputes `current` from the session's projected selection. The
|
|
445
|
-
* Host answers `selectModel` before it publishes that projection, so calling
|
|
446
|
-
* `sync()` at this point reads the OLD selection and writes it straight back
|
|
447
|
-
* over the one just chosen — the picker then looks like the click did
|
|
448
|
-
* nothing. The projection's own subscription calls `sync()` when the Host
|
|
449
|
-
* actually publishes, which is the only moment the new value is real.
|
|
450
|
-
*
|
|
451
|
-
* Until that push arrives, `current` is the selection the Host just
|
|
452
|
-
* acknowledged. `selected` in the answer is authoritative when present;
|
|
453
|
-
* otherwise the triple we sent is what the Host accepted.
|
|
454
|
-
*/
|
|
455
|
-
const settled = readSelection(result?.value?.selected) ?? next
|
|
456
|
-
store.update((draft) => {
|
|
457
|
-
draft.current = settled
|
|
458
|
-
draft.routable = true
|
|
459
|
-
})
|
|
460
|
-
return { ok: true, value: undefined }
|
|
461
215
|
}
|
|
216
|
+
}
|
|
462
217
|
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
218
|
+
//# module: src/ui/caption-glyphs.js
|
|
219
|
+
/**
|
|
220
|
+
* The panel heading as vector art.
|
|
221
|
+
*
|
|
222
|
+
* Why a path and not text: Montserrat has no CJK glyphs, so the four
|
|
223
|
+
* characters cannot get the brand face from a font stack. These outlines were
|
|
224
|
+
* taken from Noto Sans SC — the closest geometric match available — with a
|
|
225
|
+
* rounded stroke applied so the joins read like Montserrat's.
|
|
226
|
+
*
|
|
227
|
+
* Filled with `currentColor`, so the mark follows the theme exactly as the text
|
|
228
|
+
* beside it does, and holds its shape at any zoom or DPI.
|
|
229
|
+
*/
|
|
230
|
+
const CAPTION_VIEWBOX = '0 0 3891 910'
|
|
474
231
|
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
}
|
|
232
|
+
/** One entry per character: horizontal offset, baseline, and outline. */
|
|
233
|
+
const CAPTION_GLYPHS = [
|
|
234
|
+
{ x: -61, y: 833, d: 'M293 248 L323 248 L323 15 Q323 -5 350 -18 L398 -18 Q409 -18 474 -18 Q553 -18 623 -18 L638 -18 Q666 -18 694 2 Q706 64 708 127 Q713 124 723 118 Q733 114 737 113 Q734 48 718 -22 Q679 -48 639 -48 Q633 -48 585 -48 Q520 -48 454 -48 Q406 -48 401 -48 Q360 -48 312 -38 Q293 -11 293 16 Z M379 294 L399 312 Q439 292 523 240 Q597 187 621 164 L601 143 Q578 165 504 219 Q419 273 379 294 Z M754 238 L780 249 Q811 211 872 120 Q921 33 933 -1 L905 -15 Q892 19 845 107 Q785 199 754 238 Z M175 233 L200 225 Q189 186 156 94 Q113 7 87 -25 L61 -13 Q88 19 131 105 Q164 196 175 233 Z M183 557 L183 386 L808 386 L808 557 Z M183 755 L183 586 L808 586 L808 755 Z M154 784 L837 784 L837 357 L154 357 Z M483 773 L513 773 L513 372 L483 372 Z' },
|
|
235
|
+
{ x: 939, y: 833, d: 'M748 205 L778 205 Q778 205 777 198 Q777 189 776 186 Q761 78 730 -23 L708 -42 Q695 -52 668 -58 L645 -59 Q622 -59 521 -56 L463 -52 Q462 -45 458 -29 L453 -22 Q493 -26 569 -30 Q626 -31 638 -31 Q658 -31 679 -28 L686 -23 Q706 -8 733 92 L748 200 Z M313 205 L759 205 L759 176 L306 176 Z M368 361 L845 361 L845 331 L354 331 Z M73 519 L933 519 L933 489 L73 489 Z M166 698 L705 698 L705 669 L166 669 Z M442 831 L472 831 L472 505 L442 505 Z M857 778 L882 763 Q806 670 620 508 Q410 368 185 252 L68 206 Q66 210 58 219 Q49 229 44 232 Q163 276 392 390 Q602 529 784 689 L857 778 Z M360 361 L392 361 Q375 314 333 211 L317 176 L285 176 Q304 214 344 317 L360 361 Z' },
|
|
236
|
+
{ x: 1939, y: 833, d: 'M378 -13 Q440 -9 608 3 Q799 17 896 25 L895 -3 Q803 -10 618 -25 Q451 -38 385 -43 Z M783 134 L808 148 Q838 116 894 40 Q940 -33 952 -62 L926 -77 Q914 -48 869 26 Q812 102 783 134 Z M456 406 L456 220 L863 220 L863 406 Z M427 435 L892 435 L892 190 L427 190 Z M477 747 L477 578 L837 578 L837 747 Z M448 776 L867 776 L867 549 L448 549 Z M642 560 L672 560 L672 -14 L642 -16 Z M84 324 L323 324 L323 294 L84 294 Z M315 324 L345 324 Q345 324 344 317 Q344 307 343 304 Q336 186 319 43 Q299 -29 285 -43 Q276 -53 252 -62 L233 -62 Q217 -63 157 -61 L124 -58 Q123 -52 120 -35 L115 -29 Q153 -32 216 -34 L228 -34 Q241 -34 256 -31 L262 -24 Q273 -13 291 59 Q308 201 315 317 Z M97 551 L127 551 Q123 510 110 415 Q97 327 91 295 L61 295 Q68 328 82 417 Q93 511 97 551 Z M110 551 L329 551 L329 747 L64 747 L64 776 L358 776 L358 522 L110 522 Z' },
|
|
237
|
+
{ x: 2939, y: 833, d: 'M200 551 L928 551 L928 522 L200 522 Z M220 259 L816 259 L816 229 L220 229 Z M386 659 L416 659 L416 376 L720 376 L720 659 L749 659 L749 347 L386 347 Z M807 259 L814 259 L820 261 L839 248 Q797 173 657 65 Q481 -8 286 -53 L187 -65 Q186 -60 178 -44 L174 -38 Q269 -28 461 14 Q633 83 768 184 L807 252 Z M371 228 Q422 151 598 39 Q825 -31 952 -45 Q949 -48 942 -58 Q936 -67 934 -71 Q808 -54 579 19 Q398 135 345 216 Z M155 718 L941 718 L941 688 L155 688 Z M141 718 L171 718 L171 441 Q171 385 164 254 Q144 117 106 -15 L74 -72 Q72 -69 62 -63 Q53 -57 49 -56 Q81 0 117 128 Q136 260 141 386 L141 441 Z M484 824 L512 833 Q531 805 570 737 L579 714 L551 702 Q541 726 504 796 L484 824 Z' },
|
|
238
|
+
]
|
|
481
239
|
|
|
482
|
-
|
|
483
|
-
const dispose = () => {
|
|
484
|
-
disposed = true
|
|
485
|
-
unsubscribeCatalog?.()
|
|
486
|
-
unsubscribeProjection?.()
|
|
487
|
-
}
|
|
240
|
+
//# module: src/ui/CaptionMark.js
|
|
488
241
|
|
|
489
|
-
|
|
490
|
-
|
|
242
|
+
/**
|
|
243
|
+
* The panel heading, drawn as vector art.
|
|
244
|
+
*
|
|
245
|
+
* Montserrat has no CJK glyphs, so a font stack cannot give these four characters
|
|
246
|
+
* the host's brand face — they always fall back to the platform UI font. This
|
|
247
|
+
* renders them from outlines instead, so the heading keeps one shape everywhere and
|
|
248
|
+
* can be styled like a mark rather than a run of text.
|
|
249
|
+
*
|
|
250
|
+
* The fill is `currentColor`: the mark inherits whatever colour the surrounding
|
|
251
|
+
* element already resolved from the theme tokens, so light and dark follow
|
|
252
|
+
* automatically and no JS has to read the theme.
|
|
253
|
+
*
|
|
254
|
+
* The text is still exposed to assistive tech through `aria-label` and a `<title>`,
|
|
255
|
+
* because the glyphs themselves carry no semantics.
|
|
256
|
+
*
|
|
257
|
+
* @param props - `{ h, label, className, height }`.
|
|
258
|
+
* @returns the SVG element tree.
|
|
259
|
+
*/
|
|
260
|
+
function createCaptionMark(React) {
|
|
261
|
+
const h = React.createElement
|
|
491
262
|
|
|
492
|
-
|
|
263
|
+
return function CaptionMark({ label, className = 'dmp-caption-mark', height = 19 }) {
|
|
264
|
+
// The outlines use font units with Y pointing up, so each glyph is flipped on
|
|
265
|
+
// its own baseline. Doing it per glyph keeps the path data verbatim.
|
|
266
|
+
const glyphs = CAPTION_GLYPHS.map((glyph, index) => h('path', {
|
|
267
|
+
key: index,
|
|
268
|
+
transform: `translate(${glyph.x} ${glyph.y}) scale(1 -1)`,
|
|
269
|
+
d: glyph.d
|
|
270
|
+
}))
|
|
271
|
+
|
|
272
|
+
return h('svg', {
|
|
273
|
+
className,
|
|
274
|
+
viewBox: CAPTION_VIEWBOX,
|
|
275
|
+
height,
|
|
276
|
+
// Width follows the viewBox ratio, so `height` alone is enough to size it.
|
|
277
|
+
role: 'img',
|
|
278
|
+
'aria-label': label,
|
|
279
|
+
focusable: 'false'
|
|
280
|
+
},
|
|
281
|
+
h('title', null, label),
|
|
282
|
+
h('g', {
|
|
283
|
+
// A rounded stroke widens the strokes and softens every join, which is what
|
|
284
|
+
// brings the geometric Noto skeleton closer to Montserrat's weight.
|
|
285
|
+
fill: 'currentColor',
|
|
286
|
+
stroke: 'currentColor',
|
|
287
|
+
strokeWidth: 76,
|
|
288
|
+
strokeLinejoin: 'round',
|
|
289
|
+
strokeLinecap: 'round'
|
|
290
|
+
}, glyphs))
|
|
291
|
+
}
|
|
292
|
+
}
|
|
493
293
|
|
|
294
|
+
//# module: src/ui/EffortSlider.js
|
|
494
295
|
/**
|
|
495
|
-
*
|
|
496
|
-
*
|
|
296
|
+
* A discrete reasoning slider over the model's declared levels.
|
|
297
|
+
*
|
|
298
|
+
* The visuals inherit the Host theme. A real range input owns pointer and
|
|
299
|
+
* keyboard interaction; the thin track and thumb are its painted layer. Labels
|
|
300
|
+
* remain clickable jump targets.
|
|
301
|
+
*
|
|
302
|
+
* ## Why this component keeps its own position
|
|
303
|
+
*
|
|
304
|
+
* Every stop submits a selection to the Host, and the Host answers a round trip
|
|
305
|
+
* later. Two things follow from that, and both were visible as "the thumb does
|
|
306
|
+
* not follow the mouse":
|
|
307
|
+
*
|
|
308
|
+
* - A CONTROLLED input (`value={index}`) gets the old index written back on
|
|
309
|
+
* every parent render, so the thumb snaps backwards mid-drag.
|
|
310
|
+
* - Re-submitting on every pixel of travel queues a request per stop, and the
|
|
311
|
+
* directory flips to `selecting` in between — which used to disable the input
|
|
312
|
+
* and kill the gesture after the first stop.
|
|
313
|
+
*
|
|
314
|
+
* So the input is uncontrolled and this component tracks the live stop itself.
|
|
315
|
+
* `onInput` updates local state only (instant, no Host involved); the commit to
|
|
316
|
+
* the Host happens once, on `onChange` — the event browsers fire when the pointer
|
|
317
|
+
* is released. A drag is therefore always smooth, and one gesture is one request.
|
|
497
318
|
*
|
|
498
|
-
* The
|
|
499
|
-
*
|
|
500
|
-
*
|
|
501
|
-
*
|
|
502
|
-
*
|
|
319
|
+
* The one thing the uncontrolled input cannot do on its own is follow a level
|
|
320
|
+
* chosen WITHOUT a drag — clicking a label under the rail, or a level arriving
|
|
321
|
+
* from `/model`. Those change `activeId` while the input's internal value still
|
|
322
|
+
* points at the old stop, so the next key press would start from there. A ref
|
|
323
|
+
* writes the value back whenever no drag is in progress; it is never written
|
|
324
|
+
* during a gesture, which is exactly what keeps the thumb glued to the pointer.
|
|
503
325
|
*
|
|
504
|
-
* @param
|
|
505
|
-
* @
|
|
506
|
-
* @param resolveRemote - returns the client remote face. A thunk for the same
|
|
507
|
-
* reason the catalogue takes one: the service is often not visible yet when
|
|
508
|
-
* this object is built, and capturing it froze `undefined`.
|
|
509
|
-
* @returns the service object with `directoryFor`.
|
|
326
|
+
* @param props - `{ h, efforts, activeId, disabled, onChange, t, caption }`.
|
|
327
|
+
* @returns the slider element tree.
|
|
510
328
|
*/
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
const
|
|
514
|
-
|
|
515
|
-
}
|
|
329
|
+
|
|
330
|
+
function createEffortSlider(React) {
|
|
331
|
+
const h = React.createElement
|
|
332
|
+
const CaptionMark = createCaptionMark(React)
|
|
333
|
+
const { useState, useEffect, useRef } = React
|
|
516
334
|
|
|
517
335
|
/**
|
|
518
|
-
*
|
|
336
|
+
* The exhaust table.
|
|
337
|
+
*
|
|
338
|
+
* Fixed numbers, never `Math.random()`: a render has to be pure, and the stream
|
|
339
|
+
* must look identical every time the panel opens.
|
|
519
340
|
*
|
|
520
|
-
*
|
|
521
|
-
*
|
|
522
|
-
*
|
|
523
|
-
*
|
|
524
|
-
*
|
|
525
|
-
* from scratch. The id is the stable identity; `directoryFor` is called per
|
|
526
|
-
* render, so it must be cheap and idempotent.
|
|
341
|
+
* `dy0` is what makes the particles leave the knob's whole CROSS-SECTION rather
|
|
342
|
+
* than one point in its centre — it spreads the spawn points down the knob's
|
|
343
|
+
* height. `dx` / `dy` / `sz` / `rot` stop the squares from marching in step, and
|
|
344
|
+
* the per-particle `--i` turns one keyframe into a continuous stream (see the
|
|
345
|
+
* negative `animation-delay` in the stylesheet).
|
|
527
346
|
*/
|
|
528
|
-
const
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
}
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
347
|
+
const EMIT = [
|
|
348
|
+
{ dy0: -11, dx: -58, dy: -3, sz: 4, rot: 0 },
|
|
349
|
+
{ dy0: -7, dx: -74, dy: -5, sz: 3, rot: 45 },
|
|
350
|
+
{ dy0: -3, dx: -62, dy: -1, sz: 5, rot: 0 },
|
|
351
|
+
{ dy0: 1, dx: -88, dy: 2, sz: 3, rot: 90 },
|
|
352
|
+
{ dy0: 5, dx: -66, dy: 4, sz: 4, rot: 0 },
|
|
353
|
+
{ dy0: 9, dx: -80, dy: 6, sz: 3, rot: 45 },
|
|
354
|
+
{ dy0: -9, dx: -92, dy: -6, sz: 3, rot: 90 },
|
|
355
|
+
{ dy0: -5, dx: -54, dy: -2, sz: 4, rot: 0 },
|
|
356
|
+
{ dy0: -1, dx: -70, dy: 0, sz: 3, rot: 45 },
|
|
357
|
+
{ dy0: 3, dx: -84, dy: 3, sz: 4, rot: 0 },
|
|
358
|
+
{ dy0: 7, dx: -60, dy: 5, sz: 3, rot: 90 },
|
|
359
|
+
{ dy0: 11, dx: -76, dy: 7, sz: 3, rot: 0 },
|
|
360
|
+
{ dy0: -8, dx: -68, dy: -4, sz: 4, rot: 45 },
|
|
361
|
+
{ dy0: 0, dx: -90, dy: 1, sz: 3, rot: 0 },
|
|
362
|
+
{ dy0: 6, dx: -52, dy: 4, sz: 4, rot: 90 },
|
|
363
|
+
{ dy0: -2, dx: -78, dy: -1, sz: 3, rot: 0 }
|
|
364
|
+
]
|
|
365
|
+
|
|
366
|
+
return function EffortSlider({ efforts, activeId, disabled, onChange, t, caption }) {
|
|
367
|
+
const count = efforts.length
|
|
368
|
+
const incoming = Math.max(0, efforts.findIndex((effort) => effort.id === activeId))
|
|
544
369
|
|
|
545
|
-
|
|
546
|
-
|
|
370
|
+
/**
|
|
371
|
+
* The stop under the pointer, which is ahead of `activeId` while dragging.
|
|
372
|
+
*
|
|
373
|
+
* `null` means "no drag in progress, follow the prop".
|
|
374
|
+
*/
|
|
375
|
+
const [dragging, setDragging] = useState(null)
|
|
376
|
+
const index = dragging ?? incoming
|
|
377
|
+
const active = efforts[index]
|
|
378
|
+
const activeName = active?.name ?? active?.id ?? ''
|
|
379
|
+
const inputRef = useRef(null)
|
|
380
|
+
|
|
381
|
+
// A prop change from outside (another entry changed the level, the session
|
|
382
|
+
// switched model) wins over a stale drag value.
|
|
383
|
+
useEffect(() => { setDragging(null) }, [activeId, efforts.length])
|
|
384
|
+
|
|
385
|
+
// The DOM value follows the level unless a gesture owns it. Writing during a
|
|
386
|
+
// drag would fight the pointer, so `dragging` gates it.
|
|
387
|
+
useEffect(() => {
|
|
388
|
+
if (dragging !== null) return
|
|
389
|
+
const input = inputRef.current
|
|
390
|
+
if (input !== null && typeof input === 'object' && 'value' in input) {
|
|
391
|
+
input.value = String(incoming)
|
|
392
|
+
}
|
|
393
|
+
}, [incoming, dragging])
|
|
394
|
+
|
|
395
|
+
// A model with just one effort keeps its stop in the middle.
|
|
396
|
+
const position = (at) => count <= 1 ? '50%' : `${(at / (count - 1)) * 100}%`
|
|
397
|
+
|
|
398
|
+
return h('div', {
|
|
399
|
+
// `dmp-slider-dragging` drops the left transition on the knob, the fill and
|
|
400
|
+
// the emitter together. Without it the knob eases to its new stop over 150ms
|
|
401
|
+
// while a transition-free emitter jumps there at once, so the particles left
|
|
402
|
+
// from a position the knob had not reached yet.
|
|
403
|
+
className: `dmp-slider${dragging === null ? '' : ' dmp-slider-dragging'}`,
|
|
404
|
+
'aria-disabled': disabled === true,
|
|
405
|
+
style: { '--dmp-effort-at': position(index) }
|
|
406
|
+
},
|
|
407
|
+
h('div', { className: 'dmp-slider-head' },
|
|
408
|
+
h('span', { className: 'dmp-slider-caption' },
|
|
409
|
+
// The vector marks are CJK outlines; a Latin heading is plain text.
|
|
410
|
+
caption === undefined
|
|
411
|
+
? h(CaptionMark, { label: t.effortHeading, height: 16 })
|
|
412
|
+
: h('span', { className: 'dmp-caption-text' }, caption)),
|
|
413
|
+
h('span', { className: 'dmp-slider-value' }, activeName)),
|
|
414
|
+
|
|
415
|
+
h('div', { className: 'dmp-slider-rail' },
|
|
416
|
+
/**
|
|
417
|
+
* The rail's outline, drawn as vector art.
|
|
418
|
+
*
|
|
419
|
+
* This started as a `box-shadow` inset, which is a RASTER edge: at 1px it
|
|
420
|
+
* lands on the pixel grid but reads flat, and at 1.5px it falls between
|
|
421
|
+
* pixels and smears into a grey band. The knob's ring never had that problem
|
|
422
|
+
* because it is an SVG stroke — a vector edge, crisp at any scale.
|
|
423
|
+
*
|
|
424
|
+
* So the outline uses the same primitive as the knob: a `<rect>` with a
|
|
425
|
+
* stroked edge, sized to the rail and scaled to it by `preserveAspectRatio`
|
|
426
|
+
* being off. `vector-effect: non-scaling-stroke` keeps the stroke exactly
|
|
427
|
+
* 1px however the rect is stretched, so the line stays hairline-crisp instead
|
|
428
|
+
* of being squashed by the horizontal stretch. `rx` is a percentage of the
|
|
429
|
+
* half-height, matching the rail's own rounded ends.
|
|
430
|
+
*/
|
|
431
|
+
h('svg', {
|
|
432
|
+
className: 'dmp-slider-outline',
|
|
433
|
+
'aria-hidden': true,
|
|
434
|
+
preserveAspectRatio: 'none'
|
|
435
|
+
},
|
|
436
|
+
h('rect', {
|
|
437
|
+
// Half the stroke on each axis: an SVG stroke straddles the path, so a 2px
|
|
438
|
+
// line on the exact edge would be half-clipped by the rail's clip-path.
|
|
439
|
+
x: 1,
|
|
440
|
+
y: 1,
|
|
441
|
+
width: 'calc(100% - 2px)',
|
|
442
|
+
height: 'calc(100% - 2px)',
|
|
443
|
+
rx: 14,
|
|
444
|
+
ry: 14,
|
|
445
|
+
fill: 'none',
|
|
446
|
+
stroke: 'var(--dsw-alias-border-l3)',
|
|
447
|
+
strokeWidth: 2,
|
|
448
|
+
vectorEffect: 'non-scaling-stroke'
|
|
449
|
+
})),
|
|
450
|
+
/**
|
|
451
|
+
* The moving parts live in an INSET track.
|
|
452
|
+
*
|
|
453
|
+
* Stops are drawn inside the rail rather than on its very edge — a dot
|
|
454
|
+
* sitting flush against the rounded end reads as part of the border, not
|
|
455
|
+
* as a stop. The track is inset by the knob's radius on both sides, which
|
|
456
|
+
* is also exactly the knob's own travel: the native range input spans the
|
|
457
|
+
* rail with a thumb that size, so its centre runs from inset to width
|
|
458
|
+
* minus inset. Keeping all three — fill, dots, knob — on THIS box means
|
|
459
|
+
* the percentage positions from `--dmp-effort-at` line up without any
|
|
460
|
+
* per-element inset arithmetic.
|
|
461
|
+
*/
|
|
462
|
+
h('div', { className: 'dmp-slider-track' },
|
|
463
|
+
h('div', { className: 'dmp-slider-fill', 'aria-hidden': true }),
|
|
464
|
+
/**
|
|
465
|
+
* The exhaust.
|
|
466
|
+
*
|
|
467
|
+
* An empty box pinned at the knob's position (`--dmp-effort-at`), carrying
|
|
468
|
+
* the particle squares. It is a SIBLING of the knob inside the same inset
|
|
469
|
+
* track, so it shares the knob's coordinate space and its transition —
|
|
470
|
+
* which is what keeps the spawn point under the knob while it moves.
|
|
471
|
+
* The rail's clip-path trims whatever drifts past the end cap.
|
|
472
|
+
*/
|
|
473
|
+
h('span', { className: 'dmp-emit', 'aria-hidden': true },
|
|
474
|
+
EMIT.map((particle, at) => h('i', {
|
|
475
|
+
key: at,
|
|
476
|
+
className: 'dmp-emit-p',
|
|
477
|
+
style: {
|
|
478
|
+
'--i': at,
|
|
479
|
+
'--dx': `${particle.dx}px`,
|
|
480
|
+
'--dy0': `${particle.dy0}px`,
|
|
481
|
+
'--dy': `${particle.dy}px`,
|
|
482
|
+
'--sz': `${particle.sz}px`,
|
|
483
|
+
'--rot': `${particle.rot}deg`
|
|
484
|
+
}
|
|
485
|
+
}))),
|
|
486
|
+
h('div', { className: 'dmp-slider-dots', 'aria-hidden': true },
|
|
487
|
+
efforts.map((effort, at) => h('span', {
|
|
488
|
+
key: effort.id,
|
|
489
|
+
className: `dmp-slider-dot${at <= index ? ' dmp-slider-dot-lit' : ''}`,
|
|
490
|
+
style: { left: position(at) }
|
|
491
|
+
}))),
|
|
492
|
+
/**
|
|
493
|
+
* The knob is an SVG circle, not a border-radius box.
|
|
494
|
+
*
|
|
495
|
+
* A `<div>` with `border-radius` is only round as long as nothing else
|
|
496
|
+
* controls its box, and something in the host does: the same CSS that is
|
|
497
|
+
* perfectly round in isolation rendered as a rounded rectangle inside the
|
|
498
|
+
* app, at every radius value tried. An SVG circle's shape comes from its
|
|
499
|
+
* geometry, so no `border-radius`, `padding`, or inherited box rule can
|
|
500
|
+
* square it off.
|
|
501
|
+
*/
|
|
502
|
+
h('svg', {
|
|
503
|
+
className: 'dmp-slider-thumb',
|
|
504
|
+
viewBox: '0 0 28 28',
|
|
505
|
+
'aria-hidden': true
|
|
506
|
+
},
|
|
507
|
+
h('circle', {
|
|
508
|
+
// Outer edge = r + stroke/2 = 13.25 + 0.75 = 14 units, and the 28-unit
|
|
509
|
+
// box is drawn at 32px — so the disc's radius is exactly 16px, the same
|
|
510
|
+
// as the rail's end cap. Smaller and the blue cap peeks out as a thin
|
|
511
|
+
// rim around the knob at the first stop; larger and the knob spills
|
|
512
|
+
// past the pill.
|
|
513
|
+
cx: 14,
|
|
514
|
+
cy: 14,
|
|
515
|
+
r: 13.25,
|
|
516
|
+
// Fill and ring come from CSS so the two themes can differ: the dark
|
|
517
|
+
// theme keeps the knob the panel's own grey with a black ring, the light
|
|
518
|
+
// theme flips the fill to white and keeps the black ring.
|
|
519
|
+
fill: 'var(--dmp-thumb-fill, currentColor)',
|
|
520
|
+
stroke: 'var(--dmp-thumb-ring, #000)',
|
|
521
|
+
strokeWidth: 1.5
|
|
522
|
+
}))),
|
|
523
|
+
h('input', {
|
|
524
|
+
type: 'range',
|
|
525
|
+
ref: inputRef,
|
|
526
|
+
className: 'dmp-slider-input',
|
|
527
|
+
min: 0,
|
|
528
|
+
max: Math.max(0, count - 1),
|
|
529
|
+
step: 1,
|
|
530
|
+
// Uncontrolled: the DOM owns the position during a gesture, so a parent
|
|
531
|
+
// render cannot snap it back. `defaultValue` seeds it on mount, and the
|
|
532
|
+
// effect above re-seeds it whenever the level changes from outside.
|
|
533
|
+
defaultValue: incoming,
|
|
534
|
+
disabled: disabled === true || count <= 1,
|
|
535
|
+
'aria-label': t.effortHeading,
|
|
536
|
+
'aria-valuetext': activeName,
|
|
537
|
+
// Live feedback only — no Host call, so the thumb tracks at full speed.
|
|
538
|
+
onInput: (event) => {
|
|
539
|
+
const at = Number(event.target.value)
|
|
540
|
+
if (Number.isFinite(at)) setDragging(at)
|
|
541
|
+
},
|
|
542
|
+
// The commit: browsers fire `change` on release (and on every key press).
|
|
543
|
+
onChange: (event) => {
|
|
544
|
+
const at = Number(event.target.value)
|
|
545
|
+
const next = efforts[at]
|
|
546
|
+
setDragging(null)
|
|
547
|
+
if (next !== undefined) onChange(next.id)
|
|
548
|
+
}
|
|
549
|
+
})),
|
|
547
550
|
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
}))
|
|
551
|
+
h('div', { className: 'dmp-slider-legend' },
|
|
552
|
+
efforts.map((effort, at) => h('button', {
|
|
553
|
+
key: effort.id,
|
|
554
|
+
type: 'button',
|
|
555
|
+
className: `dmp-slider-tick${at === index ? ' dmp-slider-tick-active' : ''}`,
|
|
556
|
+
disabled: disabled === true,
|
|
557
|
+
'aria-pressed': at === index,
|
|
558
|
+
'data-edge': count <= 1 ? undefined : at === 0 ? 'start' : at === count - 1 ? 'end' : undefined,
|
|
559
|
+
title: effort.name ?? effort.id,
|
|
560
|
+
style: { left: position(at) },
|
|
561
|
+
onClick: () => onChange(effort.id)
|
|
562
|
+
}, effort.name ?? effort.id))),
|
|
563
|
+
h('p', { className: 'dmp-slider-hint' }, t.effortHint))
|
|
564
|
+
}
|
|
563
565
|
}
|
|
564
566
|
|
|
565
567
|
//# module: src/ui/styles.js
|
|
@@ -567,55 +569,417 @@ function Chevron({ h, open }) {
|
|
|
567
569
|
* The picker's stylesheet.
|
|
568
570
|
*
|
|
569
571
|
* Host theme tokens only, plus the two composer variables the row sets when its
|
|
570
|
-
* controls no longer fit on one line. Every class is prefixed
|
|
572
|
+
* controls no longer fit on one line. Every class is prefixed dmp- so a copied
|
|
571
573
|
* host pattern can never collide with the host's own rules.
|
|
574
|
+
*
|
|
575
|
+
* Sizing follows the shipped menu rather than inventing its own scale: the panel
|
|
576
|
+
* is max-content wide (so a short list stays narrow instead of stretching to a
|
|
577
|
+
* fixed width), rows are 34px tall with 5px/7px padding, and the panel pads 4px.
|
|
578
|
+
* Those numbers are what makes the replacement feel like part of the composer
|
|
579
|
+
* instead of a dialog dropped on top of it.
|
|
572
580
|
*/
|
|
573
581
|
const CSS = `
|
|
574
|
-
.dmp-root { position: relative; display: flex; align-items: center; gap:
|
|
582
|
+
.dmp-root { position: relative; display: flex; align-items: center; gap: 6px; min-width: 0; }
|
|
575
583
|
/* One popover anchor per control: the model list and the level list open above
|
|
576
584
|
their own button and never overlap each other. */
|
|
577
585
|
.dmp-control { position: relative; display: flex; align-items: center; min-width: 0; }
|
|
578
|
-
|
|
579
|
-
|
|
586
|
+
/* The model tag keeps a stable box too, for the same reason: switching models must
|
|
587
|
+
not move the level tag beside it. It is capped rather than fixed so a long model
|
|
588
|
+
name still gets room, but the cap is what the layout reserves. */
|
|
589
|
+
.dmp-trigger { display: flex; align-items: center; gap: 4px; min-width: 0;
|
|
590
|
+
max-width: min(220px, 34cqw); height: 28px; padding: 0 4px 0 8px; border: 0;
|
|
591
|
+
border-radius: var(--dsw-radius-sm, 6px); cursor: pointer;
|
|
580
592
|
background: transparent; color: var(--dsw-alias-label-secondary);
|
|
581
593
|
font: inherit; font-size: 13px; line-height: 20px; }
|
|
582
|
-
|
|
583
|
-
|
|
594
|
+
/* The level tag has a FIXED width.
|
|
595
|
+
Level names differ in length ("Low" vs "X-High"), so a content-sized tag made
|
|
596
|
+
the whole composer row shift every time the level changed — and pushed the
|
|
597
|
+
model button sideways with it. A fixed box keeps both controls still.
|
|
598
|
+
|
|
599
|
+
The width is sized to the longest name a provider is likely to send: "Medium"
|
|
600
|
+
measures ~50px at 12px/600, and the earlier 74px box only left 44px for text,
|
|
601
|
+
so it rendered as "Medi…". 92px leaves 56px, which also covers "Extreme" and
|
|
602
|
+
"Minimal".
|
|
603
|
+
|
|
604
|
+
The label is CENTRED in the button, which needs the caret out of the flex flow:
|
|
605
|
+
a normal flex child reserves 18px on the right only, so centring the label in
|
|
606
|
+
what was left pushed it ~7px left of the button's true centre. Absolutely
|
|
607
|
+
positioning the caret and giving the button 18px padding on BOTH sides makes
|
|
608
|
+
the label's own box symmetric, so text-align: center lands on the button's
|
|
609
|
+
centre. 18px is exactly the caret's own width (14px) plus its 4px inset, which
|
|
610
|
+
also stops a long label from running underneath it.
|
|
611
|
+
The caret keeps its transform free for the open-state rotation by centring
|
|
612
|
+
with margin-top instead of translateY. */
|
|
613
|
+
.dmp-trigger-effort { position: relative; width: 92px; box-sizing: border-box;
|
|
614
|
+
padding: 0 18px; font-size: 12px; color: var(--dsw-alias-label-secondary); }
|
|
615
|
+
.dmp-trigger-effort > .dmp-chevron { position: absolute; right: 4px; top: 50%;
|
|
616
|
+
margin-top: -7px; }
|
|
617
|
+
/* The tag names the level; it is not a brightness meter. Taking the ramp colour here
|
|
618
|
+
made "Low" render dim grey and "High" near white, so the control appeared to change
|
|
619
|
+
weight as the level moved. The ramp stays on the track, where it means intensity. */
|
|
620
|
+
.dmp-trigger-effort-text { flex: 1 1 auto; min-width: 0; text-align: center;
|
|
621
|
+
color: var(--dsw-alias-label-primary); font-weight: 600; }
|
|
622
|
+
/* Hover and open share ONE background, and it is the translucent interactive
|
|
623
|
+
token, not the opaque layer-2 surface.
|
|
624
|
+
The layer-2 token resolves to the composer's own surface colour in both themes
|
|
625
|
+
(light white on white, dark 2c2c2e on 2c2c2e), so hovering this tag painted
|
|
626
|
+
nothing at all — the level button was the only control in the row with no
|
|
627
|
+
pointer feedback. The translucent token composes over whatever is behind it
|
|
628
|
+
and matches the model tag beside it. */
|
|
629
|
+
.dmp-trigger-effort:hover:not(:disabled),
|
|
630
|
+
.dmp-trigger-effort[aria-expanded="true"] {
|
|
631
|
+
background: var(--dsw-alias-interactive-bg-hover, var(--dsw-alias-interactive-bg-active));
|
|
632
|
+
color: var(--dsw-alias-label-primary); }
|
|
633
|
+
.dmp-trigger-effort:focus-visible { outline: 2px solid var(--dsw-alias-border-l2); outline-offset: 2px; }
|
|
584
634
|
.dmp-trigger:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover, rgba(127,127,127,.12)); }
|
|
585
|
-
.dmp-trigger:disabled { color: var(--dsw-alias-state-idle-primary); cursor: default; }
|
|
586
|
-
.dmp-trigger-icon { display: var(--dsh-composer-model-icon-display, none); flex: none; }
|
|
635
|
+
.dmp-trigger:disabled { color: var(--dsw-alias-label-dimmed, var(--dsw-alias-state-idle-primary)); cursor: default; }
|
|
587
636
|
.dmp-trigger-text { display: var(--dsh-composer-model-text-display, block);
|
|
588
637
|
min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
589
|
-
.dmp-chevron { flex: none; color: var(--dsw-alias-label-secondary);
|
|
638
|
+
.dmp-chevron { flex: none; color: var(--dsw-alias-label-caption, var(--dsw-alias-label-secondary));
|
|
639
|
+
transition: transform .12s; }
|
|
590
640
|
.dmp-chevron-open { transform: rotate(180deg); }
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
.dmp-
|
|
602
|
-
.dmp-
|
|
603
|
-
color: var(--dsw-alias-label-
|
|
604
|
-
|
|
605
|
-
|
|
641
|
+
/* Content-sized, like the shipped menu: a two-model list must not be as wide as a
|
|
642
|
+
twenty-model one. The caps only exist so a long model name cannot run off the
|
|
643
|
+
viewport. */
|
|
644
|
+
.dmp-panel { position: absolute; bottom: calc(100% + 6px + var(--dmp-notice-lift, 0px)); z-index: 1000;
|
|
645
|
+
width: max-content; min-width: min(240px, 100vw - 32px); max-width: min(360px, 92vw);
|
|
646
|
+
max-height: min(320px, 60vh); display: flex; flex-direction: column; padding: 4px;
|
|
647
|
+
border: 1px solid var(--dsw-alias-border-l1); border-radius: var(--dsw-radius-lg, 10px);
|
|
648
|
+
background: var(--dsw-alias-bg-module-platform, var(--dsw-alias-bg-layer-1));
|
|
649
|
+
box-shadow: var(--dsw-elevation-panel, var(--dsw-shadow-lv3));
|
|
650
|
+
color: var(--dsw-alias-label-primary); overflow: hidden; }
|
|
651
|
+
.dmp-search { flex: none; padding: 0 0 4px; }
|
|
652
|
+
.dmp-search input { width: 100%; box-sizing: border-box; padding: 5px 7px; font: inherit; font-size: 12.5px;
|
|
653
|
+
color: var(--dsw-alias-label-primary); background: transparent;
|
|
654
|
+
border: 1px solid var(--dsw-alias-border-l1); border-radius: var(--dsw-radius-md, 8px); outline: none; }
|
|
655
|
+
.dmp-search input:focus-visible { border-color: var(--dsw-alias-state-business-primary, var(--dsw-alias-brand-primary)); }
|
|
656
|
+
/* The scroll container.
|
|
657
|
+
*
|
|
658
|
+
* The scrollbar is NOT styled here. The host owns scrollbar appearance through two
|
|
659
|
+
* CSS variables that its own stylesheets consume, so setting them on the panel is
|
|
660
|
+
* the whole job: the shell then paints a thin, rounded, theme-aware bar that matches
|
|
661
|
+
* every other DSH popover. Writing -webkit-scrollbar rules as well would override
|
|
662
|
+
* the host's treatment and is what made this panel look different from the rest of
|
|
663
|
+
* the app.
|
|
664
|
+
*
|
|
665
|
+
* This mirrors the shipped picker and the thinking-slider plugin, which both do
|
|
666
|
+
* exactly this much. */
|
|
667
|
+
.dmp-list { flex: 1; overflow-y: auto; min-height: 0;
|
|
668
|
+
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
|
|
669
|
+
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); }
|
|
670
|
+
.dmp-group { padding: 6px 7px 2px; font-size: 11px; letter-spacing: .04em;
|
|
671
|
+
color: var(--dsw-alias-label-tertiary, var(--dsw-alias-label-secondary)); }
|
|
672
|
+
.dmp-item { display: flex; align-items: center; width: 100%; box-sizing: border-box;
|
|
673
|
+
min-height: 34px; padding: 5px 7px; border: 0; border-radius: var(--dsw-radius-md, 8px);
|
|
674
|
+
background: transparent; cursor: pointer; text-align: left;
|
|
606
675
|
color: var(--dsw-alias-label-primary); font: inherit; font-size: 12.5px; }
|
|
607
676
|
.dmp-item:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(127,127,127,.12)); }
|
|
608
677
|
.dmp-item-current { color: var(--dsw-alias-brand-primary); font-weight: 600; }
|
|
609
678
|
.dmp-item-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
610
|
-
.dmp-
|
|
611
|
-
color: var(--dsw-alias-label-secondary); }
|
|
612
|
-
/* The level list holds short labels, so it stays narrow and tight. */
|
|
613
|
-
.dmp-panel-narrow { width: min(220px, 60vw); max-height: 260px; }
|
|
614
|
-
.dmp-note { padding: 10px 12px; font-size: 12px; color: var(--dsw-alias-label-secondary); }
|
|
679
|
+
.dmp-note { padding: 8px 7px; font-size: 12px; line-height: 18px;
|
|
680
|
+
color: var(--dsw-alias-label-tertiary, var(--dsw-alias-label-secondary)); }
|
|
615
681
|
.dmp-note-error { color: var(--dsw-alias-state-error-primary); }
|
|
616
682
|
.dmp-retry { margin-top: 6px; padding: 4px 10px; border: 1px solid var(--dsw-alias-border-l2);
|
|
617
|
-
border-radius: 6px; background: transparent; color: inherit;
|
|
618
|
-
|
|
683
|
+
border-radius: var(--dsw-radius-sm, 6px); background: transparent; color: inherit;
|
|
684
|
+
font: inherit; font-size: 12px; cursor: pointer; }
|
|
685
|
+
|
|
686
|
+
/* The padding is what keeps the heading and the hint off the panel edge; the values
|
|
687
|
+
are generous because this panel has no menu rows to create natural breathing room. */
|
|
688
|
+
.dmp-panel-slider { width: max-content; box-sizing: border-box;
|
|
689
|
+
min-width: min(322px, 100vw - 32px); max-width: min(374px, 100vw - 32px);
|
|
690
|
+
padding: 17px 19px 15px; border-radius: var(--dsw-radius-lg, 12px); }
|
|
691
|
+
/* The type stack, in two parts.
|
|
692
|
+
*
|
|
693
|
+
* The level names are Latin ("Low", "Medium", "X-High") and get the host's own
|
|
694
|
+
* brand face, Montserrat, which the theme plugin already ships at 300/400/500 —
|
|
695
|
+
* so this costs no download and matches the host's own headings. The CJK copy
|
|
696
|
+
* ("思考强度", the hint line) has no glyphs in Montserrat, so the stack falls
|
|
697
|
+
* through to the platform UI face, which is what the host uses for body text.
|
|
698
|
+
*
|
|
699
|
+
* Nothing here sets a size: that stays with each element, so a host font-size
|
|
700
|
+
* change still reaches this control. The slider inherits this from the panel it is
|
|
701
|
+
* always rendered inside, so it is not named here. */
|
|
702
|
+
.dmp-panel {
|
|
703
|
+
font-family: Montserrat, var(--dsw-font-family-sans, system-ui, -apple-system,
|
|
704
|
+
'Segoe UI', 'Microsoft YaHei UI', 'Microsoft YaHei', sans-serif); }
|
|
705
|
+
.dmp-slider { display: flex; flex-direction: column; gap: 7px; min-width: 0; }
|
|
706
|
+
.dmp-slider-head { display: flex; align-items: center; gap: 14px; margin-bottom: 5px; }
|
|
707
|
+
/* The heading is vector art, not text: see CaptionMark.js for why. It only needs a
|
|
708
|
+
colour — which it takes from the theme token below through currentColor — and a
|
|
709
|
+
display box that sits it on the same line as the value pill. */
|
|
710
|
+
.dmp-slider-caption { display: flex; align-items: center;
|
|
711
|
+
color: var(--dsw-alias-label-primary); }
|
|
712
|
+
.dmp-caption-mark { display: block; width: auto; }
|
|
713
|
+
/* The Latin heading, used wherever the vector mark would be unreadable. It takes
|
|
714
|
+
the heading's own size so the two stay interchangeable on the same line. */
|
|
715
|
+
.dmp-caption-text { display: block; font-size: 13px; font-weight: 600; line-height: 16px;
|
|
716
|
+
letter-spacing: .01em; white-space: nowrap; }
|
|
717
|
+
/* The value pill is sized close to the heading beside it, so the two read as one
|
|
718
|
+
line: the earlier 19px box with 11.5px type made the current level look like a
|
|
719
|
+
footnote next to the title. 26px / 14px keeps the pair balanced and still fits
|
|
720
|
+
a long level name. */
|
|
721
|
+
.dmp-slider-value { margin-left: auto; display: inline-flex; align-items: center;
|
|
722
|
+
height: 22px; box-sizing: border-box; padding: 0 8px; border-radius: 8px;
|
|
723
|
+
font-size: 12px; line-height: 1; font-weight: 600; white-space: nowrap;
|
|
724
|
+
letter-spacing: .01em;
|
|
725
|
+
/* The pill is a label, not a rank meter: it must read the SAME at every level.
|
|
726
|
+
It used to take the level's ramp colour, so Low rendered dim grey and Max near
|
|
727
|
+
white — the same component looking like two different things depending on the
|
|
728
|
+
setting. The ramp colour stays on the track, where it encodes intensity. */
|
|
729
|
+
color: var(--dsw-alias-label-primary);
|
|
730
|
+
background: var(--dsw-alias-interactive-bg-hover); }
|
|
731
|
+
/* Reasoning effort: the slider.
|
|
732
|
+
---------------------------------------------------------------------------
|
|
733
|
+
A thick pill-shaped track with a solid accent fill and a disc riding on it. The
|
|
734
|
+
thick track is what makes the control legible at a glance — a hairline rail with
|
|
735
|
+
a small dot reads as a status indicator, not something you can drag.
|
|
736
|
+
|
|
737
|
+
The fill and the active label take the host's business-primary token — the same
|
|
738
|
+
accent DSH uses for its own interactive highlights — so the control recolours
|
|
739
|
+
with the theme instead of carrying a blue that only suits one of them. */
|
|
740
|
+
/* The knob is the rail's full height (28px). Both ends keep the knob clear of the
|
|
741
|
+
rail's cap, but by DIFFERENT amounts — that asymmetry is the requested behaviour,
|
|
742
|
+
not an oversight:
|
|
743
|
+
--dmp-stop-left 28px the first stop sits a full knob diameter in from the
|
|
744
|
+
left cap, so the knob stops well short of that end;
|
|
745
|
+
--dmp-stop-right 16px the last stop sits just past the knob's radius (14), so a
|
|
746
|
+
hairline of track shows beyond the knob at the top level.
|
|
747
|
+
The right gap is deliberately the same weight as the knob's own ring: at 14 the
|
|
748
|
+
knob landed exactly on the cap and the two outlines met, which read as a smudge
|
|
749
|
+
rather than as a stop. The dots, the labels, the fill and the native input's
|
|
750
|
+
travel all derive from these two numbers, which is why they are variables rather
|
|
751
|
+
than four copies of the same value. */
|
|
752
|
+
.dmp-slider { --dmp-rail-height: 28px; --dmp-thumb-size: 28px;
|
|
753
|
+
--dmp-stop-left: 28px; --dmp-stop-right: 16px; }
|
|
754
|
+
/* The accent.
|
|
755
|
+
The alias token is one step too pale on a dark panel: in the dark theme
|
|
756
|
+
--dsw-alias-state-business-primary resolves to the deepseek-400 step, which reads
|
|
757
|
+
as washed out against the panel surface. The ramp's next step down, deepseek-450,
|
|
758
|
+
is the same hue with the weight the bar needs. The alias token stays as the
|
|
759
|
+
fallback so a future ramp change still lands on something.
|
|
760
|
+
The preview pages carry these same values, which is why a preview can look right
|
|
761
|
+
while the app looks pale — the demo used to hard-code a blue of its own. */
|
|
762
|
+
.dmp-slider { --dmp-accent: var(--dsw-static-deepseek-450,
|
|
763
|
+
var(--dsw-alias-state-business-primary, var(--dsw-alias-brand-primary))); }
|
|
764
|
+
/* Radius is a LITERAL 15px on a 28px-tall rail — deliberately ONE more than half the
|
|
765
|
+
height, so the ends curve a touch harder than a true semicircle and read as round
|
|
766
|
+
rather than as pillowed caps. Anything below half looks square; this is the
|
|
767
|
+
user-tuned value.
|
|
768
|
+
The two numbers are tied: change the height, move the radius with it.
|
|
769
|
+
|
|
770
|
+
clip-path does the actual shaping. A border-radius of 16px on this 32px box was
|
|
771
|
+
measured painting an ~10px corner in the host, so the ends kept reading as a
|
|
772
|
+
rounded rectangle no matter what the declaration said. clip-path: inset(... round
|
|
773
|
+
17px) is a different property: it clips the element AND its children to the same
|
|
774
|
+
shape, so neither the track nor the fill can be squared off by whatever was
|
|
775
|
+
winning on border-radius. border-radius stays as the fallback. */
|
|
776
|
+
.dmp-slider-rail { position: relative; height: var(--dmp-rail-height);
|
|
777
|
+
margin: 12px 0 4px; border-radius: 15px; flex: none;
|
|
778
|
+
clip-path: inset(0 round 15px);
|
|
779
|
+
background: color-mix(in srgb, var(--dsw-alias-label-primary) 12%, transparent); }
|
|
780
|
+
/* The outline: vector, like the knob's ring.
|
|
781
|
+
An inset box-shadow is a raster edge — 1px reads flat and 1.5px falls between
|
|
782
|
+
pixels and smears. This SVG stretches to the rail with preserveAspectRatio none
|
|
783
|
+
and keeps its stroke at exactly 1px through vector-effect, so the line is as
|
|
784
|
+
crisp as the knob's circle. It sits above the fill so it stays visible across the
|
|
785
|
+
blue, and pointer-events none keeps the range input reachable underneath. */
|
|
786
|
+
.dmp-slider-outline { position: absolute; inset: 0; width: 100%; height: 100%;
|
|
787
|
+
pointer-events: none; z-index: 7; overflow: visible; }
|
|
788
|
+
/* The stop axis. Its ends are the two stop offsets, which is why 0% is the first
|
|
789
|
+
stop and 100% the last — the knob's travel, the dots, the labels and the fill all
|
|
790
|
+
read from this one box. */
|
|
791
|
+
.dmp-slider-track { position: absolute; top: 0; bottom: 0;
|
|
792
|
+
left: var(--dmp-stop-left); right: var(--dmp-stop-right); }
|
|
793
|
+
/* The fill starts at the RAIL's left edge and ends on the knob's centre line.
|
|
794
|
+
|
|
795
|
+
Starting it at the stop axis left a grey stub at the left cap, and the rail's
|
|
796
|
+
clip gives that end its shape, so the run is pulled back to the rail's edge.
|
|
797
|
+
|
|
798
|
+
It carries NO radius of its own: it is a plain rectangle clipped by the rail,
|
|
799
|
+
which is what makes its left cap identical to the track's. */
|
|
800
|
+
.dmp-slider-fill { position: absolute; top: 0; height: var(--dmp-rail-height);
|
|
801
|
+
left: calc(var(--dmp-stop-left) * -1);
|
|
802
|
+
width: calc(var(--dmp-effort-at) + var(--dmp-stop-left));
|
|
803
|
+
background: var(--dmp-accent);
|
|
804
|
+
z-index: 1; transition: width .15s ease-out; pointer-events: none; }
|
|
805
|
+
.dmp-slider-dots { position: absolute; top: 0; bottom: 0; left: 0; right: 0;
|
|
806
|
+
pointer-events: none; z-index: 2; }
|
|
807
|
+
/* Stops stay SMALL and GREY, and they are only visible AHEAD of the knob: the blue
|
|
808
|
+
fill already says "you are this far", so a dot sitting on it read as dust on the
|
|
809
|
+
colour. Unreached stops keep working as tick marks on the empty part of the track,
|
|
810
|
+
which is where they still carry information. */
|
|
811
|
+
.dmp-slider-dot { position: absolute; top: 50%; width: 5px; height: 5px;
|
|
812
|
+
border-radius: 50%; transform: translate(-50%, -50%);
|
|
813
|
+
background: var(--dsw-alias-label-tertiary, currentColor); opacity: .55;
|
|
814
|
+
transition: opacity .15s ease-out; }
|
|
815
|
+
.dmp-slider-dot-lit { opacity: 0; }
|
|
816
|
+
/* The exhaust: a zero-size point pinned to the knob, carrying the particles.
|
|
817
|
+
It sits ABOVE the fill and BELOW the knob, so squares are born behind the disc and
|
|
818
|
+
drift out from under it. Its left offset carries the SAME transition as the knob — that
|
|
819
|
+
is the whole alignment contract: both are driven by --dmp-effort-at, so as long as
|
|
820
|
+
they animate with the same duration and easing the spawn point stays under the
|
|
821
|
+
knob while it travels. (A drag drops the transition on both; see the dragging
|
|
822
|
+
rule below, which is what fixed particles appearing ahead of the knob.) */
|
|
823
|
+
.dmp-emit { position: absolute; top: 50%; left: var(--dmp-effort-at);
|
|
824
|
+
width: 0; height: 0; z-index: 3; pointer-events: none;
|
|
825
|
+
transition: left .15s ease-out; }
|
|
826
|
+
/* One square. The dy0 offset spreads the spawn points down the knob cross-section; the
|
|
827
|
+
extra dy/dx send each one its own way so they do not travel as a block. */
|
|
828
|
+
.dmp-emit-p { position: absolute; left: 0; top: 0;
|
|
829
|
+
width: var(--sz, 4px); height: var(--sz, 4px); border-radius: 1px;
|
|
830
|
+
background: color-mix(in srgb, var(--dsw-alias-label-primary) 92%, transparent);
|
|
831
|
+
box-shadow: 0 0 4px color-mix(in srgb,
|
|
832
|
+
var(--dmp-accent) 80%, transparent);
|
|
833
|
+
opacity: 0; transform: translate(-50%, -50%);
|
|
834
|
+
animation: dmp-emit 1.7s cubic-bezier(.25,.1,.6,1) infinite;
|
|
835
|
+
animation-delay: calc(var(--i) * -106ms); }
|
|
836
|
+
/* The streak left behind the sheet of squares. */
|
|
837
|
+
.dmp-emit::before { content: ''; position: absolute; right: 4px; top: 50%;
|
|
838
|
+
width: 54px; height: 8px; transform: translateY(-50%); border-radius: 6px;
|
|
839
|
+
background: linear-gradient(270deg,
|
|
840
|
+
color-mix(in srgb, var(--dsw-alias-label-primary) 30%, transparent),
|
|
841
|
+
color-mix(in srgb, var(--dsw-alias-label-primary) 8%, transparent) 45%,
|
|
842
|
+
transparent);
|
|
843
|
+
filter: blur(2px); animation: dmp-emit-trail 1.7s ease-in-out infinite; }
|
|
844
|
+
@keyframes dmp-emit {
|
|
845
|
+
0% { transform: translate(-50%, -50%) translate(0, var(--dy0)) scale(1); opacity: 0; }
|
|
846
|
+
14% { opacity: .95; }
|
|
847
|
+
70% { opacity: .5; }
|
|
848
|
+
100% { transform: translate(-50%, -50%)
|
|
849
|
+
translate(var(--dx), calc(var(--dy0) + var(--dy))) scale(.2) rotate(var(--rot)); opacity: 0; } }
|
|
850
|
+
@keyframes dmp-emit-trail { 0%, 100% { opacity: .3; } 50% { opacity: .75; } }
|
|
851
|
+
/* A drag is direct manipulation: the knob, the fill and the emitter all snap to the
|
|
852
|
+
pointer instead of easing, which keeps the particles born exactly at the knob. */
|
|
853
|
+
.dmp-slider-dragging .dmp-slider-thumb,
|
|
854
|
+
.dmp-slider-dragging .dmp-slider-fill,
|
|
855
|
+
.dmp-slider-dragging .dmp-emit { transition: none; }
|
|
856
|
+
/* The knob fills the rail's height and is drawn as an SVG, so no inherited
|
|
857
|
+
border-radius, padding or box-sizing rule can square it off. Its size comes from
|
|
858
|
+
--dmp-thumb-size; the stop offsets and the input's travel are derived from the
|
|
859
|
+
same variable so the three cannot drift apart. */
|
|
860
|
+
.dmp-slider-thumb { position: absolute; top: 50%; left: var(--dmp-effort-at);
|
|
861
|
+
width: var(--dmp-thumb-size); height: var(--dmp-thumb-size);
|
|
862
|
+
margin-left: calc(var(--dmp-thumb-size) / -2);
|
|
863
|
+
margin-top: calc(var(--dmp-thumb-size) / -2);
|
|
864
|
+
display: block; overflow: visible; pointer-events: none; z-index: 5;
|
|
865
|
+
/* The knob takes the panel's grey in dark and pure white in light, with a black
|
|
866
|
+
ring in both. The two values swing with the host stylesheet's own media query,
|
|
867
|
+
so no JS theme detection is involved. */
|
|
868
|
+
--dmp-thumb-fill: var(--dsw-alias-bg-module-platform);
|
|
869
|
+
--dmp-thumb-ring: #000;
|
|
870
|
+
filter: drop-shadow(0 2px 4px color-mix(in srgb, var(--dsw-alias-label-primary) 28%, transparent));
|
|
871
|
+
transition: left .15s ease-out, transform .12s ease; }
|
|
872
|
+
.dmp-slider-rail:active .dmp-slider-thumb { transform: scale(1.06); }
|
|
873
|
+
.dmp-slider-rail:has(.dmp-slider-input:focus-visible) .dmp-slider-thumb {
|
|
874
|
+
box-shadow: 0 0 0 3px color-mix(in srgb,
|
|
875
|
+
var(--dmp-accent) 35%, transparent); }
|
|
876
|
+
/* The range input spans from the first stop's reach to the rail's right edge, and
|
|
877
|
+
its native thumb is the knob's size. That makes its invisible thumb's centre
|
|
878
|
+
travel exactly from --dmp-stop-left to (width - stop-right) — the same axis the
|
|
879
|
+
knob is drawn on. Spanning the full rail instead would start the travel one
|
|
880
|
+
radius short of the first stop, and the pointer would disagree with the knob. */
|
|
881
|
+
.dmp-slider-input { position: absolute; z-index: 6; top: 0;
|
|
882
|
+
left: calc(var(--dmp-stop-left) - var(--dmp-thumb-size) / 2);
|
|
883
|
+
width: calc(100% - var(--dmp-stop-left) + var(--dmp-thumb-size) / 2);
|
|
884
|
+
height: var(--dmp-rail-height); margin: 0; cursor: pointer;
|
|
885
|
+
-webkit-appearance: none; appearance: none; background: transparent;
|
|
886
|
+
/* Two-level invisibility: opacity alone leaves the native thumb able to paint
|
|
887
|
+
through on some engines, which reads as a stray rectangle riding the track. */
|
|
888
|
+
opacity: 0; color: transparent; border: 0; }
|
|
889
|
+
.dmp-slider-input::-webkit-slider-runnable-track { background: transparent; border: 0; }
|
|
890
|
+
/* The native thumb is sized to the painted one so pointer travel matches, then made
|
|
891
|
+
fully transparent: leaving it visible is what produced a square riding the track. */
|
|
892
|
+
.dmp-slider-input::-webkit-slider-thumb { -webkit-appearance: none; appearance: none;
|
|
893
|
+
width: var(--dmp-thumb-size); height: var(--dmp-thumb-size);
|
|
894
|
+
background: transparent; border: 0; }
|
|
895
|
+
.dmp-slider-input::-moz-range-thumb { width: var(--dmp-thumb-size); height: var(--dmp-thumb-size); border: 0; }
|
|
896
|
+
.dmp-slider-input:disabled { cursor: default; }
|
|
897
|
+
.dmp-slider[aria-disabled="true"] { opacity: .5; }
|
|
898
|
+
/* The legend shares the track's inset so a label sits under the stop it names. */
|
|
899
|
+
.dmp-slider-legend { position: relative; height: 16px;
|
|
900
|
+
margin: 0 var(--dmp-stop-right) 0 var(--dmp-stop-left); }
|
|
901
|
+
/* The level labels are Latin, so they take Montserrat explicitly — inheriting from
|
|
902
|
+
the panel would pull the platform face back in. */
|
|
903
|
+
.dmp-slider-tick { position: absolute; top: 0; transform: translateX(-50%);
|
|
904
|
+
padding: 1px 4px; border: 0; border-radius: 6px; background: transparent;
|
|
905
|
+
/* One brightness for every unselected label: the tertiary token sat too close to
|
|
906
|
+
the panel background, so the secondary token lifts all of them equally. */
|
|
907
|
+
color: var(--dsw-alias-label-secondary);
|
|
908
|
+
/* 12px, up from 11px: at 11 the stop names sat visibly below the heading's weight
|
|
909
|
+
and were hard to hit by eye under a 32px rail. */
|
|
910
|
+
font-size: 11px; line-height: 15px;
|
|
911
|
+
font-family: Montserrat, var(--dsw-font-family-sans, system-ui, sans-serif);
|
|
912
|
+
white-space: nowrap; cursor: pointer;
|
|
913
|
+
max-width: 45%; overflow: hidden; text-overflow: ellipsis; }
|
|
914
|
+
/* Every label is centred on its stop.
|
|
915
|
+
The two end labels used to be re-anchored instead (no transform at the start, a full
|
|
916
|
+
translate at the end) so their text could not overflow the legend. That kept them
|
|
917
|
+
inside the box, but it also pulled them off their dots: the last label sat entirely
|
|
918
|
+
to the LEFT of its stop, which is exactly what makes "Max" look misaligned against
|
|
919
|
+
the dot above it. The legend already spans the stop axis and the labels are short,
|
|
920
|
+
so plain centring fits — and every label now shares one rule. */
|
|
921
|
+
.dmp-slider-tick[data-edge="start"] { transform: translateX(-50%); margin-left: 0; }
|
|
922
|
+
.dmp-slider-tick[data-edge="end"] { transform: translateX(-50%); margin-left: 0; }
|
|
923
|
+
.dmp-slider-tick:hover:not(:disabled) { color: var(--dsw-alias-label-secondary);
|
|
924
|
+
background: var(--dsw-alias-interactive-bg-hover); }
|
|
925
|
+
.dmp-slider-tick:focus-visible { outline: 2px solid var(--dsw-alias-border-l3); outline-offset: -2px; }
|
|
926
|
+
.dmp-slider-tick:disabled { cursor: default; }
|
|
927
|
+
/* The active label is marked by COLOUR only, not weight.
|
|
928
|
+
Bolding it made the selected label measurably wider and taller-looking than its
|
|
929
|
+
neighbours, so the labels appeared to change size as the level moved. Keeping one
|
|
930
|
+
weight keeps every label the same box; the accent colour carries the selection. */
|
|
931
|
+
.dmp-slider-tick-active { color: var(--dmp-accent); }
|
|
932
|
+
.dmp-slider-hint { margin: 5px 0 0; padding-top: 8px;
|
|
933
|
+
border-top: 1px solid var(--dsw-alias-border-l1);
|
|
934
|
+
font-size: 11px; line-height: 15px; color: var(--dsw-alias-label-secondary); }
|
|
935
|
+
/* Light surfaces need a softer knob: a black ring reads as a hard edge on a
|
|
936
|
+
near-white panel, so the light theme pulls the ring to the host's border colour
|
|
937
|
+
and keeps the fill pure white. The two values swing with the host stylesheet's
|
|
938
|
+
own media query, so no JS theme detection is involved. */
|
|
939
|
+
@media (prefers-color-scheme: light) {
|
|
940
|
+
.dmp-slider-thumb {
|
|
941
|
+
--dmp-thumb-fill: var(--dsw-alias-bg-base);
|
|
942
|
+
--dmp-thumb-ring: var(--dsw-alias-border-l2);
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
@media (prefers-reduced-motion: reduce) {
|
|
946
|
+
.dmp-slider-fill, .dmp-slider-thumb { transition: none; }
|
|
947
|
+
/* The exhaust is decoration: with motion turned down it is simply not drawn. */
|
|
948
|
+
.dmp-emit { display: none; }
|
|
949
|
+
}
|
|
950
|
+
/* ---------------------------------------------------------------------------
|
|
951
|
+
Rejected-selection notice
|
|
952
|
+
---------------------------------------------------------------------------
|
|
953
|
+
A failed switch is reported ABOVE the panels, never inside them: the list and
|
|
954
|
+
the slider are for choosing, and a raw "code: message" dumped among the rows
|
|
955
|
+
both crowds the list and scrolls away. Collapsed it is one quiet line; the
|
|
956
|
+
detail is opt-in.
|
|
957
|
+
|
|
958
|
+
It is absolutely positioned above the row so it takes no width from the
|
|
959
|
+
composer, and --dmp-notice-lift (measured in ModelPicker) is what raises an
|
|
960
|
+
open panel over its measured height instead of on top of it. */
|
|
961
|
+
.dmp-notice { position: absolute; bottom: calc(100% + 6px); left: 0; right: auto;
|
|
962
|
+
z-index: 1100; display: flex; flex-direction: column; align-items: flex-start;
|
|
963
|
+
width: max-content; max-width: min(360px, 92vw); box-sizing: border-box;
|
|
964
|
+
border: 1px solid var(--dsw-alias-border-l1); border-radius: var(--dsw-radius-md, 8px);
|
|
965
|
+
background: var(--dsw-alias-bg-module-platform, var(--dsw-alias-bg-layer-1));
|
|
966
|
+
box-shadow: var(--dsw-elevation-panel, var(--dsw-shadow-lv2));
|
|
967
|
+
overflow: hidden; }
|
|
968
|
+
.dmp-notice-toggle { display: flex; align-items: center; gap: 6px; width: 100%;
|
|
969
|
+
box-sizing: border-box; padding: 6px 8px; border: 0; background: transparent;
|
|
970
|
+
color: var(--dsw-alias-state-error-primary); font: inherit; font-size: 12px;
|
|
971
|
+
line-height: 18px; text-align: left; cursor: pointer; }
|
|
972
|
+
.dmp-notice-toggle:hover { background: var(--dsw-alias-interactive-bg-hover); }
|
|
973
|
+
.dmp-notice-toggle:focus-visible { outline: 2px solid var(--dsw-alias-border-l2); outline-offset: -2px; }
|
|
974
|
+
.dmp-notice-toggle .dmp-chevron { margin-left: auto; }
|
|
975
|
+
.dmp-notice-text { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
976
|
+
/* The raw provider error: monospaced so a "code: message" pair is readable, and
|
|
977
|
+
selectable so it can be copied into a bug report. */
|
|
978
|
+
.dmp-notice-detail { width: 100%; box-sizing: border-box; padding: 6px 8px;
|
|
979
|
+
border-top: 1px solid var(--dsw-alias-border-l1);
|
|
980
|
+
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
|
|
981
|
+
font-size: 11.5px; line-height: 16px; white-space: pre-wrap; word-break: break-word;
|
|
982
|
+
color: var(--dsw-alias-label-secondary); user-select: text; }`
|
|
619
983
|
|
|
620
984
|
//# module: src/ui/dict.js
|
|
621
985
|
/**
|
|
@@ -624,35 +988,36 @@ const CSS = `
|
|
|
624
988
|
* Kept as a plain object rather than the Client locale service because the
|
|
625
989
|
* strings are this component's own copy, not a namespace other plugins
|
|
626
990
|
* translate. Adding a language is one more entry here.
|
|
991
|
+
*
|
|
992
|
+
* Only keys the component actually reads belong here: an entry nobody looks up
|
|
993
|
+
* is a string that silently rots, and a reviewer cannot tell it is dead.
|
|
627
994
|
*/
|
|
628
995
|
const DICT = {
|
|
629
996
|
'zh-CN': {
|
|
630
997
|
placeholder: '搜索模型',
|
|
631
998
|
empty: '没有匹配的模型',
|
|
632
999
|
loading: '正在读取模型列表…',
|
|
633
|
-
failed: '读取模型列表失败',
|
|
634
1000
|
retry: '重试',
|
|
635
1001
|
noCatalog: '当前没有可用模型',
|
|
636
1002
|
trigger: (name) => `${name}(点此切换模型)`,
|
|
637
1003
|
effortTitle: (name) => `思考档位:${name}(点此调整)`,
|
|
1004
|
+
effortHeading: '思考强度',
|
|
1005
|
+
effortHint: '更高的强度通常需要更多思考时间。',
|
|
638
1006
|
switching: '切换中…',
|
|
639
|
-
switchFailed: '切换失败'
|
|
640
|
-
providerDefault: '默认',
|
|
641
|
-
groupFailed: (name) => `${name} 加载失败`
|
|
1007
|
+
switchFailed: '切换失败'
|
|
642
1008
|
},
|
|
643
1009
|
en: {
|
|
644
1010
|
placeholder: 'Search models',
|
|
645
1011
|
empty: 'No matching model',
|
|
646
1012
|
loading: 'Loading models…',
|
|
647
|
-
failed: 'Could not load the model list',
|
|
648
1013
|
retry: 'Retry',
|
|
649
1014
|
noCatalog: 'No model is currently available',
|
|
650
1015
|
trigger: (name) => `${name} (click to switch model)`,
|
|
651
1016
|
effortTitle: (name) => `Reasoning level: ${name} (click to change)`,
|
|
1017
|
+
effortHeading: 'Reasoning effort',
|
|
1018
|
+
effortHint: 'Higher effort usually takes more time to think.',
|
|
652
1019
|
switching: 'Switching…',
|
|
653
|
-
switchFailed: 'Switch failed'
|
|
654
|
-
providerDefault: 'Default',
|
|
655
|
-
groupFailed: (name) => `${name} failed to load`
|
|
1020
|
+
switchFailed: 'Switch failed'
|
|
656
1021
|
}
|
|
657
1022
|
}
|
|
658
1023
|
|
|
@@ -671,7 +1036,27 @@ function dictionaryFor(localeId) {
|
|
|
671
1036
|
|
|
672
1037
|
|
|
673
1038
|
|
|
674
|
-
|
|
1039
|
+
|
|
1040
|
+
|
|
1041
|
+
/**
|
|
1042
|
+
* Turn a failed `select` result into the two strings the notice shows.
|
|
1043
|
+
*
|
|
1044
|
+
* `ModelDirectory.select` resolves to `{ ok: false, error: { code, message } }`
|
|
1045
|
+
* on a rejected selection. The summary is the short, always-visible line; the
|
|
1046
|
+
* detail is what expanding the notice reveals, and it is the only place the
|
|
1047
|
+
* provider's raw error text appears — the panels themselves stay free of it.
|
|
1048
|
+
*
|
|
1049
|
+
* @param result - the value `select` resolved to, possibly undefined.
|
|
1050
|
+
* @param t - the active dictionary.
|
|
1051
|
+
* @returns `{ summary, detail }`.
|
|
1052
|
+
*/
|
|
1053
|
+
function failureOf(result, t) {
|
|
1054
|
+
const error = result?.error
|
|
1055
|
+
if (error === undefined || error === null) return { summary: t.switchFailed, detail: '' }
|
|
1056
|
+
const code = typeof error.code === 'string' ? error.code : ''
|
|
1057
|
+
const message = typeof error.message === 'string' ? error.message : String(error)
|
|
1058
|
+
return { summary: t.switchFailed, detail: code.length > 0 ? `${code}: ${message}` : message }
|
|
1059
|
+
}
|
|
675
1060
|
|
|
676
1061
|
/**
|
|
677
1062
|
* The composer's model seat: a searchable, provider-grouped picker plus a
|
|
@@ -681,59 +1066,114 @@ const LABEL_SLOT = 'conversation.input.model.label'
|
|
|
681
1066
|
* `current` (the effective selection, projection or default), `routable`
|
|
682
1067
|
* (whether the Host would still accept it), `pending` (the selection being
|
|
683
1068
|
* settled), `failures` (provider groups that failed to load) and `error`.
|
|
684
|
-
* Deriving those in the component is what made an in-flight switch invisible and
|
|
685
|
-
* a broken provider group silently absent.
|
|
686
1069
|
*
|
|
687
|
-
* @param
|
|
688
|
-
*
|
|
689
|
-
* @returns the rendered control, or null when the session cannot change models.
|
|
1070
|
+
* @param React - the React runtime.
|
|
1071
|
+
* @returns the slot component.
|
|
690
1072
|
*/
|
|
691
1073
|
function createModelPicker(React) {
|
|
692
1074
|
const h = React.createElement
|
|
1075
|
+
const EffortSlider = createEffortSlider(React)
|
|
693
1076
|
const { useCallback, useEffect, useMemo, useRef, useState } = React
|
|
694
1077
|
|
|
1078
|
+
/**
|
|
1079
|
+
* Subscribe to any `{ getSnapshot, subscribe }` source.
|
|
1080
|
+
*
|
|
1081
|
+
* Both sources this component reads — the session directory and the locale
|
|
1082
|
+
* service — expose exactly that pair, so one hook covers them. It lives here
|
|
1083
|
+
* rather than in a shared module because it needs the component's own hooks,
|
|
1084
|
+
* which are bound to this factory's React instance.
|
|
1085
|
+
*
|
|
1086
|
+
* @param source - the observable source, or undefined while it is not resolved.
|
|
1087
|
+
* @returns the current snapshot.
|
|
1088
|
+
*/
|
|
1089
|
+
const useSnapshot = (source) => {
|
|
1090
|
+
const [value, setValue] = useState(() => source?.getSnapshot?.())
|
|
1091
|
+
useEffect(() => {
|
|
1092
|
+
if (source === undefined || source === null) return undefined
|
|
1093
|
+
const update = () => setValue(source.getSnapshot())
|
|
1094
|
+
update()
|
|
1095
|
+
return source.subscribe(update)
|
|
1096
|
+
}, [source])
|
|
1097
|
+
return value
|
|
1098
|
+
}
|
|
1099
|
+
|
|
695
1100
|
return function ModelPicker(props) {
|
|
696
|
-
|
|
1101
|
+
/**
|
|
1102
|
+
* The props bundle the shipped registration hands its own component.
|
|
1103
|
+
*
|
|
1104
|
+
* `directory` is the session directory's STORE (already subscribed-ready),
|
|
1105
|
+
* `load` asks it to refresh, `select` submits a triple, and `available` says
|
|
1106
|
+
* whether this session may change models at all. Nothing here reaches behind
|
|
1107
|
+
* that bundle: the data layer is the shipped one, this file is the view.
|
|
1108
|
+
*/
|
|
1109
|
+
const { locked, directory, available, load, select: submit } = props
|
|
697
1110
|
|
|
698
|
-
|
|
1111
|
+
/**
|
|
1112
|
+
* The active language, followed live.
|
|
1113
|
+
*
|
|
1114
|
+
* `localeSource` is the locale service itself (`getSnapshot` / `subscribe`),
|
|
1115
|
+
* so a language switch made while the composer is on screen re-renders with
|
|
1116
|
+
* the new dictionary. `localeId` is the value resolved when the seat was
|
|
1117
|
+
* registered, used only until the first snapshot arrives.
|
|
1118
|
+
*/
|
|
1119
|
+
const localeState = useSnapshot(props.localeSource)
|
|
1120
|
+
const localeId = typeof localeState?.active === 'string' && localeState.active.length > 0
|
|
1121
|
+
? localeState.active
|
|
1122
|
+
: typeof props.localeId === 'string' ? props.localeId : 'zh-CN'
|
|
699
1123
|
const t = dictionaryFor(localeId)
|
|
1124
|
+
// The panel heading is vector art drawn from CJK outlines, so it only reads
|
|
1125
|
+
// as words in Chinese; every other language prints the plain string.
|
|
1126
|
+
const glyphCaption = String(localeId).toLowerCase().startsWith('zh')
|
|
700
1127
|
|
|
701
1128
|
// One panel at a time: the model list and the effort list are separate
|
|
702
1129
|
// controls, and opening either closes the other.
|
|
703
1130
|
const [panel, setPanel] = useState(null)
|
|
704
1131
|
const [query, setQuery] = useState('')
|
|
1132
|
+
// The level the thumb should sit on right now, before the Host confirms.
|
|
1133
|
+
// Null means "follow the directory".
|
|
1134
|
+
const [draftEffort, setDraftEffort] = useState(null)
|
|
1135
|
+
// A rejected selection. Kept out of the panels on purpose: the notice above
|
|
1136
|
+
// them is collapsed until the user asks for the raw error.
|
|
1137
|
+
const [failure, setFailure] = useState(null)
|
|
1138
|
+
const [noticeOpen, setNoticeOpen] = useState(false)
|
|
1139
|
+
const [noticeLift, setNoticeLift] = useState(0)
|
|
705
1140
|
const rootRef = useRef(null)
|
|
1141
|
+
const noticeRef = useRef(null)
|
|
1142
|
+
// Whether the most recent action was a load or a selection. `state.error`
|
|
1143
|
+
// carries both, and only a LOAD failure belongs inside the model panel.
|
|
1144
|
+
const lastActionRef = useRef('load')
|
|
706
1145
|
|
|
707
|
-
const state =
|
|
708
|
-
// `catalogFace` is the catalogue controller, not a store: its snapshot source
|
|
709
|
-
// is `store`. Passing the controller itself made the hook call a missing
|
|
710
|
-
// `getSnapshot` on first render.
|
|
711
|
-
const catalogState = readSnapshot(useState, useEffect, catalogFace?.store)
|
|
1146
|
+
const state = useSnapshot(directory) ?? {}
|
|
712
1147
|
const groups = Array.isArray(state.groups) ? state.groups : []
|
|
713
1148
|
const failures = Array.isArray(state.failures) ? state.failures : []
|
|
714
1149
|
const selection = state.current ?? null
|
|
715
1150
|
const pending = state.pending ?? null
|
|
716
|
-
|
|
1151
|
+
/**
|
|
1152
|
+
* Distinguish a MODEL change from a LEVEL change.
|
|
1153
|
+
*
|
|
1154
|
+
* The directory reports one `selecting` status for both, and `pending` holds
|
|
1155
|
+
* whichever triple is in flight. They are not the same event to the user: a
|
|
1156
|
+
* level change is a dial, dragged back and forth, and each stop submits. So a
|
|
1157
|
+
* pending selection is treated as a model change ONLY when it names a
|
|
1158
|
+
* different model than the one in effect — otherwise every slider stop made
|
|
1159
|
+
* the model trigger print "Switching…" and go dim, which read as the model
|
|
1160
|
+
* button flickering on its own.
|
|
1161
|
+
*/
|
|
1162
|
+
const modelKeyOf = (value) => value === null ? null : `${value.provider ?? ''}:${value.model ?? ''}`
|
|
1163
|
+
const swappingModel = pending !== null && modelKeyOf(pending) !== modelKeyOf(selection)
|
|
1164
|
+
const busy = swappingModel
|
|
1165
|
+
|
|
1166
|
+
const doLoad = useCallback(() => {
|
|
1167
|
+
lastActionRef.current = 'load'
|
|
1168
|
+
// Ask the directory to load. It is idempotent: the shipped controller
|
|
1169
|
+
// shares one in-flight request and re-projects when it settles.
|
|
1170
|
+
if (typeof load === 'function') load()
|
|
1171
|
+
}, [load])
|
|
717
1172
|
|
|
718
1173
|
useEffect(() => {
|
|
719
|
-
|
|
720
|
-
* Load the directory, not just a catalogue.
|
|
721
|
-
*
|
|
722
|
-
* In reuse mode `directory` is the shipped one and `catalogFace` is this
|
|
723
|
-
* plugin's own catalogue — loading the latter alone left the shipped
|
|
724
|
-
* directory's catalogue untouched, so its `groups` never advanced past
|
|
725
|
-
* whatever it held when the seat first mounted. `directory.load()` asks the
|
|
726
|
-
* object that actually feeds `state.groups`; its own `load` is idempotent
|
|
727
|
-
* and internally refreshes the catalogue it reads.
|
|
728
|
-
*/
|
|
729
|
-
if (typeof directory?.load === 'function') {
|
|
730
|
-
const outcome = directory.load()
|
|
731
|
-
if (outcome !== undefined && typeof outcome.then === 'function') outcome.catch(() => {})
|
|
732
|
-
} else if (catalogFace !== undefined && catalogFace !== null) {
|
|
733
|
-
catalogFace.load()
|
|
734
|
-
}
|
|
1174
|
+
doLoad()
|
|
735
1175
|
return undefined
|
|
736
|
-
}, [
|
|
1176
|
+
}, [doLoad])
|
|
737
1177
|
|
|
738
1178
|
useEffect(() => {
|
|
739
1179
|
if (panel === null) return undefined
|
|
@@ -749,38 +1189,116 @@ function createModelPicker(React) {
|
|
|
749
1189
|
}
|
|
750
1190
|
}, [panel])
|
|
751
1191
|
|
|
1192
|
+
// Closing a panel also clears the transient state it carried: the search
|
|
1193
|
+
// text (so reopening shows the full list) and the failure notice.
|
|
1194
|
+
useEffect(() => {
|
|
1195
|
+
if (panel !== null) return
|
|
1196
|
+
setQuery('')
|
|
1197
|
+
setFailure(null)
|
|
1198
|
+
setNoticeOpen(false)
|
|
1199
|
+
}, [panel])
|
|
1200
|
+
|
|
1201
|
+
/**
|
|
1202
|
+
* Push a failure into the notice and collapse it again.
|
|
1203
|
+
*
|
|
1204
|
+
* @param result - the failed `select` result.
|
|
1205
|
+
*/
|
|
1206
|
+
const showFailure = useCallback((result) => {
|
|
1207
|
+
setFailure(failureOf(result, t))
|
|
1208
|
+
setNoticeOpen(false)
|
|
1209
|
+
}, [t])
|
|
1210
|
+
|
|
752
1211
|
/**
|
|
753
1212
|
* Choose a model, carrying the adapter's default effort when the caller did
|
|
754
|
-
* not name one — the shipped picker sends the same triple
|
|
755
|
-
*
|
|
1213
|
+
* not name one — the shipped picker sends the same triple.
|
|
1214
|
+
*
|
|
1215
|
+
* Returns the directory's own result unchanged: `{ ok: true }` or
|
|
1216
|
+
* `{ ok: false, error }`. Anything else would make a successful pick look
|
|
1217
|
+
* like a failure and leave the panel open.
|
|
756
1218
|
*/
|
|
757
1219
|
const select = useCallback(async (group, model, reasoningEffort) => {
|
|
758
|
-
if (
|
|
1220
|
+
if (typeof submit !== 'function') return undefined
|
|
1221
|
+
lastActionRef.current = 'select'
|
|
759
1222
|
const effort = reasoningEffort ?? model?.reasoning?.defaultEffort
|
|
760
|
-
|
|
1223
|
+
return submit({
|
|
761
1224
|
provider: group.id,
|
|
762
1225
|
model: model.id,
|
|
763
1226
|
...effort === undefined ? {} : { reasoningEffort: effort }
|
|
764
1227
|
})
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
1228
|
+
}, [submit])
|
|
1229
|
+
|
|
1230
|
+
/**
|
|
1231
|
+
* Choosing a MODEL closes the panel on success.
|
|
1232
|
+
*
|
|
1233
|
+
* A rejected selection keeps it open and raises the notice instead, so the
|
|
1234
|
+
* user can pick another model without reopening the list.
|
|
1235
|
+
*/
|
|
1236
|
+
const chooseModel = useCallback(async (group, model, reasoningEffort) => {
|
|
1237
|
+
const result = await select(group, model, reasoningEffort)
|
|
1238
|
+
if (result?.ok === true) {
|
|
1239
|
+
setFailure(null)
|
|
1240
|
+
setPanel(null)
|
|
1241
|
+
return
|
|
1242
|
+
}
|
|
1243
|
+
showFailure(result)
|
|
1244
|
+
}, [select, showFailure])
|
|
1245
|
+
|
|
1246
|
+
const queryText = query.trim()
|
|
1247
|
+
/**
|
|
1248
|
+
* The model list, ranked exactly like the shipped menus.
|
|
1249
|
+
*
|
|
1250
|
+
* `rankByName` is the host's own fuzzy matcher: an ordered subsequence of
|
|
1251
|
+
* the name (or of the model id, supplied as the second search key), prefix
|
|
1252
|
+
* hits first. A plain substring test could not find `gpt61` in
|
|
1253
|
+
* `GPT-6.1-Sol`, which is most of the point of having a search box.
|
|
1254
|
+
*/
|
|
1255
|
+
const filtered = useMemo(() => groups
|
|
1256
|
+
.map((group) => ({
|
|
1257
|
+
group,
|
|
1258
|
+
models: rankByName(
|
|
1259
|
+
(group.models ?? []).map((model) => ({
|
|
1260
|
+
...model,
|
|
1261
|
+
label: typeof model.label === 'string' ? model.label : model.id
|
|
1262
|
+
})),
|
|
1263
|
+
queryText
|
|
1264
|
+
)
|
|
1265
|
+
}))
|
|
1266
|
+
.filter((entry) => entry.models.length > 0), [groups, queryText])
|
|
778
1267
|
|
|
779
1268
|
const current = selection === null ? null : groups
|
|
780
1269
|
.find((group) => group.id === selection.provider)?.models
|
|
781
1270
|
?.find((model) => model.id === selection.model)
|
|
782
1271
|
const currentGroup = selection === null ? null : groups.find((group) => group.id === selection.provider) ?? null
|
|
783
1272
|
|
|
1273
|
+
/**
|
|
1274
|
+
* A level change keeps the panel open, and moves the thumb IMMEDIATELY.
|
|
1275
|
+
*
|
|
1276
|
+
* Two things were wrong before:
|
|
1277
|
+
*
|
|
1278
|
+
* - Closing the panel on success made it vanish mid-drag, so the animation
|
|
1279
|
+
* never played — a level is a dial, not a commitment.
|
|
1280
|
+
* - The thumb was positioned from the directory's `current`, which only
|
|
1281
|
+
* updates after the Host answers. During a drag that means the thumb is
|
|
1282
|
+
* dragged back to the old stop on every render — it looks stuck to the
|
|
1283
|
+
* start and refuses to follow the pointer.
|
|
1284
|
+
*
|
|
1285
|
+
* `draftEffort` is the optimistic stop: set it on every move so the thumb
|
|
1286
|
+
* tracks the pointer, and clear it once the Host confirms so the directory
|
|
1287
|
+
* becomes the source of truth again. If the RPC fails the draft is dropped,
|
|
1288
|
+
* the thumb springs back to the real value, and the notice says why.
|
|
1289
|
+
*/
|
|
1290
|
+
const chooseEffort = useCallback(async (effortId) => {
|
|
1291
|
+
if (currentGroup === null || current === null) return
|
|
1292
|
+
setDraftEffort(effortId)
|
|
1293
|
+
const result = await select(currentGroup, current, effortId)
|
|
1294
|
+
if (result?.ok === true) {
|
|
1295
|
+
setFailure(null)
|
|
1296
|
+
return
|
|
1297
|
+
}
|
|
1298
|
+
setDraftEffort(null)
|
|
1299
|
+
showFailure(result)
|
|
1300
|
+
}, [select, currentGroup, current, showFailure])
|
|
1301
|
+
|
|
784
1302
|
/**
|
|
785
1303
|
* What the trigger says, in the shipped picker's precedence.
|
|
786
1304
|
*
|
|
@@ -802,25 +1320,79 @@ function createModelPicker(React) {
|
|
|
802
1320
|
* will use, so that is what the control shows rather than a blank.
|
|
803
1321
|
*/
|
|
804
1322
|
const efforts = Array.isArray(current?.reasoning?.efforts) ? current.reasoning.efforts : []
|
|
805
|
-
|
|
1323
|
+
/**
|
|
1324
|
+
* The level to show, and why it is NOT simply `selection.reasoningEffort`.
|
|
1325
|
+
*
|
|
1326
|
+
* `selection.reasoningEffort` belongs to the SESSION, not to the model. When
|
|
1327
|
+
* the user switches to a model they have never used, that field still carries
|
|
1328
|
+
* the previous model's level — `xhigh` while the new model only declares
|
|
1329
|
+
* `low/high`, or `high` for both, where inheriting it silently starts the new
|
|
1330
|
+
* model at a level the user never chose for it.
|
|
1331
|
+
*
|
|
1332
|
+
* The rule is therefore: a level is only "current" for a model that has
|
|
1333
|
+
* already been used in this session. On a model switch the adapter's declared
|
|
1334
|
+
* default takes over, which is what the Host would use anyway. `modelKey`
|
|
1335
|
+
* tracks which model the level belongs to, so a switch is detectable.
|
|
1336
|
+
*/
|
|
1337
|
+
const declares = (id) => id !== undefined && id !== null && efforts.some((effort) => effort.id === id)
|
|
1338
|
+
const modelKey = current === null ? null : `${selection?.provider ?? ''}:${selection?.model ?? ''}`
|
|
1339
|
+
const seenModelRef = useRef(null)
|
|
1340
|
+
const switchedModel = seenModelRef.current !== null && seenModelRef.current !== modelKey
|
|
1341
|
+
const inheritedEffortId = switchedModel ? undefined : selection?.reasoningEffort
|
|
1342
|
+
/**
|
|
1343
|
+
* The level in effect, resolved through three fallbacks.
|
|
1344
|
+
*
|
|
1345
|
+
* A provider may declare `reasoningEfforts` without a default — the
|
|
1346
|
+
* `openai-responses` models in this profile do exactly that (five levels, no
|
|
1347
|
+
* default). Taking `defaultEffort` verbatim then left `activeEffortId`
|
|
1348
|
+
* undefined, so the tag rendered with an empty label even though the slider
|
|
1349
|
+
* had five stops. The first declared level is the right fallback: it is what
|
|
1350
|
+
* an unspecified request uses.
|
|
1351
|
+
*/
|
|
1352
|
+
const confirmedEffortId = declares(inheritedEffortId)
|
|
1353
|
+
? inheritedEffortId
|
|
1354
|
+
: declares(current?.reasoning?.defaultEffort)
|
|
1355
|
+
? current.reasoning.defaultEffort
|
|
1356
|
+
: efforts[0]?.id
|
|
1357
|
+
useEffect(() => { seenModelRef.current = modelKey }, [modelKey])
|
|
1358
|
+
const activeEffortId = draftEffort !== null && declares(draftEffort)
|
|
1359
|
+
? draftEffort
|
|
1360
|
+
: confirmedEffortId
|
|
806
1361
|
const activeEffort = efforts.find((effort) => effort.id === activeEffortId) ?? null
|
|
807
|
-
|
|
1362
|
+
|
|
1363
|
+
/**
|
|
1364
|
+
* What the trigger prints for the current level.
|
|
1365
|
+
*
|
|
1366
|
+
* The trigger is only rendered when the model declares levels, so a resolved
|
|
1367
|
+
* name always exists by then; the id is a fallback for a level whose entry has
|
|
1368
|
+
* no display name, and an empty string is the last resort.
|
|
1369
|
+
*/
|
|
1370
|
+
const effortLabel = activeEffort?.name ?? activeEffort?.id ?? String(activeEffortId ?? '')
|
|
1371
|
+
|
|
1372
|
+
/**
|
|
1373
|
+
* Drop the draft once the Host agrees with it.
|
|
1374
|
+
*
|
|
1375
|
+
* Until the directory catches up the draft IS the truth, so it must not be
|
|
1376
|
+
* cleared earlier or the thumb would jump back for one frame. When the
|
|
1377
|
+
* confirmed value finally matches, the draft has done its job.
|
|
1378
|
+
*/
|
|
1379
|
+
useEffect(() => {
|
|
1380
|
+
if (draftEffort !== null && confirmedEffortId === draftEffort) setDraftEffort(null)
|
|
1381
|
+
}, [draftEffort, confirmedEffortId])
|
|
808
1382
|
|
|
809
1383
|
/**
|
|
810
1384
|
* The label, in the shipped picker's addressable wrapper.
|
|
811
1385
|
*
|
|
812
|
-
* The `data-provider-id` / `data-model-id` span is kept because CSS that
|
|
813
|
-
* targets one model by id still works.
|
|
814
|
-
*
|
|
815
|
-
*
|
|
816
|
-
*
|
|
1386
|
+
* The `data-provider-id` / `data-model-id` span is kept because host CSS that
|
|
1387
|
+
* targets one model by id still works. It is deliberately plain text: this entry
|
|
1388
|
+
* does not declare the `conversation.input.model.label` child, so the renderer
|
|
1389
|
+
* hands it no `renderSlot` and provider badges cannot mount here (see AGENTS.md,
|
|
1390
|
+
* invariant 2 — declaring that child breaks the whole registration).
|
|
817
1391
|
*/
|
|
818
|
-
const renderLabel = (group, model
|
|
1392
|
+
const renderLabel = (group, model) => h('span', {
|
|
819
1393
|
'data-provider-id': group?.id,
|
|
820
1394
|
'data-model-id': model?.id
|
|
821
|
-
},
|
|
822
|
-
? model?.name
|
|
823
|
-
: renderSlot(LABEL_SLOT, { provider: group, model, placement, selected }, { fallback: model?.name }))
|
|
1395
|
+
}, model?.name)
|
|
824
1396
|
|
|
825
1397
|
const renderItem = (group, model) => {
|
|
826
1398
|
const isCurrent = selection !== null &&
|
|
@@ -831,9 +1403,9 @@ function createModelPicker(React) {
|
|
|
831
1403
|
role: 'option',
|
|
832
1404
|
'aria-selected': isCurrent,
|
|
833
1405
|
className: `dmp-item${isCurrent ? ' dmp-item-current' : ''}`,
|
|
834
|
-
onClick: () => void
|
|
1406
|
+
onClick: () => void chooseModel(group, model)
|
|
835
1407
|
},
|
|
836
|
-
h('span', { className: 'dmp-item-name' }, renderLabel(group, model
|
|
1408
|
+
h('span', { className: 'dmp-item-name' }, renderLabel(group, model)))
|
|
837
1409
|
}
|
|
838
1410
|
|
|
839
1411
|
const renderGroup = ({ group, models }) => h('div', { key: group.id },
|
|
@@ -842,24 +1414,23 @@ function createModelPicker(React) {
|
|
|
842
1414
|
|
|
843
1415
|
const notes = []
|
|
844
1416
|
/**
|
|
845
|
-
*
|
|
1417
|
+
* Catalogue problems only.
|
|
846
1418
|
*
|
|
847
|
-
* A
|
|
848
|
-
*
|
|
849
|
-
*
|
|
850
|
-
*
|
|
851
|
-
* failed switch erased the load error and vice versa.
|
|
1419
|
+
* A selection failure is reported by the notice above the panels instead: it
|
|
1420
|
+
* belongs to this session, and the raw `code: message` text is not worth
|
|
1421
|
+
* pushing into the list. `lastActionRef` keeps a selection error from
|
|
1422
|
+
* appearing here as if it were a load failure.
|
|
852
1423
|
*/
|
|
853
|
-
const
|
|
854
|
-
|
|
855
|
-
if (state.status === 'loading' && groups.length === 0
|
|
1424
|
+
const loadError = state.status === 'error' && lastActionRef.current === 'load' &&
|
|
1425
|
+
typeof state.error === 'string' && state.error.length > 0 ? state.error : ''
|
|
1426
|
+
if (state.status === 'loading' && groups.length === 0) {
|
|
856
1427
|
notes.push(h('div', { key: 'loading', className: 'dmp-note' }, t.loading))
|
|
857
1428
|
}
|
|
858
|
-
if (
|
|
1429
|
+
if (loadError.length > 0) {
|
|
859
1430
|
notes.push(h('div', { key: 'error', className: 'dmp-note dmp-note-error' },
|
|
860
|
-
|
|
1431
|
+
loadError,
|
|
861
1432
|
h('div', null, h('button', {
|
|
862
|
-
type: 'button', className: 'dmp-retry', onClick:
|
|
1433
|
+
type: 'button', className: 'dmp-retry', onClick: doLoad
|
|
863
1434
|
}, t.retry))))
|
|
864
1435
|
}
|
|
865
1436
|
// A provider group whose catalogue failed: say so, instead of leaving a
|
|
@@ -873,40 +1444,124 @@ function createModelPicker(React) {
|
|
|
873
1444
|
notes.push(h('div', { key: 'empty', className: 'dmp-note' }, t.empty))
|
|
874
1445
|
}
|
|
875
1446
|
|
|
876
|
-
const
|
|
1447
|
+
const hasEfforts = efforts.length > 0
|
|
1448
|
+
|
|
1449
|
+
/**
|
|
1450
|
+
* Where each popover sits.
|
|
1451
|
+
*
|
|
1452
|
+
* Both panels are wider than their trigger and open above it, so a fixed side
|
|
1453
|
+
* anchor drifts away from the button and runs off the window near an edge.
|
|
1454
|
+
* The shared hook centres each panel on its trigger and clamps the result to
|
|
1455
|
+
* the viewport; `deps` re-measures when the content width changes (a five-level
|
|
1456
|
+
* slider is wider than a three-level one, and a longer model list is wider than
|
|
1457
|
+
* a short one).
|
|
1458
|
+
*/
|
|
1459
|
+
const effortAnchor = createAnchoredPanel(React, {
|
|
1460
|
+
open: panel === 'effort',
|
|
1461
|
+
fallbackWidth: 280,
|
|
1462
|
+
deps: [efforts.length]
|
|
1463
|
+
})
|
|
1464
|
+
const modelAnchor = createAnchoredPanel(React, {
|
|
1465
|
+
open: panel === 'model',
|
|
1466
|
+
fallbackWidth: 320,
|
|
1467
|
+
deps: [filtered.length, groups.length]
|
|
1468
|
+
})
|
|
1469
|
+
|
|
1470
|
+
/**
|
|
1471
|
+
* Measure the notice so the open panel can sit above it instead of under it.
|
|
1472
|
+
*
|
|
1473
|
+
* The panel's `bottom` adds `--dmp-notice-lift`, so this only has to report
|
|
1474
|
+
* the notice's rendered height.
|
|
1475
|
+
*/
|
|
1476
|
+
useEffect(() => {
|
|
1477
|
+
const node = noticeRef.current
|
|
1478
|
+
if (failure === null || node === null || typeof node.getBoundingClientRect !== 'function') {
|
|
1479
|
+
setNoticeLift(0)
|
|
1480
|
+
return
|
|
1481
|
+
}
|
|
1482
|
+
const height = node.getBoundingClientRect().height
|
|
1483
|
+
setNoticeLift(Number.isFinite(height) && height > 0 ? height + 6 : 0)
|
|
1484
|
+
}, [failure, noticeOpen])
|
|
1485
|
+
|
|
1486
|
+
const notice = failure === null
|
|
1487
|
+
? null
|
|
1488
|
+
: h('div', { className: 'dmp-notice', ref: noticeRef, role: 'status' },
|
|
1489
|
+
h('button', {
|
|
1490
|
+
type: 'button',
|
|
1491
|
+
className: 'dmp-notice-toggle',
|
|
1492
|
+
'aria-expanded': noticeOpen,
|
|
1493
|
+
title: failure.detail,
|
|
1494
|
+
onClick: () => setNoticeOpen((value) => !value)
|
|
1495
|
+
},
|
|
1496
|
+
h('span', { className: 'dmp-notice-text' }, failure.summary),
|
|
1497
|
+
h(Chevron, { h, open: noticeOpen })),
|
|
1498
|
+
noticeOpen && failure.detail.length > 0
|
|
1499
|
+
? h('div', { className: 'dmp-notice-detail' }, failure.detail)
|
|
1500
|
+
: null)
|
|
1501
|
+
|
|
1502
|
+
const effortButton = current === null || currentGroup === null || !hasEfforts
|
|
877
1503
|
? null
|
|
878
1504
|
: h('button', {
|
|
879
1505
|
type: 'button',
|
|
1506
|
+
ref: effortAnchor.ref,
|
|
880
1507
|
className: 'dmp-trigger dmp-trigger-effort',
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
'
|
|
1508
|
+
// Only a locked composer disables the level tag. Its own commits flip the
|
|
1509
|
+
// directory to `selecting`, and disabling here killed the drag after the
|
|
1510
|
+
// first stop — the same reason the slider's range input stays enabled.
|
|
1511
|
+
disabled: locked === true,
|
|
1512
|
+
title: t.effortTitle(effortLabel),
|
|
1513
|
+
'aria-haspopup': 'dialog',
|
|
884
1514
|
'aria-expanded': panel === 'effort',
|
|
885
1515
|
onClick: () => setPanel((value) => (value === 'effort' ? null : 'effort'))
|
|
886
1516
|
},
|
|
887
|
-
h('span', { className: 'dmp-trigger-text' },
|
|
1517
|
+
h('span', { className: 'dmp-trigger-text dmp-trigger-effort-text' }, effortLabel),
|
|
888
1518
|
h(Chevron, { h, open: panel === 'effort' }))
|
|
889
1519
|
|
|
890
1520
|
const effortPanel = panel === 'effort' && effortButton !== null
|
|
891
|
-
? h('div', {
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
1521
|
+
? h('div', {
|
|
1522
|
+
className: 'dmp-panel dmp-panel-slider',
|
|
1523
|
+
role: 'dialog',
|
|
1524
|
+
'aria-label': t.effortHeading,
|
|
1525
|
+
// The host lifts the composer's stacking context when one of its own menus
|
|
1526
|
+
// is open: `.composerSeat:has([data-trigger-menu]) { z-index: 9 }`. Without
|
|
1527
|
+
// it the seat stays at z-index 7, and the chat's "jump to latest" button
|
|
1528
|
+
// (z-index 8, a sibling of the seat) paints over this panel no matter what
|
|
1529
|
+
// z-index the panel itself asks for — a child cannot escape its parent's
|
|
1530
|
+
// stacking context. Marking the panel is the supported way to ask for the
|
|
1531
|
+
// same lift.
|
|
1532
|
+
'data-trigger-menu': '',
|
|
1533
|
+
ref: effortAnchor.panelRef,
|
|
1534
|
+
style: effortAnchor.style
|
|
1535
|
+
},
|
|
1536
|
+
h(EffortSlider, {
|
|
1537
|
+
efforts,
|
|
1538
|
+
activeId: activeEffortId,
|
|
1539
|
+
/**
|
|
1540
|
+
* Deliberately NOT disabled while a change is settling.
|
|
1541
|
+
*
|
|
1542
|
+
* `busy` tracks the directory's `status === 'selecting'`, and every stop
|
|
1543
|
+
* the slider passes submits a selection — so the first stop flipped
|
|
1544
|
+
* `busy`, disabled the input mid-gesture, and the drag died. The user
|
|
1545
|
+
* then had to press again for each further stop. A level is a dial: it
|
|
1546
|
+
* stays live, and the draft value keeps the thumb honest while the Host
|
|
1547
|
+
* catches up.
|
|
1548
|
+
*/
|
|
1549
|
+
disabled: locked === true,
|
|
1550
|
+
caption: glyphCaption ? undefined : t.effortHeading,
|
|
1551
|
+
t,
|
|
1552
|
+
onChange: (effortId) => { void chooseEffort(effortId) }
|
|
1553
|
+
}))
|
|
906
1554
|
: null
|
|
907
1555
|
|
|
908
1556
|
const modelPanel = panel === 'model'
|
|
909
|
-
? h('div', {
|
|
1557
|
+
? h('div', {
|
|
1558
|
+
className: 'dmp-panel',
|
|
1559
|
+
role: 'listbox',
|
|
1560
|
+
// Same stacking lift as the effort panel: see the comment there.
|
|
1561
|
+
'data-trigger-menu': '',
|
|
1562
|
+
ref: modelAnchor.panelRef,
|
|
1563
|
+
style: modelAnchor.style
|
|
1564
|
+
},
|
|
910
1565
|
h('div', { className: 'dmp-search' },
|
|
911
1566
|
h('input', {
|
|
912
1567
|
type: 'search',
|
|
@@ -928,7 +1583,11 @@ function createModelPicker(React) {
|
|
|
928
1583
|
*/
|
|
929
1584
|
if (available === false) return null
|
|
930
1585
|
|
|
931
|
-
return h('div', {
|
|
1586
|
+
return h('div', {
|
|
1587
|
+
className: 'dmp-root',
|
|
1588
|
+
ref: rootRef,
|
|
1589
|
+
style: noticeLift > 0 ? { '--dmp-notice-lift': `${noticeLift}px` } : undefined
|
|
1590
|
+
},
|
|
932
1591
|
h('style', null, CSS),
|
|
933
1592
|
// The model control and the reasoning-level control are deliberately two
|
|
934
1593
|
// buttons: they are two independent choices, and a model change must not
|
|
@@ -936,6 +1595,7 @@ function createModelPicker(React) {
|
|
|
936
1595
|
h('div', { className: 'dmp-control' },
|
|
937
1596
|
h('button', {
|
|
938
1597
|
type: 'button',
|
|
1598
|
+
ref: modelAnchor.ref,
|
|
939
1599
|
className: 'dmp-trigger',
|
|
940
1600
|
disabled: locked === true || busy,
|
|
941
1601
|
title: t.trigger(currentLabel),
|
|
@@ -948,10 +1608,11 @@ function createModelPicker(React) {
|
|
|
948
1608
|
? t.switching
|
|
949
1609
|
: (current === null || currentGroup === null
|
|
950
1610
|
? currentLabel
|
|
951
|
-
: renderLabel(currentGroup, current
|
|
1611
|
+
: renderLabel(currentGroup, current))),
|
|
952
1612
|
h(Chevron, { h, open: panel === 'model' })),
|
|
953
1613
|
modelPanel),
|
|
954
|
-
effortButton === null ? null : h('div', { className: 'dmp-control' }, effortButton, effortPanel)
|
|
1614
|
+
effortButton === null ? null : h('div', { className: 'dmp-control' }, effortButton, effortPanel),
|
|
1615
|
+
notice)
|
|
955
1616
|
}
|
|
956
1617
|
}
|
|
957
1618
|
|
|
@@ -959,40 +1620,34 @@ function createModelPicker(React) {
|
|
|
959
1620
|
/**
|
|
960
1621
|
* Client entry: the module-loader registration and the plugin object.
|
|
961
1622
|
*
|
|
962
|
-
* This
|
|
963
|
-
*
|
|
964
|
-
*
|
|
1623
|
+
* This plugin replaces ONLY the composer's model-selection UI. Everything behind
|
|
1624
|
+
* it — the per-session directory, the catalogue cache, the invalidation events,
|
|
1625
|
+
* the selection RPC — is the shipped `ui-model-selection` implementation, reached
|
|
1626
|
+
* through its `modelDirectories` service.
|
|
965
1627
|
*
|
|
966
|
-
*
|
|
967
|
-
* `
|
|
968
|
-
*
|
|
969
|
-
*
|
|
1628
|
+
* That split works because the shipped plugin keeps its slot registration and its
|
|
1629
|
+
* component apart: the registration's `inject` hands the component a plain props
|
|
1630
|
+
* bundle (`available`, a `directory` store, `load`, `select`), and the component
|
|
1631
|
+
* may render however it likes. This entry registers the same slot with the same
|
|
1632
|
+
* `inject` contract and substitutes its own component.
|
|
970
1633
|
*
|
|
971
|
-
*
|
|
972
|
-
*
|
|
973
|
-
*
|
|
974
|
-
*
|
|
975
|
-
* registration. The renderer then hands an entry a `renderSlot` binding only for
|
|
976
|
-
* children that entry declared itself, so not declaring means not rendering. The
|
|
977
|
-
* decorations therefore do not appear here and each model is shown by its plain
|
|
978
|
-
* name. That is an accepted consequence of shadowing this slot, not an oversight.
|
|
1634
|
+
* Why not copy the shipped bundle instead: a copy registers a second
|
|
1635
|
+
* `ModelDirectoryResolver` under the same service name, the two fight over it,
|
|
1636
|
+
* and the shipped plugin parks in `loading` — which fails the whole renderer
|
|
1637
|
+
* boot. Reusing the service avoids that, and keeps tracking upstream fixes.
|
|
979
1638
|
*/
|
|
980
1639
|
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
1640
|
const PLUGIN = 'dsh-model-picker'
|
|
985
1641
|
const SLOT = 'conversation.input.model'
|
|
986
|
-
/** The shipped
|
|
1642
|
+
/** The service the shipped picker exposes for per-session model directories. */
|
|
987
1643
|
const MODEL_DIRECTORIES = 'modelDirectories'
|
|
988
1644
|
/**
|
|
989
1645
|
* BELOW the shipped occupant's `priority: 0`.
|
|
990
1646
|
*
|
|
991
1647
|
* For a `single` slot the LOWEST priority renders — the registry says so in its
|
|
992
1648
|
* own error text ("register at a different priority to shadow it (lowest
|
|
993
|
-
* renders)")
|
|
994
|
-
*
|
|
995
|
-
* instead of shadowing it.
|
|
1649
|
+
* renders)"). A larger number would lose to the shipped picker instead of
|
|
1650
|
+
* shadowing it.
|
|
996
1651
|
*/
|
|
997
1652
|
const SHADOW_PRIORITY = -1
|
|
998
1653
|
|
|
@@ -1001,17 +1656,12 @@ const SHADOW_PRIORITY = -1
|
|
|
1001
1656
|
*
|
|
1002
1657
|
* The packaged desktop app has no devtools, so a client half that fails to
|
|
1003
1658
|
* register is indistinguishable from one that never loaded. Each stage is posted
|
|
1004
|
-
* to the Host half, which logs it
|
|
1005
|
-
*
|
|
1659
|
+
* to the Host half, which logs it beside its own line: the pair tells the two
|
|
1660
|
+
* cases apart immediately.
|
|
1006
1661
|
*
|
|
1007
1662
|
* @param stage - a short description of the load stage reached.
|
|
1008
1663
|
*/
|
|
1009
1664
|
const report = (stage) => {
|
|
1010
|
-
// The renderer console reaches the desktop app log, which is captured in every
|
|
1011
|
-
// diagnostics bundle. A POST to the Host half is kept as the richer channel,
|
|
1012
|
-
// but it cannot be the only one: it is a second thing that can fail (trust
|
|
1013
|
-
// fence, no route yet, offline), and a missing report then looks exactly like
|
|
1014
|
-
// "apply was never called".
|
|
1015
1665
|
try {
|
|
1016
1666
|
console.error(`[model-picker] ${stage}`)
|
|
1017
1667
|
} catch {
|
|
@@ -1032,8 +1682,7 @@ const report = (stage) => {
|
|
|
1032
1682
|
/**
|
|
1033
1683
|
* The failure worth capturing happens *outside* apply: the client composition
|
|
1034
1684
|
* builds this plugin object, then never applies it, and whatever it threw on the
|
|
1035
|
-
* way is only visible in the page
|
|
1036
|
-
* devtools for. Both global hooks forward to the host log instead.
|
|
1685
|
+
* way is only visible in the page.
|
|
1037
1686
|
*
|
|
1038
1687
|
* @param value - the thrown value, in any shape.
|
|
1039
1688
|
* @returns a short description safe to log.
|
|
@@ -1070,184 +1719,153 @@ window.__ModuleLoader__.load({
|
|
|
1070
1719
|
return {
|
|
1071
1720
|
name: PLUGIN,
|
|
1072
1721
|
/**
|
|
1073
|
-
*
|
|
1722
|
+
* The services this half reads, PLUS the ones the shipped directory service
|
|
1723
|
+
* reads on its own behalf.
|
|
1724
|
+
*
|
|
1725
|
+
* `remote` and `remote.session` look unused here — this file never mentions
|
|
1726
|
+
* them — but `models.directoryFor(sessionId)` calls into the shipped
|
|
1727
|
+
* resolver, and that call runs in THIS entry's fiber. Cordis only exposes a
|
|
1728
|
+
* service to a fiber that declares it, so dropping them makes every
|
|
1729
|
+
* `directoryFor` throw
|
|
1074
1730
|
*
|
|
1075
|
-
*
|
|
1076
|
-
*
|
|
1077
|
-
*
|
|
1078
|
-
*
|
|
1079
|
-
*
|
|
1731
|
+
* cannot get property "remote.session" without inject
|
|
1732
|
+
*
|
|
1733
|
+
* from inside `inject`. The renderer catches that, retires the entry and
|
|
1734
|
+
* silently paints the shipped picker — the plugin "just disappears".
|
|
1735
|
+
* Declaring what the callee needs is part of calling it, so they stay.
|
|
1080
1736
|
*/
|
|
1737
|
+
inject: ['locale', 'sessions', 'slots', 'remote', 'remote.session'],
|
|
1081
1738
|
apply(ctx) {
|
|
1082
1739
|
report(`apply 已调用(slots=${ctx.get('slots') === undefined ? '尚未就绪' : '就绪'})`)
|
|
1083
1740
|
|
|
1084
1741
|
/**
|
|
1085
|
-
*
|
|
1086
|
-
* service LAZILY.
|
|
1087
|
-
*
|
|
1088
|
-
* Two facts pull in opposite directions and this is the seam between
|
|
1089
|
-
* them:
|
|
1090
|
-
*
|
|
1091
|
-
* - Event subscriptions must be installed from the plugin's own `apply`
|
|
1092
|
-
* fiber. The gateway registers a forwarded-event listener in the
|
|
1093
|
-
* CALLING fiber's private event domain, and a subscription made from a
|
|
1094
|
-
* `ctx.inject` callback lands in a different domain: `$on` returns a
|
|
1095
|
-
* disposer, so it looks successful, but the listener never fires.
|
|
1096
|
-
* - `remote` is frequently NOT visible yet when `apply` runs. Capturing
|
|
1097
|
-
* `ctx.get('remote')` here froze `undefined`, and every catalogue load
|
|
1098
|
-
* then answered "modelCatalog is unavailable".
|
|
1099
|
-
*
|
|
1100
|
-
* So the catalogue holds a resolver, not the face, and the subscription
|
|
1101
|
-
* is attached as soon as the service appears.
|
|
1102
|
-
*/
|
|
1103
|
-
const remoteFace = () => {
|
|
1104
|
-
try { return ctx.get('remote') } catch { return undefined }
|
|
1105
|
-
}
|
|
1106
|
-
const catalog = createCatalog(remoteFace, (message) => { report(message) })
|
|
1107
|
-
/**
|
|
1108
|
-
* Attach the invalidation listeners, retrying once the remote exists.
|
|
1742
|
+
* Register the seat through the shipped directory service.
|
|
1109
1743
|
*
|
|
1110
|
-
* `ctx.inject` runs
|
|
1111
|
-
*
|
|
1112
|
-
*
|
|
1113
|
-
*
|
|
1744
|
+
* `ctx.inject` runs this callback once the named services are visible, so
|
|
1745
|
+
* the registration lands at the same point the shipped one would. It is
|
|
1746
|
+
* also re-run if a service goes away and returns, which is what keeps the
|
|
1747
|
+
* seat from binding to `undefined` across a reconnect.
|
|
1114
1748
|
*/
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1749
|
+
// `remote` / `remote.session` are declared here as well as on the plugin:
|
|
1750
|
+
// this callback runs in the fiber that `ctx.inject` creates, and the
|
|
1751
|
+
// directory resolver uses those two services through that context.
|
|
1752
|
+
ctx.inject(['slots', MODEL_DIRECTORIES, 'remote', 'remote.session'], (scope) => {
|
|
1753
|
+
const slots = scope.get('slots')
|
|
1754
|
+
const sessions = scope.get('sessions')
|
|
1755
|
+
/**
|
|
1756
|
+
* The locale SERVICE, handed to the component as an observable source.
|
|
1757
|
+
*
|
|
1758
|
+
* Its snapshot is `{ active, locales, revision }` — NOT `{ locale }`.
|
|
1759
|
+
* Reading the wrong field made every UI string fall back to Chinese no
|
|
1760
|
+
* matter which language the shell was set to.
|
|
1761
|
+
*
|
|
1762
|
+
* It is passed through as a `{ getSnapshot, subscribe }` pair rather
|
|
1763
|
+
* than resolved to a string here: `inject` is cached per session
|
|
1764
|
+
* binding, so a string captured now would never follow a language
|
|
1765
|
+
* switch made while the composer is on screen.
|
|
1766
|
+
*/
|
|
1767
|
+
const locale = scope.get('locale')
|
|
1768
|
+
const localeSource = locale !== undefined &&
|
|
1769
|
+
typeof locale.getSnapshot === 'function' &&
|
|
1770
|
+
typeof locale.subscribe === 'function'
|
|
1771
|
+
? locale
|
|
1772
|
+
: undefined
|
|
1773
|
+
const initialLocale = localeSource?.getSnapshot?.()?.active
|
|
1774
|
+
const localeId = typeof initialLocale === 'string' && initialLocale.length > 0
|
|
1775
|
+
? initialLocale
|
|
1776
|
+
: 'zh-CN'
|
|
1777
|
+
|
|
1778
|
+
if (slots === undefined) {
|
|
1779
|
+
report('槽位未注册:slots 缺失')
|
|
1780
|
+
return
|
|
1129
1781
|
}
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
const slots = at.slots ?? ctx.get('slots')
|
|
1160
|
-
if (slots === undefined) {
|
|
1161
|
-
report('slots 服务仍不可用,槽位未注册')
|
|
1162
|
-
return
|
|
1782
|
+
|
|
1783
|
+
/**
|
|
1784
|
+
* Register the seat.
|
|
1785
|
+
*
|
|
1786
|
+
* Two details matter:
|
|
1787
|
+
*
|
|
1788
|
+
* - NO `children` table. The child slot `conversation.input.model.label`
|
|
1789
|
+
* is declared by the shipped occupant, and a child declaration is
|
|
1790
|
+
* EXCLUSIVE per slot key: `slots.register` throws
|
|
1791
|
+
* `slot "…label" is already declared (by …)` the moment a second entry
|
|
1792
|
+
* offers the same child. The throw escapes the slot callback and the
|
|
1793
|
+
* picker stops rendering. Disposing first does not help either — the
|
|
1794
|
+
* declaration belongs to the shipped entry, which never disposes.
|
|
1795
|
+
*
|
|
1796
|
+
* - The service lookup happens INSIDE the callback. `models` may not be
|
|
1797
|
+
* visible yet when `apply` runs; reading it here means the registration
|
|
1798
|
+
* waits for it instead of binding to undefined.
|
|
1799
|
+
*
|
|
1800
|
+
* The cost of dropping the child declaration is that provider badges
|
|
1801
|
+
* mounted at `conversation.input.model.label` no longer render here. That
|
|
1802
|
+
* is the same trade the reference plugin makes, and it is what makes the
|
|
1803
|
+
* registration survive.
|
|
1804
|
+
*/
|
|
1805
|
+
let disposeCurrent = null
|
|
1806
|
+
slots.inject(SLOT, () => {
|
|
1807
|
+
const models = scope.get(MODEL_DIRECTORIES)
|
|
1808
|
+
if (models === undefined) {
|
|
1809
|
+
report('modelDirectories 缺失,暂不注册')
|
|
1810
|
+
return () => {}
|
|
1163
1811
|
}
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
name: SLOT,
|
|
1197
|
-
priority: SHADOW_PRIORITY,
|
|
1198
|
-
inject: (sessionId) => {
|
|
1199
|
-
const sessionSessions = ctx.get('sessions')
|
|
1200
|
-
/**
|
|
1201
|
-
* The per-session directory is the data face of this picker.
|
|
1202
|
-
*
|
|
1203
|
-
* `directoryFor` is the shipped service's own entry point; when
|
|
1204
|
-
* this plugin had to provide the service, the fallback has the
|
|
1205
|
-
* same signature, so the component never knows the difference.
|
|
1206
|
-
*/
|
|
1207
|
-
const directory = directories.directoryFor(sessionId)
|
|
1208
|
-
// The shipped directory projects inside its constructor and on
|
|
1209
|
-
// its subscriptions; it exposes no `sync`. Only this plugin's
|
|
1210
|
-
// fallback has one, so probe instead of assuming — calling it
|
|
1211
|
-
// unconditionally threw on the shipped service and took the
|
|
1212
|
-
// whole slot registration down with it.
|
|
1213
|
-
if (typeof directory?.sync === 'function') directory.sync()
|
|
1812
|
+
// The callback can fire more than once (a service re-appearing re-runs
|
|
1813
|
+
// it). Registering twice at the same priority throws
|
|
1814
|
+
// `single slot "…" already has a registration`, so the previous one is
|
|
1815
|
+
// released first. Without a children table this is safe — the child
|
|
1816
|
+
// declaration is what cannot be released.
|
|
1817
|
+
if (disposeCurrent !== null) {
|
|
1818
|
+
try {
|
|
1819
|
+
disposeCurrent()
|
|
1820
|
+
} catch {
|
|
1821
|
+
// Already gone.
|
|
1822
|
+
}
|
|
1823
|
+
disposeCurrent = null
|
|
1824
|
+
}
|
|
1825
|
+
report('槽位回调已运行,准备注册 occupant')
|
|
1826
|
+
disposeCurrent = slots.register({
|
|
1827
|
+
name: SLOT,
|
|
1828
|
+
priority: SHADOW_PRIORITY,
|
|
1829
|
+
inject: (sessionId) => {
|
|
1830
|
+
const directory = models.directoryFor(sessionId)
|
|
1831
|
+
const available = typeof sessions?.subagentAddress === 'function'
|
|
1832
|
+
? sessions.subagentAddress(sessionId) === undefined
|
|
1833
|
+
: true
|
|
1834
|
+
return {
|
|
1835
|
+
localeId,
|
|
1836
|
+
localeSource,
|
|
1837
|
+
available,
|
|
1838
|
+
// The shipped registration hands over the STORE, not the
|
|
1839
|
+
// controller; the component subscribes to it directly.
|
|
1840
|
+
directory: directory.store,
|
|
1841
|
+
load: () => {
|
|
1842
|
+
if (available) directory.load().catch(() => {})
|
|
1843
|
+
},
|
|
1214
1844
|
/**
|
|
1215
|
-
*
|
|
1845
|
+
* Submit one triple.
|
|
1216
1846
|
*
|
|
1217
|
-
*
|
|
1218
|
-
*
|
|
1219
|
-
*
|
|
1220
|
-
*
|
|
1221
|
-
*
|
|
1847
|
+
* The RESULT IS PASSED THROUGH UNCHANGED. `ModelDirectory.select`
|
|
1848
|
+
* resolves to `{ ok: true, value }` or `{ ok: false, error }`,
|
|
1849
|
+
* and the component — like the shipped one — reads `ok` to
|
|
1850
|
+
* decide whether to close the panel and which failure to show.
|
|
1851
|
+
* Mapping it to a boolean here made every successful pick look
|
|
1852
|
+
* like a failure: the model switched but the list stayed open,
|
|
1853
|
+
* and a settled effort level dropped its optimistic draft.
|
|
1222
1854
|
*/
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
sessionId,
|
|
1227
|
-
localeId,
|
|
1228
|
-
remote: remoteFace(),
|
|
1229
|
-
remoteSession,
|
|
1230
|
-
sessions: sessionSessions,
|
|
1231
|
-
catalog,
|
|
1232
|
-
directory,
|
|
1233
|
-
available: isAvailable(sessionSessions, sessionId)
|
|
1234
|
-
}
|
|
1855
|
+
select: (selection) => available
|
|
1856
|
+
? directory.select(selection)
|
|
1857
|
+
: Promise.resolve(undefined)
|
|
1235
1858
|
}
|
|
1236
|
-
}
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
// can no longer fail the boot. The remotes are included because the
|
|
1247
|
-
// picker cannot do anything without them, and waiting here keeps the
|
|
1248
|
-
// failure to a missing control instead of a crashed one.
|
|
1249
|
-
if (typeof ctx.inject === 'function') ctx.inject(['slots', 'remote', 'remote.session'], install)
|
|
1250
|
-
else install(ctx)
|
|
1859
|
+
}
|
|
1860
|
+
}, ModelPicker)
|
|
1861
|
+
return () => {
|
|
1862
|
+
if (disposeCurrent !== null) {
|
|
1863
|
+
try { disposeCurrent() } catch { /* already gone */ }
|
|
1864
|
+
disposeCurrent = null
|
|
1865
|
+
}
|
|
1866
|
+
}
|
|
1867
|
+
})
|
|
1868
|
+
})
|
|
1251
1869
|
}
|
|
1252
1870
|
}
|
|
1253
1871
|
}
|