feat(web): add session model selector
This commit is contained in:
20
packages/client/ui-model-selector/README.md
Normal file
20
packages/client/ui-model-selector/README.md
Normal 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.
|
||||
62
packages/client/ui-model-selector/package.json
Normal file
62
packages/client/ui-model-selector/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
261
packages/client/ui-model-selector/src/client/ModelSelector.tsx
Normal file
261
packages/client/ui-model-selector/src/client/ModelSelector.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
28
packages/client/ui-model-selector/src/client/contract.ts
Normal file
28
packages/client/ui-model-selector/src/client/contract.ts
Normal 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
|
||||
33
packages/client/ui-model-selector/src/client/index.ts
Normal file
33
packages/client/ui-model-selector/src/client/index.ts
Normal 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)
|
||||
}
|
||||
4
packages/client/ui-model-selector/src/css-modules.d.ts
vendored
Normal file
4
packages/client/ui-model-selector/src/css-modules.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
declare module '*.module.css' {
|
||||
const classes: Record<string, string>
|
||||
export default classes
|
||||
}
|
||||
14
packages/client/ui-model-selector/src/index.ts
Normal file
14
packages/client/ui-model-selector/src/index.ts
Normal 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 {}
|
||||
30
packages/client/ui-model-selector/src/invariant.ts
Normal file
30
packages/client/ui-model-selector/src/invariant.ts
Normal 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 */
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
33
packages/client/ui-model-selector/tsconfig.json
Normal file
33
packages/client/ui-model-selector/tsconfig.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
3
packages/client/ui-model-selector/tsdown.config.ts
Normal file
3
packages/client/ui-model-selector/tsdown.config.ts
Normal 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'])
|
||||
Reference in New Issue
Block a user