feat(ui): make a session that cannot send refuse to accept one
A default naming a route the Models page has since removed left the composer saying 选择模型 while the input still accepted a message, which then failed inside the adapter mid-turn. `session.prompt` now refuses with `model-unavailable` before opening a turn. That is the enforcement boundary: the method stays callable no matter what a client disables. `session.models` reports the same fact as `routable`, and ui-model pushes a block through the new `ctx.conversation.blocks` registry so the bar renders the disabled textarea it already renders without a workspace, carrying the blocker's own reason. The push direction is forced — ui-model already depends on ui-conversation, so ui-conversation cannot read it back. The gate is `routable`, not "matches no advertised group": catalog membership is advisory, so a route serving a model it stopped advertising is missing from the groups yet perfectly usable, and `null` before the first load never blocks so a slow Host cannot lock a working composer. The scaffold gains a route-only adapter for fixture-less keyless scenarios. Registering zero providers is a test artifact — every product composition mounts one — and the goldens that froze the seat's fallback label now show the model those scenarios actually route to.
This commit is contained in:
@@ -15,6 +15,14 @@ import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
export interface ModelDirectoryState {
|
||||
/** Target the host reports for the next assembled step; null before the first load. */
|
||||
current: ModelTarget | null
|
||||
/**
|
||||
* Whether an adapter serves the current target's route, 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. */
|
||||
@@ -29,7 +37,7 @@ export interface ModelDirectoryState {
|
||||
export class ModelDirectory {
|
||||
/** The shared snapshot both entries render from (uSES-safe store). */
|
||||
readonly store: SnapshotStore<ModelDirectoryState> = createSnapshotStore<ModelDirectoryState>({
|
||||
current: null, groups: [], failures: [], status: 'idle', error: null,
|
||||
current: null, routable: null, groups: [], failures: [], status: 'idle', error: null,
|
||||
})
|
||||
|
||||
/** Latest operation wins; an older response never overwrites a newer one. */
|
||||
@@ -65,9 +73,10 @@ export class ModelDirectory {
|
||||
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, groups, failures } = result.value
|
||||
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'
|
||||
@@ -102,7 +111,14 @@ export class ModelDirectory {
|
||||
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}`)
|
||||
}
|
||||
this.store.update((s) => { s.current = result.value.selected; s.status = 'ready'; s.error = null })
|
||||
// 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
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -115,6 +131,7 @@ export class ModelDirectory {
|
||||
++this.generation
|
||||
this.store.update((s) => {
|
||||
s.current = null
|
||||
s.routable = null
|
||||
s.groups = []
|
||||
s.failures = []
|
||||
s.status = 'idle'
|
||||
|
||||
@@ -105,14 +105,16 @@ export const inject = ['command', 'connection', 'locale', 'sessions', 'slots']
|
||||
* @param ctx - client root context.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
ctx.plugin(ModelService)
|
||||
|
||||
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-model: 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(ModelService, { 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.
|
||||
|
||||
@@ -25,6 +25,7 @@ export const zh = {
|
||||
'action.reload': '重新加载',
|
||||
'warning.groupLoad': '{name} 加载失败:{message}',
|
||||
'empty.models': '没有可用的模型。',
|
||||
'blocked.composer': '当前模型不可用,请先选择模型',
|
||||
'empty.efforts': '当前模型未提供推理等级。',
|
||||
} satisfies Record<string, string>
|
||||
|
||||
@@ -48,5 +49,6 @@ export const en = {
|
||||
'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>
|
||||
|
||||
@@ -36,11 +36,16 @@ export class ModelService extends Service {
|
||||
|
||||
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) {
|
||||
constructor(ctx: Context, config: { blockReason: () => string }) {
|
||||
super(ctx, 'models')
|
||||
this.blockReason = config.blockReason
|
||||
ctx.on('connection/reset', () => {
|
||||
for (const directory of this.live.directories.values()) directory.resetConnected()
|
||||
})
|
||||
@@ -74,6 +79,27 @@ export class ModelService extends Service {
|
||||
() => 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: composer block')
|
||||
}
|
||||
actx.effect(() => () => {
|
||||
directory.dispose()
|
||||
live.directories.delete(sessionId)
|
||||
|
||||
Reference in New Issue
Block a user