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:
@@ -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);
|
||||
}
|
||||
375
packages/client/ui-model-selection/src/client/ModelSelect.tsx
Normal file
375
packages/client/ui-model-selection/src/client/ModelSelect.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
154
packages/client/ui-model-selection/src/client/directory.ts
Normal file
154
packages/client/ui-model-selection/src/client/directory.ts
Normal 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')
|
||||
}
|
||||
}
|
||||
}
|
||||
176
packages/client/ui-model-selection/src/client/index.ts
Normal file
176
packages/client/ui-model-selection/src/client/index.ts
Normal 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))
|
||||
})
|
||||
}
|
||||
54
packages/client/ui-model-selection/src/client/locales.ts
Normal file
54
packages/client/ui-model-selection/src/client/locales.ts
Normal 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>
|
||||
110
packages/client/ui-model-selection/src/client/service.ts
Normal file
110
packages/client/ui-model-selection/src/client/service.ts
Normal 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
|
||||
}
|
||||
}
|
||||
25
packages/client/ui-model-selection/src/client/slots.ts
Normal file
25
packages/client/ui-model-selection/src/client/slots.ts
Normal 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>
|
||||
}
|
||||
6
packages/client/ui-model-selection/src/css-modules.d.ts
vendored
Normal file
6
packages/client/ui-model-selection/src/css-modules.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
declare module '*.module.css' {
|
||||
const classes: Record<string, string>
|
||||
export default classes
|
||||
}
|
||||
|
||||
declare module '*.css'
|
||||
9
packages/client/ui-model-selection/src/index.ts
Normal file
9
packages/client/ui-model-selection/src/index.ts
Normal 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 {}
|
||||
31
packages/client/ui-model-selection/src/invariant.ts
Normal file
31
packages/client/ui-model-selection/src/invariant.ts
Normal 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 */
|
||||
Reference in New Issue
Block a user