feat(web): add session model selector
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Browser-plugin assembly: the selector occupies the conversation-declared
|
||||
* composer-control slot, injects only Session object actions, fails loud when
|
||||
* the slot is undeclared, and unregisters with its plugin fiber.
|
||||
*/
|
||||
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { ModelSelector } from '../src/client/ModelSelector.tsx'
|
||||
import { apply, inject } from '../src/client/index.ts'
|
||||
|
||||
const SID = 'selector-session' as SessionId
|
||||
|
||||
async function bench() {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SlotsService).await()
|
||||
const slots = ctx.get('slots') as SlotsService
|
||||
slots.register({
|
||||
name: 'root',
|
||||
children: {
|
||||
'conversation.composer.control': { kind: 'single', scope: 'session' },
|
||||
},
|
||||
} as never, (_props: { renderSlot?: unknown }) => null)
|
||||
const session = {
|
||||
refreshModels: vi.fn(() => Promise.resolve({ ok: true })),
|
||||
retryModelOperation: vi.fn(() => Promise.resolve(true)),
|
||||
selectModel: vi.fn((target: { provider: string; model: string }) => Promise.resolve({
|
||||
ok: target.model !== 'rejected',
|
||||
value: { selected: target },
|
||||
})),
|
||||
}
|
||||
ctx.provide('sessions', { manager: { get: () => session } })
|
||||
ctx.provide('conversation', {})
|
||||
return { ctx, slots, session }
|
||||
}
|
||||
|
||||
describe('model-selector browser plugin', () => {
|
||||
it('declares its ordering and service dependencies', () => {
|
||||
expect(inject).toEqual(['slots', 'sessions', 'conversation'])
|
||||
})
|
||||
|
||||
it('fails loud when the conversation control slot is not declared', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SlotsService).await()
|
||||
ctx.provide('sessions', { manager: { get: vi.fn() } })
|
||||
ctx.provide('conversation', {})
|
||||
await expect(ctx.plugin({ inject: [...inject], apply }))
|
||||
.rejects.toThrow(/slot "conversation\.composer\.control" is not declared/)
|
||||
})
|
||||
|
||||
it('registers the singleton and injects Session-owned refresh/select actions', async () => {
|
||||
const { ctx, slots, session } = await bench()
|
||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const entries = slots.entries('conversation.composer.control')
|
||||
expect(entries).toHaveLength(1)
|
||||
expect(entries[0]?.component).toBe(ModelSelector)
|
||||
const injected = (entries[0]?.inject as (sessionId: SessionId) => {
|
||||
refreshModels(): void
|
||||
retryModelOperation(): Promise<boolean>
|
||||
selectModel(target: { provider: string; model: string }): Promise<boolean>
|
||||
})(SID)
|
||||
|
||||
injected.refreshModels()
|
||||
expect(session.refreshModels).toHaveBeenCalledTimes(1)
|
||||
await expect(injected.retryModelOperation()).resolves.toBe(true)
|
||||
expect(session.retryModelOperation).toHaveBeenCalledTimes(1)
|
||||
await expect(injected.selectModel({ provider: 'deepseek', model: 'deepseek-chat' }))
|
||||
.resolves.toBe(true)
|
||||
await expect(injected.selectModel({ provider: 'deepseek', model: 'rejected' }))
|
||||
.resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('unregisters the occupant when its plugin fiber is disposed', async () => {
|
||||
const { ctx, slots } = await bench()
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
expect(slots.entries('conversation.composer.control')).toHaveLength(1)
|
||||
await fiber.dispose()
|
||||
expect(slots.entries('conversation.composer.control')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
193
packages/client/ui-model-selector/tests/model-selector.spec.tsx
Normal file
193
packages/client/ui-model-selector/tests/model-selector.spec.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Provider-grouped selector behavior: compact model-only trigger, grouped
|
||||
* radio menu, retry/error states, successful and failed selection, outside
|
||||
* dismissal, and keyboard focus navigation.
|
||||
*/
|
||||
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type {
|
||||
ConversationSnapshot, ModelSelectionSnapshot,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ModelTarget } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { ModelSelectorProps } from '../src/client/contract.ts'
|
||||
import { ModelSelector } from '../src/client/ModelSelector.tsx'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const ready: ModelSelectionSnapshot = {
|
||||
current: { provider: 'deepseek', model: 'deepseek-v4-flash' },
|
||||
groups: [
|
||||
{
|
||||
id: 'deepseek',
|
||||
name: 'DeepSeek',
|
||||
models: [
|
||||
{ id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', description: '快速响应' },
|
||||
{ id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro', description: '复杂任务' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'openai',
|
||||
name: 'OpenAI',
|
||||
models: [{ id: 'gpt-5', name: 'GPT-5' }],
|
||||
},
|
||||
],
|
||||
failures: [],
|
||||
status: 'ready',
|
||||
error: null,
|
||||
}
|
||||
|
||||
function setup(selection: ModelSelectionSnapshot = ready, removed = false) {
|
||||
let current = { modelSelection: selection, removed } as unknown as ConversationSnapshot
|
||||
const useSession = ((selector: (snapshot: ConversationSnapshot) => unknown) =>
|
||||
selector(current)) as ModelSelectorProps['useSession']
|
||||
const refreshModels = vi.fn()
|
||||
const retryModelOperation = vi.fn(() => Promise.resolve(false))
|
||||
const selectModel = vi.fn((_target: ModelTarget) => Promise.resolve(true))
|
||||
const props: ModelSelectorProps = {
|
||||
sessionId: 'selector-session' as never,
|
||||
useSession,
|
||||
useSessions: ((selector: (snapshot: never) => unknown) =>
|
||||
selector({} as never)) as ModelSelectorProps['useSessions'],
|
||||
refreshModels,
|
||||
retryModelOperation,
|
||||
selectModel,
|
||||
}
|
||||
const view = render(<ModelSelector {...props} />)
|
||||
return {
|
||||
view,
|
||||
refreshModels,
|
||||
retryModelOperation,
|
||||
selectModel,
|
||||
update(next: ModelSelectionSnapshot, nextRemoved = removed) {
|
||||
current = { modelSelection: next, removed: nextRemoved } as unknown as ConversationSnapshot
|
||||
view.rerender(<ModelSelector {...props} />)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function trigger(): HTMLButtonElement {
|
||||
return screen.getByRole('button', { name: /选择模型,当前/ })
|
||||
}
|
||||
|
||||
describe('model selector', () => {
|
||||
it('shows the catalog name, opens upward into provider groups, and marks the current radio item', () => {
|
||||
const { refreshModels } = setup()
|
||||
expect(refreshModels).toHaveBeenCalledTimes(1)
|
||||
expect(trigger().textContent).toBe('DeepSeek-V4-Flash')
|
||||
expect(trigger().textContent).not.toContain('deepseek/')
|
||||
expect(trigger().title).toBe('DeepSeek-V4-Flash')
|
||||
|
||||
fireEvent.click(trigger())
|
||||
expect(refreshModels).toHaveBeenCalledTimes(2)
|
||||
expect(screen.getByRole('menu', { name: '模型' })).toBeTruthy()
|
||||
expect(screen.getAllByRole('group')).toHaveLength(2)
|
||||
expect(screen.getByText('DeepSeek')).toBeTruthy()
|
||||
expect(screen.getByText('OpenAI')).toBeTruthy()
|
||||
const rows = screen.getAllByRole('menuitemradio')
|
||||
expect(rows.map(row => row.querySelector('[class*="modelName"]')?.textContent))
|
||||
.toEqual(['DeepSeek-V4-Flash', 'DeepSeek-V4-Pro', 'GPT-5'])
|
||||
expect(rows[0]?.getAttribute('aria-checked')).toBe('true')
|
||||
expect(rows[1]?.getAttribute('aria-checked')).toBe('false')
|
||||
expect(rows.some(row => row.textContent?.includes('deepseek/deepseek'))).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the menu open on failure, closes after success, and closes current selection without an RPC', async () => {
|
||||
const { selectModel } = setup()
|
||||
selectModel.mockResolvedValueOnce(false)
|
||||
fireEvent.click(trigger())
|
||||
fireEvent.click(screen.getByRole('menuitemradio', { name: /DeepSeek-V4-Pro/ }))
|
||||
await waitFor(() => { expect(selectModel).toHaveBeenCalledWith({ provider: 'deepseek', model: 'deepseek-v4-pro' }) })
|
||||
expect(screen.getByRole('menu')).toBeTruthy()
|
||||
|
||||
selectModel.mockResolvedValueOnce(true)
|
||||
fireEvent.click(screen.getByRole('menuitemradio', { name: /GPT-5/ }))
|
||||
await waitFor(() => { expect(screen.queryByRole('menu')).toBeNull() })
|
||||
|
||||
fireEvent.click(trigger())
|
||||
fireEvent.click(screen.getByRole('menuitemradio', { name: /DeepSeek-V4-Flash/ }))
|
||||
await waitFor(() => { expect(screen.queryByRole('menu')).toBeNull() })
|
||||
expect(selectModel).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('renders loading, empty, partial-provider, operation-error, and unavailable-current states with retries', () => {
|
||||
const error = { code: 'internal' as const, message: 'wire down', details: {} }
|
||||
const { refreshModels, retryModelOperation, update } = setup({
|
||||
...ready,
|
||||
current: { provider: 'missing', model: 'private-preview-with-a-very-long-name' },
|
||||
failures: [{ id: 'offline', name: 'Offline', message: 'catalog down' }],
|
||||
status: 'error',
|
||||
error,
|
||||
})
|
||||
expect(trigger().textContent).toBe('private-preview-with-a-very-long-name')
|
||||
fireEvent.click(trigger())
|
||||
expect(screen.getByText(/模型操作失败:wire down/)).toBeTruthy()
|
||||
expect(screen.getByText(/Offline 加载失败:catalog down/)).toBeTruthy()
|
||||
expect(screen.getByText(/当前提供方 missing 未注册/)).toBeTruthy()
|
||||
fireEvent.click(screen.getAllByRole('button', { name: '重试' })[0]!)
|
||||
expect(retryModelOperation).toHaveBeenCalledTimes(1)
|
||||
fireEvent.click(screen.getAllByRole('button', { name: '重试' })[1]!)
|
||||
expect(refreshModels).toHaveBeenCalledTimes(3)
|
||||
|
||||
update({ current: null, groups: [], failures: [], status: 'loading', error: null })
|
||||
expect(screen.getByText('正在刷新模型列表…')).toBeTruthy()
|
||||
update({ current: null, groups: [], failures: [], status: 'ready', error: null })
|
||||
expect(screen.getByText('没有可用的模型。')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('closes after retrying a failed selection successfully', async () => {
|
||||
const { retryModelOperation } = setup({
|
||||
...ready,
|
||||
status: 'error',
|
||||
error: {
|
||||
code: 'model-unavailable',
|
||||
message: 'temporary failure',
|
||||
details: { provider: 'deepseek', model: 'deepseek-v4-pro' },
|
||||
},
|
||||
})
|
||||
retryModelOperation.mockResolvedValueOnce(true)
|
||||
fireEvent.click(trigger())
|
||||
fireEvent.click(screen.getByRole('button', { name: '重试' }))
|
||||
await waitFor(() => { expect(screen.queryByRole('menu')).toBeNull() })
|
||||
})
|
||||
|
||||
it('supports Arrow/Home/End/Escape navigation and restores focus to the trigger', async () => {
|
||||
setup()
|
||||
fireEvent.keyDown(trigger(), { key: 'ArrowDown' })
|
||||
await waitFor(() => {
|
||||
expect(document.activeElement).toBe(screen.getAllByRole('menuitemradio')[0])
|
||||
})
|
||||
fireEvent.keyDown(document.activeElement as HTMLElement, { key: 'ArrowDown' })
|
||||
expect(document.activeElement).toBe(screen.getAllByRole('menuitemradio')[1])
|
||||
fireEvent.keyDown(document.activeElement as HTMLElement, { key: 'End' })
|
||||
expect(document.activeElement).toBe(screen.getAllByRole('menuitemradio')[2])
|
||||
fireEvent.keyDown(document.activeElement as HTMLElement, { key: 'Home' })
|
||||
expect(document.activeElement).toBe(screen.getAllByRole('menuitemradio')[0])
|
||||
fireEvent.keyDown(document.activeElement as HTMLElement, { key: 'ArrowUp' })
|
||||
expect(document.activeElement).toBe(screen.getAllByRole('menuitemradio')[2])
|
||||
fireEvent.keyDown(document.activeElement as HTMLElement, { key: 'Escape' })
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
expect(document.activeElement).toBe(trigger())
|
||||
})
|
||||
})
|
||||
|
||||
it('opens ArrowUp on the last option, disables rows while selecting, and dismisses outside', async () => {
|
||||
const { update } = setup()
|
||||
fireEvent.keyDown(trigger(), { key: 'ArrowUp' })
|
||||
await waitFor(() => {
|
||||
expect(document.activeElement).toBe(screen.getAllByRole('menuitemradio')[2])
|
||||
})
|
||||
update({ ...ready, status: 'selecting' })
|
||||
expect(screen.getByRole('menu').getAttribute('aria-busy')).toBe('true')
|
||||
expect(screen.getAllByRole('menuitemradio').every(row => (row as HTMLButtonElement).disabled)).toBe(true)
|
||||
fireEvent.mouseDown(document.body)
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
})
|
||||
|
||||
it('disables the trigger only when the session is removed', () => {
|
||||
setup(ready, true)
|
||||
expect(trigger().disabled).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,28 @@
|
||||
/** Host-half placeholder and package invariant companion. */
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { apply as nodeApply } from '../src/index.ts'
|
||||
import * as invariant from '../src/invariant.ts'
|
||||
|
||||
describe('model-selector node half and invariant companion', () => {
|
||||
it('keeps the host half as an intentional no-op', () => {
|
||||
nodeApply(undefined as never)
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
|
||||
it('registers the package-owned empty invariant installer', async () => {
|
||||
const register = vi.fn().mockReturnValue(() => {})
|
||||
const ctx = { invariants: { register } } as never
|
||||
const dispose = await invariant.apply(ctx)
|
||||
expect(invariant.name).toBe('client-ui-model-selector-invariant')
|
||||
expect(invariant.inject).toEqual(['invariants'])
|
||||
expect(register).toHaveBeenCalledWith(
|
||||
'@deepseek-ai/dsh-client-ui-model-selector',
|
||||
expect.any(Function),
|
||||
)
|
||||
expect(() => {
|
||||
(register.mock.calls[0]![1] as (inner: never) => void)(undefined as never)
|
||||
}).not.toThrow()
|
||||
expect(dispose).toBeTypeOf('function')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user