Merge remote-tracking branch 'origin/master' into worktree-guifork

This commit is contained in:
imccyu
2026-07-28 10:23:48 +08:00
710 changed files with 24719 additions and 6603 deletions

View File

@@ -75,7 +75,7 @@ If `test:gui` is red on code you did not touch, neither silently fix nor ignore
Bringing up a new `packages/client/<name>` plugin package (ui-workspace is the latest walked example; ui-sidebar/ui-question are good skeletons to copy):
1. **Package skeleton**: `package.json` (`@deepseek-ai/dsh-client-<name>`, exports `.`/`./invariant`/`./client`/`./src/*`/`./package.json`, `dshClient` manifest, `files` list), `tsconfig.json` (extends `tsconfig.base.client.json`, one `references` entry per workspace dependency plus `support/invariants`), `tsdown.config.ts` (`clientBundle(id, ['lib/types/index.js', 'lib/types/invariant.js'])`), `src/index.ts` (empty node-half apply), `src/invariant.ts` (companion with a real reason), `src/css-modules.d.ts` when using CSS Modules, `README.md` with the Model Experience section.
2. **Three registration surfaces, all required** (missing any one fails at a different, later point): the `tsconfig.client.json` aggregate `references` entry; the `CLIENT_PACKAGES` roster in `apps/cli/src/web.ts`; an `apps/cli/package.json` dependency (`mountWebPlugins` resolves roster packages against the composing app's URL — a roster row that is not a dependency of `apps/cli` fails to mount). `pnpm-workspace.yaml` already globs `packages/*/*`.
2. **Three registration surfaces, all required** (missing any one fails at a different, later point): the `tsconfig.client.json` aggregate `references` entry; a `dshClient` row in `apps/cli/cordis.yml`; an `apps/cli/package.json` dependency (Loader resolves each config-tree package against the composing app's URL — a row whose package is not an `apps/cli` dependency fails to import). `pnpm-workspace.yaml` already globs `packages/*/*`.
3. **dshClient manifest semantics**: `platform: 'web'` always; `immediately: true` only for stage-one-prefetch infrastructure rows. `inject` lists package-name dependency edges — they are **informational only** (preflight display, HMR diffing); they do not sequence entry activation or apply order. Activation order is cordis fiber inject waiting on *services*, nothing else.
4. **Registering into another package's slot**: if the declaring host provides no waitable service, your apply's order relative to the host's is unconstrained — a bare `slots.register` into its slot races boot (intermittent `slot "..." is not declared` page failures). Register with declaration-aware deferral: check `ctx.slots.spec(name)`, otherwise `ctx.slots.subscribe(name)` and register on the declaration event (SlotCore supports subscribing ahead of declaration); make the registration idempotent, and unsubscribe + dispose in the effect disposer. Only take a service edge in `inject` when the host actually provides one (ui-question → `'conversation'` is that case).
5. Rebuild the bundle (`pnpm --filter <pkg> bundle`) before probing a live `dsh web` server — the registry serves `lib/client.js`, not sources.

View File

@@ -10,6 +10,8 @@ export type {
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
WorkspaceApi, WorkspaceId, WorkspaceView,
CommandsApi, CommandDescriptor, SkillsApi, SkillEntry,
ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
ModelReasoningEffort, ModelTarget, SessionModels,
} from '@deepseek-ai/dsh-host-apiproxy/api'
export type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation'
export type {

View File

@@ -12,7 +12,7 @@ import type { SessionEvent, SessionId, TodoItem } from '@deepseek-ai/dsh-session
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
import type {
ApiProxy, ClientRequest, ClientResponse, HistoryEntry, HostFrame, MuxFrame, RpcReceipt,
RpcRequest, RpcResponse, RpcResult, ServerRequest, ServerResponse, SessionSummary,
ModelTarget, RpcRequest, RpcResponse, RpcResult, ServerRequest, ServerResponse, SessionSummary,
ToolCallView, ToolEventView, ToolResultView, WorkspaceId, WorkspaceView,
} from './api.ts'
import type { RequestPayload, ResponseValue, RpcMethodMap } from '@deepseek-ai/dsh-host-apiproxy/api'
@@ -49,6 +49,25 @@ const MARKDOWN_FIXTURE = [
const USER_MARKDOWN_LITERAL = '用户字面量:# 不渲染 `code` [link](https://example.com)'
const DEEPSEEK_REASONING = {
efforts: [
{ id: 'off', name: 'Off' },
{ id: 'high', name: 'High' },
{ id: 'max', name: 'Max' },
],
defaultEffort: 'high',
}
const OPENAI_REASONING = {
efforts: [
{ id: 'off', name: 'Off' },
{ id: 'medium', name: 'Medium' },
{ id: 'high', name: 'High' },
{ id: 'max', name: 'Max' },
],
defaultEffort: 'medium',
}
function sid(id: string): SessionId {
return id as SessionId
}
@@ -391,6 +410,10 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
{ sessionId: sid('fx-gamma'), updatedAt: Date.now() - 120_000, running: false, blank: false, cwd: '/tmp/fixture' },
]
const logs = new Map<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
@@ -631,6 +654,7 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
sessionId: requestedId ?? sid(`fx-${nextSession++}`), updatedAt: Date.now(), running: false, blank: true, cwd,
}
sessions.push(created)
modelTargets.set(created.sessionId, { provider: 'deepseek', model: 'deepseek-v4-flash' })
attachedSessions += 1
const emitSession = (): void => {
// Mirrors the host: the frame fires at creation, so blank is constantly true.
@@ -667,6 +691,47 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
if (doomed) throw new Error('fixture: simulated history transport failure')
return ok(request, { ...page, ...projections === undefined ? {} : { projections } })
},
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: '快速响应',
reasoning: DEEPSEEK_REASONING,
},
{
id: 'deepseek-v4-pro',
name: 'DeepSeek-V4-Pro',
description: '复杂任务',
reasoning: DEEPSEEK_REASONING,
},
],
},
{
id: 'openai',
name: 'OpenAI',
models: [{ id: 'gpt-5', name: 'GPT-5', reasoning: OPENAI_REASONING }],
},
],
failures: [],
}),
selectModel: (request) => {
const selected: ModelTarget = {
provider: request.payload.provider,
model: request.payload.model,
...request.payload.reasoningEffort === undefined
? {}
: { reasoningEffort: request.payload.reasoningEffort },
}
modelTargets.set(request.payload.sessionId, selected)
return ok(request, { selected })
},
prompt: (request) => {
const { sessionId: id, mode, content } = request.payload
const summary = summaryOf(id)
@@ -701,7 +766,13 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
turn,
userText === 'render markdown'
? MARKDOWN_FIXTURE
: `回声:${userText}。这是 fixture 的流式回复,用于验证打字机增长与定稿切换。`,
: userText === 'report model'
? (() => {
const target = modelTargets.get(id)
return `当前模型:${target?.provider ?? 'unknown'}/${target?.model ?? 'unknown'}`
+ (target?.reasoningEffort === undefined ? '' : ` · 推理等级:${target.reasoningEffort}`)
})()
: `回声:${userText}。这是 fixture 的流式回复,用于验证打字机增长与定稿切换。`,
)
return ok(request, { accepted: true as const })
},
@@ -972,6 +1043,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)

View File

@@ -15,6 +15,8 @@ export type {
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
ToolCallView, ToolResultView, WorkspaceApi, WorkspaceId, WorkspaceView,
CommandsApi, CommandDescriptor, SkillsApi, SkillEntry,
ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
ModelReasoningEffort, ModelTarget, SessionModels,
RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode,
ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt,
IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk,

View File

@@ -3,8 +3,8 @@
// deferred-controlled timing). Streams are hand pumps: pushMux/pushHost.
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
import type {
CommandDescriptor, HostFrame, IApiClient, MuxFrame,
RpcRequest, RpcResponse, SessionId, SkillEntry,
CommandDescriptor, HostFrame, IApiClient, ModelTarget, MuxFrame,
RpcRequest, RpcResponse, SessionId, SessionModels, SkillEntry,
} from '../src/client/api.ts'
import { RpcId } from '../src/client/api.ts'
@@ -46,9 +46,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 }>> =
@@ -67,6 +79,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)),
}

View File

@@ -72,6 +72,37 @@ describe('createFixtureApi', () => {
expect(empty.result.value).toEqual({ events: [], hasMore: false, projections: { asOfSeq: -1, values: {} } })
})
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')
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('emits the todo/write snapshot at the real tool boundary: between tool/call and tool/result, timestamps monotonic', async () => {
const api = createFixtureApi()
const tail = await api.sessions.history(req({ sessionId: sid('fx-alpha'), maxMessages: 10 }))

View File

@@ -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

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/runtime/README.md
README.md: a434b2d5719de2f30a883ee6e0d26264b3c62f4e
README.zh.md: a79fa99578e6bce4f0ff8e7c1f7538df8a436778
README.md: 81261945cb2fd8b15f7c2f15cb1ae0b8e9928499
README.zh.md: cbbf6eded4a5375223791275f26f3bc7b6553200

View File

@@ -24,13 +24,17 @@ SlotsService gives the renderer separate bare observables for `useSessions` and
`SessionManager` retains the latest validated `session/title` control snapshot independently of list and session-instance arrival. Newer event seqs replace older snapshots, title timestamps contribute to list recency, and a subscription baseline discards any retained title beyond its `lastSeq` before the optional folded title arrives. Explicit session removal also clears the retained title. The client-facing `SessionSummary.title` is therefore only the actual durable title; `displayTitle` is always present and falls back through the cwd basename and session id. A cold persisted session keeps that fallback until opening or resuming it causes the host to fold and project its log-backed title.
## Session model selection
Each resident `Session` owns a `modelSelection` snapshot containing the current provider/model target, provider-grouped directory, provider-local failures, and the `idle`/`loading`/`ready`/`selecting`/`error` state. History establishes or refreshes the current target, opening a selector refreshes the directory, and selection failures preserve the last target and usable groups. Directory and selection operations share a monotonically increasing generation so an older response cannot overwrite a newer selection. A reconnect rebuild restores the target reported by the Host without replacing unchanged selection substructure.
## Model Experience
None, as the 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

View File

@@ -24,13 +24,17 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
`SessionManager` 独立于列表和 Session 实例到达情况,保留最近一次通过验证的 `session/title` 控制快照。seq 更新的事件会替换旧快照,标题时间戳计入列表新近程度;订阅基线会先丢弃 seq 超过其 `lastSeq` 的任何已保留标题,再接收可选的折叠标题。显式移除 Session 也会清除已保留标题。因此,面向客户端的 `SessionSummary.title` 只包含真实的持久标题;`displayTitle` 始终存在,并依次回退到 cwd basename 和 Session id。冷启动的持久会话会保持该回退值直到打开或恢复会话促使主机折叠并投影日志支持的标题。
## 会话模型选择
每个常驻 `Session` 都拥有一个 `modelSelection` 快照,其中包含当前提供方/模型目标、按提供方分组的目录、逐提供方失败记录,以及 `idle``loading``ready``selecting``error` 状态。历史记录会建立或刷新当前目标,打开选择器会刷新目录;选择失败会保留上一个目标和可用分组。目录与选择操作共用单调递增的代次,因此较旧响应无法覆盖较新的选择。重连重建会恢复 Host 报告的目标,同时不替换未变化的选择子结构。
## 模型体验
。客户端运行时承载浏览器侧服务与 Session 对象层;这里没有任何内容进入模型请求
,因为 Session 对象层会选择后续 Host 请求使用的提供方/模型路由,但不添加任何模型可见内容
#### KV Cache 影响
无;该包既不组装也不发送提供方请求
更改目标可能改变提供方侧的缓存复用,或使其失效;该包本身不会改变提示词前缀
## 已知限制与暂缓事项

View File

@@ -3,8 +3,8 @@
// deferred-controlled timing). Streams are hand pumps: pushMux/pushHost.
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
import type {
ClientResponse, CommandDescriptor, HostFrame, IApiClient, MuxFrame,
RpcError, RpcReceipt, RpcRequest, RpcResponse, SessionId, SkillEntry,
ClientResponse, CommandDescriptor, HostFrame, IApiClient, ModelTarget, MuxFrame,
RpcError, RpcReceipt, RpcRequest, RpcResponse, SessionId, SessionModels, SkillEntry,
WorkspaceId, WorkspaceView,
} from '@deepseek-ai/dsh-client-connection/client'
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
@@ -62,10 +62,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 }))
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 }>> =
@@ -84,6 +97,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)),
}

View File

@@ -336,7 +336,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()

View File

@@ -75,7 +75,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 +87,7 @@ describe('open', () => {
})
})
describe('live event path', () => {
async function opened(events: SessionEvent[] = plainTurn(0, 0, 'a', 'b')) {
const { api, session } = makeSession()
@@ -232,7 +237,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
})
@@ -518,7 +527,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
})
@@ -537,7 +550,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')
})
@@ -551,7 +568,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])
})
@@ -595,6 +616,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({

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
README.md: 453922dafd1eb7a617cb2d1c93ac1daa2e7273c6
README.zh.md: 88992176165ab11050a30c7df381479796908ba2
README.md: 56a445ccfa86e0b11cf5aefc37819a30746f0739
README.zh.md: a7c160ecdd74074257c9d149630663dacd05c070

View File

@@ -4,17 +4,19 @@ English | [中文](README.zh.md)
Conversation domain: skeleton (header/tabs/composer/empty state), chat view (grouped step-summary flow, streaming tail isolation, stats line, per-tool row slot with a bash sample registrant and the todo row), input dock (queue rows plus the todo plan strip), minimal details panel, scope-addressed ConversationService. Contract: api-contracts v3 §7 plus the slot terminal design (store seat / props shares).
The no-session hero renders the frontend Session Intent from the Session list projection, including its frontend Workspace Intent when no real Workspace exists. It declares `conversation.empty.workspace`, where ui-workspace registers the same picker used by the sidebar. WorkspacesService starts the cross-object flow; each Workspace or Session object owns its own materialization. The Session keeps its identity across publication and retains any prompt that still needs connection or delivery; ConversationRoot reads that `pendingPrompt` from `useSession` and edits or retries it through the scoped ConversationService.
The resident conversation shell survives no-session and session transitions. Without a current session it renders a disabled input bar; its root-scoped `conversation.hero.workspace` slot hosts the Workspace picker. Selecting a Workspace connects or reuses its Host-owned blank session and opens that session without replacing the shell. Blank sessions render the same composer body as active sessions, while the InputHub carries drafts across Workspace switches and mirrors them into the session store.
The view ring IS a slot: the conversation registration declares the `'conversation.view'` list slot (session scope) in its `children` table, ConversationRoot renders the active entry through its renderSlot share (`only: <active id>`), and view tabs project from the ring ledger's registration options (`id`/`order`/`label`). The chat view is this package's own ring entry; other plugins (ui-trajectory) contribute tabs through plain `ctx.slots.register` — the former package-local view registry (`registerView`/`ViewEntry`/`ConversationViewMap` and the chrome attachment table) is retired, with per-view chrome dissolved into the view components themselves.
Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and `Write · <path>` or `Edit · <path>` summary while retaining the shared row-to-details interaction. The code variant summarizes with the model-authored `description` and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged), and the details panel resolves a selected sub-call id to its full logged args and complete output.
Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and `Write · <path>` or `Edit · <path>` summary while retaining the shared row-to-details interaction. The code variant summarizes with the model-authored `description` and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged), and the details panel resolves a selected sub-call id to its full logged args and complete output. Cordis lifecycle tools reuse those generic variants while presenting `Inspect`, `Mount temporary Plugin`, and `Unmount temporary Plugin` with a shared Cordis accent; mount keeps the code variant's expandable source rendering.
Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.toolviews`/outlet) is retired. The chat entry declares the keyed `'conversation.chat.toolview'` hole (session scope; the key space is runtime-open); its render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openDetails`) and `ToolRowProps` pre-composes it with the session standard kit. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam (apply mounts ConversationService after the chat registration, so the service being present guarantees the slot is declared); session differentiation happens inside the component (`useSessions` reading `parentId` — the bash sample is the third-party-posture exemplar). Trajectory/waterfall toolview slots share this shape and land with their own render sites (RendersCheck rejects a declaration nobody renders).
The todo surfaces are two registrations over that shape, both plain registrant plugins with `inject: ['slots', 'conversation']`. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`<done>/<total> 已完成 · <active item>` parsed from its args, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). `TodoDock` takes the `'conversation.input.dock'` list slot at `order: -1` — above the queue rows — and is the durable plan strip: it selects `todos` off the session snapshot and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and collapses to a header of title plus `"<done>/<total> tasks · <n> in progress"` (status glyphs are the figma check / progress / dashed-pending set). The dock adapter owns the selection so the panel stays a pure function of its props; the persistent list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included.
Per-session UI state (selection, ordinary composer draft, active view) lives in the declared chat store (`stores.ts` `createChatStore`): apply constructs one handle and passes it to the conversation, chat-view, and details registrations, so the session slots share one instance per session (selection written by the chat view, read by details) and the framework owns instance lifecycle and draft persistence. The frontend Session Intent comes from the Session list projection; after publication, any retained prompt comes from that Session's conversation snapshot. Components are pure the framework standard kit (`useSession`/`sessionId` when session-scoped, plus global `useSessions`/`useWorkspaces`) and the store faces (`useStore`/`actions`) arrive automatically from the registration declaration; inject factories contribute plain data and callbacks for runtime Session actions, send/stop, tabs, details, and paging.
Per-session UI state for selection and the active view lives in the declared chat store (`stores.ts` `createChatStore`); the InputHub owns the composer state machine and mirrors its draft into that store for persistence. Apply passes one store handle to the strict session subtree, chat view, and details registrations, so each session shares one instance and the framework owns its lifecycle. Components are pure: the framework standard kit supplies `useSession`/`sessionId`, global `useSessions`/`useWorkspaces`, and the input machine's `useInput`/`inputActions`; store faces and inject factories supply the remaining state and callbacks.
The composer bar declares session-scoped single seats for `'conversation.input.plan'` and `'conversation.input.model'`, plus list slots for overlay, dock, left, and right input extensions. InputBar renders the model seat immediately before its pending indicator and send/stop button. Feature packages own each control and its state; ui-conversation supplies placement, the `locked` owner prop, and the standard slot shares. The resident no-session shell uses `DisabledInputBar` and therefore dispatches no session-scoped control seats.
`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).

View File

@@ -4,17 +4,19 @@
会话领域:骨架(标题栏/标签页/编辑器/空状态)、聊天视图(分组步骤摘要流、流式尾部隔离、统计行、逐工具行 slot 及一个 bash 示例注册方与 todo 行)、输入区 dock队列行加 todo 计划条)、最小详情面板、按 scope 寻址的 ConversationService。契约api-contracts v3 §7 加 slot 终端设计store seatprops share
无会话主视觉区会渲染来自 Session 列表投影的前端 Session Intent没有真实 Workspace 时,还会包含其前端 Workspace Intent。它声明 `conversation.empty.workspace`ui-workspace 会在此注册侧边栏所用的同一选择器。WorkspacesService 启动跨对象流程;每个 Workspace 或 Session 对象拥有自身的物化。Session 在发布期间保持身份并保留任何仍需连接或交付的提示词ConversationRoot 读取该 `pendingPrompt`,其来源是 `useSession`,再通过 scope 内的 ConversationService 编辑或重试
常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会渲染禁用输入栏;其根作用域的 `conversation.hero.workspace` slot 承载 Workspace 选择器。选择 Workspace 会连接或复用由 Host 拥有的空白会话并在不替换会话壳的情况下打开该会话。空白会话与活跃会话渲染相同的输入区主体InputHub 则在 Workspace 切换间携带草稿,并将草稿镜像到会话 store
视图环本身就是 slot会话注册声明 `'conversation.view'` 列表 slotSession scope并将其列在 `children` 表中ConversationRoot 通过 renderSlot share 渲染活跃配置项(`only: <active id>`);视图标签页从环账本的注册选项(`id``order``label`投影而来。聊天视图是该包自身的环配置项其他插件ui-trajectory通过普通的 `ctx.slots.register` 贡献标签页。先前包内的视图注册表(`registerView``ViewEntry``ConversationViewMap` 及 chrome 附加表)已退役,逐视图 chrome 则被拆入视图组件自身。
通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和 `Write · <path>``Edit · <path>` 摘要同时保留共享的行到详情交互。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行details 面板则会根据选中的子调用 id 解析出其完整记录的参数与完整输出。
通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和 `Write · <path>``Edit · <path>` 摘要同时保留共享的行到详情交互。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行details 面板则会根据选中的子调用 id 解析出其完整记录的参数与完整输出。Cordis 生命周期工具复用这些通用变体,同时以统一的 Cordis 强调色呈现 `Inspect``Mount temporary Plugin``Unmount temporary Plugin`mount 行保留 code 变体的可展开源码渲染。
工具行同样是 slot独立工具环`ToolViewRegistry``ctx.toolviews`outlet已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位Session scopekey 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps``callId``toolName``block``openDetails``ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seamapply 在聊天注册后挂载 ConversationService因此服务存在即可保证 slot 已声明Session 区分在组件内部完成(`useSessions` 读取 `parentId`bash 示例是第三方姿态的范例。Trajectory/waterfall 工具视图 slot 共享此形状并随各自的渲染点落地RendersCheck 会拒绝没有任何渲染方的声明)。
todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']``TodoRow` 占用 `'conversation.chat.toolview'``todo_write` key摘要该次调用「试图写入」的内容从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock``order: -1` 占用 `'conversation.input.dock'` 列表 slot位于队列行之上是常驻的计划条它从会话快照中选取 `todos` 并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏,折叠时收成标题加 `"<已完成>/<总数> tasks · <n> in progress"` 的表头(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;常驻列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock包括这条计划条。
逐 Session UI 状态(选择、普通编辑器草稿、活跃视图位于已声明的聊天 store`stores.ts` `createChatStore`)中apply 构造一个 handle并将其传给会话、聊天视图和详情注册因此 Session slot 每个 Session 共享一个实例(选择由聊天视图写入、详情读取),框架拥有实例生命周期与草稿持久化。前端 Session Intent 来自 Session 列表投影;发布后,任何保留的提示词都来自该 Session 的会话快照。组件保持纯粹框架标准工具包Session scope 下的 `useSession``sessionId`,以及全局 `useSessions``useWorkspaces`)和 store 表层(`useStore``actions`会从注册声明自动到达inject factory 为运行时 Session 操作、发送/停止、标签页、详情和分页贡献普通数据与回调。
逐 Session UI 状态中的选择与活跃视图位于已声明的聊天 store`stores.ts` `createChatStore`)中InputHub 拥有输入区状态机,并将草稿镜像到该 store 以便持久化。apply 将同一个 store handle 传给严格限定于会话的子树、聊天视图和详情注册,因此每个会话内共享一个实例,框架拥有生命周期。组件保持纯粹:框架标准工具包提供 `useSession``sessionId`全局 `useSessions``useWorkspaces`,以及输入状态机的 `useInput``inputActions`store 表层与 inject factory 提供其余状态和回调。
输入栏为 `'conversation.input.plan'``'conversation.input.model'` 声明会话作用域的单实例 seat并为 overlay、dock、left 和 right 输入扩展声明列表 slot。InputBar 将模型 seat 渲染在 pending 指示器与发送停止按钮之前。各功能包拥有相应控件及其状态ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。常驻无会话壳使用 `DisabledInputBar`,因此不会分发任何会话作用域的控件 seat。
`src/client/` 按未来的包拆分组织:`contract/` 是唯一的跨领域共享表层(`slots.ts` slot 声明 + 组合后的 slot props包括工具行契约、`views.ts` 共享原语、`tool-call-model.ts``skeleton/``chat/``toolviews/`(示例注册方)领域目录只导入 contract 文件,彼此绝不导入;`apply.ts` 是唯一允许导入全部三个领域的组装点。`/client` 导出表层只包含契约:`apply``inject`、两个服务类和 `contract/` 类型家族;实现组件(骨架、聊天行)与 store factory 保持内部状态,只能通过 apply 的 slot 注册到达页面(测试通过 `./src/*` 子路径获取它们)。

View File

@@ -30,6 +30,7 @@ export function GenericToolCard({ toolName, block, openDetails }: ToolRowOwnerPr
return (
<ToolRow
variant={model.variant}
toolName={toolName}
icon={VARIANT_ICONS[model.variant]}
title={model.title}
summary={model.summary}

View File

@@ -42,6 +42,21 @@
color: var(--dsw-alias-label-secondary);
}
/* Cordis lifecycle tools retain their generic row mechanics while carrying a
shared product accent and tool-owned action title. */
.root[data-tool^='cordis_'] .leading,
.root[data-tool^='cordis_'] .title {
color: var(--dsw-alias-state-business-primary);
}
.root[data-tool^='cordis_'] .title {
font-weight: 500;
}
.root[data-tool^='cordis_'] .sep {
background: var(--dsw-alias-state-business-primary);
}
button.leading {
cursor: pointer;
}

View File

@@ -13,6 +13,8 @@ import css from './ToolRow.module.css'
export interface ToolRowProps {
variant: ToolRowVariant
/** Wire tool name for tool-owned styling layered over the generic variant. */
toolName?: string | undefined
/** Leading 16px tool icon, shown while collapsed and not running/failed. */
icon: ReactNode
title: string
@@ -39,6 +41,7 @@ function leadingFor(state: ToolRowState, icon: ReactNode): ReactNode {
export function ToolRow({
variant,
toolName,
icon,
title,
summary,
@@ -64,7 +67,7 @@ export function ToolRow({
toggleExpand()
}
return (
<div className={css.root} data-variant={variant} data-state={state}>
<div className={css.root} data-variant={variant} data-tool={toolName} data-state={state}>
<div
className={css.row}
data-clickable={rowExpands || onOpenDetails !== undefined || undefined}

View File

@@ -36,6 +36,16 @@ const TOOL_VARIANTS: Record<string, ToolRowVariant> = {
write: 'write',
edit: 'edit',
run_code: 'code',
cordis_inspect: 'read',
cordis_mount: 'code',
cordis_unmount: 'others',
}
/** Tool-owned titles that refine a generic row variant without replacing it. */
const TOOL_TITLES: Record<string, string> = {
cordis_inspect: 'Inspect',
cordis_mount: 'Mount temporary Plugin',
cordis_unmount: 'Unmount temporary Plugin',
}
/**
@@ -130,12 +140,15 @@ export function toolRowModel(toolName: string, block: ToolCallBlock): ToolRowMod
: block.error?.code === 'interrupted' ? 'stopped'
: block.isError ? 'error' : 'ok'
const base = argsRaw === '' ? block.callId : deriveSummary(variant, argsRaw)
const toolTitle = TOOL_TITLES[toolName]
// Others keeps the static "Tool call" title (figma literal); the real tool
// name rides the mutable summary slot so no information is lost.
const summary = variant === 'others' && toolName !== '' ? `${toolName} · ${base}` : base
// name rides the mutable summary slot unless the tool owns a specific title.
const summary = variant === 'others' && toolName !== '' && toolTitle === undefined
? `${toolName} · ${base}`
: base
return {
variant,
title: VARIANT_TITLES[variant],
title: toolTitle ?? VARIANT_TITLES[variant],
summary,
body: deriveBody(variant, argsRaw),
state,

View File

@@ -167,6 +167,28 @@ describe('run_code sub-calls through the real chat machinery', () => {
expect(view.getByText('Tool call')).toBeTruthy()
})
it('renders Cordis sub-calls with lifecycle titles over the generic variants', async () => {
const parent = 'call-cordis'
const code = 'return { name: "audit", apply(ctx) {} }'
const dispatches = new Map([[parent, [
subCall(11, parent, 1, 'cordis_inspect', { what: 'temporary' }, '## Temporary Plugins'),
subCall(12, parent, 2, 'cordis_mount', { code }, 'Temporary Plugin dyn-2 is running'),
subCall(13, parent, 3, 'cordis_unmount', { id: 'dyn-2' }, 'Temporary Plugin dyn-2 was unmounted and removed.'),
]]])
const b = await bench(snapshotWith([codeResult(10, parent)], dispatches))
const view = mountApp(b.slots)
const nest = view.container.querySelector('[data-subcalls]')!
expect(nest.querySelector('[data-tool="cordis_inspect"]')?.textContent).toContain('Inspect')
const mounted = nest.querySelector('[data-variant="code"]')
expect(mounted?.textContent).toContain(`Mount temporary Plugin${code}`)
expect(nest.querySelector('[data-tool="cordis_unmount"]')?.textContent)
.toContain('Unmount temporary Plugindyn-2')
fireEvent.click(mounted!.querySelector('button[aria-expanded]')!)
expect(mounted!.querySelector('pre.shiki')?.textContent).toBe(code)
})
it('expanding the code row reveals the program body verbatim (shiki-tokenized)', async () => {
const parent = 'call-64'
const b = await bench(snapshotWith([codeResult(10, parent)], new Map()))

View File

@@ -31,6 +31,9 @@ describe('tool-call-model', () => {
expect(classifyTool('grep')).toBe('search')
expect(classifyTool('write')).toBe('write')
expect(classifyTool('edit')).toBe('edit')
expect(classifyTool('cordis_inspect')).toBe('read')
expect(classifyTool('cordis_mount')).toBe('code')
expect(classifyTool('cordis_unmount')).toBe('others')
expect(classifyTool('todo_write')).toBe('others')
})
@@ -67,6 +70,33 @@ describe('tool-call-model', () => {
expect(toolRowModel('bash', running({ argsRaw: '' })).body).toBeNull()
expect(toolRowModel('bash', result({ call: null })).body).toBeNull()
})
it('gives Cordis lifecycle tools action titles over their generic variants', () => {
expect(toolRowModel('cordis_inspect', running({
name: 'cordis_inspect',
argsRaw: '{"what":"api","name":"tools"}',
}))).toMatchObject({
variant: 'read',
title: 'Inspect',
summary: 'api',
})
expect(toolRowModel('cordis_mount', running({
name: 'cordis_mount',
argsRaw: '{"code":"return { name: \\"audit\\", apply(ctx) {} }"}',
}))).toMatchObject({
variant: 'code',
title: 'Mount temporary Plugin',
summary: 'return { name: "audit", apply(ctx) {} }',
body: 'return { name: "audit", apply(ctx) {} }',
})
expect(toolRowModel('cordis_unmount', result({
call: { name: 'cordis_unmount', argsRaw: '{"id":"dyn-2"}' },
}))).toMatchObject({
variant: 'others',
title: 'Unmount temporary Plugin',
summary: 'dyn-2',
})
})
})
describe('ToolRow', () => {

View File

@@ -12,7 +12,7 @@
import { Context } from 'cordis'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, render } from '@testing-library/react'
import { act, cleanup, fireEvent, render } from '@testing-library/react'
import { createSnapshotStore, SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import type {
ConversationSnapshot, SessionId, SessionListState, ToolResultNode, WorkspaceListState,
@@ -166,6 +166,25 @@ describe('keyed toolview hole through the real machinery', () => {
expect(view.getByText('Tool call')).toBeTruthy()
})
it('renders top-level Cordis calls with lifecycle titles over the generic variants', async () => {
const code = 'return { name: "audit", apply(ctx) {} }'
const b = await bench([
toolResult(3, 'cordis-1', 'cordis_inspect', '{"what":"api","name":"tools"}'),
toolResult(4, 'cordis-2', 'cordis_mount', JSON.stringify({ code })),
toolResult(5, 'cordis-3', 'cordis_unmount', '{"id":"dyn-2"}'),
])
const view = mountApp(b.slots)
expect(view.container.querySelector('[data-tool="cordis_inspect"]')?.textContent).toContain('Inspect')
const mounted = view.container.querySelector('[data-variant="code"]')
expect(mounted?.textContent).toContain(`Mount temporary Plugin${code}`)
expect(view.container.querySelector('[data-tool="cordis_unmount"]')?.textContent)
.toContain('Unmount temporary Plugindyn-2')
fireEvent.click(mounted!.querySelector('button[aria-expanded]')!)
expect(mounted!.querySelector('pre.shiki')?.textContent).toBe(code)
})
it('row clicks travel owner openDetails → chat inject → layout orchestration', async () => {
const b = await bench([toolResult(3, 'c1', 'bash')])
const view = mountApp(b.slots)

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-model/README.md
README.md: 267717c78434f7a73b1c1eebca0cc0f9d65c3642
README.zh.md: 325b1d93d99ed22e0945c26f5a3a9e5b3b209c85

View File

@@ -0,0 +1,21 @@
# @deepseek-ai/dsh-client-ui-model
English | [中文](README.zh.md)
Model selection plugin, browser half: TWO entries over ONE per-session directory owned by `ModelService` (`ctx.models`). The `/model` popupSelect contribution (registered through `ctx.command`) and the composer's named `conversation.input.model` seat both load the session's advisory directory through `session.models` and submit through `session.selectModel` via the same `ModelDirectory` instance. The compact composer trigger opens a two-level Model/Effort menu: models stay provider-grouped, while the selected exact model supplies its adapter-owned effort names, descriptions, and default. The Host-reported provider/model/reasoning target is the single fact both entries echo; `/model` applies the selected model's default effort, and the composer can then choose any advertised effort. Directory loads and selections share a generation counter so an older response never overwrites a newer one; a connection reset drops every resident projection and repulls the Host-restored target before display. Provider-local metadata failures list inline while usable groups stay selectable, and selection failures retain the prior target and directory. Directories are per-session, resolved lazily through `ctx.models.directoryFor(sessionId)`, and disposed with the session scope.
The `/client` export surface is the plugin body (`apply`/`inject`), `ModelService`, `ModelDirectory` with its state shape, and the seat's injected face type.
## Model Experience
Indirectly, through the `session.selectModel` RPC both entries submit: the Host snapshots the selected provider/model/reasoning target at the next prompt-assembly boundary, so the following request uses the chosen route and effort while a running step keeps its assembled target. The selection becomes durable only when the existing request header records a request that consumes it; menu interaction adds no prompt content.
#### KV Cache effect
Switching the route can reduce or invalidate provider-side cache reuse for subsequent requests; the prompt prefix itself is untouched.
## Known Limitations and Deferred Work
- **No create-time selection** — both entries address an existing session's agent; there is no draft-phase model choice to fold into session creation (the seed order at the host's `targetFor` documents where such a tier would go).
- **Directory names are presentation-only** — selection and persistence use provider/model/effort ids; a provider whose catalog or exact-model metadata lookup fails lists as an unselectable failure row until reload.
- **No arbitrary effort input** — the composer offers only the exact model's adapter-advertised levels; an adapter without reasoning metadata leaves the Effort row absent.

View File

@@ -0,0 +1,21 @@
# @deepseek-ai/dsh-client-ui-model
[English](README.md) | 中文
模型选择插件(浏览器半侧):**两个入口共用一份 per-session 目录**,由 `ModelService``ctx.models`)持有。`/model` popupSelect contribution`ctx.command` 注册)与 composer 的具名 `conversation.input.model` 坑位都通过同一个 `ModelDirectory` 实例,经 `session.models` 加载会话的建议目录,并经 `session.selectModel` 提交。紧凑型 composer 触发器会打开两级 Model/Effort 菜单模型仍按提供方分组所选确切模型则提供由其适配器持有的推理强度名称、说明和默认值。Host 报告的提供方模型推理reasoning目标是两个入口共同回显的唯一事实`/model` 应用所选模型的默认推理强度composer 随后可以选择任一已公布的推理强度。目录加载与选择共享一个代次计数器,旧响应不会覆盖新结果;连接重置会丢弃所有常驻目录投影,并在显示前重新拉取 Host 恢复的目标。逐提供方元数据失败会内联列出,同时可用分组仍可选择;选择失败会保留先前的目标和目录。目录按会话惰性解析(`ctx.models.directoryFor(sessionId)`),随会话 scope 一并释放。
`/client` 导出面为插件本体(`apply`/`inject`)、`ModelService``ModelDirectory` 及其状态形状、坑位注入面类型。
## Model Experience
间接影响,经两个入口共同提交的 `session.selectModel` RPCHost 在下一次提示词组装边界快照所选提供方/模型/推理强度目标,因此后续请求采用所选路由和推理强度,而运行中的步骤保留已组装目标。只有当现有请求头记录一次实际采用该选择的请求后,选择才会持久化;菜单交互不会添加提示词内容。
#### KV Cache effect
切换路由可能降低或作废提供方侧后续请求的缓存复用;提示词前缀本身不受影响。
## Known Limitations and Deferred Work
- **无创建期选择**——两个入口都寻址既有会话的 agent没有 Draft 期模型选择折入会话创建的通道host `targetFor` 处的种子序注释记录了该层未来的落点)。
- **目录名仅供呈现**——选择与持久化使用提供方/模型/推理强度 id目录查询或确切模型元数据查询失败的提供方以不可选失败行列出重新加载前保持原样。
- **不能任意输入推理强度**——composer 仅提供确切模型由适配器公布的推理强度;适配器没有推理元数据时不显示 Effort 行。

View File

@@ -0,0 +1,72 @@
{
"name": "@deepseek-ai/dsh-client-ui-model",
"description": "Model selection: the /model popupSelect over session.models / session.selectModel",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./client": {
"types": "./lib/types/client/index.d.ts",
"default": "./lib/client.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"dshClient": {
"inject": [
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-command"
],
"platform": "web"
},
"scripts": {
"bundle": "tsdown",
"watch": "tsdown --watch"
},
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-client-connection": "^0.0.1",
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
"@deepseek-ai/dsh-client-ui-command": "^0.0.1",
"@deepseek-ai/dsh-client-ui-conversation": "^0.0.1",
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
"@deepseek-ai/dsh-client-ui-slash": "^0.0.1",
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"clsx": "^2.1.1",
"cordis": "^4.0.0-rc.7",
"react": "^18.2.0"
},
"devDependencies": {
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-command": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-slash": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@types/react": "~18.3.1",
"clsx": "^2.1.1",
"cordis": "^4.0.0-rc.7",
"react": "^18.2.0"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/client.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
]
}

View File

@@ -0,0 +1,251 @@
.root {
position: relative;
min-width: 0;
}
/* Figma 313:14108 ToggleButton: 13/20 medium secondary label, 4px gap,
12px caption chevron; 28px chip height matches the sibling Plan /
Read-only selects in the same tool row. */
.trigger {
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
max-width: 220px;
height: 28px;
padding: 0 4px 0 8px;
border: none;
border-radius: 8px;
outline: none;
background: transparent;
color: var(--dsw-alias-label-secondary);
font-size: 13px;
line-height: 20px;
font-weight: 500;
cursor: pointer;
}
.trigger:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover);
}
.trigger:focus-visible {
box-shadow: 0 0 0 2px var(--dsw-alias-border-l3);
}
.trigger:disabled {
color: var(--dsw-alias-label-dimmed);
cursor: default;
}
.triggerLabel {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Effort value beside the model name (mock's 'High': same 13/20/500, caption tone). */
.triggerEffort {
flex: 0 0 auto;
color: var(--dsw-alias-label-caption);
}
.chevron {
flex: 0 0 auto;
color: var(--dsw-alias-label-caption);
transition: transform 120ms ease;
}
.chevronOpen {
transform: rotate(180deg);
}
.menu {
position: absolute;
right: 0;
bottom: calc(100% + 8px);
z-index: 20;
display: flex;
flex-direction: column;
width: min(240px, calc(100vw - 32px));
max-height: min(360px, calc(100vh - 96px));
overflow: hidden;
padding: 4px;
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
border-radius: 12px;
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);
}
/* Two-level root cells (figma 496:26454 .Menu_cell): 40px row, 10px side
padding, 8px gap, 10px radius; 14/22 label in primary, value in the
#81858C tertiary tone, right chevron drilling into the sub-list. */
.cell {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
height: 40px;
padding: 0 10px;
border: none;
border-radius: 10px;
background: transparent;
color: var(--dsw-alias-label-primary);
font-size: 14px;
line-height: 22px;
cursor: pointer;
text-align: left;
}
.cell:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.cellLabel {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cellValue {
flex: 0 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--dsw-alias-label-tertiary);
}
.cellChevron {
flex: 0 0 auto;
color: var(--dsw-alias-label-tertiary);
}

View File

@@ -0,0 +1,327 @@
/**
* ModelSelect: the composer's named model seat (`conversation.input.model`).
* Two-level selection per figma 496:26454's MenuDropdown: the root menu is
* the Model / Effort row pair (label + current value + a right chevron),
* each drilling into its own list — the provider-grouped model list over
* the shared directory, and the effort levels. The trigger (313:14108's
* ToggleButton) shows both: model name + effort in the caption tone.
* Data and submission ride the SAME per-session ModelDirectory as the
* /model popup; exact-model reasoning metadata and the selected effort come
* from the Host rather than a client-owned vocabulary.
*/
import {
useEffect, useId, useMemo, useRef, useState, useSyncExternalStore,
type KeyboardEvent, type FocusEvent,
} from 'react'
import clsx from 'clsx'
import type { ModelReasoningEffort, ModelTarget } from '@deepseek-ai/dsh-client-connection/client'
import {
IconCheckOutline16, IconChevronDownOutline14, IconChevronRightOutline14,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { ModelSelectInjected } from './slots.ts'
import css from './ModelSelect.module.css'
/** Which pane the dropdown shows: the two-row root or one drilled-in list. */
type Pane = 'root' | 'model' | 'effort'
/** One dynamic effort row; undefined means preserve the provider default. */
interface EffortChoice {
key: string
effort: string | undefined
label: string
description?: string
}
/**
* Render the composer model seat.
* @param props - owner share (locked) + injected face (shared directory store/verbs).
* @returns the trigger and, while open, the two-level menu.
*/
export function ModelSelect({ locked, directory, load, select }: ModelSelectInjected & { locked: boolean }) {
const state = useSyncExternalStore(
fn => directory.subscribe(fn),
() => directory.getSnapshot(),
)
const [open, setOpen] = useState(false)
const [pane, setPane] = useState<Pane>('root')
const rootRef = useRef<HTMLDivElement | null>(null)
const triggerRef = useRef<HTMLButtonElement | null>(null)
const itemRefs = useRef<(HTMLButtonElement | null)[]>([])
const id = useId()
const choices = useMemo(() => state.groups.flatMap(group =>
group.models.map(model => ({
group,
model,
target: {
provider: group.id,
model: model.id,
...model.reasoning?.defaultEffort === undefined
? {}
: { reasoningEffort: model.reasoning.defaultEffort },
} satisfies ModelTarget,
}))), [state.groups])
const selectedIndex = state.current === null
? -1
: choices.findIndex(c => c.target.provider === state.current?.provider && c.target.model === state.current.model)
const currentChoice = choices[selectedIndex]
const reasoning = currentChoice?.model.reasoning
const effectiveEffort = state.current?.reasoningEffort ?? reasoning?.defaultEffort
const effortLabel = reasoning === undefined
? undefined
: effectiveEffort === undefined
? 'Provider default'
: reasoning.efforts.find(level => level.id === effectiveEffort)?.name ?? effectiveEffort
const effortChoices = useMemo<readonly EffortChoice[]>(() => reasoning === undefined
? []
: [
...reasoning.defaultEffort === undefined
? [{ key: 'provider-default', effort: undefined, label: 'Provider default' }]
: [],
...reasoning.efforts.map((effort: ModelReasoningEffort) => ({
key: `effort:${effort.id}`,
effort: effort.id,
label: effort.name,
...effort.description === undefined ? {} : { description: effort.description },
})),
], [reasoning])
const busy = state.status === 'selecting'
// Mount-time load resolves the trigger label; every open refreshes.
useEffect(() => { load() }, [load])
useEffect(() => {
if (!open) return
const closeOutside = (event: MouseEvent): void => {
if (!rootRef.current?.contains(event.target as Node)) setOpen(false)
}
document.addEventListener('mousedown', closeOutside)
return () => { document.removeEventListener('mousedown', closeOutside) }
}, [open])
const show = (): void => {
setPane('root')
setOpen(true)
load()
}
const close = (restoreFocus = false): void => {
setOpen(false)
setPane('root')
if (restoreFocus) queueMicrotask(() => { triggerRef.current?.focus() })
}
const moveFocus = (offset: number): void => {
const items = itemRefs.current.filter(item => item !== null)
if (items.length === 0) return
const active = items.findIndex(item => item === document.activeElement)
const next = (Math.max(active, 0) + offset + items.length) % items.length
items[next]?.focus()
}
const onRootKeyDown = (event: KeyboardEvent<HTMLDivElement>): void => {
if (event.key === 'Escape' && open) {
event.preventDefault()
// Escape backs out of a drilled pane first, then closes.
if (pane !== 'root') setPane('root')
else close(true)
return
}
if (!open) return
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault()
moveFocus(event.key === 'ArrowDown' ? 1 : -1)
}
}
const onBlur = (event: FocusEvent<HTMLDivElement>): void => {
if (event.relatedTarget instanceof Node && rootRef.current?.contains(event.relatedTarget)) return
close()
}
const choose = (target: ModelTarget): void => {
if (state.current?.provider === target.provider && state.current.model === target.model) {
close(true)
return
}
void select(target).then((accepted) => {
if (accepted && rootRef.current !== null) close(true)
})
}
const chooseEffort = (effort: string | undefined): void => {
if (state.current === null) return
if (effectiveEffort === effort) {
close(true)
return
}
const target: ModelTarget = {
provider: state.current.provider,
model: state.current.model,
...effort === undefined ? {} : { reasoningEffort: effort },
}
void select(target).then((accepted) => {
if (accepted && rootRef.current !== null) close(true)
})
}
const modelLabel = choices[selectedIndex]?.model.name ?? state.current?.model ?? '选择模型'
const triggerLabel = effortLabel === undefined ? modelLabel : `${modelLabel} · ${effortLabel}`
itemRefs.current = []
let itemIndex = 0
const itemRef = () => {
const at = itemIndex++
return (node: HTMLButtonElement | null) => { itemRefs.current[at] = node }
}
return (
<div ref={rootRef} className={css.root} onKeyDown={onRootKeyDown} onBlur={onBlur}>
<button
ref={triggerRef}
type="button"
className={css.trigger}
aria-label={`选择模型,当前 ${modelLabel}${effortLabel === undefined ? '' : `,推理等级 ${effortLabel}`}`}
aria-haspopup="menu"
aria-expanded={open}
aria-controls={open ? `${id}-menu` : undefined}
title={triggerLabel}
disabled={locked}
onClick={() => {
if (open) {
close()
} else {
show()
}
}}
>
<span className={css.triggerLabel}>{modelLabel}</span>
{effortLabel !== undefined && <span className={css.triggerEffort}>{effortLabel}</span>}
<IconChevronDownOutline14 className={clsx(css.chevron, open && css.chevronOpen)} />
</button>
{open && (
<div
id={`${id}-menu`}
className={css.menu}
role="menu"
aria-label="模型与推理等级"
aria-busy={state.status === 'loading' || busy}
>
{pane === 'root' && (
<>
<button ref={itemRef()} type="button" role="menuitem" className={css.cell} onClick={() => { setPane('model') }}>
<span className={css.cellLabel}>Model</span>
<span className={css.cellValue}>{modelLabel}</span>
<IconChevronRightOutline14 className={css.cellChevron} />
</button>
{reasoning !== undefined && (
<button ref={itemRef()} type="button" role="menuitem" className={css.cell} onClick={() => { setPane('effort') }}>
<span className={css.cellLabel}>Effort</span>
<span className={css.cellValue}>{effortLabel}</span>
<IconChevronRightOutline14 className={css.cellChevron} />
</button>
)}
</>
)}
{pane === 'model' && (
<>
{state.status === 'loading' && (
<div className={css.status}></div>
)}
{state.error !== null && (
<div className={css.error}>
<span>{state.error}</span>
<button type="button" className={css.retry} onClick={() => { load() }}></button>
</div>
)}
{state.failures.map(failure => (
<div className={css.warning} key={failure.id}>
<span>{failure.name} {failure.message}</span>
<button type="button" className={css.retry} onClick={() => { load() }}></button>
</div>
))}
<div className={clsx(css.groups, 'scrollable')}>
{state.groups.map((group) => {
const headingId = `${id}-${group.id}`
return (
<section role="group" aria-labelledby={headingId} className={css.group} key={group.id}>
<div className={css.groupTitle} id={headingId}>{group.name}</div>
{group.models.map((model) => {
const selected = state.current?.provider === group.id && state.current.model === model.id
return (
<button
ref={itemRef()}
type="button"
role="menuitemradio"
aria-checked={selected}
className={clsx(css.option, selected && css.selected)}
key={model.id}
title={model.name}
disabled={busy}
onClick={() => { choose({ provider: group.id, model: model.id }) }}
>
<span className={css.optionCopy}>
<span className={css.modelName}>{model.name}</span>
{model.description !== undefined && (
<span className={css.description}>{model.description}</span>
)}
{model.unlisted === true && (
<span className={css.unlisted}> · </span>
)}
</span>
<span className={css.check}>
{selected ? <IconCheckOutline16 /> : null}
</span>
</button>
)
})}
</section>
)
})}
</div>
{state.status === 'ready' && choices.length === 0 && (
<div className={css.empty}></div>
)}
</>
)}
{pane === 'effort' && (
<>
{state.error !== null && (
<div className={css.error}>
<span>{state.error}</span>
<button type="button" className={css.retry} onClick={() => { load() }}></button>
</div>
)}
{effortChoices.length === 0
? <div className={css.empty}></div>
: effortChoices.map(level => (
<button
ref={itemRef()}
type="button"
role="menuitemradio"
aria-checked={effectiveEffort === level.effort}
className={clsx(css.option, effectiveEffort === level.effort && css.selected)}
key={level.key}
disabled={busy}
onClick={() => { chooseEffort(level.effort) }}
>
<span className={css.optionCopy}>
<span className={css.modelName}>{level.label}</span>
{level.description !== undefined && (
<span className={css.description}>{level.description}</span>
)}
</span>
<span className={css.check}>
{effectiveEffort === level.effort ? <IconCheckOutline16 /> : null}
</span>
</button>
))}
</>
)}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,126 @@
/**
* Per-session model directory: the ONE state both selection entries share.
* The /model popup and the composer-seat selector load through the same
* controller and submit through the same selectModel call, so the host stays
* the single fact source and the store is one shared echo — a switch made in
* either entry is what the other shows next.
*/
import type {
IApiClient, ModelCatalogFailure, ModelProviderGroup, ModelTarget, SessionId, SessionModels,
} from '@deepseek-ai/dsh-client-connection/client'
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
/** Directory snapshot both entries render from. */
export interface ModelDirectoryState {
/** Target the host reports for the next assembled step; null before the first load. */
current: ModelTarget | null
/** Successfully loaded provider groups (last good load). */
groups: readonly ModelProviderGroup[]
/** Provider-local failures from the last load; usable groups stay usable. */
failures: readonly ModelCatalogFailure[]
/** Lifecycle of the in-flight operation. */
status: 'idle' | 'loading' | 'ready' | 'selecting' | 'error'
/** Whole-request or selection failure text; null when none. */
error: string | null
}
/** One session's shared directory controller; disposed with the session scope. */
export class ModelDirectory {
/** The shared snapshot both entries render from (uSES-safe store). */
readonly store: SnapshotStore<ModelDirectoryState> = createSnapshotStore<ModelDirectoryState>({
current: null, groups: [], failures: [], status: 'idle', error: null,
})
/** Latest operation wins; an older response never overwrites a newer one. */
private generation = 0
private disposed = false
/**
* @param sessions - the session wire face (captured from the plugin's root connection).
* @param sessionId - the owning session.
*/
constructor(
private readonly sessions: Pick<IApiClient['sessions'], 'models' | 'selectModel'>,
private readonly sessionId: SessionId,
) {}
/**
* Refresh the advisory directory (both entries call this on open).
* Failure preserves the last good groups and current target.
* @returns the fresh directory value.
*/
async load(): Promise<SessionModels> {
const generation = ++this.generation
this.store.update((s) => { s.status = 'loading'; s.error = null })
const { result } = await this.sessions.models({ sessionId: this.sessionId })
if (this.disposed || generation !== this.generation) {
if (!result.ok) throw new Error(`${result.error.code}: ${result.error.message}`)
return result.value
}
if (!result.ok) {
this.store.update((s) => { s.status = 'error'; s.error = `${result.error.code}: ${result.error.message}` })
throw new Error(`session.models failed: ${result.error.code}: ${result.error.message}`)
}
const { current, groups, failures } = result.value
this.store.update((s) => {
s.current = current
s.groups = groups
s.failures = failures
s.status = 'ready'
s.error = null
})
return result.value
}
/**
* Select the complete provider/model/reasoning target (both entries submit through here). Success
* updates the shared current; failure surfaces on the store and throws so
* each entry's own retry surface engages.
* @param target - provider, provider-owned model id, and optional adapter-owned effort.
*/
async select(target: ModelTarget): Promise<void> {
const generation = ++this.generation
this.store.update((s) => { s.status = 'selecting'; s.error = null })
const { result } = await this.sessions.selectModel({
sessionId: this.sessionId,
provider: target.provider,
model: target.model,
...target.reasoningEffort === undefined
? {}
: { reasoningEffort: target.reasoningEffort },
})
if (this.disposed || generation !== this.generation) {
if (!result.ok) throw new Error(`${result.error.code}: ${result.error.message}`)
return
}
if (!result.ok) {
this.store.update((s) => { s.status = 'error'; s.error = `${result.error.code}: ${result.error.message}` })
throw new Error(`session.selectModel failed: ${result.error.code}: ${result.error.message}`)
}
this.store.update((s) => { s.current = result.value.selected; s.status = 'ready'; s.error = null })
}
/**
* Drop the previous Host generation's projection and repull it. Clearing
* first prevents an unconsumed process-local selection from being displayed
* while the restarted Host has restored the last logged request target.
*/
resetConnected(): void {
if (this.disposed) return
++this.generation
this.store.update((s) => {
s.current = null
s.groups = []
s.failures = []
s.status = 'idle'
s.error = null
})
void this.load().catch(() => { /* the next menu open remains the explicit retry surface */ })
}
/** Scope teardown: late settlements lose write access to the store. */
dispose(): void {
this.disposed = true
}
}

View File

@@ -0,0 +1,130 @@
/**
* Model selection plugin, browser half — TWO entries over ONE per-session
* directory owned by ModelService (`ctx.models`). The /model popupSelect
* contribution and the composer's named `conversation.input.model` seat both
* load the session's provider-grouped advisory directory (`session.models`)
* and submit through `session.selectModel` via the same directory instance,
* so the host-reported current target is the single fact both surfaces echo
* — a switch made in either entry is what the other shows next. Failures
* ride each entry's own retry surface (popup shell error/retry; seat menu
* inline error) without forking the state.
*/
import type { ModelTarget, SessionModels } from '@deepseek-ai/dsh-client-connection/client'
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import type { CommandServiceContract, SelectOption } from '@deepseek-ai/dsh-client-ui-command/client'
// Type-only: pulls the ui-conversation SlotMap merge (the input.model seat).
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { ModelDirectoryState } from './directory.ts'
import { ModelService } from './service.ts'
import type { ModelSelectInjected } from './slots.ts'
import { ModelSelect } from './ModelSelect.tsx'
export { ModelDirectory } from './directory.ts'
export type { ModelDirectoryState } from './directory.ts'
export { ModelService } from './service.ts'
export type { ModelSelectInjected } from './slots.ts'
/** One selectable row's id: an opaque row key (resolved by lookup, never parsed). */
function rowId(providerId: string, modelId: string): string {
return `${providerId}/${modelId}`
}
/** Flatten the directory into popup rows; failure rows are listed for visibility but never selectable. */
function optionsOf(directory: SessionModels): SelectOption[] {
const rows: SelectOption[] = []
for (const group of directory.groups) {
for (const model of group.models) {
rows.push({
id: rowId(group.id, model.id),
label: model.name,
detail: model.unlisted === true
? `${group.name} · 未列入目录`
: model.description !== undefined ? `${group.name} · ${model.description}` : group.name,
...(directory.current.provider === group.id && directory.current.model === model.id
? { active: true } : {}),
})
}
}
for (const failure of directory.failures) {
rows.push({ id: `failure/${failure.id}`, label: failure.name, detail: `目录加载失败:${failure.message}` })
}
return rows
}
/**
* Resolve a picked row back to its target by matching against the loaded
* groups (the same data the rows were built from — ids stay opaque).
* @param state - the session's directory snapshot.
* @param id - the picked row id.
* @returns the row's target, or undefined for failure rows / stale ids.
*/
function targetOf(state: ModelDirectoryState, id: string): ModelTarget | undefined {
for (const group of state.groups) {
for (const model of group.models) {
if (rowId(group.id, model.id) !== id) continue
const sameRoute = state.current?.provider === group.id && state.current.model === model.id
const reasoningEffort = sameRoute
? state.current?.reasoningEffort ?? model.reasoning?.defaultEffort
: model.reasoning?.defaultEffort
return {
provider: group.id,
model: model.id,
...reasoningEffort === undefined ? {} : { reasoningEffort },
}
}
}
return undefined
}
/** Required services: the contribution registry, the seat's slot registry, and the service's own faces. */
export const inject = ['command', 'connection', 'sessions', 'slots']
/**
* Client plugin body: mount ModelService, then register the /model popup
* contribution and the composer model seat over it.
* @param ctx - client root context.
*/
export function apply(ctx: ClientContext): void {
ctx.plugin(ModelService)
// Entry 1: the /model popupSelect over the shared directory.
ctx.inject(['command', 'models'], (scope: ClientContext) => {
const command = scope.get('command') as CommandServiceContract
const models = scope.models
scope.effect(() => command.register({
name: 'model',
description: 'Select the model for this conversation',
available: () => true,
ui: {
kind: 'popupSelect',
options: async session => optionsOf(await models.directoryFor(session.sessionId).load()),
onSelect: async (option, session) => {
const directory = models.directoryFor(session.sessionId)
const target = targetOf(directory.store.getSnapshot(), option.id)
if (target === undefined) {
throw new Error('this provider\'s catalog failed to load — pick a model from a loaded group')
}
await directory.select(target)
},
},
}), 'ui-model: /model contribution')
})
// Entry 2: the composer's named model seat over the SAME directory.
// Conditional mount: the seat is declared by the composer-bar entry; the
// conversation service's presence is the registration-safe signal.
ctx.inject(['slots', 'conversation', 'models'], (scope: ClientContext) => {
const models = scope.models
scope.effect(() => scope.slots.register({
name: 'conversation.input.model',
inject: (sessionId): ModelSelectInjected => {
const directory = models.directoryFor(sessionId)
return {
directory: directory.store,
load: () => { directory.load().catch(() => { /* surfaced on the store */ }) },
select: (target: ModelTarget) => directory.select(target).then(() => true, () => false),
}
},
}, ModelSelect), 'ui-model: composer model seat registration')
})
}

View File

@@ -0,0 +1,71 @@
/**
* ModelService (`ctx.models`): the root owner of per-session
* {@link ModelDirectory} instances. Both selection entries (the /model popup
* and the composer model seat) resolve their session's directory through
* this service, which is what makes the dual entry one shared state.
*
* Per-session storage follows the client service pattern (SlashService /
* CommandService): a lazy service-internal map whose entry is deleted by the
* owning scope's disposer. The host `dsh-scope` ScopedLayers registry does
* not transplant here: it derives scope from the host carrier mechanism
* (object-keyed), while client scopes tag contexts with branded SessionId
* strings, and it models global+shadow named registries — this is a
* per-session singleton with no global layer to merge.
*/
import { Service } from 'cordis'
import type { Context } from 'cordis'
import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-client-connection/client'
import type { SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
import { ModelDirectory } from './directory.ts'
declare module 'cordis' {
interface Context {
models: ModelService
}
}
/** Live mutable state in one holder (service methods run behind the caller-ctx tracker). */
interface LiveState {
/** Per-session directories; entries are deleted by their scope disposer. */
readonly directories: Map<SessionId, ModelDirectory>
}
/** The `ctx.models` session model-selection service. */
export class ModelService extends Service {
static inject = ['connection', 'sessions']
private readonly live: LiveState = { directories: new Map() }
/**
* @param ctx - owning root context (the service registers itself as `models`).
*/
constructor(ctx: Context) {
super(ctx, 'models')
ctx.on('connection/reset', () => {
for (const directory of this.live.directories.values()) directory.resetConnected()
})
}
/**
* Resolve the per-session shared directory (lazy; the scope disposer
* removes and disposes it). Unknown sessions fail loud.
* @param sessionId - the owning session.
* @returns the resident directory both entries share.
*/
directoryFor(sessionId: SessionId): ModelDirectory {
const { live } = this
const existing = live.directories.get(sessionId)
if (existing !== undefined) return existing
const sessions = this.ctx.get('sessions') as SessionsService
const actx = sessions.scope(sessionId)
if (actx === undefined) throw new Error(`ui-model: session "${String(sessionId)}" resolved no scope`)
const connection = this.ctx.get('connection') as ConnectionHandle
const directory = new ModelDirectory(connection.api.sessions, sessionId)
live.directories.set(sessionId, directory)
actx.effect(() => () => {
directory.dispose()
live.directories.delete(sessionId)
}, 'ui-model: session directory')
return directory
}
}

View File

@@ -0,0 +1,23 @@
/**
* ModelSelect's injected face. The target 'conversation.input.model' seat is
* declared (children table) and typed by ui-conversation's composer-bar
* entry; this package only contributes the single occupant, so no SlotMap
* merge lives here.
*/
import type { ModelTarget } from '@deepseek-ai/dsh-client-connection/client'
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { ModelDirectoryState } from './directory.ts'
/** Injected business face of the composer model seat. */
export interface ModelSelectInjected {
/** The session's shared directory store (same instance the /model popup reads). */
directory: SnapshotStore<ModelDirectoryState>
/** Refresh the advisory directory (fire-and-forget; errors land on the store). */
load: () => void
/**
* Select a complete provider/model/reasoning target through the shared route.
* @param target - model target and optional adapter-owned effort.
* @returns whether the host accepted the selection.
*/
select: (target: ModelTarget) => Promise<boolean>
}

View File

@@ -0,0 +1,6 @@
declare module '*.module.css' {
const classes: Record<string, string>
export default classes
}
declare module '*.css'

View File

@@ -0,0 +1,9 @@
/**
* Model selection plugin, node half. Pure UI plugin: the empty apply exists
* so the plugin appears in the host cordis.yml / Loader; the browser half
* ships via exports["./client"], discovered through the package.json
* dshClient declaration.
*/
/** Host plugin body — no host-side behavior for this surface plugin. */
export function apply(): void {}

View File

@@ -0,0 +1,31 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-model`.
* @module @deepseek-ai/dsh-client-ui-model/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-model'
/** Cordis companion plugin name. */
export const name = 'client-ui-model-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: a single command contribution registration whose disposal is
* proven by the HMR-safety spec — it emits no cordis events and owns no
* cross-plugin mutable state.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,210 @@
/**
* ui-model browser half on a real cordis Context with fake command/slots/
* connection faces and real session scopes: the plugin mounts ModelService
* as `models`, the /model contribution and the conversation.input.model
* seat both register, and BOTH entries resolve the SAME per-session
* directory through the service — a selection submitted through the seat's
* inject face is the current the popup's next options pass marks active
* (and the reverse), the one-shared-state contract of the dual entry.
* Scope disposal drops the directory (HMR safety).
*/
import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import { createScope } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type { ModelTarget } from '@deepseek-ai/dsh-client-connection/client'
import type { CommandContribution, SelectOption } from '@deepseek-ai/dsh-client-ui-command/client'
import type { ModelSelectInjected } from '../src/client/slots.ts'
import { apply, inject } from '../src/client/index.ts'
const sid = (k: string): SessionId => k as SessionId
const GROUPS = [{
id: 'deepseek',
name: 'DeepSeek',
models: [
{
id: 'deepseek-v4-flash',
name: 'DeepSeek-V4-Flash',
reasoning: {
efforts: [
{ id: 'off', name: 'Off' },
{ id: 'high', name: 'High' },
{ id: 'max', name: 'Max' },
],
defaultEffort: 'high',
},
},
{
id: 'deepseek-v4-pro',
name: 'DeepSeek-V4-Pro',
reasoning: {
efforts: [
{ id: 'off', name: 'Off' },
{ id: 'high', name: 'High' },
{ id: 'max', name: 'Max' },
],
defaultEffort: 'high',
},
},
],
}]
/** Boot the plugin over fake faces + a stateful fake host (current moves on selectModel). */
async function bench() {
const ctx = new Context()
let current: ModelTarget = { provider: 'deepseek', model: 'deepseek-v4-flash' }
const calls = { models: 0, select: 0 }
ctx.provide('connection', { api: { sessions: {
models: () => {
calls.models += 1
return Promise.resolve({ result: { ok: true as const, value: { current, groups: GROUPS, failures: [] } } })
},
selectModel: (payload: { provider: string; model: string; reasoningEffort?: string }) => {
calls.select += 1
current = {
provider: payload.provider,
model: payload.model,
...payload.reasoningEffort === undefined
? {}
: { reasoningEffort: payload.reasoningEffort },
}
return Promise.resolve({ result: { ok: true as const, value: { selected: current } } })
},
} } })
let contribution: CommandContribution | undefined
ctx.provide('command', {
register(c: CommandContribution) {
contribution = c
return () => { contribution = undefined }
},
})
const seats = new Map<string, { inject: ((sessionId: SessionId) => ModelSelectInjected) | undefined }>()
ctx.provide('slots', {
register(options: { name: string; inject?: (sessionId: SessionId) => ModelSelectInjected }) {
seats.set(options.name, { inject: options.inject })
return () => { seats.delete(options.name) }
},
})
ctx.provide('conversation', {})
const scopes = new Map<SessionId, Context>()
ctx.provide('sessions', { scope: (id: SessionId) => scopes.get(id) })
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
await ctx.plugin(function probe() {}).await()
const mint = (key: string) => {
const handle = createScope(ctx, sid(key))
scopes.set(sid(key), handle.ctx)
return handle
}
return {
ctx, fiber, mint, calls,
contribution: () => contribution!,
seat: () => seats.get('conversation.input.model')!,
hostCurrent: () => current,
setHostCurrent: (target: ModelTarget) => { current = target },
}
}
const projection = (id: string) => ({ sessionId: sid(id) })
describe('ui-model dual entry', () => {
it('registers the /model contribution and the composer model seat', async () => {
const b = await bench()
expect(b.contribution().name).toBe('model')
expect(b.contribution().ui.kind).toBe('popupSelect')
expect(b.seat().inject).toBeTypeOf('function')
})
it('popup options mark the host current active with the provider group in the detail', async () => {
const b = await bench()
b.mint('s1')
const options = await b.contribution().ui.options(projection('s1'), new AbortController().signal)
expect(options.map((o: SelectOption) => o.label)).toEqual(['DeepSeek-V4-Flash', 'DeepSeek-V4-Pro'])
expect(options[0]).toMatchObject({ active: true, detail: 'DeepSeek' })
expect(options[1]?.active).toBeUndefined()
})
it('a seat selection is the current the popup marks active next — one shared state', async () => {
const b = await bench()
b.mint('s1')
const seatFace = b.seat().inject!(sid('s1'))
// Switch through the SEAT entry.
expect(await seatFace.select({
provider: 'deepseek',
model: 'deepseek-v4-pro',
reasoningEffort: 'max',
})).toBe(true)
expect(b.hostCurrent()).toEqual({
provider: 'deepseek',
model: 'deepseek-v4-pro',
reasoningEffort: 'max',
})
expect(seatFace.directory.getSnapshot().current).toEqual({
provider: 'deepseek',
model: 'deepseek-v4-pro',
reasoningEffort: 'max',
})
// The POPUP's next options pass reflects it without a seat-side reload.
const options = await b.contribution().ui.options(projection('s1'), new AbortController().signal)
expect(options.find((o: SelectOption) => o.label === 'DeepSeek-V4-Pro')).toMatchObject({ active: true })
})
it('a popup selection lands on the seat store — the reverse direction of the same state', async () => {
const b = await bench()
b.mint('s1')
const seatFace = b.seat().inject!(sid('s1'))
const options = await b.contribution().ui.options(projection('s1'), new AbortController().signal)
const pro = options.find((o: SelectOption) => o.label === 'DeepSeek-V4-Pro')!
await b.contribution().ui.onSelect(pro, projection('s1'))
expect(seatFace.directory.getSnapshot().current).toEqual({
provider: 'deepseek',
model: 'deepseek-v4-pro',
reasoningEffort: 'high',
})
})
it('both entries share one directory instance per session, isolated across sessions', async () => {
const b = await bench()
b.mint('a')
b.mint('b')
const faceA = b.seat().inject!(sid('a'))
const faceA2 = b.seat().inject!(sid('a'))
const faceB = b.seat().inject!(sid('b'))
expect(faceA.directory).toBe(faceA2.directory)
expect(faceA.directory).not.toBe(faceB.directory)
// The service face resolves the same instance the seat inject handed out.
expect(b.ctx.models.directoryFor(sid('a')).store).toBe(faceA.directory)
})
it('drops an unconsumed local selection and restores the Host target after reconnect', async () => {
const b = await bench()
b.mint('s1')
const face = b.seat().inject!(sid('s1'))
await face.select({ provider: 'deepseek', model: 'deepseek-v4-pro' })
b.setHostCurrent({ provider: 'deepseek', model: 'deepseek-v4-flash' })
b.ctx.emit('connection/reset')
expect(face.directory.getSnapshot()).toMatchObject({ current: null, status: 'loading' })
await Promise.resolve()
expect(face.directory.getSnapshot()).toMatchObject({
current: { provider: 'deepseek', model: 'deepseek-v4-flash' },
status: 'ready',
})
})
it('scope disposal drops the directory; a reborn scope gets a fresh one', async () => {
const b = await bench()
const first = b.mint('s1')
const face1 = b.seat().inject!(sid('s1'))
await first.fiber.dispose()
b.mint('s1')
const face2 = b.seat().inject!(sid('s1'))
expect(face2.directory).not.toBe(face1.directory)
})
it('an unknown session fails loud at the seat inject', async () => {
const b = await bench()
expect(() => b.seat().inject!(sid('ghost'))).toThrow(/resolved no scope/)
})
})

View File

@@ -0,0 +1,95 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { ModelTarget } from '@deepseek-ai/dsh-client-connection/client'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { ModelDirectoryState } from '../src/client/directory.ts'
import { ModelSelect } from '../src/client/ModelSelect.tsx'
const reasoning = {
efforts: [
{ id: 'off', name: 'Off' },
{ id: 'high', name: 'High' },
{ id: 'max', name: 'Max', description: 'Largest budget' },
],
defaultEffort: 'high',
}
function state(overrides: Partial<ModelDirectoryState> = {}): ModelDirectoryState {
return {
current: { provider: 'deepseek', model: 'deepseek-v4-flash' },
groups: [{
id: 'deepseek',
name: 'DeepSeek',
models: [{ id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', reasoning }],
}],
failures: [],
status: 'ready',
error: null,
...overrides,
}
}
afterEach(cleanup)
describe('ModelSelect reasoning effort', () => {
it('renders adapter metadata and submits the effort as part of the session target', async () => {
const directory = createSnapshotStore(state())
const select = vi.fn(async (target: ModelTarget) => {
directory.update((snapshot) => { snapshot.current = target })
return true
})
render(<ModelSelect
locked={false}
directory={directory}
load={vi.fn()}
select={select}
/>)
const trigger = screen.getByRole('button', {
name: '选择模型,当前 DeepSeek-V4-Flash推理等级 High',
})
fireEvent.click(trigger)
fireEvent.click(screen.getByRole('menuitem', { name: /Effort/ }))
expect(screen.getAllByRole('menuitemradio').map(item => item.textContent))
.toEqual(['Off', 'High', 'MaxLargest budget'])
fireEvent.click(screen.getByRole('menuitemradio', { name: /Max/ }))
await waitFor(() => {
expect(select).toHaveBeenCalledWith({
provider: 'deepseek',
model: 'deepseek-v4-flash',
reasoningEffort: 'max',
})
expect(trigger.getAttribute('aria-label')).toBe('选择模型,当前 DeepSeek-V4-Flash推理等级 Max')
})
})
it('offers provider default only when the adapter does not configure a model default', () => {
const directory = createSnapshotStore(state({
groups: [{
id: 'provider',
name: 'Provider',
models: [{
id: 'model',
name: 'Model',
reasoning: { efforts: [{ id: 'standard', name: 'Standard' }] },
}],
}],
current: { provider: 'provider', model: 'model' },
}))
render(<ModelSelect
locked={false}
directory={directory}
load={vi.fn()}
select={vi.fn().mockResolvedValue(true)}
/>)
fireEvent.click(screen.getByRole('button', {
name: '选择模型,当前 Model推理等级 Provider default',
}))
fireEvent.click(screen.getByRole('menuitem', { name: /Effort/ }))
expect(screen.getAllByRole('menuitemradio').map(item => item.textContent))
.toEqual(['Provider default', 'Standard'])
})
})

View File

@@ -0,0 +1,39 @@
{
"extends": "../../../tsconfig.base.client.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cordis"
},
{
"path": "../connection"
},
{
"path": "../runtime"
},
{
"path": "../ui-command"
},
{
"path": "../ui-conversation"
},
{
"path": "../ui-primitives"
},
{
"path": "../ui-slash"
},
{
"path": "../ui-slots"
},
{
"path": "../../support/invariants"
}
]
}

View File

@@ -0,0 +1,3 @@
import { clientBundle } from '../tsdown.client.ts'
export default clientBundle('@deepseek-ai/dsh-client-ui-model', ['lib/types/index.js', 'lib/types/invariant.js'])