feat(client): typed locale standard seat in the slot framework
Registrations declare a dictionary namespace (locale: NS) and the renderer
synthesizes a typed t prop for the entry's component from the installed
LocaleFace; the seat binding is re-derived per locale revision, so a language
switch hands out fresh t references and memoized consumers re-render through
ordinary shallow comparison. LocaleNamespaceMap is the declare-merge table
(namespace -> dictionary key union); TranslateNS<'ns'> is the
namespace-addressed translate type (namespace keys plus the shared common
vocabulary), carried by the t seat and by the locale service's typed bind.
LocaleService implements the face (lookup ns -> common -> zh -> key,
revision-carrying snapshots with subscriber isolation) and installs it
through the boot-once slots.installLocale seam, mirroring the renderer
install. The typed register(ns, {zh, en}) overload checks each dictionary
against the namespace's key union and requires every shipped locale, so a
missing or extra key and an unbalanced translation are compile errors.
Dictionary registration bumps the face revision without emitting
locale/change — the event now means exactly 'the active locale switched',
so registration-heavy boot cannot storm event listeners.
This commit is contained in:
@@ -24,6 +24,7 @@
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-command"
|
||||
],
|
||||
@@ -36,6 +37,7 @@
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-client-connection": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-locale": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-command": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "^0.0.1",
|
||||
@@ -49,6 +51,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-command": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
|
||||
|
||||
@@ -18,6 +18,7 @@ import type { ModelReasoningEffort, ModelTarget } from '@deepseek-ai/dsh-client-
|
||||
import {
|
||||
IconCheckOutline16, IconChevronDownOutline14, IconChevronRightOutline14,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ModelSelectInjected } from './slots.ts'
|
||||
import css from './ModelSelect.module.css'
|
||||
|
||||
@@ -34,10 +35,13 @@ interface EffortChoice {
|
||||
|
||||
/**
|
||||
* Render the composer model seat.
|
||||
* @param props - owner share (locked) + injected face (shared directory store/verbs).
|
||||
* @param props - owner share (locked) + injected face (shared directory
|
||||
* store/verbs) + the standard locale seat.
|
||||
* @returns the trigger and, while open, the two-level menu.
|
||||
*/
|
||||
export function ModelSelect({ locked, directory, load, select }: ModelSelectInjected & { locked: boolean }) {
|
||||
export function ModelSelect(
|
||||
{ locked, directory, load, select, t }: ModelSelectInjected & { locked: boolean } & PropsLocale<'model'>,
|
||||
) {
|
||||
const state = useSyncExternalStore(
|
||||
fn => directory.subscribe(fn),
|
||||
() => directory.getSnapshot(),
|
||||
@@ -70,13 +74,13 @@ export function ModelSelect({ locked, directory, load, select }: ModelSelectInje
|
||||
const effortLabel = reasoning === undefined
|
||||
? undefined
|
||||
: effectiveEffort === undefined
|
||||
? 'Provider default'
|
||||
? t('effort.providerDefault')
|
||||
: reasoning.efforts.find(level => level.id === effectiveEffort)?.name ?? effectiveEffort
|
||||
const effortChoices = useMemo<readonly EffortChoice[]>(() => reasoning === undefined
|
||||
? []
|
||||
: [
|
||||
...reasoning.defaultEffort === undefined
|
||||
? [{ key: 'provider-default', effort: undefined, label: 'Provider default' }]
|
||||
? [{ key: 'provider-default', effort: undefined, label: t('effort.providerDefault') }]
|
||||
: [],
|
||||
...reasoning.efforts.map((effort: ModelReasoningEffort) => ({
|
||||
key: `effort:${effort.id}`,
|
||||
@@ -84,7 +88,7 @@ export function ModelSelect({ locked, directory, load, select }: ModelSelectInje
|
||||
label: effort.name,
|
||||
...effort.description === undefined ? {} : { description: effort.description },
|
||||
})),
|
||||
], [reasoning])
|
||||
], [reasoning, t])
|
||||
const busy = state.status === 'selecting'
|
||||
|
||||
// Mount-time load resolves the trigger label; every open refreshes.
|
||||
@@ -165,7 +169,7 @@ export function ModelSelect({ locked, directory, load, select }: ModelSelectInje
|
||||
})
|
||||
}
|
||||
|
||||
const modelLabel = choices[selectedIndex]?.model.name ?? state.current?.model ?? '选择模型'
|
||||
const modelLabel = choices[selectedIndex]?.model.name ?? state.current?.model ?? t('trigger.fallback')
|
||||
const triggerLabel = effortLabel === undefined ? modelLabel : `${modelLabel} · ${effortLabel}`
|
||||
itemRefs.current = []
|
||||
let itemIndex = 0
|
||||
@@ -180,7 +184,9 @@ export function ModelSelect({ locked, directory, load, select }: ModelSelectInje
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
className={css.trigger}
|
||||
aria-label={`选择模型,当前 ${modelLabel}${effortLabel === undefined ? '' : `,推理等级 ${effortLabel}`}`}
|
||||
aria-label={effortLabel === undefined
|
||||
? t('trigger.aria', { model: modelLabel })
|
||||
: t('trigger.ariaEffort', { model: modelLabel, effort: effortLabel })}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
aria-controls={open ? `${id}-menu` : undefined}
|
||||
@@ -204,19 +210,19 @@ export function ModelSelect({ locked, directory, load, select }: ModelSelectInje
|
||||
id={`${id}-menu`}
|
||||
className={css.menu}
|
||||
role="menu"
|
||||
aria-label="模型与推理等级"
|
||||
aria-label={t('menu.aria')}
|
||||
aria-busy={state.status === 'loading' || busy}
|
||||
>
|
||||
{pane === 'root' && (
|
||||
<>
|
||||
<button ref={itemRef()} type="button" role="menuitem" className={css.cell} onClick={() => { setPane('model') }}>
|
||||
<span className={css.cellLabel}>Model</span>
|
||||
<span className={css.cellLabel}>{t('menu.model')}</span>
|
||||
<span className={css.cellValue}>{modelLabel}</span>
|
||||
<IconChevronRightOutline14 className={css.cellChevron} />
|
||||
</button>
|
||||
{reasoning !== undefined && (
|
||||
<button ref={itemRef()} type="button" role="menuitem" className={css.cell} onClick={() => { setPane('effort') }}>
|
||||
<span className={css.cellLabel}>Effort</span>
|
||||
<span className={css.cellLabel}>{t('menu.effort')}</span>
|
||||
<span className={css.cellValue}>{effortLabel}</span>
|
||||
<IconChevronRightOutline14 className={css.cellChevron} />
|
||||
</button>
|
||||
@@ -227,18 +233,18 @@ export function ModelSelect({ locked, directory, load, select }: ModelSelectInje
|
||||
{pane === 'model' && (
|
||||
<>
|
||||
{state.status === 'loading' && (
|
||||
<div className={css.status}>正在刷新模型列表…</div>
|
||||
<div className={css.status}>{t('status.loading')}</div>
|
||||
)}
|
||||
{state.error !== null && (
|
||||
<div className={css.error}>
|
||||
<span>模型操作失败:{state.error}</span>
|
||||
<button type="button" className={css.retry} onClick={() => { load() }}>重试</button>
|
||||
<span>{t('error.action', { message: state.error })}</span>
|
||||
<button type="button" className={css.retry} onClick={() => { load() }}>{t('action.retry')}</button>
|
||||
</div>
|
||||
)}
|
||||
{state.failures.map(failure => (
|
||||
<div className={css.warning} key={failure.id}>
|
||||
<span>{failure.name} 加载失败:{failure.message}</span>
|
||||
<button type="button" className={css.retry} onClick={() => { load() }}>重试</button>
|
||||
<span>{t('warning.groupLoad', { name: failure.name, message: failure.message })}</span>
|
||||
<button type="button" className={css.retry} onClick={() => { load() }}>{t('action.retry')}</button>
|
||||
</div>
|
||||
))}
|
||||
<div className={clsx(css.groups, 'scrollable')}>
|
||||
@@ -267,7 +273,7 @@ export function ModelSelect({ locked, directory, load, select }: ModelSelectInje
|
||||
<span className={css.description}>{model.description}</span>
|
||||
)}
|
||||
{model.unlisted === true && (
|
||||
<span className={css.unlisted}>当前模型 · 未列入目录</span>
|
||||
<span className={css.unlisted}>{t('option.currentUnlisted')}</span>
|
||||
)}
|
||||
</span>
|
||||
<span className={css.check}>
|
||||
@@ -281,7 +287,7 @@ export function ModelSelect({ locked, directory, load, select }: ModelSelectInje
|
||||
})}
|
||||
</div>
|
||||
{state.status === 'ready' && choices.length === 0 && (
|
||||
<div className={css.empty}>没有可用的模型。</div>
|
||||
<div className={css.empty}>{t('empty.models')}</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
@@ -290,12 +296,12 @@ export function ModelSelect({ locked, directory, load, select }: ModelSelectInje
|
||||
<>
|
||||
{state.error !== null && (
|
||||
<div className={css.error}>
|
||||
<span>模型操作失败:{state.error}</span>
|
||||
<button type="button" className={css.retry} onClick={() => { load() }}>重新加载</button>
|
||||
<span>{t('error.action', { message: state.error })}</span>
|
||||
<button type="button" className={css.retry} onClick={() => { load() }}>{t('action.reload')}</button>
|
||||
</div>
|
||||
)}
|
||||
{effortChoices.length === 0
|
||||
? <div className={css.empty}>当前模型未提供推理等级。</div>
|
||||
? <div className={css.empty}>{t('empty.efforts')}</div>
|
||||
: effortChoices.map(level => (
|
||||
<button
|
||||
ref={itemRef()}
|
||||
|
||||
@@ -14,15 +14,27 @@ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { CommandServiceContract, SelectOption } from '@deepseek-ai/dsh-client-ui-command/client'
|
||||
// Type-only: pulls the ui-conversation SlotMap merge (the input.model seat).
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ModelDirectoryState } from './directory.ts'
|
||||
import { ModelService } from './service.ts'
|
||||
import type { ModelSelectInjected } from './slots.ts'
|
||||
import { ModelSelect } from './ModelSelect.tsx'
|
||||
import { en, zh, type ModelKey } from './locales.ts'
|
||||
|
||||
export { ModelDirectory } from './directory.ts'
|
||||
export type { ModelDirectoryState } from './directory.ts'
|
||||
export { ModelService } from './service.ts'
|
||||
export type { ModelSelectInjected } from './slots.ts'
|
||||
export type { ModelKey } from './locales.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface LocaleNamespaceMap {
|
||||
/** The model selection surfaces' copy (/model popup + composer seat). */
|
||||
model: ModelKey
|
||||
}
|
||||
}
|
||||
|
||||
/** One selectable row's id: an opaque row key (resolved by lookup, never parsed). */
|
||||
function rowId(providerId: string, modelId: string): string {
|
||||
@@ -30,7 +42,7 @@ function rowId(providerId: string, modelId: string): string {
|
||||
}
|
||||
|
||||
/** Flatten the directory into popup rows; failure rows are listed for visibility but never selectable. */
|
||||
function optionsOf(directory: SessionModels): SelectOption[] {
|
||||
function optionsOf(directory: SessionModels, t: TranslateNS<'model'>): SelectOption[] {
|
||||
const rows: SelectOption[] = []
|
||||
for (const group of directory.groups) {
|
||||
for (const model of group.models) {
|
||||
@@ -38,7 +50,7 @@ function optionsOf(directory: SessionModels): SelectOption[] {
|
||||
id: rowId(group.id, model.id),
|
||||
label: model.name,
|
||||
detail: model.unlisted === true
|
||||
? `${group.name} · 未列入目录`
|
||||
? t('option.unlisted', { group: group.name })
|
||||
: model.description !== undefined ? `${group.name} · ${model.description}` : group.name,
|
||||
...(directory.current.provider === group.id && directory.current.model === model.id
|
||||
? { active: true } : {}),
|
||||
@@ -46,7 +58,11 @@ function optionsOf(directory: SessionModels): SelectOption[] {
|
||||
}
|
||||
}
|
||||
for (const failure of directory.failures) {
|
||||
rows.push({ id: `failure/${failure.id}`, label: failure.name, detail: `目录加载失败:${failure.message}` })
|
||||
rows.push({
|
||||
id: `failure/${failure.id}`,
|
||||
label: failure.name,
|
||||
detail: t('option.loadError', { message: failure.message }),
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
@@ -76,28 +92,40 @@ function targetOf(state: ModelDirectoryState, id: string): ModelTarget | undefin
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Required services: the contribution registry, the seat's slot registry, and the service's own faces. */
|
||||
export const inject = ['command', 'connection', 'sessions', 'slots']
|
||||
/** Dictionary namespace owned by this plugin. */
|
||||
const NS = 'model'
|
||||
|
||||
/** Required services: the contribution registry, the seat's slot registry, locale, and the service's own faces. */
|
||||
export const inject = ['command', 'connection', 'locale', 'sessions', 'slots']
|
||||
|
||||
/**
|
||||
* Client plugin body: mount ModelService, then register the /model popup
|
||||
* contribution and the composer model seat over it.
|
||||
* Client plugin body: mount ModelService, register the `model` dictionaries,
|
||||
* then register the /model popup contribution and the composer model seat
|
||||
* over the service.
|
||||
* @param ctx - client root context.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
ctx.plugin(ModelService)
|
||||
|
||||
// Entry 1: the /model popupSelect over the shared directory.
|
||||
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-model: dictionaries')
|
||||
|
||||
// Non-slot faces (the command description, the popup option builder) read
|
||||
// through the bound translate; the seat component reads the standard seat.
|
||||
const t = ctx.locale.bind(NS)
|
||||
|
||||
// Entry 1: the /model popupSelect over the shared directory. The command
|
||||
// description is registry-held text: it reads t() once at registration and
|
||||
// refreshes only on re-registration, not on locale change.
|
||||
ctx.inject(['command', 'models'], (scope: ClientContext) => {
|
||||
const command = scope.get('command') as CommandServiceContract
|
||||
const models = scope.models
|
||||
scope.effect(() => command.register({
|
||||
name: 'model',
|
||||
description: 'Select the model for this conversation',
|
||||
description: t('command.description'),
|
||||
available: () => true,
|
||||
ui: {
|
||||
kind: 'popupSelect',
|
||||
options: async session => optionsOf(await models.directoryFor(session.sessionId).load()),
|
||||
options: async session => optionsOf(await models.directoryFor(session.sessionId).load(), t),
|
||||
onSelect: async (option, session) => {
|
||||
const directory = models.directoryFor(session.sessionId)
|
||||
const target = targetOf(directory.store.getSnapshot(), option.id)
|
||||
@@ -117,6 +145,7 @@ export function apply(ctx: ClientContext): void {
|
||||
const models = scope.models
|
||||
scope.effect(() => scope.slots.register({
|
||||
name: 'conversation.input.model',
|
||||
locale: NS,
|
||||
inject: (sessionId): ModelSelectInjected => {
|
||||
const directory = models.directoryFor(sessionId)
|
||||
return {
|
||||
|
||||
48
packages/client/ui-model/src/client/locales.ts
Normal file
48
packages/client/ui-model/src/client/locales.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/** `model` namespace dictionaries. */
|
||||
|
||||
/** Simplified Chinese dictionary (the key-set source of truth). */
|
||||
export const zh = {
|
||||
'command.description': '选择本会话使用的模型',
|
||||
'option.unlisted': '{group} · 未列入目录',
|
||||
'option.loadError': '目录加载失败:{message}',
|
||||
'trigger.fallback': '选择模型',
|
||||
'trigger.aria': '选择模型,当前 {model}',
|
||||
'trigger.ariaEffort': '选择模型,当前 {model},推理等级 {effort}',
|
||||
'menu.aria': '模型与推理等级',
|
||||
'menu.model': '模型',
|
||||
'menu.effort': '推理等级',
|
||||
'effort.providerDefault': '服务商默认',
|
||||
'status.loading': '正在刷新模型列表…',
|
||||
'error.action': '模型操作失败:{message}',
|
||||
'action.retry': '重试',
|
||||
'action.reload': '重新加载',
|
||||
'warning.groupLoad': '{name} 加载失败:{message}',
|
||||
'option.currentUnlisted': '当前模型 · 未列入目录',
|
||||
'empty.models': '没有可用的模型。',
|
||||
'empty.efforts': '当前模型未提供推理等级。',
|
||||
} satisfies Record<string, string>
|
||||
|
||||
/** The model namespace key union. */
|
||||
export type ModelKey = keyof typeof zh
|
||||
|
||||
/** English dictionary, checked complete against the zh key set. */
|
||||
export const en: Record<ModelKey, string> = {
|
||||
'command.description': 'Select the model for this conversation',
|
||||
'option.unlisted': '{group} · Not in catalog',
|
||||
'option.loadError': 'Catalog failed to load: {message}',
|
||||
'trigger.fallback': 'Select model',
|
||||
'trigger.aria': 'Select model, current {model}',
|
||||
'trigger.ariaEffort': 'Select model, current {model}, reasoning effort {effort}',
|
||||
'menu.aria': 'Model and reasoning effort',
|
||||
'menu.model': 'Model',
|
||||
'menu.effort': 'Effort',
|
||||
'effort.providerDefault': 'Provider default',
|
||||
'status.loading': 'Refreshing model list…',
|
||||
'error.action': 'Model operation failed: {message}',
|
||||
'action.retry': 'Retry',
|
||||
'action.reload': 'Reload',
|
||||
'warning.groupLoad': '{name} failed to load: {message}',
|
||||
'option.currentUnlisted': 'Current model · Not in catalog',
|
||||
'empty.models': 'No models available.',
|
||||
'empty.efforts': 'This model provides no reasoning effort levels.',
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { Context } from 'cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createScope } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type { ModelTarget } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { CommandContribution, SelectOption } from '@deepseek-ai/dsh-client-ui-command/client'
|
||||
import type { ModelSelectInjected } from '../src/client/slots.ts'
|
||||
@@ -79,14 +80,18 @@ async function bench() {
|
||||
return () => { contribution = undefined }
|
||||
},
|
||||
})
|
||||
const seats = new Map<string, { inject: ((sessionId: SessionId) => ModelSelectInjected) | undefined }>()
|
||||
const seats = new Map<string, {
|
||||
inject: ((sessionId: SessionId) => ModelSelectInjected) | undefined
|
||||
locale: string | undefined
|
||||
}>()
|
||||
ctx.provide('slots', {
|
||||
register(options: { name: string; inject?: (sessionId: SessionId) => ModelSelectInjected }) {
|
||||
seats.set(options.name, { inject: options.inject })
|
||||
register(options: { name: string; locale?: string; inject?: (sessionId: SessionId) => ModelSelectInjected }) {
|
||||
seats.set(options.name, { inject: options.inject, locale: options.locale })
|
||||
return () => { seats.delete(options.name) }
|
||||
},
|
||||
})
|
||||
ctx.provide('conversation', {})
|
||||
ctx.provide('locale', new LocaleService(ctx))
|
||||
const scopes = new Map<SessionId, Context>()
|
||||
ctx.provide('sessions', { scope: (id: SessionId) => scopes.get(id) })
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
@@ -114,6 +119,8 @@ describe('ui-model dual entry', () => {
|
||||
expect(b.contribution().name).toBe('model')
|
||||
expect(b.contribution().ui.kind).toBe('popupSelect')
|
||||
expect(b.seat().inject).toBeTypeOf('function')
|
||||
// Copy rides the standard locale seat.
|
||||
expect(b.seat().locale).toBe('model')
|
||||
})
|
||||
|
||||
it('popup options mark the host current active with the provider group in the detail', async () => {
|
||||
|
||||
@@ -3,8 +3,19 @@ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/re
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ModelTarget } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ComponentProps } from 'react'
|
||||
import type { ModelDirectoryState } from '../src/client/directory.ts'
|
||||
import { ModelSelect } from '../src/client/ModelSelect.tsx'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
|
||||
// The seat's key domain is model ∪ common; the stub answers from the package
|
||||
// dictionary (with template params) and falls back to the key like the real chain.
|
||||
const t: ComponentProps<typeof ModelSelect>['t'] = (key, params) => {
|
||||
const template = (zh as Record<string, string>)[key] ?? key
|
||||
return params === undefined
|
||||
? template
|
||||
: template.replace(/\{(\w+)\}/g, (match, name: string) => name in params ? String(params[name]) : match)
|
||||
}
|
||||
|
||||
const reasoning = {
|
||||
efforts: [
|
||||
@@ -44,13 +55,14 @@ describe('ModelSelect reasoning effort', () => {
|
||||
directory={directory}
|
||||
load={vi.fn()}
|
||||
select={select}
|
||||
t={t}
|
||||
/>)
|
||||
|
||||
const trigger = screen.getByRole('button', {
|
||||
name: '选择模型,当前 DeepSeek-V4-Flash,推理等级 High',
|
||||
})
|
||||
fireEvent.click(trigger)
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: /Effort/ }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: /推理等级/ }))
|
||||
expect(screen.getAllByRole('menuitemradio').map(item => item.textContent))
|
||||
.toEqual(['Off', 'High', 'MaxLargest budget'])
|
||||
|
||||
@@ -83,13 +95,14 @@ describe('ModelSelect reasoning effort', () => {
|
||||
directory={directory}
|
||||
load={vi.fn()}
|
||||
select={vi.fn().mockResolvedValue(true)}
|
||||
t={t}
|
||||
/>)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', {
|
||||
name: '选择模型,当前 Model,推理等级 Provider default',
|
||||
name: '选择模型,当前 Model,推理等级 服务商默认',
|
||||
}))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: /Effort/ }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: /推理等级/ }))
|
||||
expect(screen.getAllByRole('menuitemradio').map(item => item.textContent))
|
||||
.toEqual(['Provider default', 'Standard'])
|
||||
.toEqual(['服务商默认', 'Standard'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
{
|
||||
"path": "../connection"
|
||||
},
|
||||
{
|
||||
"path": "../locale"
|
||||
},
|
||||
{
|
||||
"path": "../runtime"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user