feat(web): add session model selector

This commit is contained in:
Yichen Jiang
2026-07-24 14:55:54 +08:00
parent bc7a89b81f
commit 208a44a7ec
87 changed files with 2236 additions and 87 deletions

View File

@@ -0,0 +1,20 @@
# @deepseek-ai/dsh-client-ui-model-selector
Session-scoped Web model selector. Its browser half occupies `conversation.composer.control`, shows the current catalog name beside the send button, and opens an upward provider-grouped menu. Provider names appear once as group headings; model rows and the trigger show catalog names without repeating the provider route, with the model id as the fallback for an unlisted current target.
The selector primes the advisory directory when it mounts so the trigger can resolve the catalog name, then refreshes it whenever the menu opens. The Session object layer owns loading, selection, partial-provider-failure, and stale-response state. A selection updates only that live session and takes effect at the next prompt-assembly boundary, including while the current step is running. The latest consumed target remains durable through the existing `request/header`; an unused choice is process-local.
Catalog membership is not request validation. The current target is included as an unlisted row when its registered provider omits it, while a target whose provider is unavailable remains visible on the trigger with a warning in the menu.
## Model Experience
None, as the browser selector changes subsequent request routing but adds no model-visible content.
#### KV Cache effect
Switching routes may invalidate provider-side cache reuse according to the selected adapter. The selector itself adds no prompt content.
## Known Limitations and Deferred Work
- **The new-session composer has no selector** — a session starts with the host default and exposes the selector after creation.
- **Unused selections are not durable** — reload restores the last route consumed by a request, not a choice made without sending.

View File

@@ -0,0 +1,62 @@
{
"name": "@deepseek-ai/dsh-client-ui-model-selector",
"description": "Session-scoped provider/model selector for the Web conversation composer",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./client": {
"types": "./lib/types/client/index.d.ts",
"default": "./lib/client.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"dshClient": {
"inject": [
"@deepseek-ai/dsh-client-ui-conversation"
],
"platform": "web"
},
"scripts": {
"bundle": "tsdown",
"watch": "tsdown --watch"
},
"license": "BSD-3-Clause",
"dependencies": {
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"clsx": "^2.0.0",
"react": "^18.2.0"
},
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"@types/react": "~18.3.1",
"cordis": "^4.0.0-rc.7"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/client.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
]
}

View File

@@ -0,0 +1,197 @@
.root {
position: relative;
min-width: 0;
}
.trigger {
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
max-width: 220px;
height: 34px;
padding: 0 6px;
border: none;
border-radius: 8px;
outline: none;
background: transparent;
color: var(--dsw-alias-label-secondary);
font-size: 14px;
line-height: 22px;
font-weight: 500;
cursor: pointer;
}
.trigger:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-primary);
}
.trigger:focus-visible {
box-shadow: 0 0 0 2px var(--dsw-alias-border-l3);
}
.trigger:disabled {
color: var(--dsw-alias-label-dimmed);
cursor: default;
}
.triggerLabel {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chevron {
flex: 0 0 auto;
color: var(--dsw-alias-label-caption);
transition: transform 120ms ease;
}
.chevronOpen {
transform: rotate(180deg);
}
.menu {
position: absolute;
right: 0;
bottom: calc(100% + 8px);
z-index: 20;
display: flex;
flex-direction: column;
width: min(320px, calc(100vw - 32px));
max-height: min(360px, calc(100vh - 96px));
overflow: hidden;
padding: 6px;
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
border-radius: 14px;
background: var(--dsw-specific-input-major);
box-shadow: var(--dsw-shadow-lv3);
color: var(--dsw-alias-label-primary);
}
.status,
.empty {
padding: 10px;
color: var(--dsw-alias-label-tertiary);
font-size: 13px;
line-height: 20px;
}
.error,
.warning {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
margin-bottom: 4px;
padding: 7px 8px;
border-radius: 8px;
background: var(--dsw-alias-interactive-bg-hover-danger);
color: var(--dsw-alias-state-error-primary);
font-size: 12px;
line-height: 18px;
}
.warning {
background: var(--dsw-alias-bg-module-platform);
color: var(--dsw-alias-state-warn-label);
}
.retry {
flex: 0 0 auto;
padding: 0;
border: none;
background: transparent;
color: inherit;
font: inherit;
font-weight: 600;
cursor: pointer;
}
.groups {
min-height: 0;
overflow-y: auto;
}
.group + .group {
margin-top: 4px;
}
.groupTitle {
position: sticky;
top: 0;
z-index: 1;
padding: 5px 8px 3px;
background: var(--dsw-specific-input-major);
color: var(--dsw-alias-label-tertiary);
font-size: 12px;
line-height: 18px;
font-weight: 500;
}
.option {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
min-height: 38px;
padding: 6px 8px;
border: none;
border-radius: 10px;
outline: none;
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
}
.option:hover:not(:disabled),
.option:focus-visible,
.selected {
background: var(--dsw-alias-interactive-bg-hover);
}
.option:disabled {
color: var(--dsw-alias-label-dimmed);
cursor: default;
}
.optionCopy {
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
}
.modelName {
overflow: hidden;
color: inherit;
font-size: 14px;
line-height: 20px;
font-weight: 500;
text-overflow: ellipsis;
white-space: nowrap;
}
.description,
.unlisted {
overflow: hidden;
color: var(--dsw-alias-label-tertiary);
font-size: 12px;
line-height: 18px;
text-overflow: ellipsis;
white-space: nowrap;
}
.unlisted {
color: var(--dsw-alias-state-warn-label);
}
.check {
display: grid;
place-items: center;
flex: 0 0 18px;
color: var(--dsw-alias-state-business-primary);
}

View File

@@ -0,0 +1,261 @@
import {
useEffect, useId, useMemo, useRef, useState,
type FocusEvent, type KeyboardEvent,
} from 'react'
import clsx from 'clsx'
import type { ModelTarget } from '@deepseek-ai/dsh-client-connection/client'
import {
IconCheckOutline16, IconChevronDownOutline14,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { ModelSelectorProps } from './contract.ts'
import css from './ModelSelector.module.css'
type FocusPreference = 'current' | 'first' | 'last'
/** Session-scoped provider-grouped model selector for the composer action row. */
export function ModelSelector({
useSession, refreshModels, retryModelOperation, selectModel,
}: ModelSelectorProps) {
const selection = useSession(snapshot => snapshot.modelSelection)
const removed = useSession(snapshot => snapshot.removed)
const [open, setOpen] = useState(false)
const rootRef = useRef<HTMLDivElement | null>(null)
const triggerRef = useRef<HTMLButtonElement | null>(null)
const itemRefs = useRef<(HTMLButtonElement | null)[]>([])
const pendingFocus = useRef<FocusPreference | null>(null)
const id = useId()
const { choices, choiceIndices } = useMemo(() => {
const nextChoices = selection.groups.flatMap(group =>
group.models.map(model => ({
group,
model,
target: { provider: group.id, model: model.id } satisfies ModelTarget,
})))
return {
choices: nextChoices,
choiceIndices: new Map(nextChoices.map((choice, index) => [
JSON.stringify([choice.target.provider, choice.target.model]),
index,
])),
}
}, [selection.groups])
const selectedIndex = selection.current === null
? -1
: choiceIndices.get(JSON.stringify([
selection.current.provider,
selection.current.model,
])) ?? -1
const busy = selection.status === 'selecting'
useEffect(() => {
refreshModels()
}, [refreshModels])
useEffect(() => {
if (!open) return
const closeOutside = (event: MouseEvent): void => {
if (!rootRef.current?.contains(event.target as Node)) setOpen(false)
}
document.addEventListener('mousedown', closeOutside)
return () => { document.removeEventListener('mousedown', closeOutside) }
}, [open])
useEffect(() => {
const preference = pendingFocus.current
if (!open || preference === null || choices.length === 0) return
const index = preference === 'first'
? 0
: preference === 'last'
? choices.length - 1
: selectedIndex >= 0 ? selectedIndex : 0
itemRefs.current[index]?.focus()
pendingFocus.current = null
}, [choices.length, open, selectedIndex])
const show = (preference: FocusPreference | null = null): void => {
pendingFocus.current = preference
setOpen(true)
refreshModels()
}
const close = (restoreFocus = false): void => {
setOpen(false)
pendingFocus.current = null
if (restoreFocus) queueMicrotask(() => { triggerRef.current?.focus() })
}
const moveFocus = (offset: number): void => {
if (choices.length === 0) return
const active = itemRefs.current.findIndex(item => item === document.activeElement)
const origin = active >= 0 ? active : selectedIndex >= 0 ? selectedIndex : 0
const next = (origin + offset + choices.length) % choices.length
itemRefs.current[next]?.focus()
}
const onRootKeyDown = (event: KeyboardEvent<HTMLDivElement>): void => {
if (event.key === 'Escape' && open) {
event.preventDefault()
close(true)
return
}
if (!open) return
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault()
moveFocus(event.key === 'ArrowDown' ? 1 : -1)
return
}
if (event.key === 'Home' || event.key === 'End') {
event.preventDefault()
itemRefs.current[event.key === 'Home' ? 0 : choices.length - 1]?.focus()
}
}
const onTriggerKeyDown = (event: KeyboardEvent<HTMLButtonElement>): void => {
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return
event.preventDefault()
if (!open) {
show(event.key === 'ArrowDown' ? 'first' : 'last')
return
}
pendingFocus.current = 'current'
const index = selectedIndex >= 0 ? selectedIndex : 0
itemRefs.current[index]?.focus()
}
const onBlur = (event: FocusEvent<HTMLDivElement>): void => {
if (event.relatedTarget instanceof Node && rootRef.current?.contains(event.relatedTarget)) return
close()
}
const choose = (target: ModelTarget): void => {
if (
selection.current?.provider === target.provider
&& selection.current.model === target.model
) {
close(true)
return
}
void selectModel(target).then((accepted) => {
if (accepted && rootRef.current !== null) close(true)
})
}
const retry = (): void => {
void retryModelOperation().then((selected) => {
if (selected && rootRef.current !== null) close(true)
})
}
const currentProviderKnown = selection.current === null
|| selection.groups.some(group => group.id === selection.current?.provider)
|| selection.failures.some(failure => failure.id === selection.current?.provider)
const label = choices[selectedIndex]?.model.name ?? selection.current?.model ?? '选择模型'
return (
<div
ref={rootRef}
className={css.root}
onKeyDown={onRootKeyDown}
onBlur={onBlur}
>
<button
ref={triggerRef}
type="button"
className={css.trigger}
aria-label={`选择模型,当前 ${label}`}
aria-haspopup="menu"
aria-expanded={open}
aria-controls={open ? `${id}-menu` : undefined}
title={label}
disabled={removed}
onClick={() => { open ? close() : show() }}
onKeyDown={onTriggerKeyDown}
>
<span className={css.triggerLabel}>{label}</span>
<IconChevronDownOutline14 className={clsx(css.chevron, open && css.chevronOpen)} />
</button>
{open && (
<div
id={`${id}-menu`}
className={css.menu}
role="menu"
aria-label="模型"
aria-busy={selection.status === 'loading' || busy}
>
{selection.status === 'loading' && (
<div className={css.status}>正在刷新模型列表…</div>
)}
{selection.error !== null && (
<div className={css.error}>
<span>模型操作失败:{selection.error.message}</span>
<button type="button" className={css.retry} onClick={retry}>重试</button>
</div>
)}
{selection.failures.map(failure => (
<div className={css.warning} key={failure.id}>
<span>{failure.name} 加载失败:{failure.message}</span>
<button type="button" className={css.retry} onClick={refreshModels}>重试</button>
</div>
))}
{selection.status !== 'loading' && !currentProviderKnown && selection.current !== null && (
<div className={css.warning}>
当前提供方 {selection.current.provider} 未注册。
</div>
)}
<div className={clsx(css.groups, 'scrollable')}>
{selection.groups.map((group) => {
const headingId = `${id}-${group.id}`
return (
<section
role="group"
aria-labelledby={headingId}
className={css.group}
key={group.id}
>
<div className={css.groupTitle} id={headingId}>{group.name}</div>
{group.models.map((model) => {
const index = choiceIndices.get(JSON.stringify([group.id, model.id])) ?? -1
const selected = selection.current?.provider === group.id
&& selection.current.model === model.id
return (
<button
ref={(node) => { itemRefs.current[index] = node }}
type="button"
role="menuitemradio"
aria-checked={selected}
className={clsx(css.option, selected && css.selected)}
key={model.id}
title={model.name}
disabled={busy}
onClick={() => { choose({ provider: group.id, model: model.id }) }}
>
<span className={css.optionCopy}>
<span className={css.modelName}>{model.name}</span>
{model.description !== undefined && (
<span className={css.description}>{model.description}</span>
)}
{model.unlisted === true && (
<span className={css.unlisted}>当前模型 · 未列入目录</span>
)}
</span>
<span className={css.check}>
{selected ? <IconCheckOutline16 /> : null}
</span>
</button>
)
})}
</section>
)
})}
</div>
{selection.status === 'ready' && choices.length === 0 && (
<div className={css.empty}>没有可用的模型。</div>
)}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,28 @@
/**
* Model-selector slot contract: standard session props plus the plain
* object-layer actions injected by this package's registration.
*/
import type { ModelTarget } from '@deepseek-ai/dsh-client-connection/client'
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
/** Plain callbacks contributed by the selector registration. */
export interface ModelSelectorInjected {
/** Refresh the session's advisory model directory. */
refreshModels(): void
/**
* Retry the directory refresh or exact selection that produced the visible operation error.
* @returns Whether a model selection succeeded and the menu should close.
*/
retryModelOperation(): Promise<boolean>
/**
* Select a complete provider/model target.
* @param target - Target selected from one provider group.
* @returns Whether the host accepted the selection.
*/
selectModel(target: ModelTarget): Promise<boolean>
}
/** Full props of the conversation composer-control occupant. */
export type ModelSelectorProps =
PropsRuntime<'conversation.composer.control'> & ModelSelectorInjected

View File

@@ -0,0 +1,33 @@
/**
* Browser model-selector plugin: registers one session-scoped occupant in the
* conversation composer-control slot. The Session object owns all catalog and
* selection state; the component receives only the standard snapshot hook and
* injected callbacks.
*/
import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type { ModelSelectorInjected } from './contract.ts'
import { ModelSelector } from './ModelSelector.tsx'
export type { ModelSelectorInjected, ModelSelectorProps } from './contract.ts'
/** Required services; conversation is the slot-declaration ordering edge. */
export const inject = ['slots', 'sessions', 'conversation']
/**
* Register the model selector in the resident conversation composer.
* @param ctx - Client root context.
*/
export function apply(ctx: ClientContext): void {
const sessions = ctx.sessions
ctx.slots.register({
name: 'conversation.composer.control',
inject: (sessionId: SessionId): ModelSelectorInjected => {
const session = sessions.manager.get(sessionId)
return {
refreshModels: () => { void session.refreshModels() },
retryModelOperation: () => session.retryModelOperation(),
selectModel: async target => (await session.selectModel(target)).ok,
}
},
}, ModelSelector)
}

View File

@@ -0,0 +1,4 @@
declare module '*.module.css' {
const classes: Record<string, string>
export default classes
}

View File

@@ -0,0 +1,14 @@
/**
* Web model-selector plugin, node half. Model routing and catalog RPCs belong
* to the host runtime, so this package contributes no host registration.
*/
import type { Context } from 'cordis'
/** No host services are required. */
export const inject: string[] = []
/**
* Empty host half for the browser-only selector feature.
* @param _ctx - Host plugin context.
*/
export function apply(_ctx: Context): void {}

View File

@@ -0,0 +1,30 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-model-selector`.
* @module @deepseek-ai/dsh-client-ui-model-selector/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-model-selector'
/** Cordis companion plugin name. */
export const name = 'client-ui-model-selector-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: the slot registry owns selector registration
* lifecycle, and the wire/object-layer tests own model-target consistency.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns The installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -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)
})
})

View 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)
})
})

View File

@@ -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')
})
})

View File

@@ -0,0 +1,33 @@
{
"extends": "../../../tsconfig.base.client.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cordis"
},
{
"path": "../connection"
},
{
"path": "../runtime"
},
{
"path": "../ui-conversation"
},
{
"path": "../ui-primitives"
},
{
"path": "../ui-slots"
},
{
"path": "../../support/invariants"
}
]
}

View File

@@ -0,0 +1,3 @@
import { clientBundle } from '../tsdown.client.ts'
export default clientBundle('@deepseek-ai/dsh-client-ui-model-selector', ['lib/types/index.js', 'lib/types/invariant.js'])