feat(agents): tool levels, templates and connections in the agent editor

Part 2 of the agent editor redesign.

- Tool levels (Look only, Assistant, Full) set the stored switches and
  grants. Applying one, or a starting template, shows what changed and
  can be undone before saving. A level never grants a sensitive right.
- Templates are cards that say which level they start at.
- A Connections section holds the choice of connections an add-on may
  use and lists where the agent is connected. It uses the existing
  connections field and the messaging list.
- The model picker has search and shows what each model can do.
- Memory choices sit two by two to shorten the section.
- Advanced collapses, and the section nav sticks under the header on a
  phone.
- The overflow guards now cover 360 px, and the agent editor 320 px.

Assisted-by: Claude:claude-sonnet-5-5
This commit is contained in:
Ettore Di Giacinto committed 2026-11-05 01:15:34 +00:00
1 parent 62d75c0de0
commit 8cf8079b25
26 files changed
+1304 -62

No files matched your search

+2 -1
View File
@@ -541,7 +541,8 @@ test('create uses the chat picker, filters unusable models and persists the alia
await chip.click()
const options = page.getByRole('listbox').getByRole('option')
await expect(options.locator('code')).toHaveText(['Any model (the chat picks one)', 'production', 'alternate'])
await expect(options.locator('small')).toHaveText(['not loaded', 'not loaded'])
// The agent picker now draws the rich row (state and capabilities), so the state is the bold line.
await expect(options.locator('.home-menu__state b')).toHaveText(['not loaded', 'not loaded'])
await options.filter({ has: page.getByText('production', { exact: true }) }).click()
await page.getByRole('button', { name: 'Create Agent' }).first().click()
await expect(page).toHaveURL(/\/app\/agents$/)
@@ -0,0 +1,156 @@
import { test, expect } from './coverage-fixtures.js'
import { installAgents, DRAFT_TOOLS } from './agents-fixtures.js'
// The agent editor, part 2: tool levels and templates that can be undone, the
// Connections section, the model picker with search and capabilities, and the
// two layout fixes of part 1 (Advanced collapses, the phone nav sticks).
const MODELS = [
{ id: 'qwen3-8b', capabilities: ['FLAG_CHAT', 'FLAG_COMPLETION'] },
{ id: 'gemma-3-12b-it', capabilities: ['FLAG_CHAT', 'FLAG_VISION'] },
{ id: 'phi-4-mini', capabilities: ['FLAG_CHAT'] },
]
const WORK = { id: 'b1', name: 'Work account' }
const HOME = { id: 'b2', name: 'Home account' }
async function open(page, url = '/app/agents/new', { bindings = [] } = {}) {
const s = await installAgents(page, { models: MODELS, bindings })
await page.route('**/api/chats/draft/tools*', route => route.fulfill({
json: {
...DRAFT_TOOLS, servers: [], off: [], connectors: [],
addons: [{ id: 'tracker', tools: 14, off: false, title: 'Tracker', credentialed: true, connections: [WORK, HOME], enabled_connections: [] }, ...DRAFT_TOOLS.addons],
},
}))
await page.goto(url)
await expect(page.getByTestId('agent-edit')).toBeVisible()
return s
}
const tools = (page) => page.getByTestId('agent-tools-panel')
test.describe('Tool levels', () => {
test('a level sets the switches, says so, and can be undone before saving', async ({ page }) => {
const s = await open(page)
const files = tools(page).getByRole('switch', { name: /Your files/ })
await expect(files).toBeChecked()
await expect(page.getByTestId('agent-preset-state')).toContainText('Custom mix')
await page.getByTestId('agent-preset-readonly').click()
await expect(files).not.toBeChecked()
await expect(page.getByTestId('agent-preset-readonly').locator('input')).toBeChecked()
await expect(page.getByTestId('agent-preset-state')).toContainText('Level: Look only')
const note = page.getByTestId('agent-preset-applied')
await expect(note).toContainText('Applied the Look only level')
await expect(note).toContainText('Nothing is saved until you save')
await page.getByTestId('agent-preset-applied-undo').click()
await expect(note).toHaveCount(0)
await expect(files).toBeChecked()
await expect(page.getByTestId('agent-preset-state')).toContainText('Custom mix')
expect(s.calls.some(c => c.method === 'POST' || c.method === 'PUT')).toBe(false)
})
test('a level is sent as the stored switches and grants, and a change by hand makes it custom', async ({ page }) => {
const s = await open(page)
await page.getByTestId('agent-preset-assistant').click()
await expect(page.getByTestId('agent-preset-state')).toContainText('Level: Assistant')
await tools(page).getByRole('switch', { name: /Look things up online/ }).click()
await expect(page.getByTestId('agent-preset-state')).toContainText('Custom mix')
await page.getByTestId('agent-preset-assistant').click()
await page.getByLabel('Name', { exact: false }).first().fill('Helper')
await page.getByRole('button', { name: 'Create Agent' }).first().click()
await expect(page).toHaveURL(/\/app\/agents$/)
const body = s.calls.find(c => c.method === 'POST').body
expect(body.builtins_off).toContain('files')
expect(body.builtins_off).not.toContain('web')
expect(body.builtins_on).toContain('saved_agents')
expect(body.builtins).toEqual(expect.arrayContaining(['triggers.propose', 'memory.write']))
})
})
test.describe('Templates', () => {
test('a template fills the identity and starts at a level, and one click takes it back', async ({ page }) => {
await open(page)
const name = page.getByLabel('Name', { exact: false }).first()
await page.locator('[data-template="researcher"]').click()
await expect(name).toHaveValue('research-assistant')
await expect(page.locator('[data-template="researcher"]')).toHaveAttribute('aria-pressed', 'true')
await expect(page.getByTestId('agent-template-applied')).toContainText('Started from Research assistant')
await expect(page.getByTestId('agent-preset-state')).toContainText('Level: Look only')
await page.getByTestId('agent-template-applied-undo').click()
await expect(name).toHaveValue('')
await expect(page.getByLabel('Instructions')).toHaveValue('')
await expect(page.getByTestId('agent-preset-state')).toContainText('Custom mix')
})
})
test.describe('Connections', () => {
test('the section names the connections an add-on may use, and lists where the agent is connected', async ({ page }) => {
const bindings = [{ id: 'b9', name: 'Team', kind: 'slack', agent_id: 'a1', enabled: true, caps: ['triggers.propose', 'memory.write'] }]
const s = await open(page, '/app/agents/a1/edit', { bindings })
const section = page.locator('#aed-sec-connections')
await expect(section.getByTestId('agent-connections-hint')).toContainText('each chat chooses its own')
await section.getByLabel('Home account').check()
await expect(section.getByTestId('agent-connections-hint')).toContainText('only the ticked connections')
await expect(section.getByTestId('agent-connections-messaging')).toContainText('Slack Team')
await expect(section.getByTestId('agent-connections-messaging')).toContainText('allows 2 rights')
// The choice is drawn here and not a second time in the Tools list.
await expect(tools(page).getByTestId('agent-tools-pick')).toHaveCount(0)
await page.getByRole('button', { name: 'Save Changes' }).first().click()
await expect(page).toHaveURL(/\/app\/agents$/)
expect(s.calls.find(c => c.method === 'PUT').body.connections).toEqual({ tracker: ['b2'] })
})
test('with nothing to connect the section says so', async ({ page }) => {
await installAgents(page, { models: MODELS })
await page.goto('/app/agents/new')
const section = page.locator('#aed-sec-connections')
await expect(section.getByTestId('agent-connections-addons-empty')).toBeVisible()
await expect(section.getByTestId('agent-connections-messaging-empty')).toBeVisible()
})
})
test.describe('Model picker', () => {
test('the list can be searched and each model shows what it can do', async ({ page }) => {
await open(page, '/app/agents/a1/edit')
await expect(page.getByTestId('agent-model-caps')).toContainText('Chat')
await page.getByTestId('home-model-chip').first().click()
const filter = page.getByRole('combobox', { name: 'Filter models' })
await expect(filter).toBeVisible()
await expect(page.getByRole('option', { name: /qwen3-8b/ })).toContainText('completion')
await expect(page.getByRole('option', { name: /gemma-3-12b-it/ }).locator('[data-icon="eye"]')).toHaveCount(1)
await filter.fill('gemma')
await expect(page.getByRole('option', { name: /phi-4-mini/ })).toHaveCount(0)
await page.getByRole('option', { name: /gemma-3-12b-it/ }).click()
await expect(page.getByTestId('agent-model-caps')).toContainText('Understands images')
})
})
test.describe('Layout', () => {
test('Advanced collapses and opens again, and a jump from the nav opens it', async ({ page }) => {
await open(page, '/app/agents/a1/edit')
const toggle = page.getByTestId('agent-toggle-advanced')
await expect(toggle).toHaveAttribute('aria-expanded', 'true')
await expect(page.getByTestId('agent-mcp')).toBeVisible()
await toggle.click()
await expect(toggle).toHaveAttribute('aria-expanded', 'false')
await expect(page.getByTestId('agent-mcp')).toBeHidden()
await page.getByTestId('agent-nav-advanced').click()
await expect(toggle).toHaveAttribute('aria-expanded', 'true')
await expect(page.getByTestId('agent-mcp')).toBeVisible()
})
test('on a phone the section nav stays under the header while the page scrolls', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 800 })
await open(page, '/app/agents/a1/edit')
await page.evaluate(() => window.scrollTo(0, 1400))
await page.waitForTimeout(200)
const box = async (sel) => page.locator(sel).evaluate(el => { const r = el.getBoundingClientRect(); return { top: r.top, bottom: r.bottom } })
const bar = await box('.aed-bar')
const nav = await box('.aed-nav')
expect(bar.top).toBeGreaterThanOrEqual(-1)
expect(bar.top).toBeLessThanOrEqual(1)
expect(nav.top).toBeGreaterThanOrEqual(bar.bottom - 3)
expect(nav.top).toBeLessThanOrEqual(bar.bottom + 3)
})
})
@@ -140,13 +140,15 @@ test.describe('The connections an agent names', () => {
}))
await page.goto('/app/agents/new')
await page.getByLabel('Name', { exact: false }).first().fill('Reviewer')
// The agent form draws this choice in its Connections section.
const panel = page.getByTestId('agent-tools-panel')
const group = panel.getByRole('group', { name: 'Connections for chats of this agent' })
const section = page.getByTestId('agent-connections')
const group = section.getByRole('group', { name: 'Connections for Tracker' })
await expect(group).toBeVisible()
await expect(panel.getByTestId('agent-tools-pick-hint')).toContainText('each chat chooses its own')
await expect(section.getByTestId('agent-connections-hint')).toContainText('each chat chooses its own')
await group.getByLabel('Home').check()
await group.getByLabel('Play').check()
await expect(panel.getByTestId('agent-tools-pick-hint')).toContainText('cannot change them')
await expect(section.getByTestId('agent-connections-hint')).toContainText('only the ticked connections')
await panel.getByRole('switch', { name: /Tracker/ }).click()
await page.getByRole('button', { name: 'Create Agent' }).first().click()
await expect(page).toHaveURL(/\/app\/agents$/)
@@ -14,11 +14,13 @@ import { mockLedger, GALLERY } from './ledger-fixtures.js'
// that is meant to scroll (code, a table) does so inside its own box; the
// allowlist names those.
const WIDTHS = [390, 768]
const WIDTHS = [360, 390, 768]
// Boxes that scroll on their own, by selector.
// The strips are one row of tabs or chips that scroll on a phone by design
// (the Settings tabs outgrow 390 px with a wider font).
const SCROLLS_INSIDE = ['pre', 'code', 'table', '.ag-pre', '.wk-shortcuts__chips', '.st-groups', '.dk-hubtabs']
// (the Settings tabs outgrow 390 px with a wider font). The agent editor nav
// is one row of chips that scrolls inside itself on a phone, and so is the
// add-ons tab strip (it first overflowed at 360, the width added here).
const SCROLLS_INSIDE = ['pre', 'code', 'table', '.ag-pre', '.wk-shortcuts__chips', '.st-groups', '.dk-hubtabs', '.aed-nav', '.ad-tabs']
const join = (n, s) => Array.from({ length: n }, (_, i) => `${s}${i}`).join('_')
const WORD = join(9, 'unbroken_name_segment')
@@ -193,7 +195,8 @@ const plainBindings = [
{ id: 'b2', name: 'Personal', kind: 'telegram', agent_id: 'a2', enabled: true, caps: plainCaps.filter(c => c !== 'triggers.run') },
]
for (const width of [1280, 1100, 390]) {
// 320 is the narrowest phone the editor supports; 360 is the common one.
for (const width of [1280, 1100, 390, 360, 320]) {
for (const collapsed of [false, true]) {
test.describe(`plain agent editor at ${width}px, sidebar ${collapsed ? 'collapsed' : 'open'}`, () => {
test.use({ viewport: { width, height: 900 } })
@@ -82,7 +82,8 @@
"export": "Export",
"Files": "Dateien",
"Tools": "Werkzeuge",
"Notes": "Gedächtnis"
"Notes": "Gedächtnis",
"Connections": "Verbindungen"
},
"name": "Name",
"description": "Description",
@@ -157,13 +158,15 @@
"identity": "Identität",
"capabilities": "Fähigkeiten",
"memory": "Gedächtnis und Wissen",
"advanced": "Erweitert"
"advanced": "Erweitert",
"connections": "Verbindungen"
},
"lead": {
"identity": "Wer der Agent ist und wie er sich verhält.",
"capabilities": "Was der Agent nutzen und erreichen darf.",
"memory": "Woran sich der Agent erinnert und was er nachschlagen kann.",
"advanced": "Zusätzliche Server und Export."
"advanced": "Zusätzliche Server und Export.",
"connections": "Welche Verbindungen dieser Agent nutzen darf."
},
"header": {
"unnamed": "Unbenannter Agent",
@@ -175,6 +178,54 @@
"clean": "Keine ungespeicherten Änderungen",
"new": "Noch nicht erstellt",
"unsaved": "Ungespeicherte Änderungen ({{count}})"
},
"preset": {
"label": "Werkzeugstufe",
"loading": "Werkzeugliste wird geladen",
"current": "Stufe: {{name}}. Ändern Sie unten einen Schalter, wird daraus eine eigene Mischung.",
"custom": "Eigene Mischung von Schaltern. Wählen Sie eine Stufe, um sie zu ersetzen.",
"applied": "Stufe {{name}} angewendet",
"appliedBody": "Die Schalter unten haben sich geändert. Nichts wird gespeichert, bis Sie speichern.",
"readonly": {
"title": "Nur nachsehen",
"body": "Sucht im Internet und in Ihren Sammlungen. Keine Dateien, keine Befehle, keine Trigger."
},
"assistant": {
"title": "Assistent",
"body": "Fügt gespeicherte Agenten, Trigger und vorgeschlagene Änderungen hinzu. Keine Dateien, keine Befehle."
},
"full": {
"title": "Voll",
"body": "Jedes Werkzeug, das der Server auflistet. Sensible Rechte bleiben aus."
}
},
"tpl": {
"applied": "Gestartet mit {{name}}",
"appliedBody": "Name, Beschreibung, Anweisungen und Werkzeuge haben sich geändert. Nichts wird gespeichert, bis Sie speichern.",
"tools": "Werkzeuge: {{level}}",
"noTools": "Werkzeuge: unverändert"
},
"undo": "Rückgängig",
"keep": "Behalten",
"modelCaps": "Was dieses Modell kann",
"modelChat": "Chat",
"connections": {
"addonsTitle": "Add-ons mit Verbindung",
"addonsNone": "Kein Add-on braucht eine Verbindung.",
"messagingTitle": "Wo dieser Agent verbunden ist",
"messagingNone": "Noch keine Messaging-Verbindung nutzt diesen Agenten. Speichern Sie den Agenten und fügen Sie dann eine hinzu.",
"messagingHelp": "Messaging-Verbindungen werden auf ihrer eigenen Seite eingestellt.",
"allows_one": "erlaubt {{count}} Recht",
"allows_other": "erlaubt {{count}} Rechte",
"open": "Öffnen",
"addonOff": "aus",
"noneYet": "Noch keine Verbindung.",
"add": "Verbindung hinzufügen",
"groupLabel": "Verbindungen für {{name}}",
"chosenHelp": "Chats dieses Agenten dürfen nur die angehakten Verbindungen nutzen.",
"perChat": "Nichts angehakt: Jeder Chat wählt selbst.",
"summary_one": "{{count}} Verbindung",
"summary_other": "{{count}} Verbindungen"
}
},
"run": {
@@ -538,5 +589,19 @@
"effectiveWrite": "Liegt in einem Ordner, der geändert werden kann, daher kann auch dieser geändert werden.",
"notMounted": "{{n}} geteilte Ordner passen nicht in das Zugriffslimit eines Chats und sind nicht eingebunden.",
"exportNoteMany": "Die Datei nennt {{n}} Ordner. Beim Import erhält der Agent die Ordner mit diesen Pfaden, die es gibt, und bittet dich, die anderen zu wählen."
},
"templates": {
"researcher": {
"description": "Sucht, liest Quellen und schreibt kurze Berichte."
},
"reviewer": {
"description": "Prüft eine Änderung und nennt zuerst, was zu beheben ist."
},
"digest": {
"description": "Sammelt Neues zu einem Thema und fasst es zusammen."
},
"blank": {
"description": "Leer. Von Null beginnen."
}
}
}
@@ -112,16 +112,20 @@
},
"templates": {
"researcher": {
"label": "Research assistant"
"label": "Research assistant",
"description": "Searches, reads sources and writes short briefs."
},
"reviewer": {
"label": "Code reviewer"
"label": "Code reviewer",
"description": "Reviews a change and lists what to fix first."
},
"digest": {
"label": "Daily digest"
"label": "Daily digest",
"description": "Collects what is new on a topic and summarises it."
},
"blank": {
"label": "Blank"
"label": "Blank",
"description": "Empty. Start from scratch."
}
},
"agent": {
@@ -191,7 +195,8 @@
"export": "Export",
"Files": "Files",
"Tools": "Tools",
"Notes": "Memory"
"Notes": "Memory",
"Connections": "Connections"
},
"grantsNoticeTitle": "Some messaging features will turn off.",
"grantsNotice": "This agent had no permission list, so it kept these on its messaging connections: {{grants}}. They stop working until you tick them here.",
@@ -260,13 +265,15 @@
"identity": "Identity",
"capabilities": "Capabilities",
"memory": "Memory and knowledge",
"advanced": "Advanced"
"advanced": "Advanced",
"connections": "Connections"
},
"lead": {
"identity": "Who the agent is and how it behaves.",
"capabilities": "What the agent may use and reach.",
"memory": "What the agent remembers and what it can look up.",
"advanced": "Extra servers and export."
"advanced": "Extra servers and export.",
"connections": "Which connections this agent may use."
},
"header": {
"unnamed": "Unnamed agent",
@@ -278,6 +285,54 @@
"clean": "No unsaved changes",
"new": "Not created yet",
"unsaved": "Unsaved changes ({{count}})"
},
"preset": {
"label": "Tool level",
"loading": "Loading the tool list",
"current": "Level: {{name}}. Change a switch below and it becomes a custom mix.",
"custom": "Custom mix of switches. Pick a level to replace it.",
"applied": "Applied the {{name}} level",
"appliedBody": "The switches below changed. Nothing is saved until you save.",
"readonly": {
"title": "Look only",
"body": "Searches the web and your collections. No files, no commands, no triggers."
},
"assistant": {
"title": "Assistant",
"body": "Adds saved agents, triggers and suggested changes. No files, no commands."
},
"full": {
"title": "Full",
"body": "Every tool the server lists. Sensitive rights stay off."
}
},
"tpl": {
"applied": "Started from {{name}}",
"appliedBody": "The name, description, instructions and tools changed. Nothing is saved until you save.",
"tools": "Tools: {{level}}",
"noTools": "Tools: as they are"
},
"undo": "Undo",
"keep": "Keep",
"modelCaps": "What this model can do",
"modelChat": "Chat",
"connections": {
"addonsTitle": "Add-ons that use a connection",
"addonsNone": "No add-on needs a connection.",
"messagingTitle": "Where this agent is connected",
"messagingNone": "No messaging connection uses this agent yet. Save the agent, then add one.",
"messagingHelp": "Messaging connections are set on their own page.",
"allows_one": "allows {{count}} right",
"allows_other": "allows {{count}} rights",
"open": "Open",
"addonOff": "off",
"noneYet": "No connection yet.",
"add": "Add a connection",
"groupLabel": "Connections for {{name}}",
"chosenHelp": "Chats of this agent may use only the ticked connections.",
"perChat": "None ticked: each chat chooses its own.",
"summary_one": "{{count}} connection",
"summary_other": "{{count}} connections"
}
},
"status": {
@@ -82,7 +82,8 @@
"export": "Export",
"Files": "Archivos",
"Tools": "Herramientas",
"Notes": "Memoria"
"Notes": "Memoria",
"Connections": "Conexiones"
},
"name": "Name",
"description": "Description",
@@ -157,13 +158,15 @@
"identity": "Identidad",
"capabilities": "Capacidades",
"memory": "Memoria y conocimiento",
"advanced": "Avanzado"
"advanced": "Avanzado",
"connections": "Conexiones"
},
"lead": {
"identity": "Quién es el agente y cómo se comporta.",
"capabilities": "Lo que el agente puede usar y alcanzar.",
"memory": "Lo que el agente recuerda y lo que puede consultar.",
"advanced": "Servidores adicionales y exportación."
"advanced": "Servidores adicionales y exportación.",
"connections": "Qué conexiones puede usar este agente."
},
"header": {
"unnamed": "Agente sin nombre",
@@ -175,6 +178,54 @@
"clean": "Sin cambios sin guardar",
"new": "Aún no creado",
"unsaved": "Cambios sin guardar ({{count}})"
},
"preset": {
"label": "Nivel de herramientas",
"loading": "Cargando la lista de herramientas",
"current": "Nivel: {{name}}. Si cambia un interruptor de abajo, pasa a ser una mezcla propia.",
"custom": "Mezcla propia de interruptores. Elija un nivel para sustituirla.",
"applied": "Nivel {{name}} aplicado",
"appliedBody": "Los interruptores de abajo han cambiado. No se guarda nada hasta que guarde.",
"readonly": {
"title": "Solo consultar",
"body": "Busca en la web y en sus colecciones. Sin archivos, sin comandos, sin disparadores."
},
"assistant": {
"title": "Asistente",
"body": "Añade agentes guardados, disparadores y cambios sugeridos. Sin archivos, sin comandos."
},
"full": {
"title": "Completo",
"body": "Todas las herramientas que lista el servidor. Los permisos sensibles siguen desactivados."
}
},
"tpl": {
"applied": "Empezado desde {{name}}",
"appliedBody": "El nombre, la descripción, las instrucciones y las herramientas han cambiado. No se guarda nada hasta que guarde.",
"tools": "Herramientas: {{level}}",
"noTools": "Herramientas: sin cambios"
},
"undo": "Deshacer",
"keep": "Conservar",
"modelCaps": "Qué puede hacer este modelo",
"modelChat": "Chat",
"connections": {
"addonsTitle": "Complementos que usan una conexión",
"addonsNone": "Ningún complemento necesita una conexión.",
"messagingTitle": "Dónde está conectado este agente",
"messagingNone": "Ninguna conexión de mensajería usa todavía este agente. Guarde el agente y añada una.",
"messagingHelp": "Las conexiones de mensajería se configuran en su propia página.",
"allows_one": "permite {{count}} permiso",
"allows_other": "permite {{count}} permisos",
"open": "Abrir",
"addonOff": "desactivado",
"noneYet": "Aún no hay ninguna conexión.",
"add": "Añadir una conexión",
"groupLabel": "Conexiones para {{name}}",
"chosenHelp": "Los chats de este agente solo pueden usar las conexiones marcadas.",
"perChat": "Ninguna marcada: cada chat elige la suya.",
"summary_one": "{{count}} conexión",
"summary_other": "{{count}} conexiones"
}
},
"run": {
@@ -538,5 +589,19 @@
"effectiveWrite": "Está dentro de una carpeta que se puede cambiar, así que esta también se puede cambiar.",
"notMounted": "{{n}} carpetas compartidas no caben en el límite de acceso de un chat y no se montan.",
"exportNoteMany": "El archivo nombra {{n}} carpetas. Donde lo importes, el agente recibe las carpetas con esas rutas que existan y te pide elegir las demás."
},
"templates": {
"researcher": {
"description": "Busca, lee fuentes y escribe resúmenes breves."
},
"reviewer": {
"description": "Revisa un cambio y lista primero lo que hay que corregir."
},
"digest": {
"description": "Reúne las novedades de un tema y las resume."
},
"blank": {
"description": "Vacío. Empiece desde cero."
}
}
}
@@ -82,7 +82,8 @@
"export": "Export",
"Files": "Fichiers",
"Tools": "Outils",
"Notes": "Mémoire"
"Notes": "Mémoire",
"Connections": "Connexions"
},
"name": "Name",
"description": "Description",
@@ -157,13 +158,15 @@
"identity": "Identité",
"capabilities": "Capacités",
"memory": "Mémoire et connaissances",
"advanced": "Avancé"
"advanced": "Avancé",
"connections": "Connexions"
},
"lead": {
"identity": "Qui est l'agent et comment il se comporte.",
"capabilities": "Ce que l'agent peut utiliser et atteindre.",
"memory": "Ce dont l'agent se souvient et ce qu'il peut consulter.",
"advanced": "Serveurs supplémentaires et export."
"advanced": "Serveurs supplémentaires et export.",
"connections": "Les connexions que cet agent peut utiliser."
},
"header": {
"unnamed": "Agent sans nom",
@@ -175,6 +178,54 @@
"clean": "Aucune modification non enregistrée",
"new": "Pas encore créé",
"unsaved": "Modifications non enregistrées ({{count}})"
},
"preset": {
"label": "Niveau d'outils",
"loading": "Chargement de la liste d'outils",
"current": "Niveau : {{name}}. Si vous changez un interrupteur ci-dessous, cela devient un mélange personnalisé.",
"custom": "Mélange personnalisé d'interrupteurs. Choisissez un niveau pour le remplacer.",
"applied": "Niveau {{name}} appliqué",
"appliedBody": "Les interrupteurs ci-dessous ont changé. Rien n'est enregistré avant que vous enregistriez.",
"readonly": {
"title": "Consultation seule",
"body": "Cherche sur le web et dans vos collections. Pas de fichiers, pas de commandes, pas de déclencheurs."
},
"assistant": {
"title": "Assistant",
"body": "Ajoute les agents enregistrés, les déclencheurs et les modifications suggérées. Pas de fichiers, pas de commandes."
},
"full": {
"title": "Complet",
"body": "Tous les outils que le serveur liste. Les droits sensibles restent désactivés."
}
},
"tpl": {
"applied": "Démarré depuis {{name}}",
"appliedBody": "Le nom, la description, les instructions et les outils ont changé. Rien n'est enregistré avant que vous enregistriez.",
"tools": "Outils : {{level}}",
"noTools": "Outils : inchangés"
},
"undo": "Annuler",
"keep": "Garder",
"modelCaps": "Ce que ce modèle sait faire",
"modelChat": "Chat",
"connections": {
"addonsTitle": "Modules qui utilisent une connexion",
"addonsNone": "Aucun module n'a besoin d'une connexion.",
"messagingTitle": "Où cet agent est connecté",
"messagingNone": "Aucune connexion de messagerie n'utilise encore cet agent. Enregistrez l'agent, puis ajoutez-en une.",
"messagingHelp": "Les connexions de messagerie se règlent sur leur propre page.",
"allows_one": "autorise {{count}} droit",
"allows_other": "autorise {{count}} droits",
"open": "Ouvrir",
"addonOff": "désactivé",
"noneYet": "Aucune connexion pour l'instant.",
"add": "Ajouter une connexion",
"groupLabel": "Connexions pour {{name}}",
"chosenHelp": "Les discussions de cet agent ne peuvent utiliser que les connexions cochées.",
"perChat": "Aucune cochée : chaque discussion choisit la sienne.",
"summary_one": "{{count}} connexion",
"summary_other": "{{count}} connexions"
}
},
"run": {
@@ -538,5 +589,19 @@
"effectiveWrite": "Dans un dossier modifiable : celui-ci peut aussi être modifié.",
"notMounted": "{{n}} dossiers partagés ne tiennent pas dans la limite d'accès d'un chat et ne sont pas montés.",
"exportNoteMany": "Le fichier nomme {{n}} dossiers. Là où vous l'importez, l'agent reçoit les dossiers de ces chemins qui existent et vous demande de choisir les autres."
},
"templates": {
"researcher": {
"description": "Cherche, lit des sources et rédige de courtes synthèses."
},
"reviewer": {
"description": "Examine une modification et liste d'abord ce qu'il faut corriger."
},
"digest": {
"description": "Réunit les nouveautés d'un sujet et les résume."
},
"blank": {
"description": "Vide. Partez de zéro."
}
}
}
@@ -82,7 +82,8 @@
"export": "Export",
"Files": "File",
"Tools": "Alat",
"Notes": "Memori"
"Notes": "Memori",
"Connections": "Koneksi"
},
"name": "Name",
"description": "Description",
@@ -157,13 +158,15 @@
"identity": "Identitas",
"capabilities": "Kemampuan",
"memory": "Memori dan pengetahuan",
"advanced": "Lanjutan"
"advanced": "Lanjutan",
"connections": "Koneksi"
},
"lead": {
"identity": "Siapa agen ini dan bagaimana ia bersikap.",
"capabilities": "Apa yang boleh digunakan dan dijangkau agen.",
"memory": "Apa yang diingat agen dan apa yang bisa dicarinya.",
"advanced": "Server tambahan dan ekspor."
"advanced": "Server tambahan dan ekspor.",
"connections": "Koneksi mana yang boleh dipakai agen ini."
},
"header": {
"unnamed": "Agen tanpa nama",
@@ -175,6 +178,54 @@
"clean": "Tidak ada perubahan yang belum disimpan",
"new": "Belum dibuat",
"unsaved": "Perubahan belum disimpan ({{count}})"
},
"preset": {
"label": "Tingkat alat",
"loading": "Memuat daftar alat",
"current": "Tingkat: {{name}}. Ubah satu sakelar di bawah dan jadilah campuran kustom.",
"custom": "Campuran sakelar kustom. Pilih satu tingkat untuk menggantinya.",
"applied": "Tingkat {{name}} diterapkan",
"appliedBody": "Sakelar di bawah berubah. Tidak ada yang disimpan sampai Anda menyimpan.",
"readonly": {
"title": "Hanya melihat",
"body": "Mencari di web dan koleksi Anda. Tanpa berkas, tanpa perintah, tanpa pemicu."
},
"assistant": {
"title": "Asisten",
"body": "Menambahkan agen tersimpan, pemicu, dan perubahan yang disarankan. Tanpa berkas, tanpa perintah."
},
"full": {
"title": "Penuh",
"body": "Semua alat yang didaftarkan server. Hak sensitif tetap mati."
}
},
"tpl": {
"applied": "Dimulai dari {{name}}",
"appliedBody": "Nama, deskripsi, instruksi, dan alat berubah. Tidak ada yang disimpan sampai Anda menyimpan.",
"tools": "Alat: {{level}}",
"noTools": "Alat: tidak berubah"
},
"undo": "Urungkan",
"keep": "Pertahankan",
"modelCaps": "Apa yang bisa dilakukan model ini",
"modelChat": "Obrolan",
"connections": {
"addonsTitle": "Add-on yang memakai koneksi",
"addonsNone": "Tidak ada add-on yang butuh koneksi.",
"messagingTitle": "Tempat agen ini terhubung",
"messagingNone": "Belum ada koneksi pesan yang memakai agen ini. Simpan agen, lalu tambahkan satu.",
"messagingHelp": "Koneksi pesan diatur di halamannya sendiri.",
"allows_one": "mengizinkan {{count}} hak",
"allows_other": "mengizinkan {{count}} hak",
"open": "Buka",
"addonOff": "mati",
"noneYet": "Belum ada koneksi.",
"add": "Tambah koneksi",
"groupLabel": "Koneksi untuk {{name}}",
"chosenHelp": "Obrolan agen ini hanya boleh memakai koneksi yang dicentang.",
"perChat": "Tidak ada yang dicentang: tiap obrolan memilih sendiri.",
"summary_one": "{{count}} koneksi",
"summary_other": "{{count}} koneksi"
}
},
"run": {
@@ -538,5 +589,19 @@
"effectiveWrite": "Ada di dalam folder yang bisa diubah, jadi folder ini juga bisa diubah.",
"notMounted": "{{n}} folder bersama tidak muat dalam batas akses sebuah chat dan tidak dipasang.",
"exportNoteMany": "File ini menyebut {{n}} folder. Di tempat Anda mengimpornya, agen mendapat folder dengan path itu yang ada, dan meminta Anda memilih sisanya."
},
"templates": {
"researcher": {
"description": "Mencari, membaca sumber, dan menulis ringkasan singkat."
},
"reviewer": {
"description": "Meninjau perubahan dan mendaftar yang perlu diperbaiki lebih dulu."
},
"digest": {
"description": "Mengumpulkan yang baru pada suatu topik dan merangkumnya."
},
"blank": {
"description": "Kosong. Mulai dari nol."
}
}
}
@@ -82,7 +82,8 @@
"export": "Export",
"Files": "File",
"Tools": "Strumenti",
"Notes": "Memoria"
"Notes": "Memoria",
"Connections": "Connessioni"
},
"name": "Name",
"description": "Description",
@@ -157,13 +158,15 @@
"identity": "Identità",
"capabilities": "Capacità",
"memory": "Memoria e conoscenza",
"advanced": "Avanzate"
"advanced": "Avanzate",
"connections": "Connessioni"
},
"lead": {
"identity": "Chi è l'agente e come si comporta.",
"capabilities": "Cosa può usare e raggiungere l'agente.",
"memory": "Cosa ricorda l'agente e cosa può consultare.",
"advanced": "Server aggiuntivi ed esportazione."
"advanced": "Server aggiuntivi ed esportazione.",
"connections": "Quali connessioni può usare questo agente."
},
"header": {
"unnamed": "Agente senza nome",
@@ -175,6 +178,54 @@
"clean": "Nessuna modifica non salvata",
"new": "Non ancora creato",
"unsaved": "Modifiche non salvate ({{count}})"
},
"preset": {
"label": "Livello degli strumenti",
"loading": "Caricamento dell'elenco degli strumenti",
"current": "Livello: {{name}}. Se cambi un interruttore qui sotto diventa una combinazione personalizzata.",
"custom": "Combinazione personalizzata di interruttori. Scegli un livello per sostituirla.",
"applied": "Livello {{name}} applicato",
"appliedBody": "Gli interruttori qui sotto sono cambiati. Non viene salvato nulla finché non salvi.",
"readonly": {
"title": "Solo consultazione",
"body": "Cerca sul web e nelle tue raccolte. Niente file, niente comandi, niente trigger."
},
"assistant": {
"title": "Assistente",
"body": "Aggiunge agenti salvati, trigger e modifiche suggerite. Niente file, niente comandi."
},
"full": {
"title": "Completo",
"body": "Tutti gli strumenti elencati dal server. I permessi sensibili restano disattivati."
}
},
"tpl": {
"applied": "Partito da {{name}}",
"appliedBody": "Nome, descrizione, istruzioni e strumenti sono cambiati. Non viene salvato nulla finché non salvi.",
"tools": "Strumenti: {{level}}",
"noTools": "Strumenti: invariati"
},
"undo": "Annulla",
"keep": "Mantieni",
"modelCaps": "Cosa sa fare questo modello",
"modelChat": "Chat",
"connections": {
"addonsTitle": "Add-on che usano una connessione",
"addonsNone": "Nessun add-on ha bisogno di una connessione.",
"messagingTitle": "Dove è connesso questo agente",
"messagingNone": "Nessuna connessione di messaggistica usa ancora questo agente. Salva l'agente, poi aggiungine una.",
"messagingHelp": "Le connessioni di messaggistica si impostano nella loro pagina.",
"allows_one": "consente {{count}} permesso",
"allows_other": "consente {{count}} permessi",
"open": "Apri",
"addonOff": "disattivato",
"noneYet": "Ancora nessuna connessione.",
"add": "Aggiungi una connessione",
"groupLabel": "Connessioni per {{name}}",
"chosenHelp": "Le chat di questo agente possono usare solo le connessioni selezionate.",
"perChat": "Nessuna selezionata: ogni chat sceglie la sua.",
"summary_one": "{{count}} connessione",
"summary_other": "{{count}} connessioni"
}
},
"run": {
@@ -538,5 +589,19 @@
"effectiveWrite": "È dentro una cartella modificabile, quindi anche questa si può modificare.",
"notMounted": "{{n}} cartelle condivise non rientrano nel limite di accesso di una chat e non sono montate.",
"exportNoteMany": "Il file nomina {{n}} cartelle. Dove lo importi, l'agente riceve le cartelle con quei percorsi che esistono e ti chiede di scegliere le altre."
},
"templates": {
"researcher": {
"description": "Cerca, legge le fonti e scrive brevi sintesi."
},
"reviewer": {
"description": "Esamina una modifica ed elenca prima ciò che va corretto."
},
"digest": {
"description": "Raccoglie le novità su un argomento e le riassume."
},
"blank": {
"description": "Vuoto. Parti da zero."
}
}
}
@@ -82,7 +82,8 @@
"export": "Export",
"Files": "파일",
"Tools": "도구",
"Notes": "메모리"
"Notes": "메모리",
"Connections": "연결"
},
"name": "Name",
"description": "Description",
@@ -157,13 +158,15 @@
"identity": "정체성",
"capabilities": "기능",
"memory": "메모리와 지식",
"advanced": "고급"
"advanced": "고급",
"connections": "연결"
},
"lead": {
"identity": "에이전트가 누구이고 어떻게 행동하는지 정합니다.",
"capabilities": "에이전트가 사용하고 접근할 수 있는 것을 정합니다.",
"memory": "에이전트가 기억하는 것과 찾아볼 수 있는 것을 정합니다.",
"advanced": "추가 서버와 내보내기."
"advanced": "추가 서버와 내보내기.",
"connections": "이 에이전트가 사용할 수 있는 연결입니다."
},
"header": {
"unnamed": "이름 없는 에이전트",
@@ -175,6 +178,54 @@
"clean": "저장하지 않은 변경 사항 없음",
"new": "아직 만들지 않음",
"unsaved": "저장하지 않은 변경 사항 ({{count}})"
},
"preset": {
"label": "도구 수준",
"loading": "도구 목록을 불러오는 중",
"current": "수준: {{name}}. 아래 스위치를 바꾸면 사용자 지정 조합이 됩니다.",
"custom": "사용자 지정 스위치 조합입니다. 수준을 고르면 바뀝니다.",
"applied": "{{name}} 수준을 적용했습니다",
"appliedBody": "아래 스위치가 바뀌었습니다. 저장하기 전에는 아무것도 저장되지 않습니다.",
"readonly": {
"title": "보기만",
"body": "웹과 컬렉션을 검색합니다. 파일, 명령, 트리거는 없습니다."
},
"assistant": {
"title": "어시스턴트",
"body": "저장된 에이전트, 트리거, 제안된 변경을 더합니다. 파일과 명령은 없습니다."
},
"full": {
"title": "전체",
"body": "서버가 나열하는 모든 도구입니다. 민감한 권한은 꺼진 채로 둡니다."
}
},
"tpl": {
"applied": "{{name}}에서 시작함",
"appliedBody": "이름, 설명, 지침, 도구가 바뀌었습니다. 저장하기 전에는 아무것도 저장되지 않습니다.",
"tools": "도구: {{level}}",
"noTools": "도구: 그대로"
},
"undo": "실행 취소",
"keep": "유지",
"modelCaps": "이 모델이 할 수 있는 일",
"modelChat": "채팅",
"connections": {
"addonsTitle": "연결을 쓰는 애드온",
"addonsNone": "연결이 필요한 애드온이 없습니다.",
"messagingTitle": "이 에이전트가 연결된 곳",
"messagingNone": "이 에이전트를 쓰는 메시징 연결이 아직 없습니다. 에이전트를 저장한 뒤 추가하세요.",
"messagingHelp": "메시징 연결은 해당 페이지에서 설정합니다.",
"allows_one": "권한 {{count}}개 허용",
"allows_other": "권한 {{count}}개 허용",
"open": "열기",
"addonOff": "꺼짐",
"noneYet": "아직 연결이 없습니다.",
"add": "연결 추가",
"groupLabel": "{{name}}의 연결",
"chosenHelp": "이 에이전트의 채팅은 선택한 연결만 쓸 수 있습니다.",
"perChat": "선택 없음: 채팅마다 직접 고릅니다.",
"summary_one": "연결 {{count}}개",
"summary_other": "연결 {{count}}개"
}
},
"run": {
@@ -538,5 +589,19 @@
"effectiveWrite": "변경할 수 있는 폴더 안에 있어서 이 폴더도 변경할 수 있습니다.",
"notMounted": "공유 폴더 {{n}}개가 채팅의 접근 한도에 맞지 않아 마운트되지 않았습니다.",
"exportNoteMany": "이 파일에는 폴더 {{n}}개가 적혀 있습니다. 가져오는 곳에서는 해당 경로에 실제로 있는 폴더가 에이전트에 지정되고, 나머지는 직접 고르라고 안내합니다."
},
"templates": {
"researcher": {
"description": "검색하고 출처를 읽어 짧은 요약을 씁니다."
},
"reviewer": {
"description": "변경 사항을 검토하고 먼저 고칠 것을 나열합니다."
},
"digest": {
"description": "주제의 새 소식을 모아 요약합니다."
},
"blank": {
"description": "비어 있음. 처음부터 시작합니다."
}
}
}
@@ -82,7 +82,8 @@
"export": "Export",
"Files": "Arquivos",
"Tools": "Ferramentas",
"Notes": "Memória"
"Notes": "Memória",
"Connections": "Conexões"
},
"name": "Name",
"description": "Description",
@@ -157,13 +158,15 @@
"identity": "Identidade",
"capabilities": "Capacidades",
"memory": "Memória e conhecimento",
"advanced": "Avançado"
"advanced": "Avançado",
"connections": "Conexões"
},
"lead": {
"identity": "Quem é o agente e como ele se comporta.",
"capabilities": "O que o agente pode usar e alcançar.",
"memory": "O que o agente lembra e o que ele pode consultar.",
"advanced": "Servidores extras e exportação."
"advanced": "Servidores extras e exportação.",
"connections": "Quais conexões este agente pode usar."
},
"header": {
"unnamed": "Agente sem nome",
@@ -175,6 +178,54 @@
"clean": "Nenhuma alteração não salva",
"new": "Ainda não criado",
"unsaved": "Alterações não salvas ({{count}})"
},
"preset": {
"label": "Nível de ferramentas",
"loading": "Carregando a lista de ferramentas",
"current": "Nível: {{name}}. Se você mudar uma chave abaixo, vira uma combinação própria.",
"custom": "Combinação própria de chaves. Escolha um nível para substituí-la.",
"applied": "Nível {{name}} aplicado",
"appliedBody": "As chaves abaixo mudaram. Nada é salvo até você salvar.",
"readonly": {
"title": "Só consulta",
"body": "Pesquisa na web e nas suas coleções. Sem arquivos, sem comandos, sem gatilhos."
},
"assistant": {
"title": "Assistente",
"body": "Adiciona agentes salvos, gatilhos e mudanças sugeridas. Sem arquivos, sem comandos."
},
"full": {
"title": "Completo",
"body": "Todas as ferramentas que o servidor lista. Os direitos sensíveis continuam desligados."
}
},
"tpl": {
"applied": "Iniciado a partir de {{name}}",
"appliedBody": "Nome, descrição, instruções e ferramentas mudaram. Nada é salvo até você salvar.",
"tools": "Ferramentas: {{level}}",
"noTools": "Ferramentas: como estão"
},
"undo": "Desfazer",
"keep": "Manter",
"modelCaps": "O que este modelo sabe fazer",
"modelChat": "Chat",
"connections": {
"addonsTitle": "Complementos que usam uma conexão",
"addonsNone": "Nenhum complemento precisa de uma conexão.",
"messagingTitle": "Onde este agente está conectado",
"messagingNone": "Nenhuma conexão de mensagens usa este agente ainda. Salve o agente e adicione uma.",
"messagingHelp": "As conexões de mensagens são definidas na página própria.",
"allows_one": "permite {{count}} direito",
"allows_other": "permite {{count}} direitos",
"open": "Abrir",
"addonOff": "desligado",
"noneYet": "Ainda não há conexão.",
"add": "Adicionar uma conexão",
"groupLabel": "Conexões para {{name}}",
"chosenHelp": "Os chats deste agente só podem usar as conexões marcadas.",
"perChat": "Nenhuma marcada: cada chat escolhe a sua.",
"summary_one": "{{count}} conexão",
"summary_other": "{{count}} conexões"
}
},
"run": {
@@ -538,5 +589,19 @@
"effectiveWrite": "Está dentro de uma pasta que pode ser alterada, então esta também pode ser alterada.",
"notMounted": "{{n}} pastas compartilhadas não cabem no limite de acesso de um chat e não são montadas.",
"exportNoteMany": "O arquivo cita {{n}} pastas. Onde você importar, o agente recebe as pastas com esses caminhos que existirem e pede que você escolha as outras."
},
"templates": {
"researcher": {
"description": "Pesquisa, lê fontes e escreve resumos curtos."
},
"reviewer": {
"description": "Revisa uma mudança e lista primeiro o que corrigir."
},
"digest": {
"description": "Reúne as novidades de um tema e as resume."
},
"blank": {
"description": "Vazio. Comece do zero."
}
}
}
@@ -82,7 +82,8 @@
"export": "Export",
"Files": "文件",
"Tools": "工具",
"Notes": "记忆"
"Notes": "记忆",
"Connections": "连接"
},
"name": "Name",
"description": "Description",
@@ -157,13 +158,15 @@
"identity": "身份",
"capabilities": "能力",
"memory": "记忆与知识",
"advanced": "高级"
"advanced": "高级",
"connections": "连接"
},
"lead": {
"identity": "智能体是谁,以及它如何行事。",
"capabilities": "智能体可以使用和访问的内容。",
"memory": "智能体记住什么,以及可以查找什么。",
"advanced": "额外的服务器和导出。"
"advanced": "额外的服务器和导出。",
"connections": "此代理可以使用哪些连接。"
},
"header": {
"unnamed": "未命名的智能体",
@@ -175,6 +178,54 @@
"clean": "没有未保存的更改",
"new": "尚未创建",
"unsaved": "未保存的更改({{count}})"
},
"preset": {
"label": "工具级别",
"loading": "正在加载工具列表",
"current": "级别:{{name}}。更改下方任一开关后,它就成为自定义组合。",
"custom": "自定义开关组合。选择一个级别可替换它。",
"applied": "已应用{{name}}级别",
"appliedBody": "下方的开关已更改。保存之前不会存储任何内容。",
"readonly": {
"title": "仅查看",
"body": "搜索网页和你的集合。没有文件、命令和触发器。"
},
"assistant": {
"title": "助手",
"body": "加入已保存的代理、触发器和建议的更改。没有文件和命令。"
},
"full": {
"title": "完整",
"body": "服务器列出的所有工具。敏感权限保持关闭。"
}
},
"tpl": {
"applied": "从{{name}}开始",
"appliedBody": "名称、描述、指令和工具已更改。保存之前不会存储任何内容。",
"tools": "工具:{{level}}",
"noTools": "工具:保持不变"
},
"undo": "撤销",
"keep": "保留",
"modelCaps": "此模型能做什么",
"modelChat": "聊天",
"connections": {
"addonsTitle": "使用连接的附加组件",
"addonsNone": "没有附加组件需要连接。",
"messagingTitle": "此代理连接到哪里",
"messagingNone": "还没有消息连接使用此代理。请先保存代理,再添加连接。",
"messagingHelp": "消息连接在各自的页面中设置。",
"allows_one": "允许 {{count}} 项权限",
"allows_other": "允许 {{count}} 项权限",
"open": "打开",
"addonOff": "已关闭",
"noneYet": "还没有连接。",
"add": "添加连接",
"groupLabel": "{{name}}的连接",
"chosenHelp": "此代理的聊天只能使用已勾选的连接。",
"perChat": "未勾选:每个聊天自行选择。",
"summary_one": "{{count}} 个连接",
"summary_other": "{{count}} 个连接"
}
},
"run": {
@@ -538,5 +589,19 @@
"effectiveWrite": "位于可修改的文件夹内,因此这个文件夹也可以修改。",
"notMounted": "有 {{n}} 个共享文件夹超出了聊天的访问上限,未挂载。",
"exportNoteMany": "文件列出了 {{n}} 个文件夹。导入时,智能体会使用这些路径中存在的文件夹,并请你为其余的重新选择。"
},
"templates": {
"researcher": {
"description": "搜索、阅读来源并撰写简短摘要。"
},
"reviewer": {
"description": "审阅一项更改,并先列出需要修复的内容。"
},
"digest": {
"description": "收集某个主题的新内容并加以总结。"
},
"blank": {
"description": "空白。从头开始。"
}
}
}
@@ -27,6 +27,7 @@ const FILTER_FROM = 8
// models, loading the list is already held by the caller, so no second fetch
// grouped, loadedIds, showWarm, describe, onOpen, footer, labels
// defaultChoice, defaultSelected, onChooseDefault a first row for "use the default"
// describe(name, warm) -> { size, vision, caps: [label], fit } a richer row; caps are small tags
const ModelPicker = forwardRef(function ModelPicker(
{
value, onChange, capability, loadedIds, disabled = false, placeholder,
@@ -291,10 +292,11 @@ const ModelPicker = forwardRef(function ModelPicker(
<span className="home-menu__main">
<code>{name === null ? defaultChoice : name}</code>
{badges(name)}
{(info.size || info.vision) && (
{(info.size || info.vision || info.caps?.length > 0) && (
<small className="home-menu__caps">
{info.size && <span>{info.size}</span>}
{info.vision && <Icon name="eye" title={t('picker.vision')} />}
{(info.caps || []).map(c => <span key={c} className="home-menu__tag" data-kind="cap">{c}</span>)}
</small>
)}
</span>
@@ -0,0 +1,80 @@
/* eslint-disable no-unused-vars -- components used only inside JSX look unused to this config, which has no eslint-plugin-react */
import { useId } from 'react'
import { Link } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import './agentparts.css'
// The Connections section of the agent form: which connections of the person
// the agent's add-ons may use, and where the agent is already connected. Both
// lists come from data the form already holds (the add-on list of the draft
// tools and the messaging connections). Nothing is stored here except the
// `connections` field the form always had.
export default function AgentConnections({ addons, bindings, onChange }) {
const { t } = useTranslation('agents')
const credentialed = addons.filter((a) => a.credentialed && !a.gone)
return (
<div className="aed-conn" data-testid="agent-connections">
<div className="aed-conn__block">
<h4 className="aed-conn__h">{t('create.connections.addonsTitle')}</h4>
{credentialed.length === 0
? <p className="ag-note" data-testid="agent-connections-addons-empty">{t('create.connections.addonsNone')}</p>
: (
<ul className="aed-conn__list">
{credentialed.map((a) => <AddonRow key={a.id} addon={a} onChange={onChange} />)}
</ul>
)}
</div>
<div className="aed-conn__block">
<h4 className="aed-conn__h">{t('create.connections.messagingTitle')}</h4>
{bindings.length === 0
? <p className="ag-note" data-testid="agent-connections-messaging-empty">{t('create.connections.messagingNone')}</p>
: (
<ul className="aed-conn__list" data-testid="agent-connections-messaging">
{bindings.map((b) => (
<li key={b.id} className="aed-conn__item">
<span className="aed-conn__name">{b.name}</span>
<span className="aed-conn__meta">{t('create.connections.allows', { count: b.caps.length })}</span>
<Link className="aed-conn__link" to={b.to}>{t('create.connections.open')}</Link>
</li>
))}
</ul>
)}
<p className="ag-note">{t('create.connections.messagingHelp')}</p>
</div>
</div>
)
}
function AddonRow({ addon, onChange }) {
const { t } = useTranslation('agents')
const hintId = useId()
const options = addon.connections || []
const chosen = addon.enabled_connections || []
const toggle = (id, on) => onChange(addon.id, options.map((o) => o.id).filter((x) => (x === id ? on : chosen.includes(x))))
return (
<li className="aed-conn__item aed-conn__item--col" data-testid="agent-connections-addon">
<div className="aed-conn__top">
<span className="aed-conn__name">{addon.title || addon.id}</span>
{addon.off && <span className="aed-conn__tag">{t('create.connections.addonOff')}</span>}
</div>
{options.length === 0 ? (
<p className="ag-note">
{t('create.connections.noneYet')}{' '}
<Link className="aed-conn__link" to="/app/chats/connectors">{t('create.connections.add')}</Link>
</p>
) : (
<div role="group" aria-label={t('create.connections.groupLabel', { name: addon.title || addon.id })} aria-describedby={hintId} className="aed-conn__checks">
{options.map((o) => (
<label key={o.id} className="aed-conn__check" data-testid="agent-connections-option">
<input type="checkbox" checked={chosen.includes(o.id)} data-value={o.id} onChange={(e) => toggle(o.id, e.target.checked)} />
<span>{o.name}</span>
</label>
))}
</div>
)}
<span className="ag-note" id={hintId} data-testid="agent-connections-hint">
{chosen.length ? t('create.connections.chosenHelp') : t('create.connections.perChat')}
</span>
</li>
)
}
@@ -0,0 +1,53 @@
/* eslint-disable no-unused-vars -- components used only inside JSX look unused to this config, which has no eslint-plugin-react */
import { useId } from 'react'
import { useTranslation } from 'react-i18next'
import { Notice } from '../ui'
import { CAPABILITY_PRESETS } from '../../utils/agentPresets'
import './agentparts.css'
// What an applied level or template says about itself, with the way back.
export function AppliedNotice({ applied, onUndo, onKeep, testId }) {
const { t } = useTranslation('agents')
if (!applied) return null
return (
<Notice
severity="info"
icon="check-circle"
testId={testId}
title={applied.kind === 'template' ? t('create.tpl.applied', { name: applied.name }) : t('create.preset.applied', { name: applied.name })}
reason={applied.kind === 'template' ? t('create.tpl.appliedBody') : t('create.preset.appliedBody')}
fix={{ label: t('create.undo'), onClick: onUndo, testId: `${testId}-undo` }}
secondary={{ label: t('create.keep'), onClick: onKeep, testId: `${testId}-keep` }}
/>
)
}
// The capability levels: one choice that sets the tool switches and the
// grants in the form. Nothing is saved until the person saves. When the
// switches match none of the levels the group says it is a custom mix.
export default function AgentPresets({ active, ready, onPick }) {
const { t } = useTranslation('agents')
const labelId = useId()
return (
<div className="aed-preset" data-testid="agent-presets">
<p className="aed-preset__label" id={labelId}>{t('create.preset.label')}</p>
<div className="aed-preset__cards" role="radiogroup" aria-labelledby={labelId}>
{CAPABILITY_PRESETS.map((p) => (
<label key={p.id} className="aed-preset__card" data-on={active === p.id || undefined} data-testid={`agent-preset-${p.id}`}>
<input
type="radio" name="agent-preset" value={p.id} checked={active === p.id} disabled={!ready}
onChange={() => onPick(p.id)}
/>
<span className="aed-preset__text">
<span className="aed-preset__title">{t(`create.preset.${p.id}.title`)}</span>
<span className="aed-preset__body">{t(`create.preset.${p.id}.body`)}</span>
</span>
</label>
))}
</div>
<p className="aed-preset__state" data-testid="agent-preset-state" role="status">
{ready ? (active ? t('create.preset.current', { name: t(`create.preset.${active}.title`) }) : t('create.preset.custom')) : t('create.preset.loading')}
</p>
</div>
)
}
@@ -0,0 +1,22 @@
import { useTranslation } from 'react-i18next'
import './agentparts.css'
// The starting points of a new agent, as cards: what it is, and the level of
// tools it starts at. Picking one fills the form and can be undone.
export default function AgentTemplates({ templates, picked, onPick }) {
const { t } = useTranslation('agents')
return (
<div className="aed-tpl" role="group" aria-label={t('create.startFrom')}>
{templates.map((tpl) => (
<button
key={tpl.id} type="button" className="aed-tpl__card" aria-pressed={picked === tpl.id}
data-template={tpl.id} onClick={() => onPick(tpl)}
>
<span className="aed-tpl__name">{t(`templates.${tpl.id}.label`)}</span>
<span className="aed-tpl__body">{t(`templates.${tpl.id}.description`)}</span>
<span className="aed-tpl__tools">{tpl.preset ? t('create.tpl.tools', { level: t(`create.preset.${tpl.preset}.title`) }) : t('create.tpl.noTools')}</span>
</button>
))}
</div>
)
}
@@ -0,0 +1,77 @@
/* Parts of the agent editor: capability levels, starting points, connections,
model capabilities. Every class starts with aed-. Grid children carry
min-width: 0 so a long name wraps and never widens the page. */
.aed-preset { display: grid; gap: var(--sp-2); min-width: 0; margin-bottom: var(--sp-4); }
.aed-preset__label { margin: 0; font-size: var(--fs-small); font-weight: 600; color: var(--dk-text); }
.aed-preset__cards { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--sp-2); min-width: 0; }
.aed-preset__card {
display: flex; align-items: flex-start; gap: var(--sp-2); min-width: 0; padding: var(--sp-3);
border-radius: var(--r-md); box-shadow: inset 0 0 0 1px var(--dk-edge); cursor: pointer;
}
.aed-preset__card[data-on] { background: var(--dk-accent-wash); box-shadow: inset 0 0 0 1px var(--dk-accent); }
.aed-preset__card input { margin-top: 3px; flex: none; accent-color: var(--dk-accent); }
.aed-preset__card:focus-within { outline: 2px solid var(--focus-ring-color); outline-offset: 2px; }
.aed-preset__text { display: grid; gap: 2px; min-width: 0; }
.aed-preset__title { font-weight: 600; color: var(--dk-text); }
.aed-preset__body { font-size: var(--fs-small); color: var(--dk-muted); overflow-wrap: anywhere; }
.aed-preset__state { margin: 0; font-size: var(--fs-small); color: var(--dk-muted); }
.aed-tpl { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 200px), 1fr)); gap: var(--sp-2); min-width: 0; }
.aed-tpl__card {
display: grid; align-content: start; gap: 4px; min-width: 0; padding: var(--sp-3); text-align: left;
border: 0; border-radius: var(--r-md); background: transparent; color: inherit; font: inherit;
box-shadow: inset 0 0 0 1px var(--dk-edge); cursor: pointer;
}
.aed-tpl__card:hover { background: var(--dk-hover); }
.aed-tpl__card[aria-pressed="true"] { background: var(--dk-accent-wash); box-shadow: inset 0 0 0 1px var(--dk-accent); }
.aed-tpl__card:focus-visible { outline: 2px solid var(--focus-ring-color); outline-offset: 2px; }
.aed-tpl__name { font-weight: 600; color: var(--dk-text); overflow-wrap: anywhere; }
.aed-tpl__body { font-size: var(--fs-small); color: var(--dk-muted); overflow-wrap: anywhere; }
.aed-tpl__tools { font-size: var(--fs-meta); color: var(--dk-accent-text); }
.aed-conn { display: grid; gap: var(--sp-5); min-width: 0; }
.aed-conn__block { display: grid; gap: var(--sp-2); min-width: 0; }
.aed-conn__h { margin: 0; font-size: var(--fs-small); font-weight: 600; color: var(--dk-text); }
.aed-conn__list { display: grid; gap: var(--sp-2); margin: 0; padding: 0; list-style: none; min-width: 0; }
.aed-conn__item {
display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-1) var(--sp-3); min-width: 0;
padding: var(--sp-2) var(--sp-3); border-radius: var(--r-md); box-shadow: inset 0 0 0 1px var(--dk-edge);
}
.aed-conn__item--col { display: grid; align-items: start; }
.aed-conn__top { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2); min-width: 0; }
.aed-conn__name { min-width: 0; font-weight: 500; color: var(--dk-text); overflow-wrap: anywhere; }
.aed-conn__meta { font-size: var(--fs-small); color: var(--dk-muted); }
.aed-conn__tag { padding: 0 6px; border-radius: var(--r-sm); background: var(--dk-hover); color: var(--dk-muted); font-size: var(--fs-meta); }
.aed-conn__link { margin-left: auto; font-size: var(--fs-small); color: var(--dk-accent-text); }
.aed-conn__checks { display: flex; flex-wrap: wrap; gap: var(--sp-1) var(--sp-4); min-width: 0; }
.aed-conn__check { display: inline-flex; align-items: center; gap: var(--sp-2); min-width: 0; overflow-wrap: anywhere; }
.aed-caps { display: flex; flex-wrap: wrap; gap: var(--sp-1); margin: var(--sp-2) 0 0; padding: 0; list-style: none; }
.aed-caps__chip { padding: 0 8px; border-radius: var(--r-sm); background: var(--dk-hover); color: var(--dk-muted); font-size: var(--fs-meta); }
.aed-caps__lead { margin: var(--sp-2) 0 0; font-size: var(--fs-small); color: var(--dk-muted); }
.aed-tools .ui-callout { margin-bottom: var(--sp-3); }
/* A card whose heading collapses it. */
.aed-card__toggle {
display: inline-flex; align-items: center; gap: var(--sp-2); min-width: 0; padding: 0; border: 0;
background: none; color: inherit; font: inherit; cursor: pointer; text-align: left;
}
.aed-card__toggle:focus-visible { outline: 2px solid var(--focus-ring-color); outline-offset: 2px; border-radius: var(--r-sm); }
.aed-card__chev { flex: none; transition: transform 120ms; }
.aed-card__toggle[aria-expanded="false"] .aed-card__chev { transform: rotate(-90deg); }
.aed-card__body { display: flex; flex-direction: column; gap: var(--sp-5); min-width: 0; }
.aed-card__body[hidden] { display: none; }
@media (max-width: 640px) {
.aed-preset__cards { grid-template-columns: minmax(0, 1fr); }
}
@media (prefers-reduced-motion: reduce) { .aed-card__chev { transition: none; } }
/* The memory choices sit two by two when there is room, so the section is half as tall. */
.aed-card .am__cards { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.aed-card .am__card { padding: var(--sp-2) var(--sp-3); }
@media (max-width: 640px) {
.aed-card .am__cards { grid-template-columns: minmax(0, 1fr); }
}
+1 -1
View File
@@ -78,7 +78,7 @@ export function useAgentTools(form, update) {
// The grants are rows with their own switch each: no bulk setter, so a
// click on Select all never grants a sensitive right.
grants: grantList.length > 0 ? { list: grantList, editable: true, onToggle: (name, on) => setGrantsOn([name], on) } : null,
builtins, addons, servers: [], connectors, connections, skills, replacedSkills: replaced, error, skillsError: '',
builtins, addons, servers: [], connectors, connections, pickElsewhere: true, skills, replacedSkills: replaced, error, skillsError: '',
toggleBuiltin: (g, on) => setBuiltinsOn([g], on),
toggleAddon: (id, on) => setAddonsOn([id], on),
toggleMCP: (key, on) => setMCPOn([key], on),
+2
View File
@@ -148,6 +148,8 @@ export function useToolGroups(switches, { commandsInfo = null, commandState = tr
const options = list.map((c) => ({ value: c.id, label: c.name }))
// The only connection of the person is always enabled: there is nothing to choose.
const sole = list.length === 1 && a.connection_by === 'only'
// The agent form draws this choice in its Connections section.
if (forAgent && switches.pickElsewhere) return {}
if (forAgent) {
return {
pick: {
+106 -14
View File
@@ -5,10 +5,14 @@ import { Link, useParams, useNavigate, useLocation, useOutletContext, useSearchP
import { useTranslation } from 'react-i18next'
import { agentCollectionsApi, chatAgentsApi, modelMCPServersApi } from '../utils/api'
import { useAgentTools } from '../hooks/useAgentTools'
import { useModels } from '../hooks/useModels'
import { useToolGroups } from '../hooks/useToolGroups'
import { useMemorySettings } from '../hooks/useMemorySettings'
import { AGENT_TEMPLATES, diffConfig, displayValue, maskSecrets, summarise } from '../utils/agentConfigTools'
import { agentPayload, effectiveModes, emptyAgent, folderRows, folderState, foldersUnchosen, MAX_FOLDERS, MEMORY_GRANT, memoryChoiceOf, memorySaveOf, missingConnectorGrants, newAgent, setGrant, withMemoryChoice } from '../utils/chatAgents'
import { activePreset, PRESET_KEYS, presetPatch, snapshot, TEMPLATE_TEXT_KEYS } from '../utils/agentPresets'
import { modelCaps } from '../utils/modelCaps'
import { seesImages } from '../utils/chatVision'
import { downloadAgentFile } from '../utils/agentFiles'
import { joinAgentMCP, splitAgentMCP } from '../utils/chatMCP'
import './agents.css'
@@ -33,6 +37,12 @@ import { SwitchPanel } from '../components/chat/SwitchMenu'
import { switchCounts, switchRows } from '../utils/chatSwitchRows'
// eslint-disable-next-line no-unused-vars
import { SkillsPanel } from '../components/chat/SkillsMenu'
// eslint-disable-next-line no-unused-vars
import AgentPresets, { AppliedNotice } from '../components/agents/AgentPresets'
// eslint-disable-next-line no-unused-vars
import AgentTemplates from '../components/agents/AgentTemplates'
// eslint-disable-next-line no-unused-vars
import AgentConnections from '../components/agents/AgentConnections'
// The cards of the page, in order. `bare` parts have no heading of their own:
// the card heading says what they are. `export` exists only for a saved agent.
@@ -40,7 +50,8 @@ const GROUPS = [
{ id: 'identity', icon: 'info', parts: ['BasicInfo', 'ModelSettings', 'PromptsGoals'], bare: true },
{ id: 'capabilities', icon: 'plug', parts: ['Tools', 'Skills', 'Files'] },
{ id: 'memory', icon: 'memory', parts: ['Notes', 'MemorySettings'] },
{ id: 'advanced', icon: 'server', parts: ['MCP', 'export'] },
{ id: 'connections', icon: 'plug', parts: ['Connections'], bare: true },
{ id: 'advanced', icon: 'server', parts: ['MCP', 'export'], collapsible: true },
]
const secDomId = (gid) => `aed-sec-${gid}`
@@ -66,7 +77,7 @@ export default function AgentCreate() {
const navigate = useNavigate()
const location = useLocation()
const { addToast } = useOutletContext()
const { t } = useTranslation(['agents', 'library', 'chats'])
const { t } = useTranslation(['agents', 'library', 'chats', 'home'])
const [searchParams] = useSearchParams()
const templateId = searchParams.get('template') || ''
const isEdit = !!id
@@ -88,6 +99,12 @@ export default function AgentCreate() {
const initialFormRef = useRef(null)
const [collectionNames, setCollectionNames] = useState([])
const [modelServers, setModelServers] = useState([])
// The chat models, for the picker and for the capabilities line under it.
const { models: chatModels, loading: modelsLoading } = useModels(CAP_CHAT, { keepBroken: true })
// What a level or a template just filled in, with the values to go back to.
const [applied, setApplied] = useState(null)
const [collapsed, setCollapsed] = useState(() => new Set())
const formRef = useRef(null)
// Suggestions are best effort: a failure leaves the field free text.
useEffect(() => {
@@ -204,9 +221,12 @@ export default function AgentCreate() {
const update = () => {
frame = 0
let current = GROUPS[0].id
// The line under the sticky header (and the nav chips on a phone).
const bar = parseFloat(formRef.current?.style.getPropertyValue('--aed-bar-h')) || 132
const line = bar + (window.innerWidth <= 768 ? 72 : 40)
for (const g of GROUPS) {
const el = document.getElementById(secDomId(g.id))
if (el && el.getBoundingClientRect().top <= 180) current = g.id
if (el && el.getBoundingClientRect().top <= line) current = g.id
}
setActiveSec(current)
}
@@ -221,6 +241,19 @@ export default function AgentCreate() {
}
}, [loading, loadError])
// The sticky nav sits right under the header, whatever height the header has.
useEffect(() => {
const form = formRef.current
const bar = form?.querySelector('.aed-bar')
if (!bar) return undefined
const set = () => form.style.setProperty('--aed-bar-h', `${Math.round(bar.getBoundingClientRect().height)}px`)
set()
if (typeof ResizeObserver === 'undefined') return undefined
const ro = new ResizeObserver(set)
ro.observe(bar)
return () => ro.disconnect()
}, [loading, loadError])
const mcp = splitAgentMCP(form.mcp_servers)
if (loading || loadError) {
return (
@@ -265,6 +298,13 @@ export default function AgentCreate() {
state = on ? 'ready' : 'empty'
}
else if (s.id === 'Files') { keys = ['folders']; ({ summary, state } = folderFold()) }
else if (s.id === 'Connections') {
keys = ['connections']
const named = tools.addons.filter(a => a.credentialed && !a.gone).reduce((n, a) => n + (a.enabled_connections || []).length, 0)
const n = named + tools.connections.length
summary = n ? t('create.connections.summary', { count: n }) : t('create.noneYet')
state = n ? 'ready' : 'empty'
}
else if (s.id === 'MCP') { keys = ['mcp_servers']; summary = countOf(form.mcp_servers.length, 'create.countServers'); state = form.mcp_servers.length ? 'ready' : 'empty' }
else {
summary = summarise(SUMMARY_FIELDS[s.id], form)
@@ -302,8 +342,33 @@ export default function AgentCreate() {
const removeRow = (i) => updateField('folders', form.folders.filter((_, j) => j !== i))
const addRow = () => { updateField('folders', [...form.folders, { mode: 'read' }]); setChoosingFolder('new') }
// The level of the tool switches, and a level or template applied by hand.
// Both stay in the form until Save and can be undone.
const toolGroupsList = tools.builtins
const grantCatalog = tools.grants?.list || []
const levelReady = toolGroupsList.length > 0
const level = activePreset(form, toolGroupsList, grantCatalog)
const applyLevel = (pid) => {
const patch = presetPatch(pid, form, toolGroupsList, grantCatalog)
if (!patch) return
setApplied({ kind: 'preset', id: pid, name: t(`create.preset.${pid}.title`), before: snapshot(form, PRESET_KEYS) })
setForm(prev => ({ ...prev, ...patch }))
}
const applyTemplate = (tpl) => {
setForm(prev => ({ ...prev, name: tpl.name, description: tpl.description, instructions: tpl.instructions }))
const patch = tpl.preset ? presetPatch(tpl.preset, form, toolGroupsList, grantCatalog) : null
const keys = patch ? [...TEMPLATE_TEXT_KEYS, ...PRESET_KEYS] : TEMPLATE_TEXT_KEYS
setApplied({ kind: 'template', id: tpl.id, name: t(`templates.${tpl.id}.label`), before: snapshot(form, keys) })
setForm(prev => ({ ...prev, name: tpl.name, description: tpl.description, instructions: tpl.instructions, ...(patch || {}) }))
}
const undoApplied = () => {
if (!applied) return
setForm(prev => ({ ...prev, ...applied.before }))
setApplied(null)
}
const chosenModel = chatModels.find(m => m.id === form.model)
const describeModel = (name) => {
const m = chatModels.find(x => x.id === name)
return { vision: seesImages(m), caps: modelCaps(m).filter(c => c !== 'vision').map(c => t(`home:picker.cap.${c}`, { defaultValue: c })) }
}
const renderSection = (sid) => {
@@ -337,8 +402,24 @@ export default function AgentCreate() {
value={form.model}
onChange={(v) => updateField('model', v)}
capability={CAP_CHAT}
models={chatModels}
loading={modelsLoading}
describe={describeModel}
variant="field"
searchable
placeholder={t('create.modelPlaceholder')}
/>
{chosenModel && (
<>
<p className="aed-caps__lead">{t('create.modelCaps')}</p>
<ul className="aed-caps" data-testid="agent-model-caps">
<li className="aed-caps__chip">{t('create.modelChat')}</li>
{modelCaps(chosenModel).filter(c => c !== 'chat').map(c => (
<li key={c} className="aed-caps__chip">{c === 'vision' ? t('home:picker.vision') : t(`home:picker.cap.${c}`, { defaultValue: c })}</li>
))}
</ul>
</>
)}
<p className="ag-note">{t('create.modelHelp')}</p>
</div>
)
@@ -383,9 +464,13 @@ export default function AgentCreate() {
</div>
</>
)
case 'Connections':
return <AgentConnections addons={tools.addons} bindings={tools.connections} onChange={tools.setConnections} />
case 'Tools':
return (
<>
<AgentPresets active={level} ready={levelReady} onPick={applyLevel} />
<AppliedNotice applied={applied?.kind === 'preset' ? applied : null} onUndo={undoApplied} onKeep={() => setApplied(null)} testId="agent-preset-applied" />
<p className="ag-note">{t('create.toolsHelp')}</p>
{memoryUser.settings && memoryUser.settings.enabled === false && (
<Callout tone="info" icon="eye-off" testId="agent-memory-off">
@@ -515,9 +600,13 @@ export default function AgentCreate() {
// A card needs the person when one of its parts does.
const groupNeeds = (g) => g.parts.some(pid => pid !== 'export' && foldInfo({ id: pid }).state === 'needs')
const folded = (g) => !!g.collapsible && collapsed.has(g.id)
const flip = (gid) => setCollapsed(prev => { const n = new Set(prev); if (n.has(gid)) n.delete(gid); else n.add(gid); return n })
const goTo = (gid) => {
const el = document.getElementById(secDomId(gid))
if (!el) return
if (collapsed.has(gid)) setCollapsed(prev => { const n = new Set(prev); n.delete(gid); return n })
const calm = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
el.scrollIntoView({ behavior: calm ? 'auto' : 'smooth', block: 'start' })
setActiveSec(gid)
@@ -532,7 +621,7 @@ export default function AgentCreate() {
return (
<Page className="ag-page aed-page" data-testid="agent-edit">
<UnsavedChangesGuard when={dirty && !saving} />
<form onSubmit={handleSubmit} noValidate className="ag-edit aed">
<form ref={formRef} onSubmit={handleSubmit} noValidate className="ag-edit aed">
<button type="button" className="ag-back" onClick={leave}><Icon name="arrow-left" /> {isEdit ? savedConfig?.name : t('agent.back')}</button>
<header className="aed-bar" data-testid="agent-edit-header">
<div className="aed-bar__who">
@@ -588,13 +677,8 @@ export default function AgentCreate() {
{!isEdit && !imported && (
<section className="ag-start" aria-labelledby="ag-start-h" data-testid="agent-start">
<h2 id="ag-start-h" className="ag-h3">{t('create.startFrom')}</h2>
<div className="ag-start__row">
{AGENT_TEMPLATES.map(tpl => (
<button key={tpl.id} type="button" className="dk-chip" onClick={() => applyTemplate(tpl)} data-template={tpl.id}>
{t(`templates.${tpl.id}.label`)}
</button>
))}
</div>
<AgentTemplates templates={AGENT_TEMPLATES} picked={applied?.kind === 'template' ? applied.id : ''} onPick={applyTemplate} />
<AppliedNotice applied={applied?.kind === 'template' ? applied : null} onUndo={undoApplied} onKeep={() => setApplied(null)} testId="agent-template-applied" />
</section>
)}
@@ -605,13 +689,20 @@ export default function AgentCreate() {
const needs = infos.find(i => i.state === 'needs')
const changed = infos.reduce((n, i) => n + i.changed, 0)
return (
<section key={g.id} id={secDomId(g.id)} className="aed-card" aria-labelledby={`${secDomId(g.id)}-h`} data-group={g.id}>
<section key={g.id} id={secDomId(g.id)} className="aed-card" aria-labelledby={`${secDomId(g.id)}-h`} data-group={g.id} data-collapsed={folded(g) || undefined}>
<div className="aed-card__head">
<h2 id={`${secDomId(g.id)}-h`} className="aed-card__title">{t(`create.nav.${g.id}`)}</h2>
<h2 id={`${secDomId(g.id)}-h`} className="aed-card__title">
{g.collapsible ? (
<button type="button" className="aed-card__toggle" aria-expanded={!folded(g)} aria-controls={`${secDomId(g.id)}-body`} onClick={() => flip(g.id)} data-testid={`agent-toggle-${g.id}`}>
{t(`create.nav.${g.id}`)}<Icon name="chevron-down" className="aed-card__chev" />
</button>
) : t(`create.nav.${g.id}`)}
</h2>
<p className="aed-card__lead">{t(`create.lead.${g.id}`)}</p>
{needs && <span className="aed-card__needs" data-testid="agent-card-needs">{needs.summary}</span>}
{changed > 0 && <span className="ag-badge-changed">{t('create.changed', { count: changed })}</span>}
</div>
<div className="aed-card__body" id={`${secDomId(g.id)}-body`} hidden={folded(g)}>
{g.parts.map(pid => {
const info = pid === 'export' ? null : foldInfo({ id: pid })
return (
@@ -630,6 +721,7 @@ export default function AgentCreate() {
</div>
)
})}
</div>
</section>
)
})}
+14 -6
View File
@@ -415,7 +415,7 @@ a.ag-run-row:hover { background: color-mix(in srgb, var(--dk-hover) 45%, transpa
.aed-chip__v { min-width: 0; color: var(--dk-text); font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.aed-layout { display: grid; grid-template-columns: 180px minmax(0, 1fr); gap: var(--sp-6); align-items: start; min-width: 0; }
.aed-nav { position: sticky; top: 148px; display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.aed-nav { position: sticky; top: calc(var(--aed-bar-h, 132px) + var(--sp-3)); display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.aed-nav__item {
display: flex; align-items: center; gap: var(--sp-2); min-height: 36px; padding: 0 var(--sp-3);
border-left: 2px solid transparent; border-radius: 0 var(--r-md) var(--r-md) 0;
@@ -430,13 +430,13 @@ a.ag-run-row:hover { background: color-mix(in srgb, var(--dk-hover) 45%, transpa
.aed-card {
display: flex; flex-direction: column; gap: var(--sp-5); min-width: 0;
padding: var(--sp-5); border: 1px solid var(--dk-edge); border-radius: var(--r-lg); background: var(--dk-card);
scroll-margin-top: 148px;
scroll-margin-top: calc(var(--aed-bar-h, 132px) + var(--sp-3));
}
.aed-card__head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px var(--sp-3); }
.aed-card__title { margin: 0; font: 600 var(--fs-section)/var(--lh-section) var(--font-sans); color: var(--dk-text); }
.aed-card__lead { margin: 0; font-size: var(--fs-small); color: var(--dk-muted); }
.aed-part { display: flex; flex-direction: column; gap: var(--sp-3); min-width: 0; }
.aed-part:not([data-bare]) { padding-top: var(--sp-5); border-top: 1px solid var(--dk-edge); scroll-margin-top: 148px; }
.aed-part:not([data-bare]) { padding-top: var(--sp-5); border-top: 1px solid var(--dk-edge); scroll-margin-top: calc(var(--aed-bar-h, 132px) + var(--sp-3)); }
.aed-part__head { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2) var(--sp-3); min-width: 0; }
.aed-part__title { margin: 0; font: 600 var(--fs-body)/var(--lh-body) var(--font-sans); color: var(--dk-text); }
.aed-part__sum { min-width: 0; font-size: var(--fs-small); color: var(--dk-muted); overflow-wrap: anywhere; }
@@ -448,10 +448,16 @@ a.ag-run-row:hover { background: color-mix(in srgb, var(--dk-hover) 45%, transpa
@media (max-width: 960px) {
.aed-layout { grid-template-columns: minmax(0, 1fr); gap: var(--sp-3); }
.aed-nav { position: static; flex-direction: row; flex-wrap: wrap; gap: var(--sp-2); }
.aed-nav__item { flex: none; border: 1px solid var(--dk-edge); border-radius: var(--r-xl); padding: 0 var(--sp-3); }
/* One row of chips that scrolls inside itself and sticks under the header. */
.aed-nav {
position: sticky; top: calc(var(--aed-bar-h, 56px) - 1px); z-index: 15; flex-direction: row; gap: var(--sp-2);
padding: var(--sp-2) 0; overflow-x: auto; background: var(--dk-canvas);
border-bottom: 1px solid var(--dk-edge); scrollbar-width: none;
}
.aed-nav::-webkit-scrollbar { display: none; }
.aed-nav__item { flex: none; white-space: nowrap; border: 1px solid var(--dk-edge); border-radius: var(--r-xl); padding: 0 var(--sp-3); }
.aed-nav__item[aria-current="true"] { border-color: var(--dk-accent-text); }
.aed-card, .aed-part:not([data-bare]) { scroll-margin-top: 140px; }
.aed-card, .aed-part:not([data-bare]) { scroll-margin-top: calc(var(--aed-bar-h, 56px) + 56px); }
}
@media (max-width: 760px) {
.aed-bar { grid-template-columns: minmax(0, 1fr); padding: var(--sp-2) var(--sp-3); gap: var(--sp-2); }
@@ -468,3 +474,5 @@ a.ag-run-row:hover { background: color-mix(in srgb, var(--dk-hover) 45%, transpa
}
@media (prefers-reduced-motion: reduce) { .aed-nav__item { transition: none; } }
.aed-card__needs { font-size: var(--fs-meta); font-weight: 500; color: var(--dk-warn-text); }
.aed-part__body > .ui-callout { margin-bottom: var(--sp-3); }
+6 -1
View File
@@ -3,22 +3,27 @@
// of a section.
// Starting points are plain presets of fields every saved agent has. They
// fill the form; nothing is saved until the person saves.
// fill the form; nothing is saved until the person saves. `preset` is the
// capability level the template starts at (see agentPresets.js); a template
// without one leaves the tools as they are.
export const AGENT_TEMPLATES = [
{
id: 'researcher',
preset: 'readonly',
name: 'research-assistant',
description: 'Searches, reads sources and writes short briefs.',
instructions: 'You are a careful research assistant. Search first, read at least two sources, then summarise in plain language. Cite every claim with a numbered source. If a source cannot be read, say so.',
},
{
id: 'reviewer',
preset: 'readonly',
name: 'code-reviewer',
description: 'Reviews a change and lists what to fix first.',
instructions: 'You review code changes. Read the diff, find bugs and unclear code, and list the most important problems first. Quote the line you mean. Do not rewrite the change.',
},
{
id: 'digest',
preset: 'assistant',
name: 'daily-digest',
description: 'Collects what is new on a topic and summarises it.',
instructions: 'You collect what is new on the topics you are given and write a short digest: one line per item, newest first, each with its source.',
+66
View File
@@ -0,0 +1,66 @@
// Capability levels for the agent editor. A level is a starting point: it sets
// fields every saved agent already has (the switches of the built-in groups
// and the list of grants) and nothing else. No React, so a node test drives it.
import { builtinsAfter } from './agentTools.js'
import { CONNECTOR_GRANTS, MEMORY_GRANT } from './chatAgents.js'
import { SAVED_AGENTS_GROUP } from './toolOptions.js'
const list = (x) => (Array.isArray(x) ? x : [])
// The built-in groups each level turns on; every other listed group is turned
// off. `all` means every group the server lists. Files and commands stay off
// below Full: the files group can change and delete files.
export const CAPABILITY_PRESETS = [
{ id: 'readonly', groups: ['web', 'media', 'collections', 'memory'], grants: 'none' },
{ id: 'assistant', groups: ['web', 'media', 'collections', 'memory', 'subagents', SAVED_AGENTS_GROUP, 'triggers', 'changes'], grants: 'connector' },
{ id: 'full', groups: 'all', grants: 'plain' },
]
export const PRESET_IDS = CAPABILITY_PRESETS.map((p) => p.id)
// The fields a level may change.
export const PRESET_KEYS = ['builtins_off', 'builtins_on', 'builtins']
const same = (a, b) => JSON.stringify([...new Set(list(a))].sort()) === JSON.stringify([...new Set(list(b))].sort())
// The fields of the form after a level is applied. `groups` are the built-in
// groups as the server lists them, `catalog` the grants it declares. A level
// never grants a sensitive right, and it keeps the grant of the memory choice
// and any grant this server does not declare (the person can still take those
// away by hand). Returns null while the lists are not known.
export function presetPatch(presetId, form, groups, catalog) {
const preset = CAPABILITY_PRESETS.find((p) => p.id === presetId)
const known = list(groups)
if (!preset || known.length === 0) return null
const names = known.map((g) => g.group)
const on = preset.groups === 'all' ? names : names.filter((n) => preset.groups.includes(n))
const off = names.filter((n) => !on.includes(n))
let next = { ...form, ...builtinsAfter(form, known, on, true) }
next = { ...next, ...builtinsAfter(next, known, off, false) }
const declared = new Set(list(catalog).map((g) => g.name))
const kept = list(form.builtins).filter((g) => g === MEMORY_GRANT || !declared.has(g))
let grants = []
if (preset.grants === 'connector') grants = CONNECTOR_GRANTS
else if (preset.grants === 'plain') grants = list(catalog).filter((g) => !g.sensitive).map((g) => g.name)
grants = [...new Set([...kept, ...grants.filter((g) => declared.has(g) || list(catalog).length === 0)])]
// The saved-agents switch and its grant move together.
if (!on.includes(SAVED_AGENTS_GROUP) && names.includes(SAVED_AGENTS_GROUP)) grants = grants.filter((g) => g !== SAVED_AGENTS_GROUP)
return { builtins_off: next.builtins_off, builtins_on: next.builtins_on, builtins: grants }
}
// The level the form is at, or null for a custom mix.
export function activePreset(form, groups, catalog) {
for (const p of CAPABILITY_PRESETS) {
const patch = presetPatch(p.id, form, groups, catalog)
if (patch && PRESET_KEYS.every((k) => same(patch[k], form[k]))) return p.id
}
return null
}
// The part of the form to keep so an applied level or template can be undone.
export function snapshot(form, keys) {
return Object.fromEntries(keys.map((k) => [k, Array.isArray(form[k]) ? [...form[k]] : form[k]]))
}
// The fields a template fills, and the level it starts at (if any).
export const TEMPLATE_TEXT_KEYS = ['name', 'description', 'instructions']
+14
View File
@@ -0,0 +1,14 @@
// What a row of the model list can do, for the agent editor. The server lists
// capabilities as "vision" or as the flag name ("FLAG_VISION"). Chat is what
// the list is filtered by, and tokenize is not something a person chooses a
// model for, so neither is shown.
const HIDDEN = new Set(['chat', 'tokenize'])
export function modelCaps(model) {
const seen = []
for (const c of model?.capabilities || []) {
const key = String(c).replace(/^FLAG_/, '').toLowerCase()
if (key && !HIDDEN.has(key) && !seen.includes(key)) seen.push(key)
}
return seen
}
@@ -0,0 +1,84 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { activePreset, presetPatch, snapshot } from '../../src/utils/agentPresets.js'
import { modelCaps } from '../../src/utils/modelCaps.js'
import { AGENT_TEMPLATES } from '../../src/utils/agentConfigTools.js'
import { emptyAgent, newAgent, MEMORY_GRANT } from '../../src/utils/chatAgents.js'
const groups = ['shell', 'files', 'web', 'media', 'collections', 'memory', 'subagents', 'saved_agents', 'triggers', 'changes']
.map((group) => ({ group, opt_in: group === 'saved_agents' || group === 'shell' }))
const catalog = [
{ name: 'memory.write', sensitive: false }, { name: 'triggers.propose', sensitive: false }, { name: 'triggers.run', sensitive: false },
{ name: 'talkback.channels', sensitive: false }, { name: 'files.upload', sensitive: false },
{ name: 'saved_agents', sensitive: false }, { name: 'skills.write', sensitive: true }, { name: 'act.send', sensitive: true },
]
const apply = (id, form) => ({ ...form, ...presetPatch(id, form, groups, catalog) })
test('presets: nothing is known until the lists are loaded', () => {
assert.equal(presetPatch('readonly', emptyAgent(), [], catalog), null)
assert.equal(presetPatch('nope', emptyAgent(), groups, catalog), null)
assert.equal(activePreset(emptyAgent(), [], catalog), null)
})
test('presets: read only turns off everything that changes things and keeps the grant of the memory choice', () => {
const form = apply('readonly', newAgent())
assert.deepEqual(form.builtins_off.sort(), ['changes', 'files', 'subagents', 'triggers'].sort())
assert.deepEqual(form.builtins_on, [])
assert.deepEqual(form.builtins, [MEMORY_GRANT])
assert.equal(activePreset(form, groups, catalog), 'readonly')
})
test('presets: assistant adds saved agents and triggers, with the connector grants and no sensitive grant', () => {
const form = apply('assistant', apply('readonly', newAgent()))
assert.ok(form.builtins_on.includes('saved_agents'))
assert.ok(!form.builtins_off.includes('triggers'))
assert.ok(!form.builtins_off.includes('saved_agents'))
assert.ok(form.builtins_off.includes('files'))
assert.deepEqual([...form.builtins].sort(), ['files.upload', 'memory.write', 'talkback.channels', 'triggers.propose', 'triggers.run'])
assert.equal(activePreset(form, groups, catalog), 'assistant')
})
test('presets: full turns every listed group on but never a sensitive grant', () => {
const form = apply('full', apply('readonly', newAgent()))
assert.deepEqual(form.builtins_off, [])
assert.ok(form.builtins_on.includes('saved_agents') && form.builtins_on.includes('shell'))
assert.ok(!form.builtins.includes('skills.write') && !form.builtins.includes('act.send'))
assert.ok(form.builtins.includes('triggers.run'))
assert.equal(activePreset(form, groups, catalog), 'full')
})
test('presets: a change by hand makes it a custom mix, and the level comes back by picking it', () => {
const form = apply('assistant', newAgent())
const edited = { ...form, builtins_off: [...form.builtins_off, 'web'] }
assert.equal(activePreset(edited, groups, catalog), null)
assert.equal(activePreset(apply('assistant', edited), groups, catalog), 'assistant')
})
test('presets: a grant this server does not declare is kept, and groups the server does not list stay as they are', () => {
const form = apply('readonly', { ...newAgent(), builtins: ['memory.write', 'future.grant'], builtins_off: ['mystery'] })
assert.ok(form.builtins.includes('future.grant'))
assert.ok(form.builtins_off.includes('mystery'))
})
test('presets: a level only touches the fields it names, so a snapshot of them undoes it', () => {
const before = { ...newAgent(), name: 'x', network: 'all' }
const patch = presetPatch('readonly', before, groups, catalog)
assert.deepEqual(Object.keys(patch).sort(), ['builtins', 'builtins_off', 'builtins_on'])
const snap = snapshot(before, ['builtins', 'builtins_off', 'builtins_on'])
assert.deepEqual({ ...before, ...patch, ...snap }, before)
snap.builtins.push('z')
assert.ok(!before.builtins.includes('z'), 'a snapshot is a copy')
})
test('presets: every template names a level that exists', () => {
for (const t of AGENT_TEMPLATES) {
if (t.preset) assert.ok(presetPatch(t.preset, emptyAgent(), groups, catalog), t.id)
}
assert.ok(AGENT_TEMPLATES.some((t) => t.preset))
})
test('model caps: flags and plain names read the same, chat and tokenize are left out', () => {
assert.deepEqual(modelCaps({ capabilities: ['FLAG_CHAT', 'FLAG_VISION', 'vision', 'tokenize', 'FLAG_EMBEDDINGS'] }), ['vision', 'embeddings'])
assert.deepEqual(modelCaps(undefined), [])
})