feat(agent-presets): give a preset a name and a description
A picker showed directory names, so the settings page could only ever list `standard` / `core-web` / `cordis` and hope the reader knew what they meant. A preset may now publish display text in an optional `preset.yml` beside its composition, and the section renders cards — name, description, and the one in use — instead of rows. The file carries display text ONLY. `id` is the directory name and `trust` comes from the root a preset was discovered under, so neither is writable there: otherwise a locally authored preset could name itself into the shipped set. It is a separate file because a composition is a top-level list of plugin rows — YAML cannot carry sibling keys beside it, and a fake metadata row would hand the Loader something to load. Every read failure degrades to no metadata; absent, malformed, wrongly typed, and blank all mean the same thing and the picker falls back to the id. Presentation is not capability: a preset whose name is broken still mounts. The editor gained name and description fields above the YAML, and clearing both removes the file rather than storing a blank name.
This commit is contained in:
2
apps/cli/config/agent-presets/cordis/preset.yml
Normal file
2
apps/cli/config/agent-presets/cordis/preset.yml
Normal file
@@ -0,0 +1,2 @@
|
||||
name: 创造模式
|
||||
description: 标准模式加上自指工具集,可以读改自己运行的这套组装,并据此创作新的预设。
|
||||
2
apps/cli/config/agent-presets/core-web/profile.yml
Normal file
2
apps/cli/config/agent-presets/core-web/profile.yml
Normal file
@@ -0,0 +1,2 @@
|
||||
name: 极简模式
|
||||
description: 只向模型呈现 bash 与 str_replace_editor,适合 benchmark 与最小复现。
|
||||
2
apps/cli/config/agent-presets/standard/preset.yml
Normal file
2
apps/cli/config/agent-presets/standard/preset.yml
Normal file
@@ -0,0 +1,2 @@
|
||||
name: 标准模式
|
||||
description: 完整的编码 agent:文件读写、shell、检索、计划、委派与工作流。
|
||||
@@ -139,7 +139,7 @@ export interface PresetRoot {
|
||||
export type PresetTrust = 'system' | 'user'
|
||||
```
|
||||
|
||||
Source: [`packages/preset/agent-presets/src/types.ts:29`](../packages/preset/agent-presets/src/types.ts)
|
||||
Source: [`packages/preset/agent-presets/src/types.ts:33`](../packages/preset/agent-presets/src/types.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-agent-spine-demo`
|
||||
|
||||
|
||||
@@ -96,10 +96,11 @@ async read(id: string): Promise<string>
|
||||
* names a missing plugin still fails at the next session that selects it.
|
||||
* @param id - the profile id, which becomes its directory name.
|
||||
* @param content - the composition text.
|
||||
* @param metadata - display name and description; clearing both removes the file.
|
||||
* @throws when the id is unusable, the text is not an entry list, or the
|
||||
* deployment configures no writable root.
|
||||
*/
|
||||
async write(id: string, content: string): Promise<void>
|
||||
async write(id: string, content: string, metadata: PresetMetadata = {}): Promise<void>
|
||||
|
||||
/**
|
||||
* Delete a locally authored profile.
|
||||
@@ -145,7 +146,7 @@ serviceFor<K extends string & keyof Context>(agent: { ctx: Context }, name: K):
|
||||
async recompose(agentCtx: Context, id: string): Promise<AgentPreset>
|
||||
```
|
||||
|
||||
Source: [`packages/preset/agent-presets/src/index.ts:63`](../../packages/preset/agent-presets/src/index.ts)
|
||||
Source: [`packages/preset/agent-presets/src/index.ts:67`](../../packages/preset/agent-presets/src/index.ts)
|
||||
|
||||
## `ctx.agents` — `AgentRegistry`
|
||||
|
||||
|
||||
@@ -29,35 +29,77 @@
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.rows {
|
||||
/* Cards, not rows: a preset is a thing you pick, and the description is the
|
||||
part that tells them apart — a row would bury it beside the actions. */
|
||||
.cards {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(232px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.rowCard {
|
||||
.card {
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 12px;
|
||||
padding: 12px 14px;
|
||||
padding: 14px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
gap: 8px;
|
||||
background: var(--dsw-alias-bg-layer-3);
|
||||
}
|
||||
|
||||
.rowHead {
|
||||
/* The default preset is the one in use; it reads as selected rather than
|
||||
merely badged. */
|
||||
.cardActive {
|
||||
background: var(--dsw-alias-bg-layer-2);
|
||||
border-color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.cardHead {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.cardName {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.inUse {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cardDesc {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.cardMeta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.rowName {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
.cardId {
|
||||
font-family: var(--dsw-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
||||
font-size: 11px;
|
||||
color: var(--dsw-alias-label-dimmed);
|
||||
}
|
||||
|
||||
.cardFoot {
|
||||
display: flex;
|
||||
padding-top: 4px;
|
||||
border-top: 1px solid var(--dsw-alias-border-l2);
|
||||
}
|
||||
|
||||
.badge,
|
||||
@@ -80,30 +122,38 @@
|
||||
|
||||
.rowActions {
|
||||
display: inline-flex;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.secondaryButton {
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 999px;
|
||||
padding: 6px 14px;
|
||||
background: var(--dsw-alias-bg-layer-3);
|
||||
color: inherit;
|
||||
border: none;
|
||||
border-radius: 7px;
|
||||
padding: 5px 8px;
|
||||
background: none;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
font-size: 12.5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dangerButton {
|
||||
border: none;
|
||||
border-radius: 7px;
|
||||
padding: 5px 8px;
|
||||
background: none;
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
font-size: 12.5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.secondaryButton:hover:not(:disabled),
|
||||
.dangerButton:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
}
|
||||
|
||||
.secondaryButton:disabled,
|
||||
.dangerButton:disabled,
|
||||
.addButton:disabled {
|
||||
|
||||
@@ -35,6 +35,10 @@ export interface AgentPresetSectionInjected {
|
||||
setId: (id: string) => void
|
||||
/** Replace the draft's composition text. */
|
||||
setContent: (content: string) => void
|
||||
/** Rename the draft. */
|
||||
setName: (name: string) => void
|
||||
/** Replace the draft's description. */
|
||||
setDescription: (description: string) => void
|
||||
/** Save the open draft. */
|
||||
save: () => Promise<void>
|
||||
/** Ask for delete confirmation, or dismiss it with null. */
|
||||
@@ -56,7 +60,8 @@ interface EditorProps {
|
||||
draft: PresetDraft
|
||||
blocker: ReturnType<typeof draftBlocker>
|
||||
t: (key: AgentPresetSettingsKey) => string
|
||||
actions: Pick<AgentPresetSectionInjected, 'close' | 'save' | 'setContent' | 'setId'>
|
||||
actions: Pick<AgentPresetSectionInjected,
|
||||
'close' | 'save' | 'setContent' | 'setDescription' | 'setId' | 'setName'>
|
||||
}
|
||||
|
||||
function Editor({ draft, blocker, t, actions }: EditorProps): ReactNode {
|
||||
@@ -66,19 +71,44 @@ function Editor({ draft, blocker, t, actions }: EditorProps): ReactNode {
|
||||
{draft.creating
|
||||
? (
|
||||
<label className={css.field}>
|
||||
<span className={css.fieldLabel}>{t('presetName')}</span>
|
||||
<span className={css.fieldLabel}>{t('presetId')}</span>
|
||||
<input
|
||||
className={css.input}
|
||||
value={draft.id}
|
||||
autoFocus
|
||||
spellCheck={false}
|
||||
placeholder={t('presetNamePlaceholder')}
|
||||
placeholder={t('presetIdPlaceholder')}
|
||||
onChange={(event) => { actions.setId(event.target.value) }}
|
||||
/>
|
||||
<span className={css.hint}>{`${t('copyOf')} ${draft.source}`}</span>
|
||||
</label>
|
||||
)
|
||||
: null}
|
||||
{draft.writable
|
||||
? (
|
||||
<>
|
||||
<label className={css.field}>
|
||||
<span className={css.fieldLabel}>{t('displayName')}</span>
|
||||
<input
|
||||
className={css.input}
|
||||
value={draft.name}
|
||||
spellCheck={false}
|
||||
placeholder={draft.id === '' ? t('displayNamePlaceholder') : draft.id}
|
||||
onChange={(event) => { actions.setName(event.target.value) }}
|
||||
/>
|
||||
</label>
|
||||
<label className={css.field}>
|
||||
<span className={css.fieldLabel}>{t('displayDescription')}</span>
|
||||
<input
|
||||
className={css.input}
|
||||
value={draft.description}
|
||||
placeholder={t('displayDescriptionPlaceholder')}
|
||||
onChange={(event) => { actions.setDescription(event.target.value) }}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
)
|
||||
: null}
|
||||
{draft.writable ? null : <p className={css.notice}>{t('readOnlyNotice')}</p>}
|
||||
<label className={css.field}>
|
||||
<span className={css.fieldLabel}>{t('composition')}</span>
|
||||
@@ -142,20 +172,33 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode {
|
||||
|
||||
const { draft } = state
|
||||
const blocker = draft === null ? undefined : draftBlocker(draft, state.rows)
|
||||
const editorActions = { close: props.close, save: props.save, setContent: props.setContent, setId: props.setId }
|
||||
const editorActions = {
|
||||
close: props.close,
|
||||
save: props.save,
|
||||
setContent: props.setContent,
|
||||
setDescription: props.setDescription,
|
||||
setId: props.setId,
|
||||
setName: props.setName,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={css.section}>
|
||||
<h2 className={css.title}>{t('nav')}</h2>
|
||||
<p className={css.intro}>{t('sectionIntro')}</p>
|
||||
{state.error === null ? null : <p className={css.error} role="alert">{state.error}</p>}
|
||||
<ul className={css.rows}>
|
||||
<ul className={css.cards}>
|
||||
{state.rows.map(row => (
|
||||
<li key={row.id} className={css.rowCard}>
|
||||
<div className={css.rowHead}>
|
||||
<span className={css.rowName}>{row.id}</span>
|
||||
<li key={row.id} className={row.isDefault ? `${css.card} ${css.cardActive}` : css.card}>
|
||||
<div className={css.cardHead}>
|
||||
<span className={css.cardName}>{row.name ?? row.id}</span>
|
||||
{row.isDefault ? <span className={css.inUse}>{t('inUse')}</span> : null}
|
||||
</div>
|
||||
<p className={css.cardDesc}>{row.description ?? t('noDescription')}</p>
|
||||
<div className={css.cardMeta}>
|
||||
<span className={css.badge}>{row.trust === 'user' ? t('userTrust') : t('builtIn')}</span>
|
||||
{row.isDefault ? <span className={css.defaultBadge}>{t('defaultBadge')}</span> : null}
|
||||
<code className={css.cardId}>{row.id}</code>
|
||||
</div>
|
||||
<div className={css.cardFoot}>
|
||||
<span className={css.rowActions}>
|
||||
{row.isDefault
|
||||
? null
|
||||
|
||||
@@ -113,6 +113,8 @@ export function apply(ctx: ClientContext): void {
|
||||
close: () => { section.close() },
|
||||
setId: (id: string) => { section.setId(id) },
|
||||
setContent: (content: string) => { section.setContent(content) },
|
||||
setName: (name: string) => { section.setName(name) },
|
||||
setDescription: (description: string) => { section.setDescription(description) },
|
||||
save: () => section.save(),
|
||||
confirmDelete: (id: string | null) => { section.confirmDelete(id) },
|
||||
remove: () => section.remove(),
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
export type AgentPresetSettingsKey =
|
||||
| 'title' | 'description' | 'loading' | 'error' | 'userTrust' | 'seatHint' | 'lockedHint'
|
||||
| 'nav' | 'sectionIntro' | 'builtIn' | 'defaultBadge' | 'setDefault' | 'edit' | 'view'
|
||||
| 'duplicate' | 'delete' | 'newPreset' | 'presetName' | 'presetNamePlaceholder' | 'copyOf'
|
||||
| 'duplicate' | 'delete' | 'newPreset' | 'presetId' | 'presetIdPlaceholder' | 'copyOf'
|
||||
| 'displayName' | 'displayNamePlaceholder' | 'displayDescription' | 'displayDescriptionPlaceholder'
|
||||
| 'inUse' | 'noDescription'
|
||||
| 'composition' | 'readOnlyNotice' | 'save' | 'saving' | 'cancel' | 'close' | 'retry'
|
||||
| 'idRequired' | 'idInvalid' | 'idTaken'
|
||||
| 'deleteTitle' | 'deleteDescription' | 'deleteConfirm' | 'deleting'
|
||||
@@ -30,8 +32,14 @@ export const en: Record<AgentPresetSettingsKey, string> = {
|
||||
duplicate: 'Duplicate',
|
||||
delete: 'Delete',
|
||||
newPreset: 'New preset',
|
||||
presetName: 'Preset name',
|
||||
presetNamePlaceholder: 'my-agent',
|
||||
presetId: 'Identifier',
|
||||
presetIdPlaceholder: 'my-agent',
|
||||
displayName: 'Name',
|
||||
displayNamePlaceholder: 'Shown in the picker',
|
||||
displayDescription: 'Description',
|
||||
displayDescriptionPlaceholder: 'One sentence on what this preset is for',
|
||||
inUse: 'In use',
|
||||
noDescription: 'No description.',
|
||||
copyOf: 'Copied from',
|
||||
composition: 'Composition (cordis.yml)',
|
||||
readOnlyNotice: 'This preset ships with the deployment and cannot be edited. Duplicate it to make your own.',
|
||||
@@ -40,9 +48,9 @@ export const en: Record<AgentPresetSettingsKey, string> = {
|
||||
cancel: 'Cancel',
|
||||
close: 'Close',
|
||||
retry: 'Retry',
|
||||
idRequired: 'Name the preset.',
|
||||
idRequired: 'Give the preset an identifier.',
|
||||
idInvalid: 'Use lowercase letters, digits, and hyphens, starting with a letter or digit.',
|
||||
idTaken: 'A preset with this name already exists.',
|
||||
idTaken: 'A preset with this identifier already exists.',
|
||||
deleteTitle: 'Delete this preset?',
|
||||
deleteDescription:
|
||||
'The composition file is deleted. Sessions already running on it keep working; new sessions cannot select it.',
|
||||
@@ -69,8 +77,14 @@ export const zh: Record<AgentPresetSettingsKey, string> = {
|
||||
duplicate: '复制',
|
||||
delete: '删除',
|
||||
newPreset: '新建预设',
|
||||
presetName: '预设名称',
|
||||
presetNamePlaceholder: 'my-agent',
|
||||
presetId: '标识符',
|
||||
presetIdPlaceholder: 'my-agent',
|
||||
displayName: '名称',
|
||||
displayNamePlaceholder: '选择器中显示的名字',
|
||||
displayDescription: '描述',
|
||||
displayDescriptionPlaceholder: '一句话说明这个预设做什么',
|
||||
inUse: '当前使用',
|
||||
noDescription: '暂无描述。',
|
||||
copyOf: '复制自',
|
||||
composition: '组装(cordis.yml)',
|
||||
readOnlyNotice: '该预设随部署提供,不可编辑。复制一份即可改成自己的。',
|
||||
@@ -81,7 +95,7 @@ export const zh: Record<AgentPresetSettingsKey, string> = {
|
||||
retry: '重试',
|
||||
idRequired: '请填写预设名称。',
|
||||
idInvalid: '只能使用小写字母、数字与连字符,且以字母或数字开头。',
|
||||
idTaken: '同名预设已存在。',
|
||||
idTaken: '该标识符已被占用。',
|
||||
deleteTitle: '删除该预设?',
|
||||
deleteDescription: '组装文件将被删除。已在其上运行的会话不受影响;新会话将无法再选择它。',
|
||||
deleteConfirm: '删除',
|
||||
|
||||
@@ -17,8 +17,12 @@ const PRESET_ID = /^[a-z0-9][a-z0-9-]*$/
|
||||
|
||||
/** One preset row the page renders. */
|
||||
export interface PresetRow {
|
||||
/** Preset id, also its display name and directory name. */
|
||||
/** Preset id and directory name; the display name falls back to it. */
|
||||
id: string
|
||||
/** Display name the preset published, absent when it published none. */
|
||||
name?: string
|
||||
/** One sentence on what the preset is for. */
|
||||
description?: string
|
||||
/** Whether the preset ships with the deployment or was authored locally. */
|
||||
trust: 'system' | 'user'
|
||||
/** Whether a session that names no preset gets this one. */
|
||||
@@ -42,6 +46,10 @@ export interface PresetDraft {
|
||||
writable: boolean
|
||||
/** Whether a save is in flight. */
|
||||
saving: boolean
|
||||
/** Display name being edited; empty means the picker falls back to the id. */
|
||||
name: string
|
||||
/** Description being edited. */
|
||||
description: string
|
||||
/** The last save failure, cleared by the next edit. */
|
||||
error: string | null
|
||||
}
|
||||
@@ -172,7 +180,7 @@ export class AgentPresetSectionController {
|
||||
this.set({ error: response.result.error.message })
|
||||
return
|
||||
}
|
||||
const { content, writable } = response.result.value
|
||||
const { content, writable, name, description } = response.result.value
|
||||
this.set({
|
||||
draft: {
|
||||
id: creating ? '' : source,
|
||||
@@ -182,6 +190,10 @@ export class AgentPresetSectionController {
|
||||
// A copy is always writable: it lands in the local root regardless of
|
||||
// where the text came from.
|
||||
writable: creating || writable,
|
||||
// A copy starts from the source's text but must be renamed, or two
|
||||
// rows would present themselves identically.
|
||||
name: creating ? '' : name ?? '',
|
||||
description: description ?? '',
|
||||
saving: false,
|
||||
error: null,
|
||||
},
|
||||
@@ -212,6 +224,22 @@ export class AgentPresetSectionController {
|
||||
this.patchDraft({ content, error: null })
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename the draft.
|
||||
* @param name - the display name typed into the editor.
|
||||
*/
|
||||
setName(name: string): void {
|
||||
this.patchDraft({ name, error: null })
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the draft's description.
|
||||
* @param description - the description typed into the editor.
|
||||
*/
|
||||
setDescription(description: string): void {
|
||||
this.patchDraft({ description, error: null })
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the open draft, then re-read the roster.
|
||||
*
|
||||
@@ -225,7 +253,12 @@ export class AgentPresetSectionController {
|
||||
if (draftBlocker(draft, this.store.getSnapshot().rows) !== undefined) return
|
||||
this.patchDraft({ saving: true, error: null })
|
||||
try {
|
||||
const response = await this.api.agentPresets.write({ agentPreset: draft.id, content: draft.content })
|
||||
const response = await this.api.agentPresets.write({
|
||||
agentPreset: draft.id,
|
||||
content: draft.content,
|
||||
name: draft.name,
|
||||
description: draft.description,
|
||||
})
|
||||
if (!response.result.ok) {
|
||||
this.patchDraft({ saving: false, error: response.result.error.message })
|
||||
return
|
||||
|
||||
@@ -131,6 +131,8 @@ describe('ui-agent-preset apply', () => {
|
||||
await section.load()
|
||||
section.setId('mine')
|
||||
section.setContent('- id: x\n')
|
||||
section.setName('我的模式')
|
||||
section.setDescription('只做检索。')
|
||||
section.confirmDelete('mine')
|
||||
section.close()
|
||||
await Promise.all([section.open('standard'), section.createFrom(), section.save(), section.remove()])
|
||||
|
||||
@@ -272,7 +272,8 @@ describe('creating a preset', () => {
|
||||
|
||||
describe('the save blocker', () => {
|
||||
const base: PresetDraft = {
|
||||
id: '', source: 'standard', creating: true, content: '', writable: true, saving: false, error: null,
|
||||
id: '', source: 'standard', creating: true, content: '', writable: true,
|
||||
name: '', description: '', saving: false, error: null,
|
||||
}
|
||||
const rows: readonly PresetRow[] = [{ id: 'mine', trust: 'user', isDefault: false }]
|
||||
|
||||
@@ -405,11 +406,37 @@ describe('saving', () => {
|
||||
expect(draftOf(controller).error).toBeNull()
|
||||
})
|
||||
|
||||
it('carries the display name and description through a save', async () => {
|
||||
const { controller, calls } = harness()
|
||||
await controller.load()
|
||||
await controller.open('mine')
|
||||
|
||||
controller.setName('我的模式')
|
||||
controller.setDescription('只做检索。')
|
||||
await controller.save()
|
||||
|
||||
expect(calls.find(call => call.method === 'write')?.payload)
|
||||
.toMatchObject({ agentPreset: 'mine', name: '我的模式', description: '只做检索。' })
|
||||
})
|
||||
|
||||
it('leaves a copy unnamed so two rows cannot present themselves alike', async () => {
|
||||
const { controller } = harness()
|
||||
await controller.load()
|
||||
|
||||
await controller.createFrom('mine')
|
||||
|
||||
// The composition is copied verbatim; the display name is not.
|
||||
expect(draftOf(controller)).toMatchObject({ id: '', name: '' })
|
||||
expect(draftOf(controller).content).toBe('- id: tool-read\n')
|
||||
})
|
||||
|
||||
it('ignores an edit with no draft open', () => {
|
||||
const { controller } = harness()
|
||||
|
||||
controller.setId('x')
|
||||
controller.setContent('y')
|
||||
controller.setName('n')
|
||||
controller.setDescription('d')
|
||||
|
||||
expect(controller.store.getSnapshot().draft).toBeNull()
|
||||
})
|
||||
|
||||
@@ -22,7 +22,7 @@ const READY: AgentPresetSectionState = {
|
||||
error: null,
|
||||
authorable: true,
|
||||
rows: [
|
||||
{ id: 'standard', trust: 'system', isDefault: true },
|
||||
{ id: 'standard', trust: 'system', isDefault: true, name: '标准模式', description: '完整的编码 agent。' },
|
||||
{ id: 'mine', trust: 'user', isDefault: false },
|
||||
],
|
||||
draft: null,
|
||||
@@ -44,6 +44,8 @@ function renderSection(state: Partial<AgentPresetSectionState> = {}) {
|
||||
close: vi.fn(),
|
||||
setId: vi.fn(),
|
||||
setContent: vi.fn(),
|
||||
setName: vi.fn(),
|
||||
setDescription: vi.fn(),
|
||||
save: vi.fn(() => Promise.resolve()),
|
||||
confirmDelete: vi.fn(),
|
||||
remove: vi.fn(() => Promise.resolve()),
|
||||
@@ -58,10 +60,12 @@ function renderSection(state: Partial<AgentPresetSectionState> = {}) {
|
||||
return actions
|
||||
}
|
||||
|
||||
/** Locate a card by the id it prints, not by its display name. */
|
||||
function rowFor(id: string): HTMLElement {
|
||||
const row = screen.getByText(id).closest('li')
|
||||
/* v8 ignore next -- every rendered row id resolves to its card */
|
||||
if (row === null) throw new Error(`no row for ${id}`)
|
||||
const key = screen.getAllByText(id).find(node => node.tagName === 'CODE')
|
||||
const row = key?.closest('li') ?? null
|
||||
/* v8 ignore next -- every rendered card prints its id */
|
||||
if (row === null) throw new Error(`no card for ${id}`)
|
||||
return row
|
||||
}
|
||||
|
||||
@@ -72,12 +76,24 @@ describe('the preset list', () => {
|
||||
await waitFor(() => { expect(actions.load).toHaveBeenCalledTimes(1) })
|
||||
})
|
||||
|
||||
it('marks trust and the default, and offers no "set default" on the default row', () => {
|
||||
it('shows the published name and description, falling back to the id', () => {
|
||||
renderSection()
|
||||
|
||||
// The name is what a picker reads; the id stays visible as the key the
|
||||
// composition and the session header actually carry.
|
||||
expect(screen.getByText('标准模式')).toBeTruthy()
|
||||
expect(screen.getByText('完整的编码 agent。')).toBeTruthy()
|
||||
const mine = rowFor('mine')
|
||||
expect(within(mine).getAllByText('mine').length).toBeGreaterThan(0)
|
||||
expect(within(mine).getByText(en.noDescription)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('marks trust and the one in use, and offers no "set default" on it', () => {
|
||||
renderSection()
|
||||
|
||||
const standard = rowFor('standard')
|
||||
expect(within(standard).getByText(en.builtIn)).toBeTruthy()
|
||||
expect(within(standard).getByText(en.defaultBadge)).toBeTruthy()
|
||||
expect(within(standard).getByText(en.inUse)).toBeTruthy()
|
||||
expect(within(standard).queryByText(en.setDefault)).toBeNull()
|
||||
expect(within(rowFor('mine')).getByText(en.userTrust)).toBeTruthy()
|
||||
})
|
||||
@@ -151,13 +167,13 @@ describe('the preset list', () => {
|
||||
describe('the composition editor', () => {
|
||||
const draft = {
|
||||
id: 'mine', source: 'mine', creating: false, content: '- id: tool-read\n',
|
||||
writable: true, saving: false, error: null,
|
||||
writable: true, name: '我的预设', description: '', saving: false, error: null,
|
||||
}
|
||||
|
||||
it('opens under the row it belongs to and edits through the controller', () => {
|
||||
const actions = renderSection({ draft })
|
||||
|
||||
const editor = within(rowFor('mine')).getByRole('textbox')
|
||||
const editor = within(rowFor('mine')).getByLabelText(en.composition)
|
||||
expect(editor).toHaveProperty('value', '- id: tool-read\n')
|
||||
fireEvent.change(editor, { target: { value: '- id: tool-edit\n' } })
|
||||
|
||||
@@ -185,7 +201,7 @@ describe('the composition editor', () => {
|
||||
it('shows a shipped composition read-only, with no way to save it', () => {
|
||||
renderSection({ draft: { ...draft, id: 'standard', source: 'standard', writable: false } })
|
||||
|
||||
expect(screen.getByRole('textbox')).toHaveProperty('readOnly', true)
|
||||
expect(screen.getByLabelText(en.composition)).toHaveProperty('readOnly', true)
|
||||
expect(screen.getByText(en.readOnlyNotice)).toBeTruthy()
|
||||
expect(screen.queryByText(en.save)).toBeNull()
|
||||
expect(screen.getByText(en.close)).toBeTruthy()
|
||||
@@ -197,11 +213,28 @@ describe('the composition editor', () => {
|
||||
})
|
||||
|
||||
expect(screen.getByText(`${en.copyOf} standard`)).toBeTruthy()
|
||||
fireEvent.change(screen.getByPlaceholderText(en.presetNamePlaceholder), { target: { value: 'my-agent' } })
|
||||
fireEvent.change(screen.getByPlaceholderText(en.presetIdPlaceholder), { target: { value: 'my-agent' } })
|
||||
|
||||
expect(actions.setId).toHaveBeenCalledWith('my-agent')
|
||||
})
|
||||
|
||||
it('edits the display name and description through the controller', () => {
|
||||
const actions = renderSection({ draft })
|
||||
|
||||
fireEvent.change(screen.getByLabelText(en.displayName), { target: { value: '我的模式' } })
|
||||
fireEvent.change(screen.getByLabelText(en.displayDescription), { target: { value: '只做检索。' } })
|
||||
|
||||
expect(actions.setName).toHaveBeenCalledWith('我的模式')
|
||||
expect(actions.setDescription).toHaveBeenCalledWith('只做检索。')
|
||||
})
|
||||
|
||||
it('offers no display fields on a read-only preset', () => {
|
||||
renderSection({ draft: { ...draft, writable: false } })
|
||||
|
||||
// Nothing here can be saved, so an editable name would be a lie.
|
||||
expect(screen.queryByLabelText(en.displayName)).toBeNull()
|
||||
})
|
||||
|
||||
it('blocks a save the host would refuse, and says why', () => {
|
||||
const actions = renderSection({
|
||||
draft: { ...draft, id: 'Upper Case', source: 'standard', creating: true },
|
||||
|
||||
@@ -101,8 +101,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
jsDoc: '/**\n * Read one profile\'s composition text.\n * @param id - the profile id.\n * @returns the composition exactly as stored.\n * @throws when no configured root supplies that id.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'async write(id: string, content: string): Promise<void>',
|
||||
jsDoc: '/**\n * Create or replace a locally authored profile.\n *\n * The text is shape-checked before it lands, so a save cannot leave a file no\n * session could load; it is NOT mounted, so a composition that parses but\n * names a missing plugin still fails at the next session that selects it.\n * @param id - the profile id, which becomes its directory name.\n * @param content - the composition text.\n * @throws when the id is unusable, the text is not an entry list, or the\n * deployment configures no writable root.\n */',
|
||||
signature: 'async write(id: string, content: string, metadata: PresetMetadata = {}): Promise<void>',
|
||||
jsDoc: '/**\n * Create or replace a locally authored profile.\n *\n * The text is shape-checked before it lands, so a save cannot leave a file no\n * session could load; it is NOT mounted, so a composition that parses but\n * names a missing plugin still fails at the next session that selects it.\n * @param id - the profile id, which becomes its directory name.\n * @param content - the composition text.\n * @param metadata - display name and description; clearing both removes the file.\n * @throws when the id is unusable, the text is not an entry list, or the\n * deployment configures no writable root.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'async remove(id: string): Promise<void>',
|
||||
@@ -1641,7 +1641,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'AgentPreset',
|
||||
declaration: 'export interface AgentPreset {\n readonly id: string;\n readonly trust: PresetTrust;\n readonly path: string;\n}',
|
||||
declaration: 'export interface AgentPreset {\n readonly id: string;\n readonly trust: PresetTrust;\n readonly path: string;\n readonly name?: string;\n readonly description?: string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'AgentSetup',
|
||||
@@ -2235,6 +2235,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'PresetSpec',
|
||||
declaration: 'export interface PresetSpec {\n sandbox: SandboxMode;\n approval: ApprovalPolicy;\n name?: string;\n description?: string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'PresetMetadata',
|
||||
declaration: 'export interface PresetMetadata {\n readonly name?: string;\n readonly description?: string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'PresetTrust',
|
||||
declaration: 'export type PresetTrust = \'system\' | \'user\';',
|
||||
|
||||
@@ -2544,6 +2544,8 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
id: preset.id,
|
||||
trust: preset.trust,
|
||||
isDefault: preset.id === defaultId,
|
||||
...preset.name === undefined ? {} : { name: preset.name },
|
||||
...preset.description === undefined ? {} : { description: preset.description },
|
||||
})),
|
||||
authorable: presets.authorable,
|
||||
})
|
||||
@@ -2615,6 +2617,8 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
trust: preset.trust,
|
||||
content: await presets.read(preset.id),
|
||||
writable: preset.trust === 'user' && presets.authorable,
|
||||
...preset.name === undefined ? {} : { name: preset.name },
|
||||
...preset.description === undefined ? {} : { description: preset.description },
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
return err(request, presetError(agentPreset, error))
|
||||
@@ -2622,11 +2626,14 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
},
|
||||
|
||||
async write(request) {
|
||||
const { agentPreset, content } = request.payload
|
||||
const { agentPreset, content, name, description } = request.payload
|
||||
const presets = ctx.get('agentPresets')
|
||||
if (presets === undefined) return err(request, noRoster(agentPreset))
|
||||
try {
|
||||
await presets.write(agentPreset, content)
|
||||
await presets.write(agentPreset, content, {
|
||||
...name === undefined ? {} : { name },
|
||||
...description === undefined ? {} : { description },
|
||||
})
|
||||
return ok(request, { agentPreset })
|
||||
} catch (error: unknown) {
|
||||
return err(request, presetError(agentPreset, error))
|
||||
|
||||
@@ -14,6 +14,8 @@ export const agentPresetEntrySchema = z.object({
|
||||
id: z.string().min(1),
|
||||
trust: z.union([z.literal('system'), z.literal('user')]),
|
||||
isDefault: z.boolean(),
|
||||
name: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
}) satisfies z.ZodType<Wire<AgentPresetEntry>>
|
||||
|
||||
/** agentPreset.list request payload. */
|
||||
@@ -48,12 +50,16 @@ export const agentPresetReadValueSchema = z.object({
|
||||
trust: z.union([z.literal('system'), z.literal('user')]),
|
||||
content: z.string(),
|
||||
writable: z.boolean(),
|
||||
name: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'agentPreset.read'>>>
|
||||
|
||||
/** agentPreset.write request payload. */
|
||||
export const agentPresetWriteRequestSchema = z.object({
|
||||
agentPreset: z.string().min(1),
|
||||
content: z.string(),
|
||||
name: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'agentPreset.write'>>>
|
||||
|
||||
/** agentPreset.write response value. */
|
||||
|
||||
@@ -24,6 +24,15 @@ export interface AgentPresetEntry {
|
||||
readonly trust: 'system' | 'user'
|
||||
/** Whether a session that names no preset gets this one. */
|
||||
readonly isDefault: boolean
|
||||
/**
|
||||
* Display name the preset published, absent when it published none. A
|
||||
* surface falls back to {@link id}; it is never a second identity, and it
|
||||
* never decides trust — a locally authored preset cannot name itself into
|
||||
* the shipped set.
|
||||
*/
|
||||
readonly name?: string
|
||||
/** One sentence on what the preset is for, when it published one. */
|
||||
readonly description?: string
|
||||
}
|
||||
|
||||
/** agent-preset-domain unary methods (the map key agentPreset.* of RpcMethodMap). */
|
||||
@@ -56,14 +65,21 @@ export interface AgentPresetsApi {
|
||||
* is reconnaissance and writing one is arbitrary capability.
|
||||
*/
|
||||
read(request: RpcRequest<{ agentPreset: string }>):
|
||||
Promise<RpcResponse<{ agentPreset: string; trust: 'system' | 'user'; content: string; writable: boolean }>>
|
||||
Promise<RpcResponse<{
|
||||
agentPreset: string
|
||||
trust: 'system' | 'user'
|
||||
content: string
|
||||
writable: boolean
|
||||
name?: string
|
||||
description?: string
|
||||
}>>
|
||||
|
||||
/**
|
||||
* Create or replace a locally authored preset. Shipped presets are refused;
|
||||
* the text is shape-checked before it lands, so a save cannot leave a file no
|
||||
* session could load.
|
||||
*/
|
||||
write(request: RpcRequest<{ agentPreset: string; content: string }>):
|
||||
write(request: RpcRequest<{ agentPreset: string; content: string; name?: string; description?: string }>):
|
||||
Promise<RpcResponse<{ agentPreset: string }>>
|
||||
|
||||
/** Delete a locally authored preset. Shipped presets are refused. */
|
||||
|
||||
@@ -54,6 +54,19 @@ A row's **package name** resolves from the host composition, not from the preset
|
||||
|
||||
A **relative** path still resolves from the preset's own directory, so a preset's own plugin files and skill directories travel with it.
|
||||
|
||||
### Display metadata
|
||||
|
||||
A preset may publish display text in an optional `preset.yml` beside its composition:
|
||||
|
||||
```yaml
|
||||
name: 极简模式
|
||||
description: 只向模型呈现 bash 与 str_replace_editor,适合 benchmark 与最小复现。
|
||||
```
|
||||
|
||||
It carries display text ONLY. `id` is the directory name and `trust` comes from the root the preset was discovered under, so neither is writable here — otherwise a locally authored preset could name itself into the shipped set. It is a separate file because the composition is a top-level list of plugin rows: YAML cannot carry sibling keys beside it, and a fake metadata row would hand the Loader something to load.
|
||||
|
||||
Every read failure degrades to no metadata — absent, malformed, wrongly typed, or blank all mean the same thing, and a picker falls back to the id. Presentation is not capability: a preset with a broken name still mounts.
|
||||
|
||||
## Config
|
||||
|
||||
| Field | Default | Meaning |
|
||||
|
||||
@@ -54,6 +54,19 @@ agent 工厂的 `setup(agentCtx)` 钩子是唯一受支持的调用点。只有
|
||||
|
||||
**相对**路径仍从 preset 自身的目录解析,因此 preset 自带的插件文件与 skill 目录会随它一同迁移。
|
||||
|
||||
### 展示用元信息
|
||||
|
||||
preset 可以在组装文件旁的可选 `preset.yml` 里发布展示文本:
|
||||
|
||||
```yaml
|
||||
name: 极简模式
|
||||
description: 只向模型呈现 bash 与 str_replace_editor,适合 benchmark 与最小复现。
|
||||
```
|
||||
|
||||
它**只**承载展示文本。`id` 是目录名,`trust` 取自 preset 被发现时所在的根目录,两者都不可写在这里——否则本地创作的 preset 就能把自己命名进随附集合。之所以是独立文件:组装是插件行的顶层列表,YAML 无法在其旁携带同级键,而伪造一个元信息行等于递给 Loader 一个要加载的东西。
|
||||
|
||||
任何读取失败都退化为「没有元信息」——缺失、格式错误、类型不对、内容为空,含义相同,选择器回退到 id。展示不是能力:名字坏掉的 preset 依然能挂载。
|
||||
|
||||
## 配置
|
||||
|
||||
| 字段 | 默认值 | 含义 |
|
||||
|
||||
@@ -14,6 +14,7 @@ import { entryListSchema } from '@cordisjs/plugin-include'
|
||||
import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
|
||||
import { expandHomePath } from '@deepseek-ai/dsh-paths'
|
||||
import { COMPOSITION_FILE } from './discovery.ts'
|
||||
import { METADATA_FILE, renderPresetMetadata, type PresetMetadata } from './metadata.ts'
|
||||
import type { AgentPreset, PresetRoot } from './types.ts'
|
||||
|
||||
/**
|
||||
@@ -111,6 +112,7 @@ export async function readComposition(preset: AgentPreset): Promise<string> {
|
||||
* @param roots - the configured roots; the first `user` one receives the write.
|
||||
* @param id - the preset id, which becomes its directory name.
|
||||
* @param content - the composition text.
|
||||
* @param metadata - display name and description; clearing both removes the file.
|
||||
* @returns the absolute path written.
|
||||
* @throws when the id is unusable, the content is not an entry list, or the
|
||||
* deployment has no writable root.
|
||||
@@ -119,6 +121,7 @@ export async function writeComposition(
|
||||
roots: readonly PresetRoot[],
|
||||
id: string,
|
||||
content: string,
|
||||
metadata: PresetMetadata = {},
|
||||
): Promise<string> {
|
||||
if (!PRESET_ID.test(id)) throw new InvalidPresetIdError(id)
|
||||
assertComposition(content)
|
||||
@@ -127,6 +130,16 @@ export async function writeComposition(
|
||||
// Owner-only: a composition names the plugins a session runs, so it carries
|
||||
// the same weight as the settings document beside it.
|
||||
await writeFileAtomic(path, content, { mode: 0o600, dirMode: 0o700 })
|
||||
// Display text lands after the composition, and only when there is any: a
|
||||
// preset with no name should carry no metadata file rather than an empty
|
||||
// one. Clearing both fields therefore removes the file.
|
||||
const rendered = renderPresetMetadata(metadata)
|
||||
const metadataPath = join(dir, METADATA_FILE)
|
||||
if (rendered === undefined) {
|
||||
await rm(metadataPath, { force: true })
|
||||
} else {
|
||||
await writeFileAtomic(metadataPath, rendered, { mode: 0o600, dirMode: 0o700 })
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* Filesystem discovery of agent presets. A preset is a directory holding
|
||||
* {@link COMPOSITION_FILE}; the directory name is the preset id. Discovery
|
||||
* {@link COMPOSITION_FILE}, optionally beside a {@link METADATA_FILE} carrying
|
||||
* its display text; the directory name is the preset id. Discovery
|
||||
* re-reads the roots on every call so a preset authored while the process is
|
||||
* running is visible without a restart.
|
||||
* @module @deepseek-ai/dsh-agent-presets/discovery
|
||||
@@ -9,6 +10,7 @@
|
||||
import { readdir, stat } from 'node:fs/promises'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { expandHomePath } from '@deepseek-ai/dsh-paths'
|
||||
import { readPresetMetadata } from './metadata.ts'
|
||||
import type { AgentPreset, PresetRoot } from './types.ts'
|
||||
|
||||
/** The composition file that makes a directory a preset. */
|
||||
@@ -51,9 +53,13 @@ export async function scanRoot(root: PresetRoot): Promise<AgentPreset[]> {
|
||||
const found: AgentPreset[] = []
|
||||
for (const child of children) {
|
||||
if (!child.isDirectory()) continue
|
||||
const path = join(dir, child.name, COMPOSITION_FILE)
|
||||
const directory = join(dir, child.name)
|
||||
const path = join(directory, COMPOSITION_FILE)
|
||||
if (!await isFile(path)) continue
|
||||
found.push({ id: child.name, trust: root.trust, path })
|
||||
// Display text only, and never fatal: a preset with unreadable metadata
|
||||
// still mounts, it just shows its id.
|
||||
const metadata = await readPresetMetadata(directory)
|
||||
found.push({ id: child.name, trust: root.trust, path, ...metadata })
|
||||
}
|
||||
return found.sort((left, right) => left.id.localeCompare(right.id))
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import z from 'schemastery'
|
||||
import { settingsNamespace, type SettingsScope } from '@deepseek-ai/dsh-settings'
|
||||
import { discoverPresets } from './discovery.ts'
|
||||
import { deleteComposition, readComposition, writeComposition } from './authoring.ts'
|
||||
import type { PresetMetadata } from './metadata.ts'
|
||||
import { mountPreset, serviceForAgent, unmountPresetFor } from './mount.ts'
|
||||
import { PresetNotWritableError } from './authoring.ts'
|
||||
import { UnknownPresetError, type AgentPreset, type Config } from './types.ts'
|
||||
@@ -35,6 +36,9 @@ export const AgentPresetSettingsSchema: z<AgentPresetSettings> = z.object({
|
||||
})
|
||||
|
||||
export { COMPOSITION_FILE, discoverPresets, scanRoot } from './discovery.ts'
|
||||
export {
|
||||
METADATA_FILE, readPresetMetadata, renderPresetMetadata, type PresetMetadata,
|
||||
} from './metadata.ts'
|
||||
export {
|
||||
inactiveRows, leakedServices, livePresetMounts, mountPreset, serviceForAgent,
|
||||
unmountPresetFor, type PresetMount,
|
||||
@@ -171,17 +175,18 @@ export class AgentPresets extends Service {
|
||||
* names a missing plugin still fails at the next session that selects it.
|
||||
* @param id - the preset id, which becomes its directory name.
|
||||
* @param content - the composition text.
|
||||
* @param metadata - display name and description; clearing both removes the file.
|
||||
* @throws when the id is unusable, the text is not an entry list, or the
|
||||
* deployment configures no writable root.
|
||||
*/
|
||||
async write(id: string, content: string): Promise<void> {
|
||||
async write(id: string, content: string, metadata: PresetMetadata = {}): Promise<void> {
|
||||
// A shipped preset belongs to the deployment: overwriting it would remove
|
||||
// the known-good composition a broken local one is compared against.
|
||||
const existing = (await this.list()).find(preset => preset.id === id)
|
||||
if (existing !== undefined && existing.trust !== 'user') {
|
||||
throw new PresetNotWritableError(id, 'it ships with the deployment')
|
||||
}
|
||||
await writeComposition(this.config.roots, id, content)
|
||||
await writeComposition(this.config.roots, id, content, metadata)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
93
packages/preset/agent-presets/src/metadata.ts
Normal file
93
packages/preset/agent-presets/src/metadata.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* A preset's display metadata: the name and description a picker shows.
|
||||
*
|
||||
* It lives in its own file because the composition is a top-level list of
|
||||
* plugin rows — YAML cannot carry sibling keys beside it, and faking a
|
||||
* metadata row would hand the Loader something to load. Keeping it separate
|
||||
* also keeps the composition exactly what its name says: a Cordis file the
|
||||
* loader owns and the cordis preset can author.
|
||||
*
|
||||
* The file carries display text ONLY. `id` is the directory name and `trust`
|
||||
* comes from the root a preset was discovered under, so neither is writable
|
||||
* here — otherwise a locally authored preset could claim to be a shipped one.
|
||||
*
|
||||
* Every read failure degrades to no metadata. A preset whose display text is
|
||||
* missing, malformed, or unreadable still mounts: presentation is not a
|
||||
* capability, and a broken name must never become an agent that cannot start.
|
||||
* @module @deepseek-ai/dsh-agent-presets/metadata
|
||||
*/
|
||||
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import yaml from 'js-yaml'
|
||||
|
||||
/** The optional display-metadata file beside a preset's composition. */
|
||||
export const METADATA_FILE = 'preset.yml'
|
||||
|
||||
/** Display text a preset may publish about itself. */
|
||||
export interface PresetMetadata {
|
||||
/** Human-facing name; falls back to the preset id when absent. */
|
||||
readonly name?: string
|
||||
/** One sentence on what this preset is for. */
|
||||
readonly description?: string
|
||||
}
|
||||
|
||||
/** A non-empty trimmed string, or undefined for anything else. */
|
||||
function text(value: unknown): string | undefined {
|
||||
if (typeof value !== 'string') return undefined
|
||||
const trimmed = value.trim()
|
||||
return trimmed === '' ? undefined : trimmed
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one preset directory's display metadata.
|
||||
*
|
||||
* Absent, unparsable, and wrongly-shaped files are all the same answer —
|
||||
* empty metadata — because the caller renders a picker, not a diagnostic.
|
||||
* @param directory - the preset directory.
|
||||
* @returns the display text the preset published, possibly empty.
|
||||
*/
|
||||
export async function readPresetMetadata(directory: string): Promise<PresetMetadata> {
|
||||
let raw: string
|
||||
try {
|
||||
raw = await readFile(join(directory, METADATA_FILE), 'utf8')
|
||||
} catch {
|
||||
// Absent is the common case: metadata is optional and most presets,
|
||||
// including every one authored by duplicating another, carry none.
|
||||
return {}
|
||||
}
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = yaml.load(raw)
|
||||
} catch {
|
||||
// Malformed display text is not worth failing discovery over; the picker
|
||||
// falls back to the id, and the composition still mounts.
|
||||
return {}
|
||||
}
|
||||
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return {}
|
||||
const record = parsed as Record<string, unknown>
|
||||
const name = text(record.name)
|
||||
const description = text(record.description)
|
||||
return {
|
||||
...name === undefined ? {} : { name },
|
||||
...description === undefined ? {} : { description },
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render display metadata as the file's contents.
|
||||
*
|
||||
* Absent fields are omitted rather than written empty, so a preset with no
|
||||
* description does not ship a key that reads as an intentional blank.
|
||||
* @param metadata - the display text to store.
|
||||
* @returns the YAML document, or undefined when there is nothing to store.
|
||||
*/
|
||||
export function renderPresetMetadata(metadata: PresetMetadata): string | undefined {
|
||||
const name = text(metadata.name)
|
||||
const description = text(metadata.description)
|
||||
if (name === undefined && description === undefined) return undefined
|
||||
return yaml.dump({
|
||||
...name === undefined ? {} : { name },
|
||||
...description === undefined ? {} : { description },
|
||||
}, { lineWidth: -1 })
|
||||
}
|
||||
@@ -15,6 +15,10 @@ export interface AgentPreset {
|
||||
readonly trust: PresetTrust
|
||||
/** Absolute path of the preset's agent composition file. */
|
||||
readonly path: string
|
||||
/** Display name from the preset's own metadata; absent falls back to {@link id}. */
|
||||
readonly name?: string
|
||||
/** One sentence on what this preset is for, when it published one. */
|
||||
readonly description?: string
|
||||
}
|
||||
|
||||
/** One directory scanned for preset subdirectories. */
|
||||
|
||||
@@ -13,7 +13,9 @@ import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import Include from '@cordisjs/plugin-include'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import AgentPresets, { COMPOSITION_FILE, assertComposition } from '@deepseek-ai/dsh-agent-presets'
|
||||
import AgentPresets, {
|
||||
COMPOSITION_FILE, METADATA_FILE, assertComposition,
|
||||
} from '@deepseek-ai/dsh-agent-presets'
|
||||
|
||||
const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures')
|
||||
const VALID = '- id: tool-alpha\n name: ../../plugins/contribute.js\n config:\n tool: alpha\n'
|
||||
@@ -92,6 +94,38 @@ describe('authoring a preset', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('display metadata beside a composition', () => {
|
||||
it('stores the name and description the author supplied', async () => {
|
||||
await ctx.agentPresets.write('mine', VALID, { name: '我的模式', description: '只做检索。' })
|
||||
|
||||
expect(await readFile(join(userRoot, 'mine', METADATA_FILE), 'utf8'))
|
||||
.toContain('name: 我的模式')
|
||||
const listed = (await ctx.agentPresets.list()).find(preset => preset.id === 'mine')
|
||||
expect(listed).toMatchObject({ name: '我的模式', description: '只做检索。' })
|
||||
})
|
||||
|
||||
it('removes the file when both fields are cleared', async () => {
|
||||
await ctx.agentPresets.write('mine', VALID, { name: '我的模式' })
|
||||
|
||||
await ctx.agentPresets.write('mine', VALID, {})
|
||||
|
||||
// An empty metadata document would read as an intentional blank name;
|
||||
// absence is what "this preset publishes no display text" looks like.
|
||||
expect(existsSync(join(userRoot, 'mine', METADATA_FILE))).toBe(false)
|
||||
expect((await ctx.agentPresets.list()).find(preset => preset.id === 'mine')?.name).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps a composition mountable when its metadata is unreadable', async () => {
|
||||
await ctx.agentPresets.write('mine', VALID)
|
||||
await writeFile(join(userRoot, 'mine', METADATA_FILE), 'name: [unclosed\n')
|
||||
|
||||
// Presentation is not capability: discovery still yields the preset.
|
||||
const listed = (await ctx.agentPresets.list()).find(preset => preset.id === 'mine')
|
||||
expect(listed?.name).toBeUndefined()
|
||||
expect(await ctx.agentPresets.resolve('mine')).toMatchObject({ id: 'mine' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleting a preset', () => {
|
||||
it('removes a locally authored one', async () => {
|
||||
await ctx.agentPresets.write('mine', VALID)
|
||||
|
||||
99
packages/preset/agent-presets/tests/metadata.spec.ts
Normal file
99
packages/preset/agent-presets/tests/metadata.spec.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Display metadata is presentation, never capability: every way of getting it
|
||||
* wrong degrades to "this preset has no display text" rather than to a
|
||||
* preset that cannot be discovered or mounted. It also cannot carry identity
|
||||
* — `id` is the directory and `trust` is the root, so neither is readable
|
||||
* from the file a user can write.
|
||||
*/
|
||||
|
||||
import { mkdtemp, mkdir, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { METADATA_FILE, readPresetMetadata, renderPresetMetadata } from '../src/metadata.ts'
|
||||
|
||||
/** A preset directory holding exactly the given metadata text. */
|
||||
async function presetDir(content?: string): Promise<string> {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'dsh-preset-meta-'))
|
||||
await mkdir(dir, { recursive: true })
|
||||
if (content !== undefined) await writeFile(join(dir, METADATA_FILE), content)
|
||||
return dir
|
||||
}
|
||||
|
||||
describe('reading display metadata', () => {
|
||||
it('reads a name and a description', async () => {
|
||||
const dir = await presetDir('name: 标准模式\ndescription: 完整的编码 agent。\n')
|
||||
|
||||
expect(await readPresetMetadata(dir)).toEqual({ name: '标准模式', description: '完整的编码 agent。' })
|
||||
})
|
||||
|
||||
it('treats an absent file as no metadata', async () => {
|
||||
// The common case: every preset authored by duplicating another starts
|
||||
// without one, and a picker simply falls back to the id.
|
||||
expect(await readPresetMetadata(await presetDir())).toEqual({})
|
||||
})
|
||||
|
||||
it('treats malformed YAML as no metadata', async () => {
|
||||
const dir = await presetDir('name: [unclosed\n')
|
||||
|
||||
// Display text is not worth failing discovery over — the composition
|
||||
// beside it still mounts.
|
||||
expect(await readPresetMetadata(dir)).toEqual({})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a list', '- name: x\n'],
|
||||
['a scalar', 'just a string\n'],
|
||||
['an empty document', ''],
|
||||
])('treats %s as no metadata', async (_label, content) => {
|
||||
expect(await readPresetMetadata(await presetDir(content))).toEqual({})
|
||||
})
|
||||
|
||||
it('ignores fields that are not text', async () => {
|
||||
const dir = await presetDir('name: 42\ndescription:\n nested: true\n')
|
||||
|
||||
expect(await readPresetMetadata(dir)).toEqual({})
|
||||
})
|
||||
|
||||
it('ignores blank text rather than showing an empty name', async () => {
|
||||
const dir = await presetDir('name: " "\ndescription: ""\n')
|
||||
|
||||
expect(await readPresetMetadata(dir)).toEqual({})
|
||||
})
|
||||
|
||||
it('trims surrounding whitespace', async () => {
|
||||
const dir = await presetDir('name: " 极简模式 "\n')
|
||||
|
||||
expect(await readPresetMetadata(dir)).toEqual({ name: '极简模式' })
|
||||
})
|
||||
|
||||
it('cannot carry identity or trust', async () => {
|
||||
const dir = await presetDir('name: mine\nid: standard\ntrust: system\n')
|
||||
|
||||
// A locally authored preset writing `trust: system` must not become a
|
||||
// shipped one; identity comes from the directory and the root it sits in.
|
||||
expect(await readPresetMetadata(dir)).toEqual({ name: 'mine' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('rendering display metadata', () => {
|
||||
it('round-trips through a read', async () => {
|
||||
const rendered = renderPresetMetadata({ name: '创造模式', description: '可以改自己的组装。' })
|
||||
const dir = await presetDir(rendered)
|
||||
|
||||
expect(await readPresetMetadata(dir)).toEqual({ name: '创造模式', description: '可以改自己的组装。' })
|
||||
})
|
||||
|
||||
it('omits an absent field rather than writing it blank', () => {
|
||||
expect(renderPresetMetadata({ name: '极简模式' })).toBe('name: 极简模式\n')
|
||||
// Description without a name is legal too: the picker falls back to the id.
|
||||
expect(renderPresetMetadata({ description: '只做检索。' })).toBe('description: 只做检索。\n')
|
||||
})
|
||||
|
||||
it('renders nothing when there is nothing to store', () => {
|
||||
// Clearing both fields removes the file; an empty document would read as
|
||||
// an intentional blank name.
|
||||
expect(renderPresetMetadata({})).toBeUndefined()
|
||||
expect(renderPresetMetadata({ name: ' ', description: '' })).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -252,6 +252,7 @@ export const TYPE_LINK_EXEMPTIONS: Readonly<Record<string, string>> = {
|
||||
InsertTextRequest: 'event-local request contract is owned by packages/client/ui-slash/src/types.ts',
|
||||
AgentHandle: 'agent ownership handle is owned by packages/core/agent/README.md',
|
||||
AgentPreset: 'discovered preset record is owned by packages/preset/agent-presets/README.md',
|
||||
PresetMetadata: 'preset display text is owned by packages/preset/agent-presets/README.md',
|
||||
BashEnvContributor: 'service-local extension type is owned by packages/bash/tool-bash/src/index.ts',
|
||||
BashEnvVariableInfo: 'service-local metadata type is owned by packages/bash/tool-bash/src/index.ts',
|
||||
CompactAgentContext: 'compaction service input is owned by packages/compact/compact/src/index.ts',
|
||||
|
||||
Reference in New Issue
Block a user