From 9edfdb589d94c5dc9c354144e4d958558bc2054f Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 4 Aug 2026 13:34:22 +0800 Subject: [PATCH] fix(web): persist the default agent preset, and give its page an identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes to the settings surface. The default never reached settings.yaml. The api-proxy keeps an explicit allowlist of settings namespaces it exposes to configuration clients, and `agent-presets` was never added — so both pickers moved and then silently forgot, which is worse than refusing the control. The host seam was fine all along; only the wire boundary refused. The regression test fails with the namespace removed. The nav row showed the fallback gear and read `Agent preset` in Chinese, matching neither sibling (通用设置 / 模型). It is now 智能体 with the think glyph, the only unused icon in the set whose semantics point at the agent rather than at tuning sliders (Personalization is already the workspace browser's filter control). --- packages/client/ui-agent-preset/README.i18n.yaml | 4 ++-- packages/client/ui-agent-preset/README.md | 4 +++- packages/client/ui-agent-preset/README.zh.md | 4 +++- .../client/ui-agent-preset/src/client/locales.ts | 2 +- .../client/ui-agent-preset/tests/apply.spec.ts | 2 +- .../ui-settings/src/client/SettingsRoot.tsx | 5 ++++- .../ui-settings/tests/settings-root.spec.tsx | 1 + packages/host/apiproxy/src/api-proxy.ts | 14 +++++++++++--- .../host/apiproxy/tests/api-proxy-config.spec.ts | 15 +++++++++++++++ 9 files changed, 41 insertions(+), 10 deletions(-) diff --git a/packages/client/ui-agent-preset/README.i18n.yaml b/packages/client/ui-agent-preset/README.i18n.yaml index 40e443b224..97e553be99 100644 --- a/packages/client/ui-agent-preset/README.i18n.yaml +++ b/packages/client/ui-agent-preset/README.i18n.yaml @@ -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: d775daf1e91c6eb9ebca69c7d0484c0029e93cfc -README.zh.md: b3fb3e64324dcb78465843ba2ba58f5a3efa3d08 +README.md: be159855010c1a0af7d3f712d9551000826d4820 +README.zh.md: 73c07751f4e5cf1cea43d2723027162f09da10f6 diff --git a/packages/client/ui-agent-preset/README.md b/packages/client/ui-agent-preset/README.md index 9425b50af9..45e4ceabbb 100644 --- a/packages/client/ui-agent-preset/README.md +++ b/packages/client/ui-agent-preset/README.md @@ -24,7 +24,7 @@ The row re-reads on `settings/changed` for its own namespace and on `connection/ ## The management section -A third surface, its own settings page: the roster as rows, and one composition open in a YAML editor at a time. +A third surface, its own settings page (`settings.section` id `agent-presets`, ordered after Models — choosing a model is routine, composing an agent is the deployment-shaping act behind it): the roster as rows, and one composition open in a YAML editor at a time. A shipped preset opens read-only. It is the known-good composition a local one is written against, so reading it is the point and overwriting it is not — the deployment's copy is what a broken local preset is compared against. Authoring therefore starts by duplicating: **New preset** copies the current default, and **Duplicate** copies any row, because a copy always lands in the local root regardless of where the text came from. @@ -32,6 +32,8 @@ An id becomes a directory name, so the editor mirrors the host's own containment Deleting removes the file. Sessions already composed from it keep running — a composition is mounted once at session creation and nothing re-reads the file. +Setting the default writes the `agent-presets` settings namespace, which the host exposes to configuration clients ([`dsh-apiproxy`](../../host/apiproxy/README.md) keeps an explicit allowlist — a namespace outside it makes a picker move and then silently forget). + `agentPreset.read`, `write`, `remove`, and `select` are loopback-pinned ([`dsh-client-connection`](../connection/README.md)): a composition names the plugins a session runs, so reading one is reconnaissance and writing one is arbitrary capability. `agentPreset.list` is not — it carries ids and trust, and a LAN client's picker needs it. ## When the surfaces are absent diff --git a/packages/client/ui-agent-preset/README.zh.md b/packages/client/ui-agent-preset/README.zh.md index c5be1ea017..3552e92675 100644 --- a/packages/client/ui-agent-preset/README.zh.md +++ b/packages/client/ui-agent-preset/README.zh.md @@ -24,7 +24,7 @@ agent preset 的各个表层:General 设置中的一行,用于选择新建 ## 管理分区 -第三个表层,独立的设置页:名单以行呈现,同一时刻有一份组装在 YAML 编辑器中打开。 +第三个表层,独立的设置页(`settings.section`,id 为 `agent-presets`,排在「模型」之后——选模型是日常操作,而组装 agent 是它背后那件塑造部署形态的事):名单以行呈现,同一时刻有一份组装在 YAML 编辑器中打开。 随部署提供的 preset 以只读方式打开。它是本地 preset 据以编写的已知良好组装,因此能读到它正是意义所在,而覆写它则不是——部署自带的那一份正是用来对照有问题的本地 preset 的。因此创作从复制开始:**新建 preset** 复制当前默认值,**复制**则复制任意一行;无论文本来自何处,副本总是落在本地根目录,所以副本总是可写的。 @@ -32,6 +32,8 @@ id 会成为目录名,因此编辑器复刻宿主自身的约束规则(`[a-z 删除会移除该文件。已据其组装的会话继续运行——组装在会话创建时挂载一次,此后没有任何东西会重新读取该文件。 +设置默认值写入的是 `agent-presets` settings 命名空间,宿主需将其暴露给配置客户端([`dsh-apiproxy`](../../host/apiproxy/README.md) 维护一份显式白名单——不在其中的命名空间会让选择器动一下然后悄悄忘记)。 + `agentPreset.read`、`write`、`remove` 与 `select` 被固定在环回地址(见 [`dsh-client-connection`](../connection/README.md)):组装指明了一个会话所运行的插件,因此读取它是侦察,写入它是任意能力。`agentPreset.list` 不在其中——它只携带 id 与信任级别,而局域网客户端的选择器需要它。 ## 何时不显示这些表层 diff --git a/packages/client/ui-agent-preset/src/client/locales.ts b/packages/client/ui-agent-preset/src/client/locales.ts index fc97b4d109..95d9c5bb3b 100644 --- a/packages/client/ui-agent-preset/src/client/locales.ts +++ b/packages/client/ui-agent-preset/src/client/locales.ts @@ -59,7 +59,7 @@ export const zh: Record = { userTrust: '本地', seatHint: '本会话的 agent preset —— 发送第一条消息前可切换', lockedHint: '会话开始后,其 agent preset 即固定', - nav: 'Agent preset', + nav: '智能体', sectionIntro: 'preset 即一个会话的 agent 所运行的插件组装 —— 它的工具、提示词与能力。内置 preset 只读;复制一份即可改成自己的。', builtIn: '内置', defaultBadge: '默认', diff --git a/packages/client/ui-agent-preset/tests/apply.spec.ts b/packages/client/ui-agent-preset/tests/apply.spec.ts index 207598f471..40da44c792 100644 --- a/packages/client/ui-agent-preset/tests/apply.spec.ts +++ b/packages/client/ui-agent-preset/tests/apply.spec.ts @@ -91,7 +91,7 @@ describe('ui-agent-preset apply', () => { expect(section.component).toBe(AgentPresetSection) expect(section.options).toMatchObject({ id: 'agent-presets', order: 20 }) // The nav label is a locale-following thunk; owners resolve it at read time. - expect(resolveSlotLabel(section.options.label)).toBe('Agent preset') + expect(resolveSlotLabel(section.options.label)).toBe('智能体') }) it('registers into a declaration that arrives after apply', async () => { diff --git a/packages/client/ui-settings/src/client/SettingsRoot.tsx b/packages/client/ui-settings/src/client/SettingsRoot.tsx index 45055753ac..9e48c7d4b0 100644 --- a/packages/client/ui-settings/src/client/SettingsRoot.tsx +++ b/packages/client/ui-settings/src/client/SettingsRoot.tsx @@ -12,13 +12,16 @@ import { useCallback, useEffect, useId, useRef, useState } from 'react' import { createPortal } from 'react-dom' import clsx from 'clsx' -import { IconCloseOutline16, IconDataOutline16, IconSettingsOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +import { + IconCloseOutline16, IconDataOutline16, IconSettingsOutline16, IconThinkOutline16, +} from '@deepseek-ai/dsh-client-ui-primitives' import type { SettingsRootComponentProps, SettingsSectionRow } from './contract/slots.ts' import css from './SettingsRoot.module.css' /** Nav glyph by section id; unknown ids fall back to the settings gear. */ function navIcon(id: string) { if (id === 'models') return + if (id === 'agent-presets') return return } diff --git a/packages/client/ui-settings/tests/settings-root.spec.tsx b/packages/client/ui-settings/tests/settings-root.spec.tsx index 900c66d381..732ca4ba81 100644 --- a/packages/client/ui-settings/tests/settings-root.spec.tsx +++ b/packages/client/ui-settings/tests/settings-root.spec.tsx @@ -24,6 +24,7 @@ function mount({ rows = [ { id: 'general', order: 0, label: 'General' }, { id: 'models', order: 10, label: 'Models' }, + { id: 'agent-presets', order: 20, label: 'Agent presets' }, ], steps = [ { id: 'welcome', order: -100 }, diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 353d5a7af8..2285852da2 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -26,7 +26,8 @@ import { // Type-only: brings the `ctx.tools` Context merge into this program (viewFor reads presenters). import { InvalidCompositionError, InvalidPresetIdError, PresetMountError, - PresetNotWritableError, resolveSessionPreset, UnknownPresetError, + PresetNotWritableError, resolveSessionPreset, + SETTINGS_NAMESPACE as AGENT_PRESET_SETTINGS_NAMESPACE, UnknownPresetError, } from '@deepseek-ai/dsh-agent-presets' import type {} from '@deepseek-ai/dsh-tools' import type { @@ -89,8 +90,15 @@ const COLD_SUMMARY_BATCH_SIZE = 16 /** Conversation message event types (the pagination counting unit). */ const MESSAGE_TYPES = new Set(['user/message', 'assistant/message']) -/** Product settings intentionally exposed beside model-provider namespaces. */ -const PRODUCT_SETTINGS_NAMESPACES = new Set(['ui-onboarding']) +/** + * Product settings intentionally exposed beside model-provider namespaces. + * + * The agent-preset namespace carries one field — which preset a session with + * no explicit choice is composed from — and both browser surfaces that offer + * that choice write it through `settings.update`, so it has to cross the + * configuration boundary or the pickers silently fail to persist. + */ +const PRODUCT_SETTINGS_NAMESPACES = new Set(['ui-onboarding', AGENT_PRESET_SETTINGS_NAMESPACE]) /** Read live abort state across awaits without treating it as synchronously immutable. */ function isAborted(signal: AbortSignal): boolean { diff --git a/packages/host/apiproxy/tests/api-proxy-config.spec.ts b/packages/host/apiproxy/tests/api-proxy-config.spec.ts index 54235c0218..d606c12c4a 100644 --- a/packages/host/apiproxy/tests/api-proxy-config.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-config.spec.ts @@ -355,6 +355,21 @@ describe('settings domain', () => { expect(frames).toEqual([{ type: 'host/settings-changed', ns: 'ui-onboarding' }]) }) + it('serves the agent-preset namespace, so a browser preset picker can persist its choice', async () => { + const ctx = await harness() + ctx.settings.register(settingsNamespace('agent-presets'), z.object({ default: z.string() })) + const api = createApiProxy(ctx, DEFAULTS) + + expectOk(await api.settings.update(request({ ns: 'agent-presets', patch: { default: 'core-web' } }))) + + // Both browser surfaces that offer the choice — the General row and the + // management section — write the default through `settings.update`, so a + // namespace outside this boundary makes the picker move and then silently + // forget, which is worse than refusing the control. + expect(ctx.settings.describe().find(view => String(view.ns) === 'agent-presets')?.value) + .toEqual({ default: 'core-web' }) + }) + it('refuses even a model-provider namespace once its directory entry is gone', async () => { const ctx = await harness({ configurableProviders: false }) ctx.settings.register(NS, AdapterConfig)