refactor: apply repository naming contract

Apply the accepted pre-release package, service, type, directory, and role renames as one repository-wide change.
This commit is contained in:
Tianyi Cui
2026-08-13 00:36:22 +08:00
parent 101df7cf58
commit a2d0f7f411
3281 changed files with 21730 additions and 21592 deletions

View File

@@ -0,0 +1,261 @@
.root {
position: relative;
min-width: 0;
}
/* Figma 313:14108 ToggleButton: 13/20 medium secondary label, 4px gap,
12px caption chevron; 28px chip height matches the sibling Plan /
Read-only selects in the same tool row. */
.trigger {
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
max-width: 220px;
height: 28px;
padding: 0 4px 0 8px;
border: none;
/* Rounded chip chrome, matching the sibling permission trigger. */
border-radius: 24px;
outline: none;
background: transparent;
color: var(--dsw-alias-label-secondary);
font-size: 13px;
line-height: 20px;
font-weight: 500;
cursor: pointer;
}
.trigger:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover);
}
.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;
}
/* Effort value beside the model name (mock's 'High': same 13/20/500, caption tone). */
.triggerEffort {
flex: 0 0 auto;
color: var(--dsw-alias-label-caption);
}
.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(240px, calc(100vw - 32px));
max-height: min(360px, calc(100vh - 96px));
overflow: hidden;
padding: 4px;
/* Surface tokens match the Menu primitive card (ui-primitives
* Menu.module.css) so every dropdown reads as the same material. */
border: 1px solid var(--dsw-alias-border-inverted);
border-radius: 12px;
background: var(--dsw-specific-menu);
box-shadow: var(--dsw-shadow-lv3);
color: var(--dsw-alias-label-primary);
/* Elevated surface: the scrollbar thumb takes the l2 elevation tokens.
Declared here rather than on the scrolling `.groups` child so the
elevation choice sits with the surface; the custom properties inherit
down to whichever descendant actually scrolls (see ui-theme
styles/scrollbar.css for the rebinding contract). */
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
}
.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-menu);
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 {
background: var(--dsw-alias-interactive-bg-hover);
}
/* Selection marker is the trailing check, not a fill — matches the Menu
* primitive's selected treatment. */
.selected {
background: transparent;
}
.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 {
overflow: hidden;
color: var(--dsw-alias-label-tertiary);
font-size: 12px;
line-height: 18px;
text-overflow: ellipsis;
white-space: nowrap;
}
.check {
display: grid;
place-items: center;
flex: 0 0 18px;
color: var(--dsw-alias-label-primary);
}
/* Two-level root cells (figma 496:26454 .Menu_cell): 40px row, 10px side
padding, 8px gap, 10px radius; 14/22 label in primary, value in the
#81858C tertiary tone, right chevron drilling into the sub-list. */
.cell {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
height: 40px;
padding: 0 10px;
border: none;
border-radius: 10px;
background: transparent;
color: var(--dsw-alias-label-primary);
font-size: 14px;
line-height: 22px;
cursor: pointer;
text-align: left;
}
.cell:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.cellLabel {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cellValue {
flex: 0 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--dsw-alias-label-tertiary);
}
.cellChevron {
flex: 0 0 auto;
color: var(--dsw-alias-label-tertiary);
}

View File

@@ -0,0 +1,375 @@
/**
* ModelSelect: the composer's named model seat (`conversation.input.model`).
* Two-level selection per figma 496:26454's MenuDropdown: the root menu is
* the Model / Effort row pair (label + current value + a right chevron),
* each drilling into its own list — the provider-grouped model list over
* the shared directory, and the effort levels. The trigger (313:14108's
* ToggleButton) shows both: model name + effort in the caption tone.
* Data and submission ride the SAME per-session ModelDirectory as the
* /model popup; exact-model reasoning metadata and the selected effort come
* from the Host rather than a client-owned vocabulary. A rejected selection
* announces through the shared transient Toast anchored to the composer
* card; the in-menu strip with Retry remains the catalog-load surface.
*/
import {
useEffect, useId, useMemo, useRef, useState, useSyncExternalStore,
type KeyboardEvent, type FocusEvent,
} from 'react'
import clsx from 'clsx'
import type { ModelReasoningEffort, ModelSelection } from '@deepseek-ai/dsh-api-remotes/client'
import {
IconCheckOutline16, IconChevronDownOutline14, IconChevronRightOutline14,
IconWarningOutline16, Toast,
} 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'
/** Which pane the dropdown shows: the two-row root or one drilled-in list. */
type Pane = 'root' | 'model' | 'effort'
/** One dynamic effort row; undefined means preserve the provider default. */
interface EffortChoice {
key: string
effort: string | undefined
label: string
description?: string
}
/**
* Render the composer model seat.
* @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, available, directory, load, select, t }:
ModelSelectInjected & { locked: boolean } & PropsLocale<'model'>,
) {
const state = useSyncExternalStore(
fn => directory.subscribe(fn),
() => directory.getSnapshot(),
)
const [open, setOpen] = useState(false)
const [pane, setPane] = useState<Pane>('root')
// The in-menu error strip serves catalog loads (its Retry re-runs the
// load); a rejected SELECTION announces through the transient toast
// instead, so the strip renders only while the latest failure-capable
// action was a load.
const lastActionRef = useRef<'load' | 'select'>('load')
const [toast, setToast] = useState<{ seq: number; text: string } | null>(null)
const toastSeq = useRef(0)
const rootRef = useRef<HTMLDivElement | null>(null)
const triggerRef = useRef<HTMLButtonElement | null>(null)
const itemRefs = useRef<(HTMLButtonElement | null)[]>([])
const id = useId()
const choices = useMemo(() => state.groups.flatMap(group =>
group.models.map(model => ({
group,
model,
selection: {
provider: group.id,
model: model.id,
...model.reasoning?.defaultEffort === undefined
? {}
: { reasoningEffort: model.reasoning.defaultEffort },
} satisfies ModelSelection,
}))), [state.groups])
const selectedIndex = state.current === null
? -1
: choices.findIndex(c => c.selection.provider === state.current?.provider && c.selection.model === state.current.model)
const currentChoice = choices[selectedIndex]
const reasoning = currentChoice?.model.reasoning
const effectiveEffort = state.current?.reasoningEffort ?? reasoning?.defaultEffort
const effortLabel = reasoning === undefined
? undefined
: effectiveEffort === undefined
? 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: t('effort.providerDefault') }]
: [],
...reasoning.efforts.map((effort: ModelReasoningEffort) => ({
key: `effort:${effort.id}`,
effort: effort.id,
label: effort.name,
...effort.description === undefined ? {} : { description: effort.description },
})),
], [reasoning, t])
const busy = state.status === 'selecting'
const reload = (): void => {
lastActionRef.current = 'load'
load()
}
// Mount-time load resolves the trigger label; every open refreshes.
useEffect(() => {
if (available) {
lastActionRef.current = 'load'
load()
}
}, [available, load])
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])
if (!available) return null
const show = (): void => {
setPane('root')
setOpen(true)
reload()
}
const close = (restoreFocus = false): void => {
setOpen(false)
setPane('root')
if (restoreFocus) queueMicrotask(() => { triggerRef.current?.focus() })
}
const moveFocus = (offset: number): void => {
const items = itemRefs.current.filter(item => item !== null)
if (items.length === 0) return
const active = items.findIndex(item => item === document.activeElement)
const next = (Math.max(active, 0) + offset + items.length) % items.length
items[next]?.focus()
}
const onRootKeyDown = (event: KeyboardEvent<HTMLDivElement>): void => {
if (event.key === 'Escape' && open) {
event.preventDefault()
// Escape backs out of a drilled pane first, then closes.
if (pane !== 'root') setPane('root')
else close(true)
return
}
if (!open) return
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault()
moveFocus(event.key === 'ArrowDown' ? 1 : -1)
}
}
const onBlur = (event: FocusEvent<HTMLDivElement>): void => {
if (event.relatedTarget instanceof Node && rootRef.current?.contains(event.relatedTarget)) return
close()
}
const settleSelection = (accepted: boolean): void => {
if (accepted) {
if (rootRef.current !== null) close(true)
return
}
const message = directory.getSnapshot().error
if (message !== null) {
toastSeq.current += 1
setToast({ seq: toastSeq.current, text: t('error.action', { message }) })
}
}
const choose = (selection: ModelSelection): void => {
if (state.current?.provider === selection.provider && state.current.model === selection.model) {
close(true)
return
}
lastActionRef.current = 'select'
void select(selection).then(settleSelection)
}
const chooseEffort = (effort: string | undefined): void => {
if (state.current === null) return
if (effectiveEffort === effort) {
close(true)
return
}
const selection: ModelSelection = {
provider: state.current.provider,
model: state.current.model,
...effort === undefined ? {} : { reasoningEffort: effort },
}
lastActionRef.current = 'select'
void select(selection).then(settleSelection)
}
const modelLabel = currentChoice?.model.name ?? t('trigger.fallback')
const triggerLabel = effortLabel === undefined ? modelLabel : `${modelLabel} · ${effortLabel}`
const triggerAria = currentChoice === undefined
? t('trigger.selectAria')
: effortLabel === undefined
? t('trigger.aria', { model: modelLabel })
: t('trigger.ariaEffort', { model: modelLabel, effort: effortLabel })
itemRefs.current = []
let itemIndex = 0
const itemRef = () => {
const at = itemIndex++
return (node: HTMLButtonElement | null) => { itemRefs.current[at] = node }
}
return (
<div ref={rootRef} className={css.root} onKeyDown={onRootKeyDown} onBlur={onBlur}>
<button
ref={triggerRef}
type="button"
className={css.trigger}
aria-label={triggerAria}
aria-haspopup="menu"
aria-expanded={open}
aria-controls={open ? `${id}-menu` : undefined}
title={triggerLabel}
disabled={locked}
onClick={() => {
if (open) {
close()
} else {
show()
}
}}
>
<span className={css.triggerLabel}>{modelLabel}</span>
{effortLabel !== undefined && <span className={css.triggerEffort}>{effortLabel}</span>}
<IconChevronDownOutline14 className={clsx(css.chevron, open && css.chevronOpen)} />
</button>
{open && (
<div
id={`${id}-menu`}
className={css.menu}
role="menu"
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}>{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}>{t('menu.effort')}</span>
<span className={css.cellValue}>{effortLabel}</span>
<IconChevronRightOutline14 className={css.cellChevron} />
</button>
)}
</>
)}
{pane === 'model' && (
<>
{state.status === 'loading' && (
<div className={css.status}>{t('status.loading')}</div>
)}
{state.error !== null && lastActionRef.current === 'load' && (
<div className={css.error}>
<span>{t('error.action', { message: state.error })}</span>
<button type="button" className={css.retry} onClick={reload}>{t('retry')}</button>
</div>
)}
{state.failures.map(failure => (
<div className={css.warning} key={failure.id}>
<span>{t('warning.groupLoad', { name: failure.name, message: failure.message })}</span>
<button type="button" className={css.retry} onClick={reload}>{t('retry')}</button>
</div>
))}
<div className={clsx(css.groups, 'scrollable')}>
{state.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 selected = state.current?.provider === group.id && state.current.model === model.id
return (
<button
ref={itemRef()}
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>
)}
</span>
<span className={css.check}>
{selected ? <IconCheckOutline16 /> : null}
</span>
</button>
)
})}
</section>
)
})}
</div>
{state.status === 'ready' && choices.length === 0 && (
<div className={css.empty}>{t('empty.models')}</div>
)}
</>
)}
{pane === 'effort' && (
<>
{state.error !== null && lastActionRef.current === 'load' && (
<div className={css.error}>
<span>{t('error.action', { message: state.error })}</span>
<button type="button" className={css.retry} onClick={reload}>{t('action.reload')}</button>
</div>
)}
{effortChoices.length === 0
? <div className={css.empty}>{t('empty.efforts')}</div>
: effortChoices.map(level => (
<button
ref={itemRef()}
type="button"
role="menuitemradio"
aria-checked={effectiveEffort === level.effort}
className={clsx(css.option, effectiveEffort === level.effort && css.selected)}
key={level.key}
disabled={busy}
onClick={() => { chooseEffort(level.effort) }}
>
<span className={css.optionCopy}>
<span className={css.modelName}>{level.label}</span>
{level.description !== undefined && (
<span className={css.description}>{level.description}</span>
)}
</span>
<span className={css.check}>
{effectiveEffort === level.effort ? <IconCheckOutline16 /> : null}
</span>
</button>
))}
</>
)}
</div>
)}
{toast !== null && (
<Toast
key={toast.seq}
text={toast.text}
icon={<IconWarningOutline16 />}
anchor={rootRef.current?.closest<HTMLElement>('[data-composer-card]') ?? null}
onDone={() => { setToast(null) }}
/>
)}
</div>
)
}

View File

@@ -0,0 +1,154 @@
/**
* Per-session model directory: the ONE state both selection entries share.
* The /model popup and the composer-seat selector load through the same
* controller and submit through the same selectModel call, so the host stays
* the single fact source and the store is one shared echo — a switch made in
* either entry is what the other shows next.
*/
import type {
IApiClient, ModelCatalogFailure, ModelProviderGroup, ModelSelection, SessionId, SessionModels,
} from '@deepseek-ai/dsh-api-remotes/client'
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
/** Directory snapshot both entries render from. */
export interface ModelDirectoryState {
/** Model selection the host reports for the next assembled step; null before the first load. */
current: ModelSelection | null
/**
* Whether an adapter serves the current selection's provider, as the host reports
* it — null before the first load, which is NOT the same as blocked. Read
* this rather than "current matches no group": catalog membership is
* advisory, so a route serving a model it stopped advertising is missing
* from the groups yet perfectly usable.
*/
routable: boolean | null
/** Successfully loaded provider groups (last good load). */
groups: readonly ModelProviderGroup[]
/** Provider-local failures from the last load; usable groups stay usable. */
failures: readonly ModelCatalogFailure[]
/** Lifecycle of the in-flight operation. */
status: 'idle' | 'loading' | 'ready' | 'selecting' | 'error'
/** Whole-request or selection failure text; null when none. */
error: string | null
}
/** One session's shared directory controller; disposed with the session scope. */
export class ModelDirectory {
/** The shared snapshot both entries render from (uSES-safe store). */
readonly store: SnapshotStore<ModelDirectoryState> = createSnapshotStore<ModelDirectoryState>({
current: null, routable: null, groups: [], failures: [], status: 'idle', error: null,
})
/** Latest operation wins; an older response never overwrites a newer one. */
private generation = 0
private disposed = false
/**
* @param sessions - the session wire face (captured from the plugin's root connection).
* @param sessionId - the owning session.
* @param available - whether this session may use Agent-bound model RPCs.
*/
constructor(
private readonly sessions: Pick<IApiClient['sessions'], 'models' | 'selectModel'>,
private readonly sessionId: SessionId,
private readonly available: () => boolean,
) {}
/**
* Refresh the advisory directory (both entries call this on open).
* Failure preserves the last good groups and current selection.
* @returns the fresh directory value.
*/
async load(): Promise<SessionModels> {
this.assertAvailable()
const generation = ++this.generation
this.store.update((s) => { s.status = 'loading'; s.error = null })
const { result } = await this.sessions.models({ sessionId: this.sessionId })
if (this.disposed || generation !== this.generation) {
if (!result.ok) throw new Error(`${result.error.code}: ${result.error.message}`)
return result.value
}
if (!result.ok) {
this.store.update((s) => { s.status = 'error'; s.error = `${result.error.code}: ${result.error.message}` })
throw new Error(`session.models failed: ${result.error.code}: ${result.error.message}`)
}
const { current, routable, groups, failures } = result.value
this.store.update((s) => {
s.current = current
s.routable = routable
s.groups = groups
s.failures = failures
s.status = 'ready'
s.error = null
})
return result.value
}
/**
* Select the complete provider/model/reasoning selection (both entries submit through here). Success
* updates the shared current; failure surfaces on the store and throws so
* each entry's own retry surface engages.
* @param selection - provider, provider-owned model id, and optional adapter-owned effort.
*/
async select(selection: ModelSelection): Promise<void> {
this.assertAvailable()
const generation = ++this.generation
this.store.update((s) => { s.status = 'selecting'; s.error = null })
const { result } = await this.sessions.selectModel({
sessionId: this.sessionId,
provider: selection.provider,
model: selection.model,
...selection.reasoningEffort === undefined
? {}
: { reasoningEffort: selection.reasoningEffort },
})
if (this.disposed || generation !== this.generation) {
if (!result.ok) throw new Error(`${result.error.code}: ${result.error.message}`)
return
}
if (!result.ok) {
this.store.update((s) => { s.status = 'error'; s.error = `${result.error.code}: ${result.error.message}` })
throw new Error(`session.selectModel failed: ${result.error.code}: ${result.error.message}`)
}
// The Host validated the route before accepting it, so a selection that
// landed is by construction one it can serve.
this.store.update((s) => {
s.current = result.value.selected
s.routable = true
s.status = 'ready'
s.error = null
})
}
/**
* Drop the previous Host generation's projection and repull it. Clearing
* first prevents an unconsumed process-local selection from being displayed
* while the restarted Host has restored the last logged model selection.
*/
resetConnected(): void {
if (this.disposed) return
++this.generation
this.store.update((s) => {
s.current = null
s.routable = null
s.groups = []
s.failures = []
s.status = 'idle'
s.error = null
})
if (!this.available()) return
void this.load().catch(() => { /* the next menu open remains the explicit retry surface */ })
}
/** Scope teardown: late settlements lose write access to the store. */
dispose(): void {
this.disposed = true
}
private assertAvailable(): void {
if (!this.available()) {
throw new Error('model selection is unavailable for addressed subagent sessions')
}
}
}

View File

@@ -0,0 +1,176 @@
/**
* Model selection plugin, browser half — TWO entries over ONE per-session
* directory owned by ModelDirectoryResolver (`ctx.modelDirectories`). The /model popupSelect
* contribution and the composer's named `conversation.input.model` seat both
* load the session's provider-grouped advisory directory (`session.models`)
* and submit through `session.selectModel` via the same directory instance,
* so the host-reported current selection is the single fact both surfaces echo
* — a switch made in either entry is what the other shows next. Failures
* ride each entry's own retry surface (popup shell error/retry; seat menu
* inline error) without forking the state. Addressed subagent sessions expose
* neither entry because those Agent-bound RPCs would activate persisted
* history outside the direct-parent continuation path.
*/
// Type-only: the carrier types, the forwarded Host-event face and the ctx.remote merge.
import type { ModelSelection, SessionModels } from '@deepseek-ai/dsh-api-remotes/client'
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import type { CommandUiContract, SelectOption } from '@deepseek-ai/dsh-client-ui-commands/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 { ModelDirectoryResolver } 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 { ModelDirectoryResolver } 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 {
return `${providerId}/${modelId}`
}
/** Flatten the directory into popup rows; failure rows are listed for visibility but never selectable. */
function optionsOf(directory: SessionModels, t: TranslateNS<'model'>): SelectOption[] {
const rows: SelectOption[] = []
for (const group of directory.groups) {
for (const model of group.models) {
rows.push({
id: rowId(group.id, model.id),
label: model.name,
detail: model.description !== undefined ? `${group.name} · ${model.description}` : group.name,
...(directory.current.provider === group.id && directory.current.model === model.id
? { active: true } : {}),
})
}
}
for (const failure of directory.failures) {
rows.push({
id: `failure/${failure.id}`,
label: failure.name,
detail: t('option.loadError', { message: failure.message }),
})
}
return rows
}
/**
* Resolve a picked row back to its model selection by matching against the loaded
* groups (the same data the rows were built from — ids stay opaque).
* @param state - the session's directory snapshot.
* @param id - the picked row id.
* @returns the row's model selection, or undefined for failure rows / stale ids.
*/
function selectionOf(state: ModelDirectoryState, id: string): ModelSelection | undefined {
for (const group of state.groups) {
for (const model of group.models) {
if (rowId(group.id, model.id) !== id) continue
const sameRoute = state.current?.provider === group.id && state.current.model === model.id
const reasoningEffort = sameRoute
? state.current?.reasoningEffort ?? model.reasoning?.defaultEffort
: model.reasoning?.defaultEffort
return {
provider: group.id,
model: model.id,
...reasoningEffort === undefined ? {} : { reasoningEffort },
}
}
}
return undefined
}
/** 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 = ['commandUi', 'connection', 'locale', 'sessions', 'slots', 'remote']
/**
* Client plugin body: mount ModelDirectoryResolver, 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.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-model-selection: 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)
// The composer-block reason is this plugin's own copy, read at raise time so
// a locale change reaches the next publish.
ctx.plugin(ModelDirectoryResolver, { blockReason: () => t('blocked.composer') })
// 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(['commandUi', 'modelDirectories'], (scope: ClientContext) => {
const command = scope.get('commandUi') as CommandUiContract
const models = scope.modelDirectories
const sessions = scope.sessions
scope.effect(() => command.register({
name: 'model',
description: t('command.description'),
available: session => sessions.subagentAddress(session.sessionId) === undefined,
ui: {
kind: 'popupSelect',
options: async (session) => {
if (sessions.subagentAddress(session.sessionId) !== undefined) {
throw new Error('model selection is unavailable for addressed subagent sessions')
}
return optionsOf(await models.directoryFor(session.sessionId).load(), t)
},
onSelect: async (option, session) => {
if (sessions.subagentAddress(session.sessionId) !== undefined) {
throw new Error('model selection is unavailable for addressed subagent sessions')
}
const directory = models.directoryFor(session.sessionId)
const selection = selectionOf(directory.store.getSnapshot(), option.id)
if (selection === undefined) {
throw new Error('this provider\'s catalog failed to load — pick a model from a loaded group')
}
await directory.select(selection)
},
},
}), 'ui-model-selection: /model contribution')
})
// Entry 2: the composer's named model seat over the SAME directory.
ctx.inject(['slots', 'modelDirectories'], (scope: ClientContext) => {
const models = scope.modelDirectories
const sessions = scope.sessions
scope.slots.inject('conversation.input.model', () => scope.slots.register({
name: 'conversation.input.model',
locale: NS,
inject: (sessionId): ModelSelectInjected => {
const directory = models.directoryFor(sessionId)
const available = sessions.subagentAddress(sessionId) === undefined
return {
available,
directory: directory.store,
load: () => {
if (available) directory.load().catch(() => { /* surfaced on the store */ })
},
select: (selection: ModelSelection) => available
? directory.select(selection).then(() => true, () => false)
: Promise.resolve(false),
}
},
}, ModelSelect))
})
}

View File

@@ -0,0 +1,54 @@
/**
* `model` namespace dictionaries.
*
* `trigger.selectAria` reads identically to `trigger.fallback` today and is
* still a separate key: the visible fallback label and the accessible name of
* an unset trigger are free to diverge per locale, and folding it into
* `trigger.aria` would announce the degenerate "Select model, current Select
* model".
*/
/** Simplified Chinese dictionary (the key-set source of truth). */
export const zh = {
'command.description': '选择本会话使用的模型',
'option.loadError': '目录加载失败:{message}',
'trigger.fallback': '选择模型',
'trigger.selectAria': '选择模型',
'trigger.aria': '选择模型,当前 {model}',
'trigger.ariaEffort': '选择模型,当前 {model},推理等级 {effort}',
'menu.aria': '模型与推理等级',
'menu.model': '模型',
'menu.effort': '推理等级',
'effort.providerDefault': 'Default',
'status.loading': '正在刷新模型列表…',
'error.action': '模型操作失败:{message}',
'action.reload': '重新加载',
'warning.groupLoad': '{name} 加载失败:{message}',
'empty.models': '没有可用的模型。',
'blocked.composer': '当前模型不可用,请先选择模型',
'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 = {
'command.description': 'Select the model for this conversation',
'option.loadError': 'Catalog failed to load: {message}',
'trigger.fallback': 'Select model',
'trigger.selectAria': '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': 'Default',
'status.loading': 'Refreshing model list…',
'error.action': 'Model operation failed: {message}',
'action.reload': 'Reload',
'warning.groupLoad': '{name} failed to load: {message}',
'empty.models': 'No models available.',
'blocked.composer': 'This model is unavailable — select one to continue',
'empty.efforts': 'This model provides no reasoning effort levels.',
} satisfies Record<ModelKey, string>

View File

@@ -0,0 +1,110 @@
/**
* ModelDirectoryResolver (`ctx.modelDirectories`): the root owner of per-session
* {@link ModelDirectory} instances. Both selection entries (the /model popup
* and the composer model seat) resolve their session's directory through
* this service, which is what makes the dual entry one shared state.
*
* Per-session storage follows the client service pattern (InputTriggerService /
* CommandUiRuntime): a lazy service-internal map whose entry is deleted by the
* owning scope's disposer. The host `dsh-scope` ScopedLayers registry does
* does not belong here: it derives scope from the host carrier mechanism
* (object-keyed), while client scopes tag contexts with branded SessionId
* strings, and it models global+shadow named registries — this is a
* per-session singleton with no global layer to merge.
*/
import { Service } from '@deepseek-ai/cordis'
import type { Context } from '@deepseek-ai/cordis'
import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-api-remotes/client'
import type { SessionRuntime } from '@deepseek-ai/dsh-client-runtime/client'
import { ModelDirectory } from './directory.ts'
declare module '@deepseek-ai/cordis' {
interface Context {
modelDirectories: ModelDirectoryResolver
}
}
/** Live mutable state in one holder (service methods run behind the caller-ctx tracker). */
interface LiveState {
/** Per-session directories; entries are deleted by their scope disposer. */
readonly directories: Map<SessionId, ModelDirectory>
}
/** The `ctx.modelDirectories` session model-selection service. */
export class ModelDirectoryResolver extends Service {
static inject = ['connection', 'sessions', 'remote']
private readonly live: LiveState = { directories: new Map() }
/** Localized composer-block copy; this plugin owns the string it raises. */
private readonly blockReason: () => string
/**
* @param ctx - owning root context (the service registers itself as `models`).
* @param config - the bound translator for this plugin's own dictionary.
*/
constructor(ctx: Context, config: { blockReason: () => string }) {
super(ctx, 'modelDirectories')
this.blockReason = config.blockReason
ctx.on('connection/reset', () => {
for (const directory of this.live.directories.values()) directory.resetConnected()
})
// Either source can change the directory: registry topology commits and
// settings documents that carry provider catalogs or default selection.
const refresh = (): void => {
for (const directory of this.live.directories.values()) {
directory.load().catch(() => undefined)
}
}
ctx.remote.$on('llm/adapters-updated', refresh)
ctx.remote.$on('settings/document-updated', refresh)
}
/**
* Resolve the per-session shared directory (lazy; the scope disposer
* removes and disposes it). Unknown sessions fail loud.
* @param sessionId - the owning session.
* @returns the resident directory both entries share.
*/
directoryFor(sessionId: SessionId): ModelDirectory {
const { live } = this
const existing = live.directories.get(sessionId)
if (existing !== undefined) return existing
const sessions = this.ctx.get('sessions') as SessionRuntime
const actx = sessions.scope(sessionId)
if (actx === undefined) throw new Error(`ui-model-selection: session "${String(sessionId)}" resolved no scope`)
const connection = this.ctx.get('connection') as ConnectionHandle
const directory = new ModelDirectory(
connection.api.sessions,
sessionId,
() => sessions.subagentAddress(sessionId) === undefined,
)
live.directories.set(sessionId, directory)
// The composer cannot read this plugin (the dependency runs one way), so
// the block is pushed: the Host says whether an adapter serves the
// session's route, and only a definite `false` makes the input inert.
// `null` — before the first load, or after one failed — must not, or a
// slow or unreachable Host would lock a working composer.
const conversation = this.ctx.get('conversation')
if (conversation !== undefined) {
const publish = (): void => {
conversation.blocks.set(sessionId, directory.store.getSnapshot().routable === false
? { reason: this.blockReason() }
: undefined)
}
publish()
actx.effect(() => {
const stop = directory.store.subscribe(publish)
return () => {
stop()
conversation.blocks.set(sessionId, undefined)
}
}, 'ui-model-selection: composer block')
}
actx.effect(() => () => {
directory.dispose()
live.directories.delete(sessionId)
}, 'ui-model-selection: session directory')
return directory
}
}

View File

@@ -0,0 +1,25 @@
/**
* ModelSelect's injected face. The target 'conversation.input.model' seat is
* declared (children table) and typed by ui-conversation's composer-bar
* entry; this package only contributes the single occupant, so no SlotMap
* merge lives here.
*/
import type { ModelSelection } from '@deepseek-ai/dsh-api-remotes/client'
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { ModelDirectoryState } from './directory.ts'
/** Injected business face of the composer model seat. */
export interface ModelSelectInjected {
/** Whether this session supports Agent-bound model inspection and selection. */
available: boolean
/** The session's shared directory store (same instance the /model popup reads). */
directory: SnapshotStore<ModelDirectoryState>
/** Refresh the advisory directory (fire-and-forget; errors land on the store). */
load: () => void
/**
* Select a complete provider/model/reasoning selection.
* @param selection - model selection and optional adapter-owned effort.
* @returns whether the host accepted the selection.
*/
select: (selection: ModelSelection) => Promise<boolean>
}

View File

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

View File

@@ -0,0 +1,9 @@
/**
* Model selection plugin, node half. Pure UI plugin: the empty apply exists
* so the plugin appears in the host cordis.yml / Loader; the browser half
* ships via exports["./client"], discovered through the package.json
* dsh.client declaration.
*/
/** Host plugin body — no host-side behavior for this surface plugin. */
export function apply(): void {}

View File

@@ -0,0 +1,31 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-model-selection`.
* @module @deepseek-ai/dsh-client-ui-model-selection/invariant
*/
/* jscpd:ignore-start */
import type { Context } from '@deepseek-ai/cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-model-selection'
/** Cordis companion plugin name. */
export const name = 'client-ui-model-selection-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: a single command contribution registration whose disposal is
* proven by the HMR-safety spec — it emits no cordis events and owns no
* cross-plugin mutable state.
*/
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 */