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'])
|
||||
Reference in New Issue
Block a user