@lynn123411/dsh-model-submenu 0.2.5 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DETAILS.md +8 -2
- package/README.md +10 -8
- package/lib/client.js +1158 -326
- package/lib/client.js.map +1 -1
- package/locale/en.json +2 -2
- package/locale/zh.json +2 -2
- package/package.json +26 -20
- package/src/client/ModelPicker.tsx +305 -0
- package/src/client/ModelRow.tsx +127 -0
- package/src/client/ModelSubmenu.module.css +338 -97
- package/src/client/ModelSubmenu.tsx +237 -233
- package/src/client/effort-memory.ts +90 -0
- package/src/client/facts-source.ts +93 -0
- package/src/client/favorites.ts +103 -0
- package/src/client/icons.tsx +36 -0
- package/src/client/index.ts +29 -8
- package/src/client/locales.ts +66 -33
- package/src/client/model-facts.ts +198 -0
- package/src/client/rows.ts +125 -0
- package/src/client/selection.ts +112 -0
|
@@ -1,28 +1,39 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'
|
|
2
2
|
import { createPortal } from 'react-dom'
|
|
3
3
|
import {
|
|
4
|
-
IconCheckOutlineRegular,
|
|
5
4
|
IconChevronDownOutlineRegular,
|
|
6
|
-
|
|
7
|
-
IconChevronRightOutlineRegular,
|
|
8
|
-
IconDataOutlineRegular,
|
|
5
|
+
IconFlatListOutlineRegular,
|
|
9
6
|
IconWarningOutlineRegular,
|
|
10
7
|
MenuSurface,
|
|
11
8
|
Toast,
|
|
12
9
|
} from '@deepseek-ai/dsh-client-ui-primitives'
|
|
13
10
|
import type { ModelDirectoryState, ModelSelectInjected } from '@deepseek-ai/dsh-client-ui-model-selection/client'
|
|
14
11
|
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
|
|
12
|
+
import { ModelPicker } from './ModelPicker.tsx'
|
|
13
|
+
import { ModelRow } from './ModelRow.tsx'
|
|
14
|
+
import type { FactsSource } from './facts-source.ts'
|
|
15
|
+
import {
|
|
16
|
+
isFavorite,
|
|
17
|
+
orderWithFavorites,
|
|
18
|
+
readFavorites,
|
|
19
|
+
toggleFavorite,
|
|
20
|
+
type FavoriteModel,
|
|
21
|
+
type FavoritesStorage,
|
|
22
|
+
} from './favorites.ts'
|
|
23
|
+
import { effortFor, readEfforts, rememberEffort, type RememberedEffort } from './effort-memory.ts'
|
|
24
|
+
import type { EffortChoice, Selection } from './selection.ts'
|
|
25
|
+
import { reasoningOf } from './rows.ts'
|
|
26
|
+
import { effortChoices, planEffortCommit, planModelCommit } from './selection.ts'
|
|
15
27
|
import css from './ModelSubmenu.module.css'
|
|
16
28
|
|
|
17
|
-
type ModelSubmenuProps = ModelSelectInjected & { locked: boolean } & PropsLocale<'modelSubmenu'>
|
|
29
|
+
type ModelSubmenuProps = ModelSelectInjected & { locked: boolean } & PropsLocale<'modelSubmenu'> & {
|
|
30
|
+
/** The live settings-derived metadata the picker's line reads. */
|
|
31
|
+
readonly factsSource: FactsSource
|
|
32
|
+
}
|
|
18
33
|
|
|
19
|
-
type Selection = Parameters<ModelSelectInjected['select']>[0]
|
|
20
34
|
type ProviderGroup = ModelDirectoryState['groups'][number]
|
|
21
35
|
type CatalogModel = ProviderGroup['models'][number]
|
|
22
36
|
|
|
23
|
-
/** Which list the open menu is showing. */
|
|
24
|
-
type Pane = 'root' | 'providers' | 'models' | 'effort'
|
|
25
|
-
|
|
26
37
|
/** Unplaced portal card: hidden but laid out at a fixed origin so offsetWidth/offsetHeight are real. */
|
|
27
38
|
const MEASURE_STYLE = {
|
|
28
39
|
visibility: 'hidden',
|
|
@@ -34,27 +45,26 @@ const cls = (...parts: readonly (string | false | undefined)[]): string => parts
|
|
|
34
45
|
|
|
35
46
|
/**
|
|
36
47
|
* The composer's model seat (`conversation.input.model`), taken over from the
|
|
37
|
-
* built-in occupant with a provider
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
48
|
+
* built-in occupant with a provider-scoped menu: one provider's models above,
|
|
49
|
+
* the reasoning row as segments, the cache warning, and the door to the picker
|
|
50
|
+
* that lists every provider.
|
|
51
|
+
*
|
|
52
|
+
* Every click commits at once: a model, a reasoning level, or the picker door.
|
|
53
|
+
* Closing the menu never writes anything, and the picker reads the level the
|
|
54
|
+
* Host already accepted, so its "current" reading is never stale.
|
|
43
55
|
*/
|
|
44
|
-
export function ModelSubmenu({ locked, available, directory, load, select, t }: ModelSubmenuProps) {
|
|
56
|
+
export function ModelSubmenu({ locked, available, directory, load, select, t, factsSource }: ModelSubmenuProps) {
|
|
45
57
|
const state = useSyncExternalStore((fn) => directory.subscribe(fn), () => directory.getSnapshot())
|
|
46
58
|
const [open, setOpen] = useState(false)
|
|
47
|
-
const [
|
|
48
|
-
const [
|
|
49
|
-
const
|
|
59
|
+
const [pickerOpen, setPickerOpen] = useState(false)
|
|
60
|
+
const [favorites, setFavorites] = useState<readonly FavoriteModel[]>(() => readFavorites(storageOf()))
|
|
61
|
+
const [memory, setMemory] = useState<readonly RememberedEffort[]>(() => readEfforts(storageOf()))
|
|
50
62
|
const [toast, setToast] = useState<{ seq: number; text: string } | null>(null)
|
|
51
63
|
const toastSeq = useRef(0)
|
|
52
64
|
const rootRef = useRef<HTMLDivElement | null>(null)
|
|
53
65
|
const triggerRef = useRef<HTMLButtonElement | null>(null)
|
|
54
66
|
const menuRef = useRef<HTMLDivElement | null>(null)
|
|
55
67
|
const [menuPos, setMenuPos] = useState<{ left: number; top: number } | null>(null)
|
|
56
|
-
const itemRefs = useRef(new Map<string, HTMLButtonElement>())
|
|
57
|
-
const focusIntent = useRef<string | null>(null)
|
|
58
68
|
const id = useId()
|
|
59
69
|
|
|
60
70
|
const groups = state.groups
|
|
@@ -63,52 +73,76 @@ export function ModelSubmenu({ locked, available, directory, load, select, t }:
|
|
|
63
73
|
const currentModel = currentGroup?.models.find((model) => model.id === current?.model)
|
|
64
74
|
const reasoning = currentModel?.reasoning
|
|
65
75
|
const effectiveEffort = current?.reasoningEffort ?? reasoning?.defaultEffort
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
...reasoning.efforts.map((level) => ({ key: `effort:${level.id}`, effort: level.id as string | undefined, label: level.name })),
|
|
76
|
-
], [reasoning, t])
|
|
77
|
-
|
|
78
|
-
const shownGroup = pane === 'models' && providerId !== null ? groups.find((group) => group.id === providerId) : undefined
|
|
76
|
+
/**
|
|
77
|
+
* The trigger's caption. A model that advertises no levels still says so in
|
|
78
|
+
* the same place: the blue caption beside the name reads 默认 rather than
|
|
79
|
+
* disappearing, so the shape of the control never changes with the model.
|
|
80
|
+
*/
|
|
81
|
+
const effortLabel = reasoning === undefined || effectiveEffort === undefined
|
|
82
|
+
? t('effort.providerDefault')
|
|
83
|
+
: reasoning.efforts.find((level) => level.id === effectiveEffort)?.name ?? effectiveEffort
|
|
84
|
+
const choices = useMemo(() => effortChoices(reasoning, t('effort.providerDefault')), [reasoning, t])
|
|
79
85
|
const busy = state.status === 'selecting'
|
|
86
|
+
const live = useMemo(() => selectionOf(state), [state])
|
|
87
|
+
const reason = useMemo(() => reasoningOf(groups), [groups])
|
|
88
|
+
const facts = useSyncExternalStore((fn) => factsSource.subscribe(fn), () => factsSource.getSnapshot())
|
|
89
|
+
|
|
90
|
+
const menuRows = useMemo(() => currentGroup === undefined ? [] : orderWithFavorites(
|
|
91
|
+
currentGroup.models,
|
|
92
|
+
(model) => isFavorite(favorites, currentGroup.id, model.id),
|
|
93
|
+
), [currentGroup, favorites])
|
|
80
94
|
|
|
95
|
+
const raiseToast = (text: string) => {
|
|
96
|
+
toastSeq.current += 1
|
|
97
|
+
setToast({ seq: toastSeq.current, text })
|
|
98
|
+
}
|
|
81
99
|
const reload = () => {
|
|
82
|
-
lastActionRef.current = 'load'
|
|
83
100
|
load()
|
|
84
101
|
}
|
|
102
|
+
const submit = (selection: Selection, onDone: (() => void) | null, onRejected?: () => void) => {
|
|
103
|
+
select(selection).then((result) => {
|
|
104
|
+
if (result === undefined || result.ok) {
|
|
105
|
+
// The Host accepted it, so this is now the level this model uses.
|
|
106
|
+
remember(selection.provider, selection.model, selection.reasoningEffort)
|
|
107
|
+
onDone?.()
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
onRejected?.()
|
|
111
|
+
raiseToast(result.error.code === 'session/writer-held'
|
|
112
|
+
? t('error.sessionInUse')
|
|
113
|
+
: t('error.action', { message: `${result.error.code}: ${result.error.message}` }))
|
|
114
|
+
})
|
|
115
|
+
}
|
|
116
|
+
/** Close the menu. Closing writes nothing: every choice already committed on click. */
|
|
117
|
+
const closeMenu = useCallback((restoreFocus: boolean) => {
|
|
118
|
+
setOpen(false)
|
|
119
|
+
if (restoreFocus) queueMicrotask(() => { triggerRef.current?.focus() })
|
|
120
|
+
}, [])
|
|
85
121
|
|
|
86
122
|
useEffect(() => {
|
|
87
123
|
if (!open) return
|
|
88
124
|
const closeOutside = (event: MouseEvent) => {
|
|
89
125
|
if (rootRef.current?.contains(event.target as Node) === true) return
|
|
90
126
|
if (menuRef.current?.contains(event.target as Node) === true) return
|
|
91
|
-
|
|
92
|
-
setPane('root')
|
|
127
|
+
closeMenu(false)
|
|
93
128
|
}
|
|
94
129
|
document.addEventListener('mousedown', closeOutside)
|
|
95
130
|
return () => {
|
|
96
131
|
document.removeEventListener('mousedown', closeOutside)
|
|
97
132
|
}
|
|
98
|
-
}, [open])
|
|
133
|
+
}, [open, closeMenu])
|
|
99
134
|
|
|
100
135
|
useEffect(() => {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
}, [open, pane])
|
|
136
|
+
if (!open) return
|
|
137
|
+
const frame = requestAnimationFrame(() => {
|
|
138
|
+
const checked = menuRef.current?.querySelector<HTMLElement>('[role="menuitemradio"][aria-checked="true"]:not([disabled])')
|
|
139
|
+
const first = menuRef.current?.querySelector<HTMLElement>('[role="menuitemradio"]:not([disabled])')
|
|
140
|
+
const target = checked ?? first ?? triggerRef.current
|
|
141
|
+
target?.scrollIntoView({ block: 'nearest' })
|
|
142
|
+
target?.focus()
|
|
143
|
+
})
|
|
144
|
+
return () => { cancelAnimationFrame(frame) }
|
|
145
|
+
}, [open])
|
|
112
146
|
|
|
113
147
|
useLayoutEffect(() => {
|
|
114
148
|
if (!open) {
|
|
@@ -121,6 +155,7 @@ export function ModelSubmenu({ locked, available, directory, load, select, t }:
|
|
|
121
155
|
const MARGIN = 12
|
|
122
156
|
const lw = menuRef.current?.offsetWidth ?? 0
|
|
123
157
|
const lh = menuRef.current?.offsetHeight ?? 0
|
|
158
|
+
// Right edges align, so the card grows leftward from the trigger's corner.
|
|
124
159
|
let x = rect.right - lw
|
|
125
160
|
let y = rect.top - 8 - lh
|
|
126
161
|
if (lw > 0) x = Math.min(Math.max(x, MARGIN), window.innerWidth - lw - MARGIN)
|
|
@@ -134,61 +169,71 @@ export function ModelSubmenu({ locked, available, directory, load, select, t }:
|
|
|
134
169
|
window.removeEventListener('scroll', place, true)
|
|
135
170
|
window.removeEventListener('resize', place)
|
|
136
171
|
}
|
|
137
|
-
}, [open,
|
|
172
|
+
}, [open, state])
|
|
138
173
|
|
|
139
174
|
if (!available) return null
|
|
140
175
|
|
|
141
176
|
const show = () => {
|
|
142
|
-
setPane('root')
|
|
143
|
-
setProviderId(null)
|
|
144
177
|
setOpen(true)
|
|
145
178
|
reload()
|
|
146
179
|
}
|
|
147
|
-
|
|
180
|
+
/** The picker door: the level already landed on click, so only the surface changes. */
|
|
181
|
+
const openPicker = () => {
|
|
148
182
|
setOpen(false)
|
|
149
|
-
|
|
150
|
-
setProviderId(null)
|
|
151
|
-
if (restoreFocus) queueMicrotask(() => {
|
|
152
|
-
triggerRef.current?.focus()
|
|
153
|
-
})
|
|
183
|
+
setPickerOpen(true)
|
|
154
184
|
}
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
185
|
+
/** Record one model's level, so switching away and back restores it. */
|
|
186
|
+
const remember = (provider: string, model: string, effort: string | undefined) => {
|
|
187
|
+
setMemory(rememberEffort(storageOf(), provider, model, effort))
|
|
158
188
|
}
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
if (
|
|
162
|
-
|
|
163
|
-
|
|
189
|
+
const chooseModel = (provider: string, model: CatalogModel) => {
|
|
190
|
+
const picked = planModelCommit(live, provider, model.id, reason, effortFor(memory, provider, model.id))
|
|
191
|
+
if (picked === null) {
|
|
192
|
+
setOpen(false)
|
|
193
|
+
queueMicrotask(() => { triggerRef.current?.focus() })
|
|
164
194
|
return
|
|
165
195
|
}
|
|
166
|
-
|
|
167
|
-
|
|
196
|
+
// A model click is the decision: close at once, and keep the menu open when
|
|
197
|
+
// the host rejects it so the user can try another model.
|
|
198
|
+
setOpen(false)
|
|
199
|
+
submit(picked, () => { queueMicrotask(() => { triggerRef.current?.focus() }) }, () => {
|
|
200
|
+
// The host refused it: put the menu back so the user can pick another.
|
|
201
|
+
setOpen(true)
|
|
202
|
+
})
|
|
168
203
|
}
|
|
204
|
+
const rowButtons = () => [...(menuRef.current?.querySelectorAll<HTMLButtonElement>('[role="menuitemradio"]:not([disabled])') ?? [])]
|
|
169
205
|
const moveFocus = (offset: number) => {
|
|
170
|
-
const items =
|
|
206
|
+
const items = rowButtons()
|
|
171
207
|
if (items.length === 0) return
|
|
172
208
|
const active = items.findIndex((item) => item === document.activeElement)
|
|
173
209
|
items[active === -1 ? offset > 0 ? 0 : items.length - 1 : (active + offset + items.length) % items.length]?.focus()
|
|
174
210
|
}
|
|
211
|
+
const onEffortKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
|
212
|
+
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return
|
|
213
|
+
if (choices.length === 0 || live === null) return
|
|
214
|
+
event.preventDefault()
|
|
215
|
+
const index = choices.findIndex((choice) => choice.effort === effectiveEffort)
|
|
216
|
+
const step = event.key === 'ArrowRight' ? 1 : -1
|
|
217
|
+
const next = choices[((index === -1 ? 0 : index) + step + choices.length) % choices.length]
|
|
218
|
+
if (next === undefined) return
|
|
219
|
+
const pending = planEffortCommit(live, next.effort)
|
|
220
|
+
if (pending !== null) submit(pending, null)
|
|
221
|
+
}
|
|
175
222
|
const onRootKeyDown = (event: React.KeyboardEvent) => {
|
|
176
223
|
if (event.key === 'Escape' && open) {
|
|
177
224
|
event.preventDefault()
|
|
178
|
-
|
|
179
|
-
else close(true)
|
|
225
|
+
closeMenu(true)
|
|
180
226
|
return
|
|
181
227
|
}
|
|
182
228
|
if (!open) return
|
|
183
229
|
if (event.key === 'Tab') {
|
|
184
230
|
if (event.shiftKey) {
|
|
185
231
|
event.preventDefault()
|
|
186
|
-
|
|
187
|
-
else close(true)
|
|
232
|
+
closeMenu(true)
|
|
188
233
|
return
|
|
189
234
|
}
|
|
190
235
|
const focused = document.activeElement
|
|
191
|
-
const rows =
|
|
236
|
+
const rows = rowButtons()
|
|
192
237
|
if (focused instanceof HTMLButtonElement && rows.includes(focused)) {
|
|
193
238
|
event.preventDefault()
|
|
194
239
|
focused.click()
|
|
@@ -196,8 +241,8 @@ export function ModelSubmenu({ locked, available, directory, load, select, t }:
|
|
|
196
241
|
}
|
|
197
242
|
if (focused !== triggerRef.current) return
|
|
198
243
|
event.preventDefault()
|
|
199
|
-
const checked = menuRef.current?.querySelector<HTMLElement>('[role="menuitemradio"][aria-checked="true"]:not([disabled])
|
|
200
|
-
;(checked ?? rows
|
|
244
|
+
const checked = menuRef.current?.querySelector<HTMLElement>('[role="menuitemradio"][aria-checked="true"]:not([disabled])')
|
|
245
|
+
;(checked ?? rows[0])?.focus()
|
|
201
246
|
return
|
|
202
247
|
}
|
|
203
248
|
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
|
@@ -205,46 +250,24 @@ export function ModelSubmenu({ locked, available, directory, load, select, t }:
|
|
|
205
250
|
moveFocus(event.key === 'ArrowDown' ? 1 : -1)
|
|
206
251
|
}
|
|
207
252
|
}
|
|
253
|
+
/**
|
|
254
|
+
* The keyboard leaving the seat closes the menu — but that is not one of the
|
|
255
|
+
* four closing gestures that write a mark, so it closes without committing.
|
|
256
|
+
* Otherwise clicking the notice text or a divider inside the menu, which moves
|
|
257
|
+
* focus without unmounting it, would land a level nobody asked to commit.
|
|
258
|
+
*/
|
|
208
259
|
const onBlur = (event: React.FocusEvent) => {
|
|
209
|
-
if (
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
if (result === undefined) return
|
|
214
|
-
if (result.ok) {
|
|
215
|
-
if (rootRef.current !== null) close(true)
|
|
216
|
-
return
|
|
217
|
-
}
|
|
218
|
-
const { error } = result
|
|
219
|
-
toastSeq.current += 1
|
|
220
|
-
setToast({
|
|
221
|
-
seq: toastSeq.current,
|
|
222
|
-
text: error.code === 'session/writer-held' ? t('error.sessionInUse') : t('error.action', { message: `${error.code}: ${error.message}` }),
|
|
223
|
-
})
|
|
224
|
-
}
|
|
225
|
-
const submit = (selection: Selection) => {
|
|
226
|
-
lastActionRef.current = 'select'
|
|
227
|
-
select(selection).then(settleSelection)
|
|
228
|
-
}
|
|
229
|
-
/** A model commits outright; its reasoning levels stay behind the root's effort cell. */
|
|
230
|
-
const chooseModel = (provider: string, model: CatalogModel) => {
|
|
231
|
-
if (current?.provider === provider && current.model === model.id) {
|
|
232
|
-
close(true)
|
|
233
|
-
return
|
|
234
|
-
}
|
|
235
|
-
submit({ provider, model: model.id })
|
|
260
|
+
if (pickerOpen) return
|
|
261
|
+
if (event.relatedTarget instanceof Node
|
|
262
|
+
&& (rootRef.current?.contains(event.relatedTarget) === true || menuRef.current?.contains(event.relatedTarget) === true)) return
|
|
263
|
+
setOpen(false)
|
|
236
264
|
}
|
|
237
|
-
const
|
|
238
|
-
|
|
239
|
-
if (effectiveEffort === effort) {
|
|
240
|
-
close(true)
|
|
241
|
-
return
|
|
242
|
-
}
|
|
243
|
-
submit({ provider: current.provider, model: current.model, ...effort === undefined ? {} : { reasoningEffort: effort } })
|
|
265
|
+
const toggleFavoriteOf = (provider: string, model: string) => {
|
|
266
|
+
setFavorites(toggleFavorite(storageOf(), provider, model))
|
|
244
267
|
}
|
|
268
|
+
|
|
245
269
|
const waiting = current === null && state.status === 'loading'
|
|
246
270
|
const modelLabel = waiting ? t('trigger.loading') : currentModel?.name ?? (current === null ? t('trigger.fallback') : `${current.provider}/${current.model}`)
|
|
247
|
-
const triggerLabel = effortLabel === undefined ? modelLabel : `${modelLabel} · ${effortLabel}`
|
|
248
271
|
const triggerAria = waiting
|
|
249
272
|
? t('trigger.loading')
|
|
250
273
|
: current === null
|
|
@@ -252,18 +275,12 @@ export function ModelSubmenu({ locked, available, directory, load, select, t }:
|
|
|
252
275
|
: effortLabel === undefined
|
|
253
276
|
? t('trigger.aria', { model: modelLabel })
|
|
254
277
|
: t('trigger.ariaEffort', { model: modelLabel, effort: effortLabel })
|
|
278
|
+
const providerName = currentGroup?.name
|
|
279
|
+
?? state.failures.find((failure) => failure.id === current?.provider)?.name
|
|
280
|
+
?? current?.provider
|
|
281
|
+
?? ''
|
|
282
|
+
const providerFailure = current === null ? undefined : state.failures.find((failure) => failure.id === current.provider)
|
|
255
283
|
|
|
256
|
-
itemRefs.current = new Map()
|
|
257
|
-
const itemRef = (key: string) => (node: HTMLButtonElement | null) => {
|
|
258
|
-
if (node === null) itemRefs.current.delete(key)
|
|
259
|
-
else itemRefs.current.set(key, node)
|
|
260
|
-
}
|
|
261
|
-
const backRow = () => (
|
|
262
|
-
<button ref={itemRef('back')} type="button" role="menuitem" className={css.cell} onClick={back}>
|
|
263
|
-
<IconChevronLeftOutlineRegular className={css.cellChevron} />
|
|
264
|
-
<span className={css.cellLabel}>{t('back')}</span>
|
|
265
|
-
</button>
|
|
266
|
-
)
|
|
267
284
|
return (
|
|
268
285
|
<div ref={rootRef} className={css.root} onKeyDown={onRootKeyDown} onBlur={onBlur}>
|
|
269
286
|
<button
|
|
@@ -274,14 +291,14 @@ export function ModelSubmenu({ locked, available, directory, load, select, t }:
|
|
|
274
291
|
aria-haspopup="menu"
|
|
275
292
|
aria-expanded={open}
|
|
276
293
|
aria-controls={open ? `${id}-menu` : undefined}
|
|
277
|
-
title={
|
|
294
|
+
title={effortLabel === undefined ? modelLabel : `${modelLabel} · ${effortLabel}`}
|
|
278
295
|
disabled={locked}
|
|
279
296
|
onClick={() => {
|
|
280
|
-
if (open)
|
|
297
|
+
if (open) closeMenu(false)
|
|
281
298
|
else show()
|
|
282
299
|
}}
|
|
283
300
|
>
|
|
284
|
-
<
|
|
301
|
+
<IconFlatListOutlineRegular className={css.triggerIcon} size={16} />
|
|
285
302
|
<span className={css.triggerLabel}>{modelLabel}</span>
|
|
286
303
|
{effortLabel !== undefined && <span className={css.triggerEffort}>{effortLabel}</span>}
|
|
287
304
|
<IconChevronDownOutlineRegular className={cls(css.chevron, open && css.chevronOpen)} />
|
|
@@ -292,125 +309,95 @@ export function ModelSubmenu({ locked, available, directory, load, select, t }:
|
|
|
292
309
|
id={`${id}-menu`}
|
|
293
310
|
className={css.menu}
|
|
294
311
|
style={menuPos ?? MEASURE_STYLE}
|
|
295
|
-
role="
|
|
296
|
-
aria-label={
|
|
312
|
+
role="group"
|
|
313
|
+
aria-label={t('menu.aria')}
|
|
297
314
|
aria-busy={state.status === 'loading' || busy}
|
|
298
315
|
>
|
|
299
|
-
{
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
{reasoning !== undefined && (
|
|
307
|
-
<button ref={itemRef('root:effort')} type="button" role="menuitem" className={css.cell} onClick={() => drill('effort')}>
|
|
308
|
-
<span className={css.cellLabel}>{t('menu.effort')}</span>
|
|
309
|
-
<span className={css.cellValue}>{effortLabel}</span>
|
|
310
|
-
<IconChevronRightOutlineRegular className={css.cellChevron} />
|
|
311
|
-
</button>
|
|
312
|
-
)}
|
|
313
|
-
</Fragment>
|
|
316
|
+
<div className={css.providerName} title={providerName}>{providerName}</div>
|
|
317
|
+
{state.status === 'loading' && <div className={css.notice}>{t('status.loading')}</div>}
|
|
318
|
+
{state.error !== null && state.status === 'error' && (
|
|
319
|
+
<div className={css.notice}>
|
|
320
|
+
<span>{t('status.failed', { message: state.error })}</span>
|
|
321
|
+
<button type="button" className={css.retry} onClick={reload}>{t('action.reload')}</button>
|
|
322
|
+
</div>
|
|
314
323
|
)}
|
|
315
|
-
{
|
|
316
|
-
<
|
|
317
|
-
{
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
<span>{t('error.action', { message: state.error })}</span>
|
|
321
|
-
<button type="button" className={css.retry} onClick={reload}>{t('action.reload')}</button>
|
|
322
|
-
</div>
|
|
323
|
-
)}
|
|
324
|
-
{state.failures.map((failure) => (
|
|
325
|
-
<div className={css.warning} key={failure.id}>
|
|
326
|
-
<span>{t('warning.groupLoad', { name: failure.name, message: failure.message })}</span>
|
|
327
|
-
<button type="button" className={css.retry} onClick={reload}>{t('action.reload')}</button>
|
|
328
|
-
</div>
|
|
329
|
-
))}
|
|
330
|
-
{state.status === 'ready' && groups.length === 0 && <div className={css.empty}>{t('empty.models')}</div>}
|
|
331
|
-
<div className={cls(css.groups, 'scrollable')}>
|
|
332
|
-
{groups.map((group) => {
|
|
333
|
-
const mine = current?.provider === group.id
|
|
334
|
-
return (
|
|
335
|
-
<button
|
|
336
|
-
key={group.id}
|
|
337
|
-
ref={itemRef(`provider:${group.id}`)}
|
|
338
|
-
type="button"
|
|
339
|
-
role="menuitem"
|
|
340
|
-
aria-current={mine ? 'true' : undefined}
|
|
341
|
-
className={css.cell}
|
|
342
|
-
title={group.name}
|
|
343
|
-
onClick={() => {
|
|
344
|
-
setProviderId(group.id)
|
|
345
|
-
drill('models')
|
|
346
|
-
}}
|
|
347
|
-
>
|
|
348
|
-
<span className={css.cellLabel}>{group.name}</span>
|
|
349
|
-
<span className={css.cellValue}>{mine ? currentModel?.name ?? current?.model ?? '' : t('provider.modelCount', { count: String(group.models.length) })}</span>
|
|
350
|
-
{mine && <span className={css.check}><IconCheckOutlineRegular /></span>}
|
|
351
|
-
<IconChevronRightOutlineRegular className={css.cellChevron} />
|
|
352
|
-
</button>
|
|
353
|
-
)
|
|
354
|
-
})}
|
|
355
|
-
</div>
|
|
356
|
-
</Fragment>
|
|
324
|
+
{providerFailure !== undefined && (
|
|
325
|
+
<div className={css.notice}>
|
|
326
|
+
<span>{t('status.providerFailed', { name: providerFailure.name, message: providerFailure.message })}</span>
|
|
327
|
+
<button type="button" className={css.retry} onClick={reload}>{t('action.reload')}</button>
|
|
328
|
+
</div>
|
|
357
329
|
)}
|
|
358
|
-
{
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
{
|
|
363
|
-
|
|
364
|
-
|
|
330
|
+
<div className={cls(css.groups, 'scrollable')} role="menu" aria-label={t('menu.aria')}>
|
|
331
|
+
{menuRows.map((model) => (
|
|
332
|
+
<ModelRow
|
|
333
|
+
key={model.id}
|
|
334
|
+
name={model.name}
|
|
335
|
+
selected={current !== null && currentGroup !== undefined && current.provider === currentGroup.id && current.model === model.id}
|
|
336
|
+
busy={busy}
|
|
337
|
+
star="static"
|
|
338
|
+
favorited={currentGroup !== undefined && isFavorite(favorites, currentGroup.id, model.id)}
|
|
339
|
+
onPick={() => {
|
|
340
|
+
if (currentGroup !== undefined) chooseModel(currentGroup.id, model)
|
|
341
|
+
}}
|
|
342
|
+
/>
|
|
343
|
+
))}
|
|
344
|
+
{menuRows.length === 0 && state.status === 'ready' && (
|
|
345
|
+
<div className={css.notice}>{t('status.empty')}</div>
|
|
346
|
+
)}
|
|
347
|
+
</div>
|
|
348
|
+
{choices.length > 0 && (
|
|
349
|
+
<>
|
|
350
|
+
<div className={css.divider} />
|
|
351
|
+
<div className={css.effortRow}>
|
|
352
|
+
<span className={css.effortHeading}>{t('menu.effort')}</span>
|
|
353
|
+
<div className={css.effortTrack} role="radiogroup" aria-label={t('menu.effort')} onKeyDown={onEffortKeyDown}>
|
|
354
|
+
{choices.map((choice: EffortChoice) => (
|
|
365
355
|
<button
|
|
366
|
-
key={
|
|
367
|
-
ref={itemRef(`model:${shownGroup.id}/${model.id}`)}
|
|
356
|
+
key={choice.key}
|
|
368
357
|
type="button"
|
|
369
|
-
role="
|
|
370
|
-
aria-checked={
|
|
371
|
-
className={
|
|
372
|
-
title={model.name}
|
|
358
|
+
role="radio"
|
|
359
|
+
aria-checked={choice.effort === effectiveEffort}
|
|
360
|
+
className={css.effortSegment}
|
|
373
361
|
disabled={busy}
|
|
374
|
-
onClick={() =>
|
|
362
|
+
onClick={() => {
|
|
363
|
+
if (live !== null) {
|
|
364
|
+
const pending = planEffortCommit(live, choice.effort)
|
|
365
|
+
if (pending !== null) submit(pending, null)
|
|
366
|
+
}
|
|
367
|
+
}}
|
|
375
368
|
>
|
|
376
|
-
|
|
377
|
-
<span className={css.check}>{selected ? <IconCheckOutlineRegular /> : null}</span>
|
|
369
|
+
{choice.label}
|
|
378
370
|
</button>
|
|
379
|
-
)
|
|
380
|
-
})}
|
|
381
|
-
</div>
|
|
382
|
-
</Fragment>
|
|
383
|
-
)}
|
|
384
|
-
{pane === 'effort' && (
|
|
385
|
-
<Fragment>
|
|
386
|
-
{backRow()}
|
|
387
|
-
{state.error !== null && lastActionRef.current === 'load' && (
|
|
388
|
-
<div className={css.error}>
|
|
389
|
-
<span>{t('error.action', { message: state.error })}</span>
|
|
390
|
-
<button type="button" className={css.retry} onClick={reload}>{t('action.reload')}</button>
|
|
371
|
+
))}
|
|
391
372
|
</div>
|
|
392
|
-
|
|
393
|
-
{
|
|
394
|
-
|
|
395
|
-
: effortChoices.map((level) => (
|
|
396
|
-
<button
|
|
397
|
-
key={level.key}
|
|
398
|
-
ref={itemRef(`effort:${level.key}`)}
|
|
399
|
-
type="button"
|
|
400
|
-
role="menuitemradio"
|
|
401
|
-
aria-checked={effectiveEffort === level.effort}
|
|
402
|
-
className={cls(css.option, effectiveEffort === level.effort && css.selected)}
|
|
403
|
-
disabled={busy}
|
|
404
|
-
onClick={() => chooseEffort(level.effort)}
|
|
405
|
-
>
|
|
406
|
-
<span className={css.optionCopy}><span className={css.modelName}>{level.label}</span></span>
|
|
407
|
-
<span className={css.check}>{effectiveEffort === level.effort ? <IconCheckOutlineRegular /> : null}</span>
|
|
408
|
-
</button>
|
|
409
|
-
))}
|
|
410
|
-
</Fragment>
|
|
373
|
+
</div>
|
|
374
|
+
<div className={css.divider} />
|
|
375
|
+
</>
|
|
411
376
|
)}
|
|
377
|
+
<p className={css.hint}>{t('menu.hint')}</p>
|
|
378
|
+
<div className={css.divider} />
|
|
379
|
+
<button type="button" role="menuitem" className={css.moreRow} onClick={openPicker}>
|
|
380
|
+
<IconFlatListOutlineRegular className={css.moreIcon} size={16} />
|
|
381
|
+
<span>{t('menu.more')}</span>
|
|
382
|
+
</button>
|
|
412
383
|
</MenuSurface>
|
|
413
384
|
), document.body)}
|
|
385
|
+
<ModelPicker
|
|
386
|
+
open={pickerOpen}
|
|
387
|
+
state={state}
|
|
388
|
+
live={live}
|
|
389
|
+
facts={facts}
|
|
390
|
+
favorites={favorites}
|
|
391
|
+
busy={busy}
|
|
392
|
+
t={t}
|
|
393
|
+
onPick={(selection) => { submit(selection, () => { setPickerOpen(false) }) }}
|
|
394
|
+
onToggleFavorite={toggleFavoriteOf}
|
|
395
|
+
onReload={reload}
|
|
396
|
+
onClose={() => {
|
|
397
|
+
setPickerOpen(false)
|
|
398
|
+
queueMicrotask(() => { triggerRef.current?.focus() })
|
|
399
|
+
}}
|
|
400
|
+
/>
|
|
414
401
|
{toast !== null && (
|
|
415
402
|
<Toast
|
|
416
403
|
key={toast.seq}
|
|
@@ -424,4 +411,21 @@ export function ModelSubmenu({ locked, available, directory, load, select, t }:
|
|
|
424
411
|
)}
|
|
425
412
|
</div>
|
|
426
413
|
)
|
|
427
|
-
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** The selection in force, or null while the host has not resolved one. */
|
|
417
|
+
function selectionOf(state: ModelDirectoryState): Selection | null {
|
|
418
|
+
if (state.current === null) return null
|
|
419
|
+
return state.current.reasoningEffort === undefined
|
|
420
|
+
? { provider: state.current.provider, model: state.current.model }
|
|
421
|
+
: { provider: state.current.provider, model: state.current.model, reasoningEffort: state.current.reasoningEffort }
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** Isolate the browser's storage so a locked-down browser reads as "no favorites". */
|
|
425
|
+
function storageOf(): FavoritesStorage | undefined {
|
|
426
|
+
try {
|
|
427
|
+
return globalThis.localStorage
|
|
428
|
+
} catch {
|
|
429
|
+
return undefined
|
|
430
|
+
}
|
|
431
|
+
}
|