fix(web): persist the default agent preset, and give its page an identity

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).
This commit is contained in:
Yichen Jiang
2026-08-04 13:34:22 +08:00
parent bf0828e469
commit 9edfdb589d
9 changed files with 41 additions and 10 deletions

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: d775daf1e91c6eb9ebca69c7d0484c0029e93cfc
README.zh.md: b3fb3e64324dcb78465843ba2ba58f5a3efa3d08
README.md: be159855010c1a0af7d3f712d9551000826d4820
README.zh.md: 73c07751f4e5cf1cea43d2723027162f09da10f6

View File

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

View File

@@ -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 与信任级别,而局域网客户端的选择器需要它。
## 何时不显示这些表层

View File

@@ -59,7 +59,7 @@ export const zh: Record<AgentPresetSettingsKey, string> = {
userTrust: '本地',
seatHint: '本会话的 agent preset —— 发送第一条消息前可切换',
lockedHint: '会话开始后,其 agent preset 即固定',
nav: 'Agent preset',
nav: '智能体',
sectionIntro: 'preset 即一个会话的 agent 所运行的插件组装 —— 它的工具、提示词与能力。内置 preset 只读;复制一份即可改成自己的。',
builtIn: '内置',
defaultBadge: '默认',

View File

@@ -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 () => {

View File

@@ -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 <IconDataOutline16 className={css.navIcon} size={16} />
if (id === 'agent-presets') return <IconThinkOutline16 className={css.navIcon} size={16} />
return <IconSettingsOutline16 className={css.navIcon} size={16} />
}

View File

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

View File

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

View File

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