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