@fonlan/dsh-model-router 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/client/api.ts CHANGED
@@ -14,6 +14,10 @@ export interface RouterModelView {
14
14
  }
15
15
 
16
16
  export interface ModelRouterState {
17
+ /** Whether the composer quick route-switcher button is enabled. */
18
+ showQuickSwitch: boolean
19
+ /** Whether model ids are matched with their leading vendor/ prefix ignored. */
20
+ ignoreModelIdPrefix: boolean
17
21
  providers: RouterProviderView[]
18
22
  models: RouterModelView[]
19
23
  }
@@ -53,4 +57,8 @@ export const api = {
53
57
  reorder: (modelId: string, order: string[]) =>
54
58
  call<ModelRouterState>('reorder', { modelId, order }),
55
59
  refresh: () => call<ModelRouterState>('refresh'),
60
+ setShowQuickSwitch: (value: boolean) =>
61
+ call<ModelRouterState>('set-show-quick-switch', { value }),
62
+ setIgnoreModelIdPrefix: (value: boolean) =>
63
+ call<ModelRouterState>('set-ignore-model-id-prefix', { value }),
56
64
  }
@@ -1,18 +1,19 @@
1
1
  /**
2
2
  * @fonlan/dsh-model-router client half: the Model Router settings page plus
3
- * bilingual dictionaries. The composer model picker needs no client
4
- * injection — the host catalog already renders the `model-router` group and
5
- * each model's description carries the active provider.
3
+ * a quick provider switcher beside DSH's composer model selector.
6
4
  */
7
5
  import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
8
6
  import type {} from '@deepseek-ai/dsh-client-locale/client'
9
7
  import type {} from '@deepseek-ai/dsh-client-ui-slots'
10
8
  import type {} from '@deepseek-ai/dsh-client-ui-settings/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
11
  import { LOCALE_NS, zh, en } from './locales'
12
12
  import { makeSettingsSection } from './settings-section'
13
+ import { registerRouteSwitcher } from './route-switcher'
13
14
 
14
15
  /** Services required before mounting (provided by the client runtime). */
15
- export const inject = ['slots', 'locale']
16
+ export const inject = ['slots', 'locale', 'modelDirectories', 'sessions']
16
17
 
17
18
  /** Client plugin body. */
18
19
  export function apply(ctx: ClientContext): void {
@@ -32,4 +33,6 @@ export function apply(ctx: ClientContext): void {
32
33
  locale: LOCALE_NS,
33
34
  }, SettingsSection as never),
34
35
  )
36
+
37
+ registerRouteSwitcher(ctx)
35
38
  }
@@ -19,6 +19,18 @@ export const zh = {
19
19
  switchFailed: '切换失败:{message}',
20
20
  reorderFailed: '排序失败:{message}',
21
21
  providerCount: '{count} 个提供商',
22
+ routeButtonLabel: '快速切换模型路由',
23
+ routeButtonTitle: '切换当前模型的提供商',
24
+ routeButtonAria: '切换当前模型提供商,当前为 {provider}',
25
+ routeMenuTitle: '{model} · 选择提供商',
26
+ routeLoading: '加载路由…',
27
+ unknownProvider: '未知提供商',
28
+ noCredentialShort: '未配置凭据',
29
+ globalRouteHint: '切换后对所有会话的后续请求生效',
30
+ showQuickSwitchLabel: '在输入栏显示快速路由切换按钮',
31
+ showQuickSwitchDescription: '开启后,当当前模型是 model-router 路由时,在输入框模型选择器右侧显示 provider 快速切换按钮。',
32
+ ignorePrefixLabel: '匹配时忽略模型 ID 前缀',
33
+ ignorePrefixDescription: '开启后,deepseek/deepseek-v4-flash 与 deepseek-v4-flash 视为同一模型,统一显示不带前缀的模型 id(默认开启)。',
22
34
  }
23
35
 
24
36
  export const en = {
@@ -36,4 +48,16 @@ export const en = {
36
48
  switchFailed: 'Switch failed: {message}',
37
49
  reorderFailed: 'Reorder failed: {message}',
38
50
  providerCount: '{count} providers',
51
+ routeButtonLabel: 'Quick model route switcher',
52
+ routeButtonTitle: 'Switch the current model provider',
53
+ routeButtonAria: 'Switch current model provider, currently {provider}',
54
+ routeMenuTitle: '{model} · Choose provider',
55
+ routeLoading: 'Loading routes…',
56
+ unknownProvider: 'Unknown provider',
57
+ noCredentialShort: 'No credentials',
58
+ globalRouteHint: 'Changes apply to subsequent requests in all sessions',
59
+ showQuickSwitchLabel: 'Show quick route switcher in the composer',
60
+ showQuickSwitchDescription: 'When enabled, a provider quick-switch button appears next to the model selector when the current model is a model-router route.',
61
+ ignorePrefixLabel: 'Ignore model ID prefix when matching',
62
+ ignorePrefixDescription: 'When enabled, deepseek/deepseek-v4-flash and deepseek-v4-flash are treated as the same model and shown without the prefix (on by default).',
39
63
  }
@@ -0,0 +1,136 @@
1
+ /* Quick provider route control beside DSH's composer model seat. */
2
+ .mr-route-anchor {
3
+ /* Menu's wrapper span: never let the row crush the trigger. */
4
+ flex: none;
5
+ min-width: 0;
6
+ }
7
+
8
+ .mr-route-trigger {
9
+ min-width: 0;
10
+ max-width: 180px;
11
+ height: 28px;
12
+ display: flex;
13
+ align-items: center;
14
+ gap: 2px;
15
+ padding: 0 6px;
16
+ border: none;
17
+ border-radius: 24px;
18
+ color: var(--dsw-alias-label-secondary);
19
+ background: transparent;
20
+ cursor: pointer;
21
+ font: inherit;
22
+ font-size: 12px;
23
+ font-weight: 500;
24
+ line-height: 20px;
25
+ outline: none;
26
+ }
27
+
28
+ .mr-route-trigger:hover:not(:disabled) {
29
+ background: var(--dsw-alias-interactive-bg-hover);
30
+ }
31
+
32
+ .mr-route-trigger:focus-visible {
33
+ box-shadow: 0 0 0 2px var(--dsw-alias-border-l3);
34
+ }
35
+
36
+ .mr-route-trigger:disabled {
37
+ color: var(--dsw-alias-label-dimmed);
38
+ cursor: default;
39
+ opacity: 0.7;
40
+ }
41
+
42
+ .mr-route-trigger-label {
43
+ min-width: 0;
44
+ overflow: hidden;
45
+ text-overflow: ellipsis;
46
+ white-space: nowrap;
47
+ }
48
+
49
+ .mr-route-trigger-chevron {
50
+ color: var(--dsw-alias-label-caption);
51
+ flex: none;
52
+ transition: transform 0.12s;
53
+ }
54
+
55
+ .mr-route-trigger[aria-expanded="true"] .mr-route-trigger-chevron {
56
+ transform: rotate(180deg);
57
+ }
58
+
59
+ .mr-route-item-copy {
60
+ min-width: 0;
61
+ display: flex;
62
+ align-items: baseline;
63
+ gap: 6px;
64
+ }
65
+
66
+ .mr-route-item-name {
67
+ min-width: 0;
68
+ overflow: hidden;
69
+ text-overflow: ellipsis;
70
+ white-space: nowrap;
71
+ }
72
+
73
+ .mr-route-item-note {
74
+ flex: none;
75
+ color: var(--dsw-alias-label-tertiary);
76
+ font-size: 11px;
77
+ line-height: 16px;
78
+ }
79
+
80
+ /*
81
+ * Mobile portrait: the composer row is an inline-size container. Keep the
82
+ * fixed control row intact — attachment, permission, provider (icon-only),
83
+ * context meter, send — and let the model seat absorb the remaining width.
84
+ * The breakpoint matches DSH's own narrow-composer rule (<=460px).
85
+ */
86
+ @container (width <= 460px) {
87
+ .mr-route-anchor {
88
+ flex: 0 0 auto;
89
+ }
90
+
91
+ .mr-route-trigger {
92
+ /* The trigger now carries globe + chevron, so let it size naturally
93
+ instead of forcing a 28px circle that could overflow the row. */
94
+ height: 28px;
95
+ padding: 4px 6px;
96
+ justify-content: center;
97
+ border-radius: 999px;
98
+ }
99
+
100
+ /*
101
+ * Make the composer's trailing group (the parent of our [data-slot] right
102
+ * wrapper) shrinkable on narrow rows so the globe/chevron trigger never
103
+ * spills left over the permission control. The model seat inside it
104
+ * absorbs the compression (flex 1 1 0, min-width 0 below).
105
+ */
106
+ div:has(> [data-slot="conversation.input.right"]) {
107
+ flex: 1 1 auto;
108
+ min-width: 0;
109
+ justify-content: flex-end;
110
+ /* Mirror the trailing group's internal gap (12px) so the provider
111
+ trigger has equal spacing to the permission button on its left and
112
+ the model seat on its right. */
113
+ margin-left: 12px;
114
+ }
115
+
116
+ .mr-route-trigger-label {
117
+ display: none;
118
+ }
119
+
120
+ /*
121
+ * The model seat: each slot is wrapped in a display:contents
122
+ * [data-slot] div, so the ModelSelect root div (the real flex item of the
123
+ * composer row) is [data-slot="conversation.input.model"] > *. Give it
124
+ * the remaining row width; the other controls keep fixed sizes.
125
+ */
126
+ [data-slot="conversation.input.right"] + [data-slot="conversation.input.model"] > * {
127
+ flex: 1 1 0;
128
+ min-width: 0;
129
+ max-width: 100%;
130
+ }
131
+
132
+ [data-slot="conversation.input.right"] + [data-slot="conversation.input.model"] [aria-haspopup="menu"] {
133
+ width: 100%;
134
+ max-width: 100%;
135
+ }
136
+ }
@@ -0,0 +1,252 @@
1
+ import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from 'react'
2
+ import type { ClientContext, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
3
+ import type { ModelDirectoryState } from '@deepseek-ai/dsh-client-ui-model-selection/client'
4
+ import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
5
+ import { IconChevronDownOutline14, IconGlobeOutline14, Menu, type MenuEntry } from '@deepseek-ai/dsh-client-ui-primitives'
6
+ import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
7
+ import type {} from '@deepseek-ai/dsh-client-ui-model-selection/client'
8
+ import { api, type ModelRouterState, type RouterModelView } from './api'
9
+ import { LOCALE_NS } from './locales'
10
+ import './route-switcher.css'
11
+
12
+ const ROUTER_PROVIDER_ID = 'model-router'
13
+
14
+ interface RouteSwitcherInjected {
15
+ available: boolean
16
+ directory: SnapshotStore<ModelDirectoryState>
17
+ load: () => void
18
+ }
19
+
20
+ type RouteSwitcherProps = PropsRuntime<'conversation.input.right'>
21
+ & PropsLocale<typeof LOCALE_NS>
22
+ & RouteSwitcherInjected
23
+
24
+ type RouteProvider = RouterModelView['providers'][number]
25
+
26
+ /**
27
+ * The quick route switcher lives beside DSH's model seat in the composer row.
28
+ * It reads the same per-session model directory as the host model picker, but
29
+ * only appears when the selected model is the virtual model-router route.
30
+ */
31
+ export function RouteSwitcher({
32
+ session,
33
+ available,
34
+ directory,
35
+ load,
36
+ t,
37
+ }: RouteSwitcherProps): JSX.Element | null {
38
+ const directoryState = useSyncExternalStore(
39
+ onChange => directory.subscribe(onChange),
40
+ () => directory.getSnapshot(),
41
+ () => directory.getSnapshot(),
42
+ )
43
+ const current = directoryState.current
44
+ const [routerState, setRouterState] = useState<ModelRouterState | null>(null)
45
+ const [routerLoading, setRouterLoading] = useState(false)
46
+ const [routerError, setRouterError] = useState<string | null>(null)
47
+ const [open, setOpen] = useState(false)
48
+ const [busyProvider, setBusyProvider] = useState<string | null>(null)
49
+
50
+ const isRouterModel = available && current?.provider === ROUTER_PROVIDER_ID
51
+ const modelId = isRouterModel ? current.model : null
52
+
53
+ const loadRouterState = useCallback(async () => {
54
+ setRouterLoading(true)
55
+ try {
56
+ setRouterState(await api.state())
57
+ setRouterError(null)
58
+ } catch (cause) {
59
+ setRouterError(cause instanceof Error ? cause.message : String(cause))
60
+ } finally {
61
+ setRouterLoading(false)
62
+ }
63
+ }, [])
64
+
65
+ useEffect(() => {
66
+ if (!available) return
67
+ load()
68
+ }, [available, load])
69
+
70
+ // Fetch the global toggle (and the model's provider list) on mount too, so a
71
+ // "hide quick switcher" setting applies even before the model directory
72
+ // settles. The button stays visible while the value is unknown (default on).
73
+ useEffect(() => {
74
+ if (!available) return
75
+ void loadRouterState()
76
+ }, [available])
77
+
78
+ useEffect(() => {
79
+ setOpen(false)
80
+ setBusyProvider(null)
81
+ setRouterState(null)
82
+ setRouterError(null)
83
+ }, [modelId])
84
+
85
+ // The model-selection directory is refreshed when another session changes
86
+ // the router settings document. Re-read the global router state after that
87
+ // refresh so this button never shows a stale active provider. Include the
88
+ // model id: switching between two router models can leave the directory in
89
+ // the same ready state while changing the API row we need to display.
90
+ useEffect(() => {
91
+ if (!isRouterModel || modelId === null || directoryState.status !== 'ready') return
92
+ void loadRouterState()
93
+ }, [directoryState.status, isRouterModel, loadRouterState, modelId])
94
+
95
+ const routeModel = useMemo(
96
+ () => modelId === null
97
+ ? undefined
98
+ : routerState?.models.find(model => model.id === modelId),
99
+ [modelId, routerState],
100
+ )
101
+
102
+ const providers = useMemo(() => {
103
+ if (routeModel === undefined) return []
104
+ const byId = new Map(routeModel.providers.map(provider => [provider.id, provider]))
105
+ const ordered = routeModel.order
106
+ .map(id => byId.get(id))
107
+ .filter((provider): provider is RouteProvider => provider !== undefined)
108
+ const seen = new Set(ordered.map(provider => provider.id))
109
+ return [...ordered, ...routeModel.providers.filter(provider => !seen.has(provider.id))]
110
+ }, [routeModel])
111
+
112
+ const switchProvider = useCallback(async (providerId: string) => {
113
+ if (modelId === null || routeModel === undefined || routeModel.active === providerId) {
114
+ setOpen(false)
115
+ return
116
+ }
117
+ setBusyProvider(providerId)
118
+ try {
119
+ const next = await api.switchActive(modelId, providerId)
120
+ setRouterState(next)
121
+ setRouterError(null)
122
+ setOpen(false)
123
+ // The model directory also listens for settings updates, but an explicit
124
+ // refresh keeps the host model picker description in sync immediately.
125
+ load()
126
+ } catch (cause) {
127
+ setRouterError(t('switchFailed', { message: cause instanceof Error ? cause.message : String(cause) }))
128
+ } finally {
129
+ setBusyProvider(null)
130
+ }
131
+ }, [load, modelId, routeModel, t])
132
+
133
+ if (session.removed || !isRouterModel) return null
134
+ if (routerState?.showQuickSwitch === false) return null
135
+
136
+ const activeProvider = providers.find(provider => provider.id === routeModel?.active)
137
+ const activeName = activeProvider?.name ?? routeModel?.active ?? t('routeLoading')
138
+ if (routeModel !== undefined && providers.length < 2) return null
139
+
140
+ const menuItems: MenuEntry[] = routeModel === undefined
141
+ ? [{
142
+ type: 'label',
143
+ id: 'route-status',
144
+ text: routerError ?? t('routeLoading'),
145
+ }]
146
+ : [
147
+ {
148
+ type: 'label',
149
+ id: 'route-model',
150
+ text: t('routeMenuTitle', { model: modelId }),
151
+ },
152
+ ...providers.map(provider => ({
153
+ id: provider.id,
154
+ label: (
155
+ <span className="mr-route-item-copy">
156
+ <span className="mr-route-item-name">{provider.name}</span>
157
+ {!provider.credentialConfigured && (
158
+ <span className="mr-route-item-note">{t('noCredentialShort')}</span>
159
+ )}
160
+ </span>
161
+ ),
162
+ disabled: !provider.credentialConfigured || busyProvider !== null,
163
+ })),
164
+ ...(routerError === null ? [] : [{
165
+ id: 'route-error',
166
+ label: routerError,
167
+ disabled: true,
168
+ danger: true,
169
+ }]),
170
+ ]
171
+
172
+ return (
173
+ <Menu
174
+ className="mr-route-anchor"
175
+ open={open}
176
+ side="top"
177
+ align="end"
178
+ portal
179
+ compact
180
+ dense
181
+ items={menuItems}
182
+ selectedId={routeModel?.active ?? undefined}
183
+ onSelect={id => {
184
+ if (id === 'route-error' || routeModel === undefined) {
185
+ void loadRouterState()
186
+ load()
187
+ return
188
+ }
189
+ void switchProvider(id)
190
+ }}
191
+ onClose={() => setOpen(false)}
192
+ footer={[
193
+ {
194
+ type: 'label',
195
+ id: 'route-global-hint',
196
+ text: t('globalRouteHint'),
197
+ },
198
+ ]}
199
+ anchor={(
200
+ <button
201
+ type="button"
202
+ className="mr-route-trigger"
203
+ aria-label={t('routeButtonAria', { provider: activeName })}
204
+ aria-haspopup="menu"
205
+ aria-expanded={open}
206
+ disabled={routerLoading || busyProvider !== null}
207
+ onClick={() => {
208
+ setOpen(value => !value)
209
+ if (!open) {
210
+ void loadRouterState()
211
+ load()
212
+ }
213
+ }}
214
+ title={t('routeButtonTitle')}
215
+ >
216
+ <IconGlobeOutline14 />
217
+ <span className="mr-route-trigger-label">{activeName}</span>
218
+ <span className="mr-route-trigger-chevron" aria-hidden>
219
+ <IconChevronDownOutline14 />
220
+ </span>
221
+ </button>
222
+ )}
223
+ />
224
+ )
225
+ }
226
+
227
+ /** Register the quick route switcher into the composer right-side tool row. */
228
+ export function registerRouteSwitcher(ctx: ClientContext): void {
229
+ const t = ctx.locale.bind(LOCALE_NS)
230
+ ctx.inject(['slots', 'modelDirectories'], scope => {
231
+ const models = scope.modelDirectories
232
+ const sessions = scope.sessions
233
+ scope.slots.inject('conversation.input.right', () => scope.slots.register({
234
+ name: 'conversation.input.right',
235
+ id: 'model-router-route-switcher',
236
+ order: 100,
237
+ label: () => t('routeButtonLabel'),
238
+ locale: LOCALE_NS,
239
+ inject: (sessionId) => {
240
+ const directory = models.directoryFor(sessionId)
241
+ const available = sessions.subagentAddress(sessionId) === undefined
242
+ return {
243
+ available,
244
+ directory: directory.store,
245
+ load: () => {
246
+ if (available) directory.load().catch(() => {})
247
+ },
248
+ }
249
+ },
250
+ }, RouteSwitcher as never))
251
+ })
252
+ }
@@ -13,6 +13,41 @@
13
13
  line-height: 20px;
14
14
  }
15
15
 
16
+ .mr-quick-switch {
17
+ display: flex;
18
+ align-items: flex-start;
19
+ gap: 8px;
20
+ padding: 8px 10px;
21
+ border: 1px solid var(--dsw-alias-border-inverted);
22
+ border-radius: 10px;
23
+ background: var(--dsw-alias-bg-module-platform);
24
+ cursor: pointer;
25
+ }
26
+
27
+ .mr-quick-switch input[type="checkbox"] {
28
+ margin-top: 2px;
29
+ accent-color: var(--dsw-alias-accent);
30
+ }
31
+
32
+ .mr-quick-switch-copy {
33
+ display: flex;
34
+ flex-direction: column;
35
+ gap: 2px;
36
+ min-width: 0;
37
+ }
38
+
39
+ .mr-quick-switch-label {
40
+ color: var(--dsw-alias-label-primary);
41
+ font-size: 13px;
42
+ line-height: 20px;
43
+ }
44
+
45
+ .mr-quick-switch-desc {
46
+ color: var(--dsw-alias-label-tertiary);
47
+ font-size: 12px;
48
+ line-height: 18px;
49
+ }
50
+
16
51
  .mr-toolbar {
17
52
  display: flex;
18
53
  justify-content: space-between;
@@ -58,6 +58,8 @@ export function makeSettingsSection(ctx: ClientContext): () => JSX.Element {
58
58
  const [loading, setLoading] = useState(true)
59
59
  const [busyModel, setBusyModel] = useState<string | null>(null)
60
60
  const [drag, setDrag] = useState<{ modelId: string; index: number } | null>(null)
61
+ const [quickSwitchBusy, setQuickSwitchBusy] = useState(false)
62
+ const [ignorePrefixBusy, setIgnorePrefixBusy] = useState(false)
61
63
 
62
64
  const load = useCallback(async () => {
63
65
  setLoading(true)
@@ -110,11 +112,67 @@ export function makeSettingsSection(ctx: ClientContext): () => JSX.Element {
110
112
  }
111
113
  }, [t])
112
114
 
115
+ const toggleQuickSwitch = useCallback(async (value: boolean) => {
116
+ if (state === null) return
117
+ const previous = state.showQuickSwitch
118
+ setState(prev => prev === null ? prev : { ...prev, showQuickSwitch: value })
119
+ setQuickSwitchBusy(true)
120
+ try {
121
+ setState(await api.setShowQuickSwitch(value))
122
+ setError(null)
123
+ } catch (cause) {
124
+ setState(prev => prev === null ? prev : { ...prev, showQuickSwitch: previous })
125
+ setError(cause instanceof Error ? cause.message : String(cause))
126
+ } finally {
127
+ setQuickSwitchBusy(false)
128
+ }
129
+ }, [state])
130
+
131
+ const toggleIgnorePrefix = useCallback(async (value: boolean) => {
132
+ if (state === null) return
133
+ const previous = state.ignoreModelIdPrefix
134
+ setState(prev => prev === null ? prev : { ...prev, ignoreModelIdPrefix: value })
135
+ setIgnorePrefixBusy(true)
136
+ try {
137
+ setState(await api.setIgnoreModelIdPrefix(value))
138
+ setError(null)
139
+ } catch (cause) {
140
+ setState(prev => prev === null ? prev : { ...prev, ignoreModelIdPrefix: previous })
141
+ setError(cause instanceof Error ? cause.message : String(cause))
142
+ } finally {
143
+ setIgnorePrefixBusy(false)
144
+ }
145
+ }, [state])
146
+
113
147
  const models = useMemo(() => state?.models ?? [], [state])
114
148
 
115
149
  return (
116
150
  <div className="mr-root">
117
151
  <p className="mr-description">{t('settingsDescription')}</p>
152
+ <label className="mr-quick-switch">
153
+ <input
154
+ type="checkbox"
155
+ checked={state?.showQuickSwitch ?? true}
156
+ disabled={loading || quickSwitchBusy}
157
+ onChange={(event) => void toggleQuickSwitch(event.target.checked)}
158
+ />
159
+ <span className="mr-quick-switch-copy">
160
+ <span className="mr-quick-switch-label">{t('showQuickSwitchLabel')}</span>
161
+ <span className="mr-quick-switch-desc">{t('showQuickSwitchDescription')}</span>
162
+ </span>
163
+ </label>
164
+ <label className="mr-quick-switch">
165
+ <input
166
+ type="checkbox"
167
+ checked={state?.ignoreModelIdPrefix ?? true}
168
+ disabled={loading || ignorePrefixBusy}
169
+ onChange={(event) => void toggleIgnorePrefix(event.target.checked)}
170
+ />
171
+ <span className="mr-quick-switch-copy">
172
+ <span className="mr-quick-switch-label">{t('ignorePrefixLabel')}</span>
173
+ <span className="mr-quick-switch-desc">{t('ignorePrefixDescription')}</span>
174
+ </span>
175
+ </label>
118
176
  <div className="mr-toolbar">
119
177
  <span className="mr-count">{t('modelsCount', { count: models.length })}</span>
120
178
  <button type="button" className="mr-button" onClick={() => void load()} disabled={loading}>