Merge remote-tracking branch 'origin/master' into worktree/schedule-conversational-after

# Conflicts:
#	docs/event-producer-consumer.i18n.yaml
#	docs/event-producer-consumer.md
#	docs/event-producer-consumer.zh.md
This commit is contained in:
Tianyi Cui
2026-08-11 19:54:39 +08:00
246 changed files with 3188 additions and 1538 deletions

View File

@@ -152,14 +152,14 @@ describe('connection client apply', () => {
sockets[1]!.receive(JSON.stringify({
type: 'server-request',
rpcId: 'host-browser',
method: 'host/commands-changed',
payload: { type: 'host/commands-changed' },
method: 'host/remote-event',
payload: { type: 'host/remote-event', event: 'commands/change', args: [] },
}))
expect(await muxFrame).toMatchObject({
value: { rpcId: 'mux-browser', payload: { type: 'session/subscribed', lastSeq: 8 } },
})
expect(await hostFrame).toMatchObject({
value: { rpcId: 'host-browser', payload: { type: 'host/commands-changed' } },
value: { rpcId: 'host-browser', payload: { type: 'host/remote-event', event: 'commands/change' } },
})
expect(errors).toHaveBeenCalledTimes(2)
await vi.waitFor(() => { expect(envelopes.flat()).toHaveLength(2) })

View File

@@ -93,7 +93,7 @@ describe('WebSocket downlinks', () => {
},
async function * (signal) {
try {
yield { rpcId: RpcId('host-1'), payload: { type: 'host/commands-changed' } }
yield { rpcId: RpcId('host-1'), payload: { type: 'host/remote-event', event: 'commands/change', args: [] } }
await untilAbort(signal)
} finally {
hostAborted = true
@@ -116,8 +116,8 @@ describe('WebSocket downlinks', () => {
expect(await hostFrame).toEqual({
type: 'server-request',
rpcId: 'host-1',
method: 'host/commands-changed',
payload: { type: 'host/commands-changed' },
method: 'host/remote-event',
payload: { type: 'host/remote-event', event: 'commands/change', args: [] },
})
const muxClosed = once(mux, 'close')

View File

@@ -33,7 +33,9 @@
"client": {
"inject": [
"@deepseek-ai/dsh-client-connection",
"@deepseek-ai/dsh-client-runtime"
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-settings",
"@deepseek-ai/dsh-api-remotes"
],
"platform": "web",
"immediately": true
@@ -41,21 +43,25 @@
},
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/cordis": "workspace:^",
"react": "^18.2.0"
},
"devDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@types/react": "~18.3.1",
"@deepseek-ai/cordis": "workspace:^",
"react": "^18.2.0"
},
"dependencies": {

View File

@@ -7,7 +7,7 @@
import { useState } from 'react'
import type { PropsLocale, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
import { IconChevronDownOutline14, Menu } from '@deepseek-ai/dsh-client-ui-primitives'
import type {} from './settings-contract.ts'
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
import type { createLanguageRowStore } from './settings-store.ts'
import css from './LanguageRow.module.css'

View File

@@ -13,9 +13,11 @@ import type { Context } from '@deepseek-ai/cordis'
import {
type BoundActions, type LocaleDictOf, type LocaleNamespaceMap, type Translate, type TranslateNS,
} from '@deepseek-ai/dsh-client-ui-slots'
import {
bindSettingsScope, type ClientContext, type SettingsScope,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { ClientContext, SettingsScope } from '@deepseek-ai/dsh-client-runtime/client'
// Type-only: the ctx.settingsScope Context merge and the settings slot types.
// Cross-plugin collaboration goes through the service, never a value import
// (client bundle purity gate).
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
import {
LOCALE_PREFERENCE_FIELD, LOCALE_SETTINGS_NAMESPACE, type LocaleId, type LocaleSettings,
} from '../locale-settings.ts'
@@ -29,7 +31,6 @@ import { createLanguageRowStore } from './settings-store.ts'
export type { LanguageRowComponentProps, LanguageRowInjected } from './LanguageRow.tsx'
export type { LanguageOptionRow, LanguageRowState } from './settings-store.ts'
export type { SettingsGeneralItemOwnerProps } from './settings-contract.ts'
export type { CommonKey } from '../locales/index.ts'
export type { LocaleId, LocaleSettings } from '../locale-settings.ts'
@@ -343,7 +344,7 @@ function detectBrowserLocale(): LocaleId | undefined {
}
/** Required services: slot registration plus the settings transport. */
export const inject = ['slots', 'connection']
export const inject = ['slots', 'connection', 'remote', 'settingsScope']
/**
* Client plugin body: provide the locale service with base dictionaries and
@@ -352,7 +353,7 @@ export const inject = ['slots', 'connection']
* @param ctx - client cordis context.
*/
export function apply(ctx: ClientContext): void {
const host = bindSettingsScope<LocaleSettings>(ctx, { namespace: LOCALE_SETTINGS_NAMESPACE })
const host = ctx.settingsScope.bind<LocaleSettings>({ namespace: LOCALE_SETTINGS_NAMESPACE })
const locale = new LocaleService(ctx, host)
locale.register(COMMON_NS, { zh, en })
locale.register(SETTINGS_NS, { zh: settingsZh, en: settingsEn })

View File

@@ -1,26 +0,0 @@
/**
* The `settings.general.item` slot type — one preference row inside the
* settings General section, contributed by the feature plugin that owns the
* preference (locale → Language, ui-theme → Appearance). Options: `id` (row
* key), `order` (row position). Rows draw their own internals (row layout,
* separators via CSS); the section column only stacks them.
*
* TYPE HOME RATIONALE: the slot is declared at runtime by
* ui-settings-general's General entry, but its type lives here — this
* package is the common dependency of every item registrant (any settings
* row carries copy, so every registrant already depends on locale), whereas
* the declarer's own contract is unreachable for locale/ui-theme without a
* reference cycle.
*/
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface SlotMap {
/** One preference row inside the settings General section (see module JSDoc). */
'settings.general.item': { kind: 'list'; scope: 'root'; owner: SettingsGeneralItemOwnerProps }
}
}
/** Owner share of a General preference row (the section supplies nothing). */
export interface SettingsGeneralItemOwnerProps {
/** Marker field: item owner props are intentionally empty. */
children?: never
}

View File

@@ -4,6 +4,8 @@
import { Context } from '@deepseek-ai/cordis'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import { SettingsScopeService } from '@deepseek-ai/dsh-client-ui-settings/client'
import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
import {
apply, inject, SETTINGS_NS,
} from '@deepseek-ai/dsh-client-locale/client'
@@ -43,6 +45,9 @@ async function bench() {
}
})
ctx.provide('connection', { api: { settings: { describe, mutate } }, isLoopback: true } as never)
// The settings transport and the forwarded-event port the plugin injects.
new TestRemote(ctx)
await ctx.plugin(SettingsScopeService).await()
return {
ctx, slots: ctx.get('slots') as SlotsService, describe, mutate,
setHostPreference: (next: string | undefined) => { preference = next; revision += 1 },
@@ -79,7 +84,7 @@ describe('locale apply', () => {
})
it('declares the slot service', () => {
expect(inject).toEqual(['slots', 'connection'])
expect(inject).toEqual(['slots', 'connection', 'remote', 'settingsScope'])
})
it('provides the service with base + settings dictionaries and registers the row (declaration before or after apply)', async () => {
@@ -134,10 +139,10 @@ describe('locale apply', () => {
const locale = b.ctx.get('locale') as LocaleService
await vi.waitFor(() => { expect(locale.getLocale().active).toBe('en') })
b.setHostPreference(undefined)
b.ctx.emit('settings/changed', LOCALE_SETTINGS_NAMESPACE)
b.ctx.remote.$dispatch('settings/document-updated', [LOCALE_SETTINGS_NAMESPACE, 0])
await vi.waitFor(() => { expect(locale.getLocale().active).toBe('zh') })
b.setHostPreference('en')
b.ctx.emit('settings/changed', LOCALE_SETTINGS_NAMESPACE)
b.ctx.remote.$dispatch('settings/document-updated', [LOCALE_SETTINGS_NAMESPACE, 0])
await vi.waitFor(() => { expect(locale.getLocale().active).toBe('en') })
expect(b.describe).toHaveBeenCalledTimes(3)
})

View File

@@ -6,6 +6,7 @@ import { apply as clientApply, COMMON_NS, LocaleService, inject } from '@deepsee
import * as LocaleInvariant from '@deepseek-ai/dsh-client-locale/invariant'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import InvariantService from '@deepseek-ai/dsh-invariants'
import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
describe('invariant companion', () => {
it('registers under the package name with an empty installer', async () => {
@@ -20,10 +21,13 @@ describe('invariant companion', () => {
it('client apply provides ctx.locale seeded with the zh/en common namespace', async () => {
// The feature registers its own Language settings row, hence the slots edge.
expect(inject).toEqual(['slots', 'connection'])
expect(inject).toEqual(['slots', 'connection', 'remote', 'settingsScope'])
const ctx = new Context()
new SlotsService(ctx)
ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
// The settings row's transport and the forwarded-event port.
ctx.provide('remote', { $on: () => () => {} } as never)
ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
await ctx.plugin({ inject, apply: clientApply }).await()
const locale = ctx.get('locale')
expect(locale).toBeInstanceOf(LocaleService)

View File

@@ -25,6 +25,9 @@
},
{
"path": "../../support/invariants"
},
{
"path": "../ui-settings"
}
]
}

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: 69634d4ca577e9fa5c508a5fb2b50333290154b1
README.zh.md: 9e03cc1903b5e9dc1d13e07bf8394a6e7aee9209
README.md: be04f56ac5151c756aa6d4e2233461cc173fe261
README.zh.md: 372922b8b02f694512505c97bf08b9ab480912a5

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects, list and scope state, and the shared event window and history paging used by registered conversation view targets. WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into Session and Workspace owners and bridges the registry-invalidation frames to typed ctx events (`commands/changed`, `session/preset-changed`, `settings/changed`, `credentials/changed`, `models/changed`) so surface caches refetch without touching the stream. `host/session-preset-changed` also folds its preset into the session row, because the switch's RPC echo reaches only the client that issued it. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`. The store also publishes one reference-stable whole-value map through `SessionSummary.projectionValues`, allowing global list consumers to reuse the same projections without creating per-session subscriptions.
Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects, list and scope state, and the shared event window and history paging used by registered conversation view targets. WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into Session and Workspace owners and hands each generic `host/remote-event` frame to `ctx.remote.$dispatch`; domain packages subscribe to their owner events through `ctx.remote.$on` and decide which caches or session rows they invalidate. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`. The store also publishes one reference-stable whole-value map through `SessionSummary.projectionValues`, allowing global list consumers to reuse the same projections without creating per-session subscriptions.
`bindSettingsScope` is the browser mirror of the Host-side settings owner seam for one domain-owned namespace. It subscribes before starting a nonblocking initial read, publishes a uSES snapshot (status, section value, revision, writability, host/memory mode), serializes `set` writes with the latest known namespace revision, suppresses stale publications, recovers a rejected latest write from Host state, and reaches quiescence on plugin disposal. The default decoder validates each section against the namespace's own serialized wire schema (rehydrated through dsh-client-schema-form), so a domain adds a decoder only to narrow beyond that schema. Loopback pages use the Host settings API; remote pages stay in memory mode. Domain packages own the namespace schema, default, and live service rather than putting product policy in runtime.
## Slot declaration injection

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
客户端 cordis 启动与不依赖 React 的对象服务SlotsService 包装 SlotCore 并提供 renderer 数据源SessionsService 拥有 Session 对象、列表与 scope 状态,以及供已注册 conversation view target 共用的事件窗口与历史分页。WorkspacesService 依赖 SessionsService拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给 Session 与 Workspace 所有者,并把注册表失效帧桥接为类型化 ctx 事件(`commands/changed``session/preset-changed``settings/changed``credentials/changed``models/changed`),使各表面缓存无需触碰流即可重拉。`host/session-preset-changed` 还会把其中的 preset 折进会话行,因为这次切换的 RPC 回执只会到达发起它的那个客户端。客户端会话一律由 Host 创建(一次 `session.create` 同时产生 Session、agent智能体和 cwd客户端不持有任何实体化之前的会话状态——agent scopehost dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时创建,并随 prune 销毁。约定api-contracts v3 §4。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史记录尾部的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf``useProjection` 读取,不经 `ConversationSnapshot`。该 store 还会通过 `SessionSummary.projectionValues` 发布一份引用稳定的完整值映射,使全局列表消费方无需为每个会话创建订阅,即可复用同一组投影。
客户端 cordis 启动与不依赖 React 的对象服务SlotsService 包装 SlotCore 并提供 renderer 数据源SessionsService 拥有 Session 对象、列表与 scope 状态,以及供已注册 conversation view target 共用的事件窗口与历史分页。WorkspacesService 依赖 SessionsService拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给 Session 与 Workspace 所有者,并把每个通用 `host/remote-event` 帧交给 `ctx.remote.$dispatch`;各领域包通过 `ctx.remote.$on` 订阅自身 owner 事件,并自行决定使哪些缓存或会话行失效。客户端会话一律由 Host 创建(一次 `session.create` 同时产生 Session、agent智能体和 cwd客户端不持有任何实体化之前的会话状态——agent scopehost dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时创建,并随 prune 销毁。约定api-contracts v3 §4。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史记录尾部的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf``useProjection` 读取,不经 `ConversationSnapshot`。该 store 还会通过 `SessionSummary.projectionValues` 发布一份引用稳定的完整值映射,使全局列表消费方无需为每个会话创建订阅,即可复用同一组投影。
`bindSettingsScope` 面向单个由领域持有的 namespace是 Host 侧 settings owner seam 的浏览器镜像。它在开始非阻塞初始读取前建立订阅,发布 uSES 快照状态、分节值、revision、可写性、host内存模式使用已知最新 namespace revision 串行执行 `set` 写入,抑制陈旧发布,并在最新写入被拒时从 Host 状态恢复;插件释放时,它会达到完全停稳。默认解码器会对照该 namespace 自身的序列化 wire schema经 dsh-client-schema-form 还原)校验每个分节,因此领域只有在需要比该 schema 进一步收窄时才添加解码器。回环页面使用 Host settings API远程页面则停留在内存模式。namespace schema、默认值与实时服务归领域包所有而非把产品政策放入运行时。
## Slot 声明注入

View File

@@ -33,7 +33,8 @@
"client": {
"inject": [
"@deepseek-ai/dsh-client-connection",
"@deepseek-ai/dsh-typert-registry"
"@deepseek-ai/dsh-typert-registry",
"@deepseek-ai/dsh-api-gateway"
],
"platform": "web",
"immediately": true
@@ -44,7 +45,6 @@
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-attachment": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-schema-form": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",
@@ -59,19 +59,20 @@
"zustand": "~4.4.7"
},
"peerDependencies": {
"@deepseek-ai/dsh-api-gateway": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-type-meta": "workspace:^",
"@deepseek-ai/dsh-typert-registry": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/dsh-api-gateway": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^",
"@deepseek-ai/dsh-type-meta": "workspace:^",
"@deepseek-ai/dsh-typert-registry": "workspace:^",
"@types/react": "~18.3.1",
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/schemastery": "workspace:^"
"@types/react": "~18.3.1"
},
"files": [
"lib/index.js",

View File

@@ -0,0 +1,63 @@
/**
* The settings-namespace scope contract. The type lives here, in the common
* dependency of every feature that owns a preference, while the implementation
* and its Host transport live with the Settings surface
* (`dsh-client-ui-settings`): a feature service accepts a scope through
* `attachSettings` without depending on the surface that binds it, which would
* otherwise close a reference cycle.
*/
/** Client-side sync state of one settings namespace. */
export interface SettingsScopeSnapshot<T> {
/**
* `loading` until the first accepted section, `ready` while one stands, and
* `unavailable` when the namespace is not exposed to this client or the
* connection keeps preferences process-local (memory mode).
*/
status: 'loading' | 'ready' | 'unavailable'
/** Last accepted schema-resolved section; undefined before the first acceptance. */
value: T | undefined
/** Namespace revision fencing the next write; undefined before the first Host view. */
revision: number | undefined
/** Whether the Host document accepts writes; memory mode never does. */
writable: boolean
/** `host` syncs with the Host document; `memory` keeps a remote browser process-local. */
mode: 'host' | 'memory'
}
/** Domain-owned description of one settings namespace consumed by a browser plugin. */
export interface SettingsScopeSpec<T> {
/** Settings namespace registered by the owning Host plugin. */
namespace: string
/**
* Narrow one wire section; undefined keeps the last accepted value. The
* default validates the section against the namespace's own serialized wire
* schema, so domains add a decoder only to narrow beyond that schema.
*/
decode?: (section: unknown) => T | undefined
}
/**
* Reactive owner handle over one namespace's durable section — the browser
* mirror of the Host-side `SettingsScope` owner seam. Domain services read
* and observe the snapshot and route explicit user choices through `set`.
*/
export interface SettingsScope<T> {
/** @returns the current sync snapshot (stable reference until the next change). */
getSnapshot(): SettingsScopeSnapshot<T>
/**
* Observe snapshot replacements.
* @param listener - invoked after each snapshot change.
* @returns the disposer removing this listener.
*/
subscribe(listener: () => void): () => void
/**
* Queue one field write. Rapid writes preserve mutation order, each carries
* the latest known namespace revision, and only the latest settlement may
* publish; a rejected or failed latest write reloads Host state instead.
* @param field - scalar field inside the namespace section.
* @param value - JSON-shaped value selected by the user.
* @returns settlement after the write and any latest-write recovery read.
*/
set(field: string, value: unknown): Promise<void>
}

View File

@@ -1,6 +1,10 @@
/** Browser runtime services for slots, sessions, workspaces, and connection-stream delivery. */
import type { Context } from '@deepseek-ai/cordis'
import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-client-connection/client'
// Type-only: the ctx.remote merge. Deliberately the gateway's Client half rather
// than api-remotes': that face imports a Host-tsdown-generated artifact, and this
// project sits in the Host build graph.
import type {} from '@deepseek-ai/dsh-api-gateway/client'
import type { TypeRTContext } from '@deepseek-ai/dsh-type-meta'
import type { MaybeSnapshotSelectorHook, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import { SlotsService } from './slots.ts'
@@ -42,9 +46,12 @@ export type { SessionProvideChannelHost } from './sessions/provide.ts'
export { createScope } from './agents/scope.ts'
export type { AgentScopeHandle } from './agents/scope.ts'
export { DirectoryBrowseError, WorkspaceCreateError, WorkspacesService } from './workspaces/service.ts'
export { bindSettingsScope, SettingsScopeController } from './settings-scope.ts'
export type { SettingsScope, SettingsScopeSnapshot, SettingsScopeSpec } from './settings-scope.ts'
export { resolveWorkspacePath } from './workspaces/path.ts'
// Contract only: the scope implementation and its Host transport belong to
// dsh-client-ui-settings (see that package's settings-scope.ts).
export type {
SettingsScope, SettingsScopeSnapshot, SettingsScopeSpec,
} from './contract/settings-scope.ts'
export type { Session } from './sessions/session.ts'
export type { ISession, ProjectionsFace, SessionFace } from './contract/session.ts'
export type { AgentContext, ISessions } from './contract/sessions.ts'
@@ -150,47 +157,6 @@ declare module '@deepseek-ai/cordis' {
* @param key - the mutated SlotMap key.
*/
'slots/changed'(key: string): void
/**
* The host command registry changed (host/commands-changed passthrough).
* Pure invalidation signal: subscribers refetch `command.list` in the
* background rather than diffing.
* @mode emit
*/
'commands/changed'(): void
/**
* One settings namespace's resolved value changed on the host
* (host/settings-changed passthrough). Subscribers refetch
* `settings.describe`; the frame carries no values.
* @mode emit
* @param ns - the namespace whose resolved value changed.
*/
'settings/changed'(ns: string): void
/**
* One credential reference's state changed on the host
* (host/credentials-changed passthrough). The ref is an
* environment-variable NAME — never a value.
* @mode emit
* @param ref - the reference whose configured state changed.
*/
'credentials/changed'(ref: string): void
/**
* The host provider topology changed (host/models-changed passthrough).
* Subscribers refetch `llm.providers`/`llm.models`/`session.models`.
* @mode emit
*/
'models/changed'(): void
/**
* One session's agent preset changed (host/session-preset-changed
* passthrough), so everything its composition decides — the command
* catalog, the skill catalog — is stale for that session and no other.
* Every connected client observes it, not only the one that issued the
* switch. Subscribers refetch their own session-keyed caches; the frame
* carries no catalog.
* @mode emit
* @param sessionId - the session whose composition changed.
* @param agentPreset - the preset it now runs.
*/
'session/preset-changed'(sessionId: SessionId, agentPreset: string): void
/**
* A connection generation was (re-)established. Wire-derived caches must
* treat their state as stale and repull (commands directory; the queue
@@ -213,7 +179,7 @@ declare module '@deepseek-ai/cordis' {
}
/** Required services: the wire handle and Client TypeRT registry. */
export const inject = ['connection', 'typert']
export const inject = ['connection', 'typert', 'remote']
/** Mounts the browser runtime services and connection stream.
* @param ctx - Client Cordis context.
@@ -241,17 +207,12 @@ export function apply(ctx: Context): void {
onHostEnvelope: (envelope) => {
sessions.handleHostEnvelope(envelope)
workspaces.handleHostEnvelope(envelope)
// Typed-event bridge: the session layer ignores registry frames (no
// session routing); consumers (command directory caches, the settings
// and model services) subscribe on ctx.
// Forwarded-event bridge: the session layer ignores registry frames (no
// session routing). This plugin owns the frame sink, so it hands the
// decoded frame straight to the Remote service, which fans it out to
// `ctx.remote.$on` subscribers; no consumer reads a frame.
const frame = envelope.payload
if (frame.type === 'host/commands-changed') ctx.emit('commands/changed')
else if (frame.type === 'host/session-preset-changed') {
ctx.emit('session/preset-changed', frame.sessionId, frame.agentPreset)
}
else if (frame.type === 'host/settings-changed') ctx.emit('settings/changed', frame.ns)
else if (frame.type === 'host/credentials-changed') ctx.emit('credentials/changed', frame.ref)
else if (frame.type === 'host/models-changed') ctx.emit('models/changed')
if (frame.type === 'host/remote-event') ctx.remote.$dispatch(frame.event, frame.args)
},
onConnected: () => {
sessions.handleConnected()

View File

@@ -800,14 +800,6 @@ export class SessionManager {
}
return
}
case 'host/session-preset-changed': {
// Every connected client observes the switch here; only the tab that
// issued it also gets the RPC echo. The merge keeps the row's own
// updatedAt and lowers `blank` only, so re-applying the switching
// tab's own frame is a no-op.
this.noteAgentPreset(frame.sessionId, frame.agentPreset)
return
}
case 'host/session-removed': {
const summary = this.summaries.find(candidate => candidate.sessionId === frame.sessionId)
const durableSubagent = summary?.origin === 'subagent' || this.addresses.has(frame.sessionId)

View File

@@ -87,23 +87,6 @@ describe('list store projection', () => {
expect(b.svc.list.getSnapshot().byId[sid('s1')]?.agentPreset).toBe('minimal')
})
it('learns a preset switch from the host frame, not only from the tab that issued it', async () => {
const b = bench()
await feedList(b, [{ id: 's1', blank: true, agentPreset: 'standard' }])
// Every connected client gets this frame; only the switching tab gets the
// RPC echo. A client that ignored the payload would keep labelling the
// session with the composition it replaced.
b.svc.handleHostEnvelope({
rpcId: 'r1' as never,
payload: { type: 'host/session-preset-changed', sessionId: sid('s1'), agentPreset: 'minimal' } as never,
})
await Promise.resolve()
expect(b.svc.list.getSnapshot().byId[sid('s1')]?.agentPreset).toBe('minimal')
expect(b.svc.list.getSnapshot().byId[sid('s1')]?.blank).toBe(true)
})
it('reflects live increments (host stream via manager) into the store', async () => {
const b = bench()
await feedList(b, [{ id: 's1' }])

View File

@@ -1,26 +1,63 @@
/**
* Wire-to-typed-event bridge: host/commands-changed
* → ctx 'commands/changed'; host/session-preset-changed →
* ctx 'session/preset-changed'; each established connection generation
* ctx 'connection/reset' (the forced cache-invalidation broadcast).
* Wire-to-typed-event bridge: a `host/remote-event` frame is handed verbatim to
* the Remote service's `$dispatch` (its fan-out to `ctx.remote.$on` is
* api-gateway's own coverage); each established connection generation emits
* `connection/reset` for generation-scoped cache invalidation.
*/
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it } from 'vitest'
import type { ConnectionHandle, ConnectionSinks } from '@deepseek-ai/dsh-client-connection/client'
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
// Type-only: the api-remotes facade carries both the allowlist's selection seat
// and the owner packages' `./types` declarations, which together give `$on` its
// key face and per-event listener signatures.
import type {} from '@deepseek-ai/dsh-api-remotes/client'
import * as RuntimeClient from '../src/client/index.ts'
import { FakeApiClient } from './fake-api.ts'
/**
* Compile-time face of `ctx.remote.$on`, asserted by type-checking this file
* rather than by running it: the allowlist narrows the key set, and each
* listener's parameters come from the owner package's own cordis `Events`
* declaration (so a brand cannot be flattened on the way to a consumer).
* @param ctx - any client Context carrying the Remote service.
*/
function forwardedEventContracts(ctx: Context): void {
ctx.remote.$on('settings/document-updated', (namespace, source) => {
// @ts-expect-error -- the brand survives the wire: a bare string is not a SettingsNamespace
const bare: typeof namespace = 'plain-string'
void bare; void namespace; void source
})
ctx.remote.$on('credentials/updated', () => {})
ctx.remote.$on('commands/change', () => {})
ctx.remote.$on('llm/adapters-updated', () => {})
ctx.remote.$on('agent-preset/selected', (sessionId, agentPreset) => {
void sessionId; void agentPreset
})
// @ts-expect-error -- client-local event outside the allowlist
ctx.remote.$on('slots/changed', () => {})
// @ts-expect-error -- declared host event the allowlist does not select
ctx.remote.$on('skills/change', () => {})
}
void forwardedEventContracts
interface Bench {
ctx: Context
sinks: ConnectionSinks | undefined
/** Every `$dispatch` the runtime made, as `[event, ...args]`. */
dispatched: unknown[][]
}
async function mount(): Promise<Bench> {
const ctx = new Context()
await ctx.plugin(TypertRegistry)
const api = new FakeApiClient()
const bench: Bench = { ctx, sinks: undefined }
const bench: Bench = { ctx, sinks: undefined, dispatched: [] }
// Stands in for api-gateway's Remote service: this spec owns the carrier's
// handoff, not the fan-out behind it.
ctx.reflect.provide('remote', {
$dispatch: (event: string, args: readonly unknown[]) => { bench.dispatched.push([event, ...args]) },
})
const handle: ConnectionHandle = {
api,
isLoopback: true,
@@ -33,50 +70,52 @@ async function mount(): Promise<Bench> {
},
}
ctx.reflect.provide('connection', handle)
ctx.reflect.provide('remote', {})
await ctx.plugin(RuntimeClient).await()
return bench
}
describe('wire event bridge', () => {
it('broadcasts commands/changed on a host/commands-changed frame, not on other host frames', async () => {
it('republishes a forwarded host event verbatim, and routes no other host frame there', async () => {
const bench = await mount()
let changed = 0
bench.ctx.on('commands/changed', () => { changed++ })
bench.sinks?.onHostEnvelope?.({ rpcId: 'r1' as never, payload: { type: 'host/commands-changed' } })
expect(changed).toBe(1)
const seen = bench.dispatched
bench.sinks?.onHostEnvelope?.({
rpcId: 'r1' as never,
payload: { type: 'host/remote-event', event: 'commands/change', args: [] },
})
expect(seen).toEqual([['commands/change']])
bench.sinks?.onHostEnvelope?.({
rpcId: 'r2' as never,
payload: { type: 'host/session-status', sessionId: 's1' as never, running: true },
})
expect(changed).toBe(1)
expect(seen).toEqual([['commands/change']])
})
it('broadcasts the settings/credentials/models invalidations with their frame payloads', async () => {
it('carries each forwarded event name with its own argument list, unfiltered', async () => {
const bench = await mount()
const seen: unknown[][] = []
bench.ctx.on('settings/changed', ns => seen.push(['settings', ns]))
bench.ctx.on('credentials/changed', ref => seen.push(['credentials', ref]))
bench.ctx.on('models/changed', () => seen.push(['models']))
bench.sinks?.onHostEnvelope?.({ rpcId: 'r3' as never, payload: { type: 'host/settings-changed', ns: 'llm-pi-ai' } })
bench.sinks?.onHostEnvelope?.({ rpcId: 'r4' as never, payload: { type: 'host/credentials-changed', ref: 'OPENAI_API_KEY' } })
bench.sinks?.onHostEnvelope?.({ rpcId: 'r5' as never, payload: { type: 'host/models-changed' } })
expect(seen).toEqual([
['settings', 'llm-pi-ai'],
['credentials', 'OPENAI_API_KEY'],
['models'],
])
})
const seen = bench.dispatched
it('broadcasts session/preset-changed with the recomposed session and its new preset', async () => {
const bench = await mount()
const seen: Array<[string, string]> = []
bench.ctx.on('session/preset-changed', (sessionId, agentPreset) => { seen.push([sessionId, agentPreset]) })
bench.sinks?.onHostEnvelope?.({
rpcId: 'r1' as never,
payload: { type: 'host/session-preset-changed', sessionId: 's1' as never, agentPreset: 'minimal' },
rpcId: 'r3' as never,
payload: { type: 'host/remote-event', event: 'settings/document-updated', args: ['llm-pi-ai', 7] },
})
expect(seen).toEqual([['s1', 'minimal']])
bench.sinks?.onHostEnvelope?.({
rpcId: 'r4' as never,
payload: { type: 'host/remote-event', event: 'credentials/updated', args: ['OPENAI_API_KEY'] },
})
// The carrier does not second-guess the name: selecting what a consumer can
// receive is the allowlist's job, and dropping an unsubscribed name is the
// Remote service's. This plugin republishes whatever the frame carried.
bench.sinks?.onHostEnvelope?.({
rpcId: 'r5' as never,
payload: { type: 'host/remote-event', event: 'nobody/listening', args: ['ignored'] },
})
expect(seen).toEqual([
['settings/document-updated', 'llm-pi-ai', 7],
['credentials/updated', 'OPENAI_API_KEY'],
['nobody/listening', 'ignored'],
])
})
it('broadcasts connection/reset on every established generation (reconnect invalidation)', async () => {

View File

@@ -23,9 +23,6 @@
{
"path": "../connection"
},
{
"path": "../schema-form"
},
{
"path": "../../host/apiproxy"
},
@@ -61,6 +58,9 @@
},
{
"path": "../../typert/registry"
},
{
"path": "../../api/gateway"
}
],
"exclude": [

View File

@@ -39,6 +39,7 @@ export { FixtureSession, TestSessions } from './sessions.ts'
export { stubSettingsScope } from './settings-scope.ts'
export type { StubSettingsScope } from './settings-scope.ts'
export { TestWorkspaces } from './workspaces.ts'
export { TestRemote } from './remote.ts'
export { conversationSnapshot, workspaceListState } from './fixtures.ts'
export type { SessionBehaviorOverrides, SessionFixture, Stabilizer } from './fixtures.ts'
export { makeTranslate } from './translate.ts'

View File

@@ -0,0 +1,66 @@
/** Test-owned Remote face: `$on` subscriptions driven by the internal forwarded-event plumbing. */
import type { Context } from '@deepseek-ai/cordis'
/**
* Remote service test double for the forwarded-event path. Feature specs need
* `ctx.remote.$on` to exist (their plugins inject `remote`) and need forwarded
* host events to reach those subscribers, but not the generated namespaces or
* the wire — so this double implements subscription and dispatch only.
*
* Dispatch is driven the same way production drives it: `client/runtime` owns the
* host frame sink and hands each decoded `host/remote-event` frame to
* `$dispatch`. A spec therefore exercises its refresh chains by calling
* `$dispatch(name, args)` on this double.
*
* `$mount` rejects: a spec that reaches a generated namespace through this
* double has outgrown it and needs the real Client Remote service.
*
* One deliberate asymmetry with production: a throwing listener propagates out
* of the emit instead of being contained and logged, so a spec cannot lean on
* this double for the containment guarantee `$on` documents — assert that
* against the real service.
*/
export class TestRemote {
private readonly subscriptions = new Map<string, Set<(...args: never[]) => void>>()
/**
* Register the double as `ctx.remote`.
* @param ctx - the spec's root Context.
*/
constructor(ctx: Context) {
ctx.provide('remote', this)
}
/**
* Deliver one forwarded host event to its subscribers, standing in for the
* carrier that owns the frame sink.
* @param event - forwarded host event name.
* @param args - the Host argument list, verbatim.
*/
$dispatch(event: string, args: readonly unknown[]): void {
const listeners = this.subscriptions.get(event)
if (listeners === undefined) return
for (const listener of [...listeners]) listener(...args as never[])
}
/**
* Subscribe to one forwarded host event.
* @param event - forwarded host event name.
* @param listener - receives the Host argument list verbatim.
* @returns disposer removing this subscription.
*/
$on(event: string, listener: (...args: never[]) => void): () => void {
const listeners = this.subscriptions.get(event) ?? new Set()
this.subscriptions.set(event, listeners)
listeners.add(listener)
return () => { listeners.delete(listener) }
}
/**
* Generated-namespace mount, unsupported by this double.
* @returns never; always rejects.
*/
$mount(): Promise<() => Promise<void>> {
return Promise.reject(new Error('TestRemote: $mount needs the real Client Remote service'))
}
}

View File

@@ -0,0 +1,43 @@
/**
* TestRemote's own contract: subscription and disposal, dispatch driven by the
* internal plumbing event, the silent drop for an unsubscribed name, and the
* `$mount` refusal that sends a spec to the real Client Remote service.
*/
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it } from 'vitest'
import { TestRemote } from '../src/remote.ts'
describe('TestRemote', () => {
it('delivers a forwarded event to its subscribers and stops after disposal', async () => {
const ctx = new Context()
const remote = new TestRemote(ctx)
const seen: string[] = []
const off = remote.$on('settings/document-updated', (ns: string) => {
seen.push(ns)
})
ctx.remote.$dispatch('settings/document-updated', ['ui-theme', 1])
expect(seen).toEqual(['ui-theme'])
off()
ctx.remote.$dispatch('settings/document-updated', ['ui-theme', 2])
expect(seen).toEqual(['ui-theme'])
await ctx.fiber.dispose()
})
it('drops a forwarded event nobody subscribed to', async () => {
const ctx = new Context()
new TestRemote(ctx)
// No subscriber for this name: the emit must be inert rather than throwing,
// because the wire carries whatever the Host allowlist selected.
expect(() => { ctx.remote.$dispatch('credentials/updated', ['DEEPSEEK_API_KEY']) }).not.toThrow()
await ctx.fiber.dispose()
})
it('refuses $mount, which needs the real Client Remote service', async () => {
const ctx = new Context()
const remote = new TestRemote(ctx)
await expect(remote.$mount()).rejects.toThrow('needs the real Client Remote service')
await ctx.fiber.dispose()
})
})

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-agent-preset/README.md
README.md: 3b0db5a3eedca256a00b65a3bd2738f22c0eb62e
README.zh.md: 6f3c350f973119c201572f2c03145338b5cc5b00
README.md: f0fdff4b1f2453be9e8b4c1d20f7ca0fee506d55
README.zh.md: c391a4548f2bd2fc4bfe168e9d494bc0073a8cb2

View File

@@ -18,7 +18,7 @@ A session that has started is refused rather than queued: the host answers `agen
## The session-header label
A third surface, beside the session title: the preset THIS session runs, as static chrome. A control there would promise a switch the host refuses outright. It reads the preset from the session's own summary — a resumed session runs what it was created with, not today's default — and resolves the display name against the same roster the General row reads.
A third surface, beside the session title: the preset THIS session runs, as static chrome. A control there would promise a switch the host refuses outright. It reads the preset from the session's own summary and resolves the display name against the same roster the General row reads. Forwarded `agent-preset/selected` owner events fold committed blank-session switches into that shared summary in every tab; the initiating tab may already have applied the RPC echo, and the merge is idempotent.
## What it reads and writes

View File

@@ -18,7 +18,7 @@ chip 以部署默认值打开,其选择是**暂存**的——该界面先于
## 会话标题旁的标签
第三个表层,位于会话标题旁:**本会话**所运行的 preset作为静态装饰呈现。在那里放一个控件等于承诺一次宿主会断然拒绝的切换。它从会话自身的摘要读取 preset——被恢复的会话运行的是它创建时的那一份,而非今天的默认值——并在 General 行所读的同一份名单上解析显示名称
第三个表层,位于会话标题旁:**本会话**所运行的 preset作为静态装饰呈现。在那里放一个控件等于承诺一次宿主会断然拒绝的切换。它从会话自身的摘要读取 preset,并在 General 行所读的同一份名单上解析显示名称。转发的 owner 事件 `agent-preset/selected` 会在每个标签页中把已经提交的空会话切换折进这份共享摘要;发起方标签页可能已经采用 RPC 回执,而合并是幂等的
## 它读什么、写什么

View File

@@ -36,7 +36,8 @@
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-conversation",
"@deepseek-ai/dsh-client-ui-settings"
"@deepseek-ai/dsh-client-ui-settings",
"@deepseek-ai/dsh-api-remotes"
],
"platform": "web"
}
@@ -47,6 +48,8 @@
},
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
@@ -56,10 +59,10 @@
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-client-web-react": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/cordis": "workspace:^",
"react": "^18.2.0"
},
"devDependencies": {
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",

View File

@@ -14,6 +14,9 @@
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: pulls the ctx.remote merge and the forwarded-event key face
// (the settings invalidation rides the allowlist) into this program.
import type {} from '@deepseek-ai/dsh-api-remotes/client'
// Type-only: pulls the settings shell's SlotMap merge (the 'settings.section' entry).
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
@@ -43,7 +46,7 @@ export type { AgentPresetOption, AgentPresetSettingsState } from './settings-sto
export { AGENT_PRESET_SETTINGS_NS, writeDefaultPreset } from './settings-store.ts'
/** Required services (cordis fiber inject). */
export const inject = ['slots', 'locale', 'connection']
export const inject = ['slots', 'locale', 'connection', 'remote']
/**
* Mount the General-settings row.
@@ -71,15 +74,17 @@ export function apply(ctx: ClientContext): void {
ctx.effect(() => {
// The roster is a live directory and the default is a settings field, so
// both an external settings edit and a reconnect can move this row.
const refresh = (ns?: string): void => {
if (ns !== undefined && ns !== AGENT_PRESET_SETTINGS_NS) return
const refresh = (): void => {
void controller.load()
// The section reads the same roster and marks the same default, so a
// change made from either surface converges both.
if (section.store.getSnapshot().status !== 'idle') void section.load()
}
const disposers = [
ctx.on('settings/changed', refresh),
ctx.remote.$on('settings/document-updated', (ns) => {
if (ns !== AGENT_PRESET_SETTINGS_NS) return
refresh()
}),
ctx.on('connection/reset', () => { refresh() }),
]
return () => { for (const dispose of disposers) dispose() }
@@ -132,10 +137,15 @@ export function apply(ctx: ClientContext): void {
// the next session keeps offering the previous default until a reload,
// which is exactly the session the setting claims to govern. A staged
// pick survives: `load()` prefers it over the refreshed fallback.
const settingsMoved = scope.on('settings/changed', (ns?: string) => {
if (ns !== undefined && ns !== AGENT_PRESET_SETTINGS_NS) return
const settingsMoved = scope.remote.$on('settings/document-updated', (ns) => {
if (ns !== AGENT_PRESET_SETTINGS_NS) return
void seat.load()
})
// Every tab folds the committed preset into the shared session row; the
// initiating tab may already have applied the RPC echo, which is idempotent.
const presetSelected = scope.remote.$on('agent-preset/selected', (sessionId, agentPreset) => {
scope.sessions.noteAgentPreset(sessionId, agentPreset)
})
// Authoring writes a FILE, not a setting, so nothing on the wire
// announces it — without this the screen that starts the next session
// keeps offering the roster as it stood when the chip first loaded, and
@@ -168,6 +178,7 @@ export function apply(ctx: ClientContext): void {
return () => {
stop()
settingsMoved()
presetSelected()
rosterReaders.delete(readRoster)
creatorDraft = undefined
chip()

View File

@@ -137,9 +137,10 @@ export class AgentPresetSectionController {
/**
* Called after this page changes the roster DIRECTORY, so the other
* surfaces reading the same roster re-read it. A settings field moving is
* already announced by the host through `settings/changed`; a directory
* copied or deleted here is not, and the new-session chip has no other
* way to learn a preset it should offer now exists.
* already announced by the host through the forwarded
* `settings/document-updated`; a directory copied or deleted here is not,
* and the new-session chip has no other way to learn a preset it should
* offer now exists.
*/
private readonly rosterChanged: () => void = () => {},
) {}

View File

@@ -10,7 +10,7 @@ import { describe, expect, it, vi } from 'vitest'
import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
import { TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-agent-preset/client'
import { AgentPresetLabel } from '../src/client/AgentPresetLabel.tsx'
import type { AgentPresetLabelInjected } from '../src/client/AgentPresetLabel.tsx'
@@ -78,6 +78,9 @@ async function bench() {
await ctx.plugin(SlotsService).await()
const locale = new LocaleService(ctx)
ctx.provide('locale', locale)
// The plugins inject `remote`; forwarded events reach them through the
// same `$dispatch` handoff the connection sink makes.
new TestRemote(ctx)
const calls: string[] = []
ctx.provide('connection', {
api: {
@@ -162,6 +165,12 @@ function sessionsDouble(state: {
return () => listeners.delete(fn)
},
},
noteAgentPreset: (sessionId: string, agentPreset: string) => {
const summary = state.byId[sessionId]
if (summary === undefined || summary.agentPreset === agentPreset) return
summary.agentPreset = agentPreset
for (const fn of listeners) fn()
},
/** Push a list change the way the runtime's store does. */
notify: () => { for (const fn of listeners) fn() },
}
@@ -169,7 +178,7 @@ function sessionsDouble(state: {
describe('ui-agent-preset apply', () => {
it('declares the services it uses', () => {
expect(inject).toEqual(['slots', 'locale', 'connection'])
expect(inject).toEqual(['slots', 'locale', 'connection', 'remote'])
})
it('registers the General row and the settings section', async () => {
@@ -250,11 +259,11 @@ describe('ui-agent-preset apply', () => {
await section.load()
const before = calls.length
ctx.emit('settings/changed', 'agent-presets')
ctx.remote.$dispatch('settings/document-updated', ['agent-presets', 1])
await vi.waitFor(() => { expect(calls.length).toBe(before + 2) })
const afterRelevant = calls.length
ctx.emit('settings/changed', 'llm-deepseek')
ctx.remote.$dispatch('settings/document-updated', ['llm-deepseek', 1])
await Promise.resolve()
// Both surfaces re-read on their own namespace; an unrelated one moves
@@ -282,7 +291,7 @@ describe('ui-agent-preset apply', () => {
await ctx.plugin({ inject: [...inject], apply }).await()
const before = calls.length
ctx.emit('settings/changed', 'agent-presets')
ctx.remote.$dispatch('settings/document-updated', ['agent-presets', 1])
await vi.waitFor(() => { expect(calls.length).toBeGreaterThan(before) })
// Only the General row reloads: a section nobody opened has nothing to
@@ -333,17 +342,35 @@ describe('ui-agent-preset apply', () => {
// An unrelated namespace moves nothing: the chip re-reads on its own
// setting, not on every settings write in the process.
moveDefault()
ctx.emit('settings/changed', 'llm-deepseek')
ctx.remote.$dispatch('settings/document-updated', ['llm-deepseek', 1])
await Promise.resolve()
expect(seat.hooks.agentPresetSeat.getSnapshot().current).toBe('standard')
ctx.emit('settings/changed', 'agent-presets')
ctx.remote.$dispatch('settings/document-updated', ['agent-presets', 1])
await vi.waitFor(() => {
expect(seat.hooks.agentPresetSeat.getSnapshot().current).toBe('minimal')
})
conversation()
})
it('folds a remote preset commit into the shared session row', async () => {
const { ctx, slots } = await bench()
declareRoot(slots)
declareConversation(slots)
ctx.provide('conversation', {} as never)
const state = {
current: 's1',
byId: { s1: { id: 's1', blank: true, agentPreset: 'standard' } },
}
ctx.provide('sessions', sessionsDouble(state) as never)
ctx.provide('workspaces', workspacesDouble() as never)
await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'workspaces'], apply }).await()
ctx.remote.$dispatch('agent-preset/selected', ['s1', 'minimal'])
expect(state.byId.s1.agentPreset).toBe('minimal')
})
it('offers a just-authored preset on the new-session chip', async () => {
const { ctx, slots } = await bench()
declareRoot(slots)

View File

@@ -40,6 +40,9 @@
},
{
"path": "../../support/invariants"
},
{
"path": "../../api/remotes/tsconfig.client.json"
}
]
}

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-command/README.md
README.md: e49ce89804886a11f102fcaf60316e8044965c10
README.zh.md: 8bd5afd7d0a173980f476cb96f8115525602b0b4
README.md: 60b70cfbc3784dd5b138f8857270c4bbdc0fd634
README.zh.md: 639b7f997e967527232bb88116558c5457b4d497

View File

@@ -6,7 +6,7 @@ Client command API (`ctx.command`): the session-keyed command-directory cache, t
`src/client/contract.ts` is the fixed business contract: `CommandServiceContract.register(name, spec)` and `decorate(name, spec)` are everything a business package consumes; `CommandUiSpec{options, onSelect}` keeps popup data self-contained — the shell component belongs to this package and business packages never see it. A contribution is a client-owned command (a host-name collision fails loud); a decoration adds a bare-invocation popup to an EXISTING host command. The host keeps its catalog row, argument claim (space / argued Enter), and lifecycle logging, and a decorated name with no host row in the session's directory never fires. Command kinds derive per dispatch, never per registration: a host descriptor with `input` is `leadingInput`, a registered `CommandUiSpec` is `popupSelect`, and everything else is `execute`.
`CommandDirectory` (`src/client/directory.ts`) is the one wire-derived cache, keyed by session. Ordinary sessions fetch through `command.list({sessionId})`, and the source's scope-birth `warm` hook prewarms the session's entry. Catalog-addressed continuable children resolve an empty command directory locally: `command.list` is Agent-bound, so prewarming it would activate a child merely to view persisted history. Entries are soft-invalidated by the `commands/changed` typed event (old snapshot serves while the repull flies) and by `session/preset-changed` for that one session (recomposing an agent registers nothing, so the registry-wide signal never fires for it), hard-invalidated by `connection/reset`, epoch-guarded so a superseded pull can never overwrite a newer one. `matchSpace` answers synchronously from this cache only; `matchEnter` strong-waits it on the SubmitAttempt signal and rejects on warmup failure — a `/` line is never silently downgraded to a plain prompt.
`CommandDirectory` (`src/client/directory.ts`) is the one wire-derived cache, keyed by session. Ordinary sessions fetch through `command.list({sessionId})`, and the source's scope-birth `warm` hook prewarms the session's entry. Catalog-addressed continuable children resolve an empty command directory locally: `command.list` is Agent-bound, so prewarming it would activate a child merely to view persisted history. Entries are soft-invalidated by the forwarded `commands/change` owner event (old snapshots serve while the repull flies) and by forwarded `agent-preset/selected` for that one session (recomposing an agent registers nothing, so the registry-wide signal never fires for it), hard-invalidated by `connection/reset`, and epoch-guarded so a superseded pull can never overwrite a newer one. `matchSpace` answers synchronously from this cache only; `matchEnter` strong-waits it on the SubmitAttempt signal and rejects on warmup failure — a `/` line is never silently downgraded to a plain prompt.
Menu queries fuzzy-match ordered, case-insensitive subsequences of command names. Prefixes rank first; separator boundaries, adjacent characters, and shorter gaps rank the remaining matches, with directory and contribution order breaking ties. This affects discovery only: space and Enter still require an exact command name. Rationale: [Web slash-command fuzzy discovery](../../../.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md).

View File

@@ -6,7 +6,7 @@
`src/client/contract.ts` 是固定的业务 API 约定:`CommandServiceContract.register(name, spec)``decorate(name, spec)` 是业务包消费的全部内容;`CommandUiSpec{options, onSelect}` 自己提供 popup 数据——外层组件归本包所有业务包永远见不到它。contribution 是 client 自有命令(与 host 同名碰撞即 fail-louddecoration装饰则为**已存在的** host 命令添加裸调用 popup。host 保留目录行、带参 claimspace / 带参 Enter与生命周期记账被装饰的名字若在会话目录中无 host 行,则永不触发。命令类型按每次派发派生,绝不在注册时定型:带 `input` 的 host descriptor 是 `leadingInput`,注册了 `CommandUiSpec` 的是 `popupSelect`,其余全部是 `execute`
`CommandDirectory``src/client/directory.ts`)是唯一的 wire 派生缓存,以会话为 key。普通会话通过 `command.list({sessionId})` 拉取source 的 scope 出生 `warm` 钩子会预热该会话的缓存项。由目录寻址的可继续子代理会在客户端解析为空命令目录:`command.list` 绑定 Agent若预热它就会仅因查看持久化历史而激活子代理。缓存项由 `commands/changed` 类型化事件软失效(重拉在途期间旧快照继续服务),也由 `session/preset-changed` 对该会话单独软失效(重组 agent 不产生任何注册,注册表级信号不会为它触发),由 `connection/reset` 硬失效,并以 epoch 把关,被取代的旧拉取永远无法覆盖更新的结果。`matchSpace` 只凭该缓存同步应答;`matchEnter` 在 SubmitAttempt 信号上强等缓存,预热失败即拒绝——`/` 开头的一行绝不会被静默降级为普通提示词。
`CommandDirectory``src/client/directory.ts`)是唯一的 wire 派生缓存,以会话为 key。普通会话通过 `command.list({sessionId})` 拉取source 的 scope 出生 `warm` 钩子会预热该会话的缓存项。由目录寻址的可继续子代理会在客户端解析为空命令目录:`command.list` 绑定 Agent若预热它就会仅因查看持久化历史而激活子代理。缓存项由转发的 owner 事件 `commands/change` 软失效(重拉在途期间旧快照继续服务),也由转发的 `agent-preset/selected` 对该会话单独软失效(重组 agent 不产生任何注册,注册表级信号不会为它触发),由 `connection/reset` 硬失效,并以 epoch 把关,被取代的旧拉取永远无法覆盖更新的结果。`matchSpace` 只凭该缓存同步应答;`matchEnter` 在 SubmitAttempt 信号上强等缓存,预热失败即拒绝——`/` 开头的一行绝不会被静默降级为普通提示词。
菜单查询会按顺序且不区分大小写地模糊匹配命令名的子序列。前缀排名最高;其余匹配项按分隔符边界优先、相邻字符优先、间隔越短越优先的规则排序,若仍同分,则以目录顺序和 contribution 顺序打破平局。此行为只影响命令发现space 和 Enter 仍要求命令名精确匹配。原理:[Web 斜杠命令模糊发现](../../../.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md)。

View File

@@ -35,7 +35,8 @@
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-ui-slash",
"@deepseek-ai/dsh-client-ui-conversation"
"@deepseek-ai/dsh-client-ui-conversation",
"@deepseek-ai/dsh-api-remotes"
],
"platform": "web"
}
@@ -49,6 +50,8 @@
"clsx": "^2.0.0"
},
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
@@ -57,10 +60,10 @@
"@deepseek-ai/dsh-client-ui-slash": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/cordis": "workspace:^",
"react": "^18.2.0"
},
"devDependencies": {
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",

View File

@@ -45,7 +45,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
const NS = 'command'
/** Required services: the '/' source registry plus the scope + wire faces the service reads, and the copy's locale registry. */
export const inject = ['slash', 'sessions', 'connection', 'locale']
export const inject = ['slash', 'sessions', 'connection', 'locale', 'remote']
/**
* Client plugin body: mount the service, then register the popupSelect shell

View File

@@ -11,6 +11,9 @@ import { Service } from '@deepseek-ai/cordis'
import type { Context } from '@deepseek-ai/cordis'
import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-client-connection/client'
import type { ClientContext, ISessions } from '@deepseek-ai/dsh-client-runtime/client'
// Type-only: pulls the ctx.remote merge and the forwarded-event key face
// (`commands/change` rides the allowlist) into this program.
import type {} from '@deepseek-ai/dsh-api-remotes/client'
import type {
CandidateRequest, ClientSessionContext, CommandClaim, PickOutcome, SlashCandidate, SlashPick,
SubmitOutcome,
@@ -93,7 +96,7 @@ function fuzzyCandidates(candidates: readonly SlashCandidate[], rawQuery: string
/** Command surface: session-keyed directory + '/' source + contribution registry + per-session popups. */
export class CommandService extends Service implements CommandServiceContract {
static inject = ['slash', 'sessions', 'connection']
static inject = ['slash', 'sessions', 'connection', 'remote']
private readonly directory: CommandDirectory
private readonly live: LiveState = { contributions: new Map(), decorations: new Map(), popups: new Map() }
@@ -123,12 +126,12 @@ export class CommandService extends Service implements CommandServiceContract {
matchEnter: (session, line, signal) => this.matchEnter(session, line, signal),
warm: (session) => { this.directory.warm(session.sessionId) },
}), 'command: slash source')
ctx.on('commands/changed', () => { this.directory.invalidateAll() })
ctx.remote.$on('commands/change', () => { this.directory.invalidateAll() })
// A preset switch changes which commands one session's agent resolves and
// registers nothing globally, so the registry-wide signal above never
// fires for it: repull that key alone, soft, so the old snapshot serves
// the menu until the new one lands.
ctx.on('session/preset-changed', (sessionId) => { void this.directory.refresh(sessionId) })
ctx.remote.$on('agent-preset/selected', (sessionId) => { void this.directory.refresh(sessionId) })
ctx.on('connection/reset', () => { this.directory.resetConnected() })
}

View File

@@ -14,6 +14,7 @@ import type { SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client'
import type { CommandServiceContract } from '../src/client/contract.ts'
import type { PopupSelectInjected } from '../src/client/PopupSelectView.tsx'
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
import { apply, CommandService, inject } from '../src/client/index.ts'
const sid = (k: string): SessionId => k as SessionId
@@ -38,6 +39,8 @@ async function bench() {
name: 'root', children: { 'conversation.input.overlay': { kind: 'list', scope: 'session' } },
} as never, (() => null) as never)
ctx.provide('locale', new LocaleService(ctx))
// CommandService injects `remote` for the forwarded directory invalidation.
new TestRemote(ctx)
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
const mint = (key: string) => {
@@ -50,7 +53,7 @@ async function bench() {
describe('apply', () => {
it('declares the services it binds', () => {
expect(inject).toEqual(['slash', 'sessions', 'connection', 'locale'])
expect(inject).toEqual(['slash', 'sessions', 'connection', 'locale', 'remote'])
})
it('mounts ctx.command, registers the source and the overlay entry, and folds up on disposal', async () => {

View File

@@ -10,6 +10,7 @@
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it, vi } from 'vitest'
import { createScope, scopeOf } from '@deepseek-ai/dsh-client-runtime/client'
import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type { ClientSessionContext, ConsumeTokenRequest, SlashPick, SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client'
import type { CommandContribution, CommandDecoration, CommandUiSpec, SelectOption } from '../src/client/contract.ts'
@@ -78,6 +79,9 @@ async function bench(opts: BenchOptions = {}) {
: undefined,
})
ctx.provide('connection', { api })
// CommandService injects `remote`; the directory invalidation arrives on the
// same `$dispatch` handoff the connection sink makes.
new TestRemote(ctx)
/** Notices the fake conversation face collected (runDetached routing). */
const notices: Array<{ scope: SessionId | undefined; level: 'info' | 'error'; text: string }> = []
ctx.provide('conversation', {
@@ -598,7 +602,7 @@ describe('popupFor', () => {
})
describe('directory invalidation events', () => {
it('commands/changed repulls in the background while the old snapshot serves', async () => {
it('commands/change repulls in the background while the old snapshot serves', async () => {
let round = 0
const { ctx, source, warm } = await bench({
commands: () => {
@@ -611,13 +615,13 @@ describe('directory invalidation events', () => {
},
})
await warm(proj('s1'))
ctx.emit('commands/changed')
ctx.remote.$dispatch('commands/change', [])
await new Promise(resolve => setTimeout(resolve, 0))
expect(source.matchSpace!(proj('s1'), '/fresh')).not.toBeUndefined()
expect(source.matchSpace!(proj('s1'), '/goal')).toBeUndefined()
})
it('session/preset-changed repulls the recomposed session and leaves the others served', async () => {
it('agent-preset/selected repulls the recomposed session and leaves the others served', async () => {
const rounds = new Map<SessionId, number>()
const { ctx, source, warm } = await bench({
commands: (payload) => {
@@ -634,7 +638,7 @@ describe('directory invalidation events', () => {
await warm(proj('s2'))
// A preset switch changes which commands one session's agent resolves;
// every other session keeps the catalog its own composition serves.
ctx.emit('session/preset-changed', sid('s1'), 'minimal')
ctx.remote.$dispatch('agent-preset/selected', [sid('s1'), 'minimal'])
await new Promise(resolve => setTimeout(resolve, 0))
expect(source.matchSpace!(proj('s1'), '/fresh')).not.toBeUndefined()
expect(source.matchSpace!(proj('s1'), '/goal')).toBeUndefined()

View File

@@ -34,6 +34,9 @@
},
{
"path": "../../support/invariants"
},
{
"path": "../../api/remotes/tsconfig.client.json"
}
]
}

View File

@@ -35,6 +35,8 @@
"@deepseek-ai/dsh-client-connection",
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-settings",
"@deepseek-ai/dsh-api-remotes",
"@deepseek-ai/dsh-client-ui-layout"
],
"platform": "web"
@@ -51,49 +53,53 @@
"@deepseek-ai/schemastery": "workspace:^"
},
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-attachment": "workspace:^",
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
"@deepseek-ai/dsh-client-ui-slash": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-compact": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-compact": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm-retry": "workspace:^",
"@deepseek-ai/dsh-token-meter": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/cordis": "workspace:^",
"react": "^18.2.0"
},
"devDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-attachment": "workspace:^",
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
"@deepseek-ai/dsh-compact": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-goal": "workspace:^",
"@deepseek-ai/dsh-plan-mode": "workspace:^",
"@deepseek-ai/dsh-client-ui-layout": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
"@deepseek-ai/dsh-client-ui-slash": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-compact": "workspace:^",
"@deepseek-ai/dsh-goal": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm-retry": "workspace:^",
"@deepseek-ai/dsh-permission": "workspace:^",
"@deepseek-ai/dsh-plan-mode": "workspace:^",
"@deepseek-ai/dsh-session-projection": "workspace:^",
"@deepseek-ai/dsh-token-meter": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-tool-todo": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@types/react": "~18.3.1",
"@deepseek-ai/cordis": "workspace:^",
"react": "^18.2.0"
},
"files": [

View File

@@ -2,8 +2,11 @@
import type { Context } from '@deepseek-ai/cordis'
import { resolveSlotLabel, type BoundActions } from '@deepseek-ai/dsh-client-ui-slots'
import {
bindSettingsScope, resolveWorkspacePath, type ISessions, type SessionId,
resolveWorkspacePath, type ISessions, type SessionId,
} from '@deepseek-ai/dsh-client-runtime/client'
// Type-only: the ctx.settingsScope Context merge. Cross-plugin collaboration
// goes through the service, never a value import (client bundle purity gate).
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
import type {} from '@deepseek-ai/dsh-client-locale/client'
@@ -46,7 +49,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
/** Services required by the conversation plugin. */
export const inject = [
'slots', 'layout', 'sessions', 'workspaces', 'locale', 'connection',
'slots', 'layout', 'sessions', 'workspaces', 'locale', 'connection', 'remote', 'settingsScope',
'conversationEvents', 'conversationViews',
]
@@ -128,7 +131,7 @@ export function apply(ctx: Context): void {
// Apply-time construction keeps store identity bound to this fiber.
const chatStore = createChatStore()
const submissionPolicy = new ComposerSubmissionPolicy(
bindSettingsScope<ConversationSettings>(ctx, { namespace: CONVERSATION_SETTINGS_NAMESPACE }),
ctx.settingsScope.bind<ConversationSettings>({ namespace: CONVERSATION_SETTINGS_NAMESPACE }),
)
ctx.slots.inject('settings.general.item', () => ctx.slots.register({

View File

@@ -15,7 +15,7 @@
// chat-toolview-slot.spec.tsx.
import { describe, expect, it, vi } from 'vitest'
import { SlotTestRuntime, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
import { SlotTestRuntime, usePinnedBrowserLanguages, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
import type { SessionBehaviorOverrides } from '@deepseek-ai/dsh-client-test-runtime'
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
import type { ISession, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
@@ -48,6 +48,9 @@ function sessionFakeFor() {
async function bench() {
const runtime = await SlotTestRuntime.create()
runtime.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
// The plugin injects both; these specs exercise no settings path.
runtime.provide('remote', { $on: () => () => {} })
runtime.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
const sessionFake = sessionFakeFor()
await runtime.sessions.add({
id: ROOT,

View File

@@ -6,7 +6,7 @@ import { useState } from 'react'
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
import type { ISession, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
import { SlotTestRuntime, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
import { SlotTestRuntime, usePinnedBrowserLanguages, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
import { apply, inject, type EmptyWorkspaceOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
usePinnedBrowserLanguages('zh-CN')
@@ -51,6 +51,9 @@ function WorkspaceProbe({ open }: EmptyWorkspaceOwnerProps) {
async function bench(opts?: { blank?: boolean }) {
const runtime = await SlotTestRuntime.create()
runtime.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
// The plugin injects both; these specs exercise no settings path.
runtime.provide('remote', { $on: () => () => {} })
runtime.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
const locale = new LocaleService(runtime.ctx)
runtime.provide('locale', locale)
@@ -76,6 +79,9 @@ describe('resident composer', () => {
it('renders the locked view state while no session exists at all', async () => {
const runtime = await SlotTestRuntime.create()
runtime.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
// The plugin injects both; these specs exercise no settings path.
runtime.provide('remote', { $on: () => () => {} })
runtime.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
const locale = new LocaleService(runtime.ctx)
runtime.provide('locale', locale)
@@ -103,6 +109,9 @@ describe('resident composer', () => {
it('keeps the complete Hero tree mounted when the first Workspace session appears', async () => {
const runtime = await SlotTestRuntime.create()
runtime.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
// The plugin injects both; these specs exercise no settings path.
runtime.provide('remote', { $on: () => () => {} })
runtime.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
const locale = new LocaleService(runtime.ctx)
runtime.provide('locale', locale)
@@ -169,6 +178,9 @@ describe('prompt rejection through the assembled composer', () => {
it('renders the promptError alert strip and keeps the draft in the machine', async () => {
const runtime = await SlotTestRuntime.create()
runtime.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
// The plugin injects both; these specs exercise no settings path.
runtime.provide('remote', { $on: () => () => {} })
runtime.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
const locale = new LocaleService(runtime.ctx)
runtime.provide('locale', locale)

View File

@@ -6,7 +6,7 @@
// entries. Tool composition belongs to ui-tool and its machinery spec.
import { describe, expect, it, vi } from 'vitest'
import { SlotTestRuntime, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
import { SlotTestRuntime, usePinnedBrowserLanguages, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
@@ -22,6 +22,9 @@ const CHILD = 'child-1' as SessionId
async function bench() {
const runtime = await SlotTestRuntime.create()
runtime.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
// The plugin injects both; these specs exercise no settings path.
runtime.provide('remote', { $on: () => () => {} })
runtime.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
await runtime.sessions.add({ id: ROOT, summary: { title: 'R', displayTitle: 'R' } }, { current: false })
await runtime.sessions.add(
{ id: CHILD, summary: { title: 'C', displayTitle: 'C', parentId: ROOT } }, { current: false })

View File

@@ -76,6 +76,9 @@
},
{
"path": "../../interaction/permission"
},
{
"path": "../ui-settings"
}
],
"exclude": [

View File

@@ -16,9 +16,9 @@ import type {
ConversationTimelineSnapshot, ConversationTurnDataMap, ConversationViewDefinition,
ConversationViewNode, ToolResultNode, TurnLocation,
} from '@deepseek-ai/dsh-client-runtime/client'
import { apply as applyLocale } from '@deepseek-ai/dsh-client-locale/client'
import { apply as applyLocale, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client'
import type { ChatFileMentions, TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { makeTranslate, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
import { ProducedFiles } from '../src/client/ProducedFiles.tsx'
import {
basename, deliverablesDefinition, producedFileMentions, producedForClosing, selectProducedFiles,
@@ -341,7 +341,10 @@ describe('plugin registration', () => {
children: { 'conversation.chat.turnTail': { kind: 'chain', scope: 'session' } },
} as never, () => null)
ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
await ctx.plugin({ inject: ['slots'], apply: applyLocale }).await()
// ui-theme's Appearance row binds a durable scope through these two.
ctx.provide('remote', { $on: () => () => {} } as never)
ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
await ctx.plugin({ inject: localeInject, apply: applyLocale }).await()
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()

View File

@@ -7,6 +7,7 @@
// coverage gate still requires exercised.
import { Context } from '@deepseek-ai/cordis'
import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
@@ -26,6 +27,9 @@ async function bench() {
// seam for persistence; model this bench as a remote, memory-only browser.
ctx.provide('locale', new LocaleService(ctx))
ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
// ui-theme's Appearance row binds a durable scope through these two.
ctx.provide('remote', { $on: () => () => {} } as never)
ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
await ctx.plugin({ inject: themeInject, apply: themeApply }).await()
await slotsFiber.await()
return { ctx, slots: ctx.get('slots') as SlotsService }

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-model/README.md
README.md: fdc3258eb37b45f4773648d2d796c275f8657d47
README.zh.md: 116e151d1afeaaa22618c408eed2c7542d1357e7
README.md: e49fdee52caa99cd249db7d7fbec0064c47e2dc7
README.zh.md: 366c96c3e37619297075ad495142416ffd3baea6

View File

@@ -10,6 +10,8 @@ When the Host reports that no adapter serves the session's route (`session.model
Directories are per-session, resolved lazily through `ctx.models.directoryFor(sessionId)`, and disposed with the session scope. Addressed subagent sessions expose neither entry, and their directory rejects loads, selections, and reconnect refreshes, because ordinary Agent-bound model RPCs would activate persisted child history outside the direct-parent continuation path.
Every resident directory refetches directly on forwarded `llm/adapters-updated` and `settings/document-updated` owner events. Provider topology, provider catalogs, and the default selection therefore converge without the Host or client runtime deriving a separate model-change alias.
The `/client` exports are the plugin body (`apply`/`inject`), `ModelService`, `ModelDirectory` with its state fields, and the seat's injected face type.
## Model Experience

View File

@@ -10,6 +10,8 @@ Host 报告的 `ModelSelection` 是唯一的选择事实,其中包含提供方
目录按会话惰性解析(`ctx.models.directoryFor(sessionId)`),随会话作用域一并释放。已寻址 subagent 会话不公开任一入口,其目录会拒绝加载、选择与重新连接刷新,因为绑定到 agent智能体的普通模型 RPC 会在直接 parent 继续执行路径之外激活持久化 child 历史。
每一份常驻目录都会直接在转发的 owner 事件 `llm/adapters-updated``settings/document-updated` 上重拉。因此提供方拓扑、提供方目录与默认选择都能收敛Host 与 client runtime 无需再派生一个单独的模型变更别名。
`/client` 导出面为插件本体(`apply`/`inject`)、`ModelService``ModelDirectory` 及其状态形状、slot 注入面类型。
## 模型体验

View File

@@ -34,7 +34,8 @@
"inject": [
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-command"
"@deepseek-ai/dsh-client-ui-command",
"@deepseek-ai/dsh-api-remotes"
],
"platform": "web"
}
@@ -45,6 +46,7 @@
},
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
@@ -59,9 +61,11 @@
"react": "^18.2.0"
},
"devDependencies": {
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-command": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",

View File

@@ -12,6 +12,8 @@
* history outside the direct-parent continuation path.
*/
import type { ModelSelection, SessionModels } from '@deepseek-ai/dsh-client-connection/client'
// Type-only: pulls the forwarded Host-event face and ctx.remote merge.
import type {} from '@deepseek-ai/dsh-api-remotes/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).
@@ -96,7 +98,7 @@ function selectionOf(state: ModelDirectoryState, id: string): ModelSelection | u
const NS = 'model'
/** Required services: the contribution registry, the seat's slot registry, locale, and the service's own faces. */
export const inject = ['command', 'connection', 'locale', 'sessions', 'slots']
export const inject = ['command', 'connection', 'locale', 'sessions', 'slots', 'remote']
/**
* Client plugin body: mount ModelService, register the `model` dictionaries,

View File

@@ -32,7 +32,7 @@ interface LiveState {
/** The `ctx.models` session model-selection service. */
export class ModelService extends Service {
static inject = ['connection', 'sessions']
static inject = ['connection', 'sessions', 'remote']
private readonly live: LiveState = { directories: new Map() }
@@ -49,14 +49,15 @@ export class ModelService extends Service {
ctx.on('connection/reset', () => {
for (const directory of this.live.directories.values()) directory.resetConnected()
})
// Provider topology changed on the host (a settings-born route appeared
// or dropped): refresh every open directory in the background so pickers
// show the new catalog without a reopen. Failures stay on each store.
ctx.on('models/changed', () => {
// Either source can change the directory: registry topology commits and
// settings documents that carry provider catalogs or default selection.
const refresh = (): void => {
for (const directory of this.live.directories.values()) {
directory.load().catch(() => undefined)
}
})
}
ctx.remote.$on('llm/adapters-updated', refresh)
ctx.remote.$on('settings/document-updated', refresh)
}
/**

View File

@@ -13,6 +13,7 @@ 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 { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
import type { ModelSelection } 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'
@@ -112,6 +113,7 @@ async function bench() {
? { parentSessionId: sid('parent'), childSessionId: id, mode: 'continuable' as const }
: undefined,
})
new TestRemote(ctx)
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
await ctx.plugin(function probe() {}).await()
@@ -245,14 +247,14 @@ describe('ui-model dual entry', () => {
expect(b.blockOf('s1')).toBeUndefined()
b.setRoutable(false)
b.ctx.emit('models/changed')
b.ctx.remote.$dispatch('llm/adapters-updated', [])
await Promise.resolve()
await Promise.resolve()
expect(b.blockOf('s1')?.reason).toBe(zh['blocked.composer'])
// Recovering clears it without a reload of the surface.
b.setRoutable(true)
b.ctx.emit('models/changed')
b.ctx.remote.$dispatch('settings/document-updated', ['llm-deepseek', 1])
await Promise.resolve()
await Promise.resolve()
expect(b.blockOf('s1')).toBeUndefined()

View File

@@ -37,6 +37,9 @@
},
{
"path": "../../support/invariants"
},
{
"path": "../../api/remotes/tsconfig.client.json"
}
]
}

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-models/README.md
README.md: 89a253fca9f16ea5655cdd7536a441e98dcc4d3c
README.zh.md: 350be495f1491738e6e861ba127b3b0070a5f073
README.md: f6604f822412e9eb4574696f5b99e73fb7bd98ff
README.zh.md: 2500bbae0982571a9a88dd5c259749e3504728de

View File

@@ -8,7 +8,7 @@ Rows are the *configured* providers (their profile resolves in the owning namesp
The DeepSeek step projects `deepseek-official` readiness from that same joined snapshot after earlier onboarding pages complete. It recognizes the official adapter through its `llm-deepseek` configurable-provider declaration, so an undeclared live route with the same provider id is not treated as repairable configuration. A configured credential reference completes the step without rendering, including a read-only launch-environment credential. Only a mounted, active adapter with a missing writable reference shows the page that opens Settings on Models, whose existing setup card exclusively owns key input and `credentials.set`; the step never holds a secret. An absent adapter, inactive route, failed join, read-only deployment, or unusable settings or credential capability completes the step without rendering so onboarding cannot block the product; Models remains the diagnostic surface.
Every edit lands as `settings.mutate` path ops against the stored section — a set per changed field, an unset per cleared one, and a single unset for a deleted provider row. The page only ever holds the REDACTED descriptor, so it mutates the fields it can see rather than rebuilding a section. DeepSeek's `models` is one replace-by-value array: the editor shows inherited effective rows until the first model edit materializes the complete array in the user layer, while reset unsets that override. A row carries the model id and display name; its context window and output cap sit behind the row's own disclosure, with the same fields the pi-ai provider form uses. Either capacity is typed as a count with an optional decimal `K` or `M` suffix (`256K`, `1M`; `1M` is 1000K) and stored as the plain count, spelled back in the shortest form that round-trips. Empty ids, duplicate ids, empty explicit names, and unreadable, non-positive, or fractional capacities fail before any write. A typed API key is judged on its own field the same way: after trimming, it must be non-empty and every character must be printable ASCII (`[\x21-\x7E]`), which is exactly what an HTTP header value can carry — the twin of `normalizeApiKey` in `@deepseek-ai/dsh-llm`, mirrored here because the source-plane split forbids importing it. A value matching a pasted `NAME=value` environment line or wrapped in matching quotes is refused as the same format failure; that pasted-line check runs only in the browser, since a false positive in a resolver would leave the environment refusing the key as well. A field holding only whitespace fails rather than being silently dropped, while an empty field is not a failure at all: it means keep the stored key on an editor card, and authenticate some other way on a create card. A refused key blocks both the write and the endpoint interrogation, so the page never spends a round trip to be told what the field already says. Each settings write carries the card's current `revision`, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict`; after settings commit, the card adopts the returned redacted user subtree and revision before storing the credential, which makes a failed credential stage retry only that stage. Deletion removes a configured, writable credential only when the profile names the page's derived `<ROUTE>_API_KEY` target, then unsets the profile; both operations are idempotent, and a partial failure remains in the identified confirmation dialog for retry. Environment credentials, custom references, and credentials whose target cannot be identified remain untouched. The page refetches on the pushed invalidations (`settings/changed`, `credentials/changed`, `models/changed`, and `connection/reset`) once it has loaded, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling.
Every edit lands as `settings.mutate` path ops against the stored section — a set per changed field, an unset per cleared one, and a single unset for a deleted provider row. The page only ever holds the REDACTED descriptor, so it mutates the fields it can see rather than rebuilding a section. DeepSeek's `models` is one replace-by-value array: the editor shows inherited effective rows until the first model edit materializes the complete array in the user layer, while reset unsets that override. A row carries the model id and display name; its context window and output cap sit behind the row's own disclosure, with the same fields the pi-ai provider form uses. Either capacity is typed as a count with an optional decimal `K` or `M` suffix (`256K`, `1M`; `1M` is 1000K) and stored as the plain count, spelled back in the shortest form that round-trips. Empty ids, duplicate ids, empty explicit names, and unreadable, non-positive, or fractional capacities fail before any write. A typed API key is judged on its own field the same way: after trimming, it must be non-empty and every character must be printable ASCII (`[\x21-\x7E]`), which is exactly what an HTTP header value can carry — the twin of `normalizeApiKey` in `@deepseek-ai/dsh-llm`, mirrored here because the source-plane split forbids importing it. A value matching a pasted `NAME=value` environment line or wrapped in matching quotes is refused as the same format failure; that pasted-line check runs only in the browser, since a false positive in a resolver would leave the environment refusing the key as well. A field holding only whitespace fails rather than being silently dropped, while an empty field is not a failure at all: it means keep the stored key on an editor card, and authenticate some other way on a create card. A refused key blocks both the write and the endpoint interrogation, so the page never spends a round trip to be told what the field already says. Each settings write carries the card's current `revision`, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict`; after settings commit, the card adopts the returned redacted user subtree and revision before storing the credential, which makes a failed credential stage retry only that stage. Deletion removes a configured, writable credential only when the profile names the page's derived `<ROUTE>_API_KEY` target, then unsets the profile; both operations are idempotent, and a partial failure remains in the identified confirmation dialog for retry. Environment credentials, custom references, and credentials whose target cannot be identified remain untouched. Once loaded, the page subscribes directly to forwarded `settings/document-updated`, `credentials/updated`, and `llm/adapters-updated` owner events, plus local `connection/reset`, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling.
## Model list and endpoint interrogation

View File

@@ -8,7 +8,7 @@
前序首次使用引导页面完成后DeepSeek 步骤会从同一个联接快照得出 `deepseek-official` 的就绪状态。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此同 id 但未声明的存活路由不属于可修复配置。凭据引用已配置时该步骤会直接完成而不渲染其中包括来自启动环境且只读的凭据。只有已挂载且活跃、引用可写但尚未配置的适配器才会显示前往「设置」Models 分区的页面;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,该步骤绝不持有 secret。适配器缺失、路由不活跃、联接失败、部署只读或设置凭据能力不可用时该步骤均不渲染并直接完成以免首次使用引导阻塞产品Models 页仍是诊断界面。
每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除提供方行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor因此它只修改自己看得见的字段而不重建分节。DeepSeek 的 `models` 是一个按值整体替换的数组:编辑器会显示继承而来的生效模型行,直到第一次模型编辑将完整数组具化到用户层;重置则会取消该覆盖。每个模型行承载模型 ID 与显示名称,其上下文窗口与最大输出 token 数则收在该行自己的折叠区里,使用与 pi-ai 提供方表单相同的字段。两项容量都按数值键入,可带十进制的 `K``M` 后缀(`256K``1M``1M` 即 1000K存储为纯数值回显时写成能够往返的最短形式。空 ID、重复 ID、显式填写的空名称以及无法读取、非正数或非整数的容量都会在写入前失败。键入的 API 密钥同样在它自己的字段上被判定trim 之后必须非空,且每个字符都是可打印 ASCII`[\x21-\x7E]`)——这正是 HTTP 标头值所能承载的范围,是 `@deepseek-ai/dsh-llm``normalizeApiKey` 的孪生体,因源码平面分割禁止直接引入而在此镜像。与整行粘贴的 `NAME=value` 环境变量匹配或首尾成对引号包裹的值,会以同一条格式失败被拒绝;这项粘贴行检查只在浏览器中运行,因为 resolver 中的一次误判会连带让环境变量这条路也拒绝该密钥。只含空白的输入框会失败而不是被静默丢弃;留空则完全不是失败:在编辑卡片上意味着保持已存储的密钥,在新建卡片上则意味着以其他方式鉴权。被拒绝的密钥会同时拦截写入与端点探测,因此页面不会白花一次往返去换取字段上已经写明的答案。每次 settings 写入都携带卡片当前的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝settings 提交成功后,卡片会在存储凭据前采用响应返回的脱敏用户子树与 revision因此凭据阶段失败时重试只会重复该阶段。删除操作只会在 profile 指向页面派生的 `<ROUTE>_API_KEY` 目标时清除已配置且可写的凭据,随后取消设置 profile两项操作都具备幂等性部分失败会停留在点名目标的确认对话框中供重试。环境凭据、自定义引用和无法识别目标的凭据保持不变。页面加载完成后会在推送的失效事件`settings/changed``credentials/changed``models/changed` `connection/reset`)上重拉,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。
每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除提供方行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor因此它只修改自己看得见的字段而不重建分节。DeepSeek 的 `models` 是一个按值整体替换的数组:编辑器会显示继承而来的生效模型行,直到第一次模型编辑将完整数组具化到用户层;重置则会取消该覆盖。每个模型行承载模型 ID 与显示名称,其上下文窗口与最大输出 token 数则收在该行自己的折叠区里,使用与 pi-ai 提供方表单相同的字段。两项容量都按数值键入,可带十进制的 `K``M` 后缀(`256K``1M``1M` 即 1000K存储为纯数值回显时写成能够往返的最短形式。空 ID、重复 ID、显式填写的空名称以及无法读取、非正数或非整数的容量都会在写入前失败。键入的 API 密钥同样在它自己的字段上被判定trim 之后必须非空,且每个字符都是可打印 ASCII`[\x21-\x7E]`)——这正是 HTTP 标头值所能承载的范围,是 `@deepseek-ai/dsh-llm``normalizeApiKey` 的孪生体,因源码平面分割禁止直接引入而在此镜像。与整行粘贴的 `NAME=value` 环境变量匹配或首尾成对引号包裹的值,会以同一条格式失败被拒绝;这项粘贴行检查只在浏览器中运行,因为 resolver 中的一次误判会连带让环境变量这条路也拒绝该密钥。只含空白的输入框会失败而不是被静默丢弃;留空则完全不是失败:在编辑卡片上意味着保持已存储的密钥,在新建卡片上则意味着以其他方式鉴权。被拒绝的密钥会同时拦截写入与端点探测,因此页面不会白花一次往返去换取字段上已经写明的答案。每次 settings 写入都携带卡片当前的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝settings 提交成功后,卡片会在存储凭据前采用响应返回的脱敏用户子树与 revision因此凭据阶段失败时重试只会重复该阶段。删除操作只会在 profile 指向页面派生的 `<ROUTE>_API_KEY` 目标时清除已配置且可写的凭据,随后取消设置 profile两项操作都具备幂等性部分失败会停留在点名目标的确认对话框中供重试。环境凭据、自定义引用和无法识别目标的凭据保持不变。页面加载完成后会直接订阅转发的 owner 事件 `settings/document-updated``credentials/updated``llm/adapters-updated`,以及本地 `connection/reset`,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。
## 模型列表与端点询问

View File

@@ -34,7 +34,8 @@
"inject": [
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-settings",
"@deepseek-ai/dsh-client-locale"
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-api-remotes"
],
"platform": "web"
}
@@ -45,6 +46,8 @@
},
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-schema-form": "workspace:^",
@@ -52,10 +55,10 @@
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-client-web-react": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/cordis": "workspace:^",
"react": "^18.2.0"
},
"devDependencies": {
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",

View File

@@ -12,6 +12,9 @@ import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
import type {} from '@deepseek-ai/dsh-client-locale/client'
// Type-only: pulls the ctx.remote merge and the forwarded-event key face
// (settings/credentials invalidations ride the allowlist) into this program.
import type {} from '@deepseek-ai/dsh-api-remotes/client'
import { ModelsSection } from './ModelsSection.tsx'
import type { ModelsSectionInjected } from './ModelsSection.tsx'
import { DeepSeekOnboardingDialog } from './DeepSeekOnboardingDialog.tsx'
@@ -48,7 +51,7 @@ export function refreshIfLoaded(controller: ModelsSettingsStore): void {
* ui-settings' apply, whose activation order relative to this one is NOT
* constrained; registration depends on each slot through `slots.inject()`.
*/
export const inject = ['slots', 'locale', 'connection']
export const inject = ['slots', 'locale', 'connection', 'remote']
/**
* Register the Models section once the `settings.section` declaration is on
@@ -82,9 +85,9 @@ export function apply(ctx: ClientContext): void {
ctx.effect(() => {
const refresh = (): void => { refreshIfLoaded(controller) }
const disposers = [
ctx.on('settings/changed', refresh),
ctx.on('credentials/changed', refresh),
ctx.on('models/changed', refresh),
ctx.remote.$on('settings/document-updated', refresh),
ctx.remote.$on('credentials/updated', refresh),
ctx.remote.$on('llm/adapters-updated', refresh),
ctx.on('connection/reset', refresh),
]
return () => { for (const dispose of disposers) dispose() }

View File

@@ -4,7 +4,7 @@ import { describe, expect, it, vi } from 'vitest'
import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
import { TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
import { apply, inject, refreshIfLoaded } from '@deepseek-ai/dsh-client-ui-models/client'
import { ModelsSection } from '../src/client/ModelsSection.tsx'
import { DeepSeekOnboardingDialog } from '../src/client/DeepSeekOnboardingDialog.tsx'
@@ -18,6 +18,9 @@ async function bench() {
await ctx.plugin(SlotsService).await()
const locale = new LocaleService(ctx)
ctx.provide('locale', locale)
// The plugins inject `remote`; forwarded events reach them through the
// same `$dispatch` handoff the connection sink makes.
new TestRemote(ctx)
// The apply path only captures the wire face; no call leaves this fake
// until a section actually loads.
ctx.provide('connection', { api: {} } as never)
@@ -39,7 +42,7 @@ function declare(slots: SlotsService): () => void {
describe('ui-models apply', () => {
it('declares the services it uses', () => {
expect(inject).toEqual(['slots', 'locale', 'connection'])
expect(inject).toEqual(['slots', 'locale', 'connection', 'remote'])
})
it('registers the models nav entry for declarations before or after apply', async () => {
@@ -135,9 +138,9 @@ describe('pushed invalidations', () => {
declare(b.slots)
await b.ctx.plugin({ inject: [...inject], apply }).await()
// The fake wire face has no methods: a fetch attempt would throw.
b.ctx.emit('settings/changed', 'llm-pi-ai')
b.ctx.emit('credentials/changed', 'OPENAI_API_KEY')
b.ctx.emit('models/changed')
b.ctx.remote.$dispatch('settings/document-updated', ['llm-pi-ai', 1])
b.ctx.remote.$dispatch('credentials/updated', ['OPENAI_API_KEY'])
b.ctx.remote.$dispatch('llm/adapters-updated', [])
b.ctx.emit('connection/reset')
})
@@ -167,7 +170,7 @@ describe('pushed invalidations', () => {
)()
injected.controller.store.update((state) => { state.status = 'ready' })
const load = vi.spyOn(injected.controller, 'load').mockResolvedValue()
b.ctx.emit('credentials/changed', 'DEEPSEEK_API_KEY')
b.ctx.remote.$dispatch('credentials/updated', ['DEEPSEEK_API_KEY'])
expect(load).toHaveBeenCalledTimes(1)
})
})

View File

@@ -37,6 +37,9 @@
},
{
"path": "../../support/invariants"
},
{
"path": "../../api/remotes/tsconfig.client.json"
}
]
}

View File

@@ -35,7 +35,9 @@
"@deepseek-ai/dsh-client-connection",
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-command"
"@deepseek-ai/dsh-client-ui-command",
"@deepseek-ai/dsh-api-remotes",
"@deepseek-ai/dsh-client-ui-settings"
],
"platform": "web"
}
@@ -46,33 +48,38 @@
},
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-schema-form": "workspace:^",
"@deepseek-ai/dsh-client-ui-command": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
"@deepseek-ai/dsh-client-ui-slash": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-permission": "workspace:^",
"@deepseek-ai/cordis": "workspace:^",
"react": "^18.2.0"
},
"devDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-schema-form": "workspace:^",
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-command": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
"@deepseek-ai/dsh-client-ui-slash": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-client-web-react": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-permission": "workspace:^",
"@types/react": "~18.3.1",
"@deepseek-ai/cordis": "workspace:^",
"react": "^18.2.0"
},
"files": [

View File

@@ -16,6 +16,11 @@
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 slot types (this package registers a General row).
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
// Type-only: pulls the ctx.remote merge and the forwarded-event key face
// (the settings invalidation rides the allowlist) into this program.
import type {} from '@deepseek-ai/dsh-api-remotes/client'
import type { ClientContext, SessionFace } from '@deepseek-ai/dsh-client-runtime/client'
import type { CommandServiceContract, SelectOption } from '@deepseek-ai/dsh-client-ui-command/client'
import type { ClientSessionContext } from '@deepseek-ai/dsh-client-ui-slash/client'
@@ -38,7 +43,7 @@ export type {
} from './settings-store.ts'
/** Required services (cordis fiber inject). */
export const inject = ['command', 'sessions', 'slots', 'locale', 'connection']
export const inject = ['command', 'sessions', 'slots', 'locale', 'connection', 'remote']
const ACCESS_NS = 'permission.access'
@@ -118,12 +123,12 @@ export function apply(ctx: ClientContext): void {
})
ctx.effect(() => {
const refresh = (ns?: string): void => {
if (ns !== undefined && ns !== PERMISSION_SETTINGS_NS) return
refreshPermissionIfLoaded(controller)
}
const refresh = (): void => { refreshPermissionIfLoaded(controller) }
const disposers = [
ctx.on('settings/changed', refresh),
ctx.remote.$on('settings/document-updated', (ns) => {
if (ns !== PERMISSION_SETTINGS_NS) return
refresh()
}),
ctx.on('connection/reset', () => { refresh() }),
]
return () => {

View File

@@ -12,6 +12,7 @@ import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it } from 'vitest'
import { SlotsService, type SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
import type { CommandDecoration } from '@deepseek-ai/dsh-client-ui-command/client'
import type { PermissionSelect } from '@deepseek-ai/dsh-permission/client'
import {
@@ -37,6 +38,9 @@ async function bench() {
const locale = new LocaleService(ctx)
locale.setLocale('en')
ctx.provide('locale', locale)
// The plugin injects `remote`; forwarded events reach it through the same
// `$dispatch` handoff the connection sink makes.
new TestRemote(ctx)
ctx.slots.register({
name: 'root',
children: {
@@ -158,8 +162,8 @@ describe('ui-permission browser plugin', () => {
it('disposal removes the decoration (HMR safety)', async () => {
const b = await bench()
expect(b.decoration()).toBeDefined()
b.ctx.emit('settings/changed', 'another')
b.ctx.emit('settings/changed', 'permission')
b.ctx.remote.$dispatch('settings/document-updated', ['another', 1])
b.ctx.remote.$dispatch('settings/document-updated', ['permission', 1])
b.ctx.emit('connection/reset')
await b.fiber.dispose()
expect(b.decoration()).toBeUndefined()

View File

@@ -43,6 +43,12 @@
},
{
"path": "../../support/invariants"
},
{
"path": "../../api/remotes/tsconfig.client.json"
},
{
"path": "../ui-settings"
}
]
}

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-settings-general/README.md
README.md: ab27e073dc76335efc619f56365d1705007f7ef2
README.zh.md: 16ff5604bee5425569b783e27a29699344f630e3
README.md: 0cdffdfaad20784535a7ed010ad4b71d63a0a2c1
README.zh.md: 0b72b3db96b335f0c288758b3f7a8b56441ee88c

View File

@@ -2,11 +2,13 @@
English | [中文](README.zh.md)
Settings ownerless-copy and product-onboarding plugin: registers everything on the Settings surface that belongs to no single feature — the shell's trigger/header/close chrome content, the local configuration-file action, the General section and its `settings.general.item` slot, the `settings` dictionaries, and the first ordered welcome step. Feature-owned rows (Permission, Language, Appearance), sections (Models), and conditional onboarding steps stay with their feature packages.
Settings shell, ownerless-copy, and product-onboarding plugin. It occupies `sidebar.settings` with the trigger chrome and modal settings panel, projects the `settings.section` ledger into the navigation and the `settings.onboarding` ledger into one mounted page at a time, and registers everything on the Settings pages that belongs to no single feature — the trigger/header/close chrome content, the local configuration-file action, the General section and its `settings.general.item` slot, the `settings` dictionaries, and the first ordered welcome step. The slot types it renders into belong to ui-settings, the settings domain base; only the shell's own contract types live here, because they reference ui-sidebar's slot type and the base layer must depend on no `ui-*` package. Feature-owned rows (Permission, Language, Appearance), sections (Models), and conditional onboarding steps stay with their feature packages.
The shell ships no copy of its own — all text arrives from registrants. Nav labels may be locale-following thunks, so the nav projection resolves them through `resolveSlotLabel` and re-renders on the section ledger bump or the locale revision (an optional `ctx.get('locale')` read; no hard locale dependency). The onboarding ledger projects in ascending order and mounts exactly one page at a time; the takeover chrome (body-level stage, mask, app-root `inert`) belongs to the step itself through ui-primitives' `OnboardingSurface`, so a mounted step still resolving its private facts renders null and neither paints nor blocks anything — the shell shows no empty stage while a step decides. The active registrant receives its id, `complete()`, and an `openSection(id)` callback; completing or skipping transfers ownership to the next entry. Registrants own durable completion, capability readiness, copy, mutations, and the surface wrap, so independently registered flows cannot stack and the shell does not become a second configuration fact source.
A loopback browser loads the provider's `hasDocument` capability through `settings.describe` and renders **Open configuration file** only when the Host confirms that a provider-owned local document can be prepared. The action sends the pathless, loopback-only `settings.openDocument` request; the Host resolves the provider path again, materializes an absent document, and hands it to a native text editor (`open -t` on macOS, bypassing a browser file association; the desktop file association on Linux and Windows; Windows association after `wslpath -w` translation on WSL). Open failures keep the action available and render a localized error. Reopening the dialog or reconnecting refreshes availability after a transient read failure or Host topology change. Remote browsers never register the action and never issue the privileged settings read.
`src/onboarding-copy.ts` is the single editable owner of the complete notice plus `WELCOME_NOTICE_VERSION`; both supported GUI locales intentionally render the same Chinese copy. The Host half registers `ui-onboarding` in the user-settings seam. A loopback browser compares `welcomeNoticeVersion` for exact equality and writes the current value only after Continue succeeds. The path mutation is idempotent across tabs and preserves sibling settings, while `host/settings-changed` makes an externally acknowledged notice advance without a reload. A non-loopback browser cannot access the privileged settings API: it still presents the notice, but Continue advances only the current browser process and a reload presents the notice again. A different version deliberately presents the notice again. The welcome page preserves every authored paragraph, gives the requested clause in the final paragraph the sole emphasis, initially focuses the title, and has no close, Escape, mask-click, or secondary path. None of its copy or acknowledgement enters a Session log or model request. The notice identifies `DSH_TELEMETRY_DISABLED=1` as the telemetry opt-out.
`src/onboarding-copy.ts` is the single editable owner of the complete notice plus `WELCOME_NOTICE_VERSION`; both supported GUI locales intentionally render the same Chinese copy. The Host half registers `ui-onboarding` in the user-settings seam. A loopback browser compares `welcomeNoticeVersion` for exact equality and writes the current value only after Continue succeeds. The path mutation is idempotent across tabs and preserves sibling settings, while the forwarded `settings/document-updated` event makes an externally acknowledged notice advance without a reload. A non-loopback browser cannot access the privileged settings API: it still presents the notice, but Continue advances only the current browser process and a reload presents the notice again. A different version deliberately presents the notice again. The welcome page preserves every authored paragraph, gives the requested clause in the final paragraph the sole emphasis, initially focuses the title, and has no close, Escape, mask-click, or secondary path. None of its copy or acknowledgement enters a Session log or model request. The notice identifies `DSH_TELEMETRY_DISABLED=1` as the telemetry opt-out.
## Model Experience

View File

@@ -2,11 +2,13 @@
[English](README.md) | 中文
设置界面无特定功能归属文案与产品引导插件:在设置界面注册所有不属于单一功能的内容,包括外壳的触发器、标题栏与关闭控件内容、本地配置文件操作,「通用」分区及其 `settings.general.item` slot、`settings` 字典,以及第一个有序欢迎步骤。归具体功能所有的行(「权限」、「语言」、「外观」)、分区(「模型」)和条件式首次使用引导步骤仍由各自的功能包提供。
设置外壳、无特定功能归属文案与产品引导插件。它以触发控件和模态设置面板占用 `sidebar.settings`,把 `settings.section` 账本投影成导航、把 `settings.onboarding` 账本投影成每次只挂载一页的引导流程,并在设置页面上注册所有不属于单一功能的内容触发器、标题栏与关闭控件内容、本地配置文件操作,「通用」分区及其 `settings.general.item` slot、`settings` 字典,以及第一个有序欢迎步骤。它渲染进的那些 slot 类型归 ui-settings——设置领域底座——所有只有外壳自身的契约类型放在这里因为它们引用 ui-sidebar 的 slot 类型,而底座不得依赖任何 `ui-*` 包。归具体功能所有的行(「权限」、「语言」、「外观」)、分区(「模型」)和条件式首次使用引导步骤仍由各自的功能包提供。
外壳不自带文案:所有文本都来自注册方。导航 label 可以是跟随语言的 thunk因此导航投影经 `resolveSlotLabel` 解析,并在分区账本更新或 locale revision 变化时重新渲染(`ctx.get('locale')` 可选读取,无硬 locale 依赖。首次使用引导记录按升序投影每次只挂载一个页面接管界面框架body 层级的展示层、遮罩、应用根节点 `inert`)经 ui-primitives 的 `OnboardingSurface` 由步骤自身持有,因此已挂载但仍在判定私有事实的步骤渲染 null 时不绘制也不阻塞任何内容——步骤判定期间外壳不会露出空白展示层。当前注册方会收到该条目的 id、`complete()``openSection(id)` 回调;完成或跳过当前页面后,所有权转交给下一项。持久化完成状态、能力就绪状态、文案、变更操作以及展示层包装均由注册方持有,因此独立注册的流程无法堆叠,外壳也不会成为第二个配置事实来源。
回环浏览器通过 `settings.describe` 加载提供方的 `hasDocument` 能力,且只有在 Host 确认可准备好一份由提供方持有的本地文档时才渲染**打开配置文件**。该操作发送无路径参数且仅限回环访问的 `settings.openDocument` 请求Host 会再次解析提供方路径、在文档缺失时将其创建出来并交给原生文本编辑器macOS 上使用 `open -t`绕过浏览器文件关联Linux 和 Windows 上使用桌面文件关联WSL 上经 `wslpath -w` 转换后使用 Windows 文件关联)。打开失败时该操作仍可使用,并渲染本地化错误。临时读取失败或 Host 拓扑变化后,重新打开对话框或重新连接会刷新可用性。远程浏览器从不注册该操作,也从不发起这项特权设置读取。
`src/onboarding-copy.ts` 是完整通知文案和 `WELCOME_NOTICE_VERSION` 的唯一可编辑来源GUI 支持的两种 locale 都有意渲染同一份中文文案。宿主端在用户设置 seam 中注册 `ui-onboarding`。回环浏览器会比较 `welcomeNoticeVersion` 是否精确相等,仅在「继续」操作成功后写入当前值。该路径变更在不同标签页间幂等,并会保留同级设置;`host/settings-changed` 则让页面在通知被外部确认后,无需重新加载即可推进。非回环浏览器不能访问受保护的设置 API它仍会显示通知但「继续」只推进当前浏览器进程重新加载后会再次显示通知。版本不同时系统也会有意重新显示通知。欢迎页保留原文的每个段落仅强调最后一段中指定的句段初始焦点落在标题上并且没有关闭操作、Escape、点击遮罩或次要操作路径。其文案和确认状态均不会进入会话日志或模型请求。通知明确以 `DSH_TELEMETRY_DISABLED=1` 作为遥测关闭方式。
`src/onboarding-copy.ts` 是完整通知文案和 `WELCOME_NOTICE_VERSION` 的唯一可编辑来源GUI 支持的两种 locale 都有意渲染同一份中文文案。宿主端在用户设置 seam 中注册 `ui-onboarding`。回环浏览器会比较 `welcomeNoticeVersion` 是否精确相等,仅在「继续」操作成功后写入当前值。该路径变更在不同标签页间幂等,并会保留同级设置;转发的 `settings/document-updated` 事件则让页面在通知被外部确认后,无需重新加载即可推进。非回环浏览器不能访问受保护的设置 API它仍会显示通知但「继续」只推进当前浏览器进程重新加载后会再次显示通知。版本不同时系统也会有意重新显示通知。欢迎页保留原文的每个段落仅强调最后一段中指定的句段初始焦点落在标题上并且没有关闭操作、Escape、点击遮罩或次要操作路径。其文案和确认状态均不会进入会话日志或模型请求。通知明确以 `DSH_TELEMETRY_DISABLED=1` 作为遥测关闭方式。
## 模型体验

View File

@@ -35,7 +35,9 @@
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-settings",
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-connection"
"@deepseek-ai/dsh-client-connection",
"@deepseek-ai/dsh-api-remotes",
"@deepseek-ai/dsh-client-ui-sidebar"
],
"platform": "web"
}
@@ -47,14 +49,17 @@
"license": "BSD-3-Clause",
"dependencies": {
"@deepseek-ai/dsh-settings": "workspace:^",
"@deepseek-ai/schemastery": "workspace:^"
"@deepseek-ai/schemastery": "workspace:^",
"clsx": "^2.0.0"
},
"peerDependencies": {
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
"@deepseek-ai/dsh-client-ui-sidebar": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-client-web-react": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
@@ -62,17 +67,19 @@
"react": "^18.2.0"
},
"devDependencies": {
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
"@deepseek-ai/dsh-client-ui-sidebar": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-client-web-react": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@types/react": "~18.3.1",
"@deepseek-ai/cordis": "workspace:^",
"@types/react": "~18.3.1",
"react": "^18.2.0"
},
"files": [

View File

@@ -16,7 +16,7 @@ import clsx from 'clsx'
import {
IconAgentPresetOutline16, IconCloseOutline16, IconDataOutline16, IconSettingsOutline16,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { SettingsRootComponentProps, SettingsSectionRow } from './contract/slots.ts'
import type { SettingsRootComponentProps, SettingsSectionRow } from './shell-contract.ts'
import css from './SettingsRoot.module.css'
/** Nav glyph by section id; unknown ids fall back to the settings gear. */

View File

@@ -1,17 +1,29 @@
/**
* Settings ownerless-copy plugin, browser half: registers everything on the
* Settings surface that belongs to no single feature — the trigger/header
* chrome content, local-document action, General section, and `settings`
* dictionaries. Feature-owned rows and sections stay with their features.
* Settings shell and ownerless-copy plugin, browser half: renders the
* `sidebar.settings` occupant — panel chrome, section navigation, and the
* onboarding stage — and registers everything on the Settings pages that
* belongs to no single feature: the trigger/header chrome content,
* local-document action, General section, and `settings` dictionaries.
* Feature-owned rows and sections stay with their features.
* Export discipline: packages/client/AGENTS.md.
*/
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
// Type-only: pulls the shell's SlotMap merges (trigger/header/section/item).
// Type-only: the settings slot declarations plus the ctx.settingsScope Context
// merge. Cross-plugin collaboration goes through the service, never a value
// import (client bundle purity gate).
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
// Type-only: pulls ctx.locale and the 'settings.general.item' SlotMap merge.
// Type-only: pulls ctx.locale into this program.
import type {} from '@deepseek-ai/dsh-client-locale/client'
// Type-only: pulls the ctx.remote merge and the forwarded-event key face
// (the settings invalidation rides the allowlist) into this program.
import type {} from '@deepseek-ai/dsh-api-remotes/client'
import type {
SettingsOnboardingStep, SettingsRootInjected, SettingsSectionRow,
} from './shell-contract.ts'
import { SettingsRoot } from './SettingsRoot.tsx'
import { CloseLabel, HeaderContent, TriggerContent } from './chrome.tsx'
import { GeneralSection } from './GeneralSection.tsx'
import { SettingsDocumentAction } from './SettingsDocumentAction.tsx'
@@ -51,7 +63,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']
export const inject = ['slots', 'locale', 'connection', 'remote']
/**
* Register the `settings` dictionaries, the chrome content, and the General
@@ -83,12 +95,12 @@ export function apply(ctx: ClientContext): void {
})
ctx.effect(() => {
const refresh = (ns?: string): void => {
if (ns !== undefined && ns !== WELCOME_NOTICE_SETTINGS_NAMESPACE) return
refreshWelcomeIfLoaded(welcomeController)
}
const refresh = (): void => { refreshWelcomeIfLoaded(welcomeController) }
const disposers = [
ctx.on('settings/changed', refresh),
ctx.remote.$on('settings/document-updated', (ns) => {
if (ns !== WELCOME_NOTICE_SETTINGS_NAMESPACE) return
refresh()
}),
ctx.on('connection/reset', () => {
refresh()
refreshDocumentIfLoaded(documentController)
@@ -96,6 +108,77 @@ export function apply(ctx: ClientContext): void {
]
return () => { for (const dispose of disposers) dispose() }
}, 'ui-settings-general: metadata invalidations')
// The settings shell: this package occupies the sidebar-owned hole and
// declares the settings slots. Ledger → nav-row projection as an observable
// source (uSES contract: getSnapshot returns the cached rows until the
// ledger version moves). Labels may be locale-following thunks, so the cache
// key includes the locale revision and subscribers ride both sources.
let rowsVersion = -1
let rowsRevision = -1
let rows: readonly SettingsSectionRow[] = []
let onboardingVersion = -1
let onboardingSteps: readonly SettingsOnboardingStep[] = []
const shellInjected = (): SettingsRootInjected => ({
hooks: {
sections: {
getSnapshot: () => {
const version = ctx.slots.getVersion('settings.section')
const revision = ctx.locale.getSnapshot().revision
if (version !== rowsVersion || revision !== rowsRevision) {
rowsVersion = version
rowsRevision = revision
rows = ctx.slots.entries('settings.section')
.map(e => ({
/* v8 ignore next -- list-slot registration requires id (SlotCore rejects an entry without one) */
id: e.options.id ?? '',
order: e.options.order ?? 0,
label: resolveSlotLabel(e.options.label) ?? '',
}))
.sort((a, b) => a.order - b.order)
}
return rows
},
subscribe: (listener) => {
const offLedger = ctx.slots.subscribe('settings.section', listener)
const offLocale = ctx.locale.subscribe(listener)
return () => {
offLedger()
offLocale()
}
},
},
onboardingSteps: {
getSnapshot: () => {
const version = ctx.slots.getVersion('settings.onboarding')
if (version !== onboardingVersion) {
onboardingVersion = version
onboardingSteps = ctx.slots.entries('settings.onboarding')
.map(e => ({
/* v8 ignore next -- list-slot registration requires id */
id: e.options.id ?? '',
order: e.options.order ?? 0,
}))
.sort((a, b) => a.order - b.order)
}
return onboardingSteps
},
subscribe: listener => ctx.slots.subscribe('settings.onboarding', listener),
},
},
})
ctx.slots.inject('sidebar.settings', () => ctx.slots.register({
name: 'sidebar.settings',
children: {
'settings.trigger': { kind: 'single', scope: 'root' },
'settings.header': { kind: 'single', scope: 'root' },
'settings.action': { kind: 'list', scope: 'root' },
'settings.close': { kind: 'single', scope: 'root' },
'settings.section': { kind: 'list', scope: 'root' },
'settings.onboarding': { kind: 'list', scope: 'root' },
},
inject: shellInjected,
}, SettingsRoot))
ctx.slots.inject('settings.trigger', () =>
ctx.slots.register({ name: 'settings.trigger', locale: NS }, TriggerContent))
ctx.slots.inject('settings.header', () =>

View File

@@ -0,0 +1,59 @@
/**
* Settings shell contract — the types of the `sidebar.settings` occupant this
* package renders. They live here rather than in ui-settings because they
* reference the sidebar's own slot type: ui-settings is the settings domain's
* base layer and must not depend on any `ui-*` presentation package, or the
* reference graph closes a cycle through ui-sidebar → ui-layout → ui-theme.
* The settings SLOT types (what registrants contribute) stay in ui-settings.
*/
import type { HostObservable, InjectFace, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
// Type-only: pulls ui-sidebar's SlotMap merge (the 'sidebar.settings' entry)
// into every program that sees this contract.
import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client'
// Type-only: pulls the settings slot declarations the shell renders into.
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
/** One nav row projected from a settings.section registration's options. */
export interface SettingsSectionRow {
id: string
order: number
label: string
}
/** One ordered onboarding step projected from a slot registration. */
export interface SettingsOnboardingStep {
id: string
order: number
}
/**
* Registrant-private injected share of the settings shell (assembled in
* apply): the ledger's nav-row projection as a hooks-compartment source —
* the shell reads no locale state and subscribes through the bound hook.
*/
export type SettingsRootInjected = {
hooks: {
/** settings.section ledger projected into ordered nav rows. */
sections: HostObservable<readonly SettingsSectionRow[]>
/** settings.onboarding ledger projected into coordinator order. */
onboardingSteps: HostObservable<readonly SettingsOnboardingStep[]>
}
}
/**
* Full component props of the settings shell root: the sidebar owner share
* (wide/rail state) plus the declared render shares and the injected face
* (hooks compartment bound to useSections). No store is registered — modal
* open state and active section id are component-local viewing state.
*/
export type SettingsRootComponentProps =
PropsRuntime<'sidebar.settings'>
& PropsRenderSlots<
| 'settings.trigger'
| 'settings.header'
| 'settings.action'
| 'settings.close'
| 'settings.section'
| 'settings.onboarding'
>
& InjectFace<SettingsRootInjected>

View File

@@ -4,7 +4,7 @@ import { describe, expect, it, vi } from 'vitest'
import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
import { TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-settings-general/client'
import { CloseLabel, HeaderContent, TriggerContent } from '../src/client/chrome.tsx'
import { GeneralSection } from '../src/client/GeneralSection.tsx'
@@ -33,6 +33,9 @@ async function bench(isLoopback = true) {
await ctx.plugin(SlotsService).await()
const locale = new LocaleService(ctx)
ctx.provide('locale', locale)
// The plugins inject `remote`; forwarded events reach them through the
// same `$dispatch` handoff the connection sink makes.
new TestRemote(ctx)
const settingsDescribe = vi.fn(() => Promise.resolve({
rpcId: 'settings-general' as never,
result: {
@@ -86,7 +89,7 @@ function generalEntry(slots: SlotsService) {
describe('ui-settings-general apply', () => {
it('declares the services it uses', () => {
expect(inject).toEqual(['slots', 'locale', 'connection'])
expect(inject).toEqual(['slots', 'locale', 'connection', 'remote'])
})
it('fills all six seats for declarations before or after apply', async () => {
@@ -167,9 +170,9 @@ describe('ui-settings-general apply', () => {
const { controller } = (entry.inject as unknown as () => WelcomeNoticeInjected)()
await controller.load()
expect(b.settingsDescribe).toHaveBeenCalledOnce()
b.ctx.emit('settings/changed', 'unrelated')
b.ctx.remote.$dispatch('settings/document-updated', ['unrelated', 1])
expect(b.settingsDescribe).toHaveBeenCalledOnce()
b.ctx.emit('settings/changed', WELCOME_NOTICE_SETTINGS_NAMESPACE)
b.ctx.remote.$dispatch('settings/document-updated', [WELCOME_NOTICE_SETTINGS_NAMESPACE, 1])
await vi.waitFor(() => { expect(b.settingsDescribe).toHaveBeenCalledTimes(2) })
b.ctx.emit('connection/reset')
await vi.waitFor(() => { expect(b.settingsDescribe).toHaveBeenCalledTimes(3) })

View File

@@ -2,7 +2,7 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { useEffect, useState } from 'react'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import type { SettingsRootComponentProps } from '../src/client/contract/slots.ts'
import type { SettingsRootComponentProps } from '../src/client/shell-contract.ts'
import { SettingsRoot } from '../src/client/SettingsRoot.tsx'
afterEach(cleanup)

View File

@@ -2,13 +2,26 @@
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it, vi } from 'vitest'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-settings/client'
import type { SettingsRootInjected } from '@deepseek-ai/dsh-client-ui-settings/client'
import { apply, inject } from '../src/client/index.ts'
import type { SettingsRootInjected } from '../src/client/shell-contract.ts'
import { SettingsRoot } from '../src/client/SettingsRoot.tsx'
async function bench() {
const ctx = new Context()
await ctx.plugin(SlotsService).await()
// Copy machinery the shell only reads a revision from; the real locale
// plugin would drag its own settings-row dependencies into this bench.
ctx.provide('locale', {
register: () => () => {},
bind: () => (key: string) => key,
getSnapshot: () => ({ active: 'zh', locales: [], revision: 0 }),
subscribe: () => () => {},
} as never)
ctx.provide('connection', {
api: { settings: { describe: async () => ({ result: { ok: false } }) } },
isLoopback: false,
} as never)
ctx.provide('remote', { $on: () => () => {} } as never)
return { ctx, slots: ctx.get('slots') as SlotsService }
}
@@ -36,7 +49,7 @@ const CHILD_SPECS = {
describe('ui-settings apply', () => {
it('declares only the slot registry (a pure composition face, no locale)', () => {
expect(inject).toEqual(['slots'])
expect(inject).toEqual(['slots', 'locale', 'connection', 'remote'])
})
it('registers the shell and declares every child slot, before or after the declaration', async () => {
@@ -63,13 +76,16 @@ describe('ui-settings apply', () => {
declare(b.slots)
await b.ctx.plugin({ inject: [...inject], apply }).await()
const { sections } = injectedOf(b.slots).hooks
// The shell ships no sections of its own — registrants fill the ledger.
expect(sections.getSnapshot()).toEqual([])
// This package registers the General section itself; every other section
// arrives from a feature registrant.
const GENERAL = { id: 'general', order: 0, label: 'general.nav' }
expect(sections.getSnapshot()).toEqual([GENERAL])
b.slots.register({ name: 'settings.section', id: 'z', order: 20, label: 'Z' } as never, () => null)
// No order and no label: both projection defaults apply.
b.slots.register({ name: 'settings.section', id: 'a' } as never, () => null)
const rows = sections.getSnapshot()
expect(rows).toEqual([
GENERAL,
{ id: 'a', order: 0, label: '' },
{ id: 'z', order: 20, label: 'Z' },
])
@@ -94,6 +110,8 @@ describe('ui-settings apply', () => {
b.slots.register({ name: 'settings.onboarding', id: 'default-order' } as never, () => null)
const steps = onboardingSteps.getSnapshot()
expect(steps).toEqual([
// This package's own onboarding page, registered by the same apply.
{ id: 'welcome-notice', order: -100 },
{ id: 'welcome', order: -100 },
{ id: 'credential', order: 0 },
{ id: 'default-order', order: 0 },

View File

@@ -37,6 +37,12 @@
},
{
"path": "../../support/invariants"
},
{
"path": "../../api/remotes/tsconfig.client.json"
},
{
"path": "../ui-sidebar"
}
]
}

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-settings/README.md
README.md: 785f0417f00ec8eb1f8c9273b4d81f8ca5ca1810
README.zh.md: 239987e379aaabd2edbfc9e09cd2fc8cef7a685e
README.md: 8bf085fd02e3c76148674065bb4f5708e9a6e8d8
README.zh.md: f7b6f18809c64be6830ea23c3968e9af70b70c41

View File

@@ -2,13 +2,13 @@
English | [中文](README.zh.md)
Settings shell plugin: a pure composition face. It occupies `sidebar.settings` with the trigger chrome and modal settings panel, and declares the slots registrants fill: `settings.trigger` / `settings.header` / `settings.close` (chrome content), `settings.action` (ordered content-header actions), `settings.section` (one page per feature), and `settings.onboarding` (ordered feature-owned pages in a full-viewport stage). The shell ships no copy of its own — all text arrives from registrants (ui-settings-general owns chrome, General, and the product notice; features own their actions, sections, rows, and conditional onboarding pages). Nav labels may be locale-following thunks, so the nav projection resolves them through `resolveSlotLabel` and re-renders on the section ledger bump or the locale revision (an optional `ctx.get('locale')` read; no hard locale dependency).
The settings domain's base layer, with two roles and no presentation of its own. It provides `ctx.settingsScope`, the Host transport every preference row binds its durable namespace section through, and it declares the settings slot types registrants fill: `settings.trigger` / `settings.header` / `settings.close` (chrome content), `settings.action` (ordered content-header actions), `settings.section` (one page per feature), and `settings.onboarding` (ordered feature-owned pages). It depends on no `ui-*` presentation package, so any feature that owns a preference can reach it; the settings SHELL — the `sidebar.settings` occupant, its navigation, and the chrome — lives in ui-settings-general, because a shell dependency on ui-sidebar would close a reference graph cycle through ui-layout and ui-theme. The shell's own contract types live beside the shell for the same reason.
The shell projects the onboarding ledger into ascending order and mounts exactly one page at a time; the takeover chrome (body-level stage, mask, app-root `inert`) belongs to the step itself through ui-primitives' `OnboardingSurface`, so a mounted step still resolving its private facts renders null and neither paints nor blocks anything — the shell shows no empty stage while a step decides. The active registrant receives its id, `complete()`, and an `openSection(id)` callback; completing or skipping transfers ownership to the next entry. Registrants own durable completion, capability readiness, copy, mutations, and the surface wrap, so independently registered flows cannot stack and the shell does not become a second configuration fact source.
The plugin injects nothing and waits for nothing: `ctx.settingsScope.bind(spec)` resolves the wire face through the CALLER's context at call time, so the bound scope's disposer belongs to the calling fiber, and the caller injects `connection` for the transport and `remote` for the invalidation. Listeners exist before the first background read starts, so a row's activation never blocks on the settings transport. A bound scope reloads on the forwarded `settings/document-updated` event for its own namespace and on `connection/reset`. Writes carry one field path and the last known namespace revision as `expectedRevision`; a rejected or failed write re-reads unless a newer write already superseded it, and a stale read never publishes over a newer one. Without a `decode` in the spec, a section that is not a plain object, fails its rehydrated schema, or carries a schema envelope this client cannot rehydrate publishes no value at all, so a row renders its own absent state instead of a half-decoded one.
## Model Experience
None, as the settings shell serves browser UI composition; nothing here reaches a model request.
None, as the settings domain base serves browser preference storage and slot declarations; nothing here reaches a model request.
#### KV Cache effect
@@ -16,4 +16,5 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **Panel is browser-preference scope only** — host-side settings surfaces (permission mode, tool-call mode) have no RPC backing yet; their skeletons live in ui-settings-general.
- **Remote browsers get no durable settings** — the settings RPCs are loopback-only, so a scope bound in a non-loopback browser starts `unavailable` and never crosses the wire; every row it backs is inert there.
- **One field per write** — `set` sends a single `set` op, so a row that must move two fields together has no transaction and publishes two revisions.

View File

@@ -2,13 +2,13 @@
[English](README.md) | 中文
设置外壳插件:一个纯组合表层。它以触发控件和模态设置面板占用 `sidebar.settings`并声明由注册方填充的 slot`settings.trigger``settings.header``settings.close`(界面框架内容)、`settings.action`(内容标题栏中的有序操作)、`settings.section`(每项功能一页)和 `settings.onboarding`(由各功能持有、显示在全视口展示层中的有序页面。外壳不自带文案所有文本都来自注册方ui-settings-general 拥有界面框架、「通用」分区和产品声明;各功能拥有各自的操作、分区、行和条件式首次使用引导页面)。导航 label 可以是跟随语言的 thunk因此导航投影经 `resolveSlotLabel` 解析,并在分区账本更新或 locale revision 变化时重新渲染(`ctx.get('locale')` 可选读取,无硬 locale 依赖)
设置领域的底座,承担两项职责,本身不含任何呈现内容。它提供 `ctx.settingsScope`——每个偏好设置行绑定自己那份持久化命名空间分区所用的宿主传输层;并声明由注册方填充的设置 slot 类型`settings.trigger``settings.header``settings.close`(界面框架内容)、`settings.action`(内容标题栏中的有序操作)、`settings.section`(每项功能一页)和 `settings.onboarding`(由各功能持有的有序页面)。它不依赖任何 `ui-*` 呈现包,因此任何持有偏好设置的功能都能够到它;设置**外壳**——`sidebar.settings` 占位方、它的导航与界面框架——位于 ui-settings-general因为外壳一旦依赖 ui-sidebar就会经 ui-layout 与 ui-theme 闭合出一条引用图环路。外壳自身的契约类型出于同一原因与外壳放在一起
外壳将首次使用引导记录按升序投影每次只挂载一个页面接管界面框架body 层级的展示层、遮罩、应用根节点 `inert`)经 ui-primitives 的 `OnboardingSurface` 由步骤自身持有,因此已挂载但仍在判定私有事实的步骤渲染 null 时不绘制也不阻塞任何内容——步骤判定期间外壳不会露出空白展示层。当前注册方会收到该条目的 id、`complete()``openSection(id)` 回调;完成或跳过当前页面后,所有权转交给下一项。持久化完成状态、能力就绪状态、文案、变更操作以及展示层包装均由注册方持有,因此独立注册的流程无法堆叠,外壳也不会成为第二个配置事实来源
该插件不注入任何服务、也不等待任何服务:`ctx.settingsScope.bind(spec)` 在调用时经**调用方**的 context 解析线路面,因此绑定所得 scope 的 disposer 归调用方 fiber 所有,而由调用方注入 `connection` 取得传输层、注入 `remote` 取得失效通知。监听器在首次后台读取启动之前就已存在,因此某一行的激活绝不会阻塞在设置传输层上。已绑定的 scope 会在收到属于自己命名空间的转发 `settings/document-updated` 事件时、以及在 `connection/reset` 时重新读取。写入携带单一字段路径以及最近已知的命名空间 revision 作为 `expectedRevision`;被拒绝或失败的写入会重新读取,除非已有更新的写入取代了它,而过期的读取绝不会覆盖发布更新的结果。若 spec 未提供 `decode`,则分区不是普通对象、未通过其重建后的 schema 校验、或携带本客户端无法重建的 schema 信封时,一律不发布任何值,于是行渲染自己的缺失状态,而不是一份半解码的值
## 模型体验
无。设置外壳为浏览器 UI 提供组合能力;这里没有任何内容进入模型请求。
无。设置领域底座为浏览器提供偏好设置存储与 slot 声明;这里没有任何内容进入模型请求。
#### KV Cache 影响
@@ -16,4 +16,5 @@
## 已知限制与暂缓事项
- **面板仅涵盖浏览器偏好设置**:宿主侧设置表层(权限模式、工具调用模式)尚无 RPC 支撑;其骨架位于 ui-settings-general。
- **远程浏览器没有持久化设置**:设置 RPC 仅限 loopback因此在非 loopback 浏览器中绑定的 scope 以 `unavailable` 起步且从不跨线路,它支撑的每一行在那里都是无效的。
- **每次写入仅一个字段**`set` 只发送单个 `set` op因此需要同时改动两个字段的行没有事务可用会发布两个 revision。

View File

@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-client-ui-settings",
"description": "Settings shell plugin: sidebar trigger, modal panel, feature sections, and an ordered full-page onboarding stage",
"description": "Settings domain base plugin: the settings-namespace scope service and the canonical settings slot-type contract",
"version": "0.0.1-rc.1",
"publishConfig": {
"access": "restricted"
@@ -32,8 +32,9 @@
"dsh": {
"client": {
"inject": [
"@deepseek-ai/dsh-client-connection",
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-sidebar"
"@deepseek-ai/dsh-api-remotes"
],
"platform": "web"
}
@@ -43,30 +44,31 @@
"watch": "tsdown --watch"
},
"license": "BSD-3-Clause",
"dependencies": {
"clsx": "^2.0.0"
},
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-api-gateway": "workspace:^",
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-schema-form": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/cordis": "workspace:^",
"react": "^18.2.0",
"react-dom": "^18.2.0"
"@deepseek-ai/dsh-settings": "workspace:^",
"react": "^18.2.0"
},
"devDependencies": {
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-api-gateway": "workspace:^",
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-sidebar": "workspace:^",
"@deepseek-ai/dsh-client-schema-form": "workspace:^",
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@types/react-dom": "~18.3.0",
"@deepseek-ai/dsh-settings": "workspace:^",
"@types/react": "~18.3.1",
"@deepseek-ai/cordis": "workspace:^",
"react": "^18.2.0",
"react-dom": "^18.2.0"
"react": "^18.2.0"
},
"files": [
"lib/index.js",

View File

@@ -1,16 +1,14 @@
/**
* Settings shell slot contract — the canonical home of every settings slot
* type. The shell is a pure composition face with zero copy of its own: it
* occupies the sidebar-owned `sidebar.settings` hole and declares the slots
* below; ALL text (trigger label, panel title, header actions, close aria,
* section content) arrives from registrants. A feature owns its settings surface — adding a
* setting never means editing the shell; copy that belongs to no single
* feature (chrome, the General section) is owned by ui-settings-general.
* Settings slot contract — the canonical home of every settings slot type,
* owned by the settings domain base rather than by the shell that renders
* them (ui-settings-general, which occupies `sidebar.settings`). The shell has
* zero copy of its own: ALL text (trigger label, panel title, header actions,
* close aria, section content) arrives from registrants. A feature owns its
* own settings pages — adding a setting never means editing the shell; copy
* that belongs to no single feature (chrome, the General section) is owned by
* ui-settings-general too.
*/
import type { HostObservable, InjectFace, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
// Type-only: pulls ui-sidebar's SlotMap merge (the 'sidebar.settings' entry)
// into every program that sees this contract.
import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client'
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface SlotMap {
@@ -66,8 +64,24 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
* would render without mask or stage).
*/
'settings.onboarding': { kind: 'list'; scope: 'root'; owner: SettingsOnboardingOwnerProps }
/**
* One preference row inside the General section, contributed by the
* feature plugin that owns the preference (locale → Language, ui-theme →
* Appearance, ui-conversation → Composer Enter). Options: `id` (row key),
* `order` (row position). Rows draw their own internals; the section
* column only stacks them. Declared at runtime by ui-settings-general's
* General entry — the type lives here with every other settings slot type,
* because this package is the settings domain's base layer and every
* registrant already depends on it for `ctx.settingsScope`.
*/
'settings.general.item': { kind: 'list'; scope: 'root'; owner: SettingsGeneralItemOwnerProps }
}
}
/** Owner share of a General preference row (the section supplies nothing). */
export interface SettingsGeneralItemOwnerProps {
/** Marker field: item owner props are intentionally empty. */
children?: never
}
/** Owner share of the trigger content seat: the sidebar column state. */
export interface SettingsTriggerOwnerProps {
@@ -102,48 +116,3 @@ export interface SettingsOnboardingOwnerProps {
/** Open the settings panel directly on one registered section. */
openSection: (id: string) => void
}
/** One nav row projected from a settings.section registration's options. */
export interface SettingsSectionRow {
id: string
order: number
label: string
}
/** One ordered onboarding step projected from a slot registration. */
export interface SettingsOnboardingStep {
id: string
order: number
}
/**
* Registrant-private injected share of the settings shell (assembled in
* apply): the ledger's nav-row projection as a hooks-compartment source —
* the shell reads no locale state and subscribes through the bound hook.
*/
export type SettingsRootInjected = {
hooks: {
/** settings.section ledger projected into ordered nav rows. */
sections: HostObservable<readonly SettingsSectionRow[]>
/** settings.onboarding ledger projected into coordinator order. */
onboardingSteps: HostObservable<readonly SettingsOnboardingStep[]>
}
}
/**
* Full component props of the settings shell root: the sidebar owner share
* (wide/rail state) plus the declared render shares and the injected face
* (hooks compartment bound to useSections). No store is registered — modal
* open state and active section id are component-local viewing state.
*/
export type SettingsRootComponentProps =
PropsRuntime<'sidebar.settings'>
& PropsRenderSlots<
| 'settings.trigger'
| 'settings.header'
| 'settings.action'
| 'settings.close'
| 'settings.section'
| 'settings.onboarding'
>
& InjectFace<SettingsRootInjected>

View File

@@ -1,111 +1,35 @@
/**
* Settings shell plugin, browser half. A pure composition face: occupies the
* sidebar-owned `sidebar.settings` hole with the trigger chrome + modal
* panel, declares its chrome, section, and onboarding slots, and projects the
* section ledger into panel navigation. The shell ships no copy; it reads the
* optional locale revision only to resolve registrant-owned nav-label thunks.
* ui-settings-general owns the chrome and General content; features own their
* rows, sections, and onboarding pages. Export discipline: packages/client/AGENTS.md.
* Settings domain base plugin, browser half. Provides `ctx.settingsScope`, the
* settings-namespace Host transport every preference row binds its durable
* section through, and owns the canonical slot-type contract for the settings
* surface. It depends on no `ui-*` presentation package, so any feature that
* owns a preference can reach it: the settings SHELL — the `sidebar.settings`
* occupant, its navigation, and the chrome — lives in ui-settings-general,
* because a shell dependency on ui-sidebar would close a reference cycle
* through ui-layout and ui-theme. Export discipline: packages/client/AGENTS.md.
*/
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
// Type-only: the ctx.locale Context merge for the optional ctx.get('locale')
// read (nav labels may be locale-following thunks; the shell still ships no
// copy of its own and takes no hard locale dependency).
import type {} from '@deepseek-ai/dsh-client-locale/client'
import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
import type {
SettingsOnboardingStep, SettingsRootInjected, SettingsSectionRow,
} from './contract/slots.ts'
import { SettingsRoot } from './SettingsRoot.tsx'
import { SettingsScopeService } from './settings-scope.ts'
export type {
SettingsHeaderOwnerProps, SettingsRootComponentProps, SettingsRootInjected,
SettingsOnboardingOwnerProps, SettingsOnboardingStep, SettingsSectionOwnerProps,
SettingsSectionRow, SettingsTriggerOwnerProps,
SettingsGeneralItemOwnerProps, SettingsHeaderOwnerProps, SettingsOnboardingOwnerProps,
SettingsSectionOwnerProps, SettingsTriggerOwnerProps,
} from './contract/slots.ts'
export { SettingsScopeController, SettingsScopeService } from './settings-scope.ts'
/**
* Required services (cordis fiber inject). The target slot is declared by
* ui-sidebar's apply, whose activation order relative to this one is NOT
* constrained (dsh.client.inject edges are informational); registration
* depends on the slot through `slots.inject()`.
* Required services: none. The transport is resolved per caller through
* `this.ctx` at `bind` time, so this plugin waits for nothing.
*/
export const inject = ['slots']
export const inject = []
/**
* Register the settings shell into `sidebar.settings` once the declaration is
* on the ledger.
* Provide the settings-namespace scope service.
*
* Constructing the service in this plugin's fiber keeps its traced methods
* bound to each consuming plugin's context.
* @param ctx - client root context.
*/
export function apply(ctx: ClientContext): void {
// Ledger → nav-row projection as an observable source (uSES contract:
// getSnapshot returns the cached rows until the ledger version moves).
// Labels may be locale-following thunks, so the cache key includes the
// locale revision and subscribers ride both sources.
let rowsVersion = -1
let rowsRevision = -1
let rows: readonly SettingsSectionRow[] = []
let onboardingVersion = -1
let onboardingSteps: readonly SettingsOnboardingStep[] = []
const localeRevision = (): number => ctx.get('locale')?.getSnapshot().revision ?? 0
const injected = (): SettingsRootInjected => ({
hooks: {
sections: {
getSnapshot: () => {
const version = ctx.slots.getVersion('settings.section')
const revision = localeRevision()
if (version !== rowsVersion || revision !== rowsRevision) {
rowsVersion = version
rowsRevision = revision
rows = ctx.slots.entries('settings.section')
.map(e => ({
/* v8 ignore next -- list-slot registration requires id (SlotCore rejects an entry without one) */
id: e.options.id ?? '',
order: e.options.order ?? 0,
label: resolveSlotLabel(e.options.label) ?? '',
}))
.sort((a, b) => a.order - b.order)
}
return rows
},
subscribe: (listener) => {
const offLedger = ctx.slots.subscribe('settings.section', listener)
const offLocale = ctx.get('locale')?.subscribe(listener)
return () => {
offLedger()
offLocale?.()
}
},
},
onboardingSteps: {
getSnapshot: () => {
const version = ctx.slots.getVersion('settings.onboarding')
if (version !== onboardingVersion) {
onboardingVersion = version
onboardingSteps = ctx.slots.entries('settings.onboarding')
.map(e => ({
/* v8 ignore next -- list-slot registration requires id */
id: e.options.id ?? '',
order: e.options.order ?? 0,
}))
.sort((a, b) => a.order - b.order)
}
return onboardingSteps
},
subscribe: listener => ctx.slots.subscribe('settings.onboarding', listener),
},
},
})
ctx.slots.inject('sidebar.settings', () => ctx.slots.register({
name: 'sidebar.settings',
children: {
'settings.trigger': { kind: 'single', scope: 'root' },
'settings.header': { kind: 'single', scope: 'root' },
'settings.action': { kind: 'list', scope: 'root' },
'settings.close': { kind: 'single', scope: 'root' },
'settings.section': { kind: 'list', scope: 'root' },
'settings.onboarding': { kind: 'list', scope: 'root' },
},
inject: injected,
}, SettingsRoot))
new SettingsScopeService(ctx)
}

View File

@@ -1,67 +1,36 @@
/** Host-backed settings-namespace synchronization for browser plugins. */
/**
* Host transport for the settings-namespace scope contract. The contract types
* live in `dsh-client-runtime` (the common dependency of every feature that
* owns a preference); this file owns the wire behavior and the invalidation
* subscription, both of which are Settings-surface concerns.
*/
import { Service } from '@deepseek-ai/cordis'
import type { Context } from '@deepseek-ai/cordis'
import type {
ConnectionHandle, IApiClient, SettingsNamespaceView,
} from '@deepseek-ai/dsh-client-connection/client'
import { rehydrateSchema, validateDraft } from '@deepseek-ai/dsh-client-schema-form'
import { createSnapshotStore, type SnapshotStore } from './contract/store.ts'
/** Client-side sync state of one settings namespace. */
export interface SettingsScopeSnapshot<T> {
/**
* `loading` until the first accepted section, `ready` while one stands, and
* `unavailable` when the namespace is not exposed to this client or the
* connection keeps preferences process-local (memory mode).
*/
status: 'loading' | 'ready' | 'unavailable'
/** Last accepted schema-resolved section; undefined before the first acceptance. */
value: T | undefined
/** Namespace revision fencing the next write; undefined before the first Host view. */
revision: number | undefined
/** Whether the Host document accepts writes; memory mode never does. */
writable: boolean
/** `host` syncs with the Host document; `memory` keeps a remote browser process-local. */
mode: 'host' | 'memory'
}
/** Domain-owned description of one settings namespace consumed by a browser plugin. */
export interface SettingsScopeSpec<T> {
/** Settings namespace registered by the owning Host plugin. */
namespace: string
/**
* Narrow one wire section; undefined keeps the last accepted value. The
* default validates the section against the namespace's own serialized wire
* schema, so domains add a decoder only to narrow beyond that schema.
*/
decode?: (section: unknown) => T | undefined
}
/**
* Reactive owner handle over one namespace's durable section the browser
* mirror of the Host-side `SettingsScope` owner seam. Domain services read
* and observe the snapshot and route explicit user choices through `set`.
*/
export interface SettingsScope<T> {
/** @returns the current sync snapshot (stable reference until the next change). */
getSnapshot(): SettingsScopeSnapshot<T>
/**
* Observe snapshot replacements.
* @param listener - invoked after each snapshot change.
* @returns the disposer removing this listener.
*/
subscribe(listener: () => void): () => void
/**
* Queue one field write. Rapid writes preserve mutation order, each carries
* the latest known namespace revision, and only the latest settlement may
* publish; a rejected or failed latest write reloads Host state instead.
* @param field - scalar field inside the namespace section.
* @param value - JSON-shaped value selected by the user.
* @returns settlement after the write and any latest-write recovery read.
*/
set(field: string, value: unknown): Promise<void>
}
import {
createSnapshotStore, type SettingsScope, type SettingsScopeSnapshot,
type SettingsScopeSpec, type SnapshotStore,
} from '@deepseek-ai/dsh-client-runtime/client'
// Type-only, and deliberately NOT `@deepseek-ai/dsh-api-remotes/client`: this
// package is reachable from the Host build graph through its feature-package
// callers, and api-remotes' Client face imports a Host-tsdown-generated
// `/remote` artifact, which would deadlock the Host tsc phase. The gateway's
// Client half declares `ctx.remote` with no generated import, and the
// allowlist's `types` subpath is a pure-type source file, so the pair supplies
// `$on` and its key face without dragging a build artifact in. The runtime
// `remote` injection belongs to whoever calls bindSettingsScope: the
// subscription is registered on the caller's own context.
import type {} from '@deepseek-ai/dsh-api-gateway/client'
import type {} from '@deepseek-ai/dsh-api-remotes/types'
// The forwarded event's own declaration: `$on`'s key face is
// `Extract<keyof Events, keyof Selection>`, so the allowlist alone resolves to
// never — the owning package's client-safe, type-only subpath supplies the
// cordis `Events` entry (and with it the branded `SettingsNamespace`).
import type {} from '@deepseek-ai/dsh-settings/types'
type SettingsFace = Pick<IApiClient, 'settings'>
/**
@@ -224,38 +193,60 @@ export class SettingsScopeController<T> implements SettingsScope<T> {
}
}
/**
* Bind one namespace scope to settings and connection invalidations on the
* caller's plugin lifecycle. Listeners exist before the initial background
* read starts, so activation never blocks on the settings transport.
* @param ctx - owning browser plugin context.
* @param spec - domain-owned namespace contract.
* @returns the bound scope consumed by the domain's services and rows.
*/
export function bindSettingsScope<T>(
ctx: Context,
spec: SettingsScopeSpec<T>,
): SettingsScope<T> {
const connection = ctx.get('connection') as ConnectionHandle
const controller = new SettingsScopeController<T>(
connection.api,
spec,
connection.isLoopback ? 'host' : 'memory',
)
ctx.effect(() => {
const refresh = (namespace?: string): void => {
if (namespace !== undefined && namespace !== spec.namespace) return
void controller.load()
}
const disposers = [
ctx.on('settings/changed', refresh),
ctx.on('connection/reset', () => { refresh() }),
]
void controller.load()
return async () => {
for (const dispose of disposers) dispose()
await controller.dispose()
}
}, `runtime: ${spec.namespace} settings scope`)
return controller
declare module '@deepseek-ai/cordis' {
interface Context {
settingsScope: SettingsScopeService
}
}
/**
* The settings domain's base service. Features that own a preference reach the
* settings transport through this service rather than a shared function: the
* client bundle purity gate forbids cross-plugin value imports and directs
* cross-plugin collaboration through cordis services
* (`packages/client/tsdown.client.ts`).
*/
export class SettingsScopeService extends Service {
/**
* @param ctx - the providing plugin's context.
*/
constructor(ctx: Context) {
super(ctx, 'settingsScope')
}
/**
* Bind one namespace scope to settings and connection invalidations on the
* CALLER's plugin lifecycle the service proxy binds `this.ctx` to the
* caller at call time, so the scope's disposer belongs to the calling fiber.
* Listeners exist before the initial background read starts, so activation
* never blocks on the settings transport. The caller injects `connection`
* for the transport and `remote` for the forwarded settings invalidation.
* @param spec - domain-owned namespace contract.
* @returns the bound scope consumed by the domain's services and rows.
*/
bind<T>(spec: SettingsScopeSpec<T>): SettingsScope<T> {
const ctx = this.ctx
const connection = ctx.get('connection') as ConnectionHandle
const controller = new SettingsScopeController<T>(
connection.api,
spec,
connection.isLoopback ? 'host' : 'memory',
)
ctx.effect(() => {
const refresh = (namespace?: string): void => {
if (namespace !== undefined && namespace !== spec.namespace) return
void controller.load()
}
const disposers = [
(ctx.get('remote') as Context['remote']).$on('settings/document-updated', refresh),
ctx.on('connection/reset', () => { refresh() }),
]
void controller.load()
return async () => {
for (const dispose of disposers) dispose()
await controller.dispose()
}
}, `ui-settings: ${spec.namespace} settings scope`)
return controller
}
}

View File

@@ -1,4 +1,4 @@
/** Host loader entry for the browser implementation exported from `./client`. */
/** Host plugin body — no host-side behavior for the settings shell plugin. */
/** Host plugin body — no host-side behavior for the settings domain base plugin. */
export function apply(): void {}

View File

@@ -0,0 +1,29 @@
/**
* The settings domain base plugin's own mounting behavior: it stands up
* `ctx.settingsScope` for every feature that owns a preference row, and the
* service retires with its fiber.
*/
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it } from 'vitest'
import { apply, inject, SettingsScopeService } from '../src/client/index.ts'
/** Boot the browser half over a bare root context; it injects nothing. */
function bench() {
const ctx = new Context()
return { ctx, fiber: ctx.plugin({ inject: [...inject], apply }) }
}
describe('settings domain base plugin', () => {
it('mounts the scope service under settingsScope', async () => {
const { ctx, fiber } = bench()
await fiber.await()
expect(ctx.get('settingsScope')).toBeInstanceOf(SettingsScopeService)
})
it('fiber disposal retires the service', async () => {
const { ctx, fiber } = bench()
await fiber.await()
await fiber.dispose()
expect(ctx.get('settingsScope')).toBeUndefined()
})
})

View File

@@ -2,9 +2,9 @@ import { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import { describe, expect, it, vi } from 'vitest'
import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client'
import {
bindSettingsScope, SettingsScopeController, type SettingsScope,
} from '../src/client/settings-scope.ts'
import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
import type { SettingsScope } from '@deepseek-ai/dsh-client-runtime/client'
import { SettingsScopeController, SettingsScopeService } from '../src/client/settings-scope.ts'
interface UiTestSettings {
preference: 'light' | 'dark' | 'system'
@@ -294,8 +294,7 @@ describe('SettingsScopeController', () => {
expect(mutate).not.toHaveBeenCalled()
})
})
describe('bindSettingsScope', () => {
describe('SettingsScopeService.bind', () => {
it('subscribes before the initial read and converges to the latest queued invalidation', async () => {
const initial = deferred<ReturnType<typeof described>>()
const describeCall = vi.fn()
@@ -308,16 +307,18 @@ describe('bindSettingsScope', () => {
isLoopback: true,
} as never)
let scope!: SettingsScope<UiTestSettings>
new TestRemote(ctx)
await ctx.plugin(SettingsScopeService).await()
const fiber = ctx.plugin({
inject: ['connection'],
inject: ['connection', 'remote', 'settingsScope'],
apply: (plugin: Context) => {
scope = bindSettingsScope<UiTestSettings>(plugin, { namespace: 'ui-test' })
scope = plugin.settingsScope.bind<UiTestSettings>({ namespace: 'ui-test' })
},
})
await fiber.await()
await vi.waitFor(() => { expect(describeCall).toHaveBeenCalledOnce() })
ctx.emit('settings/changed', 'unrelated')
ctx.emit('settings/changed', 'ui-test')
ctx.remote.$dispatch('settings/document-updated', ['unrelated', 0])
ctx.remote.$dispatch('settings/document-updated', ['ui-test', 0])
ctx.emit('connection/reset')
initial.resolve(described({ preference: 'dark' }, 1))
await vi.waitFor(() => { expect(describeCall).toHaveBeenCalledTimes(3) })
@@ -325,7 +326,7 @@ describe('bindSettingsScope', () => {
expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'system' }, revision: 3 })
})
await fiber.dispose()
ctx.emit('settings/changed', 'ui-test')
ctx.remote.$dispatch('settings/document-updated', ['ui-test', 0])
await Promise.resolve()
expect(describeCall).toHaveBeenCalledTimes(3)
})
@@ -338,10 +339,12 @@ describe('bindSettingsScope', () => {
isLoopback: false,
} as never)
let scope!: SettingsScope<UiTestSettings>
new TestRemote(ctx)
await ctx.plugin(SettingsScopeService).await()
const fiber = ctx.plugin({
inject: ['connection'],
inject: ['connection', 'remote', 'settingsScope'],
apply: (plugin: Context) => {
scope = bindSettingsScope<UiTestSettings>(plugin, { namespace: 'ui-test' })
scope = plugin.settingsScope.bind<UiTestSettings>({ namespace: 'ui-test' })
},
})
await fiber.await()

View File

@@ -11,20 +11,23 @@
{
"path": "../../../vendor/cordis"
},
{
"path": "../locale"
},
{
"path": "../ui-slots"
},
{
"path": "../ui-primitives"
},
{
"path": "../runtime"
},
{
"path": "../ui-sidebar"
"path": "../connection"
},
{
"path": "../schema-form"
},
{
"path": "../../api/gateway"
},
{
"path": "../../settings/settings"
},
{
"path": "../../support/invariants"

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-skill/README.md
README.md: 0456db4de9453e5060e39b5f061422486e44dfc9
README.zh.md: 336f43117e7bc4de41a31e636ee0966e5d1a2cd6
README.md: f6818ae9f44a7d0a484302fe8e294ee2159890ec
README.zh.md: 4b9d646fe39887e9d655b28c61c11876b55d1111

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Skill invocation source, browser half: registers the `/`-trigger `skill` source into `ctx.slash`. Ordinary-session candidates come from the `skill.list` RPC addressed by the per-call `ClientSessionContext` projection's `{sessionId}`, with the host resolving `cwd` from the session header. The host serves every user-invocable skill; a `modelInvocable: false` entry (a `disable-model-invocation` skill, whose only entry point is this path) wears the user-only marker as a description prefix in the active language. Catalog-addressed continuable children resolve no skill candidates locally because the existing skill RPC requires an attached session; viewing their persisted history must not activate them. Catalogs cache per ordinary session with a single-flight fetch; the scope-birth `warm` hook prewarms the session's entry, `session/preset-changed` drops that one session's entry (the catalog belongs to the preset, and a blank session may switch after the warm), and `connection/reset` clears everything. Results filter by `startsWith(query)`.
Skill invocation source, browser half: registers the `/`-trigger `skill` source into `ctx.slash`. Ordinary-session candidates come from the `skill.list` RPC addressed by the per-call `ClientSessionContext` projection's `{sessionId}`, with the host resolving `cwd` from the session header. The host serves every user-invocable skill; a `modelInvocable: false` entry (a `disable-model-invocation` skill, whose only entry point is this path) wears the user-only marker as a description prefix in the active language. Catalog-addressed continuable children resolve no skill candidates locally because the existing skill RPC requires an attached session; viewing their persisted history must not activate them. Catalogs cache per ordinary session with a single-flight fetch; the scope-birth `warm` hook prewarms the session's entry, the forwarded `agent-preset/selected` owner event drops that one session's entry (the catalog belongs to the preset, and a blank session may switch after the warm), and `connection/reset` clears everything. Results filter by `startsWith(query)`.
A pick lands the literal `/name ` text and the prompt ships the same literal ([slash-pipeline Agent Note](../../../.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md)) — this source implements no adjudication hooks and no reference codec. Determinism lives host-side: the pre-step gesture boundary (`dsh-tool-skill`) recognizes whitespace-bounded `/name` tokens naming user-invocable skills anywhere in a user message and injects the rendered `<skill_content>` for every entry point, so a menu pick, a hand-typed token, and a TUI/ACP prompt all load the skill the same way. A name shared with a host command still resolves to the command: adjudication claims the line client-side before it ever becomes a prompt — deliberate precedence, matching peer products. The list RPC rides the plugin's root-context connection captured at registration — the source never reads services off a per-call argument; draft chip visuals derive from the `lexicon` scan.

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
skill技能调用 source 的浏览器端:把 `/` 触发的 `skill` source 注册进 `ctx.slash`。普通会话的候选来自 `skill.list` RPC以每次调用的 `ClientSessionContext` 投影中的 `{sessionId}` 寻址host 从会话 header 解析 `cwd`。宿主提供每一个用户可调用的 skill`modelInvocable: false` 的条目(即 `disable-model-invocation` skill此路径是其唯一入口会以当前语言把仅限用户标记作为描述前缀带上。由目录寻址的可继续 subagent 在客户端解析为没有 skill 候选,因为现有 skill RPC 要求会话已挂载;查看其持久化历史不得激活它。目录按普通会话缓存,拉取走 single-flightscope 创建时的 `warm` 钩子预热该会话的缓存项,`session/preset-changed` 丢弃该会话这一项(目录属于 preset而空会话可能在预热之后才切换`connection/reset` 清空全部缓存。结果按 `startsWith(query)` 过滤。
skill技能调用 source 的浏览器端:把 `/` 触发的 `skill` source 注册进 `ctx.slash`。普通会话的候选来自 `skill.list` RPC以每次调用的 `ClientSessionContext` 投影中的 `{sessionId}` 寻址host 从会话 header 解析 `cwd`。宿主提供每一个用户可调用的 skill`modelInvocable: false` 的条目(即 `disable-model-invocation` skill此路径是其唯一入口会以当前语言把仅限用户标记作为描述前缀带上。由目录寻址的可继续 subagent 在客户端解析为没有 skill 候选,因为现有 skill RPC 要求会话已挂载;查看其持久化历史不得激活它。目录按普通会话缓存,拉取走 single-flightscope 创建时的 `warm` 钩子预热该会话的缓存项,转发的 owner 事件 `agent-preset/selected` 丢弃该会话这一项(目录属于 preset而空会话可能在预热之后才切换`connection/reset` 清空全部缓存。结果按 `startsWith(query)` 过滤。
pick 会落下字面文本 `/name `,提示词发出的就是同一段字面文本([slash 流水线 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md))——本 source 不实现任何裁决钩子,也没有引用 codec。确定性在宿主侧pre-step 手势边界(`dsh-tool-skill`)识别用户消息中任意位置、以空白为界、指名用户可调用 skill 的 `/name` token并为每个入口注入渲染后的 `<skill_content>`,因此菜单 pick、手动键入的 token 与 TUI/ACPAgent Client Protocol提示词都以同一种方式加载 skill。与宿主命令同名的名称仍解析为命令裁决在客户端把该行认领走它根本不会成为提示词——这是有意的优先级与同行产品一致。列表 RPC 使用插件注册时捕获的根上下文连接——source 绝不从每次调用的参数上读取服务;草稿 chip 视觉由 `lexicon` 扫描派生。

View File

@@ -35,7 +35,8 @@
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-ui-tool",
"@deepseek-ai/dsh-client-ui-slash"
"@deepseek-ai/dsh-client-ui-slash",
"@deepseek-ai/dsh-api-remotes"
],
"platform": "web"
}
@@ -46,6 +47,7 @@
},
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
@@ -58,6 +60,7 @@
"react": "^18.2.0"
},
"devDependencies": {
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",

View File

@@ -30,6 +30,8 @@
* accent row derived only from each logged call/result slice.
*/
import type { ConnectionHandle, SessionId, SkillEntry } from '@deepseek-ai/dsh-client-connection/client'
// Type-only: pulls the forwarded Host-event face and ctx.remote merge.
import type {} from '@deepseek-ai/dsh-api-remotes/client'
import type { ClientContext, ISessions } from '@deepseek-ai/dsh-client-runtime/client'
import type { SlashServiceContract, SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client'
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
@@ -53,7 +55,7 @@ interface CatalogFetch {
}
/** Required services: reference source faces plus the tool-row and locale registries. */
export const inject = ['slash', 'connection', 'sessions', 'slots', 'locale']
export const inject = ['slash', 'connection', 'sessions', 'slots', 'locale', 'remote']
/**
* Client plugin body: register the '/' source, dictionaries, and keyed tool row.
@@ -178,7 +180,7 @@ export function apply(ctx: ClientContext): void {
const slash = ctx.get('slash') as SlashServiceContract
// A preset decides which skill providers an agent reads, so a switched
// session's cached catalog belongs to the composition it no longer runs.
ctx.on('session/preset-changed', invalidate)
ctx.remote.$on('agent-preset/selected', invalidate)
ctx.on('connection/reset', clearAll)
ctx.effect(() => {
const unregister = slash.registerSource(source)

View File

@@ -18,6 +18,7 @@ import { describe, expect, it, vi } from 'vitest'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import { SlashService } from '@deepseek-ai/dsh-client-ui-slash/client'
import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
import type { ClientSessionContext, SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client'
import { apply, inject } from '../src/client/index.ts'
import { SkillRow as SkillToolRow } from '../src/client/SkillRow.tsx'
@@ -73,6 +74,7 @@ async function bench(list: ListFn, addressed?: SessionId, invoke?: InvokeFn) {
? { parentSessionId: sid('parent'), childSessionId: id, mode: 'continuable' as const }
: undefined,
})
new TestRemote(ctx)
providePresentation(ctx)
await ctx.plugin({ inject: [...inject], apply }).await()
return { ctx, source: captured! }
@@ -105,7 +107,7 @@ const req = (query: string, signal?: AbortSignal) =>
describe('apply', () => {
it('declares the services it binds', () => {
expect(inject).toEqual(['slash', 'connection', 'sessions', 'slots', 'locale'])
expect(inject).toEqual(['slash', 'connection', 'sessions', 'slots', 'locale', 'remote'])
})
it('registers the dedicated skill row and its locale dictionaries', async () => {
@@ -113,6 +115,7 @@ describe('apply', () => {
ctx.provide('slash', { registerSource: () => () => {} })
ctx.provide('connection', { api: { skills: { list: listOk(CATALOG) } } })
ctx.provide('sessions', { subagentAddress: () => undefined })
new TestRemote(ctx)
const presentation = providePresentation(ctx)
await ctx.plugin({ inject: [...inject], apply }).await()
const entry = presentation.slots.entries('tool.call.toolview')[0]
@@ -145,6 +148,7 @@ describe('apply', () => {
ctx.provide('sessions', {})
await ctx.plugin(SlashService).await()
ctx.provide('connection', { api: { skills: { list: listOk(CATALOG) } } })
new TestRemote(ctx)
const presentation = providePresentation(ctx)
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
@@ -263,7 +267,7 @@ describe('catalog cache', () => {
expect(payloads).toHaveLength(2)
})
it('session/preset-changed clears only the recomposed session', async () => {
it('agent-preset/selected clears only the recomposed session', async () => {
const { list, payloads } = countingList()
const { ctx, source } = await bench(list)
await source.candidates(proj('s1'), req(''))
@@ -271,7 +275,7 @@ describe('catalog cache', () => {
expect(payloads).toHaveLength(2)
// The catalog a preset supplies is the preset's; the other session's
// composition did not change, so its cached catalog still holds.
ctx.emit('session/preset-changed', sid('s1'), 'minimal')
ctx.remote.$dispatch('agent-preset/selected', [sid('s1'), 'minimal'])
await source.candidates(proj('s1'), req(''))
await source.candidates(proj('s2'), req(''))
expect(payloads).toHaveLength(3)

View File

@@ -34,6 +34,9 @@
},
{
"path": "../../support/invariants"
},
{
"path": "../../api/remotes/tsconfig.client.json"
}
]
}

View File

@@ -12,6 +12,7 @@
* source's own contract.
*/
import { Context } from '@deepseek-ai/cordis'
import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
import { describe, expect, it } from 'vitest'
import {
SlotsService, type ConversationSnapshot, type SessionId, type SessionListState,
@@ -87,6 +88,9 @@ async function fullBench(sessions: SessionSummary[]) {
ctx.provide('slash', { registerSource: (src: SlashSource) => { captured = src; return () => {} } })
ctx.provide('sessions', face)
ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
// ui-theme's Appearance row binds a durable scope through these two.
ctx.provide('remote', { $on: () => () => {} } as never)
ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
await provideSlotFaces(ctx)
await ctx.plugin({ inject: localeInject, apply: applyLocale }).await()
await ctx.plugin({ inject: [...inject], apply }).await()
@@ -123,6 +127,9 @@ describe('apply', () => {
await ctx.plugin(SlashService).await()
ctx.provide('sessions', sessionsWith(FAMILY))
ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
// ui-theme's Appearance row binds a durable scope through these two.
ctx.provide('remote', { $on: () => () => {} } as never)
ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
await provideSlotFaces(ctx)
await ctx.plugin({ inject: localeInject, apply: applyLocale }).await()
const fiber = ctx.plugin({ inject: [...inject], apply })

View File

@@ -8,6 +8,7 @@ import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it } from 'vitest'
import InvariantService from '@deepseek-ai/dsh-invariants'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
import { apply as applyLocale, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client'
import { apply, inject } from '../src/client/index.ts'
import { apply as applyNode } from '../src/index.ts'
@@ -32,8 +33,11 @@ async function bench(): Promise<{ ctx: Context; fiber: ReturnType<Context['plugi
},
} as never, () => null)
ctx.provide('sessions', {})
// The locale plugin binds a settings scope, which reads the connection handle.
// The locale plugin binds a settings scope, which reads the connection handle
// and the forwarded-event port.
ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
ctx.provide('remote', { $on: () => () => {} } as never)
ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
await ctx.plugin({ inject: localeInject, apply: applyLocale }).await()
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()

View File

@@ -35,7 +35,9 @@
"inject": [
"@deepseek-ai/dsh-client-connection",
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-locale"
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-ui-settings",
"@deepseek-ai/dsh-api-remotes"
],
"platform": "web",
"immediately": true
@@ -43,26 +45,30 @@
},
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-host-webserver": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/cordis": "workspace:^",
"react": "^18.2.0"
},
"devDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-host-webserver": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@types/react": "~18.3.1",
"@deepseek-ai/cordis": "workspace:^",
"react": "^18.2.0"
},
"files": [

View File

@@ -12,7 +12,7 @@ import {
import type { PropsLocale, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
import type { ThemePreference } from '../theme-settings.ts'
import type { ThemeKey } from './locales.ts'
import type {} from './settings-contract.ts'
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
import type { createAppearanceRowStore } from './settings-store.ts'
import css from './AppearanceRow.module.css'

Some files were not shown because too many files have changed in this diff Show More