refactor(client): consume migrated Remote namespaces

This commit is contained in:
imccyu
2026-08-27 21:57:56 +08:00
parent 2d4393d842
commit 5b2f679e4a
25 changed files with 219 additions and 212 deletions
+12 -8
View File
@@ -5,8 +5,8 @@ import agentPresetsRemote from '@deepseek-ai/dsh-agent-presets/remote'
import commandsRemote from '@deepseek-ai/dsh-commands/remote'
import settingsControllerRemote from '@deepseek-ai/dsh-api-settings-controller/remote'
import goalsRemote from '@deepseek-ai/dsh-goal/remote'
import llmRemote from '@deepseek-ai/dsh-llm/remote'
import dynamicRemote from '@deepseek-ai/dsh-cordis-host-runner/remote'
import fileReferencesRemote from '@deepseek-ai/dsh-file-reference/remote'
import pluginInventoryRemote from '@deepseek-ai/dsh-host-plugin-inventory/remote'
import messageFeedbackRemote from '@deepseek-ai/dsh-message-feedback/remote'
import sessionReferencesRemote from '@deepseek-ai/dsh-session-reference/remote'
@@ -20,8 +20,8 @@ export type { PluginInventorySnapshot } from '@deepseek-ai/dsh-host-plugin-inven
export type {} from '@deepseek-ai/dsh-agent-presets/remote'
export type {} from '@deepseek-ai/dsh-commands/remote'
export type {} from '@deepseek-ai/dsh-api-settings-controller/remote'
export type {} from '@deepseek-ai/dsh-file-reference/remote'
export type {} from '@deepseek-ai/dsh-goal/remote'
export type {} from '@deepseek-ai/dsh-llm/remote'
export type {} from '@deepseek-ai/dsh-host-plugin-inventory/remote'
export type {} from '@deepseek-ai/dsh-message-feedback/remote'
export type {} from '@deepseek-ai/dsh-session-reference/remote'
@@ -54,11 +54,10 @@ export type {} from '@deepseek-ai/dsh-api-session-controller/types'
* the carrier's runtime values stay behind their own module edge.
*/
export type {
ConfigurableProviderView, ConnectionHandle, ConnectionSinks, ContentBlock,
DiscoveredModelView, IApiClient,
MessageId, ModelCatalog, ModelCatalogFailure, ModelProviderGroup, ModelReasoningEffort, ModelSelection,
ConnectionHandle, ConnectionSinks, ContentBlock, IApiClient,
MessageId, ModelCatalogFailure, ModelProviderGroup, ModelReasoningEffort, ModelSelection,
RpcError, RpcId, RpcRequest, RpcResponse, RpcResult, SessionId,
SkillEntry, StreamChunk,
StreamChunk,
} from '@deepseek-ai/dsh-client-connection/client'
export type {} from '@deepseek-ai/dsh-api-gateway/client'
export type {} from '@deepseek-ai/dsh-cordis-host-runner/remote'
@@ -111,6 +110,11 @@ export type { CredentialInfo } from '@deepseek-ai/dsh-credentials/types'
export type {
SettingsDescribeValue, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView,
} from '@deepseek-ai/dsh-settings/types'
// Provider registry and discovery vocabulary for the llm namespace.
export type {
LlmConfigurableProvider, LlmDiscoveredModel, LlmModelDiscoveryError,
LlmModelDiscoveryRequest, LlmProviderInfo,
} from '@deepseek-ai/dsh-llm/types'
// Reference-discovery result vocabulary for the fileReferences and
// sessionReferenceResolver namespaces.
export type { FileReferenceCandidate } from '@deepseek-ai/dsh-file-reference/types'
@@ -123,6 +127,7 @@ export type ClientFailure =
| import('@deepseek-ai/dsh-api-session-controller/types').SessionError
| import('@deepseek-ai/dsh-api-settings-controller/types').CredentialError
| import('@deepseek-ai/dsh-api-settings-controller/types').SettingsError
| import('@deepseek-ai/dsh-llm/types').LlmModelDiscoveryError
| import('@deepseek-ai/dsh-subagent/client').SubagentControlError
| import('@deepseek-ai/dsh-api-workspace-controller/types').WorkspaceError
@@ -150,8 +155,7 @@ export async function apply(ctx: Context): Promise<() => Promise<void>> {
const disposers: Array<() => Promise<void>> = []
try {
for (const contribution of [
agentPresetsRemote, commandsRemote, settingsControllerRemote, goalsRemote, dynamicRemote,
fileReferencesRemote,
agentPresetsRemote, commandsRemote, settingsControllerRemote, goalsRemote, llmRemote, dynamicRemote,
pluginInventoryRemote, messageFeedbackRemote, sessionReferencesRemote,
subagentsRemote, sessionRemote, workspaceRemote,
]) {
@@ -8,11 +8,8 @@
export type {
ApiProxy, HostApi,
ResponseValue,
SkillsApi, SkillEntry,
ModelCatalog, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
ModelReasoningEffort, ModelSelection,
SettingsApi,
ConfigurableProviderView, DiscoveredModelView, LlmApi,
} from '@deepseek-ai/dsh-host-apiproxy/api'
export type {
RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode,
@@ -551,7 +551,7 @@ const OPENAI_REASONING = {
defaultEffort: 'medium',
}
/** Catalog served by `llm.models` (fresh copies per call). */
/** Catalog served by `session/modelCatalog` (fresh copies per call). */
function fixtureModelGroups(): ModelProviderGroup[] {
return [
{
@@ -1834,6 +1834,25 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
},
}
},
openSettingsDocument(): RpcResult<{ opened: true }> {
return { ok: true, value: { opened: true } }
},
openAgentPresetDirectory(agentPreset: string): RpcResult<
{ opened: true } | { opened: false; path: string }
> {
const existing = fixturePresets.get(agentPreset)
if (existing === undefined || existing.trust === 'system') {
return {
ok: false,
error: {
code: 'agent-preset-read-only',
message: `agent preset "${agentPreset}" ships with the deployment`,
details: { agentPreset, reason: 'it ships with the deployment' },
},
}
}
return { ok: true, value: { opened: true } }
},
}
const credentialRemotes = {
@@ -1999,10 +2018,6 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
function ok<P, T>(request: RpcRequest<P>, value: T): Promise<RpcResponse<T>> {
return Promise.resolve({ rpcId: request.rpcId, result: { ok: true, value } })
}
function err<P, T>(request: RpcRequest<P>, error: Extract<RpcResult<T>, { ok: false }>['error']): Promise<RpcResponse<T>> {
return Promise.resolve({ rpcId: request.rpcId, result: { ok: false, error } })
}
function sessionOk<T>(value: T): Promise<ConnectionRpcResult<T>> {
return Promise.resolve({ ok: true, value })
}
@@ -2012,16 +2027,6 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
}
const summaryOf = (id: SessionId): FixtureSessionSummary | undefined => sessions.find(s => s.sessionId === id)
/** Shared session guard for sessionId-addressed catalog routes: the error
* response when the session is unknown, undefined when it exists. */
const requireSession = (request: RpcRequest<{ sessionId: SessionId }>): Promise<RpcResponse<never>> | undefined => {
if (summaryOf(request.payload.sessionId) !== undefined) return undefined
return err<{ sessionId: SessionId }, never>(request, {
code: 'session-not-found',
message: `no session ${request.payload.sessionId}`,
details: { sessionId: request.payload.sessionId },
})
}
const requireRemoteSession = (
request: { readonly sessionId: SessionId },
): Promise<ConnectionRpcResult<never>> | undefined => {
@@ -3398,65 +3403,6 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
describe: request => ok(request, {
version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions, home: FIXTURE_HOME, canOpenPath: true,
}),
openPath: request => ok(request, { opened: true as const }),
},
agentPresets: {
// Native opens are deterministic no-op successes in this fixture, so the
// open-directory affordance renders and the path-text fallback stays a
// component-test concern.
openDocument: (request) => {
const { agentPreset } = request.payload
const existing = fixturePresets.get(agentPreset)
if (existing === undefined || existing.trust === 'system') {
return err(request, {
code: 'agent-preset-read-only',
message: `agent preset "${agentPreset}" ships with the deployment`,
details: { agentPreset, reason: 'it ships with the deployment' },
})
}
return ok(request, { opened: true as const })
},
},
skills: {
list: (request) => {
const missing = requireSession(request)
if (missing !== undefined) return missing
return ok(request, {
skills: [
{ name: 'fixture-demo', description: 'fixture 技能样本', whenToUse: '仅供 UI 目录渲染验收', modelInvocable: true },
{ name: 'fixture-user-only', description: 'fixture 仅用户技能样本', modelInvocable: false },
],
})
},
},
settings: {
// Native opens are deterministic no-op successes in this fixture, as is host.openPath.
openDocument: request => ok(request, { opened: true as const }),
},
llm: {
providers: request => ok(request, {
providers: [
{ provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [], active: true },
{ provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'], active: true, declared: false },
{ provider: 'anthropic', displayName: 'anthropic', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'anthropic'], active: false, declared: false },
// One hand-declared route, so a surface reading this fixture meets
// the tagged shape rather than only the shipped one.
{ provider: 'acme-gateway', displayName: 'Acme Gateway', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'acme-gateway'], active: true, declared: true },
],
}),
models: request => ok(request, {
default: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
routableProviders: ['deepseek-official', 'openai', 'acme-gateway'],
groups: fixtureModelGroups(),
failures: [],
}),
// The fixture endpoint is imaginary, so the interrogation answers the
// catalog it already serves — enough for a surface to exercise adopting
// candidates without a reachable provider.
discoverModels: request => ok(request, {
models: fixtureModelGroups().flatMap(group => group.models.map(model => ({ id: model.id, name: model.name }))),
}),
},
// Satisfies the ApiProxy contract type only: the browser export button
// hands GET /api/session.export to the native download manager, so this
@@ -3484,6 +3430,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
refs?: readonly string[]
value?: string
ns?: string
settingsNs?: string
agentPreset?: string
from?: string
id?: string
@@ -3538,6 +3485,58 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
case 'credentials/set': return Promise.resolve(credentialRemotes.set(args.ref as string))
case 'credentials/unset': return Promise.resolve(credentialRemotes.unset(args.ref as string))
case 'settings/describe': return Promise.resolve(settingsRemotes.describe())
case 'settings/openSettingsDocument': return Promise.resolve(settingsRemotes.openSettingsDocument())
case 'settings/openAgentPresetDirectory': return Promise.resolve(
settingsRemotes.openAgentPresetDirectory(args.agentPreset as string),
)
case 'skills/list': {
const skillRequest = request as { readonly sessionId: SessionId }
const missing = requireRemoteSession(skillRequest)
if (missing !== undefined) return missing
return sessionOk({
skills: [
{ name: 'fixture-demo', description: 'fixture 技能样本', whenToUse: '仅供 UI 目录渲染验收', modelInvocable: true },
{ name: 'fixture-user-only', description: 'fixture 仅用户技能样本', modelInvocable: false },
],
})
}
case 'session/openWorkspacePath': {
const pathRequest = request as { readonly sessionId: SessionId; readonly path: string }
const missing = requireRemoteSession(pathRequest)
return missing ?? sessionOk({ opened: true as const })
}
case 'session/modelCatalog': return Promise.resolve({
ok: true,
value: {
default: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
routableProviders: ['deepseek-official', 'openai', 'acme-gateway'],
groups: fixtureModelGroups(),
failures: [],
},
})
case 'llm/listProviders': return Promise.resolve({
ok: true,
value: [
{ id: 'deepseek-official', name: 'DeepSeek' },
{ id: 'openai', name: 'openai' },
{ id: 'acme-gateway', name: 'Acme Gateway' },
],
})
case 'llm/listConfigurableProviders': return Promise.resolve({
ok: true,
value: [
{ provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [] },
{ provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'], declared: false },
{ provider: 'anthropic', displayName: 'anthropic', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'anthropic'], declared: false },
{ provider: 'acme-gateway', displayName: 'Acme Gateway', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'acme-gateway'], declared: true },
],
})
// The fixture endpoint is imaginary, so interrogation answers the
// catalog it already serves without a network request.
case 'llm/discoverModels': return Promise.resolve({
ok: true,
value: fixtureModelGroups().flatMap(group => group.models.map(model => ({ id: model.id, name: model.name }))),
})
case 'settings/update': return Promise.resolve(settingsRemotes.update(args.ns as string))
case 'settings/replace': return Promise.resolve(settingsRemotes.replace(args.ns as string))
case 'settings/mutate': return Promise.resolve(settingsRemotes.mutate(args.ns as string))
@@ -3643,13 +3642,13 @@ export class FixtureApiClient extends AbstractApiClient {
payload: RequestPayload<K>,
signal?: AbortSignal,
): Promise<RpcResponse<ResponseValue<K>>> {
void signal
const request = rpcRequest(payload)
const full: ClientRequest = { type: 'client-request', rpcId: request.rpcId, method, payload }
this.onEnvelope(full)
const response = await this.dispatch(
method,
request as RpcRequest<never>,
signal ?? new AbortController().signal,
) as RpcResponse<ResponseValue<K>>
const fullResponse: ServerResponse = { type: 'server-response', rpcId: response.rpcId, result: response.result }
this.onEnvelope(fullResponse)
@@ -3660,17 +3659,9 @@ export class FixtureApiClient extends AbstractApiClient {
private dispatch(
method: keyof RpcMethodMap,
request: RpcRequest<never>,
signal: AbortSignal,
): Promise<RpcResponse<unknown>> {
switch (method) {
case 'host.describe': return this.api.host.describe(request)
case 'host.openPath': return this.api.host.openPath(request, new AbortController().signal)
case 'skill.list': return this.api.skills.list(request)
case 'agentPreset.openDocument': return this.api.agentPresets.openDocument(request, new AbortController().signal)
case 'settings.openDocument': return this.api.settings.openDocument(request, signal)
case 'llm.providers': return this.api.llm.providers(request)
case 'llm.models': return this.api.llm.models(request)
case 'llm.discoverModels': return this.api.llm.discoverModels(request, signal)
}
}
@@ -31,14 +31,11 @@ declare module '@deepseek-ai/cordis' {
// ---- Contract re-exports (browser-safe apiproxy channels + core types) ----
export type {
ApiProxy, HostApi,
SkillsApi, SkillEntry,
ModelCatalog, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
MessageId, ModelReasoningEffort, ModelSelection,
RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode,
ClientRequest, ServerResponse, RpcMessage,
HostDescription, IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk,
SettingsApi,
ConfigurableProviderView, DiscoveredModelView, LlmApi,
} from './api.ts'
export {
RpcId,
@@ -65,7 +65,7 @@ export function apply(ctx: ClientContext): void {
// One roster, four surfaces. The chip is registered in a later scope, so it
// subscribes here rather than being reached from this one.
const rosterReaders = new Set<() => void>()
const section = new AgentPresetSectionController({ ...api, ...settingsWire }, ctx.remote, () => {
const section = new AgentPresetSectionController(api, ctx.remote, () => {
void controller.load()
for (const read of rosterReaders) read()
})
@@ -15,7 +15,6 @@
*/
import type { ClientRemote, IApiClient } from '@deepseek-ai/dsh-api-remotes/client'
import type { SettingsWireFace } from '@deepseek-ai/dsh-client-ui-settings/client'
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
import { beginRosterRead, messageOf, writeDefaultPreset } from './settings-store.ts'
@@ -134,8 +133,8 @@ export class AgentPresetSectionController {
readonly store: SnapshotStore<AgentPresetSectionState> = createSnapshotStore(INITIAL)
constructor(
private readonly api: SettingsWireFace & Pick<IApiClient, 'agentPresets' | 'host'>,
private readonly remote: Pick<ClientRemote, 'agentPresets'>,
private readonly api: Pick<IApiClient, 'host'>,
private readonly remote: Pick<ClientRemote, 'agentPresets' | 'settings'>,
/**
* Called after this page changes the roster DIRECTORY, so the other
* surfaces reading the same roster re-read it. A settings field moving is
@@ -296,13 +295,13 @@ export class AgentPresetSectionController {
*/
async openLocation(id: string): Promise<void> {
try {
const response = await this.api.agentPresets.openDocument({ agentPreset: id })
if (!response.result.ok) {
this.set({ error: response.result.error.message })
const result = await this.remote.settings.openAgentPresetDirectory(id)
if (!result.ok) {
this.set({ error: result.error.message })
return
}
if (response.result.value.opened) return
const { path } = response.result.value
if (result.value.opened) return
const { path } = result.value
this.set({ revealedPaths: { ...this.store.getSnapshot().revealedPaths, [id]: path } })
} catch (error) {
this.set({ error: messageOf(error) })
@@ -350,7 +349,7 @@ export class AgentPresetSectionController {
* @returns once the write settled and the roster was re-read.
*/
async makeDefault(id: string): Promise<void> {
const failure = await writeDefaultPreset(this.api, id)
const failure = await writeDefaultPreset(this.remote, id)
if (failure !== undefined) {
this.set({ error: failure })
return
+1 -3
View File
@@ -74,8 +74,7 @@
"@deepseek-ai/dsh-session-stats": "workspace:^",
"@deepseek-ai/dsh-settings": "workspace:^",
"@deepseek-ai/dsh-token-meter": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-util-workspace-path": "workspace:^"
"@deepseek-ai/dsh-tools": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/cordis": "workspace:^",
@@ -105,7 +104,6 @@
"@deepseek-ai/dsh-settings": "workspace:^",
"@deepseek-ai/dsh-token-meter": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-util-workspace-path": "workspace:^",
"@types/react": "~18.3.1",
"react": "^18.2.0"
},
+6 -5
View File
@@ -1,10 +1,10 @@
/** Register the Chat Conversation target, renderers, stats, and details surface. */
import type { Context } from '@deepseek-ai/cordis'
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import type {} from '@deepseek-ai/dsh-api-remotes/client'
import type { SessionBinding } from '@deepseek-ai/dsh-api-session-controller/client'
import type { BoundActions, ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { resolveWorkspacePath } from '@deepseek-ai/dsh-util-workspace-path'
// Type-only service and declaration merges used by the apply world.
import type {} from '@deepseek-ai/dsh-client-locale/client'
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
@@ -46,7 +46,8 @@ const CHAT_NODE_INJECT: ChatNodeTurnDataInjected = {
/** Services required by the Chat target and its presentation registrations. */
export const inject = [
'slots', 'sessions', 'uiSession', 'uiConversation', 'uiWorkspace', 'layout', 'locale', 'settingsScope',
'slots', 'sessions', 'uiSession', 'uiConversation', 'layout', 'locale',
'settingsScope', 'remote', 'remote.session',
]
/**
@@ -115,9 +116,9 @@ export function apply(ctx: Context): void {
ctx.layout.openDetails()
},
fileMentions: (owner: TurnTailOwnerProps) => ctx.get('chatFileMentions')?.forClosing(owner),
openFile: (path) => {
const cwd = ctx.sessions.list.getSnapshot().byId[sessionId]?.cwd
return ctx.uiWorkspace.openPath(resolveWorkspacePath(cwd, path))
openFile: async (path) => {
const result = await ctx.remote.session.openWorkspacePath({ sessionId, path })
if (!result.ok) throw new Error(`path open failed: ${result.error.message}`)
},
loadOlder: () => { void session.loadOlder() },
loadImage: Object.assign(
-3
View File
@@ -50,9 +50,6 @@
{
"path": "../../runtime-diagnostics/invariants"
},
{
"path": "../../util/workspace-path"
},
{
"path": "../../session/session-stats"
},
@@ -33,7 +33,6 @@
"client": {
"inject": [
"@deepseek-ai/dsh-api-session-controller",
"@deepseek-ai/dsh-client-connection",
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-ui-commands",
"@deepseek-ai/dsh-api-remotes"
@@ -49,7 +48,6 @@
"peerDependencies": {
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-api-session-controller": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-ui-commands": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
@@ -64,7 +62,6 @@
"devDependencies": {
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-api-session-controller": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-commands": "workspace:^",
@@ -1,9 +1,6 @@
/** One Host-generation model catalog shared by every Session selector. */
import {
type IApiClient,
type ModelCatalog,
} from '@deepseek-ai/dsh-client-connection/client'
import type { ClientRemote, ModelCatalog } from '@deepseek-ai/dsh-api-remotes/client'
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
/** Observable lifecycle of the shared model catalog. */
@@ -25,8 +22,8 @@ export class ModelCatalogDirectory {
private generation = 0
private inflight: Promise<ModelCatalog> | undefined
/** @param api - shared connection API client. */
constructor(private readonly api: IApiClient) {}
/** @param session - Session Remote namespace carrying the Host-generation catalog. */
constructor(private readonly session: Pick<ClientRemote['session'], 'modelCatalog'>) {}
/**
* Return the current generation's catalog, sharing its one in-flight load.
@@ -41,14 +38,14 @@ export class ModelCatalogDirectory {
draft.status = 'loading'
draft.error = null
})
const operation = this.api.llm.models({}).then((response) => {
if (!response.result.ok) {
throw new Error(`${response.result.error.code}: ${response.result.error.message}`)
const operation = this.session.modelCatalog().then((response) => {
if (!response.ok) {
throw new Error(`${response.error.code}: ${response.error.message}`)
}
if (generation === this.generation) {
this.store.set({ value: response.result.value, status: 'ready', error: null })
this.store.set({ value: response.value, status: 'ready', error: null })
}
return response.result.value
return response.value
}).catch((error: unknown) => {
if (generation === this.generation) {
this.store.update((draft) => {
@@ -2,7 +2,7 @@
* Model selection plugin, browser half — TWO entries over ONE per-session
* directory owned by ModelDirectoryResolver (`ctx.modelDirectories`). The /model popupSelect
* contribution and the composer's named `conversation.input.model` seat share
* one Host-generation `llm.models` catalog, combine it with the Session's
* one Host-generation `session/modelCatalog` catalog, combine it with the Session's
* durable model-selection projection, and submit through `session.selectModel`.
* 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
@@ -15,7 +15,6 @@
import { Service } from '@deepseek-ai/cordis'
import type { Context } from '@deepseek-ai/cordis'
import type {} from '@deepseek-ai/dsh-api-session-controller/client'
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { ModelCatalogDirectory } from './catalog.ts'
import { ModelDirectory } from './directory.ts'
@@ -34,7 +33,7 @@ interface LiveState {
/** The `ctx.modelDirectories` session model-selection service. */
export class ModelDirectoryResolver extends Service {
static inject = ['sessions', 'remote', 'remote.session', 'connection']
static inject = ['sessions', 'remote', 'remote.session']
private readonly live: LiveState = { directories: new Map() }
private readonly catalog: ModelCatalogDirectory
@@ -49,9 +48,7 @@ export class ModelDirectoryResolver extends Service {
constructor(ctx: Context, config: { blockReason: () => string }) {
super(ctx, 'modelDirectories')
this.blockReason = config.blockReason
const connection = ctx.get('connection') as ConnectionHandle | undefined
if (connection === undefined) throw new Error('ui-model-selection: connection service is unavailable')
this.catalog = new ModelCatalogDirectory(connection.api)
this.catalog = new ModelCatalogDirectory(ctx.remote.session)
void this.catalog.load().catch(() => { /* selectors expose the shared error */ })
ctx.on('connection/reset', () => {
this.catalog.resetGeneration()
@@ -55,7 +55,7 @@ const NS = 'settings'
* ui-settings' apply, whose activation order relative to this one is NOT
* constrained; registrations depend on their slots through `slots.inject()`.
*/
export const inject = ['slots', 'locale', 'connection', 'settingsScope']
export const inject = ['slots', 'locale', 'connection', 'remote', 'remote.settings', 'settingsScope']
/**
* Register the `settings` dictionaries, the chrome content, and the General
@@ -72,7 +72,7 @@ export function apply(ctx: ClientContext): void {
const connection = ctx.get('connection') as ConnectionHandle
// The shared SettingsScope mirror updates after document commits and reconnects.
const documentController = connection.isLoopback
? new SettingsDocumentStore(connection.api, ctx.settingsScope.describe())
? new SettingsDocumentStore(ctx.remote, ctx.settingsScope.describe())
: undefined
const documentInjected = documentController === undefined
? undefined
@@ -1,6 +1,6 @@
/** State owner for the optional local settings-document action. */
import type { IApiClient } from '@deepseek-ai/dsh-api-remotes/client'
import type { ClientRemote } from '@deepseek-ai/dsh-api-remotes/client'
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { SettingsDescribeFace } from '@deepseek-ai/dsh-client-ui-settings/client'
@@ -32,7 +32,7 @@ export class SettingsDocumentStore {
* @param describeFace - the shared mirror's describe face (`hasDocument` source).
*/
constructor(
private readonly api: Pick<IApiClient, 'settings'>,
private readonly remote: Pick<ClientRemote, 'settings'>,
private readonly describeFace: SettingsDescribeFace,
) {}
@@ -63,8 +63,8 @@ export class SettingsDocumentStore {
state.error = null
})
try {
const response = await this.api.settings.openDocument({})
if (!response.result.ok) throw new Error(response.result.error.message)
const result = await this.remote.settings.openSettingsDocument()
if (!result.ok) throw new Error(result.error.message)
} catch (error) {
this.store.update((state) => { state.error = messageOf(error) })
} finally {
@@ -47,7 +47,6 @@
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
@@ -55,7 +54,6 @@
},
"devDependencies": {
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-store": "workspace:^",
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
@@ -16,11 +16,11 @@
import { useState } from 'react'
import type { ReactNode } from 'react'
import type { DiscoveredModelView, IApiClient } from '@deepseek-ai/dsh-api-remotes/client'
import type { LlmDiscoveredModel } from '@deepseek-ai/dsh-api-remotes/client'
import { Button, Modal } from '@deepseek-ai/dsh-client-ui-primitives'
import { formatCapacity, parseCapacity } from './DeepSeekModelsEditor.tsx'
import type { DeepSeekModelDraft } from './DeepSeekModelsEditor.tsx'
import { messageOf } from './store.ts'
import { messageOf, type ModelsWire } from './store.ts'
import type { en } from './locales.ts'
import styles from './ModelsSection.module.css'
@@ -80,7 +80,7 @@ export interface ModelListEditorProps {
*/
probeBlocked?: keyof typeof en | undefined
/** Wire face the fetch action calls. */
api: Pick<IApiClient, 'llm'>
api: Pick<ModelsWire, 'llm'>
/** Section copy. */
t: (key: keyof typeof en) => string
/** Disable every control (read-only deployment or a pending write). */
@@ -142,7 +142,7 @@ function capacitySpelling(value: number | undefined): string {
}
/** Adopt a candidate, keeping whatever capacities the provider disclosed. */
function adopt(candidate: DiscoveredModelView): ModelDraft {
function adopt(candidate: LlmDiscoveredModel): ModelDraft {
return {
id: candidate.id,
...candidate.name === undefined ? {} : { name: candidate.name },
@@ -160,7 +160,7 @@ export function ModelListEditor(props: ModelListEditorProps): ReactNode {
const { models, onChange, probe, api, t, disabled } = props
const [busy, setBusy] = useState(false)
const [failure, setFailure] = useState<string | undefined>(undefined)
const [candidates, setCandidates] = useState<readonly DiscoveredModelView[] | undefined>(undefined)
const [candidates, setCandidates] = useState<readonly LlmDiscoveredModel[] | undefined>(undefined)
const [picked, setPicked] = useState<ReadonlySet<string>>(new Set())
// Rows carry an id and a name; capacities are the exception, so they stay
// folded until asked for rather than crowding every row with four inputs.
@@ -229,18 +229,17 @@ export function ModelListEditor(props: ModelListEditorProps): ReactNode {
setBusy(true)
setFailure(undefined)
try {
const response = await api.llm.discoverModels({
settingsNs: probe.settingsNs,
const response = await api.llm.discoverModels(probe.settingsNs, {
...probe.provider === undefined ? {} : { provider: probe.provider },
...probe.baseURL === undefined || probe.baseURL.length === 0 ? {} : { baseURL: probe.baseURL },
...probe.api === undefined ? {} : { api: probe.api },
...probe.apiKey === undefined ? {} : { apiKey: probe.apiKey },
})
if (!response.result.ok) {
setFailure(response.result.error.message)
if (!response.ok) {
setFailure(response.error.message)
return
}
const found = response.result.value.models
const found = response.value
if (found.length === 0) {
setFailure(t('fetchEmpty'))
return
@@ -7,7 +7,6 @@
* packages/client/AGENTS.md.
*/
import type { Context as ClientContext } from '@deepseek-ai/cordis'
import type { ConnectionHandle } from '@deepseek-ai/dsh-api-remotes/client'
// Type-only: pulls the shell's SlotMap merge (the 'settings.section' entry).
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
@@ -42,7 +41,9 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
/** Dictionary namespace owned by this plugin. */
const NS = 'settings.models'
export type { ModelsCredentials, ModelsSettingsState, ModelsWire, ProviderRow } from './store.ts'
export type {
ModelsCredentials, ModelsLlm, ModelsSettingsState, ModelsWire, ProviderDirectoryEntry, ProviderRow,
} from './store.ts'
/**
* Refetch the page snapshot only after its first load: an unopened Models
@@ -60,7 +61,7 @@ export function refreshIfLoaded(controller: ModelsSettingsStore): void {
* constrained; registration depends on each slot through `slots.inject()`.
*/
export const inject = [
'slots', 'locale', 'connection', 'remote', 'remote.credentials', 'remote.settings',
'slots', 'locale', 'remote', 'remote.credentials', 'remote.llm', 'remote.settings',
'settingsScope', 'settingsSchema',
]
@@ -73,14 +74,11 @@ export const inject = [
export function apply(ctx: ClientContext): void {
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-settings-models: copy dictionaries')
const connection = ctx.get('connection') as ConnectionHandle
const schema = createSettingsSchemaOperations(ctx.settingsSchema)
// The page's two carriers under one face: model discovery and the catalog
// still ride the unary API, while settings and credentials are Remote
// namespaces.
// Every configuration operation rides its owning Remote namespace.
const wire: ModelsWire = {
...connection.api,
credentials: ctx.remote.credentials,
llm: ctx.remote.llm,
settings: ctx.remote.settings,
}
const controller = new ModelsSettingsStore(wire, schema, ctx.settingsScope.describe())
@@ -4,7 +4,7 @@
* without editing it.
*
* `settings.models.provider-card` is keyed by the row's owning settings
* namespace (`ConfigurableProviderView.settingsNs`): an adapter family's
* namespace (`ProviderDirectoryEntry.settingsNs`): an adapter family's
* companion plugin registers one entry under the family's namespace and
* receives every card of that family — shipped, added, and hand-declared rows
* alike — while the section never learns what the namespace means. Keying on
@@ -17,8 +17,8 @@
* the declaration. The types therefore live with their declarer.
*/
import type { ConfigurableProviderView } from '@deepseek-ai/dsh-api-remotes/client'
import type {} from '@deepseek-ai/dsh-client-ui-slots'
import type { ProviderDirectoryEntry } from './store.ts'
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface SlotMap {
@@ -42,7 +42,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
/** Owner share of one provider-card extension occurrence. */
export interface ProviderCardExtrasOwnerProps {
/** The card's directory row (route id, display name, settings address, live state). */
provider: ConfigurableProviderView
provider: ProviderDirectoryEntry
/** Whether any layer configures this provider (its profile resolves); `false` while the add-provider draft edits a dormant row. */
configured: boolean
/** Whether the row's referenced api-key credential is confirmed configured (the page's credential join). */
@@ -1,13 +1,14 @@
/**
* Models settings page store: one snapshot joining the configurable-provider
* directory (`llm.providers`), the settings namespaces (shared settings mirror),
* directory (`llm/listProviders` joined with `llm/listConfigurableProviders`),
* the settings namespaces (shared settings mirror),
* and the referenced credentials (`credentials/describe`). The host stays the
* single fact source — every mutation writes through the wire and the page
* re-renders from the next describe, pushed or refetched.
*/
import type {
ClientRemote, ConfigurableProviderView, CredentialInfo, IApiClient, SettingsNamespaceView,
ClientRemote, CredentialInfo, LlmConfigurableProvider, LlmProviderInfo, SettingsNamespaceView,
} from '@deepseek-ai/dsh-api-remotes/client'
import type { SnapshotStore } from '@deepseek-ai/dsh-client-store'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
@@ -23,22 +24,71 @@ const PROBE_ROUTE = '\u0000probe'
/** The credentials Remote methods the Models page reads and writes through. */
export type ModelsCredentials = Pick<ClientRemote['credentials'], 'describe' | 'set' | 'unset'>
/** LLM Remote methods used by the Models page. */
export type ModelsLlm = Pick<
ClientRemote['llm'],
'discoverModels' | 'listConfigurableProviders' | 'listProviders'
>
/** One provider row after joining the configurable directory with live routes. */
export interface ProviderDirectoryEntry {
readonly provider: string
readonly displayName: string
readonly settingsNs: string
readonly settingsPath: readonly string[]
readonly active: boolean
readonly declared?: boolean
}
/**
* Every wire face the Models page reaches: the settings and llm unary domains,
* plus the credentials Remote namespace, which is addressed by reference name
* and never answers with a value.
* Join declared configurable providers with the currently registered routes.
* @param registered - live provider routes in registration order.
* @param directory - declared configurable providers in declaration order.
* @returns declared rows followed by live routes with no declaration.
*/
export interface ModelsWire extends Pick<IApiClient, 'llm'> {
export function joinProviderDirectory(
registered: readonly LlmProviderInfo[],
directory: readonly LlmConfigurableProvider[],
): ProviderDirectoryEntry[] {
const active = new Set(registered.map(provider => provider.id))
const declared = new Set(directory.map(entry => entry.provider))
const rows: ProviderDirectoryEntry[] = directory.map(entry => ({
provider: entry.provider,
displayName: entry.displayName,
settingsNs: entry.settingsNs,
settingsPath: [...entry.settingsPath],
active: active.has(entry.provider),
...entry.declared === undefined ? {} : { declared: entry.declared },
}))
for (const provider of registered) {
if (declared.has(provider.id)) continue
rows.push({
provider: provider.id,
displayName: provider.name,
settingsNs: '',
settingsPath: [],
active: true,
})
}
return rows
}
/**
* Every Remote wire face the Models page reaches.
*/
export interface ModelsWire {
/** The settings Remote namespace: the redacted read and the profile writes. */
settings: SettingsRemote
/** Credential state and writes for the references provider profiles name. */
credentials: ModelsCredentials
/** Provider directory reads and draft endpoint discovery. */
llm: ModelsLlm
}
/** One provider row the page renders. */
export interface ProviderRow {
/** The directory entry (route id, display name, settings address, live state). */
entry: ConfigurableProviderView
entry: ProviderDirectoryEntry
/** Whether any layer configures this provider (its profile resolves). */
configured: boolean
/** Whether the user layer alone carries the profile (removal restores the base). */
@@ -157,20 +207,22 @@ export class ModelsSettingsStore {
async load(): Promise<void> {
const generation = ++this.generation
this.store.update((s) => { s.status = 'loading'; s.error = null })
let providers: ConfigurableProviderView[]
let providers: ProviderDirectoryEntry[]
let writable: boolean
let views: readonly SettingsNamespaceView[]
try {
const [providersResponse] = await Promise.all([
this.api.llm.providers({}),
const [registered, declared] = await Promise.all([
this.api.llm.listProviders(),
this.api.llm.listConfigurableProviders(),
this.describeFace.ensure(),
])
if (!providersResponse.result.ok) throw new Error(providersResponse.result.error.message)
if (!registered.ok) throw new Error(registered.error.message)
if (!declared.ok) throw new Error(declared.error.message)
const mirrored = this.describeFace.getSnapshot()
if (mirrored.view === undefined) {
throw new Error(mirrored.error ?? 'settings are unavailable in this browser')
}
providers = providersResponse.result.value.providers
providers = joinProviderDirectory(registered.value, declared.value)
writable = mirrored.view.writable
views = mirrored.view.namespaces
} catch (error) {
@@ -9,7 +9,6 @@
* settings scope, which keeps them unaware of one another and of other tabs.
*/
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
import type {} from '@deepseek-ai/dsh-client-locale/client'
// Type-only: the settings shell's SlotMap merge (the 'settings.section' entry)
@@ -54,14 +53,15 @@ export type { WebSearchCardFace, WebSearchCardState } from './web-search-card-co
const NS = 'settings.plugins'
/** Required services (cordis fiber inject). */
export const inject = ['slots', 'locale', 'connection', 'remote', 'remote.credentials', 'settingsScope']
export const inject = [
'slots', 'locale', 'connection', 'remote', 'remote.credentials', 'remote.session', 'settingsScope',
]
/**
* Mount the plugin configuration section and the cards this package ships.
* @param ctx - the browser plugin context.
*/
export function apply(ctx: ClientContext): void {
const { api } = ctx.get('connection') as ConnectionHandle
const t = ctx.locale.bind(NS)
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-settings-plugins: section dictionaries')
@@ -71,7 +71,7 @@ export function apply(ctx: ClientContext): void {
ctx.settingsScope.bind({ namespace: WEB_SEARCH_NS }), ctx.remote.credentials)
const subagentModelSelection = new SubagentModelSelectionCardController(
ctx.settingsScope.bind({ namespace: SUBAGENT_MODEL_SELECTION_NS }),
api,
ctx.remote.session,
)
// The credential a card reports is not part of any settings section, so its
@@ -1,7 +1,7 @@
/** Staged editor for the Host-owned subagent model allowlist. */
import type {
IApiClient,
ClientRemote,
ModelProviderGroup,
} from '@deepseek-ai/dsh-api-remotes/client'
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
@@ -145,11 +145,11 @@ export class SubagentModelSelectionCardController {
/**
* @param scope - bound `subagent-model-selection` settings scope.
* @param api - Host LLM directory face.
* @param session - Host Session model-catalog face.
*/
constructor(
private readonly scope: SettingsScope<SubagentModelSelectionSettings>,
private readonly api: Pick<IApiClient, 'llm'>,
private readonly session: Pick<ClientRemote['session'], 'modelCatalog'>,
) {
this.store = createSnapshotStore(this.projection())
this.unsubscribe = scope.subscribe(() => {
@@ -321,11 +321,11 @@ export class SubagentModelSelectionCardController {
this.catalogPartial = false
this.publish()
try {
const response = await this.api.llm.models({})
const response = await this.session.modelCatalog()
if (generation !== this.catalogGeneration) return
if (!response.result.ok) throw new Error(response.result.error.message)
this.catalogGroups = response.result.value.groups
this.catalogPartial = response.result.value.failures.length > 0
if (!response.ok) throw new Error(response.error.message)
this.catalogGroups = response.value.groups
this.catalogPartial = response.value.failures.length > 0
this.catalogStatus = 'ready'
} catch {
if (generation !== this.catalogGeneration) return
+6 -6
View File
@@ -1,6 +1,6 @@
/**
* Skill reference plugin, browser half: registers the '/' skill source
* candidates from the skill.list RPC addressed by the per-call session
* candidates from the `skills/list` Remote addressed by the per-call session
* projection's sessionId (sessions are always agent-backed; the host
* resolves cwd from the session header). A pick lands the literal `/name `
* text and the prompt ships the same literal (plain-text-reference decision;
@@ -10,7 +10,7 @@
* leading `/name` naming a user-invocable skill and injects the rendered
* body for every entry point, including `disable-model-invocation` skills the
* model-side catalog never lists (issue #1470). The RPC rides the plugin's
* root-context connection captured at registration the source never reads
* root-context Remote captured at registration the source never reads
* services off a per-call argument. Draft chip visuals derive from
* the lexicon scan; this source implements no reference codec.
*
@@ -31,7 +31,7 @@
*/
// Type-only: the carrier types, the forwarded Host-event face and the ctx.remote merge.
import type { Context as ClientContext } from '@deepseek-ai/cordis'
import type { ConnectionHandle, SkillEntry } from '@deepseek-ai/dsh-api-remotes/client'
import type { SkillEntry } from '@deepseek-ai/dsh-api-remotes/client'
import type {} from '@deepseek-ai/dsh-api-session-controller/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { InputTriggerServiceContract, InputTriggerSource } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
@@ -71,7 +71,7 @@ export function apply(ctx: ClientContext): void {
SkillRow,
))
const skills = (ctx.get('connection') as ConnectionHandle).api.skills
const skills = ctx.remote.skills
const sessions = ctx.sessions
// Session-keyed catalog cache; single-flight per key. Plugin-closure state:
// the fiber effect below is its teardown boundary.
@@ -98,8 +98,8 @@ export function apply(ctx: ClientContext): void {
if (existing !== undefined) return existing.promise
const abort = new AbortController()
const promise = (async () => {
const { result } = await skills.list({ sessionId }, abort.signal)
if (!result.ok) throw new Error(`skill.list failed: ${result.error.code}: ${result.error.message}`)
const result = await skills.list({ sessionId }, abort.signal)
if (!result.ok) throw new Error(`skills/list failed: ${result.error.code}: ${result.error.message}`)
return result.value.skills
})()
const entry: CatalogFetch = { promise, abort }
@@ -75,7 +75,7 @@ export function apply(ctx: Context): void {
const workspaces = ctx.get('workspaces') as IWorkspaces
const hostDescription = connection.hostDescription
const uiWorkspace = new UiWorkspaceService(
ctx, connection.api, ctx.remote.directoryPicker, workspaces, sessions)
ctx, ctx.remote.directoryPicker, workspaces, sessions)
ctx.slots.provideRoot({ hooks: { workspaces: workspaces.list } })
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-workspace: dictionaries')
@@ -1,7 +1,6 @@
/** Workspace archive and directory UI capability. */
import { Service, type Context } from '@deepseek-ai/cordis'
import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client'
import type { ClientRemote, DirectoryListing } from '@deepseek-ai/dsh-api-remotes/client'
import type { RemoteFailure } from '@deepseek-ai/dsh-typert-protocol'
import type {
@@ -50,11 +49,6 @@ export interface UiWorkspace {
* @returns created absolute path.
*/
createDirectory(path: string, name: string): Promise<string>
/**
* Open a path with the Host operating system.
* @param path - absolute or Host-resolvable path.
*/
openPath(path: string): Promise<void>
}
declare module '@deepseek-ai/cordis' {
@@ -80,14 +74,12 @@ class UiWorkspaceService extends Service implements UiWorkspace {
/**
* @param ctx - Client root Context.
* @param api - shared Host API carrier.
* @param directoryPicker - the directory-picking Remote namespace.
* @param workspaces - pure Workspace Controller.
* @param sessions - pure Session Controller.
*/
constructor(
ctx: Context,
private readonly api: IApiClient,
private readonly directoryPicker: ClientRemote['directoryPicker'],
private readonly workspaces: IWorkspaces,
private readonly sessions: ISessions,
@@ -163,13 +155,6 @@ class UiWorkspaceService extends Service implements UiWorkspace {
return result.value
}
async openPath(path: string): Promise<void> {
const response = await this.api.host.openPath({ path })
if (!response.result.ok) {
throw new Error(`path open failed: ${response.result.error.message}`)
}
}
private watchNavigation(): () => void {
let initial: 'waiting' | 'connecting' | 'done' = 'waiting'
let disposed = false