Merge branch 'stack/agent-profiles-8-authoring' into stack/agent-profiles-9-rename

This commit is contained in:
Yichen Jiang
2026-08-07 11:19:53 +08:00
6 changed files with 45 additions and 25 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: 6261984835a70b6b669545acc8f9bfafc93ab64d
README.zh.md: fb860812b1646f6b1912e4aedf56b00696957352
README.md: c9aa370ebb7612cdc18bbe3d63c85cbb7097f69b
README.zh.md: 707745c547f2172abb4d11cda09f3de1c5c1ec78

View File

@@ -32,7 +32,7 @@ The row re-reads on `settings/changed` for its own namespace and on `connection/
A fourth 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.
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. **Duplicate** copies any row, because a copy always lands in the local root regardless of where the text came from; **New preset** starts blank, since copying is already offered on the row being copied and a composition nobody named is text the author has to recognise as unwanted before deleting it.
An id becomes a directory name, so the editor mirrors the host's own containment rule (`[a-z0-9][a-z0-9-]*`) and refuses a name already in use — a create landing on an existing name would overwrite a preset the user never opened. Both checks are conveniences: the host re-applies them, along with the composition's shape, and its answer is what the editor reports on failure. A save that parses is still only a save; a composition naming a plugin that does not exist fails at the next session that selects it.

View File

@@ -32,7 +32,7 @@ chip 以部署默认值打开,其选择是**暂存**的——该界面先于
第四个表层,独立的设置页(`settings.section`id 为 `agent-presets`,排在「模型」之后——选模型是日常操作,而组装 agent 是它背后那件塑造部署形态的事):名单以行呈现,同一时刻有一份组装在 YAML 编辑器中打开。
随部署提供的 preset 以只读方式打开。它是本地 preset 据以编写的已知良好组装,因此能读到它正是意义所在,而覆写它则不是——部署自带的那一份正是用来对照有问题的本地 preset 的。因此创作从复制开始:**新建 preset** 复制当前默认值,**复制**则复制任意一行;无论文本来自何处,副本总是落在本地根目录,所以副本总是可写的
随部署提供的 preset 以只读方式打开。它是本地 preset 据以编写的已知良好组装,因此能读到它正是意义所在,而覆写它则不是——部署自带的那一份正是用来对照有问题的本地 preset 的。**复制**复制任意一行;无论文本来自何处,副本总是落在本地根目录,所以副本总是可写的。**新建 preset** 则从空白开始——复制这件事已经由被复制那一行自己提供,而一份没人指名的组装,只会让作者先认出它不是自己想要的、再把它删掉
id 会成为目录名,因此编辑器复刻宿主自身的约束规则(`[a-z0-9][a-z0-9-]*`),并拒绝已被占用的名称——新建若落在已存在的名称上,就会覆盖用户从未打开过的 preset。这两项检查只是便利宿主会连同组装的形状一起重新校验失败时编辑器报告的正是宿主的答复。能解析的保存也仅仅是保存引用了不存在插件的组装会在下一个选择它的会话处失败。

View File

@@ -82,7 +82,9 @@ function Editor({ draft, blocker, t, actions }: EditorProps): ReactNode {
placeholder={t('presetIdPlaceholder')}
onChange={(event) => { actions.setId(event.target.value) }}
/>
<span className={css.hint}>{`${t('copyOf')} ${draft.source}`}</span>
{draft.source === undefined
? null
: <span className={css.hint}>{`${t('copyOf')} ${draft.source}`}</span>}
</label>
)
: null}
@@ -195,8 +197,8 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode {
</button>
<span className={css.editorTitle}>
{draft.creating
? `${t('newPreset')} · ${t('copyOf')} ${draft.source}`
: `${draft.writable ? t('edit') : t('view')} · ${draft.name === '' ? draft.source : draft.name}`}
? (draft.source === undefined ? t('newPreset') : `${t('newPreset')} · ${t('copyOf')} ${draft.source}`)
: `${draft.writable ? t('edit') : t('view')} · ${draft.name === '' ? draft.id : draft.name}`}
</span>
</div>
<Editor draft={draft} blocker={blocker} t={t} actions={editorActions} />

View File

@@ -35,9 +35,10 @@ export interface PresetDraft {
id: string
/**
* The preset the text came from, shown while a new preset is unnamed so
* the editor can say what it is a copy of.
* the editor can say what it is a copy of. Absent on a preset started
* blank, which is a copy of nothing.
*/
source: string
source?: string
/** Whether saving creates a preset rather than replacing one. */
creating: boolean
/** Composition text being edited. */
@@ -169,17 +170,32 @@ export class AgentPresetSectionController {
}
/**
* Open a copy of one preset as a new, unnamed preset. With no argument the
* copy is taken from the default preset, which is the composition a new
* session gets and therefore the one worth starting from.
* @param from - the preset to copy, or undefined for the default.
* Open a new, unnamed preset. With no argument it starts blank; copying is
* its own action, offered on the row being copied, so the two arrive at the
* same editor by the route the author actually chose. Starting from some
* preset nobody named would put text in the editor that the author has to
* recognise as unwanted before deleting it.
* @param from - the preset to copy, or undefined to start blank.
* @returns once the composition loaded or the failure is on the page.
*/
async createFrom(from?: string): Promise<void> {
const source = from ?? this.store.getSnapshot().rows.find(row => row.isDefault)?.id
/* v8 ignore next -- the section only renders with a roster, and every roster marks a default */
if (source === undefined) return
await this.openDraft(source, true)
if (from === undefined) {
this.set({
error: null,
draft: {
id: '',
creating: true,
content: '',
writable: true,
name: '',
description: '',
saving: false,
error: null,
},
})
return
}
await this.openDraft(from, true)
}
private async openDraft(source: string, creating: boolean): Promise<void> {

View File

@@ -239,16 +239,18 @@ describe('opening a composition', () => {
})
describe('creating a preset', () => {
it('copies the default composition when no source is named', async () => {
const { controller } = harness()
it('starts blank when no source is named', async () => {
const { controller, calls } = harness()
await controller.load()
const before = calls.length
await controller.createFrom()
// The default is the composition a new session gets, so it is the one
// worth starting from — and a copy is writable wherever it came from.
expect(draftOf(controller)).toMatchObject({ id: '', source: 'standard', creating: true, writable: true })
expect(draftOf(controller).content).toBe('- id: tool-bash\n')
// Copying is its own action on the row being copied, so this one is a copy
// of nothing: no source to name, and no read to make.
expect(draftOf(controller)).toMatchObject({ id: '', creating: true, writable: true, content: '' })
expect(draftOf(controller).source).toBeUndefined()
expect(calls).toHaveLength(before)
})
it('copies a named preset', async () => {
@@ -260,12 +262,12 @@ describe('creating a preset', () => {
expect(draftOf(controller)).toMatchObject({ source: 'mine', creating: true, content: '- id: tool-read\n' })
})
it('does nothing before the roster loaded', async () => {
it('opens the blank editor without the roster, which it no longer reads', async () => {
const { controller, calls } = harness()
await controller.createFrom()
expect(controller.store.getSnapshot().draft).toBeNull()
expect(draftOf(controller)).toMatchObject({ id: '', creating: true, content: '' })
expect(calls).toHaveLength(0)
})
})