feat(web): author agent presets from a settings page
A composition is a file, but "edit it on the filesystem" is not a browser affordance. The roster gains `read`/`write`/`remove` beside `select`, and the browser gains a settings section over them: the presets as rows, one composition open in a YAML editor at a time, and per-row default, duplicate, and delete. All four authoring methods are loopback-pinned. A composition names the plugins a session runs, so reading one is reconnaissance, writing one is arbitrary capability, and selecting one can move a session onto a preset that edits the live runtime. `agentPreset.list` deliberately stays ordinary and now reports `authorable`, so a surface knows whether creating is possible at all rather than offering a button whose save always fails. Authoring starts by duplicating: a shipped preset opens read-only because the deployment's copy is what a broken local one is compared against. Ids are contained before they become directory names, and the text is parsed with the loader's own schema, so a save cannot leave a file no session could load. Fixes a defect the real-composition test found: a preset written under the user's home could never mount, because the loader resolves a row against the composition's own directory and Node's `node_modules` walk from there never reaches the installed harness. The mount now records the host base and sends bare specifiers there, leaving relative paths resolving from the preset. Also closes the coverage the earlier surfaces in this stack shipped without — the General row, the composer seat, and the plugin halves now have tests.
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
max-width: 720px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.intro {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.notice {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--dsw-alias-state-warn-label);
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 12px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.rows {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.rowCard {
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 12px;
|
||||
padding: 12px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
background: var(--dsw-alias-bg-layer-3);
|
||||
}
|
||||
|
||||
.rowHead {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.rowName {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge,
|
||||
.defaultBadge {
|
||||
border-radius: 999px;
|
||||
padding: 2px 8px;
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.defaultBadge {
|
||||
background: var(--dsw-alias-brand-primary);
|
||||
color: var(--dsw-alias-label-primary-foreground);
|
||||
}
|
||||
|
||||
.rowActions {
|
||||
display: inline-flex;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.secondaryButton {
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 999px;
|
||||
padding: 6px 14px;
|
||||
background: var(--dsw-alias-bg-layer-3);
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dangerButton {
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.secondaryButton:disabled,
|
||||
.dangerButton:disabled,
|
||||
.addButton:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.fieldLabel {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.input,
|
||||
.code {
|
||||
box-sizing: border-box;
|
||||
padding: 9px 12px;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 10px;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.code {
|
||||
font-family: var(--dsw-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
||||
line-height: 1.5;
|
||||
resize: vertical;
|
||||
white-space: pre;
|
||||
overflow-wrap: normal;
|
||||
overflow-x: auto;
|
||||
tab-size: 2;
|
||||
}
|
||||
|
||||
.input:focus,
|
||||
.code:focus {
|
||||
outline: none;
|
||||
border-color: var(--dsw-alias-brand-primary);
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
color: var(--dsw-alias-label-dimmed);
|
||||
}
|
||||
|
||||
/* A shipped composition is drawn a rung up, and it is the one most likely to
|
||||
overflow, so its scroll thumb rebinds to that rung. */
|
||||
.code[readonly] {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
background: var(--dsw-alias-bg-layer-2);
|
||||
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
|
||||
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
|
||||
}
|
||||
|
||||
.editorActions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.addCard {
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 12px;
|
||||
background: var(--dsw-alias-bg-layer-3);
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.addButton {
|
||||
align-self: flex-start;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 999px;
|
||||
padding: 8px 16px;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
background: var(--dsw-alias-bg-layer-3);
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
.deleteDialog {
|
||||
width: min(480px, 100%);
|
||||
}
|
||||
|
||||
.deleteConfirm:not(:disabled) {
|
||||
border-color: var(--dsw-alias-state-error-primary);
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
.deleteConfirm:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-interactive-bg-hover-danger);
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
/**
|
||||
* Agent-presets settings section: 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 authoring starts by duplicating one. Deleting a
|
||||
* preset leaves running sessions alone: a composition is mounted once at
|
||||
* session creation and nothing re-reads the file.
|
||||
*/
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { Button, Modal } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { draftBlocker, type AgentPresetSectionState, type PresetDraft } from './section-store.ts'
|
||||
import type { AgentPresetSettingsKey } from './locales.ts'
|
||||
import css from './AgentPresetSection.module.css'
|
||||
|
||||
/** Registration-side business face for the management section. */
|
||||
export interface AgentPresetSectionInjected {
|
||||
hooks: {
|
||||
/** Page snapshot bound by the renderer as useAgentPresetSection. */
|
||||
agentPresetSection: SnapshotStore<AgentPresetSectionState>
|
||||
}
|
||||
/** Read the roster; called once when the section first renders. */
|
||||
load: () => Promise<void>
|
||||
/** Open one preset's composition in the editor. */
|
||||
open: (id: string) => Promise<void>
|
||||
/** Open a copy of one preset — or of the default — as a new preset. */
|
||||
createFrom: (from?: string) => Promise<void>
|
||||
/** Close the editor, discarding the draft. */
|
||||
close: () => void
|
||||
/** Name the preset a new draft saves to. */
|
||||
setId: (id: string) => void
|
||||
/** Replace the draft's composition text. */
|
||||
setContent: (content: string) => void
|
||||
/** Save the open draft. */
|
||||
save: () => Promise<void>
|
||||
/** Ask for delete confirmation, or dismiss it with null. */
|
||||
confirmDelete: (id: string | null) => void
|
||||
/** Delete the preset awaiting confirmation. */
|
||||
remove: () => Promise<void>
|
||||
/** Make one preset the default for sessions created later. */
|
||||
makeDefault: (id: string) => Promise<void>
|
||||
}
|
||||
|
||||
/** Full component props. */
|
||||
export type AgentPresetSectionProps =
|
||||
PropsRuntime<'settings.section'>
|
||||
& PropsLocale<'settings.agentPreset'>
|
||||
& InjectFace<AgentPresetSectionInjected>
|
||||
|
||||
/** Editor sub-view props: the draft plus the actions that mutate it. */
|
||||
interface EditorProps {
|
||||
draft: PresetDraft
|
||||
blocker: ReturnType<typeof draftBlocker>
|
||||
t: (key: AgentPresetSettingsKey) => string
|
||||
actions: Pick<AgentPresetSectionInjected, 'close' | 'save' | 'setContent' | 'setId'>
|
||||
}
|
||||
|
||||
function Editor({ draft, blocker, t, actions }: EditorProps): ReactNode {
|
||||
const message = draft.error ?? (blocker === undefined ? null : t(blocker))
|
||||
return (
|
||||
<div className={css.editor}>
|
||||
{draft.creating
|
||||
? (
|
||||
<label className={css.field}>
|
||||
<span className={css.fieldLabel}>{t('presetName')}</span>
|
||||
<input
|
||||
className={css.input}
|
||||
value={draft.id}
|
||||
autoFocus
|
||||
spellCheck={false}
|
||||
placeholder={t('presetNamePlaceholder')}
|
||||
onChange={(event) => { actions.setId(event.target.value) }}
|
||||
/>
|
||||
<span className={css.hint}>{`${t('copyOf')} ${draft.source}`}</span>
|
||||
</label>
|
||||
)
|
||||
: null}
|
||||
{draft.writable ? null : <p className={css.notice}>{t('readOnlyNotice')}</p>}
|
||||
<label className={css.field}>
|
||||
<span className={css.fieldLabel}>{t('composition')}</span>
|
||||
<textarea
|
||||
className={css.code}
|
||||
value={draft.content}
|
||||
readOnly={!draft.writable}
|
||||
spellCheck={false}
|
||||
rows={16}
|
||||
onChange={(event) => { actions.setContent(event.target.value) }}
|
||||
/>
|
||||
</label>
|
||||
{message === null ? null : <p className={css.error} role="alert">{message}</p>}
|
||||
<div className={css.editorActions}>
|
||||
<Button variant="outline" disabled={draft.saving} onClick={() => { actions.close() }}>
|
||||
{draft.writable ? t('cancel') : t('close')}
|
||||
</Button>
|
||||
{draft.writable
|
||||
? (
|
||||
<Button
|
||||
disabled={draft.saving || blocker !== undefined}
|
||||
onClick={() => { void actions.save() }}
|
||||
>
|
||||
{draft.saving ? t('saving') : t('save')}
|
||||
</Button>
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the Agent presets section content column.
|
||||
* @param props - composed slot props.
|
||||
* @returns the section, or null when the deployment composes no presets.
|
||||
*/
|
||||
export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode {
|
||||
const { useAgentPresetSection, t, load } = props
|
||||
const state = useAgentPresetSection(snapshot => snapshot)
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
// A deployment that composes no presets has nothing to manage: every
|
||||
// session shares the host composition and the page would be an empty list.
|
||||
if (state.status === 'unavailable') return null
|
||||
if (state.status === 'error') {
|
||||
/* v8 ignore next -- an error status always carries text; the fallback satisfies the nullable type */
|
||||
const detail = state.error ?? ''
|
||||
return (
|
||||
<div className={css.section}>
|
||||
<p className={css.error} role="alert">{`${t('error')} ${detail}`}</p>
|
||||
<button type="button" className={css.secondaryButton} onClick={() => { void load() }}>
|
||||
{t('retry')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 }
|
||||
|
||||
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}>
|
||||
{state.rows.map(row => (
|
||||
<li key={row.id} className={css.rowCard}>
|
||||
<div className={css.rowHead}>
|
||||
<span className={css.rowName}>{row.id}</span>
|
||||
<span className={css.badge}>{row.trust === 'user' ? t('userTrust') : t('builtIn')}</span>
|
||||
{row.isDefault ? <span className={css.defaultBadge}>{t('defaultBadge')}</span> : null}
|
||||
<span className={css.rowActions}>
|
||||
{row.isDefault
|
||||
? null
|
||||
: (
|
||||
<button
|
||||
type="button"
|
||||
className={css.secondaryButton}
|
||||
onClick={() => { void props.makeDefault(row.id) }}
|
||||
>
|
||||
{t('setDefault')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={css.secondaryButton}
|
||||
onClick={() => { void props.open(row.id) }}
|
||||
>
|
||||
{row.trust === 'user' ? t('edit') : t('view')}
|
||||
</button>
|
||||
{state.authorable
|
||||
? (
|
||||
<button
|
||||
type="button"
|
||||
className={css.secondaryButton}
|
||||
onClick={() => { void props.createFrom(row.id) }}
|
||||
>
|
||||
{t('duplicate')}
|
||||
</button>
|
||||
)
|
||||
: null}
|
||||
{row.trust === 'user'
|
||||
? (
|
||||
<button
|
||||
type="button"
|
||||
className={css.dangerButton}
|
||||
onClick={() => { props.confirmDelete(row.id) }}
|
||||
>
|
||||
{t('delete')}
|
||||
</button>
|
||||
)
|
||||
: null}
|
||||
</span>
|
||||
</div>
|
||||
{draft !== null && !draft.creating && draft.source === row.id
|
||||
? <Editor draft={draft} blocker={blocker} t={t} actions={editorActions} />
|
||||
: null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{draft !== null && draft.creating
|
||||
? (
|
||||
<div className={css.addCard}>
|
||||
<Editor draft={draft} blocker={blocker} t={t} actions={editorActions} />
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<button
|
||||
type="button"
|
||||
className={css.addButton}
|
||||
disabled={!state.authorable || state.rows.length === 0}
|
||||
onClick={() => { void props.createFrom() }}
|
||||
>
|
||||
{`+ ${t('newPreset')}`}
|
||||
</button>
|
||||
)}
|
||||
<Modal
|
||||
open={state.pendingDelete !== null}
|
||||
onClose={() => { props.confirmDelete(null) }}
|
||||
title={t('deleteTitle')}
|
||||
closeLabel={t('close')}
|
||||
description={t('deleteDescription')}
|
||||
className={css.deleteDialog as string}
|
||||
footer={(
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
autoFocus
|
||||
disabled={state.deleting}
|
||||
onClick={() => { props.confirmDelete(null) }}
|
||||
>
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={css.deleteConfirm}
|
||||
disabled={state.deleting}
|
||||
onClick={() => { void props.remove() }}
|
||||
>
|
||||
{state.deleting ? t('deleting') : t('deleteConfirm')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,31 +1,41 @@
|
||||
/**
|
||||
* Agent-preset surface plugin, browser half — one General-settings row that
|
||||
* writes the default preset for sessions created later.
|
||||
* Agent-preset surface plugin, browser half — three surfaces over one roster:
|
||||
* a General-settings row for the default preset, a composer seat for the
|
||||
* session about to start, and a settings section that authors the compositions
|
||||
* themselves.
|
||||
*
|
||||
* 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.
|
||||
* adopt an existing session under a different preset), so the General row is
|
||||
* a new-session preference rather than a live switch, and the seat stops
|
||||
* offering a choice once the conversation starts.
|
||||
*/
|
||||
|
||||
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'
|
||||
// Type-only: pulls the settings shell's SlotMap merge (the 'settings.section' entry).
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { AgentPresetRow } from './AgentPresetRow.tsx'
|
||||
import type { AgentPresetRowInjected } from './AgentPresetRow.tsx'
|
||||
import { AgentPresetSeat } from './AgentPresetSeat.tsx'
|
||||
import type { AgentPresetSeatInjected } from './AgentPresetSeat.tsx'
|
||||
import { AgentPresetSection } from './AgentPresetSection.tsx'
|
||||
import type { AgentPresetSectionInjected } from './AgentPresetSection.tsx'
|
||||
import { AgentPresetSeatController } from './seat-store.ts'
|
||||
import { AgentPresetSectionController } from './section-store.ts'
|
||||
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 { AgentPresetSeatInjected, AgentPresetSeatProps } from './AgentPresetSeat.tsx'
|
||||
export type { AgentPresetSectionInjected, AgentPresetSectionProps } from './AgentPresetSection.tsx'
|
||||
export type { AgentPresetSeatState } from './seat-store.ts'
|
||||
export {
|
||||
draftBlocker, type AgentPresetSectionState, type PresetDraft, type PresetRow,
|
||||
} from './section-store.ts'
|
||||
export type { AgentPresetOption, AgentPresetSettingsState } from './settings-store.ts'
|
||||
export { AGENT_PRESET_SETTINGS_NS } from './settings-store.ts'
|
||||
export { AGENT_PRESET_SETTINGS_NS, writeDefaultPreset } from './settings-store.ts'
|
||||
|
||||
/** Required services (cordis fiber inject). */
|
||||
export const inject = ['slots', 'locale', 'connection']
|
||||
@@ -35,7 +45,9 @@ export const inject = ['slots', 'locale', 'connection']
|
||||
* @param ctx - the browser plugin context.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
const controller = new AgentPresetSettingsController((ctx.get('connection') as ConnectionHandle).api)
|
||||
const { api } = ctx.get('connection') as ConnectionHandle
|
||||
const controller = new AgentPresetSettingsController(api)
|
||||
const section = new AgentPresetSectionController(api)
|
||||
|
||||
ctx.effect(() => ctx.locale.register('settings.agentPreset', { zh, en }), 'ui-agent-preset: settings row dictionaries')
|
||||
|
||||
@@ -51,6 +63,9 @@ export function apply(ctx: ClientContext): void {
|
||||
const refresh = (ns?: string): void => {
|
||||
if (ns !== undefined && ns !== AGENT_PRESET_SETTINGS_NS) return
|
||||
void controller.load()
|
||||
// The section reads the same roster and marks the same default, so a
|
||||
// change made from either surface converges both.
|
||||
if (section.store.getSnapshot().status !== 'idle') void section.load()
|
||||
}
|
||||
const disposers = [
|
||||
ctx.on('settings/changed', refresh),
|
||||
@@ -90,6 +105,20 @@ export function apply(ctx: ClientContext): void {
|
||||
}, AgentPresetSeat), 'ui-agent-preset: composer seat registration')
|
||||
})
|
||||
|
||||
const sectionInjected = (): AgentPresetSectionInjected => ({
|
||||
hooks: { agentPresetSection: section.store },
|
||||
load: () => section.load(),
|
||||
open: (id: string) => section.open(id),
|
||||
createFrom: (from?: string) => section.createFrom(from),
|
||||
close: () => { section.close() },
|
||||
setId: (id: string) => { section.setId(id) },
|
||||
setContent: (content: string) => { section.setContent(content) },
|
||||
save: () => section.save(),
|
||||
confirmDelete: (id: string | null) => { section.confirmDelete(id) },
|
||||
remove: () => section.remove(),
|
||||
makeDefault: (id: string) => section.makeDefault(id),
|
||||
})
|
||||
|
||||
ctx.slots.inject('settings.general.item', () => ctx.slots.register({
|
||||
name: 'settings.general.item',
|
||||
id: 'agent-preset',
|
||||
@@ -97,4 +126,14 @@ export function apply(ctx: ClientContext): void {
|
||||
locale: 'settings.agentPreset',
|
||||
inject: injected,
|
||||
}, AgentPresetRow))
|
||||
// Ordered after Models: choosing a model is routine, and composing an
|
||||
// agent is the deployment-shaping act behind it.
|
||||
ctx.slots.inject('settings.section', () => ctx.slots.register({
|
||||
name: 'settings.section',
|
||||
id: 'agent-presets',
|
||||
order: 20,
|
||||
label: () => ctx.locale.bind('settings.agentPreset')('nav'),
|
||||
locale: 'settings.agentPreset',
|
||||
inject: sectionInjected,
|
||||
}, AgentPresetSection))
|
||||
}
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
/** Locale bundles for the agent-preset General-settings row. */
|
||||
/** Locale bundles for the agent-preset settings row, composer seat, and management section. */
|
||||
|
||||
/** Locale keys this row renders. */
|
||||
/** Locale keys these surfaces render. */
|
||||
export type AgentPresetSettingsKey =
|
||||
| 'title' | 'description' | 'loading' | 'error' | 'userTrust' | 'seatHint' | 'lockedHint'
|
||||
| 'nav' | 'sectionIntro' | 'builtIn' | 'defaultBadge' | 'setDefault' | 'edit' | 'view'
|
||||
| 'duplicate' | 'delete' | 'newPreset' | 'presetName' | 'presetNamePlaceholder' | 'copyOf'
|
||||
| 'composition' | 'readOnlyNotice' | 'save' | 'saving' | 'cancel' | 'close' | 'retry'
|
||||
| 'idRequired' | 'idInvalid' | 'idTaken'
|
||||
| 'deleteTitle' | 'deleteDescription' | 'deleteConfirm' | 'deleting'
|
||||
|
||||
/** English copy. */
|
||||
export const en: Record<AgentPresetSettingsKey, string> = {
|
||||
@@ -13,6 +18,36 @@ export const en: Record<AgentPresetSettingsKey, string> = {
|
||||
userTrust: 'Local',
|
||||
seatHint: 'Agent preset for this session — switchable until you send the first message',
|
||||
lockedHint: 'This session\'s agent preset is fixed once the conversation starts',
|
||||
nav: 'Agent presets',
|
||||
sectionIntro:
|
||||
'A preset is the plugin composition one session\'s agent runs — its tools, prompt, and capabilities. '
|
||||
+ 'Built-in presets are read-only; duplicate one to make your own.',
|
||||
builtIn: 'Built-in',
|
||||
defaultBadge: 'Default',
|
||||
setDefault: 'Set as default',
|
||||
edit: 'Edit',
|
||||
view: 'View',
|
||||
duplicate: 'Duplicate',
|
||||
delete: 'Delete',
|
||||
newPreset: 'New preset',
|
||||
presetName: 'Preset name',
|
||||
presetNamePlaceholder: 'my-agent',
|
||||
copyOf: 'Copied from',
|
||||
composition: 'Composition (cordis.yml)',
|
||||
readOnlyNotice: 'This preset ships with the deployment and cannot be edited. Duplicate it to make your own.',
|
||||
save: 'Save',
|
||||
saving: 'Saving…',
|
||||
cancel: 'Cancel',
|
||||
close: 'Close',
|
||||
retry: 'Retry',
|
||||
idRequired: 'Name the preset.',
|
||||
idInvalid: 'Use lowercase letters, digits, and hyphens, starting with a letter or digit.',
|
||||
idTaken: 'A preset with this name already exists.',
|
||||
deleteTitle: 'Delete this preset?',
|
||||
deleteDescription:
|
||||
'The composition file is deleted. Sessions already running on it keep working; new sessions cannot select it.',
|
||||
deleteConfirm: 'Delete',
|
||||
deleting: 'Deleting…',
|
||||
}
|
||||
|
||||
/** Simplified Chinese copy. */
|
||||
@@ -24,4 +59,31 @@ export const zh: Record<AgentPresetSettingsKey, string> = {
|
||||
userTrust: '本地',
|
||||
seatHint: '本会话的 agent preset —— 发送第一条消息前可切换',
|
||||
lockedHint: '会话开始后,其 agent preset 即固定',
|
||||
nav: 'Agent preset',
|
||||
sectionIntro: 'preset 即一个会话的 agent 所运行的插件组装 —— 它的工具、提示词与能力。内置 preset 只读;复制一份即可改成自己的。',
|
||||
builtIn: '内置',
|
||||
defaultBadge: '默认',
|
||||
setDefault: '设为默认',
|
||||
edit: '编辑',
|
||||
view: '查看',
|
||||
duplicate: '复制',
|
||||
delete: '删除',
|
||||
newPreset: '新建 preset',
|
||||
presetName: 'preset 名称',
|
||||
presetNamePlaceholder: 'my-agent',
|
||||
copyOf: '复制自',
|
||||
composition: '组装(cordis.yml)',
|
||||
readOnlyNotice: '该 preset 随部署提供,不可编辑。复制一份即可改成自己的。',
|
||||
save: '保存',
|
||||
saving: '正在保存…',
|
||||
cancel: '取消',
|
||||
close: '关闭',
|
||||
retry: '重试',
|
||||
idRequired: '请填写 preset 名称。',
|
||||
idInvalid: '只能使用小写字母、数字与连字符,且以字母或数字开头。',
|
||||
idTaken: '同名 preset 已存在。',
|
||||
deleteTitle: '删除该 preset?',
|
||||
deleteDescription: '组装文件将被删除。已在其上运行的会话不受影响;新会话将无法再选择它。',
|
||||
deleteConfirm: '删除',
|
||||
deleting: '正在删除…',
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import {
|
||||
createSnapshotStore, type SessionId, type SnapshotStore,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { messageOf } from './settings-store.ts'
|
||||
import type { AgentPresetOption } from './settings-store.ts'
|
||||
|
||||
/** Composer-seat snapshot for one session. */
|
||||
@@ -69,7 +70,7 @@ export class AgentPresetSeatController {
|
||||
error: null,
|
||||
})
|
||||
} catch (error) {
|
||||
this.set({ error: error instanceof Error ? error.message : String(error) })
|
||||
this.set({ error: messageOf(error) })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,11 +91,7 @@ export class AgentPresetSeatController {
|
||||
}
|
||||
this.set({ busy: false, current: response.result.value.agentPreset })
|
||||
} catch (error) {
|
||||
this.set({
|
||||
busy: false,
|
||||
current: before.current,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
this.set({ busy: false, current: before.current, error: messageOf(error) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
292
packages/client/ui-agent-preset/src/client/section-store.ts
Normal file
292
packages/client/ui-agent-preset/src/client/section-store.ts
Normal file
@@ -0,0 +1,292 @@
|
||||
/**
|
||||
* Agent-preset management controller: the roster as a list, and one
|
||||
* composition open in the editor at a time.
|
||||
*
|
||||
* The host stays the single fact source. Every mutation writes through the
|
||||
* wire and the page re-reads the roster afterwards, because a save can change
|
||||
* more than the row it targeted — creating a preset adds one, and the shape
|
||||
* check the host applies is what decides whether the text landed at all.
|
||||
*/
|
||||
|
||||
import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { messageOf, writeDefaultPreset } from './settings-store.ts'
|
||||
|
||||
/** Ids a preset directory may be named, mirroring the host's own rule. */
|
||||
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. */
|
||||
id: 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. */
|
||||
isDefault: boolean
|
||||
}
|
||||
|
||||
/** The composition currently open in the editor. */
|
||||
export interface PresetDraft {
|
||||
/** Preset the draft saves to; empty until a new one is named. */
|
||||
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.
|
||||
*/
|
||||
source: string
|
||||
/** Whether saving creates a preset rather than replacing one. */
|
||||
creating: boolean
|
||||
/** Composition text being edited. */
|
||||
content: string
|
||||
/** Whether this draft can be saved at all — a shipped preset opens read-only. */
|
||||
writable: boolean
|
||||
/** Whether a save is in flight. */
|
||||
saving: boolean
|
||||
/** The last save failure, cleared by the next edit. */
|
||||
error: string | null
|
||||
}
|
||||
|
||||
/** Page snapshot. */
|
||||
export interface AgentPresetSectionState {
|
||||
status: 'idle' | 'loading' | 'ready' | 'unavailable' | 'error'
|
||||
/** Whole-load failure text; a save failure stays on the draft. */
|
||||
error: string | null
|
||||
/** Whether the deployment configures a root new presets can be written to. */
|
||||
authorable: boolean
|
||||
/** Every preset the deployment currently supplies. */
|
||||
rows: readonly PresetRow[]
|
||||
/** The open editor, or null when the page is just the list. */
|
||||
draft: PresetDraft | null
|
||||
/** The preset awaiting delete confirmation. */
|
||||
pendingDelete: string | null
|
||||
/** Whether a delete is in flight. */
|
||||
deleting: boolean
|
||||
}
|
||||
|
||||
const INITIAL: AgentPresetSectionState = {
|
||||
status: 'idle',
|
||||
error: null,
|
||||
authorable: false,
|
||||
rows: [],
|
||||
draft: null,
|
||||
pendingDelete: null,
|
||||
deleting: false,
|
||||
}
|
||||
|
||||
/**
|
||||
* Why this draft cannot be saved yet, as a locale key, or undefined when it
|
||||
* can. Client-side only: the host re-checks both the id and the composition
|
||||
* shape, and its answer is what the editor reports on failure.
|
||||
* @param draft - the open draft.
|
||||
* @param rows - the roster, for the collision check.
|
||||
* @returns the blocking reason's locale key, or undefined when saveable.
|
||||
*/
|
||||
export function draftBlocker(
|
||||
draft: PresetDraft,
|
||||
rows: readonly PresetRow[],
|
||||
): 'idRequired' | 'idInvalid' | 'idTaken' | undefined {
|
||||
if (!draft.creating) return undefined
|
||||
if (draft.id === '') return 'idRequired'
|
||||
if (!PRESET_ID.test(draft.id)) return 'idInvalid'
|
||||
// Replacing an existing preset is what Edit is for; a create that lands on
|
||||
// a name already in use would overwrite something the user did not open.
|
||||
if (rows.some(row => row.id === draft.id)) return 'idTaken'
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Reads the roster and drives the composition editor. */
|
||||
export class AgentPresetSectionController {
|
||||
/** Page snapshot the renderer subscribes to. */
|
||||
readonly store: SnapshotStore<AgentPresetSectionState> = createSnapshotStore(INITIAL)
|
||||
|
||||
constructor(private readonly api: Pick<IApiClient, 'agentPresets' | 'settings'>) {}
|
||||
|
||||
private set(patch: Partial<AgentPresetSectionState>): void {
|
||||
this.store.set({ ...this.store.getSnapshot(), ...patch })
|
||||
}
|
||||
|
||||
private patchDraft(patch: Partial<PresetDraft>): void {
|
||||
const { draft } = this.store.getSnapshot()
|
||||
if (draft === null) return
|
||||
this.set({ draft: { ...draft, ...patch } })
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the roster. An empty roster means the deployment composes no
|
||||
* presets, which is a valid deployment rather than a failure — the section
|
||||
* 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, authorable } = response.result.value
|
||||
if (presets.length === 0) {
|
||||
this.set({ status: 'unavailable', rows: [], authorable, draft: null })
|
||||
return
|
||||
}
|
||||
this.set({ status: 'ready', error: null, authorable, rows: presets.map(preset => ({ ...preset })) })
|
||||
} catch (error) {
|
||||
this.set({ status: 'error', error: messageOf(error) })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open one preset's composition in the editor.
|
||||
*
|
||||
* A shipped preset opens read-only rather than not opening: it is the
|
||||
* known-good composition a local one is written against, so being able to
|
||||
* read it is the point.
|
||||
* @param id - the preset to open.
|
||||
* @returns once the composition loaded or the failure is on the page.
|
||||
*/
|
||||
async open(id: string): Promise<void> {
|
||||
await this.openDraft(id, false)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* @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)
|
||||
}
|
||||
|
||||
private async openDraft(source: string, creating: boolean): Promise<void> {
|
||||
this.set({ error: null })
|
||||
try {
|
||||
const response = await this.api.agentPresets.read({ agentPreset: source })
|
||||
if (!response.result.ok) {
|
||||
this.set({ error: response.result.error.message })
|
||||
return
|
||||
}
|
||||
const { content, writable } = response.result.value
|
||||
this.set({
|
||||
draft: {
|
||||
id: creating ? '' : source,
|
||||
source,
|
||||
creating,
|
||||
content,
|
||||
// A copy is always writable: it lands in the local root regardless of
|
||||
// where the text came from.
|
||||
writable: creating || writable,
|
||||
saving: false,
|
||||
error: null,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
this.set({ error: messageOf(error) })
|
||||
}
|
||||
}
|
||||
|
||||
/** Close the editor, discarding whatever was typed. */
|
||||
close(): void {
|
||||
this.set({ draft: null })
|
||||
}
|
||||
|
||||
/**
|
||||
* Name the preset a new draft saves to.
|
||||
* @param id - the id typed into the editor.
|
||||
*/
|
||||
setId(id: string): void {
|
||||
this.patchDraft({ id, error: null })
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the draft's composition text.
|
||||
* @param content - the text in the editor.
|
||||
*/
|
||||
setContent(content: string): void {
|
||||
this.patchDraft({ content, error: null })
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the open draft, then re-read the roster.
|
||||
*
|
||||
* The host shape-checks the text, so a composition that could never load is
|
||||
* refused here rather than at the next session that selects it.
|
||||
* @returns once the write settled and the page reflects it.
|
||||
*/
|
||||
async save(): Promise<void> {
|
||||
const draft = this.store.getSnapshot().draft
|
||||
if (draft === null || draft.saving || !draft.writable) return
|
||||
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 })
|
||||
if (!response.result.ok) {
|
||||
this.patchDraft({ saving: false, error: response.result.error.message })
|
||||
return
|
||||
}
|
||||
this.set({ draft: null })
|
||||
await this.load()
|
||||
} catch (error) {
|
||||
this.patchDraft({ saving: false, error: messageOf(error) })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask for confirmation before deleting one preset.
|
||||
* @param id - the preset to delete, or null to dismiss the confirmation.
|
||||
*/
|
||||
confirmDelete(id: string | null): void {
|
||||
if (this.store.getSnapshot().deleting) return
|
||||
this.set({ pendingDelete: id })
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the preset awaiting confirmation, then re-read the roster.
|
||||
*
|
||||
* A session already composed from it keeps running: its composition was
|
||||
* mounted at creation and nothing re-reads the file.
|
||||
* @returns once the delete settled and the page reflects it.
|
||||
*/
|
||||
async remove(): Promise<void> {
|
||||
const { pendingDelete, deleting, draft } = this.store.getSnapshot()
|
||||
if (pendingDelete === null || deleting) return
|
||||
this.set({ deleting: true, error: null })
|
||||
try {
|
||||
const response = await this.api.agentPresets.remove({ agentPreset: pendingDelete })
|
||||
if (!response.result.ok) {
|
||||
this.set({ deleting: false, pendingDelete: null, error: response.result.error.message })
|
||||
return
|
||||
}
|
||||
this.set({
|
||||
deleting: false,
|
||||
pendingDelete: null,
|
||||
// The editor cannot stay open on a file that no longer exists.
|
||||
draft: draft?.id === pendingDelete && !draft.creating ? null : draft,
|
||||
})
|
||||
await this.load()
|
||||
} catch (error) {
|
||||
this.set({ deleting: false, pendingDelete: null, error: messageOf(error) })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Make one preset the default for sessions created later. Running sessions
|
||||
* keep the composition they began with, so this never disturbs work.
|
||||
* @param id - the preset to make default.
|
||||
* @returns once the write settled and the roster was re-read.
|
||||
*/
|
||||
async makeDefault(id: string): Promise<void> {
|
||||
const failure = await writeDefaultPreset(this.api, id)
|
||||
if (failure !== undefined) {
|
||||
this.set({ error: failure })
|
||||
return
|
||||
}
|
||||
await this.load()
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,42 @@ import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client
|
||||
/** The agent-preset settings namespace on the host wire. */
|
||||
export const AGENT_PRESET_SETTINGS_NS = 'agent-presets'
|
||||
|
||||
/**
|
||||
* Human text for a rejected wire call. A transport failure rejects with an
|
||||
* Error; a host or a runtime can reject with anything, and the surface still
|
||||
* has to say something.
|
||||
* @param error - the rejection value.
|
||||
* @returns the message to show.
|
||||
*/
|
||||
export function messageOf(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist one preset as the default for sessions created later.
|
||||
*
|
||||
* The default is a settings field rather than a preset property, so both the
|
||||
* General row and the management section write it here — one home for which
|
||||
* namespace and field the host resolves at session creation.
|
||||
* @param api - the settings wire face.
|
||||
* @param id - the preset to make default.
|
||||
* @returns the failure message, or undefined once the write landed.
|
||||
*/
|
||||
export async function writeDefaultPreset(
|
||||
api: Pick<IApiClient, 'settings'>,
|
||||
id: string,
|
||||
): Promise<string | undefined> {
|
||||
let response
|
||||
try {
|
||||
response = await api.settings.update({ ns: AGENT_PRESET_SETTINGS_NS, patch: { default: id } })
|
||||
} catch (error) {
|
||||
// The transport rejected rather than answering; the caller must be able to
|
||||
// say so instead of the row silently snapping back.
|
||||
return messageOf(error)
|
||||
}
|
||||
return response.result.ok ? undefined : response.result.error.message
|
||||
}
|
||||
|
||||
/** One selectable preset. */
|
||||
export interface AgentPresetOption {
|
||||
/** Preset id, written to Settings and shown as the label. */
|
||||
@@ -65,7 +101,8 @@ export class AgentPresetSettingsController {
|
||||
return
|
||||
}
|
||||
const presets = response.result.value.presets
|
||||
if (presets.length === 0) {
|
||||
const [first] = presets
|
||||
if (first === undefined) {
|
||||
this.set({ status: 'unavailable', options: [], currentValue: '' })
|
||||
return
|
||||
}
|
||||
@@ -73,10 +110,12 @@ export class AgentPresetSettingsController {
|
||||
status: 'ready',
|
||||
error: null,
|
||||
options: presets.map(preset => ({ id: preset.id, trust: preset.trust })),
|
||||
currentValue: presets.find(preset => preset.isDefault)?.id ?? presets[0]?.id ?? '',
|
||||
// A roster can mark nothing default: settings can name a preset that
|
||||
// was since deleted, and the picker still has to show something.
|
||||
currentValue: presets.find(preset => preset.isDefault)?.id ?? first.id,
|
||||
})
|
||||
} catch (error) {
|
||||
this.set({ status: 'error', error: error instanceof Error ? error.message : String(error) })
|
||||
this.set({ status: 'error', error: messageOf(error) })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,24 +130,13 @@ export class AgentPresetSettingsController {
|
||||
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),
|
||||
})
|
||||
const failure = await writeDefaultPreset(this.api, id)
|
||||
if (failure !== undefined) {
|
||||
this.set({ status: 'ready', currentValue: before.currentValue, error: failure })
|
||||
return
|
||||
}
|
||||
// Re-read rather than trust the patch: the host resolves the default
|
||||
// through the same roster the row displays.
|
||||
await this.load()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user