@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.
@@ -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
+ }
@@ -0,0 +1,4 @@
1
+ declare module '*.module.css' {
2
+ const classes: Record<string, string>
3
+ export default classes
4
+ }
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ /** Host entry. This plugin is client-only; the browser half is `./client`. */
2
+ export function apply(): void {}