fix(client): tighten UI service boundaries

This commit is contained in:
imccyu committed 2026-08-18 01:34:24 +08:00
1 parent 85fa5b6943
commit c065ba34d1
20 files changed
+83 -45

No files matched your search

+2 -2
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: 4e9ab5a054c410eb295b27d94ce12297443fee40
README.zh.md: b9a266afd7d4341583896618e527171a7ae32d6e
README.md: cad8a08f015a7115ebff76cfce4d3ab5eb7a175e
README.zh.md: 262ab1eeb8ce2d09e801e2af790046d8cdc11e55
+1 -1
View File
@@ -6,7 +6,7 @@ Client cordis boot and React-free object services: SlotRegistry wraps SlotCore a
For each prompt that can reach a local root or continuable child Agent, the runtime samples the browser's current `Intl.DateTimeFormat().resolvedOptions().timeZone` and attaches it to that one Session or subagent prompt RPC. It is neither cached nor included in Session creation or fork state, so travel and concurrent tabs keep message-local provenance. A browser that cannot provide a non-empty zone fails the prompt locally instead of silently substituting deployment state.
`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, the composition `base` and raw `user` layers, revision, writability, host/memory mode), serializes `set` and `unset` 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. A field is overridden when it is PRESENT in `user` — an override equal to the composition default is still an override, which comparing values could not see — and `unset` is how a form clears one back to `base`. Domain packages own the namespace schema, default, and live service rather than putting product policy in runtime.
Settings owners share the React-free `SettingsScopeSpec`, `SettingsScope`, and snapshot types defined here. ui-settings owns `ctx.settingsScope.bind(spec)`, its Host transport, schema validation, and lifecycle; see [its package contract](../ui-settings/README.md).
## Slot declaration injection
+1 -1
View File
@@ -6,7 +6,7 @@
对于每条可到达本地根 Agent 或可继续子 Agent 的提示词,运行时都会采样浏览器当前的 `Intl.DateTimeFormat().resolvedOptions().timeZone`,并只把该值附加到这一次 Session 或 subagent 提示词 RPC。该值既不缓存,也不包含在 Session 创建或 fork 状态中,因此旅行与并发标签页都能保留消息本地的来源信息。浏览器若无法提供非空时区,会在本地拒绝该提示词,而不会悄然使用部署状态代替。
`bindSettingsScope` 面向单个由领域持有的 namespace,是 Host 侧 settings owner seam 的浏览器镜像。它在开始非阻塞初始读取前建立订阅,发布 uSES 快照(状态、分节值、组装 `base` 层与原始 `user` 层、revision、可写性、host/内存模式),使用已知最新 namespace revision 串行执行 `set` 与 `unset` 写入,抑制陈旧发布,并在最新写入被拒时从 Host 状态恢复;插件释放时,它会达到完全停稳。默认解码器会对照该 namespace 自身的序列化 wire schema(经 dsh-client-schema-form 还原)校验每个分节,因此领域只有在需要比该 schema 进一步收窄时才添加解码器。回环页面使用 Host settings API,远程页面则停留在内存模式。字段是否被覆盖,取决于它是否**出现**在 `user` 中——与组装默认值相同的覆盖仍然是覆盖,比较值是看不出来的——而 `unset` 就是表单把某个字段清回 `base` 的方式。namespace schema、默认值与实时服务归领域包所有,而非把产品政策放入运行时。
设置所有者共用本包定义的不依赖 React 的 `SettingsScopeSpec`、`SettingsScope` 与快照类型。ui-settings 拥有 `ctx.settingsScope.bind(spec)`、对应的 Host 传输、schema 校验与生命周期;详见[该包的约定](../ui-settings/README.md)。
## Slot 声明注入
@@ -13,6 +13,7 @@ import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { InjectFace, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type { ModelsSettingsState, ModelsSettingsStore } from './store.ts'
import { onboardingReadiness } from './store.ts'
import type { SettingsSchemaOperations } from './schema-operations.ts'
import { ProviderEditor } from './ProviderEditor.tsx'
import type { en } from './locales.ts'
import { OnboardingModal } from './OnboardingModal.tsx'
@@ -28,6 +29,8 @@ export interface DeepSeekOnboardingInjected {
controller: ModelsSettingsStore
/** Existing wire face reused by the Models credential editor. */
api: Pick<IApiClient, 'settings' | 'credentials' | 'llm'>
/** Settings schema and immutable path callbacks. */
schema: SettingsSchemaOperations
/** Feature copy. */
t: (key: keyof typeof en) => string
}
@@ -48,7 +51,7 @@ function assertNever(_value: never): never {
* @returns the onboarding modal or null when onboarding needs no intervention.
*/
export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps): ReactNode {
const { complete, controller, useModels, api, t } = props
const { complete, controller, useModels, api, schema, t } = props
const state = useModels(snapshot => snapshot)
const readiness = onboardingReadiness(state)
@@ -101,7 +104,7 @@ export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps):
provider={row.entry.provider}
displayName={row.entry.displayName}
namespace={namespace}
schema={controller.schema}
schema={schema}
settingsPath={row.entry.settingsPath}
api={api}
t={t}
@@ -20,6 +20,7 @@ import type { InjectFace } from '@deepseek-ai/dsh-client-ui-slots'
import { CustomProviderCard } from './CustomProviderCard.tsx'
import { deriveKeyRef, messageOf, protocolChoices, providerUsable } from './store.ts'
import type { ModelsSettingsStore, ProviderRow } from './store.ts'
import type { SettingsSchemaOperations } from './schema-operations.ts'
import { ProviderEditor, type ProviderEditorProps } from './ProviderEditor.tsx'
import type { en } from './locales.ts'
import styles from './ModelsSection.module.css'
@@ -34,6 +35,8 @@ export interface ModelsSectionInjected {
}
/** Wire faces the editor writes through. */
api: Pick<IApiClient, 'settings' | 'credentials' | 'llm'>
/** Settings schema and immutable path callbacks. */
schema: SettingsSchemaOperations
/** Section copy. */
t: (key: keyof typeof en) => string
}
@@ -173,13 +176,16 @@ export function providerCopy(template: string, target: ProviderIdentity): string
* @returns the section, or null while the shell has not injected yet.
*/
export function ModelsSection(props: ModelsSectionProps): ReactNode {
const { controller, useSnapshot, api, t } = props
if (controller === undefined || useSnapshot === undefined || api === undefined || t === undefined) return null
return <Loaded injected={{ controller, useSnapshot, api, t }} />
const { controller, useSnapshot, api, schema, t } = props
if (
controller === undefined || useSnapshot === undefined || api === undefined
|| schema === undefined || t === undefined
) return null
return <Loaded injected={{ controller, useSnapshot, api, schema, t }} />
}
function Loaded({ injected }: { injected: ModelsSectionFace }): ReactNode {
const { controller, api, t } = injected
const { controller, api, schema, t } = injected
const state = injected.useSnapshot(snapshot => snapshot)
const [editing, setEditing] = useState<EditorTarget | undefined>(undefined)
const [adding, setAdding] = useState(false)
@@ -273,7 +279,7 @@ function Loaded({ injected }: { injected: ModelsSectionFace }): ReactNode {
// Hand-declared routes live in the pi-ai namespace, which is also the only
// one whose schema names the protocols one may speak; without it mounted
// there is nothing to declare and the entry point stays disabled.
const protocols = protocolChoices(state.namespaces.get('llm-pi-ai'), controller.schema)
const protocols = protocolChoices(state.namespaces.get('llm-pi-ai'), schema)
return (
<div className={styles['section']}>
@@ -301,7 +307,7 @@ function Loaded({ injected }: { injected: ModelsSectionFace }): ReactNode {
{renderProviderEditor({
target,
namespace,
schema: controller.schema,
schema,
api,
t,
readOnly: !state.writable,
@@ -386,7 +392,7 @@ function Loaded({ injected }: { injected: ModelsSectionFace }): ReactNode {
? renderProviderEditor({
target,
namespace,
schema: controller.schema,
schema,
api,
t,
readOnly: !state.writable,
@@ -425,7 +431,7 @@ function Loaded({ injected }: { injected: ModelsSectionFace }): ReactNode {
displayName={addTarget.displayName}
hideTitle
namespace={addNamespace}
schema={controller.schema}
schema={schema}
settingsPath={addTarget.settingsPath}
api={api}
t={t}
@@ -24,7 +24,6 @@
import { useEffect, useMemo, useState } from 'react'
import type { ReactNode } from 'react'
import type { CredentialView, IApiClient, SettingsNamespaceView, SettingsPathOpView } from '@deepseek-ai/dsh-api-remotes/client'
import type { SettingsSchemaService } from '@deepseek-ai/dsh-client-ui-settings/client'
import {
DeepSeekModelsEditor, modelDrafts, validateDeepSeekModels,
} from './DeepSeekModelsEditor.tsx'
@@ -32,6 +31,7 @@ import { apiKeyFailure } from './apiKey.ts'
import { EditorFooter } from './EditorFooter.tsx'
import { ModelListEditor } from './ModelListEditor.tsx'
import { deriveKeyRef, messageOf, protocolChoices } from './store.ts'
import type { SettingsSchemaOperations } from './schema-operations.ts'
import type { en } from './locales.ts'
import styles from './ModelsSection.module.css'
@@ -60,7 +60,7 @@ export interface ProviderEditorProps {
/** The owning namespace view (schema, layers, secrets). */
namespace: SettingsNamespaceView
/** Settings-owned synchronous schema and immutable path operations. */
schema: SettingsSchemaService
schema: SettingsSchemaOperations
/** Path from the section root to this provider's profile. */
settingsPath: readonly string[]
/** Wire faces for writes and for interrogating a provider endpoint. */
@@ -87,7 +87,7 @@ export interface ProviderEditorProps {
/** A user-section subtree as a plain draft object (absent → empty). */
function draftAt(
schema: SettingsSchemaService,
schema: SettingsSchemaOperations,
namespace: SettingsNamespaceView,
path: readonly string[],
): Record<string, unknown> {
@@ -134,7 +134,7 @@ function layoutOf(ns: string): EditorLayout {
/** The credential reference this profile resolves keys through. */
function refFor(
schema: SettingsSchemaService,
schema: SettingsSchemaOperations,
namespace: SettingsNamespaceView,
path: readonly string[],
provider: string,
@@ -23,6 +23,7 @@ import { WelcomeNotice } from './WelcomeNotice.tsx'
import type { WelcomeNoticeInjected } from './WelcomeNotice.tsx'
import { refreshWelcomeIfLoaded, WelcomeNoticeStore } from './welcome-store.ts'
import { ModelsSettingsStore } from './store.ts'
import { createSettingsSchemaOperations } from './schema-operations.ts'
import { en, zh, type ModelsKey } from './locales.ts'
import { WELCOME_NOTICE_SETTINGS_NAMESPACE } from '../onboarding-copy.ts'
@@ -67,7 +68,8 @@ export function apply(ctx: ClientContext): void {
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-settings-models: copy dictionaries')
const connection = ctx.get('connection') as ConnectionHandle
const controller = new ModelsSettingsStore(connection.api, ctx.settingsSchema)
const schema = createSettingsSchemaOperations(ctx.settingsSchema)
const controller = new ModelsSettingsStore(connection.api, schema)
// Registration-time text (the nav label thunk) and the inject faces share
// one bound translate; copy freshness rides the locale revision.
const t = ctx.locale.bind(NS) as ModelsSectionInjected['t']
@@ -75,12 +77,14 @@ export function apply(ctx: ClientContext): void {
controller,
hooks: { snapshot: controller.store },
api: connection.api,
schema,
t,
})
const deepSeekOnboardingInjected = (): DeepSeekOnboardingInjected => ({
controller,
hooks: { models: controller.store },
api: connection.api,
schema,
t,
})
const welcomeController = new WelcomeNoticeStore(
@@ -0,0 +1,26 @@
import type {
SettingsSchemaService,
} from '@deepseek-ai/dsh-client-ui-settings/client'
/** Plain schema callbacks exposed to Models stores and presentation components. */
export type SettingsSchemaOperations = Pick<
SettingsSchemaService,
'rehydrate' | 'validate' | 'nodeAtPath' | 'getPath' | 'hasPath' | 'setPath' | 'deletePath'
>
/**
* Hide the Cordis service identity behind bound schema callbacks.
* @param service - settings-owned schema service available in the apply context.
* @returns callbacks that cannot expose the service context to React components.
*/
export function createSettingsSchemaOperations(service: SettingsSchemaService): SettingsSchemaOperations {
return {
rehydrate: serialized => service.rehydrate(serialized),
validate: (schema, draft) => service.validate(schema, draft),
nodeAtPath: (root, path) => service.nodeAtPath(root, path),
getPath: (value, path) => service.getPath(value, path),
hasPath: (value, path) => service.hasPath(value, path),
setPath: (root, path, value) => service.setPath(root, path, value),
deletePath: (root, path) => service.deletePath(root, path),
}
}
@@ -11,7 +11,7 @@ import type {
} from '@deepseek-ai/dsh-api-remotes/client'
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { SettingsSchemaService } from '@deepseek-ai/dsh-client-ui-settings/client'
import type { SettingsSchemaOperations } from './schema-operations.ts'
/**
* Any route key walks a dict schema to the same profile node, so the lookup
@@ -81,7 +81,7 @@ export function deriveKeyRef(provider: string): string {
*/
export function protocolChoices(
namespace: SettingsNamespaceView | undefined,
schema: SettingsSchemaService,
schema: SettingsSchemaOperations,
): string[] {
if (namespace === undefined) return []
const node = schema.nodeAtPath(schema.rehydrate(namespace.schema), ['providers', PROBE_ROUTE, 'api'])
@@ -94,7 +94,7 @@ export function protocolChoices(
function apiKeyEnvOf(
namespace: SettingsNamespaceView | undefined,
path: readonly string[],
schema: SettingsSchemaService,
schema: SettingsSchemaOperations,
): string | undefined {
if (namespace === undefined) return undefined
const profile = schema.getPath(namespace.value, path)
@@ -118,7 +118,7 @@ export class ModelsSettingsStore {
*/
constructor(
private readonly api: Pick<IApiClient, 'settings' | 'credentials' | 'llm'>,
readonly schema: SettingsSchemaService,
private readonly schema: SettingsSchemaOperations,
) {}
/**
@@ -192,6 +192,7 @@ async function mountFace(scripted: ReturnType<typeof scriptedFace>) {
controller,
useSnapshot: bindSnapshotSelector(controller.store),
api: face as never,
schema: settingsSchema,
t,
}
const view = render(<ModelsSection {...injected} />)
@@ -272,6 +273,7 @@ describe('ModelsSection', () => {
controller={controller}
useSnapshot={bindSnapshotSelector(controller.store)}
api={face as never}
schema={settingsSchema}
t={t}
/>)
@@ -295,6 +297,7 @@ describe('ModelsSection', () => {
controller={controller}
useSnapshot={bindSnapshotSelector(controller.store)}
api={face as never}
schema={settingsSchema}
t={t}
/>)
// Now a row with an Edit button, not an open card.
@@ -1017,6 +1020,7 @@ describe('ModelsSection', () => {
controller={controller}
useSnapshot={bindSnapshotSelector(controller.store)}
api={face as never}
schema={settingsSchema}
t={t}
/>)
const key = await screen.findByLabelText<HTMLInputElement>(en.keyInput)
@@ -1154,6 +1158,7 @@ describe('ModelsSection', () => {
controller={controller}
useSnapshot={bindSnapshotSelector(controller.store)}
api={face.face as never}
schema={settingsSchema}
t={t}
/>)
expect(screen.getByText(/directory down/)).toBeTruthy()
@@ -1175,6 +1180,7 @@ describe('ModelsSection', () => {
controller={controller}
useSnapshot={bindSnapshotSelector(controller.store)}
api={face as never}
schema={settingsSchema}
t={t}
/>)
expect(screen.getByText(en.readOnly)).toBeTruthy()
@@ -1235,6 +1241,7 @@ describe('ModelsSection', () => {
controller={controller}
useSnapshot={bindSnapshotSelector(controller.store)}
api={face as never}
schema={settingsSchema}
t={t}
/>)
await screen.findByText('DeepSeek')
@@ -138,6 +138,7 @@ function harness(options: {
controller,
useModels: bindSnapshotSelector(controller.store),
api: face as never,
schema: settingsSchema,
t: key => en[key],
}
return {
@@ -146,6 +146,7 @@ async function mountSection(options: Parameters<typeof scriptedFace>[0] = {}) {
controller,
useSnapshot: bindSnapshotSelector(controller.store),
api: scripted.face as never,
schema: settingsSchema,
t,
}
render(<ModelsSection {...injected} />)
@@ -644,6 +645,7 @@ describe('provider rows', () => {
controller={controller}
useSnapshot={bindSnapshotSelector(controller.store)}
api={scripted.face as never}
schema={settingsSchema}
t={t}
/>)
@@ -1,5 +1,6 @@
import { Context } from '@deepseek-ai/cordis'
import { SettingsSchemaService } from '@deepseek-ai/dsh-client-ui-settings/client'
import { createSettingsSchemaOperations } from '../src/client/schema-operations.ts'
/** Stateless schema operations used by settings-model component fixtures. */
export const settingsSchema = new SettingsSchemaService(new Context())
export const settingsSchema = createSettingsSchemaOperations(new SettingsSchemaService(new Context()))
+2 -2
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: 094d7ef7dec4836d2596536f9baf2029f7d07e6a
README.zh.md: aa26269ff668fb531dbd6dfb84d98015dcad92e7
README.md: 723e6f7120c256406daedd2085eef055b72263fe
README.zh.md: 42f6f6e09ee520542ddc74344be5619adf5adf02
+1 -1
View File
@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
The settings domain's base layer, with no presentation of its own. It provides `ctx.settingsScope`, the Host transport every preference row binds its durable namespace section through; `ctx.settingsSchema`, the synchronous schema-rehydration, validation, and immutable path-editing service used by settings plugins; and 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), `settings.plugins.tab` (feature-owned pages inside the Plugins section), 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 plugin injects nothing and waits for nothing: schema operations are synchronous, while `ctx.settingsScope.bind(spec)` resolves the wire face through the caller's context at call time. The bound scope's disposer belongs to the calling fiber, and the caller injects `connection` for the transport and `remote` for 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.
The plugin injects nothing and waits for nothing: schema operations are synchronous, while `ctx.settingsScope.bind(spec)` resolves the wire face through the caller's context at call time. The bound scope's disposer belongs to the calling fiber, and the caller injects `connection` for the transport and `remote` for 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. The snapshot carries the resolved section, composition `base`, raw `user`, revision, writability, and host/memory mode. A field is overridden when it is present in `user`, even when its value equals `base`; `unset` clears that override. 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
+1 -1
View File
@@ -4,7 +4,7 @@
设置领域的底座,本身不含任何呈现内容。它提供 `ctx.settingsScope`——每个偏好设置行绑定自己那份持久化命名空间分区所用的宿主传输层;`ctx.settingsSchema`——设置插件使用的同步 schema 重建、校验与不可变路径编辑服务;并声明由注册方填充的设置 slot 类型:`settings.trigger`/`settings.header`/`settings.close`(界面框架内容)、`settings.action`(内容标题栏中的有序操作)、`settings.section`(每项功能一页)、`settings.plugins.tab`(“插件”分区内由各功能持有的页面)和 `settings.onboarding`(由各功能持有的有序页面)。它不依赖任何 `ui-*` 呈现包,因此任何持有偏好设置的功能都能够到它;设置**外壳**——`sidebar.settings` 占位方、它的导航与界面框架——位于 ui-settings-general,因为外壳一旦依赖 ui-sidebar,就会经 ui-layout 与 ui-theme 闭合出一条引用图环路。外壳自身的契约类型出于同一原因与外壳放在一起。
该插件不注入任何服务、也不等待任何服务:schema 操作为同步调用,而 `ctx.settingsScope.bind(spec)` 在调用时经调用方的 context 解析线路面。绑定所得 scope 的 disposer 归调用方 fiber 所有,而由调用方注入 `connection` 取得传输层、注入 `remote` 取得失效通知。监听器在首次后台读取启动之前就已存在,因此某一行的激活绝不会阻塞在设置传输层上。已绑定的 scope 会在收到属于自己命名空间的转发 `settings/document-updated` 事件时、以及在 `connection/reset` 时重新读取。写入携带单一字段路径以及最近已知的命名空间 revision 作为 `expectedRevision`;被拒绝或失败的写入会重新读取,除非已有更新的写入取代了它,而过期的读取绝不会覆盖发布更新的结果。若 spec 未提供 `decode`,则分区不是普通对象、未通过其重建后的 schema 校验、或携带本客户端无法重建的 schema 信封时,一律不发布任何值,于是行渲染自己的缺失状态,而不是一份半解码的值。
该插件不注入任何服务、也不等待任何服务:schema 操作为同步调用,而 `ctx.settingsScope.bind(spec)` 在调用时经调用方的 context 解析线路面。绑定所得 scope 的 disposer 归调用方 fiber 所有,而由调用方注入 `connection` 取得传输层、注入 `remote` 取得失效通知。监听器在首次后台读取启动之前就已存在,因此某一行的激活绝不会阻塞在设置传输层上。已绑定的 scope 会在收到属于自己命名空间的转发 `settings/document-updated` 事件时、以及在 `connection/reset` 时重新读取。写入携带单一字段路径以及最近已知的命名空间 revision 作为 `expectedRevision`;被拒绝或失败的写入会重新读取,除非已有更新的写入取代了它,而陈旧的读取绝不会覆盖更新的发布结果。快照携带解析后的分区、组合 `base`、原始 `user`、revision、可写性以及 host/内存模式。字段只要出现在 `user` 中即视为覆盖,即使其值与 `base` 相等;`unset` 会清除该覆盖。若 spec 未提供 `decode`,则分区不是普通对象、未通过其重建后的 schema 校验、或携带本客户端无法重建的 schema 信封时,一律不发布任何值,于是行渲染自己的缺失状态,而不是一份半解码的值。
## 模型体验
@@ -21,7 +21,7 @@ import {
// 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
// `remote` injection belongs to whoever calls `ctx.settingsScope.bind(spec)`: the
// subscription is registered on the caller's own context.
import type {} from '@deepseek-ai/dsh-api-remotes/client'
import type {} from '@deepseek-ai/dsh-api-remotes/types'
@@ -50,12 +50,13 @@ export class SettingsScopeController<T> implements SettingsScope<T> {
* @param api - settings wire face.
* @param spec - namespace identity and optional narrowing decoder.
* @param persistence - remote browsers remain process-local because settings RPCs are loopback-only.
* @param schema - settings-owned schema operations.
*/
constructor(
private readonly api: SettingsFace,
private readonly spec: SettingsScopeSpec<T>,
private readonly persistence: 'host' | 'memory' = 'host',
private readonly schema?: SettingsSchemaService,
private readonly schema: SettingsSchemaService,
) {
this.store = createSnapshotStore<SettingsScopeSnapshot<T>>({
status: persistence === 'host' ? 'loading' : 'unavailable',
@@ -202,7 +203,6 @@ export class SettingsScopeController<T> implements SettingsScope<T> {
if (typeof view.value !== 'object' || view.value === null || Array.isArray(view.value)) return undefined
let failure: string | undefined
try {
if (this.schema === undefined) throw new Error('ui-settings: schema service unavailable')
failure = this.schema.validate(this.schema.rehydrate(view.schema), view.value)
} catch (_malformedSchemaEnvelope) {
// A schema envelope this client cannot rehydrate vouches for no section;
@@ -125,16 +125,6 @@ describe('SettingsScopeController', () => {
expect(scope.getSnapshot()).toMatchObject({ status: 'loading', value: undefined, revision: 2 })
})
it('rejects host values when no schema service is available', async () => {
const describeCall = vi.fn().mockResolvedValueOnce(described({ preference: 'dark' }, 2))
const scope = new ProductionSettingsScopeController<UiTestSettings>(
{ settings: { describe: describeCall } } as never,
{ namespace: 'ui-test' },
)
await scope.load()
expect(scope.getSnapshot()).toMatchObject({ status: 'loading', value: undefined, revision: 2 })
})
it('suppresses a superseded read of an unexposed namespace', async () => {
const describeCall = vi.fn()
.mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [] }))
+1 -1
View File
@@ -372,7 +372,7 @@ function dynamicToken(name: string): ThemeTokenInspection {
/**
* Required services: settings transport plus slots/locale for the Appearance
* row. `remote` carries the forwarded settings invalidation that
* `bindSettingsScope` subscribes to on this context.
* `ctx.settingsScope.bind(spec)` subscribes to on this context.
*/
export const inject = ['slots', 'locale', 'connection', 'remote', 'settingsScope']
+1 -3
View File
@@ -81,9 +81,7 @@ export class AppWebEntry {
/** Mount through a dependency fiber so replacing uiRenderer remounts the application. */
private async mountApp(ctx: Context): Promise<void> {
const mounted = ctx.inject(['uiRenderer'], (scope) => {
const renderer = scope.get('uiRenderer')
if (renderer === undefined) throw new Error('web boot: uiRenderer service missing after settled')
scope.effect(() => renderer.mount(this.container), 'web boot: application mount')
scope.effect(() => scope.uiRenderer.mount(this.container), 'web boot: application mount')
this.page.dispose()
})
await mounted