feat(web): choose the default agent preset from General settings
One settings row picking which preset new sessions are composed from. It is deliberately a new-session preference, not a live switch: a session's preset is fixed at creation and the host refuses to adopt an existing session under a different one, so the row says "applies to sessions you start from now on" rather than implying it can retune a running agent. Options and the current value come from one `agentPreset.list` call — the roster already reports which id an unspecified session gets, so the row needs no settings-schema introspection, unlike the permission row it is modelled on. The write targets only the namespace's `default` field. The menu marks `user` rows: a locally authored preset is exactly as privileged as the plugins it names, and presenting it identically to a shipped one would hide that. An empty roster reads as `unavailable` and renders nothing, because composing no presets is a valid deployment rather than a failure — distinct from a roster call that failed, which surfaces its message.
This commit is contained in:
@@ -19,6 +19,7 @@
|
||||
"@cordisjs/plugin-timer": "workspace:*",
|
||||
"@deepseek-ai/dsh-app-boot": "workspace:^",
|
||||
"@deepseek-ai/dsh-base": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-agent-preset": "workspace:^",
|
||||
"@deepseek-ai/dsh-headless": "workspace:^",
|
||||
"@deepseek-ai/dsh-mcp-client": "workspace:^",
|
||||
"@deepseek-ai/dsh-paths": "workspace:^",
|
||||
|
||||
@@ -2482,6 +2482,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
|
||||
- `@deepseek-ai/dsh-client-locale` ([`packages/client/locale/src/index.ts`](../packages/client/locale/src/index.ts))
|
||||
- `@deepseek-ai/dsh-client-modules` — requires `httpServer` · `loader` ([`packages/client/modules/src/index.ts`](../packages/client/modules/src/index.ts))
|
||||
- `@deepseek-ai/dsh-client-runtime` ([`packages/client/runtime/src/index.ts`](../packages/client/runtime/src/index.ts))
|
||||
- `@deepseek-ai/dsh-client-ui-agent-preset` ([`packages/client/ui-agent-preset/src/index.ts`](../packages/client/ui-agent-preset/src/index.ts))
|
||||
- `@deepseek-ai/dsh-client-ui-command` ([`packages/client/ui-command/src/index.ts`](../packages/client/ui-command/src/index.ts))
|
||||
- `@deepseek-ai/dsh-client-ui-conversation` ([`packages/client/ui-conversation/src/index.ts`](../packages/client/ui-conversation/src/index.ts))
|
||||
- `@deepseek-ai/dsh-client-ui-goal` ([`packages/client/ui-goal/src/index.ts`](../packages/client/ui-goal/src/index.ts))
|
||||
|
||||
@@ -180,6 +180,11 @@
|
||||
- id: ui-permission
|
||||
name: '@deepseek-ai/dsh-client-ui-permission'
|
||||
|
||||
# The agent-preset row in General settings: the default preset for
|
||||
# sessions created later. Absent a roster it renders nothing.
|
||||
- id: ui-agent-preset
|
||||
name: '@deepseek-ai/dsh-client-ui-agent-preset'
|
||||
|
||||
# Plan control: the composer plan seat over the plan projection + /plan channel.
|
||||
- id: ui-plan
|
||||
name: '@deepseek-ai/dsh-client-ui-plan'
|
||||
|
||||
@@ -167,6 +167,10 @@ export class FakeApiClient implements IApiClient {
|
||||
execute: (payload: unknown) => this.record('command.execute', payload, this.onCommandExecute(payload)),
|
||||
}
|
||||
|
||||
readonly agentPresets: IApiClient['agentPresets'] = {
|
||||
list: (payload: unknown) => this.record('agentPreset.list', payload, Promise.resolve(ok({ presets: [] }))),
|
||||
}
|
||||
|
||||
readonly skills: IApiClient['skills'] = {
|
||||
list: (payload: unknown) => this.record('skill.list', payload, this.onSkillList(payload)),
|
||||
}
|
||||
|
||||
@@ -202,6 +202,10 @@ export class FakeApiClient implements IApiClient {
|
||||
execute: (payload: unknown) => this.record('command.execute', payload, this.onCommandExecute(payload)),
|
||||
}
|
||||
|
||||
readonly agentPresets: IApiClient['agentPresets'] = {
|
||||
list: (payload: unknown) => this.record('agentPreset.list', payload, Promise.resolve(ok({ presets: [] }))),
|
||||
}
|
||||
|
||||
readonly skills: IApiClient['skills'] = {
|
||||
list: (payload: unknown) => this.record('skill.list', payload, this.onSkillList(payload)),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# 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: c22d5a40659bf6300f133ff902fbc3bf8bc75276
|
||||
README.zh.md: 6b7e43adcdfe7d6de781511edb5d91a4c45cf11e
|
||||
@@ -0,0 +1,35 @@
|
||||
# dsh-client-ui-agent-preset
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The agent-preset surface: one General-settings row choosing which [preset](../../preset/agent-presets/README.md) new sessions are composed from.
|
||||
|
||||
## Why it is a new-session preference
|
||||
|
||||
A session's preset is fixed when the session is created — the host refuses to adopt an existing session under a different one, because that session's history was produced under the first preset's tools. So this row cannot be a live switch, and it says so: changing it applies to sessions started afterwards while running sessions keep the composition they began with.
|
||||
|
||||
## What it reads and writes
|
||||
|
||||
Options and the current default both come from one `agentPreset.list` call. The roster already reports which id a session with no explicit choice gets, so the row needs no settings-schema introspection; the write targets the `agent-presets` settings namespace's `default` field, which is what the host resolves at creation.
|
||||
|
||||
A locally authored preset is exactly as privileged as the plugins it names, so the list marks `user` rows rather than presenting every preset as shipped and vetted.
|
||||
|
||||
The row re-reads on `settings/changed` for its own namespace and on `connection/reset`: the roster is a live directory and the default is a settings field, so an external edit or a reconnect can both move it.
|
||||
|
||||
## When the row is absent
|
||||
|
||||
A deployment that composes no presets answers with an empty roster, and the row renders nothing — every session then shares the host composition, and there is nothing to choose between.
|
||||
|
||||
## Model Experience
|
||||
|
||||
Indirectly, through the preset a later session is composed from; [`dsh-agent-presets`](../../preset/agent-presets/README.md) owns what that composition puts in front of the model.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
No direct invalidation. Changing the default never touches a running session's prefix; a session created afterwards establishes its own prefix from its own composition.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **No per-session choice at creation** — this row sets the default only. The wire already carries `agentPreset` on `session.create`, so a session-start surface can offer the choice; that surface does not exist yet.
|
||||
- **Presets are listed by id** — a preset carries no display metadata, so the menu shows directory names.
|
||||
- **No authoring** — creating, editing, or deleting a preset is a filesystem act; this surface only chooses among what the roster supplies.
|
||||
@@ -0,0 +1,35 @@
|
||||
# dsh-client-ui-agent-preset
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
agent preset 表层:General 设置中的一行,用于选择新建会话据以组装的 [preset](../../preset/agent-presets/README.md)。
|
||||
|
||||
## 为什么它是"新建会话"的偏好设置
|
||||
|
||||
会话的 preset 在创建时即固定——宿主拒绝以不同 preset 接管已存在的会话,因为该会话的历史是在最初那份 preset 的工具下产生的。因此本行不可能是实时切换,它也如实说明了这一点:更改只对此后开启的会话生效,而运行中的会话保持它们开始时的组装。
|
||||
|
||||
## 它读什么、写什么
|
||||
|
||||
选项与当前默认值都来自同一次 `agentPreset.list` 调用。名单本身已经报告了"未显式选择的会话会得到哪个 id",因此本行无需对 settings schema 做内省;写入目标是 `agent-presets` settings 命名空间的 `default` 字段,也正是宿主在创建时解析的那个字段。
|
||||
|
||||
本地创作的 preset 的权限恰好等于它所引用的插件,因此列表会标注 `user` 行,而不是把每个 preset 都呈现为随附且已审核的。
|
||||
|
||||
本行在自身命名空间的 `settings/changed` 以及 `connection/reset` 时重新读取:名单是一个活动目录,默认值是一项设置,外部编辑与重新连接都可能改变它。
|
||||
|
||||
## 何时不显示本行
|
||||
|
||||
未组装任何 preset 的部署返回空名单,本行不渲染任何内容——此时每个会话共用宿主组装,也就无从选择。
|
||||
|
||||
## Model Experience
|
||||
|
||||
Indirectly, through the preset a later session is composed from; [`dsh-agent-presets`](../../preset/agent-presets/README.md) owns what that composition puts in front of the model.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
没有直接的失效影响。更改默认值绝不触及运行中会话的前缀;此后创建的会话依据它自己的组装建立自己的前缀。
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **创建时无法逐会话选择** —— 本行只设置默认值。wire 上 `session.create` 已经携带 `agentPreset`,因此会话开启表层可以提供该选择;该表层尚不存在。
|
||||
- **preset 按 id 列出** —— preset 不携带展示用元数据,因此菜单显示的是目录名。
|
||||
- **不提供创作能力** —— 创建、编辑或删除 preset 是文件系统行为;本表层只在名单提供的范围内做选择。
|
||||
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-agent-preset",
|
||||
"description": "Agent-preset surface: the default preset for later sessions, in General settings",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./client": {
|
||||
"types": "./lib/types/client/index.d.ts",
|
||||
"default": "./lib/client.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-connection",
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-runtime"
|
||||
],
|
||||
"platform": "web"
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
"watch": "tsdown --watch"
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-client-connection": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-locale": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@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-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@types/react": "~18.3.1",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/client.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/* Agent-preset row: title/description plus the preset selector pill. */
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 16px 0;
|
||||
border-bottom: 1px solid var(--dsw-alias-border-l2);
|
||||
}
|
||||
|
||||
.rowText {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding-right: 48px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 22px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.desc {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.selector {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
height: 36px;
|
||||
padding: 0 14px;
|
||||
border: none;
|
||||
border-radius: 18px;
|
||||
background: var(--dsw-alias-bg-module-platform);
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.selector:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.selector:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
flex: none;
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Agent-preset preference row: the preset new sessions are composed from.
|
||||
* A running session keeps the composition it began with, so this row never
|
||||
* disturbs work in progress.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { IconChevronDownOutline14, Menu } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { AgentPresetSettingsState } from './settings-store.ts'
|
||||
import type { AgentPresetSettingsKey } from './locales.ts'
|
||||
import css from './AgentPresetRow.module.css'
|
||||
|
||||
/** Registration-side business face for the host-backed preference. */
|
||||
export interface AgentPresetRowInjected {
|
||||
hooks: {
|
||||
/** Agent-preset settings snapshot bound by the renderer as useAgentPreset. */
|
||||
agentPreset: SnapshotStore<AgentPresetSettingsState>
|
||||
}
|
||||
/** Load the roster when the row first renders. */
|
||||
load: () => Promise<void>
|
||||
/** Persist one preset as the default for later sessions. */
|
||||
select: (id: string) => Promise<void>
|
||||
}
|
||||
|
||||
/** Full component props. */
|
||||
export type AgentPresetRowProps =
|
||||
PropsRuntime<'settings.general.item'>
|
||||
& PropsLocale<'settings.agentPreset'>
|
||||
& InjectFace<AgentPresetRowInjected>
|
||||
|
||||
/**
|
||||
* Render the new-session agent-preset selector.
|
||||
* @param props - composed slot props.
|
||||
* @returns the row, or null when the deployment composes no presets.
|
||||
*/
|
||||
export function AgentPresetRow({ load, select, useAgentPreset, t }: AgentPresetRowProps) {
|
||||
const state = useAgentPreset(snapshot => snapshot)
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
useEffect(() => {
|
||||
if (state.writable && state.status !== 'unavailable') return
|
||||
setOpen(false)
|
||||
}, [state.status, state.writable])
|
||||
|
||||
// A deployment that composes no presets has nothing to choose between, and
|
||||
// every session shares the host composition — the row simply does not exist.
|
||||
if (state.status === 'unavailable') return null
|
||||
const busy = state.status === 'loading' || state.status === 'saving'
|
||||
const label = state.currentValue === '' ? t('loading') : state.currentValue
|
||||
const description: string = state.error ?? t('description')
|
||||
|
||||
return (
|
||||
<div className={css.row}>
|
||||
<div className={css.rowText}>
|
||||
<div className={css.title}>{t('title')}</div>
|
||||
<div className={css.desc} role={state.error === null ? undefined : 'alert'}>{description}</div>
|
||||
</div>
|
||||
<Menu
|
||||
open={open}
|
||||
onClose={() => { setOpen(false) }}
|
||||
// A locally authored preset is exactly as privileged as the plugins it
|
||||
// names, so the list says which rows are local rather than presenting
|
||||
// every preset as shipped and vetted.
|
||||
items={state.options.map(option => ({
|
||||
id: option.id,
|
||||
label: option.trust === 'user' ? `${option.id} · ${t('userTrust')}` : option.id,
|
||||
}))}
|
||||
selectedId={state.currentValue}
|
||||
onSelect={(id) => {
|
||||
setOpen(false)
|
||||
void select(id)
|
||||
}}
|
||||
align="end"
|
||||
portal
|
||||
anchor={(
|
||||
<button
|
||||
type="button"
|
||||
className={css.selector}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
disabled={busy || !state.writable || state.options.length === 0}
|
||||
onClick={() => { setOpen(value => !value) }}
|
||||
>
|
||||
{label}
|
||||
<IconChevronDownOutline14 className={css.chevron} />
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface LocaleNamespaceMap {
|
||||
/** Agent-preset row copy. */
|
||||
'settings.agentPreset': AgentPresetSettingsKey
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Agent-preset surface plugin, browser half — one General-settings row that
|
||||
* writes the default preset for sessions created later.
|
||||
*
|
||||
* A running session keeps the composition it began with (the host refuses to
|
||||
* adopt an existing session under a different preset), so this row is a
|
||||
* new-session preference rather than a live switch. Per-session choice at
|
||||
* creation time belongs to the session-start surface, which reads the same
|
||||
* roster.
|
||||
*/
|
||||
|
||||
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'
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { AgentPresetRow } from './AgentPresetRow.tsx'
|
||||
import type { AgentPresetRowInjected } from './AgentPresetRow.tsx'
|
||||
import { en, zh } from './locales.ts'
|
||||
import { AGENT_PRESET_SETTINGS_NS, AgentPresetSettingsController } from './settings-store.ts'
|
||||
|
||||
export type { AgentPresetRowInjected, AgentPresetRowProps } from './AgentPresetRow.tsx'
|
||||
export type { AgentPresetOption, AgentPresetSettingsState } from './settings-store.ts'
|
||||
export { AGENT_PRESET_SETTINGS_NS } from './settings-store.ts'
|
||||
|
||||
/** Required services (cordis fiber inject). */
|
||||
export const inject = ['slots', 'locale', 'connection']
|
||||
|
||||
/**
|
||||
* Mount the General-settings row.
|
||||
* @param ctx - the browser plugin context.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
const controller = new AgentPresetSettingsController((ctx.get('connection') as ConnectionHandle).api)
|
||||
|
||||
ctx.effect(() => ctx.locale.register('settings.agentPreset', { zh, en }), 'ui-agent-preset: settings row dictionaries')
|
||||
|
||||
const injected = (): AgentPresetRowInjected => ({
|
||||
hooks: { agentPreset: controller.store },
|
||||
load: () => controller.load(),
|
||||
select: (id: string) => controller.select(id),
|
||||
})
|
||||
|
||||
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
|
||||
void controller.load()
|
||||
}
|
||||
const disposers = [
|
||||
ctx.on('settings/changed', refresh),
|
||||
ctx.on('connection/reset', () => { refresh() }),
|
||||
]
|
||||
return () => { for (const dispose of disposers) dispose() }
|
||||
}, 'ui-agent-preset: settings refresh')
|
||||
|
||||
ctx.slots.inject('settings.general.item', () => ctx.slots.register({
|
||||
name: 'settings.general.item',
|
||||
id: 'agent-preset',
|
||||
order: -25,
|
||||
locale: 'settings.agentPreset',
|
||||
inject: injected,
|
||||
}, AgentPresetRow))
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/** Locale bundles for the agent-preset General-settings row. */
|
||||
|
||||
/** Locale keys this row renders. */
|
||||
export type AgentPresetSettingsKey =
|
||||
| 'title' | 'description' | 'loading' | 'error' | 'userTrust'
|
||||
|
||||
/** English copy. */
|
||||
export const en: Record<AgentPresetSettingsKey, string> = {
|
||||
title: 'Agent preset',
|
||||
description: 'Applies to sessions you start from now on. Running sessions keep the preset they began with.',
|
||||
loading: 'Loading presets…',
|
||||
error: 'Could not load agent presets.',
|
||||
userTrust: 'Local',
|
||||
}
|
||||
|
||||
/** Simplified Chinese copy. */
|
||||
export const zh: Record<AgentPresetSettingsKey, string> = {
|
||||
title: 'Agent preset',
|
||||
description: '对此后新建的会话生效。运行中的会话保持它开始时的 preset。',
|
||||
loading: '正在加载 preset…',
|
||||
error: '无法加载 agent preset。',
|
||||
userTrust: '本地',
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Agent-preset default-settings controller.
|
||||
*
|
||||
* Options and the current default both come from one `agentPreset.list` call:
|
||||
* the roster already reports which id a session with no explicit choice gets,
|
||||
* so the row needs no schema introspection. Writes target the settings
|
||||
* namespace's `default` field, which is what the host resolves at creation.
|
||||
*/
|
||||
|
||||
import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
/** The agent-preset settings namespace on the host wire. */
|
||||
export const AGENT_PRESET_SETTINGS_NS = 'agent-presets'
|
||||
|
||||
/** One selectable preset. */
|
||||
export interface AgentPresetOption {
|
||||
/** Preset id, written to Settings and shown as the label. */
|
||||
id: string
|
||||
/** Whether the preset ships with the deployment or was authored locally. */
|
||||
trust: 'system' | 'user'
|
||||
}
|
||||
|
||||
/** Agent-preset settings-row snapshot. */
|
||||
export interface AgentPresetSettingsState {
|
||||
status: 'idle' | 'loading' | 'ready' | 'saving' | 'unavailable' | 'error'
|
||||
error: string | null
|
||||
writable: boolean
|
||||
currentValue: string
|
||||
options: readonly AgentPresetOption[]
|
||||
}
|
||||
|
||||
const INITIAL: AgentPresetSettingsState = {
|
||||
status: 'idle',
|
||||
error: null,
|
||||
writable: true,
|
||||
currentValue: '',
|
||||
options: [],
|
||||
}
|
||||
|
||||
/** Reads the roster and persists the chosen default. */
|
||||
export class AgentPresetSettingsController {
|
||||
/** Row snapshot the renderer subscribes to. */
|
||||
readonly store: SnapshotStore<AgentPresetSettingsState> = createSnapshotStore(INITIAL)
|
||||
|
||||
constructor(private readonly api: IApiClient) {}
|
||||
|
||||
private set(patch: Partial<AgentPresetSettingsState>): void {
|
||||
this.store.set({ ...this.store.getSnapshot(), ...patch })
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the roster. An empty roster means the deployment composes no
|
||||
* presets, which is a valid deployment rather than a failure — the row
|
||||
* reports `unavailable` and renders nothing.
|
||||
* @returns once the snapshot reflects the host.
|
||||
*/
|
||||
async load(): Promise<void> {
|
||||
if (this.store.getSnapshot().status === 'loading') return
|
||||
this.set({ status: 'loading', error: null })
|
||||
try {
|
||||
const response = await this.api.agentPresets.list({})
|
||||
if (!response.result.ok) {
|
||||
this.set({ status: 'error', error: response.result.error.message })
|
||||
return
|
||||
}
|
||||
const presets = response.result.value.presets
|
||||
if (presets.length === 0) {
|
||||
this.set({ status: 'unavailable', options: [], currentValue: '' })
|
||||
return
|
||||
}
|
||||
this.set({
|
||||
status: 'ready',
|
||||
error: null,
|
||||
options: presets.map(preset => ({ id: preset.id, trust: preset.trust })),
|
||||
currentValue: presets.find(preset => preset.isDefault)?.id ?? presets[0]?.id ?? '',
|
||||
})
|
||||
} catch (error) {
|
||||
this.set({ status: 'error', error: error instanceof Error ? error.message : String(error) })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist one preset as the default for sessions created later. Running
|
||||
* sessions keep the composition they were created with, so this never
|
||||
* disturbs work in progress.
|
||||
* @param id - the preset to make default.
|
||||
* @returns once the write settled and the roster was re-read.
|
||||
*/
|
||||
async select(id: string): Promise<void> {
|
||||
const before = this.store.getSnapshot()
|
||||
if (before.status === 'saving' || id === before.currentValue) return
|
||||
this.set({ status: 'saving', error: null, currentValue: id })
|
||||
try {
|
||||
const response = await this.api.settings.update({
|
||||
ns: AGENT_PRESET_SETTINGS_NS,
|
||||
patch: { default: id },
|
||||
})
|
||||
if (!response.result.ok) {
|
||||
this.set({ status: 'ready', currentValue: before.currentValue, error: response.result.error.message })
|
||||
return
|
||||
}
|
||||
// Re-read rather than trust the patch: the host resolves the default
|
||||
// through the same roster the row displays.
|
||||
await this.load()
|
||||
} catch (error) {
|
||||
this.set({
|
||||
status: 'ready',
|
||||
currentValue: before.currentValue,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
declare module '*.module.css' {
|
||||
const classes: Record<string, string>
|
||||
export default classes
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Agent-preset surface plugin, node half. The empty apply exists so the plugin
|
||||
* appears in the host cordis.yml / Loader; the browser half ships the
|
||||
* General-settings row through exports["./client"], discovered from the
|
||||
* package.json dshClient declaration.
|
||||
*/
|
||||
|
||||
/** Host plugin body — no host-side behavior for this surface plugin. */
|
||||
export function apply(): void {}
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-agent-preset`.
|
||||
* @module @deepseek-ai/dsh-client-ui-agent-preset/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-agent-preset'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'client-ui-agent-preset-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this is a browser-side surface plugin whose node half owns no event stream
|
||||
* or mutable runtime data; the roster and the settings write are host contracts covered there.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* The agent-preset settings controller: it derives both the options and the
|
||||
* current default from one roster call, writes only the `default` field, and
|
||||
* treats an empty roster as "this deployment composes no presets" rather than
|
||||
* as a failure.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import {
|
||||
AGENT_PRESET_SETTINGS_NS, AgentPresetSettingsController,
|
||||
} from '../src/client/settings-store.ts'
|
||||
|
||||
interface Recorded { ns: string; patch: unknown }
|
||||
|
||||
/** A client whose roster and write outcome the test controls. */
|
||||
function fakeApi(
|
||||
presets: { id: string; trust: 'system' | 'user'; isDefault: boolean }[],
|
||||
options: { writes?: Recorded[]; failWrite?: string; failList?: string } = {},
|
||||
): IApiClient {
|
||||
return {
|
||||
agentPresets: {
|
||||
list: () => Promise.resolve(options.failList === undefined
|
||||
? { rpcId: 'r', result: { ok: true as const, value: { presets } } }
|
||||
: { rpcId: 'r', result: { ok: false as const, error: { code: 'internal', message: options.failList, details: {} } } }),
|
||||
},
|
||||
settings: {
|
||||
update: (payload: { ns: string; patch: unknown }) => {
|
||||
options.writes?.push({ ns: payload.ns, patch: payload.patch })
|
||||
if (options.failWrite !== undefined) {
|
||||
return Promise.resolve({ rpcId: 'r', result: { ok: false as const, error: { code: 'internal', message: options.failWrite, details: {} } } })
|
||||
}
|
||||
// A committed write moves the roster's default, exactly as the host does.
|
||||
for (const preset of presets) {
|
||||
preset.isDefault = preset.id === (payload.patch as { default?: string }).default
|
||||
}
|
||||
return Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: {} } })
|
||||
},
|
||||
},
|
||||
} as unknown as IApiClient
|
||||
}
|
||||
|
||||
describe('the agent-preset settings controller', () => {
|
||||
it('derives options and the current default from one roster call', async () => {
|
||||
const controller = new AgentPresetSettingsController(fakeApi([
|
||||
{ id: 'standard', trust: 'system', isDefault: true },
|
||||
{ id: 'mine', trust: 'user', isDefault: false },
|
||||
]))
|
||||
|
||||
await controller.load()
|
||||
|
||||
const state = controller.store.getSnapshot()
|
||||
expect(state.status).toBe('ready')
|
||||
expect(state.currentValue).toBe('standard')
|
||||
expect(state.options).toEqual([
|
||||
{ id: 'standard', trust: 'system' },
|
||||
{ id: 'mine', trust: 'user' },
|
||||
])
|
||||
})
|
||||
|
||||
it('reports an empty roster as unavailable, not as an error', async () => {
|
||||
const controller = new AgentPresetSettingsController(fakeApi([]))
|
||||
|
||||
await controller.load()
|
||||
|
||||
// A deployment composing no presets is valid: every session shares the
|
||||
// host composition and the row renders nothing.
|
||||
expect(controller.store.getSnapshot().status).toBe('unavailable')
|
||||
expect(controller.store.getSnapshot().error).toBeNull()
|
||||
})
|
||||
|
||||
it('writes only the default field, into the agent-presets namespace', async () => {
|
||||
const writes: Recorded[] = []
|
||||
const controller = new AgentPresetSettingsController(fakeApi([
|
||||
{ id: 'standard', trust: 'system', isDefault: true },
|
||||
{ id: 'core-web', trust: 'system', isDefault: false },
|
||||
], { writes }))
|
||||
await controller.load()
|
||||
|
||||
await controller.select('core-web')
|
||||
|
||||
expect(writes).toEqual([{ ns: AGENT_PRESET_SETTINGS_NS, patch: { default: 'core-web' } }])
|
||||
expect(controller.store.getSnapshot().currentValue).toBe('core-web')
|
||||
})
|
||||
|
||||
it('restores the previous value and surfaces the message when the write fails', async () => {
|
||||
const controller = new AgentPresetSettingsController(fakeApi([
|
||||
{ id: 'standard', trust: 'system', isDefault: true },
|
||||
{ id: 'core-web', trust: 'system', isDefault: false },
|
||||
], { failWrite: 'read-only settings' }))
|
||||
await controller.load()
|
||||
|
||||
await controller.select('core-web')
|
||||
|
||||
const state = controller.store.getSnapshot()
|
||||
expect(state.currentValue).toBe('standard')
|
||||
expect(state.error).toBe('read-only settings')
|
||||
expect(state.status).toBe('ready')
|
||||
})
|
||||
|
||||
it('ignores a pick that is already the default', async () => {
|
||||
const writes: Recorded[] = []
|
||||
const controller = new AgentPresetSettingsController(fakeApi([
|
||||
{ id: 'standard', trust: 'system', isDefault: true },
|
||||
], { writes }))
|
||||
await controller.load()
|
||||
|
||||
await controller.select('standard')
|
||||
|
||||
expect(writes).toEqual([])
|
||||
})
|
||||
|
||||
it('surfaces a roster failure without claiming the deployment has no presets', async () => {
|
||||
const controller = new AgentPresetSettingsController(fakeApi([], { failList: 'host down' }))
|
||||
|
||||
await controller.load()
|
||||
|
||||
const state = controller.store.getSnapshot()
|
||||
expect(state.status).toBe('error')
|
||||
expect(state.error).toBe('host down')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.client.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../connection"
|
||||
},
|
||||
{
|
||||
"path": "../locale"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../runtime"
|
||||
},
|
||||
{
|
||||
"path": "../ui-primitives"
|
||||
},
|
||||
{
|
||||
"path": "../ui-slots"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { clientBundle } from '../tsdown.client.ts'
|
||||
|
||||
export default clientBundle('@deepseek-ai/dsh-client-ui-agent-preset', ['lib/types/index.js', 'lib/types/invariant.js'])
|
||||
Generated
+33
@@ -146,6 +146,9 @@ importers:
|
||||
'@deepseek-ai/dsh-base':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/bundle/base
|
||||
'@deepseek-ai/dsh-client-ui-agent-preset':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/client/ui-agent-preset
|
||||
'@deepseek-ai/dsh-headless':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/bundle/headless
|
||||
@@ -1432,6 +1435,36 @@ importers:
|
||||
specifier: ^18.2.0
|
||||
version: 18.3.1(react@18.3.1)
|
||||
|
||||
packages/client/ui-agent-preset:
|
||||
devDependencies:
|
||||
'@deepseek-ai/dsh-client-connection':
|
||||
specifier: workspace:^
|
||||
version: link:../connection
|
||||
'@deepseek-ai/dsh-client-locale':
|
||||
specifier: workspace:^
|
||||
version: link:../locale
|
||||
'@deepseek-ai/dsh-client-runtime':
|
||||
specifier: workspace:^
|
||||
version: link:../runtime
|
||||
'@deepseek-ai/dsh-client-ui-primitives':
|
||||
specifier: workspace:^
|
||||
version: link:../ui-primitives
|
||||
'@deepseek-ai/dsh-client-ui-slots':
|
||||
specifier: workspace:^
|
||||
version: link:../ui-slots
|
||||
'@deepseek-ai/dsh-invariants':
|
||||
specifier: workspace:^
|
||||
version: link:../../support/invariants
|
||||
'@types/react':
|
||||
specifier: ~18.3.1
|
||||
version: 18.3.31
|
||||
cordis:
|
||||
specifier: ^4.0.0-rc.7
|
||||
version: link:../../../vendor/cordis
|
||||
react:
|
||||
specifier: ^18.2.0
|
||||
version: 18.3.1
|
||||
|
||||
packages/client/ui-command:
|
||||
dependencies:
|
||||
clsx:
|
||||
|
||||
@@ -47,6 +47,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
|
||||
'packages/bash/pwsh-local': { kind: 'indirect', reason: 'The executor backend delegates model rendering to dsh-tool-pwsh.' },
|
||||
'packages/code-runtime/code-runtime': { kind: 'indirect', reason: 'The service interface delegates model rendering to Code Mode in dsh-tools.' },
|
||||
'packages/code-runtime/code-runtime-worker': { kind: 'indirect', reason: 'The worker backend delegates model rendering to Code Mode in dsh-tools.' },
|
||||
'packages/client/ui-agent-preset': { kind: 'indirect', reason: 'Browser-side settings row; the preset it selects owns every model-facing effect.' },
|
||||
'packages/preset/agent-presets': { kind: 'indirect', reason: 'The mount installs a preset\'s own plugins, which own every model-facing registration it makes visible.' },
|
||||
'packages/typert/registry': { kind: 'none', reason: 'Runtime type registry; consumers (cordis_inspect, wire faces, gates) own any model-visible projection of registry contents.' },
|
||||
'packages/typert/loader': { kind: 'none', reason: 'Loader integration only registers generated artifacts; consumers own any model-visible projection.' },
|
||||
|
||||
@@ -157,6 +157,7 @@
|
||||
"@deepseek-ai/dsh-client-ui-command": ["./packages/client/ui-command/src"],
|
||||
"@deepseek-ai/dsh-client-ui-model": ["./packages/client/ui-model/src"],
|
||||
"@deepseek-ai/dsh-client-ui-goal": ["./packages/client/ui-goal/src"],
|
||||
"@deepseek-ai/dsh-client-ui-agent-preset": ["./packages/client/ui-agent-preset/src"],
|
||||
"@deepseek-ai/dsh-client-ui-permission": ["./packages/client/ui-permission/src"],
|
||||
"@deepseek-ai/dsh-client-ui-skill": ["./packages/client/ui-skill/src"],
|
||||
"@deepseek-ai/dsh-client-ui-subagent": ["./packages/client/ui-subagent/src"],
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
{ "path": "./packages/client/ui-subagent" },
|
||||
{ "path": "./packages/client/ui-goal" },
|
||||
{ "path": "./packages/client/ui-model" },
|
||||
{ "path": "./packages/client/ui-agent-preset" },
|
||||
{ "path": "./packages/client/ui-permission" },
|
||||
{ "path": "./packages/client/ui-plan" },
|
||||
{ "path": "./packages/client/ui-question" },
|
||||
|
||||
Reference in New Issue
Block a user