diff --git a/core/http/react-ui/e2e/agents-edit.spec.js b/core/http/react-ui/e2e/agents-edit.spec.js index 6d52a32ec9..365bb035b7 100644 --- a/core/http/react-ui/e2e/agents-edit.spec.js +++ b/core/http/react-ui/e2e/agents-edit.spec.js @@ -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$/) diff --git a/core/http/react-ui/e2e/agents-editor-levels.spec.js b/core/http/react-ui/e2e/agents-editor-levels.spec.js new file mode 100644 index 0000000000..591858c1e8 --- /dev/null +++ b/core/http/react-ui/e2e/agents-editor-levels.spec.js @@ -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) + }) +}) diff --git a/core/http/react-ui/e2e/chats-connection.spec.js b/core/http/react-ui/e2e/chats-connection.spec.js index bc8e5b91ea..ff13207b12 100644 --- a/core/http/react-ui/e2e/chats-connection.spec.js +++ b/core/http/react-ui/e2e/chats-connection.spec.js @@ -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$/) diff --git a/core/http/react-ui/e2e/no-horizontal-overflow.spec.js b/core/http/react-ui/e2e/no-horizontal-overflow.spec.js index 47ffbd4100..80ddf52be0 100644 --- a/core/http/react-ui/e2e/no-horizontal-overflow.spec.js +++ b/core/http/react-ui/e2e/no-horizontal-overflow.spec.js @@ -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 } }) diff --git a/core/http/react-ui/public/locales/de/agents.json b/core/http/react-ui/public/locales/de/agents.json index 067122b0e4..b4f25c341d 100644 --- a/core/http/react-ui/public/locales/de/agents.json +++ b/core/http/react-ui/public/locales/de/agents.json @@ -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." + } } } diff --git a/core/http/react-ui/public/locales/en/agents.json b/core/http/react-ui/public/locales/en/agents.json index 6fa19b1bc7..d207aff8b2 100644 --- a/core/http/react-ui/public/locales/en/agents.json +++ b/core/http/react-ui/public/locales/en/agents.json @@ -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": { diff --git a/core/http/react-ui/public/locales/es/agents.json b/core/http/react-ui/public/locales/es/agents.json index ab2d8ec9be..6daab5e07a 100644 --- a/core/http/react-ui/public/locales/es/agents.json +++ b/core/http/react-ui/public/locales/es/agents.json @@ -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." + } } } diff --git a/core/http/react-ui/public/locales/fr/agents.json b/core/http/react-ui/public/locales/fr/agents.json index ae08a81df0..6bb9f17550 100644 --- a/core/http/react-ui/public/locales/fr/agents.json +++ b/core/http/react-ui/public/locales/fr/agents.json @@ -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." + } } } diff --git a/core/http/react-ui/public/locales/id/agents.json b/core/http/react-ui/public/locales/id/agents.json index e1a85b8f98..7e7322bfff 100644 --- a/core/http/react-ui/public/locales/id/agents.json +++ b/core/http/react-ui/public/locales/id/agents.json @@ -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." + } } } diff --git a/core/http/react-ui/public/locales/it/agents.json b/core/http/react-ui/public/locales/it/agents.json index c9510a0d87..91bd094769 100644 --- a/core/http/react-ui/public/locales/it/agents.json +++ b/core/http/react-ui/public/locales/it/agents.json @@ -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." + } } } diff --git a/core/http/react-ui/public/locales/ko/agents.json b/core/http/react-ui/public/locales/ko/agents.json index d2ad5d7f94..bcbfbbb2e4 100644 --- a/core/http/react-ui/public/locales/ko/agents.json +++ b/core/http/react-ui/public/locales/ko/agents.json @@ -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": "비어 있음. 처음부터 시작합니다." + } } } diff --git a/core/http/react-ui/public/locales/pt-BR/agents.json b/core/http/react-ui/public/locales/pt-BR/agents.json index c6e3e2915d..e473dda4c9 100644 --- a/core/http/react-ui/public/locales/pt-BR/agents.json +++ b/core/http/react-ui/public/locales/pt-BR/agents.json @@ -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." + } } } diff --git a/core/http/react-ui/public/locales/zh-CN/agents.json b/core/http/react-ui/public/locales/zh-CN/agents.json index 1e8aa280fe..c13ed91516 100644 --- a/core/http/react-ui/public/locales/zh-CN/agents.json +++ b/core/http/react-ui/public/locales/zh-CN/agents.json @@ -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": "空白。从头开始。" + } } } diff --git a/core/http/react-ui/src/components/ModelPicker.jsx b/core/http/react-ui/src/components/ModelPicker.jsx index 00d63b043d..7b0338b562 100644 --- a/core/http/react-ui/src/components/ModelPicker.jsx +++ b/core/http/react-ui/src/components/ModelPicker.jsx @@ -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( {name === null ? defaultChoice : name} {badges(name)} - {(info.size || info.vision) && ( + {(info.size || info.vision || info.caps?.length > 0) && ( {info.size && {info.size}} {info.vision && } + {(info.caps || []).map(c => {c})} )} diff --git a/core/http/react-ui/src/components/agents/AgentConnections.jsx b/core/http/react-ui/src/components/agents/AgentConnections.jsx new file mode 100644 index 0000000000..21b5188e72 --- /dev/null +++ b/core/http/react-ui/src/components/agents/AgentConnections.jsx @@ -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 ( +
+
+

{t('create.connections.addonsTitle')}

+ {credentialed.length === 0 + ?

{t('create.connections.addonsNone')}

+ : ( + + )} +
+
+

{t('create.connections.messagingTitle')}

+ {bindings.length === 0 + ?

{t('create.connections.messagingNone')}

+ : ( + + )} +

{t('create.connections.messagingHelp')}

+
+
+ ) +} + +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 ( +
  • +
    + {addon.title || addon.id} + {addon.off && {t('create.connections.addonOff')}} +
    + {options.length === 0 ? ( +

    + {t('create.connections.noneYet')}{' '} + {t('create.connections.add')} +

    + ) : ( +
    + {options.map((o) => ( + + ))} +
    + )} + + {chosen.length ? t('create.connections.chosenHelp') : t('create.connections.perChat')} + +
  • + ) +} diff --git a/core/http/react-ui/src/components/agents/AgentPresets.jsx b/core/http/react-ui/src/components/agents/AgentPresets.jsx new file mode 100644 index 0000000000..9fb85cf9c1 --- /dev/null +++ b/core/http/react-ui/src/components/agents/AgentPresets.jsx @@ -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 ( + + ) +} + +// 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 ( +
    +

    {t('create.preset.label')}

    +
    + {CAPABILITY_PRESETS.map((p) => ( + + ))} +
    +

    + {ready ? (active ? t('create.preset.current', { name: t(`create.preset.${active}.title`) }) : t('create.preset.custom')) : t('create.preset.loading')} +

    +
    + ) +} diff --git a/core/http/react-ui/src/components/agents/AgentTemplates.jsx b/core/http/react-ui/src/components/agents/AgentTemplates.jsx new file mode 100644 index 0000000000..f79f4f4011 --- /dev/null +++ b/core/http/react-ui/src/components/agents/AgentTemplates.jsx @@ -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 ( +
    + {templates.map((tpl) => ( + + ))} +
    + ) +} diff --git a/core/http/react-ui/src/components/agents/agentparts.css b/core/http/react-ui/src/components/agents/agentparts.css new file mode 100644 index 0000000000..d473bd251f --- /dev/null +++ b/core/http/react-ui/src/components/agents/agentparts.css @@ -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); } +} diff --git a/core/http/react-ui/src/hooks/useAgentTools.js b/core/http/react-ui/src/hooks/useAgentTools.js index cdc28af943..efa6023500 100644 --- a/core/http/react-ui/src/hooks/useAgentTools.js +++ b/core/http/react-ui/src/hooks/useAgentTools.js @@ -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), diff --git a/core/http/react-ui/src/hooks/useToolGroups.js b/core/http/react-ui/src/hooks/useToolGroups.js index 61ee97ca8f..715c59a7d5 100644 --- a/core/http/react-ui/src/hooks/useToolGroups.js +++ b/core/http/react-ui/src/hooks/useToolGroups.js @@ -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: { diff --git a/core/http/react-ui/src/pages/AgentCreate.jsx b/core/http/react-ui/src/pages/AgentCreate.jsx index a11c41287d..5573a6061c 100644 --- a/core/http/react-ui/src/pages/AgentCreate.jsx +++ b/core/http/react-ui/src/pages/AgentCreate.jsx @@ -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 && ( + <> +

    {t('create.modelCaps')}

    + + + )}

    {t('create.modelHelp')}

    ) @@ -383,9 +464,13 @@ export default function AgentCreate() { ) + case 'Connections': + return case 'Tools': return ( <> + + setApplied(null)} testId="agent-preset-applied" />

    {t('create.toolsHelp')}

    {memoryUser.settings && memoryUser.settings.enabled === false && ( @@ -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 ( -
    +
    @@ -588,13 +677,8 @@ export default function AgentCreate() { {!isEdit && !imported && (

    {t('create.startFrom')}

    -
    - {AGENT_TEMPLATES.map(tpl => ( - - ))} -
    + + setApplied(null)} testId="agent-template-applied" />
    )} @@ -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 ( -
    +
    -

    {t(`create.nav.${g.id}`)}

    +

    + {g.collapsible ? ( + + ) : t(`create.nav.${g.id}`)} +

    {t(`create.lead.${g.id}`)}

    {needs && {needs.summary}} {changed > 0 && {t('create.changed', { count: changed })}}
    + ) })} +
    ) })} diff --git a/core/http/react-ui/src/pages/agents.css b/core/http/react-ui/src/pages/agents.css index 1a1a7d040e..ee14d1a317 100644 --- a/core/http/react-ui/src/pages/agents.css +++ b/core/http/react-ui/src/pages/agents.css @@ -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); } diff --git a/core/http/react-ui/src/utils/agentConfigTools.js b/core/http/react-ui/src/utils/agentConfigTools.js index f61547def7..9ab9beeff5 100644 --- a/core/http/react-ui/src/utils/agentConfigTools.js +++ b/core/http/react-ui/src/utils/agentConfigTools.js @@ -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.', diff --git a/core/http/react-ui/src/utils/agentPresets.js b/core/http/react-ui/src/utils/agentPresets.js new file mode 100644 index 0000000000..acd7fccaae --- /dev/null +++ b/core/http/react-ui/src/utils/agentPresets.js @@ -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'] diff --git a/core/http/react-ui/src/utils/modelCaps.js b/core/http/react-ui/src/utils/modelCaps.js new file mode 100644 index 0000000000..f8e4e10761 --- /dev/null +++ b/core/http/react-ui/src/utils/modelCaps.js @@ -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 +} diff --git a/core/http/react-ui/tests/unit/agentpresets.test.mjs b/core/http/react-ui/tests/unit/agentpresets.test.mjs new file mode 100644 index 0000000000..6a4668586c --- /dev/null +++ b/core/http/react-ui/tests/unit/agentpresets.test.mjs @@ -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), []) +})