From c132cbdb6a63161c88400b0403d046e89fcba09d Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 27 Jul 2026 10:28:45 +0800 Subject: [PATCH 1/4] Adapt session model selection to the slash/input/session architecture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - host trio kept on the merged api-proxy: session.models (provider-grouped advisory directory), session.selectModel (validated provider, advisory model), installAgentLlmTarget threaded through create/resume/ensureSession; the gateway declares the llm inject it reads - history no longer piggybacks modelTarget: the current target travels on session.models alone (the /model popup is the sole consumer) - new @deepseek-ai/dsh-client-ui-model plugin: /model popupSelect over the wire — options load the directory (group label in the detail column, provider-local failures listed inline), onSelect routes selectModel; failures ride the popup shell's error/retry surface - ModelSelector package, conversation.composer.control slot, and the Session-side modelSelection state machine are removed: model selection belongs to the /model popup; the named conversation.input.model seat stays empty until a control-seat entry is designed for it --- apps/cli/cordis.yml | 4 + apps/cli/package.json | 1 + packages/client/connection/src/client/api.ts | 1 + .../client/connection/src/client/fixture.ts | 40 ++++- .../client/connection/src/client/index.ts | 1 + packages/client/connection/tests/fake-api.ts | 32 ++-- .../client/connection/tests/fixture.spec.ts | 2 - packages/client/runtime/tests/fake-api.ts | 33 ++-- packages/client/runtime/tests/session.spec.ts | 159 ----------------- packages/client/ui-model/package.json | 61 +++++++ packages/client/ui-model/src/client/index.ts | Bin 0 -> 3506 bytes packages/client/ui-model/src/css-modules.d.ts | 6 + packages/client/ui-model/src/index.ts | 9 + packages/client/ui-model/src/invariant.ts | 31 ++++ packages/client/ui-model/tsconfig.json | 30 ++++ packages/client/ui-model/tsdown.config.ts | 3 + packages/host/apiproxy/src/api-proxy.ts | 105 +++++++++++- packages/host/apiproxy/src/api/rpc.schema.ts | 1 + packages/host/apiproxy/src/api/rpc.ts | 1 + .../host/apiproxy/src/api/sessions.schema.ts | 1 - packages/host/apiproxy/src/api/sessions.ts | 2 +- packages/host/apiproxy/src/index.ts | 2 +- .../apiproxy/tests/api-proxy-models.spec.ts | 162 ++++++++++++++++++ .../host/apiproxy/tests/rpc-schemas.spec.ts | 44 ++++- pnpm-lock.yaml | 24 +++ tsconfig.base.json | 1 + tsconfig.client.json | 1 + 27 files changed, 564 insertions(+), 193 deletions(-) create mode 100644 packages/client/ui-model/package.json create mode 100644 packages/client/ui-model/src/client/index.ts create mode 100644 packages/client/ui-model/src/css-modules.d.ts create mode 100644 packages/client/ui-model/src/index.ts create mode 100644 packages/client/ui-model/src/invariant.ts create mode 100644 packages/client/ui-model/tsconfig.json create mode 100644 packages/client/ui-model/tsdown.config.ts create mode 100644 packages/host/apiproxy/tests/api-proxy-models.spec.ts diff --git a/apps/cli/cordis.yml b/apps/cli/cordis.yml index efd75c1cf5..6d72bcae58 100644 --- a/apps/cli/cordis.yml +++ b/apps/cli/cordis.yml @@ -300,6 +300,10 @@ - id: ui-subagent name: '@deepseek-ai/dsh-client-ui-subagent' +# Model selection: the /model popupSelect over session.models / selectModel. +- id: ui-model + name: '@deepseek-ai/dsh-client-ui-model' + - id: ui-question name: '@deepseek-ai/dsh-client-ui-question' diff --git a/apps/cli/package.json b/apps/cli/package.json index e0a3a51c94..44920d6baf 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -29,6 +29,7 @@ "@deepseek-ai/dsh-client-ui-command": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-layout": "workspace:^", + "@deepseek-ai/dsh-client-ui-model": "workspace:^", "@deepseek-ai/dsh-client-ui-models": "workspace:^", "@deepseek-ai/dsh-client-ui-question": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", diff --git a/packages/client/connection/src/client/api.ts b/packages/client/connection/src/client/api.ts index edc5b2e25d..181b385077 100644 --- a/packages/client/connection/src/client/api.ts +++ b/packages/client/connection/src/client/api.ts @@ -10,6 +10,7 @@ export type { ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView, WorkspaceApi, WorkspaceId, WorkspaceView, CommandsApi, CommandDescriptor, CommandExecuteResult, SkillsApi, SkillEntry, + ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelTarget, SessionModels, } from '@deepseek-ai/dsh-host-apiproxy/api' export type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation' export type { diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index eaab0a43f9..27b7269a4d 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -9,7 +9,7 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types' import type { ApiProxy, ClientRequest, ClientResponse, HistoryEntry, HostFrame, MuxFrame, RpcReceipt, - RpcRequest, RpcResponse, RpcResult, ServerRequest, ServerResponse, SessionSummary, + ModelTarget, RpcRequest, RpcResponse, RpcResult, ServerRequest, ServerResponse, SessionSummary, ToolCallView, ToolEventView, ToolResultView, WorkspaceId, WorkspaceView, } from './api.ts' import type { RequestPayload, ResponseValue, RpcMethodMap } from '@deepseek-ai/dsh-host-apiproxy/api' @@ -354,6 +354,10 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { { sessionId: sid('fx-gamma'), updatedAt: Date.now() - 120_000, running: false, blank: false, cwd: '/tmp/fixture' }, ] const logs = new Map([[sid('fx-alpha'), buildAlphaLog()]]) + const modelTargets = new Map(sessions.map(session => [ + session.sessionId, + { provider: 'deepseek', model: 'deepseek-v4-flash' }, + ])) const nextTurn = new Map([[sid('fx-alpha'), 60]]) let nextSession = 1 let nextRpc = 1 @@ -596,6 +600,7 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { sessionId: requestedId ?? sid(`fx-${nextSession++}`), updatedAt: Date.now(), running: false, blank: true, cwd, } sessions.push(created) + modelTargets.set(created.sessionId, { provider: 'deepseek', model: 'deepseek-v4-flash' }) attachedSessions += 1 const emitSession = (): void => { // Mirrors the host: the frame fires at creation, so blank is constantly true. @@ -624,7 +629,32 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { const delay = historyDelayMs if (delay > 0) await new Promise(resolve => setTimeout(resolve, delay)) if (doomed) throw new Error('fixture: simulated history transport failure') - return ok(request, page) + return ok(request, { ...page }) + }, + models: request => ok(request, { + current: modelTargets.get(request.payload.sessionId) + ?? { provider: 'deepseek', model: 'deepseek-v4-flash' }, + groups: [ + { + id: 'deepseek', + name: 'DeepSeek', + models: [ + { id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', description: '快速响应' }, + { id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro', description: '复杂任务' }, + ], + }, + { + id: 'openai', + name: 'OpenAI', + models: [{ id: 'gpt-5', name: 'GPT-5' }], + }, + ], + failures: [], + }), + selectModel: (request) => { + const selected = { provider: request.payload.provider, model: request.payload.model } + modelTargets.set(request.payload.sessionId, selected) + return ok(request, { selected }) }, prompt: (request) => { const { sessionId: id, mode, content } = request.payload @@ -660,7 +690,9 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { turn, userText === 'render markdown' ? MARKDOWN_FIXTURE - : `回声:${userText}。这是 fixture 的流式回复,用于验证打字机增长与定稿切换。`, + : userText === 'report model' + ? `当前模型:${modelTargets.get(id)?.provider ?? 'unknown'}/${modelTargets.get(id)?.model ?? 'unknown'}` + : `回声:${userText}。这是 fixture 的流式回复,用于验证打字机增长与定稿切换。`, ) return ok(request, { accepted: true as const }) }, @@ -908,6 +940,8 @@ export class FixtureApiClient extends AbstractApiClient { case 'session.list': return this.api.sessions.list(request) case 'session.create': return this.api.sessions.create(request) case 'session.history': return this.api.sessions.history(request) + case 'session.models': return this.api.sessions.models(request) + case 'session.selectModel': return this.api.sessions.selectModel(request) case 'session.prompt': return this.api.sessions.prompt(request) case 'session.cancel': return this.api.sessions.cancel(request) case 'host.describe': return this.api.host.describe(request) diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index d4505eb659..e255664eb1 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -15,6 +15,7 @@ export type { ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView, ToolCallView, ToolResultView, WorkspaceApi, WorkspaceId, WorkspaceView, CommandsApi, CommandDescriptor, CommandExecuteResult, SkillsApi, SkillEntry, + ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelTarget, SessionModels, RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode, ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt, IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk, diff --git a/packages/client/connection/tests/fake-api.ts b/packages/client/connection/tests/fake-api.ts index bf7295cc50..580224060b 100644 --- a/packages/client/connection/tests/fake-api.ts +++ b/packages/client/connection/tests/fake-api.ts @@ -2,8 +2,8 @@ // data source on a real clock; behavior tests need per-case responses and // deferred-controlled timing). Streams are hand pumps: pushMux/pushHost. import type { - CommandDescriptor, CommandExecuteResult, HostFrame, IApiClient, MuxFrame, - RpcRequest, RpcResponse, SessionId, SkillEntry, + CommandDescriptor, CommandExecuteResult, HostFrame, IApiClient, ModelTarget, MuxFrame, + RpcRequest, RpcResponse, SessionId, SessionModels, SkillEntry, } from '../src/client/api.ts' import { RpcId } from '../src/client/api.ts' @@ -45,9 +45,21 @@ export class FakeApiClient implements IApiClient { onList: (payload: unknown) => Promise> = () => Promise.resolve(ok({ items: [] })) onCreate: (payload: unknown) => Promise> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId })) onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number }) - => Promise> = - () => Promise.resolve(ok({ events: [], hasMore: false })) + => Promise> = + () => Promise.resolve(ok({ + events: [], + hasMore: false, + modelTarget: { provider: 'deepseek', model: 'deepseek-chat' }, + })) + onModels: (payload: unknown) => Promise> = () => Promise.resolve(ok({ + current: { provider: 'deepseek', model: 'deepseek-chat' }, + groups: [], + failures: [], + })) + onSelectModel: (payload: ModelTarget & { sessionId: SessionId }) + => Promise> = + payload => Promise.resolve(ok({ selected: { provider: payload.provider, model: payload.model } })) onPrompt: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) onCancel: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) onDescribe: (payload: unknown) => Promise> = @@ -64,6 +76,9 @@ export class FakeApiClient implements IApiClient { create: (payload: unknown) => this.record('session.create', payload, this.onCreate(payload)), history: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number }) => this.record('session.history', payload, this.onHistory(payload)), + models: (payload: unknown) => this.record('session.models', payload, this.onModels(payload)), + selectModel: (payload: ModelTarget & { sessionId: SessionId }) => + this.record('session.selectModel', payload, this.onSelectModel(payload)), prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)), cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)), } @@ -88,12 +103,9 @@ export class FakeApiClient implements IApiClient { // Payloads stay `unknown` (lint-lane note above); response rows are the real // wire shapes so cases can program catalogs and skill lists without casts. - onCommandList: (payload: unknown) => Promise> - = () => Promise.resolve(ok({ commands: [] })) - onCommandExecute: (payload: unknown) => Promise> - = () => Promise.resolve(ok({ matched: false })) - onSkillList: (payload: unknown) => Promise> - = () => Promise.resolve(ok({ skills: [] })) + onCommandList: (payload: unknown) => Promise> = () => Promise.resolve(ok({ commands: [] })) + onCommandExecute: (payload: unknown) => Promise> = () => Promise.resolve(ok({ matched: false })) + onSkillList: (payload: unknown) => Promise> = () => Promise.resolve(ok({ skills: [] })) readonly commands: IApiClient['commands'] = { list: (payload: unknown) => this.record('command.list', payload, this.onCommandList(payload)), diff --git a/packages/client/connection/tests/fixture.spec.ts b/packages/client/connection/tests/fixture.spec.ts index fc6c0a1cef..8dbb466213 100644 --- a/packages/client/connection/tests/fixture.spec.ts +++ b/packages/client/connection/tests/fixture.spec.ts @@ -71,7 +71,6 @@ describe('createFixtureApi', () => { expect(empty.result.value).toEqual({ events: [], hasMore: false, - modelTarget: { provider: 'deepseek', model: 'deepseek-v4-flash' }, }) }) @@ -93,7 +92,6 @@ describe('createFixtureApi', () => { expect(selected.result.value.selected).toEqual({ provider: 'openai', model: 'gpt-5' }) const history = await api.sessions.history(req({ sessionId })) if (!history.result.ok) throw new Error('history failed') - expect(history.result.value.modelTarget).toEqual({ provider: 'openai', model: 'gpt-5' }) const prompt = await api.sessions.prompt(req({ sessionId, diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts index dcb334f6ea..6e0f0df2ef 100644 --- a/packages/client/runtime/tests/fake-api.ts +++ b/packages/client/runtime/tests/fake-api.ts @@ -2,8 +2,8 @@ // data source on a real clock; behavior tests need per-case responses and // deferred-controlled timing). Streams are hand pumps: pushMux/pushHost. import type { - ClientResponse, CommandDescriptor, CommandExecuteResult, HostFrame, IApiClient, MuxFrame, - RpcError, RpcReceipt, RpcRequest, RpcResponse, SessionId, SkillEntry, + ClientResponse, CommandDescriptor, CommandExecuteResult, HostFrame, IApiClient, ModelTarget, MuxFrame, + RpcError, RpcReceipt, RpcRequest, RpcResponse, SessionId, SessionModels, SkillEntry, WorkspaceId, WorkspaceView, } from '@deepseek-ai/dsh-client-connection/client' import { RpcId } from '@deepseek-ai/dsh-client-connection/client' @@ -61,10 +61,23 @@ export class FakeApiClient implements IApiClient { // Programmable slots (defaults answer OK-empty); reassign per case. onList: (payload: unknown) => Promise> = () => Promise.resolve(ok({ items: [] })) onCreate: (payload: unknown) => Promise> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId })) + readonly defaultModel: ModelTarget = { provider: 'deepseek', model: 'deepseek-v4-flash' } onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number }) - => Promise> = - () => Promise.resolve(ok({ events: [], hasMore: false })) + => Promise> = + () => Promise.resolve(ok({ events: [], hasMore: false, modelTarget: this.defaultModel })) + onModels: (payload: unknown) => Promise> = () => Promise.resolve(ok({ + current: this.defaultModel, + groups: [{ + id: 'deepseek', + name: 'DeepSeek', + models: [{ id: 'deepseek-v4-flash', name: 'DeepSeek V4 Flash' }], + }], + failures: [], + })) + onSelectModel: (payload: { provider: string; model: string }) => + Promise> = + payload => Promise.resolve(ok({ selected: { provider: payload.provider, model: payload.model } })) onPrompt: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) onCancel: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) onDescribe: (payload: unknown) => Promise> = @@ -81,6 +94,9 @@ export class FakeApiClient implements IApiClient { create: (payload: unknown) => this.record('session.create', payload, this.onCreate(payload)), history: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number }) => this.record('session.history', payload, this.onHistory(payload)), + models: (payload: unknown) => this.record('session.models', payload, this.onModels(payload)), + selectModel: (payload: { provider: string; model: string }) => + this.record('session.selectModel', payload, this.onSelectModel(payload)), prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)), cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)), } @@ -110,12 +126,9 @@ export class FakeApiClient implements IApiClient { // Payloads stay `unknown` (lint-lane note above); response rows are the real // wire shapes so cases can program requires-bearing catalogs and dual-address // skill lists without casts. - onCommandList: (payload: unknown) => Promise> - = () => Promise.resolve(ok({ commands: [] })) - onCommandExecute: (payload: unknown) => Promise> - = () => Promise.resolve(ok({ matched: false })) - onSkillList: (payload: unknown) => Promise> - = () => Promise.resolve(ok({ skills: [] })) + onCommandList: (payload: unknown) => Promise> = () => Promise.resolve(ok({ commands: [] })) + onCommandExecute: (payload: unknown) => Promise> = () => Promise.resolve(ok({ matched: false })) + onSkillList: (payload: unknown) => Promise> = () => Promise.resolve(ok({ skills: [] })) readonly commands: IApiClient['commands'] = { list: (payload: unknown) => this.record('command.list', payload, this.onCommandList(payload)), diff --git a/packages/client/runtime/tests/session.spec.ts b/packages/client/runtime/tests/session.spec.ts index a3bd647117..9a7f9e5ad6 100644 --- a/packages/client/runtime/tests/session.spec.ts +++ b/packages/client/runtime/tests/session.spec.ts @@ -91,164 +91,6 @@ describe('open', () => { }) }) -describe('model selection', () => { - it('restores the current target from history, then refreshes grouped models with partial failures', async () => { - const { api, session } = makeSession() - api.onHistory = () => histResponse([]) - api.onModels = () => Promise.resolve(ok({ - current: { provider: 'deepseek', model: 'deepseek-v4-flash' }, - groups: [{ - id: 'deepseek', - name: 'DeepSeek', - models: [{ id: 'deepseek-v4-flash', name: 'DeepSeek V4 Flash' }], - }], - failures: [{ id: 'offline', name: 'Offline', message: 'catalog down' }], - })) - await session.open() - expect(session.getSnapshot().modelSelection).toEqual({ - current: { provider: 'deepseek', model: 'deepseek-v4-flash' }, - groups: [], - failures: [], - status: 'idle', - error: null, - }) - - const refreshing = session.refreshModels() - expect(session.getSnapshot().modelSelection.status).toBe('loading') - await refreshing - expect(session.getSnapshot().modelSelection).toEqual({ - current: { provider: 'deepseek', model: 'deepseek-v4-flash' }, - groups: [{ - id: 'deepseek', - name: 'DeepSeek', - models: [{ id: 'deepseek-v4-flash', name: 'DeepSeek V4 Flash' }], - }], - failures: [{ id: 'offline', name: 'Offline', message: 'catalog down' }], - status: 'ready', - error: null, - }) - }) - - it('keeps the previous target and directory when selection fails, then accepts a retry', async () => { - const { api, session } = makeSession() - await session.open() - await session.refreshModels() - const before = session.getSnapshot().modelSelection - api.onSelectModel = () => Promise.resolve(err({ - code: 'model-unavailable', - message: 'gone', - details: { provider: 'deepseek', model: 'deepseek-v4-pro' }, - })) - - const failed = session.selectModel({ provider: 'deepseek', model: 'deepseek-v4-pro' }) - expect(session.getSnapshot().modelSelection.status).toBe('selecting') - await failed - const errored = session.getSnapshot().modelSelection - expect(errored.current).toBe(before.current) - expect(errored.groups).toBe(before.groups) - expect(errored).toMatchObject({ status: 'error', error: { code: 'model-unavailable' } }) - - api.onSelectModel = payload => Promise.resolve(ok({ - selected: { provider: payload.provider, model: payload.model }, - })) - await session.retryModelOperation() - expect(api.callsOf('session.selectModel').at(-1)).toEqual({ - sessionId: SID, - provider: 'deepseek', - model: 'deepseek-v4-pro', - }) - expect(session.getSnapshot().modelSelection).toMatchObject({ - current: { provider: 'deepseek', model: 'deepseek-v4-pro' }, - status: 'ready', - error: null, - }) - }) - - it('drops stale directory responses after a newer selection operation wins', async () => { - const { api, session } = makeSession() - await session.open() - const stale = deferred>>() - api.onModels = () => stale.promise - const refreshing = session.refreshModels() - await session.selectModel({ provider: 'deepseek', model: 'deepseek-v4-pro' }) - const selected = session.getSnapshot().modelSelection - - stale.resolve(ok({ - current: { provider: 'deepseek', model: 'stale' }, - groups: [{ id: 'deepseek', name: 'Old', models: [{ id: 'stale', name: 'Stale' }] }], - failures: [], - })) - await refreshing - expect(session.getSnapshot().modelSelection).toBe(selected) - expect(session.getSnapshot().modelSelection.current) - .toEqual({ provider: 'deepseek', model: 'deepseek-v4-pro' }) - }) - - it('does not let an older history response overwrite a newer selected target', async () => { - const { api, session } = makeSession() - const history = deferred>>() - api.onHistory = () => history.promise - const opening = session.open() - await session.selectModel({ provider: 'deepseek', model: 'deepseek-v4-pro' }) - history.resolve(ok({ - events: [], - hasMore: false, - modelTarget: { provider: 'deepseek', model: 'deepseek-v4-flash' }, - })) - await opening - expect(session.getSnapshot().modelSelection.current) - .toEqual({ provider: 'deepseek', model: 'deepseek-v4-pro' }) - }) - - it('folds directory and selection transport failures without discarding usable state', async () => { - const { api, session } = makeSession() - await session.open() - await session.refreshModels() - const groups = session.getSnapshot().modelSelection.groups - api.onModels = () => Promise.reject(new Error('directory transport down')) - await session.refreshModels() - expect(session.getSnapshot().modelSelection).toMatchObject({ - groups, - status: 'error', - error: { code: 'internal', message: 'directory transport down' }, - }) - api.onModels = () => Promise.resolve(ok({ - current: { provider: 'deepseek', model: 'deepseek-v4-flash' }, - groups: [...groups], - failures: [], - })) - await session.retryModelOperation() - expect(session.getSnapshot().modelSelection.status).toBe('ready') - - api.onSelectModel = () => Promise.reject(new Error('selection transport down')) - await session.selectModel({ provider: 'deepseek', model: 'deepseek-v4-pro' }) - expect(session.getSnapshot().modelSelection).toMatchObject({ - current: { provider: 'deepseek', model: 'deepseek-v4-flash' }, - groups, - status: 'error', - error: { code: 'internal', message: 'selection transport down' }, - }) - }) - - it('reconciles a failed selection from authoritative history on reconnect', async () => { - const { api, session } = makeSession() - await session.open() - api.onSelectModel = () => Promise.reject(new Error('lost response')) - await session.selectModel({ provider: 'deepseek', model: 'deepseek-v4-pro' }) - expect(session.getSnapshot().modelSelection.status).toBe('error') - api.onHistory = () => Promise.resolve(ok({ - events: [], - hasMore: false, - modelTarget: { provider: 'deepseek', model: 'deepseek-v4-pro' }, - })) - await session.resync() - expect(session.getSnapshot().modelSelection).toMatchObject({ - current: { provider: 'deepseek', model: 'deepseek-v4-pro' }, - status: 'idle', - error: null, - }) - }) -}) describe('live event path', () => { async function opened(events: SessionEvent[] = plainTurn(0, 0, 'a', 'b')) { @@ -949,7 +791,6 @@ describe('reference stability (the memo contract)', () => { expect(after).not.toBe(before) expect(after.runningCalls).toBe(before.runningCalls) expect(after.pending).toBe(before.pending) - expect(after.modelSelection).toBe(before.modelSelection) // And a mutation on the tracked domain swaps that array. feed(ev.toolResult(10, 1, 'c1', 'ECHO')) const resolved = session.getSnapshot() diff --git a/packages/client/ui-model/package.json b/packages/client/ui-model/package.json new file mode 100644 index 0000000000..67eafd66cc --- /dev/null +++ b/packages/client/ui-model/package.json @@ -0,0 +1,61 @@ +{ + "name": "@deepseek-ai/dsh-client-ui-model", + "description": "Model selection: the /model popupSelect over session.models / session.selectModel", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dshClient": { + "inject": [ + "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-ui-command" + ], + "platform": "web" + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-client-connection": "^0.0.1", + "@deepseek-ai/dsh-client-runtime": "^0.0.1", + "@deepseek-ai/dsh-client-ui-command": "^0.0.1", + "@deepseek-ai/dsh-client-ui-slash": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-command": "workspace:^", + "@deepseek-ai/dsh-client-ui-slash": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "cordis": "^4.0.0-rc.7" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/client.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ] +} diff --git a/packages/client/ui-model/src/client/index.ts b/packages/client/ui-model/src/client/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..e83a05c7a321d9e4eb736e66f6a308f6cf25c48a GIT binary patch literal 3506 zcmbtXPm3c(6!)A@@fL>dWK25uNOWOQ+{3cs9@l|Xx+_UJm8!O?Ix`^~@S=!EQ3Uni z!M%781i_1Y@uMt^?#-{@d#|cHo!MDNFfcp4>G%Kr-tWB`pPu&M6du}AYVcCaA}DL1 z()CiAAq?d&%8l~YZJ|U1MWCntESb*Pw4V)z zoitD{NVXML_`F;}D;{&@yl2p-7+2N@@J3XAWrGJ5Km-oT$jgx8Syy2syqD{_-oo70 zrW9_A9xVuPwK2*pA=J*mxs(;z@Q}nY{2<&?hCxoYK4@~&wh$T$5rno&SO}$SCq4Mu zEkq$v8IsOdlBSd;850X^3Wr*=C~(32TX3&8w7EOrwVmJ4Hm7oUFx+TN3tJGgI+iNJavW6*L4k0Tzq{2unOaCQyZy^^XX<@rdcap_m1LMv&)2r-rP$MKu& zx&xL&HvwC9t2t~`P;2=*TXwz1C?89=QH3O%P85Mcrr~(Rh`rTCtuSb|(|YNRPfsDK zs}kp=l*I(Bk+9GRv{*=u^98;VI1>%}Q!{fo9rqTsiSro;=Rw(Tq$bz{hu|2-5o`WC zXdYnK1Ds#R=ImtGsO{f4+411)XT5!oLwpaT3DPi}4s)hVuqj4{U!NfcTFj9*2tkn( zN*NHrn4GTHh=m(s_Kz0**6idM2pDa*%1_QXYV1B1g}lDR zL>`A>z_yNLIdMQ)mPiEB$YHh6V_wyM)u-CUk1DZc3<0ctR8wWDd9KRAu$AP>Oky{3 zBi8cpDi*1ER~u?N(!+E*h2UzO!}jwIV6UPZGO>q0e}}(*`1$IKZ?8Uk_V@R{Ts{A0 z))8+^VmAt>B2w?5Rir7oPzHV6WH2Eh?`Z93FiBN84jm^!ZRDg=-J*65XEbjR#c4E^ zfxCBa5RG-GbomME*3@P{;B5BzFa0FEQX?94eu!L#6AYUs;;tYzX++@KAQf!$OpKpSdS( zOxy|O_OK|~f7+8rA-0RrSsS2@kXOUKN;t8GB3!}*TO5;pzEb7zrrSYUzs{Q*qh#<*BCZ|;jUa0I;xWDJrlLZpLHd=trniySdU zL=aXAdut8$)eAZ=lB(DsGtnZQK=ewp7OmE5lGp(NH!RM0*12kuJ~Y?L1Z%T31@u!H zas8{M5t>suP(@R0+~&2Wr8EU`A@Hhbs-Fm_-}d7HI!-V1^!q^x`_?{|E`HPk`gfw; z+@23GWgHPO@&ewaZGHVF;;TZU@snLr;-#L`3fjN&?Ha)$mU2Ifvq&CeDl~?eM5I5W z@nmcxbS)3U + export default classes +} + +declare module '*.css' diff --git a/packages/client/ui-model/src/index.ts b/packages/client/ui-model/src/index.ts new file mode 100644 index 0000000000..83daadbf12 --- /dev/null +++ b/packages/client/ui-model/src/index.ts @@ -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 + * dshClient declaration. + */ + +/** Host plugin body — no host-side behavior for this surface plugin. */ +export function apply(): void {} diff --git a/packages/client/ui-model/src/invariant.ts b/packages/client/ui-model/src/invariant.ts new file mode 100644 index 0000000000..baac6dcc70 --- /dev/null +++ b/packages/client/ui-model/src/invariant.ts @@ -0,0 +1,31 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-model`. + * @module @deepseek-ai/dsh-client-ui-model/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-model' + +/** Cordis companion plugin name. */ +export const name = 'client-ui-model-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 */ diff --git a/packages/client/ui-model/tsconfig.json b/packages/client/ui-model/tsconfig.json new file mode 100644 index 0000000000..5cefc3e6de --- /dev/null +++ b/packages/client/ui-model/tsconfig.json @@ -0,0 +1,30 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../connection" + }, + { + "path": "../runtime" + }, + { + "path": "../ui-command" + }, + { + "path": "../ui-slash" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/client/ui-model/tsdown.config.ts b/packages/client/ui-model/tsdown.config.ts new file mode 100644 index 0000000000..79905da095 --- /dev/null +++ b/packages/client/ui-model/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-client-ui-model', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index f81f5c9be8..611a1f7384 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -7,7 +7,8 @@ import { randomUUID } from 'node:crypto' import { mkdir, stat } from 'node:fs/promises' import { join } from 'node:path' import type { Context } from 'cordis' -import type { Agent, AgentMessage, AgentMessageId, AgentStatus } from '@deepseek-ai/dsh-agent' +import { installAgentLlmTarget } from '@deepseek-ai/dsh-agent' +import type { Agent, AgentLlmTargetRef, AgentMessage, AgentMessageId, AgentStatus } from '@deepseek-ai/dsh-agent' import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' import type { JsonValue, Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' @@ -20,7 +21,8 @@ import { // Type-only: brings the `ctx.tools` Context merge into this program (viewFor reads presenters). import type {} from '@deepseek-ai/dsh-tools' import type { - ApiProxy, HistoryEntry, HostFrame, MuxFrame, QuestionResponsePayload, SessionSummary, ToolEventView, + ApiProxy, HistoryEntry, HostFrame, ModelCatalogFailure, ModelProviderGroup, ModelTarget, + MuxFrame, QuestionResponsePayload, SessionSummary, ToolEventView, WorkspaceId, WorkspaceView, } from './api/index.ts' // Type-only edges: resolve `ctx.get('commands')`, the `commands/change` event, and `ctx.get('skills')`. @@ -348,6 +350,8 @@ function changedWorkspaceView(workspaceId: string, value: unknown): WorkspaceVie */ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiProxy { const agentOptions = { provider: defaults.provider, model: defaults.model } + type WebLlmTargetRef = AgentLlmTargetRef & { current: ModelTarget } + const targets = new WeakMap() /** Implicit resume of cold sessions, deduplicating concurrent calls (follows the jsonrpc sessionCreations precedent). */ const resumes = new Map>() /** Client-chosen identity creation/resume, deduplicated across concurrent retries. */ @@ -357,6 +361,34 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro const pendingQuestions = new Map() const muxQueues = new Set>>() + /** + * Install or return the session-local target that prompt assembly snapshots. + * Seed order: latest logged request/header, else the host default routing. + * There is no create-time per-session override tier on this wire — if one + * returns (a create-options contribution), it must fold in between the two. + */ + function targetFor(agent: Agent): WebLlmTargetRef { + const installed = targets.get(agent) + if (installed !== undefined) return installed + const logged = agent.session.requestHeader()?.config + const target: WebLlmTargetRef = { + current: logged === undefined + ? { provider: defaults.provider, model: defaults.model } + : { provider: logged.provider, model: logged.model }, + assembled: undefined, + } + installAgentLlmTarget(agent.ctx, target) + targets.set(agent, target) + return target + } + + /** Pre-publication setup used by both fresh and resumed Web agents. */ + function installTarget(agentCtx: Context): void { + const agent = agentCtx.agent + if (agent === undefined) throw new Error('api-proxy: agent setup has no scoped agent') + targetFor(agent) + } + /** Send one transient frame to every connected mux consumer. */ function broadcast(payload: MuxFrame): void { const envelope = frame(payload) @@ -470,7 +502,11 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro resume = (async () => { try { await assertServable(sessionId) - const handle = await ctx.agents.resume({ resumeSessionId: sessionId, agentOptions }) + const handle = await ctx.agents.resume({ + resumeSessionId: sessionId, + agentOptions, + setup: installTarget, + }) return handle.agent } finally { resumes.delete(sessionId) @@ -645,6 +681,69 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro return ok(request, { events: entries, hasMore: page.hasMore }) }, + async models(request) { + const { sessionId } = request.payload + const found = await agentFor(sessionId) + if ('error' in found) return err(request, found.error) + const current = targetFor(found.agent).current + const catalog = await Promise.all(ctx.llm.listProviders().map(async (provider) => { + try { + const models = await ctx.llm.listModels(provider.id) + const group: ModelProviderGroup = { + id: provider.id, + name: provider.name, + models: models.map(model => ({ + id: model.id, + name: model.name, + ...model.description === undefined ? {} : { description: model.description }, + })), + } + return { kind: 'group' as const, group } + } catch (error: unknown) { + const failure: ModelCatalogFailure = { + id: provider.id, + name: provider.name, + message: error instanceof Error ? error.message : String(error), + } + return { kind: 'failure' as const, failure } + } + })) + const groups = catalog.flatMap(item => item.kind === 'group' ? [item.group] : []) + const failures = catalog.flatMap(item => item.kind === 'failure' ? [item.failure] : []) + const currentGroup = groups.find(group => group.id === current.provider) + if ( + currentGroup !== undefined + && !currentGroup.models.some(model => model.id === current.model) + ) { + currentGroup.models.push({ + id: current.model, + name: current.model, + unlisted: true, + }) + } + return ok(request, { + current: { ...current }, + groups: groups.filter(group => group.models.length > 0), + failures, + }) + }, + + async selectModel(request) { + const { sessionId, provider, model } = request.payload + const found = await agentFor(sessionId) + if ('error' in found) return err(request, found.error) + if (!ctx.llm.listProviders().some(entry => entry.id === provider)) { + return err(request, { + code: 'model-unavailable', + message: `provider "${provider}" is not registered`, + details: { provider, model }, + }) + } + const selected: ModelTarget = { provider, model } + targetFor(found.agent).current = selected + return ok(request, { selected: { ...selected } }) + }, + async prompt(request) { const { sessionId, mode, content } = request.payload const found = await agentFor(sessionId) diff --git a/packages/host/apiproxy/src/api/rpc.schema.ts b/packages/host/apiproxy/src/api/rpc.schema.ts index d83ae2ce98..d12d643e3f 100644 --- a/packages/host/apiproxy/src/api/rpc.schema.ts +++ b/packages/host/apiproxy/src/api/rpc.schema.ts @@ -41,6 +41,7 @@ export const rpcErrorSchema: z.ZodType = z.discriminatedUnion('code', z.object({ code: z.literal('workspace-invalid-path'), message: z.string(), details: z.object({ path: z.string() }) }), z.object({ code: z.literal('workspace-name-conflict'), message: z.string(), details: z.object({ name: z.string() }) }), z.object({ code: z.literal('workspace-move-invalid'), message: z.string(), details: z.object({ workspaceId: z.string(), sessionId: z.string(), beforeSessionId: z.string().optional() }) }), + z.object({ code: z.literal('model-unavailable'), message: z.string(), details: z.object({ provider: z.string(), model: z.string() }) }), z.object({ code: z.literal('agent-busy'), message: z.string(), details: z.object({ reason: z.string() }) }), z.object({ code: z.literal('internal'), message: z.string(), details: z.object({}) }), ]) as unknown as z.ZodType diff --git a/packages/host/apiproxy/src/api/rpc.ts b/packages/host/apiproxy/src/api/rpc.ts index ad06c42fbe..652331ebd5 100644 --- a/packages/host/apiproxy/src/api/rpc.ts +++ b/packages/host/apiproxy/src/api/rpc.ts @@ -38,6 +38,7 @@ export interface RpcErrorDetailsMap { 'workspace-invalid-path': { path: string } 'workspace-name-conflict': { name: string } 'workspace-move-invalid': { workspaceId: string; sessionId: SessionId; beforeSessionId?: SessionId } + 'model-unavailable': { provider: string; model: string } 'agent-busy': { reason: string } 'internal': {} } diff --git a/packages/host/apiproxy/src/api/sessions.schema.ts b/packages/host/apiproxy/src/api/sessions.schema.ts index a7f451bf55..69d1ab3fa3 100644 --- a/packages/host/apiproxy/src/api/sessions.schema.ts +++ b/packages/host/apiproxy/src/api/sessions.schema.ts @@ -128,7 +128,6 @@ export const historyEntrySchema = z.object({ export const sessionHistoryValueSchema = z.object({ events: z.array(historyEntrySchema), hasMore: z.boolean(), - modelTarget: modelTargetSchema, }) satisfies z.ZodType>> /** session.models request payload. */ diff --git a/packages/host/apiproxy/src/api/sessions.ts b/packages/host/apiproxy/src/api/sessions.ts index afd22d9739..c12e9b317f 100644 --- a/packages/host/apiproxy/src/api/sessions.ts +++ b/packages/host/apiproxy/src/api/sessions.ts @@ -129,7 +129,7 @@ export interface SessionsApi { * rebuilds the surface from the events with the shared fold. */ history(request: RpcRequest<{ sessionId: SessionId; beforeSeq?: number; maxMessages?: number }>): - Promise> + Promise> /** Reads a fresh advisory model directory for this session. Provider lookups run independently. */ models(request: RpcRequest<{ sessionId: SessionId }>): Promise> diff --git a/packages/host/apiproxy/src/index.ts b/packages/host/apiproxy/src/index.ts index 8a63c3de32..e7ef6c7332 100644 --- a/packages/host/apiproxy/src/index.ts +++ b/packages/host/apiproxy/src/index.ts @@ -45,7 +45,7 @@ export interface Config { * project directory and the fallback parent for name-created Workspaces. */ export class ApiProxyService extends Service implements ApiProxy { - static inject = ['agents', 'sessions', 'tools', 'userInteraction', 'workspace'] + static inject = ['agents', 'llm', 'sessions', 'tools', 'userInteraction', 'workspace'] static Config: z = z.object({ provider: z.string().required(), diff --git a/packages/host/apiproxy/tests/api-proxy-models.spec.ts b/packages/host/apiproxy/tests/api-proxy-models.spec.ts new file mode 100644 index 0000000000..96efe60a9d --- /dev/null +++ b/packages/host/apiproxy/tests/api-proxy-models.spec.ts @@ -0,0 +1,162 @@ +/** + * Web session model-directory and selection behavior: dynamic provider grouping, + * provider-local catalog failures, logged-target restoration, advisory unlisted + * models, and the prompt-assembly boundary for a running selection change. + */ + +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' +import LlmService, { LlmAdapter } from '@deepseek-ai/dsh-llm' +import type { + GenerateOptions, LlmCallConfig, LlmModelInfo, LlmProviderInfo, StreamChunk, +} from '@deepseek-ai/dsh-llm' +import SessionStore from '@deepseek-ai/dsh-session' +import type { SessionId } from '@deepseek-ai/dsh-session' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import UserInteractionService from '@deepseek-ai/dsh-user-interaction' +import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' +import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' +import { createApiProxy } from '../src/api-proxy.ts' + +let nextRpc = 1 +function request

(payload: P): RpcRequest

{ + return { rpcId: RpcId(`models-${String(nextRpc++)}`), payload } +} + +class CatalogAdapter extends LlmAdapter { + constructor( + private readonly name: string, + private readonly models: readonly LlmModelInfo[] | Error, + ) { + super() + } + + override providerInfo(provider: string): LlmProviderInfo { + return { id: provider, name: this.name } + } + + override listModels(): Promise { + return this.models instanceof Error + ? Promise.reject(this.models) + : Promise.resolve(this.models) + } + + override async *stream(_options: GenerateOptions): AsyncIterable { + // Catalog tests never enter provider streaming. + } +} + +async function harness(logged?: { provider: string; model: string }): Promise<{ + ctx: Context + agent: Agent + sessionId: SessionId +}> { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt, { persona: '' }) + await ctx.plugin(LlmService) + await ctx.plugin(UserInteractionService) + await ctx.plugin(AgentRegistry) + ctx.llm.registerAdapter(['deepseek'], new CatalogAdapter('DeepSeek', [ + { provider: 'deepseek', id: 'deepseek-chat', name: 'DeepSeek Chat' }, + { provider: 'deepseek', id: 'deepseek-reasoner', name: 'DeepSeek Reasoner', description: 'Reasoning model' }, + ])) + ctx.llm.registerAdapter(['broken'], new CatalogAdapter('Broken Provider', new Error('catalog offline'))) + ctx.llm.registerAdapter(['empty'], new CatalogAdapter('Empty Provider', [])) + ctx.llm.registerAdapter(['duplicate'], new CatalogAdapter('Duplicate Provider', [ + { provider: 'duplicate', id: 'same', name: 'Same' }, + { provider: 'duplicate', id: 'same', name: 'Same Again' }, + ])) + const session = ctx.sessions.create() + if (logged !== undefined) { + session.append('request/header', { header: { config: logged }, reason: 'initial' }) + } + const agent = { + id: session.id, + session, + status: 'running', + ctx, + } as Agent + ctx.agents.register(agent) + return { ctx, agent, sessionId: session.id } +} + +function expectValue(response: { result: { ok: true; value: T } | { ok: false } }): T { + if (!response.result.ok) throw new Error('expected successful response') + return response.result.value +} + +describe('Web session model selection', () => { + it('groups successful providers, isolates failures, and preserves an unlisted current model', async () => { + const { ctx, sessionId } = await harness({ provider: 'deepseek', model: 'private-preview' }) + const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' }) + + const catalog = expectValue(await api.sessions.models(request({ sessionId }))) + expect(catalog.current).toEqual({ provider: 'deepseek', model: 'private-preview' }) + expect(catalog.groups).toEqual([{ + id: 'deepseek', + name: 'DeepSeek', + models: [ + { id: 'deepseek-chat', name: 'DeepSeek Chat' }, + { id: 'deepseek-reasoner', name: 'DeepSeek Reasoner', description: 'Reasoning model' }, + { id: 'private-preview', name: 'private-preview', unlisted: true }, + ], + }]) + expect(catalog.failures).toEqual([ + { id: 'broken', name: 'Broken Provider', message: 'catalog offline' }, + { + id: 'duplicate', + name: 'Duplicate Provider', + message: 'adapter returned invalid or duplicate model metadata for provider "duplicate"', + }, + ]) + await ctx.fiber.dispose() + }) + + it('accepts an advisory-unlisted model, rejects an unavailable provider, and switches only after the next assembly', async () => { + const { ctx, agent, sessionId } = await harness() + const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' }) + const seed: LlmCallConfig = { provider: 'seed', model: 'seed', temperature: 0.2 } + const signal = new AbortController().signal + + expect(expectValue(await api.sessions.models(request({ sessionId }))).current) + .toEqual({ provider: 'deepseek', model: 'deepseek-chat' }) + expect((await ctx.systemPrompt.assemble()).variables) + .toMatchObject({ provider: 'deepseek', model: 'deepseek-chat' }) + + const selected = expectValue(await api.sessions.selectModel(request({ + sessionId, + provider: 'deepseek', + model: 'private-preview', + }))) + expect(selected.selected).toEqual({ provider: 'deepseek', model: 'private-preview' }) + await expect(agentEvents(ctx, agent).waterfall( + 'agent/request', 1, 0, seed, signal, () => Promise.resolve(seed), + )).resolves.toMatchObject({ provider: 'deepseek', model: 'deepseek-chat' }) + + expect((await ctx.systemPrompt.assemble()).variables) + .toMatchObject({ provider: 'deepseek', model: 'private-preview' }) + await expect(agentEvents(ctx, agent).waterfall( + 'agent/request', 1, 1, seed, signal, () => Promise.resolve(seed), + )).resolves.toMatchObject({ provider: 'deepseek', model: 'private-preview' }) + + const rejected = await api.sessions.selectModel(request({ + sessionId, + provider: 'missing', + model: 'model', + })) + expect(rejected.result).toEqual({ + ok: false, + error: { + code: 'model-unavailable', + message: 'provider "missing" is not registered', + details: { provider: 'missing', model: 'model' }, + }, + }) + expect(expectValue(await api.sessions.models(request({ sessionId }))).current) + .toEqual({ provider: 'deepseek', model: 'private-preview' }) + await ctx.fiber.dispose() + }) +}) diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index 02ca8dec22..aa08f8a0d2 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -8,8 +8,9 @@ import { z } from 'zod' import { contentBlockSchema, sessionCancelRequestSchema, sessionCancelValueSchema, sessionCreateRequestSchema, sessionCreateValueSchema, sessionEventSchema, sessionHistoryRequestSchema, sessionHistoryValueSchema, - sessionIdSchema, sessionListRequestSchema, sessionListValueSchema, sessionPromptRequestSchema, - sessionPromptValueSchema, sessionSummarySchema, + sessionIdSchema, sessionListRequestSchema, sessionListValueSchema, sessionModelsRequestSchema, + sessionModelsValueSchema, sessionPromptRequestSchema, sessionPromptValueSchema, + sessionSelectModelRequestSchema, sessionSelectModelValueSchema, sessionSummarySchema, } from '../src/api/sessions.schema.ts' import { hostDescribeRequestSchema, hostDescribeValueSchema } from '../src/api/host.schema.ts' import { @@ -55,6 +56,11 @@ describe('rpcErrorSchema', () => { expect(rpcErrorSchema.parse({ code: 'workspace-invalid-path', message: 'm', details: { path: '/x' } }).code).toBe('workspace-invalid-path') expect(rpcErrorSchema.parse({ code: 'workspace-name-conflict', message: 'm', details: { name: 'x' } }).code).toBe('workspace-name-conflict') expect(rpcErrorSchema.parse({ code: 'workspace-move-invalid', message: 'm', details: { workspaceId: 'w', sessionId: 's' } }).code).toBe('workspace-move-invalid') + expect(rpcErrorSchema.parse({ + code: 'model-unavailable', + message: 'm', + details: { provider: 'p', model: 'm' }, + }).code).toBe('model-unavailable') expect(rpcErrorSchema.parse({ code: 'agent-busy', message: 'm', details: { reason: 'r' } }).code).toBe('agent-busy') expect(rpcErrorSchema.parse({ code: 'internal', message: 'm', details: {} }).code).toBe('internal') }) @@ -128,7 +134,39 @@ describe('sessions domain schemas', () => { expect(sessionCreateValueSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1') expect(sessionHistoryRequestSchema.parse({ sessionId: 's1', beforeSeq: 3, maxMessages: 5 }).beforeSeq).toBe(3) expect(() => sessionHistoryRequestSchema.parse({ sessionId: 's1', maxMessages: 0 })).toThrow() - expect(sessionHistoryValueSchema.parse({ events: [], hasMore: false }).hasMore).toBe(false) + expect(sessionHistoryValueSchema.parse({ + events: [], + hasMore: false, + modelTarget: { provider: 'deepseek', model: 'deepseek-v4-flash' }, + }).hasMore).toBe(false) + expect(sessionModelsRequestSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1') + expect(sessionModelsValueSchema.parse({ + current: { provider: 'deepseek', model: 'deepseek-v4-flash' }, + groups: [{ + id: 'deepseek', + name: 'DeepSeek', + models: [{ + id: 'deepseek-v4-flash', + name: 'DeepSeek V4 Flash', + description: 'fast', + unlisted: true, + }], + }], + failures: [{ id: 'broken', name: 'Broken', message: 'offline' }], + }).groups[0]?.models[0]?.id).toBe('deepseek-v4-flash') + expect(sessionSelectModelRequestSchema.parse({ + sessionId: 's1', + provider: 'deepseek', + model: 'deepseek-v4-pro', + }).model).toBe('deepseek-v4-pro') + expect(sessionSelectModelValueSchema.parse({ + selected: { provider: 'deepseek', model: 'deepseek-v4-pro' }, + }).selected.model).toBe('deepseek-v4-pro') + expect(() => sessionSelectModelRequestSchema.parse({ + sessionId: 's1', + provider: '', + model: 'm', + })).toThrow() const prompt = sessionPromptRequestSchema.parse({ sessionId: 's1', mode: 'queue', content: [{ type: 'text', text: 'hi' }] }) expect(prompt.mode).toBe('queue') expect(() => sessionPromptRequestSchema.parse({ sessionId: 's1', mode: 'inject', content: [] })).toThrow() diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a2e7b31fe0..7031a77617 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -143,6 +143,9 @@ importers: '@deepseek-ai/dsh-client-ui-layout': specifier: workspace:^ version: link:../../packages/client/ui-layout + '@deepseek-ai/dsh-client-ui-model': + specifier: workspace:^ + version: link:../../packages/client/ui-model '@deepseek-ai/dsh-client-ui-models': specifier: workspace:^ version: link:../../packages/client/ui-models @@ -959,6 +962,27 @@ importers: specifier: ^18.2.0 version: 18.3.1 + packages/client/ui-model: + devDependencies: + '@deepseek-ai/dsh-client-connection': + specifier: workspace:^ + version: link:../connection + '@deepseek-ai/dsh-client-runtime': + specifier: workspace:^ + version: link:../runtime + '@deepseek-ai/dsh-client-ui-command': + specifier: workspace:^ + version: link:../ui-command + '@deepseek-ai/dsh-client-ui-slash': + specifier: workspace:^ + version: link:../ui-slash + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/client/ui-models: devDependencies: '@deepseek-ai/dsh-client-locale': diff --git a/tsconfig.base.json b/tsconfig.base.json index fcfe6f6e90..271a91567b 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -113,6 +113,7 @@ "@deepseek-ai/dsh-client-ui-conversation": ["./packages/client/ui-conversation/src"], "@deepseek-ai/dsh-client-ui-slash": ["./packages/client/ui-slash/src"], "@deepseek-ai/dsh-client-ui-command": ["./packages/client/ui-command/src"], + "@deepseek-ai/dsh-client-ui-model": ["./packages/client/ui-model/src"], "@deepseek-ai/dsh-client-ui-skill": ["./packages/client/ui-skill/src"], "@deepseek-ai/dsh-client-ui-subagent": ["./packages/client/ui-subagent/src"], "@deepseek-ai/dsh-client-ui-question": ["./packages/client/ui-question/src"], diff --git a/tsconfig.client.json b/tsconfig.client.json index 0da6e76918..caa3000a9f 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -39,6 +39,7 @@ { "path": "./packages/client/ui-command" }, { "path": "./packages/client/ui-skill" }, { "path": "./packages/client/ui-subagent" }, + { "path": "./packages/client/ui-model" }, { "path": "./packages/client/ui-question" }, { "path": "./packages/client/ui-trajectory" }, { "path": "./packages/client/ui-theme" }, From 190e49356f6ba61686d6721f5b3d09b303551ef6 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 27 Jul 2026 11:27:49 +0800 Subject: [PATCH 2/4] Revive the composer model selector on the named input.model seat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The original ModelSelector returns as the conversation.input.model seat occupant (the named single slot the composer bar renders before its primary button), alongside — not instead of — the /model popup. Both entries are projections of ONE per-session directory owned by the new ModelService (ctx.models): the same session.models load, the same selectModel submit, the same host-reported current — a switch in either entry is what the other shows next, asserted both ways in the plugin spec. Trigger chrome follows figma 313:14108's composer ToggleButton (13/20 medium secondary label, 4px gap, caption chevron, 28px chip height matching the sibling Plan/Read-only selects); the menu keeps the original upward provider-grouped single-select with keyboard navigation. The seat honors the owner locked bit as its disabled state. --- packages/client/ui-model/package.json | 15 +- .../src/client/ModelSelect.module.css | 199 +++++++++++++++ .../ui-model/src/client/ModelSelect.tsx | 231 ++++++++++++++++++ .../client/ui-model/src/client/directory.ts | 96 ++++++++ packages/client/ui-model/src/client/index.ts | Bin 3506 -> 5451 bytes .../client/ui-model/src/client/service.ts | 68 ++++++ packages/client/ui-model/src/client/slots.ts | 23 ++ .../ui-model/tests/browser-plugin.spec.ts | 149 +++++++++++ packages/client/ui-model/tsconfig.json | 9 + pnpm-lock.yaml | 18 ++ 10 files changed, 806 insertions(+), 2 deletions(-) create mode 100644 packages/client/ui-model/src/client/ModelSelect.module.css create mode 100644 packages/client/ui-model/src/client/ModelSelect.tsx create mode 100644 packages/client/ui-model/src/client/directory.ts create mode 100644 packages/client/ui-model/src/client/service.ts create mode 100644 packages/client/ui-model/src/client/slots.ts create mode 100644 packages/client/ui-model/tests/browser-plugin.spec.ts diff --git a/packages/client/ui-model/package.json b/packages/client/ui-model/package.json index 67eafd66cc..a4d412dffd 100644 --- a/packages/client/ui-model/package.json +++ b/packages/client/ui-model/package.json @@ -38,17 +38,28 @@ "@deepseek-ai/dsh-client-connection": "^0.0.1", "@deepseek-ai/dsh-client-runtime": "^0.0.1", "@deepseek-ai/dsh-client-ui-command": "^0.0.1", + "@deepseek-ai/dsh-client-ui-conversation": "^0.0.1", + "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", "@deepseek-ai/dsh-client-ui-slash": "^0.0.1", + "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "clsx": "^2.1.1", + "cordis": "^4.0.0-rc.7", + "react": "^18.2.0" }, "devDependencies": { "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-command": "workspace:^", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slash": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@types/react": "~18.3.1", + "clsx": "^2.1.1", + "cordis": "^4.0.0-rc.7", + "react": "^18.2.0" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-model/src/client/ModelSelect.module.css b/packages/client/ui-model/src/client/ModelSelect.module.css new file mode 100644 index 0000000000..f3b044496c --- /dev/null +++ b/packages/client/ui-model/src/client/ModelSelect.module.css @@ -0,0 +1,199 @@ +.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; + border-radius: 8px; + 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; +} + +.chevron { + flex: 0 0 auto; + color: var(--dsw-alias-label-caption); + transition: transform 120ms ease; +} + +.chevronOpen { + transform: rotate(180deg); +} + +.menu { + position: absolute; + right: 0; + bottom: calc(100% + 8px); + z-index: 20; + display: flex; + flex-direction: column; + width: min(320px, calc(100vw - 32px)); + max-height: min(360px, calc(100vh - 96px)); + overflow: hidden; + padding: 6px; + border: 1px solid var(--dsw-alias-border-l2-darkmode-thin); + border-radius: 14px; + background: var(--dsw-specific-input-major); + box-shadow: var(--dsw-shadow-lv3); + color: var(--dsw-alias-label-primary); +} + +.status, +.empty { + padding: 10px; + color: var(--dsw-alias-label-tertiary); + font-size: 13px; + line-height: 20px; +} + +.error, +.warning { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 8px; + margin-bottom: 4px; + padding: 7px 8px; + border-radius: 8px; + background: var(--dsw-alias-interactive-bg-hover-danger); + color: var(--dsw-alias-state-error-primary); + font-size: 12px; + line-height: 18px; +} + +.warning { + background: var(--dsw-alias-bg-module-platform); + color: var(--dsw-alias-state-warn-label); +} + +.retry { + flex: 0 0 auto; + padding: 0; + border: none; + background: transparent; + color: inherit; + font: inherit; + font-weight: 600; + cursor: pointer; +} + +.groups { + min-height: 0; + overflow-y: auto; +} + +.group + .group { + margin-top: 4px; +} + +.groupTitle { + position: sticky; + top: 0; + z-index: 1; + padding: 5px 8px 3px; + background: var(--dsw-specific-input-major); + color: var(--dsw-alias-label-tertiary); + font-size: 12px; + line-height: 18px; + font-weight: 500; +} + +.option { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + min-height: 38px; + padding: 6px 8px; + border: none; + border-radius: 10px; + outline: none; + background: transparent; + color: inherit; + text-align: left; + cursor: pointer; +} + +.option:hover:not(:disabled), +.option:focus-visible, +.selected { + background: var(--dsw-alias-interactive-bg-hover); +} + +.option:disabled { + color: var(--dsw-alias-label-dimmed); + cursor: default; +} + +.optionCopy { + display: flex; + flex: 1; + flex-direction: column; + min-width: 0; +} + +.modelName { + overflow: hidden; + color: inherit; + font-size: 14px; + line-height: 20px; + font-weight: 500; + text-overflow: ellipsis; + white-space: nowrap; +} + +.description, +.unlisted { + overflow: hidden; + color: var(--dsw-alias-label-tertiary); + font-size: 12px; + line-height: 18px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.unlisted { + color: var(--dsw-alias-state-warn-label); +} + +.check { + display: grid; + place-items: center; + flex: 0 0 18px; + color: var(--dsw-alias-state-business-primary); +} diff --git a/packages/client/ui-model/src/client/ModelSelect.tsx b/packages/client/ui-model/src/client/ModelSelect.tsx new file mode 100644 index 0000000000..86068777e4 --- /dev/null +++ b/packages/client/ui-model/src/client/ModelSelect.tsx @@ -0,0 +1,231 @@ +/** + * ModelSelect: the composer's named model seat (`conversation.input.model`). + * Compact trigger + upward provider-grouped single-select menu, revived from + * the original PR #600 ModelSelector form. Data and submission ride the SAME + * per-session ModelDirectory as the /model popup — one shared current, one + * catalog load path, one selectModel route: a switch in either entry is what + * the other shows next. + */ +import { + useEffect, useId, useMemo, useRef, useState, useSyncExternalStore, + type KeyboardEvent, type FocusEvent, +} from 'react' +import clsx from 'clsx' +import type { ModelTarget } from '@deepseek-ai/dsh-client-connection/client' +import { IconCheckOutline16, IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' +import type { ModelSelectInjected } from './slots.ts' +import css from './ModelSelect.module.css' + +type FocusPreference = 'current' | 'first' | 'last' + +/** + * Render the composer model seat. + * @param props - owner share (locked) + injected face (shared directory store/verbs). + * @returns the trigger and, while open, the upward menu. + */ +export function ModelSelect({ locked, directory, load, select }: ModelSelectInjected & { locked: boolean }) { + const state = useSyncExternalStore( + fn => directory.subscribe(fn), + () => directory.getSnapshot(), + ) + const [open, setOpen] = useState(false) + const rootRef = useRef(null) + const triggerRef = useRef(null) + const itemRefs = useRef<(HTMLButtonElement | null)[]>([]) + const pendingFocus = useRef(null) + const id = useId() + + const choices = useMemo(() => state.groups.flatMap(group => + group.models.map(model => ({ + group, + model, + target: { provider: group.id, model: model.id } satisfies ModelTarget, + }))), [state.groups]) + const selectedIndex = state.current === null + ? -1 + : choices.findIndex(c => c.target.provider === state.current?.provider && c.target.model === state.current.model) + const busy = state.status === 'selecting' + + // Mount-time load resolves the trigger label; every open refreshes. + useEffect(() => { load() }, [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]) + + useEffect(() => { + const preference = pendingFocus.current + if (!open || preference === null || choices.length === 0) return + const index = preference === 'first' + ? 0 + : preference === 'last' + ? choices.length - 1 + : selectedIndex >= 0 ? selectedIndex : 0 + itemRefs.current[index]?.focus() + pendingFocus.current = null + }, [choices.length, open, selectedIndex]) + + const show = (preference: FocusPreference | null = null): void => { + pendingFocus.current = preference + setOpen(true) + load() + } + + const close = (restoreFocus = false): void => { + setOpen(false) + pendingFocus.current = null + if (restoreFocus) queueMicrotask(() => { triggerRef.current?.focus() }) + } + + const moveFocus = (offset: number): void => { + if (choices.length === 0) return + const active = itemRefs.current.findIndex(item => item === document.activeElement) + const origin = active >= 0 ? active : selectedIndex >= 0 ? selectedIndex : 0 + const next = (origin + offset + choices.length) % choices.length + itemRefs.current[next]?.focus() + } + + const onRootKeyDown = (event: KeyboardEvent): void => { + if (event.key === 'Escape' && open) { + event.preventDefault() + close(true) + return + } + if (!open) return + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + event.preventDefault() + moveFocus(event.key === 'ArrowDown' ? 1 : -1) + return + } + if (event.key === 'Home' || event.key === 'End') { + event.preventDefault() + itemRefs.current[event.key === 'Home' ? 0 : choices.length - 1]?.focus() + } + } + + const onTriggerKeyDown = (event: KeyboardEvent): void => { + if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return + event.preventDefault() + if (!open) { + show(event.key === 'ArrowDown' ? 'first' : 'last') + return + } + pendingFocus.current = 'current' + const index = selectedIndex >= 0 ? selectedIndex : 0 + itemRefs.current[index]?.focus() + } + + const onBlur = (event: FocusEvent): void => { + if (event.relatedTarget instanceof Node && rootRef.current?.contains(event.relatedTarget)) return + close() + } + + const choose = (target: ModelTarget): void => { + if (state.current?.provider === target.provider && state.current.model === target.model) { + close(true) + return + } + void select(target).then((accepted) => { + if (accepted && rootRef.current !== null) close(true) + }) + } + + const label = choices[selectedIndex]?.model.name ?? state.current?.model ?? '选择模型' + + return ( +

+ + + {open && ( + + )} +
+ ) +} diff --git a/packages/client/ui-model/src/client/directory.ts b/packages/client/ui-model/src/client/directory.ts new file mode 100644 index 0000000000..e6221c84c9 --- /dev/null +++ b/packages/client/ui-model/src/client/directory.ts @@ -0,0 +1,96 @@ +/** + * 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, ModelTarget, SessionId, SessionModels, +} from '@deepseek-ai/dsh-client-connection/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 { + /** Target the host reports for the next assembled step; null before the first load. */ + current: ModelTarget | 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 { + readonly store: SnapshotStore = createSnapshotStore({ + current: 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. + */ + constructor( + private readonly sessions: Pick, + private readonly sessionId: SessionId, + ) {} + + /** + * Refresh the advisory directory (both entries call this on open). + * Failure preserves the last good groups and current target. + * @returns the fresh directory value. + */ + async load(): Promise { + 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, groups, failures } = result.value + this.store.set({ current, groups, failures, status: 'ready', error: null }) + return result.value + } + + /** + * Select the complete route (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 target - provider and provider-owned model id. + */ + async select(target: ModelTarget): Promise { + const generation = ++this.generation + this.store.update((s) => { s.status = 'selecting'; s.error = null }) + const { result } = await this.sessions.selectModel({ + sessionId: this.sessionId, provider: target.provider, model: target.model, + }) + 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}`) + } + this.store.update((s) => { s.current = result.value.selected; s.status = 'ready'; s.error = null }) + } + + /** Scope teardown: late settlements lose write access to the store. */ + dispose(): void { + this.disposed = true + } +} diff --git a/packages/client/ui-model/src/client/index.ts b/packages/client/ui-model/src/client/index.ts index e83a05c7a321d9e4eb736e66f6a308f6cf25c48a..f25b32d5f8dd3ec55f5bdbd31fac6a42e3557ba9 100644 GIT binary patch literal 5451 zcmbVQO^+i<5xx6Y1k!3 zh%*QYIB;Mu9FUNZxG)!f3R1(~_zS#<$V``aXLcJ#ni<;_`QrOUWX;abMs!9m>%x}g zZE07Xt1D{Ce&ebsE!+Cu+m^PbT+=^)_A7ez<7={2*E;K|zQdF2A3dkWw&&h@kCO~m zxE3&VyQliTvIQ;oihpCF;R%Itu@fEY~ihQ)GMHGQY(3wUHYTGri`(aPE=La-G8O}=$a z;uZ$(8%qybJ+CATQ@YVYljwRn(7LU6H2%1-w(-{9o*OqS{PuiRI+%Qp(x|kiXZp+d zE7+GJcZouS^9lW`_*Ue$uR6E0?}M#(JCsN$d4_M1F~;W>yKbbSzs>mG!G6I>T4pnP zg`k|*Rk@#2)0d@|g@_)zfNsjVdubZlSriXtoasShNMMMTjrgL81+4^Jfx9Q=Wx6p_L} z{LOeYvTvmD!hz(+;LNEDL4IgfI`qxXpKAJM=ii~_3C@pZXJ>Scp7T<4W(nQ^pQDLg zF$aNB-I&jM`HgPvo-#CjUEU#-VC=H4Z~JBnPGFXAOzUj{u10UJ`$~rdu&#Sr+VssdZ=`Tvtb+!_2NKr=_ceTz5 zcE&#g6$)R;H!`iI(s`~2Pz%V{yQM2#whdr@^H^oXF2tzNhaV0y5JffMEGM53vw{5|VG@DE-JP9FB)|!C zdN`!09ws7uz`y)=kn>ofCs_|aro0cbDgC(!0_cqJ3FaIYQwq6@KS~HFW^JKyfBfYi zzI^-k@sEG`@~>a~=kLEgLhLX-&Bh{xVlEpR`jPdA&f{_YiE;rpiN>vNksLgyrCHq) z+Rt^EI=YsCDs~2&1`lzwF}&7D!}79a5xz4Gxz`z_OOWYwC1RCyZ_%>L-j!Gec$D+{ z;tCHgF<2mlp)jd{^D#)s?D%(#lylt}bQb`lrH&Kbkz>y#N{J1P=yDxt*$*lz1uqi% zC-$=*n*kPQ9qb;BBn$mu%Wv%lgwyV)A;G3YMmz>O42nJQ;yG~U;NJd0~fDnZjU=VelVVFj6t@C<9_b6)uAyPBe*D>?!7_Q!7eErxD!>_Au~*c$lk zBB;^*p^NW_F$nj-Bq3wSo`~nT2xTda6EdDI-??Z@IVyyzObGf_ zo-3qXudx(nndlDs@|edoS(#<$G?vrjI4lP<3C}`YI3yw_o!f13*@7YIewq%OJB(T0 z$>yZGdAdgA-eI(ek=$3fAz`eK2bbf7LZC>#OQ4mE-&ZRXkle#LkjH(-+#B3e)%844 z<%_z_;?*|%e}x^7nLC@Lu?7v5x)xPr3OtRv23cckk$uTs(u3GPO?7fOLWCKE%|nLG za5Kw2-Y{4$h?_w8*#wj=!QfqIfW>?lk%s%mag$QB`5u3d9r2!?^CdMKOY-9S@DtF? z3X`j>H!=uN@|cA3PJu@jPZkWzka-?tMjk>;Sav+LNa<3wYHm`naxcqzF!*@r5KAL` zz}`V)-1ppD0E&V#*^Dshz(U8TTu%P}FRBTO#DcMSSyvm(NeT8Zei2RX8p< zQ^HrUpaRNCRC-}orLY0k0>(f;$-A2Kr3o(|p7HN$(y%S!NI;MqTth70jKnt{f%oO@ z&bc>hFpk?m|JQhir-V@W5SS@ltMBQoqw-Ir3}q3~{a)G_8Z>-CmD*Uj5=?OH!>|=- z>!nL>;>Z8F&+tkEba|unfId$R6866%-Ow@X^W#TtxLAd(o@+mn*TX7S}0HNq`=MKj7!YWFsyct+q0MOdCC;O3*&HK6~M{QL>rO;`h*0m|U9WYw# dzt~IDILrC{z-eQ}3J#qug+8pv*ky;&e*v;VIY0ma literal 3506 zcmbtXPm3c(6!)A@@fL>dWK25uNOWOQ+{3cs9@l|Xx+_UJm8!O?Ix`^~@S=!EQ3Uni z!M%781i_1Y@uMt^?#-{@d#|cHo!MDNFfcp4>G%Kr-tWB`pPu&M6du}AYVcCaA}DL1 z()CiAAq?d&%8l~YZJ|U1MWCntESb*Pw4V)z zoitD{NVXML_`F;}D;{&@yl2p-7+2N@@J3XAWrGJ5Km-oT$jgx8Syy2syqD{_-oo70 zrW9_A9xVuPwK2*pA=J*mxs(;z@Q}nY{2<&?hCxoYK4@~&wh$T$5rno&SO}$SCq4Mu zEkq$v8IsOdlBSd;850X^3Wr*=C~(32TX3&8w7EOrwVmJ4Hm7oUFx+TN3tJGgI+iNJavW6*L4k0Tzq{2unOaCQyZy^^XX<@rdcap_m1LMv&)2r-rP$MKu& zx&xL&HvwC9t2t~`P;2=*TXwz1C?89=QH3O%P85Mcrr~(Rh`rTCtuSb|(|YNRPfsDK zs}kp=l*I(Bk+9GRv{*=u^98;VI1>%}Q!{fo9rqTsiSro;=Rw(Tq$bz{hu|2-5o`WC zXdYnK1Ds#R=ImtGsO{f4+411)XT5!oLwpaT3DPi}4s)hVuqj4{U!NfcTFj9*2tkn( zN*NHrn4GTHh=m(s_Kz0**6idM2pDa*%1_QXYV1B1g}lDR zL>`A>z_yNLIdMQ)mPiEB$YHh6V_wyM)u-CUk1DZc3<0ctR8wWDd9KRAu$AP>Oky{3 zBi8cpDi*1ER~u?N(!+E*h2UzO!}jwIV6UPZGO>q0e}}(*`1$IKZ?8Uk_V@R{Ts{A0 z))8+^VmAt>B2w?5Rir7oPzHV6WH2Eh?`Z93FiBN84jm^!ZRDg=-J*65XEbjR#c4E^ zfxCBa5RG-GbomME*3@P{;B5BzFa0FEQX?94eu!L#6AYUs;;tYzX++@KAQf!$OpKpSdS( zOxy|O_OK|~f7+8rA-0RrSsS2@kXOUKN;t8GB3!}*TO5;pzEb7zrrSYUzs{Q*qh#<*BCZ|;jUa0I;xWDJrlLZpLHd=trniySdU zL=aXAdut8$)eAZ=lB(DsGtnZQK=ewp7OmE5lGp(NH!RM0*12kuJ~Y?L1Z%T31@u!H zas8{M5t>suP(@R0+~&2Wr8EU`A@Hhbs-Fm_-}d7HI!-V1^!q^x`_?{|E`HPk`gfw; z+@23GWgHPO@&ewaZGHVF;;TZU@snLr;-#L`3fjN&?Ha)$mU2Ifvq&CeDl~?eM5I5W z@nmcxbS)3U +} + +/** The `ctx.models` session model-selection service. */ +export class ModelService extends Service { + static inject = ['connection', 'sessions'] + + private readonly live: LiveState = { directories: new Map() } + + /** + * @param ctx - owning root context (the service registers itself as `models`). + */ + constructor(ctx: Context) { + super(ctx, 'models') + } + + /** + * 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 SessionsService + const actx = sessions.scope(sessionId) + if (actx === undefined) throw new Error(`ui-model: session "${String(sessionId)}" resolved no scope`) + const connection = this.ctx.get('connection') as ConnectionHandle + const directory = new ModelDirectory(connection.api.sessions, sessionId) + live.directories.set(sessionId, directory) + actx.effect(() => () => { + directory.dispose() + live.directories.delete(sessionId) + }, 'ui-model: session directory') + return directory + } +} diff --git a/packages/client/ui-model/src/client/slots.ts b/packages/client/ui-model/src/client/slots.ts new file mode 100644 index 0000000000..fdbd1a744e --- /dev/null +++ b/packages/client/ui-model/src/client/slots.ts @@ -0,0 +1,23 @@ +/** + * 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 { ModelTarget } from '@deepseek-ai/dsh-client-connection/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 { + /** The session's shared directory store (same instance the /model popup reads). */ + directory: SnapshotStore + /** Refresh the advisory directory (fire-and-forget; errors land on the store). */ + load(): void + /** + * Select a complete provider/model target through the shared route. + * @param target - target picked from one provider group. + * @returns whether the host accepted the selection. + */ + select(target: ModelTarget): Promise +} diff --git a/packages/client/ui-model/tests/browser-plugin.spec.ts b/packages/client/ui-model/tests/browser-plugin.spec.ts new file mode 100644 index 0000000000..d04e001bd2 --- /dev/null +++ b/packages/client/ui-model/tests/browser-plugin.spec.ts @@ -0,0 +1,149 @@ +/** + * ui-model browser half on a real cordis Context with fake command/slots/ + * connection faces and real session scopes: the plugin mounts ModelService + * as `models`, the /model contribution and the conversation.input.model + * seat both register, and BOTH entries resolve the SAME per-session + * directory through the service — a selection submitted through the seat's + * inject face is the current the popup's next options pass marks active + * (and the reverse), the one-shared-state contract of the dual entry. + * Scope disposal drops the directory (HMR safety). + */ +import { Context } from 'cordis' +import { describe, expect, it } from 'vitest' +import { createScope } from '@deepseek-ai/dsh-client-runtime/client' +import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import type { ModelTarget } from '@deepseek-ai/dsh-client-connection/client' +import type { CommandContribution, SelectOption } from '@deepseek-ai/dsh-client-ui-command/client' +import type { ModelSelectInjected } from '../src/client/slots.ts' +import { apply, inject } from '../src/client/index.ts' + +const sid = (k: string): SessionId => k as SessionId + +const GROUPS = [{ + id: 'deepseek', + name: 'DeepSeek', + models: [ + { id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash' }, + { id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro' }, + ], +}] + +/** Boot the plugin over fake faces + a stateful fake host (current moves on selectModel). */ +async function bench() { + const ctx = new Context() + let current: ModelTarget = { provider: 'deepseek', model: 'deepseek-v4-flash' } + const calls = { models: 0, select: 0 } + ctx.provide('connection', { api: { sessions: { + models: () => { + calls.models += 1 + return Promise.resolve({ result: { ok: true as const, value: { current, groups: GROUPS, failures: [] } } }) + }, + selectModel: (payload: { provider: string; model: string }) => { + calls.select += 1 + current = { provider: payload.provider, model: payload.model } + return Promise.resolve({ result: { ok: true as const, value: { selected: current } } }) + }, + } } }) + let contribution: CommandContribution | undefined + ctx.provide('command', { + register(c: CommandContribution) { + contribution = c + return () => { contribution = undefined } + }, + }) + const seats = new Map ModelSelectInjected) | undefined }>() + ctx.provide('slots', { + register(options: { name: string; inject?: (sessionId: SessionId) => ModelSelectInjected }) { + seats.set(options.name, { inject: options.inject }) + return () => { seats.delete(options.name) } + }, + }) + ctx.provide('conversation', {}) + const scopes = new Map() + ctx.provide('sessions', { scope: (id: SessionId) => scopes.get(id) }) + const fiber = ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + await ctx.plugin(function probe() {}).await() + const mint = (key: string) => { + const handle = createScope(ctx, sid(key)) + scopes.set(sid(key), handle.ctx) + return handle + } + return { + ctx, fiber, mint, calls, + contribution: () => contribution!, + seat: () => seats.get('conversation.input.model')!, + hostCurrent: () => current, + } +} + +const projection = (id: string) => ({ sessionId: sid(id) }) + +describe('ui-model dual entry', () => { + it('registers the /model contribution and the composer model seat', async () => { + const b = await bench() + expect(b.contribution().name).toBe('model') + expect(b.contribution().ui.kind).toBe('popupSelect') + expect(b.seat().inject).toBeTypeOf('function') + }) + + it('popup options mark the host current active with the provider group in the detail', async () => { + const b = await bench() + b.mint('s1') + const options = await b.contribution().ui.options(projection('s1'), new AbortController().signal) + expect(options.map((o: SelectOption) => o.label)).toEqual(['DeepSeek-V4-Flash', 'DeepSeek-V4-Pro']) + expect(options[0]).toMatchObject({ active: true, detail: 'DeepSeek' }) + expect(options[1]?.active).toBeUndefined() + }) + + it('a seat selection is the current the popup marks active next — one shared state', async () => { + const b = await bench() + b.mint('s1') + const seatFace = b.seat().inject!(sid('s1')) + // Switch through the SEAT entry. + expect(await seatFace.select({ provider: 'deepseek', model: 'deepseek-v4-pro' })).toBe(true) + expect(b.hostCurrent()).toEqual({ provider: 'deepseek', model: 'deepseek-v4-pro' }) + expect(seatFace.directory.getSnapshot().current).toEqual({ provider: 'deepseek', model: 'deepseek-v4-pro' }) + // The POPUP's next options pass reflects it without a seat-side reload. + const options = await b.contribution().ui.options(projection('s1'), new AbortController().signal) + expect(options.find((o: SelectOption) => o.label === 'DeepSeek-V4-Pro')).toMatchObject({ active: true }) + }) + + it('a popup selection lands on the seat store — the reverse direction of the same state', async () => { + const b = await bench() + b.mint('s1') + const seatFace = b.seat().inject!(sid('s1')) + const options = await b.contribution().ui.options(projection('s1'), new AbortController().signal) + const pro = options.find((o: SelectOption) => o.label === 'DeepSeek-V4-Pro')! + await b.contribution().ui.onSelect(pro, projection('s1')) + expect(seatFace.directory.getSnapshot().current).toEqual({ provider: 'deepseek', model: 'deepseek-v4-pro' }) + }) + + it('both entries share one directory instance per session, isolated across sessions', async () => { + const b = await bench() + b.mint('a') + b.mint('b') + const faceA = b.seat().inject!(sid('a')) + const faceA2 = b.seat().inject!(sid('a')) + const faceB = b.seat().inject!(sid('b')) + expect(faceA.directory).toBe(faceA2.directory) + expect(faceA.directory).not.toBe(faceB.directory) + // The service face resolves the same instance the seat inject handed out. + expect(b.ctx.models.directoryFor(sid('a')).store).toBe(faceA.directory) + }) + + it('scope disposal drops the directory; a reborn scope gets a fresh one', async () => { + const b = await bench() + const first = b.mint('s1') + const face1 = b.seat().inject!(sid('s1')) + await first.fiber.dispose() + b.mint('s1') + const face2 = b.seat().inject!(sid('s1')) + expect(face2.directory).not.toBe(face1.directory) + }) + + it('an unknown session fails loud at the seat inject', async () => { + const b = await bench() + expect(() => b.seat().inject!(sid('ghost'))).toThrow(/resolved no scope/) + }) +}) diff --git a/packages/client/ui-model/tsconfig.json b/packages/client/ui-model/tsconfig.json index 5cefc3e6de..5e6c9a35ac 100644 --- a/packages/client/ui-model/tsconfig.json +++ b/packages/client/ui-model/tsconfig.json @@ -20,9 +20,18 @@ { "path": "../ui-command" }, + { + "path": "../ui-conversation" + }, + { + "path": "../ui-primitives" + }, { "path": "../ui-slash" }, + { + "path": "../ui-slots" + }, { "path": "../../support/invariants" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7031a77617..0757393b6c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -973,15 +973,33 @@ importers: '@deepseek-ai/dsh-client-ui-command': specifier: workspace:^ version: link:../ui-command + '@deepseek-ai/dsh-client-ui-conversation': + specifier: workspace:^ + version: link:../ui-conversation + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives '@deepseek-ai/dsh-client-ui-slash': specifier: workspace:^ version: link:../ui-slash + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + clsx: + specifier: ^2.1.1 + version: 2.1.1 cordis: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + react: + specifier: ^18.2.0 + version: 18.3.1 packages/client/ui-models: devDependencies: From ae0b0cc49b6e54902d76da2707e9619c9e6bcc02 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 27 Jul 2026 11:46:39 +0800 Subject: [PATCH 3/4] Fix lint findings and calibrate the seat trigger to the figma mock - unused SessionId import and an unnecessary assertion in ui-model - two over-length fake-api lines split - trigger chrome calibrated against figma 313:14108's ToggleButton (13/20 medium secondary at 28px; chevron in label-caption = the mock's #ADB2B8; the mock's leading icon is invisible there, so the seat has none, and its 'High' effort text has no wire concept to render) --- packages/client/connection/tests/fake-api.ts | 3 ++- packages/client/runtime/tests/fake-api.ts | 3 ++- packages/client/ui-model/src/client/index.ts | 4 ++-- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/client/connection/tests/fake-api.ts b/packages/client/connection/tests/fake-api.ts index 580224060b..c46f8161cf 100644 --- a/packages/client/connection/tests/fake-api.ts +++ b/packages/client/connection/tests/fake-api.ts @@ -104,7 +104,8 @@ export class FakeApiClient implements IApiClient { // Payloads stay `unknown` (lint-lane note above); response rows are the real // wire shapes so cases can program catalogs and skill lists without casts. onCommandList: (payload: unknown) => Promise> = () => Promise.resolve(ok({ commands: [] })) - onCommandExecute: (payload: unknown) => Promise> = () => Promise.resolve(ok({ matched: false })) + onCommandExecute: (payload: unknown) => Promise> = + () => Promise.resolve(ok({ matched: false })) onSkillList: (payload: unknown) => Promise> = () => Promise.resolve(ok({ skills: [] })) readonly commands: IApiClient['commands'] = { diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts index 6e0f0df2ef..61a1f34983 100644 --- a/packages/client/runtime/tests/fake-api.ts +++ b/packages/client/runtime/tests/fake-api.ts @@ -127,7 +127,8 @@ export class FakeApiClient implements IApiClient { // wire shapes so cases can program requires-bearing catalogs and dual-address // skill lists without casts. onCommandList: (payload: unknown) => Promise> = () => Promise.resolve(ok({ commands: [] })) - onCommandExecute: (payload: unknown) => Promise> = () => Promise.resolve(ok({ matched: false })) + onCommandExecute: (payload: unknown) => Promise> = + () => Promise.resolve(ok({ matched: false })) onSkillList: (payload: unknown) => Promise> = () => Promise.resolve(ok({ skills: [] })) readonly commands: IApiClient['commands'] = { diff --git a/packages/client/ui-model/src/client/index.ts b/packages/client/ui-model/src/client/index.ts index f25b32d5f8..83bcf9ba31 100644 --- a/packages/client/ui-model/src/client/index.ts +++ b/packages/client/ui-model/src/client/index.ts @@ -10,7 +10,7 @@ * inline error) without forking the state. */ import type { ModelTarget, SessionModels } from '@deepseek-ai/dsh-client-connection/client' -import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' import type { CommandServiceContract, SelectOption } from '@deepseek-ai/dsh-client-ui-command/client' // Type-only: pulls the ui-conversation SlotMap merge (the input.model seat). import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' @@ -109,7 +109,7 @@ export function apply(ctx: ClientContext): void { scope.effect(() => scope.slots.register({ name: 'conversation.input.model', inject: (sessionId): ModelSelectInjected => { - const directory = models.directoryFor(sessionId as SessionId) + const directory = models.directoryFor(sessionId) return { directory: directory.store, load: () => { directory.load().catch(() => { /* surfaced on the store */ }) }, From b4476d24fe80d51087c0f9536e06b97e4cf52b48 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 27 Jul 2026 12:11:54 +0800 Subject: [PATCH 4/4] Settle the local gates for the model-selector round - ui-model README pair (short-form Model Experience + audited registry entry + Known Limitations), recorded in the pairing manifest - llm-deepseek README pair rebased onto master's version with this PR's real deltas (friendly names, 256k, selector consumers) on both sides; runtime/ui-conversation READMEs revert to master (their sections documented the removed Session state machine and control slot) - config catalog regenerated for the ui-model roster row - ModelDirectory.store JSDoc; ui-model files join the TODO(gui) coverage deferral lane beside their ui-command/ui-slash siblings --- ...-model-catalog-and-acp-selection.i18n.yaml | 4 ++-- ...7-23-client-plugin-loading-model.i18n.yaml | 4 ++-- docs/config-catalog.md | 3 ++- packages/client/runtime/README.md | 8 ++----- packages/client/ui-conversation/README.md | 2 -- packages/client/ui-model/README.i18n.yaml | 6 ++++++ packages/client/ui-model/README.md | 21 +++++++++++++++++++ packages/client/ui-model/README.zh.md | 21 +++++++++++++++++++ .../client/ui-model/src/client/directory.ts | 1 + packages/llm/llm-deepseek/README.i18n.yaml | 4 ++-- packages/llm/llm-deepseek/README.md | 4 +++- packages/llm/llm-deepseek/README.zh.md | 4 ++-- .../verify-package-readme-model-experience.ts | 1 + vitest.config.ts | 5 +++++ 14 files changed, 70 insertions(+), 18 deletions(-) create mode 100644 packages/client/ui-model/README.i18n.yaml create mode 100644 packages/client/ui-model/README.md create mode 100644 packages/client/ui-model/README.zh.md diff --git a/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.i18n.yaml index 4fd4acc97e..89204b0a67 100644 --- a/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-15-llm-model-catalog-and-acp-selection.md: e97eb9abec35cca5608f3a10d6b2363a2d936510 -2026-07-15-llm-model-catalog-and-acp-selection.zh.md: d3c410d6cb4585f2bd67b376bbd00ed25565c48c +2026-07-15-llm-model-catalog-and-acp-selection.md: 9edc723b0dfafeaf395eb9325373835138ddbc41 +2026-07-15-llm-model-catalog-and-acp-selection.zh.md: 677d9e2a200d488fa9fc27fc2a922dc8f1f871d1 diff --git a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml index 1604ee7896..6c1dd7368d 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-23-client-plugin-loading-model.md: 4894670d16c083093f970b0058fd88f79c150673 -2026-07-23-client-plugin-loading-model.zh.md: 94f4e8891bcc065873b932c4a7e07741d6521b50 +2026-07-23-client-plugin-loading-model.md: 4028f50bf5cf8a05063df3bf5e2b4b7f45a3c02e +2026-07-23-client-plugin-loading-model.zh.md: 7dc96b47c950af07adb71ba7fe131d53e5c0e51d diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 6a31c353fd..37112c12d2 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -487,7 +487,7 @@ Source: [`packages/hooks/hooks-codex/src/index.ts:42`](../packages/hooks/hooks-c ## `@deepseek-ai/dsh-host-apiproxy` -Requires: `agents` · `sessions` · `tools` · `userInteraction` · `workspace` +Requires: `agents` · `llm` · `sessions` · `tools` · `userInteraction` · `workspace` ```ts config-catalog /** Gateway plugin config: host-level agent routing and Workspace creation root. */ @@ -2050,6 +2050,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-client-ui-command` ([`packages/client/ui-command/src/index.ts`](../packages/client/ui-command/src/index.ts)) - `@deepseek-ai/dsh-client-ui-conversation` ([`packages/client/ui-conversation/src/index.ts`](../packages/client/ui-conversation/src/index.ts)) - `@deepseek-ai/dsh-client-ui-layout` ([`packages/client/ui-layout/src/index.ts`](../packages/client/ui-layout/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-model` ([`packages/client/ui-model/src/index.ts`](../packages/client/ui-model/src/index.ts)) - `@deepseek-ai/dsh-client-ui-models` ([`packages/client/ui-models/src/index.ts`](../packages/client/ui-models/src/index.ts)) - `@deepseek-ai/dsh-client-ui-question` — requires `tools` · `userInteraction` ([`packages/client/ui-question/src/index.ts`](../packages/client/ui-question/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings` ([`packages/client/ui-settings/src/index.ts`](../packages/client/ui-settings/src/index.ts)) diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index 91c5487f9d..4724ebc75d 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -22,17 +22,13 @@ SlotsService gives the renderer separate bare observables for `useSessions` and `SessionManager` retains the latest validated `session/title` control snapshot independently of list and session-instance arrival. Newer event seqs replace older snapshots, title timestamps contribute to list recency, and a subscription baseline discards any retained title beyond its `lastSeq` before the optional folded title arrives. Explicit session removal also clears the retained title. The client-facing `SessionSummary.title` is therefore only the actual durable title; `displayTitle` is always present and falls back through the cwd basename and session id. A cold persisted session keeps that fallback until opening or resuming it causes the host to fold and project its log-backed title. -## Session model selection - -Each resident `Session` owns a `modelSelection` snapshot containing the current provider/model target, provider-grouped directory, provider-local failures, and the `idle`/`loading`/`ready`/`selecting`/`error` state. History establishes or refreshes the current target, opening a selector refreshes the directory, and selection failures preserve the last target and usable groups. Directory and selection operations share a monotonically increasing generation so an older response cannot overwrite a newer selection. A reconnect rebuild restores the target reported by the Host without replacing unchanged selection substructure. - ## Model Experience -None, as the session object layer selects the provider/model route used by a later Host request but adds no model-visible content. +None, as the client runtime hosts browser-side services and the session object layer; nothing here reaches a model request. #### KV Cache effect -Changing the target can change or invalidate provider-side cache reuse; this package does not alter the prompt prefix itself. +None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 1e0075fc77..b9ec555f15 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -14,8 +14,6 @@ Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.to Per-session UI state (selection, ordinary composer draft, active view) lives in the declared chat store (`stores.ts` `createChatStore`): apply constructs one handle and passes it to the conversation, chat-view, and details registrations, so the session slots share one instance per session (selection written by the chat view, read by details) and the framework owns instance lifecycle and draft persistence. The frontend Session Intent comes from the Session list projection; after publication, any retained prompt comes from that Session's conversation snapshot. Components are pure — the framework standard kit (`useSession`/`sessionId` when session-scoped, plus global `useSessions`/`useWorkspaces`) and the store faces (`useStore`/`actions`) arrive automatically from the registration declaration; inject factories contribute plain data and callbacks for runtime Session actions, send/stop, tabs, details, and paging. -The resident composer declares the session-scoped single slot `'conversation.composer.control'` and renders its occupant immediately before the send/stop button. Feature packages own the control and its state; ui-conversation supplies only the placement and standard slot shares. The new-session empty-state composer deliberately has no corresponding control slot. - `src/client/` is organized for the future package split: `contract/` is the sole inter-domain shared face (`slots.ts` slot declarations + composed slot props including the tool-row contract, `views.ts` shared primitives, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` (sample registrants) domain directories import contract files and never each other; `apply.ts` is the only assembly point allowed to import all three domains. The `/client` export surface is the contract only — `apply`/`inject`, the two service classes, and the `contract/` type families; implementation components (skeleton, chat rows) and the store factory stay internal and reach the page exclusively through apply's slot registrations (tests take them via the `./src/*` subpath). ## Model Experience diff --git a/packages/client/ui-model/README.i18n.yaml b/packages/client/ui-model/README.i18n.yaml new file mode 100644 index 0000000000..ded16f0dac --- /dev/null +++ b/packages/client/ui-model/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: fa972e01579a5608b968e13010bd5224f38bdfbc +README.zh.md: b1a4980dc74e4cb0a4d6607dabae58d37bb18792 diff --git a/packages/client/ui-model/README.md b/packages/client/ui-model/README.md new file mode 100644 index 0000000000..fa972e0157 --- /dev/null +++ b/packages/client/ui-model/README.md @@ -0,0 +1,21 @@ +# @deepseek-ai/dsh-client-ui-model + +English | [中文](README.zh.md) + +Model selection plugin, browser half: TWO entries over ONE per-session directory owned by `ModelService` (`ctx.models`). The `/model` popupSelect contribution (registered through `ctx.command`) and the composer's named `conversation.input.model` seat (a compact trigger + upward provider-grouped menu, figma 313:14108's ToggleButton chrome) both load the session's advisory directory through `session.models` and submit through `session.selectModel` via the same `ModelDirectory` instance — the host-reported current target is the single fact both surfaces echo, so a switch made in either entry is what the other shows next. Directory loads and selections share a generation counter (an older response never overwrites a newer one); provider-local catalog failures list inline while usable groups stay selectable; whole-request and selection failures surface on each entry's own retry face (the popup shell's error/retry, the seat menu's inline error) without forking the state. Directories are per-session, resolved lazily through `ctx.models.directoryFor(sessionId)`, and disposed with the session scope. + +The `/client` export surface is the plugin body (`apply`/`inject`), `ModelService`, `ModelDirectory` with its state shape, and the seat's injected face type. + +## Model Experience + +Indirectly, through the `session.selectModel` RPC both entries submit: the host snapshots the selected provider/model pair at the next prompt-assembly boundary, so the following request routes (and stamps its prompt variables) with the chosen target while a running step keeps its assembled one — the directory, both menus, and every selection interaction stay client-side and never enter the session log. + +#### KV Cache effect + +Switching the route can reduce or invalidate provider-side cache reuse for subsequent requests; the prompt prefix itself is untouched. + +## Known Limitations and Deferred Work + +- **No create-time selection** — both entries address an existing session's agent; there is no draft-phase model choice to fold into session creation (the seed order at the host's `targetFor` documents where such a tier would go). +- **Directory names are presentation-only** — selection and persistence use provider/model ids; a provider whose catalog lookup fails lists as an unselectable failure row until reload. +- **The seat shows no effort level** — the figma mock's `High` text has no wire concept behind it yet; the trigger renders the model name alone. diff --git a/packages/client/ui-model/README.zh.md b/packages/client/ui-model/README.zh.md new file mode 100644 index 0000000000..b1a4980dc7 --- /dev/null +++ b/packages/client/ui-model/README.zh.md @@ -0,0 +1,21 @@ +# @deepseek-ai/dsh-client-ui-model + +[English](README.md) | 中文 + +模型选择插件(浏览器半侧):**两个入口共用一份 per-session 目录**,由 `ModelService`(`ctx.models`)持有。`/model` popupSelect contribution(经 `ctx.command` 注册)与 composer 的具名 `conversation.input.model` 坑位(紧凑触发器 + 向上展开的按提供方分组菜单,视觉取 figma 313:14108 的 ToggleButton)都通过同一个 `ModelDirectory` 实例经 `session.models` 加载会话的建议目录、经 `session.selectModel` 提交——host 报告的 current target 是两个界面共同回显的唯一事实,在任一入口切换,另一入口下次打开显示的就是新值。目录加载与选择共享一个代次计数器(旧响应永不覆盖新结果);提供方级目录失败内联列出,可用分组保持可选;整体失败与选择失败落各入口自己的重试面(popup 壳的 error/retry、坑位菜单的内联错误),状态不分叉。目录按会话惰性解析(`ctx.models.directoryFor(sessionId)`),随会话 scope 一并释放。 + +`/client` 导出面为插件本体(`apply`/`inject`)、`ModelService`、`ModelDirectory` 及其状态形状、坑位注入面类型。 + +## Model Experience + +间接影响,经两个入口共同提交的 `session.selectModel` RPC:host 在下一次提示词组装边界快照所选提供方/模型对,因此后续请求按所选目标路由(并盖入提示词变量),运行中的步骤保持其已组装目标——目录、两个菜单及全部选择交互都留在 client 侧,永不进入 session log。 + +#### KV Cache effect + +切换路由可能降低或作废提供方侧后续请求的缓存复用;提示词前缀本身不受影响。 + +## Known Limitations and Deferred Work + +- **无创建期选择**——两个入口都寻址既有会话的 agent;没有 Draft 期模型选择折入会话创建的通道(host `targetFor` 处的种子序注释记录了该层未来的落点)。 +- **目录名仅供呈现**——选择与持久化使用提供方/模型 id;目录查询失败的提供方以不可选失败行列出,重新加载前保持原样。 +- **坑位不显示 effort 档位**——figma 设计稿中的 `High` 文本尚无对应 wire 概念;触发器只渲染模型名。 diff --git a/packages/client/ui-model/src/client/directory.ts b/packages/client/ui-model/src/client/directory.ts index e6221c84c9..8252790b53 100644 --- a/packages/client/ui-model/src/client/directory.ts +++ b/packages/client/ui-model/src/client/directory.ts @@ -27,6 +27,7 @@ export interface ModelDirectoryState { /** 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 = createSnapshotStore({ current: null, groups: [], failures: [], status: 'idle', error: null, }) diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index f5b4d04574..51f198e985 100644 --- a/packages/llm/llm-deepseek/README.i18n.yaml +++ b/packages/llm/llm-deepseek/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -README.md: e191f3fcd265a6ca9cec3a8dae5f730ce27accf1 -README.zh.md: 268096e5f1a145e8d5cf6469524d36fe48984617 +README.md: f7d44be3ee9e0d44cf338dcc0dc7210ef16309dd +README.zh.md: 557aca834d9842749b5b3dd97f98f21c8046a51c diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index d33c792b21..f7d44be3ee 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-llm-deepseek +English | [中文](README.zh.md) + DeepSeek chat-completions adapter for the harness LLM seam: hand-rolled `fetch` + SSE translation from the official wire format (source of truth: the API docs — guides/thinking_mode, guides/tool_calls, api/create-chat-completion) into the `StreamChunk` protocol. A second, library-backed implementation of the same seam exists in `@deepseek-ai/dsh-llm-pi-ai`. This package always owns the `deepseek` provider route; mounting a pi-ai profile with `provider: deepseek` in the same context throws `LlmError('DUPLICATE_ADAPTER')` by design. @@ -49,7 +51,7 @@ Every request carries the shared attribution header from dsh-llm's `attributionH ## Errors -Non-2xx responses throw `LlmError` with stable codes: `AUTH` (401/403), `QUOTA` (a response whose provider details identify exhausted quota, balance, or credits), `RATE_LIMIT` (other 429s), `CONTEXT_WINDOW_EXCEEDED` (a 400 whose provider code, type, or message identifies context overflow), `INVALID_REQUEST` (other 400s), `SERVER` (5xx), `HTTP_` otherwise. Its serializable `failure` retains the HTTP status plus a valid positive `Retry-After` seconds/date delay and `x-request-id` / `x-deepseek-request-id` when present. A pre-response transport failure (DNS, refused connection, TLS, proxy) throws `TRANSPORT` naming the configured endpoint and chaining the original rejection as `cause`; caller aborts throw `ABORTED`, and the loop's cancellation signal remains authoritative. Protocol violations throw `STREAM_CLOSED` (no `[DONE]`) or `MALFORMED_RESPONSE` (bad JSON payload). Unknown wire `finish_reason`s (e.g. `content_filter`, `insufficient_system_resource`) become `finish {kind: 'error', failure}` chunks. +Non-2xx responses throw `LlmError` with stable codes: `AUTH` (401/403), `QUOTA` (a response whose provider details identify exhausted quota, balance, or credits), `RATE_LIMIT` (other 429s), `CONTEXT_WINDOW_EXCEEDED` (a 400 whose provider code, type, or message identifies context overflow), `INVALID_REQUEST` (other 400s), `SERVER` (5xx), `HTTP_` otherwise. Its serializable `failure` retains the HTTP status plus a valid positive `Retry-After` seconds/date delay and `x-request-id` / `x-deepseek-request-id` when present. A pre-response transport failure (DNS, refused connection, TLS, proxy) throws `TRANSPORT` naming the configured endpoint and chaining the original rejection as `cause`; caller aborts throw `ABORTED`, and the loop's cancellation signal remains authoritative. Protocol violations throw `STREAM_CLOSED` (no `[DONE]`) or `MALFORMED_RESPONSE` (bad JSON payload). Unknown wire `finish_reason`s (e.g. `content_filter`, `insufficient_system_resource`) become `finish {kind: 'error', failure}` chunks, and a completed stream whose `stop` (or absent) finish opened no content blocks becomes a `finish {kind: 'error'}` with code `EMPTY_RESPONSE` (retried by default policy). ## Testing diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index 268096e5f1..557aca834d 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -22,13 +22,13 @@ harness LLM seam 的 DeepSeek chat-completions 适配器:手写 `fetch` + SSE defaultContextWindow: 256000 # optional positive-integer fallback for models without an exact value models: # optional; defaults to V4 Flash and V4 Pro - id: deepseek-v4-flash - name: DeepSeek V4 Flash + name: DeepSeek-V4-Flash - id: private-reasoner description: Company-hosted reasoning model contextWindow: 64000 ``` -该插件注册唯一提供方路由 `deepseek`。请求使用 `provider: deepseek` 选择该路由;其 `model` 会作为协议 `model` 字符串原样传递,因此更改 DeepSeek 模型不需要生命周期时注册。省略 `models` 会公布 `deepseek-v4-flash` 和 `deepseek-v4-pro`,两者的上下文窗口均为 128,000 token;显式列表会替换这些默认值,`models: []` 则不公布任何模型。Catalog 配置项通过 `ctx.llm.listModels('deepseek')` 公开给 UI selector 与部署自省,但仍只提供建议:未列出模型 id 仍原样传递。省略配置项 name 默认为其 id。 +该插件注册唯一提供方路由 `deepseek`。请求使用 `provider: deepseek` 选择该路由;其 `model` 会作为协议 `model` 字符串原样传递,因此更改 DeepSeek 模型不需要生命周期时注册。省略 `models` 会以 `DeepSeek-V4-Flash` 名义公布 `deepseek-v4-flash`、以 `DeepSeek-V4-Pro` 名义公布 `deepseek-v4-pro`,两者的上下文窗口均为 256,000 token;显式列表会替换这些默认值,`models: []` 则不公布任何模型。Catalog 配置项通过 `ctx.llm.listModels('deepseek')` 公开给 ACP 编辑器与 Web selector 等客户端,但仍只提供建议:未列出模型 id 仍原样传递。省略配置项 name 默认为其 id。 `contextWindow` 对每个已配置模型都可选,不会通过建议 catalog 公开。`ctx.llm.resolveModelContext('deepseek', model)` 先返回精确模型值,再对不含容量的配置项或未列出原样传递 id 返回 `defaultContextWindow`。两者都不存在时返回 `undefined`,不会使路由失效。因此,压力敏感插件可以获得部署拥有的容量,不会将模型 selector 视为权威。为 `deepseek` 注册另一个适配器会抛出 `LlmError('DUPLICATE_ADAPTER')`。 diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 0aa5ae6c34..6d76dd7b26 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -57,6 +57,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/client/ui-conversation': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, 'packages/client/ui-slash': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, 'packages/client/ui-command': { kind: 'indirect', reason: 'The dispatch paths trigger the host command.execute RPC; each command handler\'s host package owns any model-visible effect.' }, + 'packages/client/ui-model': { kind: 'indirect', reason: 'Selection routes session.selectModel; the host snapshots the target at the next prompt-assembly boundary and owns the model-visible effect.' }, 'packages/client/ui-question': { kind: 'indirect', reason: 'The package mounts dsh-tool-ask-user; that tool owns the model-visible schema and answer rendering.' }, 'packages/client/ui-trajectory': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, 'packages/client/ui-workspace': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, diff --git a/vitest.config.ts b/vitest.config.ts index 33228e21ec..7f286cbbec 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -133,6 +133,11 @@ export default defineConfig({ 'packages/client/ui-command/src/client/directory.ts', 'packages/client/ui-command/src/client/service.ts', 'packages/client/ui-command/src/client/PopupSelectView.tsx', + 'packages/client/ui-model/src/index.ts', + 'packages/client/ui-model/src/client/ModelSelect.tsx', + 'packages/client/ui-model/src/client/directory.ts', + 'packages/client/ui-model/src/client/index.ts', + 'packages/client/ui-model/src/client/service.ts', 'packages/client/ui-slash/src/client/controller.ts', 'packages/client/ui-slash/src/client/service.ts', 'packages/client/ui-slash/src/core/menu.ts',