feat(web): add session model selector
This commit is contained in:
@@ -7,7 +7,8 @@
|
||||
|
||||
export type {
|
||||
ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame,
|
||||
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
|
||||
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ModelCatalogFailure,
|
||||
ModelCatalogModel, ModelProviderGroup, ModelTarget, SessionModels, ToolEventView,
|
||||
} from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
export type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation'
|
||||
export type {
|
||||
|
||||
@@ -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,
|
||||
} from './api.ts'
|
||||
import type { RequestPayload, ResponseValue, RpcMethodMap } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
@@ -295,6 +295,10 @@ export function createFixtureApi(): ApiProxy {
|
||||
{ sessionId: sid('fx-gamma'), updatedAt: Date.now() - 120_000, running: false, cwd: '/tmp/fixture' },
|
||||
]
|
||||
const logs = new Map<SessionId, SessionEvent[]>([[sid('fx-alpha'), buildAlphaLog()]])
|
||||
const modelTargets = new Map<SessionId, ModelTarget>(sessions.map(session => [
|
||||
session.sessionId,
|
||||
{ provider: 'deepseek', model: 'deepseek-v4-flash' },
|
||||
]))
|
||||
const nextTurn = new Map<SessionId, number>([[sid('fx-alpha'), 60]])
|
||||
let nextSession = 1
|
||||
let nextRpc = 1
|
||||
@@ -469,6 +473,7 @@ export function createFixtureApi(): ApiProxy {
|
||||
sessionId: sid(`fx-${nextSession++}`), updatedAt: Date.now(), running: false, cwd: '/tmp/fixture',
|
||||
}
|
||||
sessions.push(created)
|
||||
modelTargets.set(created.sessionId, { provider: 'deepseek', model: 'deepseek-v4-flash' })
|
||||
emitHost({ type: 'host/session-added', sessionId: created.sessionId })
|
||||
return ok(request, { sessionId: created.sessionId })
|
||||
},
|
||||
@@ -481,7 +486,36 @@ export function createFixtureApi(): 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,
|
||||
modelTarget: modelTargets.get(request.payload.sessionId)
|
||||
?? { provider: 'deepseek', model: 'deepseek-v4-flash' },
|
||||
})
|
||||
},
|
||||
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
|
||||
@@ -508,7 +542,9 @@ export function createFixtureApi(): 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 })
|
||||
},
|
||||
@@ -631,6 +667,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)
|
||||
|
||||
@@ -15,7 +15,8 @@ import { WebApiClient } from './web-api-client.ts'
|
||||
// ---- Contract re-exports (browser-safe apiproxy channels + core types) ----
|
||||
export type {
|
||||
ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame,
|
||||
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
|
||||
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ModelCatalogFailure,
|
||||
ModelCatalogModel, ModelProviderGroup, ModelTarget, SessionModels, ToolEventView,
|
||||
ToolCallView, ToolResultView,
|
||||
RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode,
|
||||
ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// data source on a real clock; behavior tests need per-case responses and
|
||||
// deferred-controlled timing). Streams are hand pumps: pushMux/pushHost.
|
||||
import type {
|
||||
HostFrame, IApiClient, MuxFrame, RpcRequest, RpcResponse, SessionId,
|
||||
HostFrame, IApiClient, ModelTarget, MuxFrame, RpcRequest, RpcResponse, SessionId, SessionModels,
|
||||
} from '../src/client/api.ts'
|
||||
import { RpcId } from '../src/client/api.ts'
|
||||
|
||||
@@ -44,9 +44,21 @@ export class FakeApiClient implements IApiClient {
|
||||
onList: (payload: unknown) => Promise<RpcResponse<{ items: never[] }>> = () => Promise.resolve(ok({ items: [] }))
|
||||
onCreate: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId }))
|
||||
onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number })
|
||||
=> Promise<RpcResponse<{ events: never[]; hasMore: boolean }>> =
|
||||
() => Promise.resolve(ok({ events: [], hasMore: false }))
|
||||
=> Promise<RpcResponse<{ events: never[]; hasMore: boolean; modelTarget: ModelTarget }>> =
|
||||
() => Promise.resolve(ok({
|
||||
events: [],
|
||||
hasMore: false,
|
||||
modelTarget: { provider: 'deepseek', model: 'deepseek-chat' },
|
||||
}))
|
||||
|
||||
onModels: (payload: unknown) => Promise<RpcResponse<SessionModels>> = () => Promise.resolve(ok({
|
||||
current: { provider: 'deepseek', model: 'deepseek-chat' },
|
||||
groups: [],
|
||||
failures: [],
|
||||
}))
|
||||
onSelectModel: (payload: ModelTarget & { sessionId: SessionId })
|
||||
=> Promise<RpcResponse<{ selected: ModelTarget }>> =
|
||||
payload => Promise.resolve(ok({ selected: { provider: payload.provider, model: payload.model } }))
|
||||
onPrompt: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
|
||||
onCancel: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
|
||||
onDescribe: (payload: unknown) => Promise<RpcResponse<{ version: string; cwd: string; attachedSessions: number }>> =
|
||||
@@ -63,6 +75,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)),
|
||||
}
|
||||
|
||||
@@ -68,7 +68,43 @@ describe('createFixtureApi', () => {
|
||||
// Unknown session: empty page, not an error (history of a bare id).
|
||||
const empty = await api.sessions.history(req({ sessionId: sid('no-such'), maxMessages: 10 }))
|
||||
if (!empty.result.ok) throw new Error('empty failed')
|
||||
expect(empty.result.value).toEqual({ events: [], hasMore: false })
|
||||
expect(empty.result.value).toEqual({
|
||||
events: [],
|
||||
hasMore: false,
|
||||
modelTarget: { provider: 'deepseek', model: 'deepseek-v4-flash' },
|
||||
})
|
||||
})
|
||||
|
||||
it('serves grouped models and keeps a selected target for later history and fixture requests', async () => {
|
||||
const api = createFixtureApi()
|
||||
const sessionId = sid('fx-alpha')
|
||||
const catalog = await api.sessions.models(req({ sessionId }))
|
||||
if (!catalog.result.ok) throw new Error('models failed')
|
||||
expect(catalog.result.value.groups.map(group => group.name)).toEqual(['DeepSeek', 'OpenAI'])
|
||||
expect(catalog.result.value.groups[0]?.models.map(model => model.id))
|
||||
.toEqual(['deepseek-v4-flash', 'deepseek-v4-pro'])
|
||||
|
||||
const selected = await api.sessions.selectModel(req({
|
||||
sessionId,
|
||||
provider: 'openai',
|
||||
model: 'gpt-5',
|
||||
}))
|
||||
if (!selected.result.ok) throw new Error('selection failed')
|
||||
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,
|
||||
mode: 'queue',
|
||||
content: [{ type: 'text', text: 'report model' }],
|
||||
}))
|
||||
expect(prompt.result.ok).toBe(true)
|
||||
await new Promise(resolve => setTimeout(resolve, 600))
|
||||
const after = await api.sessions.history(req({ sessionId }))
|
||||
if (!after.result.ok) throw new Error('history failed')
|
||||
expect(JSON.stringify(after.result.value.events)).toContain('openai/gpt-5')
|
||||
})
|
||||
|
||||
it('create adds a session and pushes host/session-added to open host streams', async () => {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* `rebuilt` frame it re-fetches the entry's bundle and swaps the cordis
|
||||
* fiber in place. Every graph entry is a plugin bundle under the web2 model
|
||||
* — `immediately` rows differ only in stage-one prefetch (a boot
|
||||
* optimization), so all nine plugin packages share these reload semantics;
|
||||
* optimization), so all rostered plugin packages share these reload semantics;
|
||||
* normal packages (react family, cordis, shell, pure libs) are not entries
|
||||
* and shell changes still mean a page reload. Cascade is zero-touch:
|
||||
* downstream fibers key their activation epoch on provider fiber uids
|
||||
|
||||
@@ -6,13 +6,17 @@ Client cordis boot + core services: SlotsService (Service wrapper over SlotCore
|
||||
|
||||
`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 client runtime hosts browser-side services and the session object layer; nothing here reaches a model request.
|
||||
None, as the session object layer selects the provider/model route used by a later Host request but adds no model-visible content.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
None; this package neither assembles nor sends a provider request.
|
||||
Changing the target can change or invalidate provider-side cache reuse; this package does not alter the prompt prefix itself.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ export type {
|
||||
} from './contract/store.ts'
|
||||
export type {
|
||||
AssistantBlock, AssistantMessageNode, ContextMessageNode, ConversationNode, ConversationSnapshot,
|
||||
RunningToolCall, SteeringMessageNode,
|
||||
ModelSelectionSnapshot, ModelSelectionStatus, RunningToolCall, SteeringMessageNode,
|
||||
ToolResultNode, UnknownSurfaceNode, UserMessageNode,
|
||||
} from './sessions/conversation.ts'
|
||||
// PendingWait is a value export: tests construct fixture waits directly.
|
||||
|
||||
@@ -4,7 +4,10 @@
|
||||
// string here (narrow to real brands when convenient).
|
||||
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { RpcError, SessionId, ToolCallView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type {
|
||||
ModelCatalogFailure, ModelProviderGroup, ModelTarget, RpcError, SessionId,
|
||||
ToolCallView, ToolResultView,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { PendingInteraction } from './pending.ts'
|
||||
|
||||
/** Assistant content blocks sorted by what the UI cares about
|
||||
@@ -139,6 +142,23 @@ export interface PromptError {
|
||||
error: RpcError
|
||||
}
|
||||
|
||||
/** Lifecycle of the session-local model directory and selection requests. */
|
||||
export type ModelSelectionStatus = 'idle' | 'loading' | 'ready' | 'selecting' | 'error'
|
||||
|
||||
/** Immutable model-selector state owned by the Session object layer. */
|
||||
export interface ModelSelectionSnapshot {
|
||||
/** Target selected for the next assembled step, or null before history opens. */
|
||||
current: ModelTarget | null
|
||||
/** Last successfully loaded provider groups. */
|
||||
groups: readonly ModelProviderGroup[]
|
||||
/** Provider-local failures from the last successful directory response. */
|
||||
failures: readonly ModelCatalogFailure[]
|
||||
/** Current directory or selection operation state. */
|
||||
status: ModelSelectionStatus
|
||||
/** Whole-request or selection failure; partial provider failures use {@link failures}. */
|
||||
error: RpcError | null
|
||||
}
|
||||
|
||||
/** The immutable snapshot contract Session hands to uSES (see the web client architecture RFC). */
|
||||
export interface ConversationSnapshot {
|
||||
sessionId: SessionId
|
||||
@@ -158,4 +178,6 @@ export interface ConversationSnapshot {
|
||||
loadingOlder: boolean
|
||||
promptError: PromptError | null
|
||||
lastAgentError: string | null
|
||||
/** Session-local model target and advisory directory state. */
|
||||
modelSelection: ModelSelectionSnapshot
|
||||
}
|
||||
|
||||
@@ -6,15 +6,16 @@
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import type {
|
||||
HistoryEntry, IApiClient, MuxFrame, RpcError, RpcId, RpcResult,
|
||||
SessionId, ToolEventView,
|
||||
HistoryEntry, IApiClient, ModelTarget, MuxFrame, RpcError, RpcId, RpcResult,
|
||||
SessionId, SessionModels, ToolEventView,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
// Value import from the inline-safe wire layer (not the connection plugin):
|
||||
// plugin-to-plugin value imports are a bundle purity error.
|
||||
import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import type { ObservableSnapshot } from '../contract/store.ts'
|
||||
import type {
|
||||
ConversationNode, ConversationSnapshot, OpenState, PromptError, RunningToolCall,
|
||||
ConversationNode, ConversationSnapshot, ModelSelectionSnapshot, OpenState, PromptError,
|
||||
RunningToolCall,
|
||||
} from './conversation.ts'
|
||||
import type { PendingInteraction } from './pending.ts'
|
||||
import { PendingWait } from './pending.ts'
|
||||
@@ -69,6 +70,17 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
private removed = false
|
||||
private promptError: PromptError | null = null
|
||||
private lastAgentError: string | null = null
|
||||
private modelSelection: ModelSelectionSnapshot = {
|
||||
current: null,
|
||||
groups: [],
|
||||
failures: [],
|
||||
status: 'idle',
|
||||
error: null,
|
||||
}
|
||||
/** Latest model-directory/selection operation; stale responses drop all writes. */
|
||||
private modelGeneration = 0
|
||||
/** Failed selection target; null means the retryable operation is a directory refresh. */
|
||||
private modelRetryTarget: ModelTarget | null = null
|
||||
/** Buffer for live events arriving while open/resync is in flight (stitched by seq once history lands, §D.3). */
|
||||
private liveBuffer: { event: SessionEvent; view: ToolEventView | undefined }[] = []
|
||||
/** Gap-repair (resync-lite) in flight: acceptLiveEvent detours to liveBuffer until the tail page lands (audit S3). */
|
||||
@@ -128,6 +140,102 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the advisory provider/model directory. Independent provider
|
||||
* failures remain in a successful snapshot; whole-request failures preserve
|
||||
* the last usable groups and current target.
|
||||
* @returns the model-directory RPC result.
|
||||
*/
|
||||
async refreshModels(): Promise<RpcResult<SessionModels>> {
|
||||
const generation = ++this.modelGeneration
|
||||
this.modelRetryTarget = null
|
||||
this.modelSelection = {
|
||||
...this.modelSelection,
|
||||
status: 'loading',
|
||||
error: null,
|
||||
}
|
||||
this.notifier.markDirty()
|
||||
let result: RpcResult<SessionModels>
|
||||
try {
|
||||
result = (await this.api.sessions.models({ sessionId: this.sessionId })).result
|
||||
} catch (error: unknown) {
|
||||
result = transportError(error)
|
||||
}
|
||||
if (generation !== this.modelGeneration) return result
|
||||
this.modelSelection = result.ok
|
||||
? {
|
||||
current: result.value.current,
|
||||
groups: result.value.groups,
|
||||
failures: result.value.failures,
|
||||
status: 'ready',
|
||||
error: null,
|
||||
}
|
||||
: {
|
||||
...this.modelSelection,
|
||||
status: 'error',
|
||||
error: result.error,
|
||||
}
|
||||
this.notifier.markDirty()
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Select the complete route for this session. The host snapshots it at the
|
||||
* next prompt-assembly boundary, so running work keeps its assembled target.
|
||||
* @param target - Provider and provider-owned model id.
|
||||
* @returns the selection RPC result.
|
||||
*/
|
||||
async selectModel(target: ModelTarget): Promise<RpcResult<{ selected: ModelTarget }>> {
|
||||
const generation = ++this.modelGeneration
|
||||
this.modelRetryTarget = target
|
||||
this.modelSelection = {
|
||||
...this.modelSelection,
|
||||
status: 'selecting',
|
||||
error: null,
|
||||
}
|
||||
this.notifier.markDirty()
|
||||
let result: RpcResult<{ selected: ModelTarget }>
|
||||
try {
|
||||
result = (await this.api.sessions.selectModel({
|
||||
sessionId: this.sessionId,
|
||||
provider: target.provider,
|
||||
model: target.model,
|
||||
})).result
|
||||
} catch (error: unknown) {
|
||||
result = transportError(error)
|
||||
}
|
||||
if (generation !== this.modelGeneration) return result
|
||||
if (result.ok) this.modelRetryTarget = null
|
||||
this.modelSelection = result.ok
|
||||
? {
|
||||
...this.modelSelection,
|
||||
current: result.value.selected,
|
||||
status: 'ready',
|
||||
error: null,
|
||||
}
|
||||
: {
|
||||
...this.modelSelection,
|
||||
status: 'error',
|
||||
error: result.error,
|
||||
}
|
||||
this.notifier.markDirty()
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Repeat the operation that produced the visible model error.
|
||||
* A failed selection retains its exact target; directory failures refresh.
|
||||
* @returns Whether a model selection succeeded; directory retries return false.
|
||||
*/
|
||||
async retryModelOperation(): Promise<boolean> {
|
||||
const target = this.modelRetryTarget
|
||||
if (target === null) {
|
||||
await this.refreshModels()
|
||||
return false
|
||||
}
|
||||
return (await this.selectModel(target)).ok
|
||||
}
|
||||
|
||||
/** First open: pull the tail page (idempotent — in-flight/already-open returns the existing promise). */
|
||||
open(): Promise<void> {
|
||||
if (this.openState === 'open') return Promise.resolve()
|
||||
@@ -318,6 +426,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
this.openError = null
|
||||
this.notifier.markDirty()
|
||||
try {
|
||||
let modelGeneration = this.modelGeneration
|
||||
let { result } = await this.api.sessions.history({ sessionId: this.sessionId, maxMessages: PAGE_MESSAGES })
|
||||
if (generation !== this.openGeneration) return
|
||||
if (!result.ok) {
|
||||
@@ -325,13 +434,24 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
this.openError = result.error
|
||||
return
|
||||
}
|
||||
this.installWindow(result.value.events, result.value.hasMore)
|
||||
this.installWindow(
|
||||
result.value.events,
|
||||
result.value.hasMore,
|
||||
modelGeneration === this.modelGeneration ? result.value.modelTarget : undefined,
|
||||
)
|
||||
// Gap detection (§D.3-4): baseline past the window tail and liveBuffer did not cover it -> pull the tail page once more.
|
||||
const tailSeq = this.windowTailSeq()
|
||||
if (this.subscribedLastSeq !== null && tailSeq !== null && this.subscribedLastSeq > tailSeq) {
|
||||
modelGeneration = this.modelGeneration
|
||||
result = (await this.api.sessions.history({ sessionId: this.sessionId, maxMessages: PAGE_MESSAGES })).result
|
||||
if (generation !== this.openGeneration) return
|
||||
if (result.ok) this.installWindow(result.value.events, result.value.hasMore)
|
||||
if (result.ok) {
|
||||
this.installWindow(
|
||||
result.value.events,
|
||||
result.value.hasMore,
|
||||
modelGeneration === this.modelGeneration ? result.value.modelTarget : undefined,
|
||||
)
|
||||
}
|
||||
}
|
||||
this.openState = 'open'
|
||||
} catch (error) {
|
||||
@@ -349,13 +469,30 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
* Stitching MUST NOT route through acceptLiveEvent: openState is still 'loading' here
|
||||
* (doOpen flips it after install), so recursing would push every buffered event straight
|
||||
* back into liveBuffer where nothing ever drains it — a silent drop loop (audit S1). */
|
||||
private installWindow(entries: HistoryEntry[], hasMore: boolean): void {
|
||||
private installWindow(entries: HistoryEntry[], hasMore: boolean, modelTarget?: ModelTarget): void {
|
||||
this.events = entries.map(e => e.event)
|
||||
this.views = entries.map(e => e.view)
|
||||
this.baseSeq = this.events[0]?.seq ?? 0
|
||||
this.hasMore = hasMore
|
||||
this.foldAdapter.reset(this.events, this.baseSeq, this.views)
|
||||
this.rebuildDerivedFromWindow()
|
||||
if (modelTarget !== undefined) {
|
||||
const current = this.modelSelection.current
|
||||
if (
|
||||
current === null
|
||||
|| current.provider !== modelTarget.provider
|
||||
|| current.model !== modelTarget.model
|
||||
|| this.modelSelection.error !== null
|
||||
) {
|
||||
this.modelRetryTarget = null
|
||||
this.modelSelection = {
|
||||
...this.modelSelection,
|
||||
current: modelTarget,
|
||||
status: this.modelSelection.groups.length > 0 ? 'ready' : 'idle',
|
||||
error: null,
|
||||
}
|
||||
}
|
||||
}
|
||||
const buffered = this.liveBuffer
|
||||
this.liveBuffer = []
|
||||
for (const item of buffered) this.appendLive(item.event, item.view)
|
||||
@@ -400,11 +537,16 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
if (this.stitching) return
|
||||
this.stitching = true
|
||||
const generation = this.openGeneration
|
||||
const modelGeneration = this.modelGeneration
|
||||
try {
|
||||
const { result } = await this.api.sessions.history({ sessionId: this.sessionId, maxMessages: PAGE_MESSAGES })
|
||||
// Failure or superseded by a full resync: drop — the resync path rebuilds and clears the buffer itself.
|
||||
if (result.ok && generation === this.openGeneration && this.openState === 'open') {
|
||||
this.installWindow(result.value.events, result.value.hasMore)
|
||||
this.installWindow(
|
||||
result.value.events,
|
||||
result.value.hasMore,
|
||||
modelGeneration === this.modelGeneration ? result.value.modelTarget : undefined,
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[web-runtime] gap repair failed:', error)
|
||||
@@ -539,6 +681,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
loadingOlder: this.loadingOlder,
|
||||
promptError: this.promptError,
|
||||
lastAgentError: this.lastAgentError,
|
||||
modelSelection: this.modelSelection,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +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, HostFrame, IApiClient, MuxFrame, RpcError, RpcReceipt, RpcRequest, RpcResponse, SessionId,
|
||||
ClientResponse, HostFrame, IApiClient, ModelTarget, MuxFrame, RpcError, RpcReceipt,
|
||||
RpcRequest, RpcResponse, SessionId, SessionModels,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
|
||||
@@ -46,10 +47,23 @@ export class FakeApiClient implements IApiClient {
|
||||
// Programmable slots (defaults answer OK-empty); reassign per case.
|
||||
onList: (payload: unknown) => Promise<RpcResponse<{ items: never[] }>> = () => Promise.resolve(ok({ items: [] }))
|
||||
onCreate: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => 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<RpcResponse<{ events: never[]; hasMore: boolean }>> =
|
||||
() => Promise.resolve(ok({ events: [], hasMore: false }))
|
||||
=> Promise<RpcResponse<{ events: never[]; hasMore: boolean; modelTarget: ModelTarget }>> =
|
||||
() => Promise.resolve(ok({ events: [], hasMore: false, modelTarget: this.defaultModel }))
|
||||
|
||||
onModels: (payload: unknown) => Promise<RpcResponse<SessionModels>> = () => 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<RpcResponse<{ selected: ModelTarget }>> =
|
||||
payload => Promise.resolve(ok({ selected: { provider: payload.provider, model: payload.model } }))
|
||||
onPrompt: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
|
||||
onCancel: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
|
||||
onDescribe: (payload: unknown) => Promise<RpcResponse<{ version: string; cwd: string; attachedSessions: number }>> =
|
||||
@@ -66,6 +80,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)),
|
||||
}
|
||||
|
||||
@@ -267,7 +267,11 @@ describe('remaining branches', () => {
|
||||
describe('connected generation', () => {
|
||||
it('refreshes the list and resyncs only opened instances', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onHistory = () => Promise.resolve(ok({ events: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false }))
|
||||
api.onHistory = () => Promise.resolve(ok({
|
||||
events: entries(plainTurn(0, 0, 'a', 'b')) as never[],
|
||||
hasMore: false,
|
||||
modelTarget: { provider: 'deepseek', model: 'deepseek-chat' },
|
||||
}))
|
||||
const manager = new SessionManager(api)
|
||||
const openedSession = manager.get(S1)
|
||||
await openedSession.open()
|
||||
|
||||
@@ -24,7 +24,11 @@ function makeSession(api = new FakeApiClient()): { api: FakeApiClient; session:
|
||||
|
||||
function histResponse(events: SessionEvent[], hasMore = false) {
|
||||
// history now returns HistoryEntry[] ({event, view?}); these tests are view-less.
|
||||
return Promise.resolve(ok({ events: entries(events) as never[], hasMore }))
|
||||
return Promise.resolve(ok({
|
||||
events: entries(events) as never[],
|
||||
hasMore,
|
||||
modelTarget: { provider: 'deepseek', model: 'deepseek-v4-flash' },
|
||||
}))
|
||||
}
|
||||
|
||||
describe('open', () => {
|
||||
@@ -75,7 +79,11 @@ describe('open', () => {
|
||||
const page = plainTurn(10, 0, '早', '安')
|
||||
session.handleMuxEnvelope('r1' as never, { type: 'session/event', sessionId: SID, event: ev.turnStart(15, 1) })
|
||||
session.handleMuxEnvelope('r2' as never, { type: 'session/event', sessionId: SID, event: ev.user(16, '插进来的') })
|
||||
gate.resolve(ok({ events: entries(page) as never[], hasMore: false }))
|
||||
gate.resolve(ok({
|
||||
events: entries(page) as never[],
|
||||
hasMore: false,
|
||||
modelTarget: { provider: 'deepseek', model: 'deepseek-v4-flash' },
|
||||
}))
|
||||
await opening
|
||||
const seqs = session.getSnapshot().nodes.map(n => n.seq)
|
||||
// Overlapping seq-15 frame (== page tail turn/end) was dropped; 16 appended once.
|
||||
@@ -83,6 +91,165 @@ 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<Awaited<ReturnType<FakeApiClient['onModels']>>>()
|
||||
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<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
|
||||
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')) {
|
||||
const { api, session } = makeSession()
|
||||
@@ -210,7 +377,11 @@ describe('paging', () => {
|
||||
api.onHistory = () => gate.promise
|
||||
const first = session.loadOlder()
|
||||
const second = session.loadOlder()
|
||||
gate.resolve(ok({ events: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false }))
|
||||
gate.resolve(ok({
|
||||
events: entries(plainTurn(0, 0, 'a', 'b')) as never[],
|
||||
hasMore: false,
|
||||
modelTarget: { provider: 'deepseek', model: 'deepseek-v4-flash' },
|
||||
}))
|
||||
await Promise.all([first, second])
|
||||
expect(api.callsOf('session.history')).toHaveLength(2) // open + one page, not two
|
||||
})
|
||||
@@ -482,7 +653,11 @@ describe('remaining branches', () => {
|
||||
const opening = session.open()
|
||||
api.onHistory = () => histResponse(plainTurn(6, 1, '新', '代'))
|
||||
const resynced = session.resync()
|
||||
stale.resolve(ok({ events: entries(plainTurn(0, 0, '旧', '代')) as never[], hasMore: false })) // success, but its generation is gone
|
||||
stale.resolve(ok({
|
||||
events: entries(plainTurn(0, 0, '旧', '代')) as never[],
|
||||
hasMore: false,
|
||||
modelTarget: { provider: 'deepseek', model: 'stale' },
|
||||
})) // success, but its generation is gone
|
||||
await Promise.all([opening, resynced])
|
||||
expect(session.getSnapshot().nodes.map(n => n.seq)).toEqual([7, 9]) // only the fresh generation's window
|
||||
})
|
||||
@@ -501,7 +676,11 @@ describe('remaining branches', () => {
|
||||
const opening = session.open() // triggers the second pull, which parks
|
||||
await vi.waitFor(() => { expect(call).toBe(2) })
|
||||
const resynced = session.resync()
|
||||
secondPull.resolve(ok({ events: entries([...plainTurn(0, 0, 'a', 'b'), ...plainTurn(6, 1, 'c', 'd')]) as never[], hasMore: false }))
|
||||
secondPull.resolve(ok({
|
||||
events: entries([...plainTurn(0, 0, 'a', 'b'), ...plainTurn(6, 1, 'c', 'd')]) as never[],
|
||||
hasMore: false,
|
||||
modelTarget: { provider: 'deepseek', model: 'stale' },
|
||||
}))
|
||||
await Promise.all([opening, resynced])
|
||||
expect(session.getSnapshot().openState).toBe('open')
|
||||
})
|
||||
@@ -515,7 +694,11 @@ describe('remaining branches', () => {
|
||||
session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event: ev.user(9, '洞') }) // starts repairGap
|
||||
api.onHistory = () => histResponse(plainTurn(6, 1, 'c', 'd'))
|
||||
const resynced = session.resync() // bumps the generation
|
||||
repairPull.resolve(ok({ events: entries(plainTurn(0, 0, '旧', '页')) as never[], hasMore: false })) // repair result: stale, dropped
|
||||
repairPull.resolve(ok({
|
||||
events: entries(plainTurn(0, 0, '旧', '页')) as never[],
|
||||
hasMore: false,
|
||||
modelTarget: { provider: 'deepseek', model: 'stale' },
|
||||
})) // repair result: stale, dropped
|
||||
await resynced
|
||||
expect(session.getSnapshot().nodes.map(n => n.seq)).toEqual([7, 9])
|
||||
})
|
||||
@@ -559,6 +742,7 @@ describe('remaining branches', () => {
|
||||
{ event: ev.toolResult(7, 1, 'h1', 'done'), view: { for: 'result', view: { card: 'generic', title: '历史果' } } },
|
||||
] as never[],
|
||||
hasMore: false,
|
||||
modelTarget: { provider: 'deepseek', model: 'deepseek-v4-flash' },
|
||||
}))
|
||||
await session.open()
|
||||
expect(session.getSnapshot().nodes.at(-1)).toMatchObject({
|
||||
@@ -662,6 +846,7 @@ 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()
|
||||
|
||||
@@ -10,6 +10,8 @@ Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.to
|
||||
|
||||
Per-session UI state (selection, 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. Components are pure — the framework standard kit (`useSession`/`sessionId`/`useSessions`) and the store faces (`useStore`/`actions`) arrive automatically from the registration declaration; the inject factories contribute plain data and callbacks only (send/stop choreography, tab read face, details/paging callbacks, startSession chain).
|
||||
|
||||
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
|
||||
|
||||
@@ -74,6 +74,7 @@ export function apply(ctx: Context): void {
|
||||
children: {
|
||||
'conversation.view': { kind: 'list', scope: 'session' },
|
||||
'conversation.composer': { kind: 'chain', scope: 'session' },
|
||||
'conversation.composer.control': { kind: 'single', scope: 'session' },
|
||||
},
|
||||
store: chatStore,
|
||||
inject: (sessionId: SessionId, actions: BoundActions<typeof chatStore>): ConversationInjected => {
|
||||
|
||||
@@ -41,6 +41,16 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
* zero owner changes.
|
||||
*/
|
||||
'conversation.composer': { kind: 'chain'; scope: 'session'; owner: ComposerChainProps }
|
||||
/**
|
||||
* Optional controls rendered in the resident InputBar immediately before
|
||||
* its primary send/stop button. The conversation entry declares and owns
|
||||
* the render site; feature plugins contribute the single occupant.
|
||||
*/
|
||||
'conversation.composer.control': {
|
||||
kind: 'single'
|
||||
scope: 'session'
|
||||
owner: ComposerControlOwnerProps
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,9 +137,13 @@ export interface ComposerChainProps {
|
||||
interactions: readonly PendingInteraction[]
|
||||
}
|
||||
|
||||
/** Full conversation-slot component props: runtime & child-render (view ring + composer chain) & store & injected shares. */
|
||||
/** Composer-control owner share; session state and actions arrive through the standard and injected shares. */
|
||||
export interface ComposerControlOwnerProps {}
|
||||
|
||||
/** Full conversation-slot component props: runtime & child-render shares & store & injected shares. */
|
||||
export type ConversationSlotProps =
|
||||
PropsRuntime<'conversation'> & PropsRenderSlots<'conversation.view' | 'conversation.composer'>
|
||||
PropsRuntime<'conversation'>
|
||||
& PropsRenderSlots<'conversation.view' | 'conversation.composer' | 'conversation.composer.control'>
|
||||
& PropsStore<ChatStore> & ConversationInjected
|
||||
|
||||
/**
|
||||
|
||||
@@ -18,8 +18,8 @@ export type {
|
||||
} from './contract/views.ts'
|
||||
export type { ToolCallBlock } from './contract/tool-call-model.ts'
|
||||
export type {
|
||||
ChatStore, ChatViewInjected, ChatViewSlotProps, ComposerChainProps, ConversationInjected,
|
||||
ConversationSlotProps, ConvViewOwnerProps, ConvViewProps, DetailsInjected, DetailsSlotProps,
|
||||
ChatStore, ChatViewInjected, ChatViewSlotProps, ComposerChainProps, ComposerControlOwnerProps,
|
||||
ConversationInjected, ConversationSlotProps, ConvViewOwnerProps, ConvViewProps, DetailsInjected, DetailsSlotProps,
|
||||
EmptyStateInjected, EmptyStateSlotProps, ToolRowOwnerProps, ToolRowProps,
|
||||
} from './contract/slots.ts'
|
||||
// Export discipline: packages/client/AGENTS.md.
|
||||
|
||||
@@ -68,6 +68,7 @@ export function ConversationRoot({
|
||||
disabled={removed}
|
||||
error={error}
|
||||
variant="composer"
|
||||
control={renderSlot('conversation.composer.control', {})}
|
||||
onDraftChange={actions.setDraft}
|
||||
onSend={(mode) => { send(draft, mode) }}
|
||||
onStop={stop}
|
||||
|
||||
@@ -124,9 +124,14 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 0 10px 10px 12px;
|
||||
}
|
||||
|
||||
.control {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Primary send (figma IconButton 34:10465): 34px circle, #3964FE light /
|
||||
#679EFE dark — the info-fill pair (500→400), NOT button-primary (ink);
|
||||
white glyph; empty text = 0.4 opacity. */
|
||||
|
||||
@@ -26,13 +26,16 @@ export interface InputBarProps {
|
||||
placeholder?: string
|
||||
/** Optional leading accessory row content (the empty state mounts its cwd picker here). */
|
||||
accessory?: ReactNode
|
||||
/** Optional resident composer control rendered immediately before the primary button. */
|
||||
control?: ReactNode
|
||||
onDraftChange: (text: string) => void
|
||||
onSend: (mode: 'queue' | 'steer') => void
|
||||
onStop: () => void
|
||||
}
|
||||
|
||||
export function InputBar({
|
||||
draft, running, disabled, error, variant, placeholder, accessory, onDraftChange, onSend, onStop,
|
||||
draft, running, disabled, error, variant, placeholder, accessory, control,
|
||||
onDraftChange, onSend, onStop,
|
||||
}: InputBarProps) {
|
||||
const empty = draft.trim() === ''
|
||||
const inputRef = useRef<HTMLTextAreaElement | null>(null)
|
||||
@@ -116,6 +119,7 @@ export function InputBar({
|
||||
<div aria-hidden className={css.mirror}>{`${draft}\n`}</div>
|
||||
</div>
|
||||
<div className={css.row}>
|
||||
{control !== undefined && <div className={css.control}>{control}</div>}
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(css.primary, running && css.stopping)}
|
||||
|
||||
@@ -29,6 +29,7 @@ function snapshotBase(): ConversationSnapshot {
|
||||
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [],
|
||||
pending: [], running: false, removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null,
|
||||
modelSelection: { current: null, groups: [], failures: [], status: 'idle', error: null },
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -41,7 +41,8 @@ function snapshotWith(nodes: ToolResultNode[]): ConversationSnapshot {
|
||||
sessionId: SID, nodes, foldDegraded: false, partial: null, runningCalls: [],
|
||||
pending: [], running: false, removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null,
|
||||
} as ConversationSnapshot
|
||||
modelSelection: { current: null, groups: [], failures: [], status: 'idle', error: null },
|
||||
}
|
||||
}
|
||||
|
||||
/** Test-owned AppFrame role: declares the layout-owned children and renders the conversation area under the framework session provider. */
|
||||
|
||||
@@ -31,6 +31,7 @@ function snapshotBase(): ConversationSnapshot {
|
||||
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [],
|
||||
pending: [], running: false, removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null,
|
||||
modelSelection: { current: null, groups: [], failures: [], status: 'idle', error: null },
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,8 @@ function snapshotBase(): ConversationSnapshot {
|
||||
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [],
|
||||
pending: [], running: false, removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null,
|
||||
} as ConversationSnapshot
|
||||
modelSelection: { current: null, groups: [], failures: [], status: 'idle', error: null },
|
||||
}
|
||||
}
|
||||
|
||||
describe('render branch tails', () => {
|
||||
|
||||
@@ -128,4 +128,11 @@ describe('error strip and variants', () => {
|
||||
expect(view.getByTestId('acc')).toBeTruthy()
|
||||
expect(view.container.querySelector('[class*="hero"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('renders the optional composer control immediately before the primary button', () => {
|
||||
const { view } = setup({ control: <button type="button">model</button> })
|
||||
const buttons = view.getAllByRole('button')
|
||||
expect(buttons.map(button => button.textContent)).toEqual(['model', ''])
|
||||
expect(buttons[1]?.getAttribute('aria-label')).toBe('发送')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -30,7 +30,8 @@ function snapshotBase(): ConversationSnapshot {
|
||||
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [],
|
||||
pending: [], running: false, removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null,
|
||||
} as ConversationSnapshot
|
||||
modelSelection: { current: null, groups: [], failures: [], status: 'idle', error: null },
|
||||
}
|
||||
}
|
||||
|
||||
function sessionSource(over?: Partial<ConversationSnapshot>) {
|
||||
@@ -58,7 +59,9 @@ function listHook(rows: { id: string; title: string; cwd?: string; parentId?: st
|
||||
describe('ConversationRoot branches', () => {
|
||||
const chatTab: ViewTab = { id: 'chat', label: 'Chat' }
|
||||
/** renderSlot stub in the outlet's baked shape (ring key + only filter marker). */
|
||||
const stubRenderSlot = (() => <div data-testid="view-body" />) as unknown as ConversationRootProps['renderSlot']
|
||||
const stubRenderSlot = ((key: string) =>
|
||||
key === 'conversation.view' ? <div data-testid="view-body" /> : null
|
||||
) as unknown as ConversationRootProps['renderSlot']
|
||||
/** SessionProvider seat stub (render-prop pass-through; ConversationRoot never invokes it). */
|
||||
const SessionProviderStub: ConversationRootProps['SessionProvider'] = ({ children }) => <>{children(SID)}</>
|
||||
|
||||
|
||||
@@ -177,8 +177,11 @@ describe('ConversationRoot', () => {
|
||||
})
|
||||
|
||||
it('hides the tab strip with a single view; composer writes the store draft and sends it', () => {
|
||||
const { chat, send } = bench([tab('chat', 'Chat')])
|
||||
const { chat, send, renderSlot } = bench([tab('chat', 'Chat')])
|
||||
expect(screen.queryByRole('tablist')).toBeNull()
|
||||
expect(renderSlot).toHaveBeenCalledWith('conversation.composer.control', {})
|
||||
expect(screen.getByTestId('view-(all)').getAttribute('data-slot'))
|
||||
.toBe('conversation.composer.control')
|
||||
const box = screen.getByPlaceholderText(/输入消息/)
|
||||
fireEvent.change(box, { target: { value: 'hi' } })
|
||||
// Typing goes through actions.setDraft into the shared store.
|
||||
|
||||
20
packages/client/ui-model-selector/README.md
Normal file
20
packages/client/ui-model-selector/README.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# @deepseek-ai/dsh-client-ui-model-selector
|
||||
|
||||
Session-scoped Web model selector. Its browser half occupies `conversation.composer.control`, shows the current catalog name beside the send button, and opens an upward provider-grouped menu. Provider names appear once as group headings; model rows and the trigger show catalog names without repeating the provider route, with the model id as the fallback for an unlisted current target.
|
||||
|
||||
The selector primes the advisory directory when it mounts so the trigger can resolve the catalog name, then refreshes it whenever the menu opens. The Session object layer owns loading, selection, partial-provider-failure, and stale-response state. A selection updates only that live session and takes effect at the next prompt-assembly boundary, including while the current step is running. The latest consumed target remains durable through the existing `request/header`; an unused choice is process-local.
|
||||
|
||||
Catalog membership is not request validation. The current target is included as an unlisted row when its registered provider omits it, while a target whose provider is unavailable remains visible on the trigger with a warning in the menu.
|
||||
|
||||
## Model Experience
|
||||
|
||||
None, as the browser selector changes subsequent request routing but adds no model-visible content.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Switching routes may invalidate provider-side cache reuse according to the selected adapter. The selector itself adds no prompt content.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **The new-session composer has no selector** — a session starts with the host default and exposes the selector after creation.
|
||||
- **Unused selections are not durable** — reload restores the last route consumed by a request, not a choice made without sending.
|
||||
62
packages/client/ui-model-selector/package.json
Normal file
62
packages/client/ui-model-selector/package.json
Normal file
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-model-selector",
|
||||
"description": "Session-scoped provider/model selector for the Web conversation composer",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./client": {
|
||||
"types": "./lib/types/client/index.d.ts",
|
||||
"default": "./lib/client.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-ui-conversation"
|
||||
],
|
||||
"platform": "web"
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
"watch": "tsdown --watch"
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"clsx": "^2.0.0",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@types/react": "~18.3.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/client.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
.root {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
max-width: 220px;
|
||||
height: 34px;
|
||||
padding: 0 6px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.trigger:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.trigger:focus-visible {
|
||||
box-shadow: 0 0 0 2px var(--dsw-alias-border-l3);
|
||||
}
|
||||
|
||||
.trigger:disabled {
|
||||
color: var(--dsw-alias-label-dimmed);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.triggerLabel {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
flex: 0 0 auto;
|
||||
color: var(--dsw-alias-label-caption);
|
||||
transition: transform 120ms ease;
|
||||
}
|
||||
|
||||
.chevronOpen {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.menu {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: calc(100% + 8px);
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(320px, calc(100vw - 32px));
|
||||
max-height: min(360px, calc(100vh - 96px));
|
||||
overflow: hidden;
|
||||
padding: 6px;
|
||||
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
|
||||
border-radius: 14px;
|
||||
background: var(--dsw-specific-input-major);
|
||||
box-shadow: var(--dsw-shadow-lv3);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.status,
|
||||
.empty {
|
||||
padding: 10px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.error,
|
||||
.warning {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
padding: 7px 8px;
|
||||
border-radius: 8px;
|
||||
background: var(--dsw-alias-interactive-bg-hover-danger);
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.warning {
|
||||
background: var(--dsw-alias-bg-module-platform);
|
||||
color: var(--dsw-alias-state-warn-label);
|
||||
}
|
||||
|
||||
.retry {
|
||||
flex: 0 0 auto;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.groups {
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.group + .group {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.groupTitle {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
padding: 5px 8px 3px;
|
||||
background: var(--dsw-specific-input-major);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
min-height: 38px;
|
||||
padding: 6px 8px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.option:hover:not(:disabled),
|
||||
.option:focus-visible,
|
||||
.selected {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.option:disabled {
|
||||
color: var(--dsw-alias-label-dimmed);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.optionCopy {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.modelName {
|
||||
overflow: hidden;
|
||||
color: inherit;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
font-weight: 500;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.description,
|
||||
.unlisted {
|
||||
overflow: hidden;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.unlisted {
|
||||
color: var(--dsw-alias-state-warn-label);
|
||||
}
|
||||
|
||||
.check {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: 0 0 18px;
|
||||
color: var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
261
packages/client/ui-model-selector/src/client/ModelSelector.tsx
Normal file
261
packages/client/ui-model-selector/src/client/ModelSelector.tsx
Normal file
@@ -0,0 +1,261 @@
|
||||
import {
|
||||
useEffect, useId, useMemo, useRef, useState,
|
||||
type FocusEvent, type KeyboardEvent,
|
||||
} from 'react'
|
||||
import clsx from 'clsx'
|
||||
import type { ModelTarget } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import {
|
||||
IconCheckOutline16, IconChevronDownOutline14,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ModelSelectorProps } from './contract.ts'
|
||||
import css from './ModelSelector.module.css'
|
||||
|
||||
type FocusPreference = 'current' | 'first' | 'last'
|
||||
|
||||
/** Session-scoped provider-grouped model selector for the composer action row. */
|
||||
export function ModelSelector({
|
||||
useSession, refreshModels, retryModelOperation, selectModel,
|
||||
}: ModelSelectorProps) {
|
||||
const selection = useSession(snapshot => snapshot.modelSelection)
|
||||
const removed = useSession(snapshot => snapshot.removed)
|
||||
const [open, setOpen] = useState(false)
|
||||
const rootRef = useRef<HTMLDivElement | null>(null)
|
||||
const triggerRef = useRef<HTMLButtonElement | null>(null)
|
||||
const itemRefs = useRef<(HTMLButtonElement | null)[]>([])
|
||||
const pendingFocus = useRef<FocusPreference | null>(null)
|
||||
const id = useId()
|
||||
const { choices, choiceIndices } = useMemo(() => {
|
||||
const nextChoices = selection.groups.flatMap(group =>
|
||||
group.models.map(model => ({
|
||||
group,
|
||||
model,
|
||||
target: { provider: group.id, model: model.id } satisfies ModelTarget,
|
||||
})))
|
||||
return {
|
||||
choices: nextChoices,
|
||||
choiceIndices: new Map(nextChoices.map((choice, index) => [
|
||||
JSON.stringify([choice.target.provider, choice.target.model]),
|
||||
index,
|
||||
])),
|
||||
}
|
||||
}, [selection.groups])
|
||||
const selectedIndex = selection.current === null
|
||||
? -1
|
||||
: choiceIndices.get(JSON.stringify([
|
||||
selection.current.provider,
|
||||
selection.current.model,
|
||||
])) ?? -1
|
||||
const busy = selection.status === 'selecting'
|
||||
|
||||
useEffect(() => {
|
||||
refreshModels()
|
||||
}, [refreshModels])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const closeOutside = (event: MouseEvent): void => {
|
||||
if (!rootRef.current?.contains(event.target as Node)) setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', closeOutside)
|
||||
return () => { document.removeEventListener('mousedown', closeOutside) }
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
const preference = pendingFocus.current
|
||||
if (!open || preference === null || choices.length === 0) return
|
||||
const index = preference === 'first'
|
||||
? 0
|
||||
: preference === 'last'
|
||||
? choices.length - 1
|
||||
: selectedIndex >= 0 ? selectedIndex : 0
|
||||
itemRefs.current[index]?.focus()
|
||||
pendingFocus.current = null
|
||||
}, [choices.length, open, selectedIndex])
|
||||
|
||||
const show = (preference: FocusPreference | null = null): void => {
|
||||
pendingFocus.current = preference
|
||||
setOpen(true)
|
||||
refreshModels()
|
||||
}
|
||||
|
||||
const close = (restoreFocus = false): void => {
|
||||
setOpen(false)
|
||||
pendingFocus.current = null
|
||||
if (restoreFocus) queueMicrotask(() => { triggerRef.current?.focus() })
|
||||
}
|
||||
|
||||
const moveFocus = (offset: number): void => {
|
||||
if (choices.length === 0) return
|
||||
const active = itemRefs.current.findIndex(item => item === document.activeElement)
|
||||
const origin = active >= 0 ? active : selectedIndex >= 0 ? selectedIndex : 0
|
||||
const next = (origin + offset + choices.length) % choices.length
|
||||
itemRefs.current[next]?.focus()
|
||||
}
|
||||
|
||||
const onRootKeyDown = (event: KeyboardEvent<HTMLDivElement>): void => {
|
||||
if (event.key === 'Escape' && open) {
|
||||
event.preventDefault()
|
||||
close(true)
|
||||
return
|
||||
}
|
||||
if (!open) return
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
moveFocus(event.key === 'ArrowDown' ? 1 : -1)
|
||||
return
|
||||
}
|
||||
if (event.key === 'Home' || event.key === 'End') {
|
||||
event.preventDefault()
|
||||
itemRefs.current[event.key === 'Home' ? 0 : choices.length - 1]?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
const onTriggerKeyDown = (event: KeyboardEvent<HTMLButtonElement>): void => {
|
||||
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return
|
||||
event.preventDefault()
|
||||
if (!open) {
|
||||
show(event.key === 'ArrowDown' ? 'first' : 'last')
|
||||
return
|
||||
}
|
||||
pendingFocus.current = 'current'
|
||||
const index = selectedIndex >= 0 ? selectedIndex : 0
|
||||
itemRefs.current[index]?.focus()
|
||||
}
|
||||
|
||||
const onBlur = (event: FocusEvent<HTMLDivElement>): void => {
|
||||
if (event.relatedTarget instanceof Node && rootRef.current?.contains(event.relatedTarget)) return
|
||||
close()
|
||||
}
|
||||
|
||||
const choose = (target: ModelTarget): void => {
|
||||
if (
|
||||
selection.current?.provider === target.provider
|
||||
&& selection.current.model === target.model
|
||||
) {
|
||||
close(true)
|
||||
return
|
||||
}
|
||||
void selectModel(target).then((accepted) => {
|
||||
if (accepted && rootRef.current !== null) close(true)
|
||||
})
|
||||
}
|
||||
|
||||
const retry = (): void => {
|
||||
void retryModelOperation().then((selected) => {
|
||||
if (selected && rootRef.current !== null) close(true)
|
||||
})
|
||||
}
|
||||
|
||||
const currentProviderKnown = selection.current === null
|
||||
|| selection.groups.some(group => group.id === selection.current?.provider)
|
||||
|| selection.failures.some(failure => failure.id === selection.current?.provider)
|
||||
const label = choices[selectedIndex]?.model.name ?? selection.current?.model ?? '选择模型'
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
className={css.root}
|
||||
onKeyDown={onRootKeyDown}
|
||||
onBlur={onBlur}
|
||||
>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
className={css.trigger}
|
||||
aria-label={`选择模型,当前 ${label}`}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
aria-controls={open ? `${id}-menu` : undefined}
|
||||
title={label}
|
||||
disabled={removed}
|
||||
onClick={() => { open ? close() : show() }}
|
||||
onKeyDown={onTriggerKeyDown}
|
||||
>
|
||||
<span className={css.triggerLabel}>{label}</span>
|
||||
<IconChevronDownOutline14 className={clsx(css.chevron, open && css.chevronOpen)} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
id={`${id}-menu`}
|
||||
className={css.menu}
|
||||
role="menu"
|
||||
aria-label="模型"
|
||||
aria-busy={selection.status === 'loading' || busy}
|
||||
>
|
||||
{selection.status === 'loading' && (
|
||||
<div className={css.status}>正在刷新模型列表…</div>
|
||||
)}
|
||||
{selection.error !== null && (
|
||||
<div className={css.error}>
|
||||
<span>模型操作失败:{selection.error.message}</span>
|
||||
<button type="button" className={css.retry} onClick={retry}>重试</button>
|
||||
</div>
|
||||
)}
|
||||
{selection.failures.map(failure => (
|
||||
<div className={css.warning} key={failure.id}>
|
||||
<span>{failure.name} 加载失败:{failure.message}</span>
|
||||
<button type="button" className={css.retry} onClick={refreshModels}>重试</button>
|
||||
</div>
|
||||
))}
|
||||
{selection.status !== 'loading' && !currentProviderKnown && selection.current !== null && (
|
||||
<div className={css.warning}>
|
||||
当前提供方 {selection.current.provider} 未注册。
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={clsx(css.groups, 'scrollable')}>
|
||||
{selection.groups.map((group) => {
|
||||
const headingId = `${id}-${group.id}`
|
||||
return (
|
||||
<section
|
||||
role="group"
|
||||
aria-labelledby={headingId}
|
||||
className={css.group}
|
||||
key={group.id}
|
||||
>
|
||||
<div className={css.groupTitle} id={headingId}>{group.name}</div>
|
||||
{group.models.map((model) => {
|
||||
const index = choiceIndices.get(JSON.stringify([group.id, model.id])) ?? -1
|
||||
const selected = selection.current?.provider === group.id
|
||||
&& selection.current.model === model.id
|
||||
return (
|
||||
<button
|
||||
ref={(node) => { itemRefs.current[index] = node }}
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
aria-checked={selected}
|
||||
className={clsx(css.option, selected && css.selected)}
|
||||
key={model.id}
|
||||
title={model.name}
|
||||
disabled={busy}
|
||||
onClick={() => { choose({ provider: group.id, model: model.id }) }}
|
||||
>
|
||||
<span className={css.optionCopy}>
|
||||
<span className={css.modelName}>{model.name}</span>
|
||||
{model.description !== undefined && (
|
||||
<span className={css.description}>{model.description}</span>
|
||||
)}
|
||||
{model.unlisted === true && (
|
||||
<span className={css.unlisted}>当前模型 · 未列入目录</span>
|
||||
)}
|
||||
</span>
|
||||
<span className={css.check}>
|
||||
{selected ? <IconCheckOutline16 /> : null}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</section>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{selection.status === 'ready' && choices.length === 0 && (
|
||||
<div className={css.empty}>没有可用的模型。</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
28
packages/client/ui-model-selector/src/client/contract.ts
Normal file
28
packages/client/ui-model-selector/src/client/contract.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Model-selector slot contract: standard session props plus the plain
|
||||
* object-layer actions injected by this package's registration.
|
||||
*/
|
||||
import type { ModelTarget } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
|
||||
/** Plain callbacks contributed by the selector registration. */
|
||||
export interface ModelSelectorInjected {
|
||||
/** Refresh the session's advisory model directory. */
|
||||
refreshModels(): void
|
||||
/**
|
||||
* Retry the directory refresh or exact selection that produced the visible operation error.
|
||||
* @returns Whether a model selection succeeded and the menu should close.
|
||||
*/
|
||||
retryModelOperation(): Promise<boolean>
|
||||
/**
|
||||
* Select a complete provider/model target.
|
||||
* @param target - Target selected from one provider group.
|
||||
* @returns Whether the host accepted the selection.
|
||||
*/
|
||||
selectModel(target: ModelTarget): Promise<boolean>
|
||||
}
|
||||
|
||||
/** Full props of the conversation composer-control occupant. */
|
||||
export type ModelSelectorProps =
|
||||
PropsRuntime<'conversation.composer.control'> & ModelSelectorInjected
|
||||
33
packages/client/ui-model-selector/src/client/index.ts
Normal file
33
packages/client/ui-model-selector/src/client/index.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Browser model-selector plugin: registers one session-scoped occupant in the
|
||||
* conversation composer-control slot. The Session object owns all catalog and
|
||||
* selection state; the component receives only the standard snapshot hook and
|
||||
* injected callbacks.
|
||||
*/
|
||||
import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ModelSelectorInjected } from './contract.ts'
|
||||
import { ModelSelector } from './ModelSelector.tsx'
|
||||
|
||||
export type { ModelSelectorInjected, ModelSelectorProps } from './contract.ts'
|
||||
|
||||
/** Required services; conversation is the slot-declaration ordering edge. */
|
||||
export const inject = ['slots', 'sessions', 'conversation']
|
||||
|
||||
/**
|
||||
* Register the model selector in the resident conversation composer.
|
||||
* @param ctx - Client root context.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
const sessions = ctx.sessions
|
||||
ctx.slots.register({
|
||||
name: 'conversation.composer.control',
|
||||
inject: (sessionId: SessionId): ModelSelectorInjected => {
|
||||
const session = sessions.manager.get(sessionId)
|
||||
return {
|
||||
refreshModels: () => { void session.refreshModels() },
|
||||
retryModelOperation: () => session.retryModelOperation(),
|
||||
selectModel: async target => (await session.selectModel(target)).ok,
|
||||
}
|
||||
},
|
||||
}, ModelSelector)
|
||||
}
|
||||
4
packages/client/ui-model-selector/src/css-modules.d.ts
vendored
Normal file
4
packages/client/ui-model-selector/src/css-modules.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
declare module '*.module.css' {
|
||||
const classes: Record<string, string>
|
||||
export default classes
|
||||
}
|
||||
14
packages/client/ui-model-selector/src/index.ts
Normal file
14
packages/client/ui-model-selector/src/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Web model-selector plugin, node half. Model routing and catalog RPCs belong
|
||||
* to the host runtime, so this package contributes no host registration.
|
||||
*/
|
||||
import type { Context } from 'cordis'
|
||||
|
||||
/** No host services are required. */
|
||||
export const inject: string[] = []
|
||||
|
||||
/**
|
||||
* Empty host half for the browser-only selector feature.
|
||||
* @param _ctx - Host plugin context.
|
||||
*/
|
||||
export function apply(_ctx: Context): void {}
|
||||
30
packages/client/ui-model-selector/src/invariant.ts
Normal file
30
packages/client/ui-model-selector/src/invariant.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-model-selector`.
|
||||
* @module @deepseek-ai/dsh-client-ui-model-selector/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-model-selector'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'client-ui-model-selector-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: the slot registry owns selector registration
|
||||
* lifecycle, and the wire/object-layer tests own model-target consistency.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns The installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Browser-plugin assembly: the selector occupies the conversation-declared
|
||||
* composer-control slot, injects only Session object actions, fails loud when
|
||||
* the slot is undeclared, and unregisters with its plugin fiber.
|
||||
*/
|
||||
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { ModelSelector } from '../src/client/ModelSelector.tsx'
|
||||
import { apply, inject } from '../src/client/index.ts'
|
||||
|
||||
const SID = 'selector-session' as SessionId
|
||||
|
||||
async function bench() {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SlotsService).await()
|
||||
const slots = ctx.get('slots') as SlotsService
|
||||
slots.register({
|
||||
name: 'root',
|
||||
children: {
|
||||
'conversation.composer.control': { kind: 'single', scope: 'session' },
|
||||
},
|
||||
} as never, (_props: { renderSlot?: unknown }) => null)
|
||||
const session = {
|
||||
refreshModels: vi.fn(() => Promise.resolve({ ok: true })),
|
||||
retryModelOperation: vi.fn(() => Promise.resolve(true)),
|
||||
selectModel: vi.fn((target: { provider: string; model: string }) => Promise.resolve({
|
||||
ok: target.model !== 'rejected',
|
||||
value: { selected: target },
|
||||
})),
|
||||
}
|
||||
ctx.provide('sessions', { manager: { get: () => session } })
|
||||
ctx.provide('conversation', {})
|
||||
return { ctx, slots, session }
|
||||
}
|
||||
|
||||
describe('model-selector browser plugin', () => {
|
||||
it('declares its ordering and service dependencies', () => {
|
||||
expect(inject).toEqual(['slots', 'sessions', 'conversation'])
|
||||
})
|
||||
|
||||
it('fails loud when the conversation control slot is not declared', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SlotsService).await()
|
||||
ctx.provide('sessions', { manager: { get: vi.fn() } })
|
||||
ctx.provide('conversation', {})
|
||||
await expect(ctx.plugin({ inject: [...inject], apply }))
|
||||
.rejects.toThrow(/slot "conversation\.composer\.control" is not declared/)
|
||||
})
|
||||
|
||||
it('registers the singleton and injects Session-owned refresh/select actions', async () => {
|
||||
const { ctx, slots, session } = await bench()
|
||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const entries = slots.entries('conversation.composer.control')
|
||||
expect(entries).toHaveLength(1)
|
||||
expect(entries[0]?.component).toBe(ModelSelector)
|
||||
const injected = (entries[0]?.inject as (sessionId: SessionId) => {
|
||||
refreshModels(): void
|
||||
retryModelOperation(): Promise<boolean>
|
||||
selectModel(target: { provider: string; model: string }): Promise<boolean>
|
||||
})(SID)
|
||||
|
||||
injected.refreshModels()
|
||||
expect(session.refreshModels).toHaveBeenCalledTimes(1)
|
||||
await expect(injected.retryModelOperation()).resolves.toBe(true)
|
||||
expect(session.retryModelOperation).toHaveBeenCalledTimes(1)
|
||||
await expect(injected.selectModel({ provider: 'deepseek', model: 'deepseek-chat' }))
|
||||
.resolves.toBe(true)
|
||||
await expect(injected.selectModel({ provider: 'deepseek', model: 'rejected' }))
|
||||
.resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('unregisters the occupant when its plugin fiber is disposed', async () => {
|
||||
const { ctx, slots } = await bench()
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
expect(slots.entries('conversation.composer.control')).toHaveLength(1)
|
||||
await fiber.dispose()
|
||||
expect(slots.entries('conversation.composer.control')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
193
packages/client/ui-model-selector/tests/model-selector.spec.tsx
Normal file
193
packages/client/ui-model-selector/tests/model-selector.spec.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Provider-grouped selector behavior: compact model-only trigger, grouped
|
||||
* radio menu, retry/error states, successful and failed selection, outside
|
||||
* dismissal, and keyboard focus navigation.
|
||||
*/
|
||||
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type {
|
||||
ConversationSnapshot, ModelSelectionSnapshot,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ModelTarget } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { ModelSelectorProps } from '../src/client/contract.ts'
|
||||
import { ModelSelector } from '../src/client/ModelSelector.tsx'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const ready: ModelSelectionSnapshot = {
|
||||
current: { provider: 'deepseek', model: 'deepseek-v4-flash' },
|
||||
groups: [
|
||||
{
|
||||
id: 'deepseek',
|
||||
name: 'DeepSeek',
|
||||
models: [
|
||||
{ id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', description: '快速响应' },
|
||||
{ id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro', description: '复杂任务' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'openai',
|
||||
name: 'OpenAI',
|
||||
models: [{ id: 'gpt-5', name: 'GPT-5' }],
|
||||
},
|
||||
],
|
||||
failures: [],
|
||||
status: 'ready',
|
||||
error: null,
|
||||
}
|
||||
|
||||
function setup(selection: ModelSelectionSnapshot = ready, removed = false) {
|
||||
let current = { modelSelection: selection, removed } as unknown as ConversationSnapshot
|
||||
const useSession = ((selector: (snapshot: ConversationSnapshot) => unknown) =>
|
||||
selector(current)) as ModelSelectorProps['useSession']
|
||||
const refreshModels = vi.fn()
|
||||
const retryModelOperation = vi.fn(() => Promise.resolve(false))
|
||||
const selectModel = vi.fn((_target: ModelTarget) => Promise.resolve(true))
|
||||
const props: ModelSelectorProps = {
|
||||
sessionId: 'selector-session' as never,
|
||||
useSession,
|
||||
useSessions: ((selector: (snapshot: never) => unknown) =>
|
||||
selector({} as never)) as ModelSelectorProps['useSessions'],
|
||||
refreshModels,
|
||||
retryModelOperation,
|
||||
selectModel,
|
||||
}
|
||||
const view = render(<ModelSelector {...props} />)
|
||||
return {
|
||||
view,
|
||||
refreshModels,
|
||||
retryModelOperation,
|
||||
selectModel,
|
||||
update(next: ModelSelectionSnapshot, nextRemoved = removed) {
|
||||
current = { modelSelection: next, removed: nextRemoved } as unknown as ConversationSnapshot
|
||||
view.rerender(<ModelSelector {...props} />)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function trigger(): HTMLButtonElement {
|
||||
return screen.getByRole('button', { name: /选择模型,当前/ })
|
||||
}
|
||||
|
||||
describe('model selector', () => {
|
||||
it('shows the catalog name, opens upward into provider groups, and marks the current radio item', () => {
|
||||
const { refreshModels } = setup()
|
||||
expect(refreshModels).toHaveBeenCalledTimes(1)
|
||||
expect(trigger().textContent).toBe('DeepSeek-V4-Flash')
|
||||
expect(trigger().textContent).not.toContain('deepseek/')
|
||||
expect(trigger().title).toBe('DeepSeek-V4-Flash')
|
||||
|
||||
fireEvent.click(trigger())
|
||||
expect(refreshModels).toHaveBeenCalledTimes(2)
|
||||
expect(screen.getByRole('menu', { name: '模型' })).toBeTruthy()
|
||||
expect(screen.getAllByRole('group')).toHaveLength(2)
|
||||
expect(screen.getByText('DeepSeek')).toBeTruthy()
|
||||
expect(screen.getByText('OpenAI')).toBeTruthy()
|
||||
const rows = screen.getAllByRole('menuitemradio')
|
||||
expect(rows.map(row => row.querySelector('[class*="modelName"]')?.textContent))
|
||||
.toEqual(['DeepSeek-V4-Flash', 'DeepSeek-V4-Pro', 'GPT-5'])
|
||||
expect(rows[0]?.getAttribute('aria-checked')).toBe('true')
|
||||
expect(rows[1]?.getAttribute('aria-checked')).toBe('false')
|
||||
expect(rows.some(row => row.textContent?.includes('deepseek/deepseek'))).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the menu open on failure, closes after success, and closes current selection without an RPC', async () => {
|
||||
const { selectModel } = setup()
|
||||
selectModel.mockResolvedValueOnce(false)
|
||||
fireEvent.click(trigger())
|
||||
fireEvent.click(screen.getByRole('menuitemradio', { name: /DeepSeek-V4-Pro/ }))
|
||||
await waitFor(() => { expect(selectModel).toHaveBeenCalledWith({ provider: 'deepseek', model: 'deepseek-v4-pro' }) })
|
||||
expect(screen.getByRole('menu')).toBeTruthy()
|
||||
|
||||
selectModel.mockResolvedValueOnce(true)
|
||||
fireEvent.click(screen.getByRole('menuitemradio', { name: /GPT-5/ }))
|
||||
await waitFor(() => { expect(screen.queryByRole('menu')).toBeNull() })
|
||||
|
||||
fireEvent.click(trigger())
|
||||
fireEvent.click(screen.getByRole('menuitemradio', { name: /DeepSeek-V4-Flash/ }))
|
||||
await waitFor(() => { expect(screen.queryByRole('menu')).toBeNull() })
|
||||
expect(selectModel).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('renders loading, empty, partial-provider, operation-error, and unavailable-current states with retries', () => {
|
||||
const error = { code: 'internal' as const, message: 'wire down', details: {} }
|
||||
const { refreshModels, retryModelOperation, update } = setup({
|
||||
...ready,
|
||||
current: { provider: 'missing', model: 'private-preview-with-a-very-long-name' },
|
||||
failures: [{ id: 'offline', name: 'Offline', message: 'catalog down' }],
|
||||
status: 'error',
|
||||
error,
|
||||
})
|
||||
expect(trigger().textContent).toBe('private-preview-with-a-very-long-name')
|
||||
fireEvent.click(trigger())
|
||||
expect(screen.getByText(/模型操作失败:wire down/)).toBeTruthy()
|
||||
expect(screen.getByText(/Offline 加载失败:catalog down/)).toBeTruthy()
|
||||
expect(screen.getByText(/当前提供方 missing 未注册/)).toBeTruthy()
|
||||
fireEvent.click(screen.getAllByRole('button', { name: '重试' })[0]!)
|
||||
expect(retryModelOperation).toHaveBeenCalledTimes(1)
|
||||
fireEvent.click(screen.getAllByRole('button', { name: '重试' })[1]!)
|
||||
expect(refreshModels).toHaveBeenCalledTimes(3)
|
||||
|
||||
update({ current: null, groups: [], failures: [], status: 'loading', error: null })
|
||||
expect(screen.getByText('正在刷新模型列表…')).toBeTruthy()
|
||||
update({ current: null, groups: [], failures: [], status: 'ready', error: null })
|
||||
expect(screen.getByText('没有可用的模型。')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('closes after retrying a failed selection successfully', async () => {
|
||||
const { retryModelOperation } = setup({
|
||||
...ready,
|
||||
status: 'error',
|
||||
error: {
|
||||
code: 'model-unavailable',
|
||||
message: 'temporary failure',
|
||||
details: { provider: 'deepseek', model: 'deepseek-v4-pro' },
|
||||
},
|
||||
})
|
||||
retryModelOperation.mockResolvedValueOnce(true)
|
||||
fireEvent.click(trigger())
|
||||
fireEvent.click(screen.getByRole('button', { name: '重试' }))
|
||||
await waitFor(() => { expect(screen.queryByRole('menu')).toBeNull() })
|
||||
})
|
||||
|
||||
it('supports Arrow/Home/End/Escape navigation and restores focus to the trigger', async () => {
|
||||
setup()
|
||||
fireEvent.keyDown(trigger(), { key: 'ArrowDown' })
|
||||
await waitFor(() => {
|
||||
expect(document.activeElement).toBe(screen.getAllByRole('menuitemradio')[0])
|
||||
})
|
||||
fireEvent.keyDown(document.activeElement as HTMLElement, { key: 'ArrowDown' })
|
||||
expect(document.activeElement).toBe(screen.getAllByRole('menuitemradio')[1])
|
||||
fireEvent.keyDown(document.activeElement as HTMLElement, { key: 'End' })
|
||||
expect(document.activeElement).toBe(screen.getAllByRole('menuitemradio')[2])
|
||||
fireEvent.keyDown(document.activeElement as HTMLElement, { key: 'Home' })
|
||||
expect(document.activeElement).toBe(screen.getAllByRole('menuitemradio')[0])
|
||||
fireEvent.keyDown(document.activeElement as HTMLElement, { key: 'ArrowUp' })
|
||||
expect(document.activeElement).toBe(screen.getAllByRole('menuitemradio')[2])
|
||||
fireEvent.keyDown(document.activeElement as HTMLElement, { key: 'Escape' })
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
expect(document.activeElement).toBe(trigger())
|
||||
})
|
||||
})
|
||||
|
||||
it('opens ArrowUp on the last option, disables rows while selecting, and dismisses outside', async () => {
|
||||
const { update } = setup()
|
||||
fireEvent.keyDown(trigger(), { key: 'ArrowUp' })
|
||||
await waitFor(() => {
|
||||
expect(document.activeElement).toBe(screen.getAllByRole('menuitemradio')[2])
|
||||
})
|
||||
update({ ...ready, status: 'selecting' })
|
||||
expect(screen.getByRole('menu').getAttribute('aria-busy')).toBe('true')
|
||||
expect(screen.getAllByRole('menuitemradio').every(row => (row as HTMLButtonElement).disabled)).toBe(true)
|
||||
fireEvent.mouseDown(document.body)
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
})
|
||||
|
||||
it('disables the trigger only when the session is removed', () => {
|
||||
setup(ready, true)
|
||||
expect(trigger().disabled).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,28 @@
|
||||
/** Host-half placeholder and package invariant companion. */
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { apply as nodeApply } from '../src/index.ts'
|
||||
import * as invariant from '../src/invariant.ts'
|
||||
|
||||
describe('model-selector node half and invariant companion', () => {
|
||||
it('keeps the host half as an intentional no-op', () => {
|
||||
nodeApply(undefined as never)
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
|
||||
it('registers the package-owned empty invariant installer', async () => {
|
||||
const register = vi.fn().mockReturnValue(() => {})
|
||||
const ctx = { invariants: { register } } as never
|
||||
const dispose = await invariant.apply(ctx)
|
||||
expect(invariant.name).toBe('client-ui-model-selector-invariant')
|
||||
expect(invariant.inject).toEqual(['invariants'])
|
||||
expect(register).toHaveBeenCalledWith(
|
||||
'@deepseek-ai/dsh-client-ui-model-selector',
|
||||
expect.any(Function),
|
||||
)
|
||||
expect(() => {
|
||||
(register.mock.calls[0]![1] as (inner: never) => void)(undefined as never)
|
||||
}).not.toThrow()
|
||||
expect(dispose).toBeTypeOf('function')
|
||||
})
|
||||
})
|
||||
33
packages/client/ui-model-selector/tsconfig.json
Normal file
33
packages/client/ui-model-selector/tsconfig.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.client.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../connection"
|
||||
},
|
||||
{
|
||||
"path": "../runtime"
|
||||
},
|
||||
{
|
||||
"path": "../ui-conversation"
|
||||
},
|
||||
{
|
||||
"path": "../ui-primitives"
|
||||
},
|
||||
{
|
||||
"path": "../ui-slots"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
3
packages/client/ui-model-selector/tsdown.config.ts
Normal file
3
packages/client/ui-model-selector/tsdown.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { clientBundle } from '../tsdown.client.ts'
|
||||
|
||||
export default clientBundle('@deepseek-ai/dsh-client-ui-model-selector', ['lib/types/index.js', 'lib/types/invariant.js'])
|
||||
@@ -10,6 +10,8 @@ The layering/protocol decisions are recorded in the [GUI layering and RPC protoc
|
||||
|
||||
The mux stream projects the latest log-backed title as a validated `session/title` control frame after each attached-session subscription baseline and immediately after the corresponding live raw title event. This projection does not add titles to `session.list`; cold sessions remain metadata-only there until opening or resuming attaches their logs.
|
||||
|
||||
Session model routing is a session-domain contract. `session.history` returns the selected `modelTarget`, `session.models` returns that target with provider-grouped advisory model metadata and provider-local lookup failures, and `session.selectModel` replaces the target selected for the next prompt-assembly boundary. Catalog membership is not validation: a registered provider may accept an unlisted model, while an unregistered provider returns `model-unavailable`.
|
||||
|
||||
## Carrier layer (`/client` + root)
|
||||
|
||||
`AbstractApiClient` holds every protocol invariant — rpcId minting, envelope wrap/unwrap, zod parsing, SSE frame decoding, unary timeout, microtask-batched envelope observation (`subscribeEnvelopes`) — while platform subclasses supply only the `doFetch` transport aspect. `InProcessApiClient` over `toFetchHandler(api)` is the isomorphic point: the full wire serialization/validation path with no network, used by `dsh -p` headless.
|
||||
|
||||
@@ -19,7 +19,10 @@ export interface ApiProxy {
|
||||
}
|
||||
|
||||
// ---- Domain interfaces and payload entities ----
|
||||
export type { HistoryEntry, SessionsApi, SessionSummary } from './sessions.ts'
|
||||
export type {
|
||||
HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelTarget,
|
||||
SessionModels, SessionsApi, SessionSummary,
|
||||
} from './sessions.ts'
|
||||
export type { HostApi } from './host.ts'
|
||||
export type { EventsApi, MuxFrame, HostFrame, ToolCallView, ToolEventView, ToolResultView } from './events.ts'
|
||||
export type { ApprovalResponsePayload } from './approvals.ts'
|
||||
|
||||
@@ -13,6 +13,8 @@ export interface RpcMethodMap {
|
||||
'session.list': SessionsApi['list']
|
||||
'session.create': SessionsApi['create']
|
||||
'session.history': SessionsApi['history']
|
||||
'session.models': SessionsApi['models']
|
||||
'session.selectModel': SessionsApi['selectModel']
|
||||
'session.prompt': SessionsApi['prompt']
|
||||
'session.cancel': SessionsApi['cancel']
|
||||
'host.describe': HostApi['describe']
|
||||
|
||||
@@ -35,6 +35,7 @@ export const rpcErrorSchema: z.ZodType<RpcError> = z.discriminatedUnion('code',
|
||||
z.object({ code: z.literal('bad-request'), message: z.string(), details: z.object({ issues: z.array(z.custom<ZodIssue>()) }) }),
|
||||
z.object({ code: z.literal('cancelled'), message: z.string(), details: z.object({}) }),
|
||||
z.object({ code: z.literal('session-not-found'), message: z.string(), details: z.object({ sessionId: z.string() }) }),
|
||||
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<RpcError>
|
||||
|
||||
@@ -32,6 +32,7 @@ export interface RpcErrorDetailsMap {
|
||||
'bad-request': { issues: ZodIssue[] }
|
||||
'cancelled': {}
|
||||
'session-not-found': { sessionId: SessionId }
|
||||
'model-unavailable': { provider: string; model: string }
|
||||
'agent-busy': { reason: string }
|
||||
'internal': {}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,10 @@ import { z } from 'zod'
|
||||
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { RequestPayload, ResponseValue } from './rpc-map.ts'
|
||||
import type { Wire } from './rpc.schema.ts'
|
||||
import type { HistoryEntry, SessionSummary } from './sessions.ts'
|
||||
import type {
|
||||
HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelTarget,
|
||||
SessionSummary,
|
||||
} from './sessions.ts'
|
||||
import type { ToolEventView } from './events.ts'
|
||||
|
||||
/** SessionId: one brand cast after shape validation (the only cast point in this domain). */
|
||||
@@ -61,6 +64,34 @@ export const sessionHistoryRequestSchema = z.object({
|
||||
maxMessages: z.number().int().positive().optional(),
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'session.history'>>>
|
||||
|
||||
/** Complete provider/model target. */
|
||||
export const modelTargetSchema = z.object({
|
||||
provider: z.string().min(1),
|
||||
model: z.string().min(1),
|
||||
}) satisfies z.ZodType<Wire<ModelTarget>>
|
||||
|
||||
/** One advisory model entry inside a provider group. */
|
||||
export const modelCatalogModelSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
unlisted: z.literal(true).optional(),
|
||||
}) satisfies z.ZodType<Wire<ModelCatalogModel>>
|
||||
|
||||
/** One successfully loaded provider group. */
|
||||
export const modelProviderGroupSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
models: z.array(modelCatalogModelSchema),
|
||||
}) satisfies z.ZodType<Wire<ModelProviderGroup>>
|
||||
|
||||
/** One provider-local catalog failure. */
|
||||
export const modelCatalogFailureSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
message: z.string(),
|
||||
}) satisfies z.ZodType<Wire<ModelCatalogFailure>>
|
||||
|
||||
/**
|
||||
* ToolEventView passthrough: lock only the `for` discriminant and the presence
|
||||
* of a card-tagged `view` object. The view interior is a host-computed product
|
||||
@@ -82,8 +113,33 @@ export const historyEntrySchema = z.object({
|
||||
export const sessionHistoryValueSchema = z.object({
|
||||
events: z.array(historyEntrySchema),
|
||||
hasMore: z.boolean(),
|
||||
modelTarget: modelTargetSchema,
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'session.history'>>>
|
||||
|
||||
/** session.models request payload. */
|
||||
export const sessionModelsRequestSchema = z.object({
|
||||
sessionId: sessionIdSchema,
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'session.models'>>>
|
||||
|
||||
/** session.models response value. */
|
||||
export const sessionModelsValueSchema = z.object({
|
||||
current: modelTargetSchema,
|
||||
groups: z.array(modelProviderGroupSchema),
|
||||
failures: z.array(modelCatalogFailureSchema),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'session.models'>>>
|
||||
|
||||
/** session.selectModel request payload. */
|
||||
export const sessionSelectModelRequestSchema = z.object({
|
||||
sessionId: sessionIdSchema,
|
||||
provider: z.string().min(1),
|
||||
model: z.string().min(1),
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'session.selectModel'>>>
|
||||
|
||||
/** session.selectModel response value. */
|
||||
export const sessionSelectModelValueSchema = z.object({
|
||||
selected: modelTargetSchema,
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'session.selectModel'>>>
|
||||
|
||||
/** ContentBlock passthrough: core is merge-extensible — the type discriminant envelope is strict, the rest stays wide. */
|
||||
export const contentBlockSchema = z.looseObject({ type: z.string() })
|
||||
|
||||
|
||||
@@ -31,6 +31,56 @@ export interface HistoryEntry {
|
||||
view?: ToolEventView
|
||||
}
|
||||
|
||||
/** Complete provider/model route selected for one session. */
|
||||
export interface ModelTarget {
|
||||
/** Registered provider route. */
|
||||
provider: string
|
||||
/** Provider-owned model id. */
|
||||
model: string
|
||||
}
|
||||
|
||||
/** One model displayed inside its provider group. */
|
||||
export interface ModelCatalogModel {
|
||||
/** Provider-owned model id. */
|
||||
id: string
|
||||
/** Provider-supplied display name. */
|
||||
name: string
|
||||
/** Optional provider-supplied description. */
|
||||
description?: string
|
||||
/** The current model was inserted because the advisory catalog omitted it. */
|
||||
unlisted?: true
|
||||
}
|
||||
|
||||
/** One provider and the models it advertised successfully. */
|
||||
export interface ModelProviderGroup {
|
||||
/** Provider route id used for requests. */
|
||||
id: string
|
||||
/** Provider display name. */
|
||||
name: string
|
||||
/** Models in provider-preferred order. */
|
||||
models: ModelCatalogModel[]
|
||||
}
|
||||
|
||||
/** A provider whose asynchronous catalog lookup failed. */
|
||||
export interface ModelCatalogFailure {
|
||||
/** Provider route id. */
|
||||
id: string
|
||||
/** Provider display name. */
|
||||
name: string
|
||||
/** Lookup failure diagnostic. */
|
||||
message: string
|
||||
}
|
||||
|
||||
/** Detached model-directory snapshot for one session. */
|
||||
export interface SessionModels {
|
||||
/** Target selected for the session's next assembled step. */
|
||||
current: ModelTarget
|
||||
/** Successfully loaded provider groups. */
|
||||
groups: ModelProviderGroup[]
|
||||
/** Provider-local failures; successful groups remain usable. */
|
||||
failures: ModelCatalogFailure[]
|
||||
}
|
||||
|
||||
/** Session list entry (v1 builds no index: list does readdir+stat). */
|
||||
export interface SessionSummary {
|
||||
sessionId: SessionId
|
||||
@@ -62,7 +112,17 @@ export interface SessionsApi {
|
||||
* rebuilds the surface from the events with the shared fold.
|
||||
*/
|
||||
history(request: RpcRequest<{ sessionId: SessionId; beforeSeq?: number; maxMessages?: number }>):
|
||||
Promise<RpcResponse<{ events: HistoryEntry[]; hasMore: boolean }>>
|
||||
Promise<RpcResponse<{ events: HistoryEntry[]; hasMore: boolean; modelTarget: ModelTarget }>>
|
||||
|
||||
/** Reads a fresh advisory model directory for this session. Provider lookups run independently. */
|
||||
models(request: RpcRequest<{ sessionId: SessionId }>): Promise<RpcResponse<SessionModels>>
|
||||
|
||||
/**
|
||||
* Selects the complete route for this session. The registered provider is
|
||||
* validated, while model catalog membership remains advisory.
|
||||
*/
|
||||
selectModel(request: RpcRequest<{ sessionId: SessionId; provider: string; model: string }>):
|
||||
Promise<RpcResponse<{ selected: ModelTarget }>>
|
||||
|
||||
/** Sends a message. content is core's ContentBlock[] verbatim; mode maps 1:1 — queue→send, steer→steer. */
|
||||
prompt(request: RpcRequest<{ sessionId: SessionId; mode: 'queue' | 'steer'; content: ContentBlock[] }>):
|
||||
|
||||
@@ -19,7 +19,9 @@ import {
|
||||
sessionCreateValueSchema,
|
||||
sessionHistoryValueSchema,
|
||||
sessionListValueSchema,
|
||||
sessionModelsValueSchema,
|
||||
sessionPromptValueSchema,
|
||||
sessionSelectModelValueSchema,
|
||||
} from '../api/sessions.schema.ts'
|
||||
|
||||
/**
|
||||
@@ -42,6 +44,8 @@ export interface IApiClient {
|
||||
list(payload: RequestPayload<'session.list'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.list'>>>
|
||||
create(payload: RequestPayload<'session.create'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.create'>>>
|
||||
history(payload: RequestPayload<'session.history'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.history'>>>
|
||||
models(payload: RequestPayload<'session.models'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.models'>>>
|
||||
selectModel(payload: RequestPayload<'session.selectModel'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.selectModel'>>>
|
||||
prompt(payload: RequestPayload<'session.prompt'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.prompt'>>>
|
||||
cancel(payload: RequestPayload<'session.cancel'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.cancel'>>>
|
||||
}
|
||||
@@ -64,6 +68,8 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseV
|
||||
'session.list': sessionListValueSchema,
|
||||
'session.create': sessionCreateValueSchema,
|
||||
'session.history': sessionHistoryValueSchema,
|
||||
'session.models': sessionModelsValueSchema,
|
||||
'session.selectModel': sessionSelectModelValueSchema,
|
||||
'session.prompt': sessionPromptValueSchema,
|
||||
'session.cancel': sessionCancelValueSchema,
|
||||
'host.describe': hostDescribeValueSchema,
|
||||
@@ -245,6 +251,8 @@ export abstract class AbstractApiClient implements IApiClient {
|
||||
list: (payload, signal) => this.callUnary('session.list', payload, signal),
|
||||
create: (payload, signal) => this.callUnary('session.create', payload, signal),
|
||||
history: (payload, signal) => this.callUnary('session.history', payload, signal),
|
||||
models: (payload, signal) => this.callUnary('session.models', payload, signal),
|
||||
selectModel: (payload, signal) => this.callUnary('session.selectModel', payload, signal),
|
||||
prompt: (payload, signal) => this.callUnary('session.prompt', payload, signal),
|
||||
cancel: (payload, signal) => this.callUnary('session.cancel', payload, signal),
|
||||
}
|
||||
|
||||
@@ -19,7 +19,9 @@ import {
|
||||
sessionCreateRequestSchema,
|
||||
sessionHistoryRequestSchema,
|
||||
sessionListRequestSchema,
|
||||
sessionModelsRequestSchema,
|
||||
sessionPromptRequestSchema,
|
||||
sessionSelectModelRequestSchema,
|
||||
} from '../api/sessions.schema.ts'
|
||||
import { hostDescribeRequestSchema } from '../api/host.schema.ts'
|
||||
|
||||
@@ -41,6 +43,8 @@ const UNARY_ROUTES: UnaryRoutes = {
|
||||
'session.list': { schema: sessionListRequestSchema, invoke: (api, r) => api.sessions.list(r) },
|
||||
'session.create': { schema: sessionCreateRequestSchema, invoke: (api, r) => api.sessions.create(r) },
|
||||
'session.history': { schema: sessionHistoryRequestSchema, invoke: (api, r) => api.sessions.history(r) },
|
||||
'session.models': { schema: sessionModelsRequestSchema, invoke: (api, r) => api.sessions.models(r) },
|
||||
'session.selectModel': { schema: sessionSelectModelRequestSchema, invoke: (api, r) => api.sessions.selectModel(r) },
|
||||
'session.prompt': { schema: sessionPromptRequestSchema, invoke: (api, r) => api.sessions.prompt(r) },
|
||||
'session.cancel': { schema: sessionCancelRequestSchema, invoke: (api, r) => api.sessions.cancel(r) },
|
||||
'host.describe': { schema: hostDescribeRequestSchema, invoke: (api, r) => api.host.describe(r) },
|
||||
|
||||
@@ -28,7 +28,19 @@ function scriptedApi(overrides: {
|
||||
sessions: {
|
||||
list: r => ok(r, { items: [] }),
|
||||
create: r => ok(r, { sessionId: sid('s-new') }),
|
||||
history: r => ok(r, { events: [], hasMore: false }),
|
||||
history: r => ok(r, {
|
||||
events: [],
|
||||
hasMore: false,
|
||||
modelTarget: { provider: 'deepseek', model: 'deepseek-v4-flash' },
|
||||
}),
|
||||
models: r => ok(r, {
|
||||
current: { provider: 'deepseek', model: 'deepseek-v4-flash' },
|
||||
groups: [],
|
||||
failures: [],
|
||||
}),
|
||||
selectModel: r => ok(r, {
|
||||
selected: { provider: r.payload.provider, model: r.payload.model },
|
||||
}),
|
||||
prompt: r => ok(r, { accepted: true as const }),
|
||||
cancel: r => ok(r, { accepted: true as const }),
|
||||
...overrides.sessions,
|
||||
|
||||
@@ -30,6 +30,28 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
|
||||
result: { ok: false, error: { code: 'session-not-found', message: 'nope', details: { sessionId: request.payload.sessionId } } },
|
||||
}
|
||||
},
|
||||
async models(request) {
|
||||
return {
|
||||
rpcId: request.rpcId,
|
||||
result: {
|
||||
ok: true,
|
||||
value: {
|
||||
current: { provider: 'deepseek', model: 'deepseek-v4-flash' },
|
||||
groups: [],
|
||||
failures: [],
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
async selectModel(request) {
|
||||
return {
|
||||
rpcId: request.rpcId,
|
||||
result: {
|
||||
ok: true,
|
||||
value: { selected: { provider: request.payload.provider, model: request.payload.model } },
|
||||
},
|
||||
}
|
||||
},
|
||||
async prompt(request) {
|
||||
return { rpcId: request.rpcId, result: { ok: true, value: { accepted: true as const } } }
|
||||
},
|
||||
@@ -78,6 +100,12 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
|
||||
it('covers create/prompt/cancel/describe passthrough', async () => {
|
||||
const c = client()
|
||||
expect((await c.sessions.create({})).result.ok).toBe(true)
|
||||
expect((await c.sessions.models({ sessionId: 's' as never })).result.ok).toBe(true)
|
||||
expect((await c.sessions.selectModel({
|
||||
sessionId: 's' as never,
|
||||
provider: 'deepseek',
|
||||
model: 'deepseek-v4-flash',
|
||||
})).result.ok).toBe(true)
|
||||
expect((await c.sessions.prompt({ sessionId: 's' as never, mode: 'queue', content: [{ type: 'text', text: 'x' }] })).result.ok).toBe(true)
|
||||
expect((await c.sessions.cancel({ sessionId: 's' as never })).result.ok).toBe(true)
|
||||
expect((await c.host.describe({})).result.ok).toBe(true)
|
||||
|
||||
@@ -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 { hostFrameSchema, muxFrameSchema, askUserQuestionItemSchema } from '../src/api/events.schema.ts'
|
||||
@@ -31,6 +32,11 @@ describe('rpcErrorSchema', () => {
|
||||
expect(rpcErrorSchema.parse({ code: 'bad-request', message: 'm', details: { issues: [] } }).code).toBe('bad-request')
|
||||
expect(rpcErrorSchema.parse({ code: 'cancelled', message: 'm', details: {} }).code).toBe('cancelled')
|
||||
expect(rpcErrorSchema.parse({ code: 'session-not-found', message: 'm', details: { sessionId: 's' } }).code).toBe('session-not-found')
|
||||
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')
|
||||
})
|
||||
@@ -99,7 +105,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()
|
||||
|
||||
@@ -20,6 +20,8 @@ Which plugins mount and with what defaults is decided only here — shells must
|
||||
|
||||
Unary methods take the narrow `RpcRequest<P>` and echo `request.rpcId`; a prompt's rpcId rides `MessageSource` into the `user/message` event so clients can promote optimistic echoes. `history`/`prompt` on a cold session implicitly resume it, deduplicating concurrent calls through an in-flight table; `history` paginates backwards on message boundaries (never mid-message). The mux stream replays a `session/subscribed` baseline per attached session and every still-pending question with its original rpcId. Question responses, including blank per-item answers, are validated against the owning session and exact request before an atomic first-wins claim; answer, whole-request cancellation, owner abort, and provider disposal broadcast `question/resolved`. The host stream carries session lifecycle, running flips, and `agent/error` as the only outlet for live failures with no turn position.
|
||||
|
||||
The Web front door installs the shared `installAgentLlmTarget` coupling for every created or resumed agent. A session starts from the latest logged `request/header` route when one exists, otherwise from the Host default. `session.models` discovers every registered provider concurrently, keeps successful groups when another provider fails, and inserts the current target as an unlisted row when its provider omits it. A selection changes the mutable session target immediately; prompt assembly snapshots it atomically with request routing, so a change during a running step first applies to the next assembled step.
|
||||
|
||||
## Model Experience
|
||||
|
||||
Indirectly, through the non-blocking first-message title request owned by [`dsh-session-title-llm`](../../session-title/session-title-llm/README.md) when `sessionTitleLlm` is enabled, the provider/model defaults injected into created and resumed agents, the other model-facing plugins `bootHost` mounts, and the logged [workspace-instruction prefix](../../context/workspace-context/README.md#prompt-shape) when `workspaceContext` is enabled.
|
||||
|
||||
@@ -6,13 +6,15 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { stat } from 'node:fs/promises'
|
||||
import type { Context } from 'cordis'
|
||||
import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
|
||||
import { installAgentLlmTarget } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent, AgentLlmTargetRef, 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'
|
||||
import { foldSessionTitle } from '@deepseek-ai/dsh-session-title'
|
||||
import type {
|
||||
ApiProxy, HistoryEntry, HostFrame, MuxFrame, QuestionResponsePayload, SessionSummary, ToolEventView,
|
||||
ApiProxy, HistoryEntry, HostFrame, ModelCatalogFailure, ModelProviderGroup, ModelTarget,
|
||||
MuxFrame, QuestionResponsePayload, SessionSummary, ToolEventView,
|
||||
} from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import { questionResponsePayloadSchema } from '@deepseek-ai/dsh-host-apiproxy/api/questions.schema'
|
||||
import type { ClientResponse, RpcError, RpcReceipt, RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
||||
@@ -280,11 +282,36 @@ class SessionNotFound extends Error {}
|
||||
*/
|
||||
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<Agent, WebLlmTargetRef>()
|
||||
/** Implicit resume of cold sessions, deduplicating concurrent calls (follows the jsonrpc sessionCreations precedent). */
|
||||
const resumes = new Map<SessionId, Promise<Agent>>()
|
||||
const pendingQuestions = new Map<RpcId, PendingQuestion>()
|
||||
const muxQueues = new Set<FrameQueue<RpcRequest<MuxFrame>>>()
|
||||
|
||||
/** Install or return the session-local target that prompt assembly snapshots. */
|
||||
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)
|
||||
@@ -363,7 +390,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)
|
||||
@@ -409,7 +440,12 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
// one, the default project is the host-level default (the host process
|
||||
// working directory unless boot overrides it).
|
||||
const cwd = request.payload.cwd ?? defaults.cwd
|
||||
const handle = await ctx.agents.create({ sessionId, agentOptions, meta: { cwd } })
|
||||
const handle = await ctx.agents.create({
|
||||
sessionId,
|
||||
agentOptions,
|
||||
meta: { cwd },
|
||||
setup: installTarget,
|
||||
})
|
||||
return ok(request, { sessionId: handle.agent.id })
|
||||
},
|
||||
|
||||
@@ -425,7 +461,71 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
const view = viewFor(ctx, event, callId => backscanArgs(page.events, callId))
|
||||
return { event, ...view === undefined ? {} : { view } }
|
||||
})
|
||||
return ok(request, { events: entries, hasMore: page.hasMore })
|
||||
const current = targetFor(found.agent).current
|
||||
return ok(request, { events: entries, hasMore: page.hasMore, modelTarget: { ...current } })
|
||||
},
|
||||
|
||||
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) {
|
||||
|
||||
165
packages/host/runtime/tests/api-proxy-models.spec.ts
Normal file
165
packages/host/runtime/tests/api-proxy-models.spec.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* 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<P>(payload: P): RpcRequest<P> {
|
||||
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<readonly LlmModelInfo[]> {
|
||||
return this.models instanceof Error
|
||||
? Promise.reject(this.models)
|
||||
: Promise.resolve(this.models)
|
||||
}
|
||||
|
||||
override async *stream(_options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
// 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<T>(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' })
|
||||
|
||||
const history = expectValue(await api.sessions.history(request({ sessionId })))
|
||||
expect(history.modelTarget).toEqual({ provider: 'deepseek', model: 'private-preview' })
|
||||
|
||||
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' })
|
||||
const seed: LlmCallConfig = { provider: 'seed', model: 'seed', temperature: 0.2 }
|
||||
const signal = new AbortController().signal
|
||||
|
||||
expect(expectValue(await api.sessions.history(request({ sessionId }))).modelTarget)
|
||||
.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.history(request({ sessionId }))).modelTarget)
|
||||
.toEqual({ provider: 'deepseek', model: 'private-preview' })
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
@@ -20,13 +20,13 @@ The package root exposes the Cordis plugin contract and `DeepSeekAdapter`; wire
|
||||
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
|
||||
```
|
||||
|
||||
The plugin registers the single provider route `deepseek`. A request selects it with `provider: deepseek`; its `model` is passed through as the wire `model` string, so changing DeepSeek models does not require lifecycle-time registration. Omitting `models` advertises `deepseek-v4-flash` and `deepseek-v4-pro`, each with a 128,000-token context window; an explicit list replaces those defaults, while `models: []` advertises none. Catalog entries are exposed through `ctx.llm.listModels('deepseek')` for clients such as ACP editors, but remain advisory: unlisted model ids still pass through unchanged. An omitted entry name defaults to its id.
|
||||
The plugin registers the single provider route `deepseek`. A request selects it with `provider: deepseek`; its `model` is passed through as the wire `model` string, so changing DeepSeek models does not require lifecycle-time registration. Omitting `models` advertises `deepseek-v4-flash` as `DeepSeek-V4-Flash` and `deepseek-v4-pro` as `DeepSeek-V4-Pro`, each with a 256,000-token context window; an explicit list replaces those defaults, while `models: []` advertises none. Catalog entries are exposed through `ctx.llm.listModels('deepseek')` for clients such as ACP editors and the Web selector, but remain advisory: unlisted model ids still pass through unchanged. An omitted entry name defaults to its id.
|
||||
|
||||
`contextWindow` is optional per configured model and is not exposed through the advisory catalog. `ctx.llm.resolveModelContext('deepseek', model)` returns an exact model value first, then `defaultContextWindow` for an entry without capacity or an unlisted pass-through id. When neither value exists it returns `undefined` without invalidating routing. Pressure-sensitive plugins therefore get deployment-owned capacity without treating the model selector as authoritative. Registering another adapter for `deepseek` throws `LlmError('DUPLICATE_ADAPTER')`.
|
||||
|
||||
|
||||
@@ -21,8 +21,8 @@ export const name = 'llm-deepseek'
|
||||
export const inject = ['llm']
|
||||
|
||||
const DEFAULT_MODELS: DeepSeekCatalogModel[] = [
|
||||
{ id: 'deepseek-v4-flash', contextWindow: 128_000 },
|
||||
{ id: 'deepseek-v4-pro', contextWindow: 128_000 },
|
||||
{ id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', contextWindow: 256_000 },
|
||||
{ id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro', contextWindow: 256_000 },
|
||||
]
|
||||
|
||||
/**
|
||||
|
||||
@@ -528,11 +528,11 @@ describe('plugin registration and config', () => {
|
||||
await ctx.plugin(LlmDeepSeek, { apiKey: 'k', baseURL: 'http://127.0.0.1:1' })
|
||||
expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek', name: 'DeepSeek' }])
|
||||
await expect(ctx.llm.listModels('deepseek')).resolves.toEqual([
|
||||
{ provider: 'deepseek', id: 'deepseek-v4-flash', name: 'deepseek-v4-flash' },
|
||||
{ provider: 'deepseek', id: 'deepseek-v4-pro', name: 'deepseek-v4-pro' },
|
||||
{ provider: 'deepseek', id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash' },
|
||||
{ provider: 'deepseek', id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro' },
|
||||
])
|
||||
await expect(ctx.llm.resolveModelContext('deepseek', 'deepseek-v4-flash'))
|
||||
.resolves.toEqual({ contextWindow: 128_000 })
|
||||
.resolves.toEqual({ contextWindow: 256_000 })
|
||||
})
|
||||
|
||||
it('uses the default model catalog when apply is called directly', async () => {
|
||||
@@ -540,8 +540,8 @@ describe('plugin registration and config', () => {
|
||||
await ctx.plugin(LlmService)
|
||||
LlmDeepSeek.apply(ctx, { apiKey: 'k', baseURL: 'http://127.0.0.1:1' })
|
||||
await expect(ctx.llm.listModels('deepseek')).resolves.toEqual([
|
||||
{ provider: 'deepseek', id: 'deepseek-v4-flash', name: 'deepseek-v4-flash' },
|
||||
{ provider: 'deepseek', id: 'deepseek-v4-pro', name: 'deepseek-v4-pro' },
|
||||
{ provider: 'deepseek', id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash' },
|
||||
{ provider: 'deepseek', id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro' },
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user