@lynn123411/dsh-model-submenu 0.1.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/README.md +19 -0
- package/cordis.patch.yml +4 -0
- package/lib/client.js +620 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +5 -0
- package/package.json +84 -0
- package/src/client/ModelSubmenu.module.css +247 -0
- package/src/client/ModelSubmenu.tsx +426 -0
- package/src/client/index.ts +59 -0
- package/src/client/locales.ts +76 -0
- package/src/css-modules.d.ts +4 -0
- package/src/index.ts +2 -0
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
import { Fragment, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'
|
|
2
|
+
import { createPortal } from 'react-dom'
|
|
3
|
+
import {
|
|
4
|
+
IconCheckOutline16,
|
|
5
|
+
IconChevronDownOutline14,
|
|
6
|
+
IconChevronLeftOutline14,
|
|
7
|
+
IconChevronRightOutline14,
|
|
8
|
+
IconDataOutline16,
|
|
9
|
+
IconWarningOutline16,
|
|
10
|
+
Toast,
|
|
11
|
+
} from '@deepseek-ai/dsh-client-ui-primitives'
|
|
12
|
+
import type { ModelDirectoryState, ModelSelectInjected } from '@deepseek-ai/dsh-client-ui-model-selection/client'
|
|
13
|
+
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
|
|
14
|
+
import css from './ModelSubmenu.module.css'
|
|
15
|
+
|
|
16
|
+
type ModelSubmenuProps = ModelSelectInjected & { locked: boolean } & PropsLocale<'modelSubmenu'>
|
|
17
|
+
|
|
18
|
+
type Selection = Parameters<ModelSelectInjected['select']>[0]
|
|
19
|
+
type ProviderGroup = ModelDirectoryState['groups'][number]
|
|
20
|
+
type CatalogModel = ProviderGroup['models'][number]
|
|
21
|
+
|
|
22
|
+
/** Which list the open menu is showing. */
|
|
23
|
+
type Pane = 'root' | 'providers' | 'models' | 'effort'
|
|
24
|
+
|
|
25
|
+
/** Unplaced portal card: hidden but laid out at a fixed origin so offsetWidth/offsetHeight are real. */
|
|
26
|
+
const MEASURE_STYLE = {
|
|
27
|
+
visibility: 'hidden',
|
|
28
|
+
left: 0,
|
|
29
|
+
top: 0,
|
|
30
|
+
} as const
|
|
31
|
+
|
|
32
|
+
const cls = (...parts: readonly (string | false | undefined)[]): string => parts.filter((part) => part !== false && part !== undefined).join(' ')
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The composer's model seat (`conversation.input.model`), taken over from the
|
|
36
|
+
* built-in occupant with a provider level: the root pane keeps the built-in's
|
|
37
|
+
* two cells — 供应商 in place of 模型, plus the native 推理等级 — the provider
|
|
38
|
+
* cell drills into the catalog's providers, and a provider drills into its
|
|
39
|
+
* models before submitting. The trigger, the menu surface, the keyboard
|
|
40
|
+
* contract (arrows move, Tab settles, Escape and Shift+Tab leave a drilled pane
|
|
41
|
+
* first), the effort list and the failure surfaces are the built-in ones.
|
|
42
|
+
*/
|
|
43
|
+
export function ModelSubmenu({ locked, available, directory, load, select, t }: ModelSubmenuProps) {
|
|
44
|
+
const state = useSyncExternalStore((fn) => directory.subscribe(fn), () => directory.getSnapshot())
|
|
45
|
+
const [open, setOpen] = useState(false)
|
|
46
|
+
const [pane, setPane] = useState<Pane>('root')
|
|
47
|
+
const [providerId, setProviderId] = useState<string | null>(null)
|
|
48
|
+
const lastActionRef = useRef<'load' | 'select'>('load')
|
|
49
|
+
const [toast, setToast] = useState<{ seq: number; text: string } | null>(null)
|
|
50
|
+
const toastSeq = useRef(0)
|
|
51
|
+
const rootRef = useRef<HTMLDivElement | null>(null)
|
|
52
|
+
const triggerRef = useRef<HTMLButtonElement | null>(null)
|
|
53
|
+
const menuRef = useRef<HTMLDivElement | null>(null)
|
|
54
|
+
const [menuPos, setMenuPos] = useState<{ left: number; top: number } | null>(null)
|
|
55
|
+
const itemRefs = useRef(new Map<string, HTMLButtonElement>())
|
|
56
|
+
const focusIntent = useRef<string | null>(null)
|
|
57
|
+
const id = useId()
|
|
58
|
+
|
|
59
|
+
const groups = state.groups
|
|
60
|
+
const current = state.current
|
|
61
|
+
const currentGroup = current === null ? undefined : groups.find((group) => group.id === current.provider)
|
|
62
|
+
const currentModel = currentGroup?.models.find((model) => model.id === current?.model)
|
|
63
|
+
const reasoning = currentModel?.reasoning
|
|
64
|
+
const effectiveEffort = current?.reasoningEffort ?? reasoning?.defaultEffort
|
|
65
|
+
const effortLabel = reasoning === undefined
|
|
66
|
+
? undefined
|
|
67
|
+
: effectiveEffort === undefined
|
|
68
|
+
? t('effort.providerDefault')
|
|
69
|
+
: reasoning.efforts.find((level) => level.id === effectiveEffort)?.name ?? effectiveEffort
|
|
70
|
+
const effortChoices = useMemo(() => reasoning === undefined ? [] : [
|
|
71
|
+
...reasoning.defaultEffort === undefined
|
|
72
|
+
? [{ key: 'provider-default', effort: undefined as string | undefined, label: t('effort.providerDefault') }]
|
|
73
|
+
: [],
|
|
74
|
+
...reasoning.efforts.map((level) => ({ key: `effort:${level.id}`, effort: level.id as string | undefined, label: level.name })),
|
|
75
|
+
], [reasoning, t])
|
|
76
|
+
|
|
77
|
+
const shownGroup = pane === 'models' && providerId !== null ? groups.find((group) => group.id === providerId) : undefined
|
|
78
|
+
const busy = state.status === 'selecting'
|
|
79
|
+
|
|
80
|
+
const reload = () => {
|
|
81
|
+
lastActionRef.current = 'load'
|
|
82
|
+
load()
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
if (!open) return
|
|
87
|
+
const closeOutside = (event: MouseEvent) => {
|
|
88
|
+
if (rootRef.current?.contains(event.target as Node) === true) return
|
|
89
|
+
if (menuRef.current?.contains(event.target as Node) === true) return
|
|
90
|
+
setOpen(false)
|
|
91
|
+
setPane('root')
|
|
92
|
+
}
|
|
93
|
+
document.addEventListener('mousedown', closeOutside)
|
|
94
|
+
return () => {
|
|
95
|
+
document.removeEventListener('mousedown', closeOutside)
|
|
96
|
+
}
|
|
97
|
+
}, [open])
|
|
98
|
+
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
const intent = focusIntent.current
|
|
101
|
+
focusIntent.current = null
|
|
102
|
+
if (!open || intent === null) return
|
|
103
|
+
if (intent === 'initial') {
|
|
104
|
+
const checked = menuRef.current?.querySelector<HTMLElement>('[role="menuitemradio"][aria-checked="true"]:not([disabled]), [role="menuitem"][aria-current="true"]:not([disabled])')
|
|
105
|
+
const first = [...itemRefs.current].find(([key]) => !key.startsWith('back:'))?.[1]
|
|
106
|
+
;(checked ?? first ?? triggerRef.current)?.focus()
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
;(itemRefs.current.get(intent) ?? triggerRef.current)?.focus()
|
|
110
|
+
}, [open, pane])
|
|
111
|
+
|
|
112
|
+
useLayoutEffect(() => {
|
|
113
|
+
if (!open) {
|
|
114
|
+
setMenuPos(null)
|
|
115
|
+
return
|
|
116
|
+
}
|
|
117
|
+
const place = () => {
|
|
118
|
+
const rect = triggerRef.current?.getBoundingClientRect()
|
|
119
|
+
if (rect === undefined) return
|
|
120
|
+
const MARGIN = 12
|
|
121
|
+
const lw = menuRef.current?.offsetWidth ?? 0
|
|
122
|
+
const lh = menuRef.current?.offsetHeight ?? 0
|
|
123
|
+
let x = rect.right - lw
|
|
124
|
+
let y = rect.top - 8 - lh
|
|
125
|
+
if (lw > 0) x = Math.min(Math.max(x, MARGIN), window.innerWidth - lw - MARGIN)
|
|
126
|
+
if (lh > 0) y = Math.min(Math.max(y, MARGIN), window.innerHeight - lh - MARGIN)
|
|
127
|
+
setMenuPos({ left: x, top: y })
|
|
128
|
+
}
|
|
129
|
+
place()
|
|
130
|
+
window.addEventListener('scroll', place, true)
|
|
131
|
+
window.addEventListener('resize', place)
|
|
132
|
+
return () => {
|
|
133
|
+
window.removeEventListener('scroll', place, true)
|
|
134
|
+
window.removeEventListener('resize', place)
|
|
135
|
+
}
|
|
136
|
+
}, [open, pane, state])
|
|
137
|
+
|
|
138
|
+
if (!available) return null
|
|
139
|
+
|
|
140
|
+
const show = () => {
|
|
141
|
+
setPane('root')
|
|
142
|
+
setProviderId(null)
|
|
143
|
+
setOpen(true)
|
|
144
|
+
reload()
|
|
145
|
+
}
|
|
146
|
+
const close = (restoreFocus = false) => {
|
|
147
|
+
setOpen(false)
|
|
148
|
+
setPane('root')
|
|
149
|
+
setProviderId(null)
|
|
150
|
+
if (restoreFocus) queueMicrotask(() => {
|
|
151
|
+
triggerRef.current?.focus()
|
|
152
|
+
})
|
|
153
|
+
}
|
|
154
|
+
const drill = (next: Pane) => {
|
|
155
|
+
focusIntent.current = 'initial'
|
|
156
|
+
setPane(next)
|
|
157
|
+
}
|
|
158
|
+
/** Leave the shown pane for its parent, handing the keyboard back to the row that opened it. */
|
|
159
|
+
const back = () => {
|
|
160
|
+
if (pane === 'models') {
|
|
161
|
+
focusIntent.current = providerId === null ? null : `provider:${providerId}`
|
|
162
|
+
setPane('providers')
|
|
163
|
+
return
|
|
164
|
+
}
|
|
165
|
+
focusIntent.current = pane === 'effort' ? 'root:effort' : 'root:provider'
|
|
166
|
+
setPane('root')
|
|
167
|
+
}
|
|
168
|
+
const moveFocus = (offset: number) => {
|
|
169
|
+
const items = [...itemRefs.current.values()]
|
|
170
|
+
if (items.length === 0) return
|
|
171
|
+
const active = items.findIndex((item) => item === document.activeElement)
|
|
172
|
+
items[active === -1 ? offset > 0 ? 0 : items.length - 1 : (active + offset + items.length) % items.length]?.focus()
|
|
173
|
+
}
|
|
174
|
+
const onRootKeyDown = (event: React.KeyboardEvent) => {
|
|
175
|
+
if (event.key === 'Escape' && open) {
|
|
176
|
+
event.preventDefault()
|
|
177
|
+
if (pane !== 'root') back()
|
|
178
|
+
else close(true)
|
|
179
|
+
return
|
|
180
|
+
}
|
|
181
|
+
if (!open) return
|
|
182
|
+
if (event.key === 'Tab') {
|
|
183
|
+
if (event.shiftKey) {
|
|
184
|
+
event.preventDefault()
|
|
185
|
+
if (pane !== 'root') back()
|
|
186
|
+
else close(true)
|
|
187
|
+
return
|
|
188
|
+
}
|
|
189
|
+
const focused = document.activeElement
|
|
190
|
+
const rows = [...itemRefs.current.values()]
|
|
191
|
+
if (focused instanceof HTMLButtonElement && rows.includes(focused)) {
|
|
192
|
+
event.preventDefault()
|
|
193
|
+
focused.click()
|
|
194
|
+
return
|
|
195
|
+
}
|
|
196
|
+
if (focused !== triggerRef.current) return
|
|
197
|
+
event.preventDefault()
|
|
198
|
+
const checked = menuRef.current?.querySelector<HTMLElement>('[role="menuitemradio"][aria-checked="true"]:not([disabled]), [role="menuitem"][aria-current="true"]:not([disabled])')
|
|
199
|
+
;(checked ?? rows.find((item) => !item.disabled))?.focus()
|
|
200
|
+
return
|
|
201
|
+
}
|
|
202
|
+
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
|
203
|
+
event.preventDefault()
|
|
204
|
+
moveFocus(event.key === 'ArrowDown' ? 1 : -1)
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
const onBlur = (event: React.FocusEvent) => {
|
|
208
|
+
if (event.relatedTarget instanceof Node && (rootRef.current?.contains(event.relatedTarget) === true || menuRef.current?.contains(event.relatedTarget) === true)) return
|
|
209
|
+
close()
|
|
210
|
+
}
|
|
211
|
+
const settleSelection = (result: Awaited<ReturnType<ModelSelectInjected['select']>>) => {
|
|
212
|
+
if (result === undefined) return
|
|
213
|
+
if (result.ok) {
|
|
214
|
+
if (rootRef.current !== null) close(true)
|
|
215
|
+
return
|
|
216
|
+
}
|
|
217
|
+
const { error } = result
|
|
218
|
+
toastSeq.current += 1
|
|
219
|
+
setToast({
|
|
220
|
+
seq: toastSeq.current,
|
|
221
|
+
text: error.code === 'session/writer-held' ? t('error.sessionInUse') : t('error.action', { message: `${error.code}: ${error.message}` }),
|
|
222
|
+
})
|
|
223
|
+
}
|
|
224
|
+
const submit = (selection: Selection) => {
|
|
225
|
+
lastActionRef.current = 'select'
|
|
226
|
+
select(selection).then(settleSelection)
|
|
227
|
+
}
|
|
228
|
+
/** A model commits outright; its reasoning levels stay behind the root's effort cell. */
|
|
229
|
+
const chooseModel = (provider: string, model: CatalogModel) => {
|
|
230
|
+
if (current?.provider === provider && current.model === model.id) {
|
|
231
|
+
close(true)
|
|
232
|
+
return
|
|
233
|
+
}
|
|
234
|
+
submit({ provider, model: model.id })
|
|
235
|
+
}
|
|
236
|
+
const chooseEffort = (effort: string | undefined) => {
|
|
237
|
+
if (current === null) return
|
|
238
|
+
if (effectiveEffort === effort) {
|
|
239
|
+
close(true)
|
|
240
|
+
return
|
|
241
|
+
}
|
|
242
|
+
submit({ provider: current.provider, model: current.model, ...effort === undefined ? {} : { reasoningEffort: effort } })
|
|
243
|
+
}
|
|
244
|
+
const waiting = current === null && state.status === 'loading'
|
|
245
|
+
const modelLabel = waiting ? t('trigger.loading') : currentModel?.name ?? (current === null ? t('trigger.fallback') : `${current.provider}/${current.model}`)
|
|
246
|
+
const triggerLabel = effortLabel === undefined ? modelLabel : `${modelLabel} · ${effortLabel}`
|
|
247
|
+
const triggerAria = waiting
|
|
248
|
+
? t('trigger.loading')
|
|
249
|
+
: current === null
|
|
250
|
+
? t('trigger.selectAria')
|
|
251
|
+
: effortLabel === undefined
|
|
252
|
+
? t('trigger.aria', { model: modelLabel })
|
|
253
|
+
: t('trigger.ariaEffort', { model: modelLabel, effort: effortLabel })
|
|
254
|
+
|
|
255
|
+
itemRefs.current = new Map()
|
|
256
|
+
const itemRef = (key: string) => (node: HTMLButtonElement | null) => {
|
|
257
|
+
if (node === null) itemRefs.current.delete(key)
|
|
258
|
+
else itemRefs.current.set(key, node)
|
|
259
|
+
}
|
|
260
|
+
const backRow = () => (
|
|
261
|
+
<button ref={itemRef('back')} type="button" role="menuitem" className={css.cell} onClick={back}>
|
|
262
|
+
<IconChevronLeftOutline14 className={css.cellChevron} />
|
|
263
|
+
<span className={css.cellLabel}>{t('back')}</span>
|
|
264
|
+
</button>
|
|
265
|
+
)
|
|
266
|
+
return (
|
|
267
|
+
<div ref={rootRef} className={css.root} onKeyDown={onRootKeyDown} onBlur={onBlur}>
|
|
268
|
+
<button
|
|
269
|
+
ref={triggerRef}
|
|
270
|
+
type="button"
|
|
271
|
+
className={css.trigger}
|
|
272
|
+
aria-label={triggerAria}
|
|
273
|
+
aria-haspopup="menu"
|
|
274
|
+
aria-expanded={open}
|
|
275
|
+
aria-controls={open ? `${id}-menu` : undefined}
|
|
276
|
+
title={triggerLabel}
|
|
277
|
+
disabled={locked}
|
|
278
|
+
onClick={() => {
|
|
279
|
+
if (open) close()
|
|
280
|
+
else show()
|
|
281
|
+
}}
|
|
282
|
+
>
|
|
283
|
+
<IconDataOutline16 className={css.triggerIcon} size={16} />
|
|
284
|
+
<span className={css.triggerLabel}>{modelLabel}</span>
|
|
285
|
+
{effortLabel !== undefined && <span className={css.triggerEffort}>{effortLabel}</span>}
|
|
286
|
+
<IconChevronDownOutline14 className={cls(css.chevron, open && css.chevronOpen)} />
|
|
287
|
+
</button>
|
|
288
|
+
{open && createPortal((
|
|
289
|
+
<div
|
|
290
|
+
ref={menuRef}
|
|
291
|
+
id={`${id}-menu`}
|
|
292
|
+
className={css.menu}
|
|
293
|
+
style={menuPos ?? MEASURE_STYLE}
|
|
294
|
+
role="menu"
|
|
295
|
+
aria-label={pane === 'models' && shownGroup !== undefined ? shownGroup.name : pane === 'effort' ? t('menu.effort') : t('menu.aria')}
|
|
296
|
+
aria-busy={state.status === 'loading' || busy}
|
|
297
|
+
>
|
|
298
|
+
{pane === 'root' && (
|
|
299
|
+
<Fragment>
|
|
300
|
+
<button ref={itemRef('root:provider')} type="button" role="menuitem" className={css.cell} onClick={() => drill('providers')}>
|
|
301
|
+
<span className={css.cellLabel}>{t('menu.provider')}</span>
|
|
302
|
+
<span className={css.cellValue}>{currentGroup?.name ?? current?.provider ?? ''}</span>
|
|
303
|
+
<IconChevronRightOutline14 className={css.cellChevron} />
|
|
304
|
+
</button>
|
|
305
|
+
{reasoning !== undefined && (
|
|
306
|
+
<button ref={itemRef('root:effort')} type="button" role="menuitem" className={css.cell} onClick={() => drill('effort')}>
|
|
307
|
+
<span className={css.cellLabel}>{t('menu.effort')}</span>
|
|
308
|
+
<span className={css.cellValue}>{effortLabel}</span>
|
|
309
|
+
<IconChevronRightOutline14 className={css.cellChevron} />
|
|
310
|
+
</button>
|
|
311
|
+
)}
|
|
312
|
+
</Fragment>
|
|
313
|
+
)}
|
|
314
|
+
{pane === 'providers' && (
|
|
315
|
+
<Fragment>
|
|
316
|
+
{state.status === 'loading' && <div className={css.status}>{t('status.loading')}</div>}
|
|
317
|
+
{state.error !== null && lastActionRef.current === 'load' && (
|
|
318
|
+
<div className={css.error}>
|
|
319
|
+
<span>{t('error.action', { message: state.error })}</span>
|
|
320
|
+
<button type="button" className={css.retry} onClick={reload}>{t('action.reload')}</button>
|
|
321
|
+
</div>
|
|
322
|
+
)}
|
|
323
|
+
{state.failures.map((failure) => (
|
|
324
|
+
<div className={css.warning} key={failure.id}>
|
|
325
|
+
<span>{t('warning.groupLoad', { name: failure.name, message: failure.message })}</span>
|
|
326
|
+
<button type="button" className={css.retry} onClick={reload}>{t('action.reload')}</button>
|
|
327
|
+
</div>
|
|
328
|
+
))}
|
|
329
|
+
{state.status === 'ready' && groups.length === 0 && <div className={css.empty}>{t('empty.models')}</div>}
|
|
330
|
+
<div className={cls(css.groups, 'scrollable')}>
|
|
331
|
+
{groups.map((group) => {
|
|
332
|
+
const mine = current?.provider === group.id
|
|
333
|
+
return (
|
|
334
|
+
<button
|
|
335
|
+
key={group.id}
|
|
336
|
+
ref={itemRef(`provider:${group.id}`)}
|
|
337
|
+
type="button"
|
|
338
|
+
role="menuitem"
|
|
339
|
+
aria-current={mine ? 'true' : undefined}
|
|
340
|
+
className={css.cell}
|
|
341
|
+
title={group.name}
|
|
342
|
+
onClick={() => {
|
|
343
|
+
setProviderId(group.id)
|
|
344
|
+
drill('models')
|
|
345
|
+
}}
|
|
346
|
+
>
|
|
347
|
+
<span className={css.cellLabel}>{group.name}</span>
|
|
348
|
+
<span className={css.cellValue}>{mine ? currentModel?.name ?? current?.model ?? '' : t('provider.modelCount', { count: String(group.models.length) })}</span>
|
|
349
|
+
{mine && <span className={css.check}><IconCheckOutline16 /></span>}
|
|
350
|
+
<IconChevronRightOutline14 className={css.cellChevron} />
|
|
351
|
+
</button>
|
|
352
|
+
)
|
|
353
|
+
})}
|
|
354
|
+
</div>
|
|
355
|
+
</Fragment>
|
|
356
|
+
)}
|
|
357
|
+
{pane === 'models' && shownGroup !== undefined && (
|
|
358
|
+
<Fragment>
|
|
359
|
+
{backRow()}
|
|
360
|
+
<div className={cls(css.groups, 'scrollable')}>
|
|
361
|
+
{shownGroup.models.map((model) => {
|
|
362
|
+
const selected = current?.provider === shownGroup.id && current.model === model.id
|
|
363
|
+
return (
|
|
364
|
+
<button
|
|
365
|
+
key={model.id}
|
|
366
|
+
ref={itemRef(`model:${shownGroup.id}/${model.id}`)}
|
|
367
|
+
type="button"
|
|
368
|
+
role="menuitemradio"
|
|
369
|
+
aria-checked={selected}
|
|
370
|
+
className={cls(css.option, selected && css.selected)}
|
|
371
|
+
title={model.name}
|
|
372
|
+
disabled={busy}
|
|
373
|
+
onClick={() => chooseModel(shownGroup.id, model)}
|
|
374
|
+
>
|
|
375
|
+
<span className={css.optionCopy}><span className={css.modelName}>{model.name}</span></span>
|
|
376
|
+
<span className={css.check}>{selected ? <IconCheckOutline16 /> : null}</span>
|
|
377
|
+
</button>
|
|
378
|
+
)
|
|
379
|
+
})}
|
|
380
|
+
</div>
|
|
381
|
+
</Fragment>
|
|
382
|
+
)}
|
|
383
|
+
{pane === 'effort' && (
|
|
384
|
+
<Fragment>
|
|
385
|
+
{backRow()}
|
|
386
|
+
{state.error !== null && lastActionRef.current === 'load' && (
|
|
387
|
+
<div className={css.error}>
|
|
388
|
+
<span>{t('error.action', { message: state.error })}</span>
|
|
389
|
+
<button type="button" className={css.retry} onClick={reload}>{t('action.reload')}</button>
|
|
390
|
+
</div>
|
|
391
|
+
)}
|
|
392
|
+
{effortChoices.length === 0
|
|
393
|
+
? <div className={css.empty}>{t('empty.efforts')}</div>
|
|
394
|
+
: effortChoices.map((level) => (
|
|
395
|
+
<button
|
|
396
|
+
key={level.key}
|
|
397
|
+
ref={itemRef(`effort:${level.key}`)}
|
|
398
|
+
type="button"
|
|
399
|
+
role="menuitemradio"
|
|
400
|
+
aria-checked={effectiveEffort === level.effort}
|
|
401
|
+
className={cls(css.option, effectiveEffort === level.effort && css.selected)}
|
|
402
|
+
disabled={busy}
|
|
403
|
+
onClick={() => chooseEffort(level.effort)}
|
|
404
|
+
>
|
|
405
|
+
<span className={css.optionCopy}><span className={css.modelName}>{level.label}</span></span>
|
|
406
|
+
<span className={css.check}>{effectiveEffort === level.effort ? <IconCheckOutline16 /> : null}</span>
|
|
407
|
+
</button>
|
|
408
|
+
))}
|
|
409
|
+
</Fragment>
|
|
410
|
+
)}
|
|
411
|
+
</div>
|
|
412
|
+
), document.body)}
|
|
413
|
+
{toast !== null && (
|
|
414
|
+
<Toast
|
|
415
|
+
key={toast.seq}
|
|
416
|
+
text={toast.text}
|
|
417
|
+
icon={<IconWarningOutline16 />}
|
|
418
|
+
anchor={rootRef.current?.closest('[data-composer-card]') ?? null}
|
|
419
|
+
onDone={() => {
|
|
420
|
+
setToast(null)
|
|
421
|
+
}}
|
|
422
|
+
/>
|
|
423
|
+
)}
|
|
424
|
+
</div>
|
|
425
|
+
)
|
|
426
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { Context as ClientContext } from '@deepseek-ai/cordis'
|
|
2
|
+
// Type-only: `slots` and its LocaleNamespaceMap merge come from the renderer and
|
|
3
|
+
// the slot package; the composer seat's SlotMap entry and owner props from
|
|
4
|
+
// ui-conversation; the `modelDirectories` service and the seat's injected face
|
|
5
|
+
// from ui-model-selection's browser half.
|
|
6
|
+
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
|
|
7
|
+
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
|
8
|
+
import type {} from '@deepseek-ai/dsh-api-remotes/client'
|
|
9
|
+
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
|
10
|
+
import type {} from '@deepseek-ai/dsh-client-ui-model-selection/client'
|
|
11
|
+
import { ModelSubmenu } from './ModelSubmenu.tsx'
|
|
12
|
+
import { NS, en, zh } from './locales.ts'
|
|
13
|
+
|
|
14
|
+
export { ModelSubmenu } from './ModelSubmenu.tsx'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Cordis services required by the browser half. `slots` is the contribution
|
|
18
|
+
* registry, `sessions` tells whether a session may select a model, and
|
|
19
|
+
* `modelDirectories` owns the per-session catalog snapshot this seat renders
|
|
20
|
+
* from. `remote` / `remote.session` are declared because a service method runs
|
|
21
|
+
* on the calling context: `directoryFor` reads `this.ctx.remote.session` while
|
|
22
|
+
* building the directory, so the seat must carry that service too.
|
|
23
|
+
*/
|
|
24
|
+
export const inject = ['locale', 'modelDirectories', 'remote', 'remote.session', 'sessions', 'slots']
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Client plugin body: register this plugin's dictionaries, then take over the
|
|
28
|
+
* composer model seat below the built-in occupant's priority so this menu
|
|
29
|
+
* renders in its place.
|
|
30
|
+
* @param ctx - client root context.
|
|
31
|
+
*/
|
|
32
|
+
export function apply(ctx: ClientContext): void {
|
|
33
|
+
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'dsh-model-submenu: dictionaries')
|
|
34
|
+
ctx.inject(['slots', 'modelDirectories'], (scope) => {
|
|
35
|
+
const models = scope.modelDirectories
|
|
36
|
+
const sessions = scope.sessions
|
|
37
|
+
scope.slots.inject('conversation.input.model', () => scope.slots.register({
|
|
38
|
+
name: 'conversation.input.model',
|
|
39
|
+
priority: -100,
|
|
40
|
+
registrant: '@lynn123411/dsh-model-submenu',
|
|
41
|
+
locale: NS,
|
|
42
|
+
inject: (sessionId) => {
|
|
43
|
+
// The seat's inject parameter is typed as a bare string; the service
|
|
44
|
+
// faces carry the branded SessionId of that same runtime value.
|
|
45
|
+
const key = sessionId as Parameters<typeof models.directoryFor>[0]
|
|
46
|
+
const directory = models.directoryFor(key)
|
|
47
|
+
const available = sessions.subagentAddress(key) === undefined
|
|
48
|
+
return {
|
|
49
|
+
available,
|
|
50
|
+
directory: directory.store,
|
|
51
|
+
load: () => {
|
|
52
|
+
if (available) directory.load().catch(() => {})
|
|
53
|
+
},
|
|
54
|
+
select: (selection) => available ? directory.select(selection) : Promise.resolve(undefined),
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
}, ModelSubmenu))
|
|
58
|
+
})
|
|
59
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/** Locale bundles for the composer model seat this plugin takes over. */
|
|
2
|
+
|
|
3
|
+
/** Dictionary namespace owned by this plugin. */
|
|
4
|
+
export const NS = 'modelSubmenu'
|
|
5
|
+
|
|
6
|
+
/** Locale keys the seat renders. */
|
|
7
|
+
export type ModelSubmenuLocaleKey =
|
|
8
|
+
| 'back'
|
|
9
|
+
| 'provider.modelCount'
|
|
10
|
+
| 'action.reload'
|
|
11
|
+
| 'trigger.fallback'
|
|
12
|
+
| 'trigger.loading'
|
|
13
|
+
| 'trigger.selectAria'
|
|
14
|
+
| 'trigger.aria'
|
|
15
|
+
| 'trigger.ariaEffort'
|
|
16
|
+
| 'menu.aria'
|
|
17
|
+
| 'menu.provider'
|
|
18
|
+
| 'menu.effort'
|
|
19
|
+
| 'effort.providerDefault'
|
|
20
|
+
| 'status.loading'
|
|
21
|
+
| 'error.action'
|
|
22
|
+
| 'error.sessionInUse'
|
|
23
|
+
| 'warning.groupLoad'
|
|
24
|
+
| 'empty.models'
|
|
25
|
+
| 'empty.efforts'
|
|
26
|
+
|
|
27
|
+
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
|
28
|
+
interface LocaleNamespaceMap {
|
|
29
|
+
/** The composer model seat's copy. */
|
|
30
|
+
modelSubmenu: ModelSubmenuLocaleKey
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Simplified Chinese dictionary (the key-set source of truth). */
|
|
35
|
+
export const zh: Record<ModelSubmenuLocaleKey, string> = {
|
|
36
|
+
'back': '返回',
|
|
37
|
+
'provider.modelCount': '{count} 个模型',
|
|
38
|
+
'action.reload': '重新加载',
|
|
39
|
+
'trigger.fallback': '选择模型',
|
|
40
|
+
'trigger.loading': '正在加载模型…',
|
|
41
|
+
'trigger.selectAria': '选择模型',
|
|
42
|
+
'trigger.aria': '选择模型,当前 {model}',
|
|
43
|
+
'trigger.ariaEffort': '选择模型,当前 {model},推理等级 {effort}',
|
|
44
|
+
'menu.aria': '供应商与推理等级',
|
|
45
|
+
'menu.provider': '供应商',
|
|
46
|
+
'menu.effort': '推理等级',
|
|
47
|
+
'effort.providerDefault': 'Default',
|
|
48
|
+
'status.loading': '正在刷新模型列表…',
|
|
49
|
+
'error.action': '模型操作失败:{message}',
|
|
50
|
+
'error.sessionInUse': '当前会话已被占用,可能是其他正在运行的 DSH 导致的(如其他 dsh web、桌面端),请退出其他正在运行的 DSH 后重试。',
|
|
51
|
+
'warning.groupLoad': '{name} 加载失败:{message}',
|
|
52
|
+
'empty.models': '没有可用的模型。',
|
|
53
|
+
'empty.efforts': '当前模型未提供推理等级。',
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** English dictionary, checked complete against the zh key set. */
|
|
57
|
+
export const en: Record<ModelSubmenuLocaleKey, string> = {
|
|
58
|
+
'back': 'Back',
|
|
59
|
+
'provider.modelCount': '{count} models',
|
|
60
|
+
'action.reload': 'Reload',
|
|
61
|
+
'trigger.fallback': 'Select model',
|
|
62
|
+
'trigger.loading': 'Loading models…',
|
|
63
|
+
'trigger.selectAria': 'Select model',
|
|
64
|
+
'trigger.aria': 'Select model, current {model}',
|
|
65
|
+
'trigger.ariaEffort': 'Select model, current {model}, reasoning effort {effort}',
|
|
66
|
+
'menu.aria': 'Provider and reasoning effort',
|
|
67
|
+
'menu.provider': 'Provider',
|
|
68
|
+
'menu.effort': 'Effort',
|
|
69
|
+
'effort.providerDefault': 'Default',
|
|
70
|
+
'status.loading': 'Refreshing model list…',
|
|
71
|
+
'error.action': 'Model operation failed: {message}',
|
|
72
|
+
'error.sessionInUse': 'This session is already in use, possibly by another running DSH instance (such as dsh web or the desktop app). Quit other running DSH instances and try again.',
|
|
73
|
+
'warning.groupLoad': '{name} failed to load: {message}',
|
|
74
|
+
'empty.models': 'No models available.',
|
|
75
|
+
'empty.efforts': 'This model provides no reasoning effort levels.',
|
|
76
|
+
}
|
package/src/index.ts
ADDED