diff --git a/packages/client/ui-agent-preset/README.i18n.yaml b/packages/client/ui-agent-preset/README.i18n.yaml index 80d6a464cc..0e6ce1245b 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: 6261984835a70b6b669545acc8f9bfafc93ab64d -README.zh.md: fb860812b1646f6b1912e4aedf56b00696957352 +README.md: c9aa370ebb7612cdc18bbe3d63c85cbb7097f69b +README.zh.md: 707745c547f2172abb4d11cda09f3de1c5c1ec78 diff --git a/packages/client/ui-agent-preset/README.md b/packages/client/ui-agent-preset/README.md index 6261984835..c9aa370ebb 100644 --- a/packages/client/ui-agent-preset/README.md +++ b/packages/client/ui-agent-preset/README.md @@ -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. diff --git a/packages/client/ui-agent-preset/README.zh.md b/packages/client/ui-agent-preset/README.zh.md index fb860812b1..707745c547 100644 --- a/packages/client/ui-agent-preset/README.zh.md +++ b/packages/client/ui-agent-preset/README.zh.md @@ -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。这两项检查只是便利:宿主会连同组装的形状一起重新校验,失败时编辑器报告的正是宿主的答复。能解析的保存也仅仅是保存;引用了不存在插件的组装,会在下一个选择它的会话处失败。 diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx b/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx index da9eac9f39..dbd6ff05d0 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx @@ -82,7 +82,9 @@ function Editor({ draft, blocker, t, actions }: EditorProps): ReactNode { placeholder={t('presetIdPlaceholder')} onChange={(event) => { actions.setId(event.target.value) }} /> - {`${t('copyOf')} ${draft.source}`} + {draft.source === undefined + ? null + : {`${t('copyOf')} ${draft.source}`}} ) : null} @@ -195,8 +197,8 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode { {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}`} diff --git a/packages/client/ui-agent-preset/src/client/section-store.ts b/packages/client/ui-agent-preset/src/client/section-store.ts index f57c502f52..15adb17e60 100644 --- a/packages/client/ui-agent-preset/src/client/section-store.ts +++ b/packages/client/ui-agent-preset/src/client/section-store.ts @@ -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 { - 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 { diff --git a/packages/client/ui-agent-preset/tests/section-store.spec.ts b/packages/client/ui-agent-preset/tests/section-store.spec.ts index 67672b3ef1..31d2f80378 100644 --- a/packages/client/ui-agent-preset/tests/section-store.spec.ts +++ b/packages/client/ui-agent-preset/tests/section-store.spec.ts @@ -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) }) })