feat(agents): effective view and Try it in the agent editor

The agent form now has an Effective view that says in plain words what
the agent can do right now, worked out from the form as it stands, saved
or not: tools, skills, files, extra rights, rights it uses on its own,
the memory choice and the save level that applies, knowledge
collections and messaging connections. Each line names the section it
explains. Anything blocked shows its reason code and one fix: a button
that moves to the switch on this form, or a link to the page that owns
it. A connection that is saved is checked with the existing access
preview endpoint. A line says when memory is off in the person's
Settings.

Try it sends a message to a short-lived test chat built from the form
(model, tool switches, instructions as the system prompt) without saving
the agent. The panel says that permissions, memory, knowledge and
folders are not used by the test, and the chat is deleted on close. The
header gets a More menu with Export for a saved agent.

Assisted-by: Claude:claude-sonnet-5-5
This commit is contained in:
Ettore Di Giacinto committed 2026-11-05 06:56:40 +00:00
1 parent 71283448da
commit 1143acb292
20 files changed
+1927 -56

No files matched your search

@@ -0,0 +1,236 @@
import { test, expect } from './coverage-fixtures.js'
import { installAgents, AGENTS } from './agents-fixtures.js'
// The agent editor, part 3: the Effective view (what the agent can do right
// now, from the form as it is, with the reason and the fix of anything
// blocked), Try it (a test chat with the unsaved settings) and the More menu.
// The API is stubbed.
const SHOTS = process.env.EDITOR_SHOTS || ''
async function shot(page, name, options = {}) {
if (!SHOTS) return
const width = page.viewportSize().width
for (const theme of ['light', 'dark']) {
await page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme)
await page.waitForTimeout(400)
await page.screenshot({ path: `${SHOTS}/${name}-${theme}-${width}.png`, ...options })
}
await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'light'))
}
const eff = (page) => page.getByTestId('agent-effective')
const line = (page, id) => page.locator(`[data-testid=agent-effective-line][data-line="${id}"]`)
const memory = (page, enabled) => page.route('**/api/chats/memory', route => route.fulfill({
json: { enabled, enabled_set: true, save_mode: 'ask', messaging_save: 'ask', save_mode_set: false, provider: 'builtin', available: true },
}))
const VIEW = {
binding: 'b1',
subjects: [
{
id: 'anyone', title: 'Anyone on the list',
decisions: [
{ capability: 'files.upload', allowed: true },
{ capability: 'memory.write', allowed: false, code: 'agent_permission_off', blocked_by: 'agent', message: 'The agent has not been given this right.', fix: { kind: 'open_agent', target: 'a2', field: 'memory.write' } },
{ capability: 'triggers.run', allowed: false, code: 'connection_permission_off', blocked_by: 'connection', message: 'The connection does not allow this.', fix: { kind: 'open_connection', target: 'caps', field: 'triggers.run' } },
],
},
],
recent: [], preview: true,
}
test.describe('Effective view', () => {
test('follows the form as it is, before anything is saved', async ({ page }) => {
const s = await installAgents(page)
await memory(page, true)
await page.goto('/app/agents/new')
await expect(eff(page)).toBeVisible()
await expect(line(page, 'tools')).toContainText('Your files')
await expect(page.getByTestId('agent-effective-summary')).toContainText('Nothing is blocked')
await expect(line(page, 'memory')).toContainText('Reads your notes and its own')
await expect(line(page, 'memory-level')).toContainText('waits for you')
await expect(line(page, 'knowledge')).toContainText('No knowledge collections')
await expect(line(page, 'connections')).toContainText('Save the agent')
await page.getByTestId('agent-tools-panel').getByRole('switch', { name: /Your files/ }).click()
await expect(line(page, 'tools')).not.toContainText('Your files')
await page.getByRole('combobox', { name: 'Collections +' }).fill('handbook')
await page.getByRole('combobox', { name: 'Collections +' }).press('Enter')
await expect(line(page, 'knowledge')).toContainText('handbook')
await page.getByTestId('agent-memory-none').click()
await expect(line(page, 'memory')).toContainText('Keeps and reads no notes')
await expect(page.getByTestId('agent-effective-unsaved')).toBeVisible()
expect(s.calls.some(c => c.method === 'POST' || c.method === 'PUT')).toBe(false)
})
test('says memory is off when the person turned it off in Settings, and links to the fix', async ({ page }) => {
await installAgents(page)
await memory(page, false)
await page.goto('/app/agents/new')
const row = line(page, 'memory-off')
await expect(row).toContainText('Memory is off in your Settings')
await expect(row).toHaveAttribute('data-code', 'memory_off_user')
await expect(row.getByTestId('agent-effective-fix')).toHaveAttribute('href', /\/app\/account\?tab=memory/)
await expect(line(page, 'memory')).toHaveCount(0)
})
test('a right under a switch that is off is blocked, and the fix moves to the switch', async ({ page }) => {
await installAgents(page, { agents: [{ ...AGENTS[1], builtins: ['triggers.propose'], builtins_off: ['triggers'] }] })
await memory(page, true)
await page.goto('/app/agents/a2/edit')
const row = line(page, 'grant-triggers.propose')
await expect(row).toHaveAttribute('data-state', 'blocked')
await expect(row).toHaveAttribute('data-code', 'tools_switch_off')
await expect(page.getByTestId('agent-effective-summary')).toContainText('blocked')
await row.getByTestId('agent-effective-fix').click()
await expect(page.locator('#aed-sec-capabilities')).toBeInViewport()
// Turning the switch on clears the line, without saving.
await page.getByTestId('agent-tools-panel').getByRole('switch', { name: 'Create and manage triggers' }).click()
await expect(line(page, 'grant-triggers.propose')).toHaveCount(0)
})
test('a saved connection is checked with the access preview and what is blocked is said with its fix', async ({ page }) => {
await installAgents(page, { bindings: [{ id: 'b1', name: 'Bender', kind: 'slack', agent_id: 'a2', enabled: true, caps: [] }] })
await memory(page, true)
const asked = []
await page.route(/\/api\/chats\/connectors\/b1\/access\/preview$/, route => { asked.push(route.request().postDataJSON()); return route.fulfill({ json: VIEW }) })
await page.goto('/app/agents/a2/edit')
await expect(line(page, 'conn-b1')).toContainText('Slack Bender: 1 allowed, 2 not allowed')
expect(asked).toEqual([{ agent_id: 'a2' }])
const agentSide = line(page, 'conn-b1-memory.write')
await expect(agentSide).toContainText('The agent has not been given this right.')
await expect(agentSide).toHaveAttribute('data-code', 'agent_permission_off')
await expect(agentSide.getByTestId('agent-effective-fix')).toHaveText('Fix it here')
const connSide = line(page, 'conn-b1-triggers.run')
await expect(connSide.getByTestId('agent-effective-fix')).toHaveAttribute('href', /apps\?binding=b1|chats\/connectors/)
// A change in the form does not ask again; the lines are for the saved connection.
await page.getByLabel('Description').fill('Changed')
await expect(page.getByTestId('agent-effective-unsaved')).toContainText('saved connection')
expect(asked).toHaveLength(1)
})
test('a connection that cannot be checked says so', async ({ page }) => {
await installAgents(page, { bindings: [{ id: 'b1', name: 'Bender', kind: 'slack', agent_id: 'a2', enabled: true, caps: [] }] })
await memory(page, true)
await page.route(/\/api\/chats\/connectors\/b1\/access\/preview$/, route => route.fulfill({ status: 500, json: { error: { message: 'boom' } } }))
await page.goto('/app/agents/a2/edit')
await expect(line(page, 'conn-b1')).toContainText('Could not check Slack Bender')
})
test('every line names the section it explains', async ({ page }) => {
await installAgents(page)
await memory(page, true)
await page.goto('/app/agents/new')
await line(page, 'knowledge').getByTestId('agent-effective-goto').click()
await expect(page.locator('#aed-sec-memory')).toBeInViewport()
await expect(page.getByTestId('agent-nav-effective')).toBeVisible()
})
})
test.describe('Try it and More', () => {
async function chatStub(page) {
const seen = { create: null, patch: null, sent: null, deleted: false, running: 1 }
await page.route(/\/api\/chats(\?.*)?$/, route => {
if (route.request().method() !== 'POST') return route.fallback()
seen.create = route.request().postDataJSON()
return route.fulfill({ status: 201, json: { id: 't1', title: 'Test', model: seen.create.model } })
})
await page.route(/\/api\/chats\/t1$/, route => {
const m = route.request().method()
if (m === 'PATCH') { seen.patch = route.request().postDataJSON(); return route.fulfill({ json: { id: 't1' } }) }
if (m === 'DELETE') { seen.deleted = true; return route.fulfill({ status: 204, body: '' }) }
seen.running -= 1
return route.fulfill({ json: { id: 't1', running: seen.running > 0 } })
})
await page.route(/\/api\/chats\/t1\/messages(\?.*)?$/, route => {
if (route.request().method() === 'POST') { seen.sent = route.request().postDataJSON(); return route.fulfill({ json: { ok: true } }) }
return route.fulfill({ json: { messages: [
{ seq: 1, role: 'user', text: seen.sent?.text || '' },
{ seq: 2, role: 'tool', text: 'web_search\n{"q":"x"}' },
{ seq: 3, role: 'assistant', text: 'Bonjour, voici la réponse.' },
] } })
})
await page.route(/\/api\/chats\/t1\/stop$/, route => route.fulfill({ json: {} }))
return seen
}
test('sends a message with the settings in the form, saves nothing, and deletes the test chat on close', async ({ page }) => {
const s = await installAgents(page, { agents: [{ ...AGENTS[1], builtins: ['memory.write'] }] })
await memory(page, true)
const seen = await chatStub(page)
await page.goto('/app/agents/a2/edit')
await page.getByLabel('Instructions').fill('Answer in French.')
await page.getByTestId('agent-tools-panel').getByRole('switch', { name: /Your files/ }).click()
await page.getByTestId('agent-try-open').click()
const sheet = page.getByTestId('agent-try')
await expect(sheet).toBeVisible()
await expect(page.getByTestId('agent-try-skipped')).toContainText('permissions')
await page.getByTestId('agent-try-input').fill('Hello')
await page.getByTestId('agent-try-send').click()
await expect(page.getByTestId('agent-try-log')).toContainText('Bonjour, voici la réponse.')
await expect(page.getByTestId('agent-try-log')).toContainText('Used a tool: web_search')
expect(seen.create.model).toBeTruthy()
expect(seen.create.switches.builtins_off).toEqual(['files'])
expect(seen.patch).toEqual({ settings: { system_prompt: 'Answer in French.' } })
expect(seen.sent.text).toBe('Hello')
expect(s.calls.some(c => c.method === 'POST' || c.method === 'PUT')).toBe(false)
await shot(page, 'try-it')
await page.keyboard.press('Escape')
await expect(sheet).toHaveCount(0)
await expect.poll(() => seen.deleted).toBe(true)
// The form still holds its unsaved changes.
await expect(page.getByLabel('Instructions')).toHaveValue('Answer in French.')
})
test('the More menu exports a saved agent and is not offered for a new one', async ({ page }) => {
await installAgents(page)
await memory(page, true)
await page.goto('/app/agents/new')
await expect(page.getByRole('button', { name: 'More actions' })).toHaveCount(0)
await page.goto('/app/agents/a2/edit')
await page.getByRole('button', { name: 'More actions' }).click()
const download = page.waitForEvent('download')
await page.getByRole('menuitem', { name: 'Export agent' }).click()
expect((await download).suggestedFilename()).toBe('code-reviewer.json')
})
test('the sheet and the Effective view stay inside a 320 px screen', async ({ page }) => {
await page.setViewportSize({ width: 320, height: 700 })
await installAgents(page, { agents: [{ ...AGENTS[1], name: `reviewer_${'x'.repeat(60)}`, collections: ['a'.repeat(70)] }] })
await memory(page, false)
await page.goto('/app/agents/a2/edit')
await expect(eff(page)).toBeVisible()
const wide = () => page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)
expect(await wide()).toBeLessThanOrEqual(0)
await page.getByTestId('agent-try-open').click()
await expect(page.getByTestId('agent-try')).toBeVisible()
expect(await wide()).toBeLessThanOrEqual(0)
const box = await page.getByTestId('agent-try').boundingBox()
expect(box.x + box.width).toBeLessThanOrEqual(320)
})
})
test.describe('Screenshots', () => {
test.skip(!SHOTS, 'set EDITOR_SHOTS to take them')
for (const width of [1280, 768, 375]) {
test(`effective view at ${width}`, async ({ page }) => {
await page.setViewportSize({ width, height: 900 })
await installAgents(page, {
agents: [{ ...AGENTS[1], builtins: ['triggers.propose', 'act.send'], builtins_off: ['triggers'], collections: ['handbook'] }],
bindings: [{ id: 'b1', name: 'Bender', kind: 'slack', agent_id: 'a2', enabled: true, caps: [] }],
})
await memory(page, false)
await page.route(/\/api\/chats\/connectors\/b1\/access\/preview$/, route => route.fulfill({ json: VIEW }))
await page.goto('/app/agents/a2/edit')
await expect(line(page, 'conn-b1-memory.write')).toBeVisible()
await page.getByTestId('agent-nav-effective').click()
await page.waitForTimeout(700)
await shot(page, 'effective')
await page.evaluate(() => window.scrollTo(0, 0))
await page.waitForTimeout(300)
await shot(page, 'top')
await page.getByTestId('agent-try-open').click()
await shot(page, 'try-empty')
})
}
})
@@ -83,7 +83,8 @@
"Files": "Dateien",
"Tools": "Werkzeuge",
"Notes": "Gedächtnis",
"Connections": "Verbindungen"
"Connections": "Verbindungen",
"Effective": "Wirksame Ansicht"
},
"name": "Name",
"description": "Description",
@@ -159,14 +160,16 @@
"capabilities": "Fähigkeiten",
"memory": "Gedächtnis und Wissen",
"advanced": "Erweitert",
"connections": "Verbindungen"
"connections": "Verbindungen",
"effective": "Wirksame Ansicht"
},
"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.",
"connections": "Welche Verbindungen dieser Agent nutzen darf."
"connections": "Welche Verbindungen dieser Agent nutzen darf.",
"effective": "Was dieser Agent gerade tun kann."
},
"header": {
"unnamed": "Unbenannter Agent",
@@ -226,6 +229,110 @@
"perChat": "Nichts angehakt: Jeder Chat wählt selbst.",
"summary_one": "{{count}} Verbindung",
"summary_other": "{{count}} Verbindungen"
},
"more": "Weitere Aktionen",
"eff": {
"summaryOk": "Nichts ist blockiert.",
"summaryBlocked_one": "{{count}} Sache ist blockiert.",
"summaryBlocked_other": "{{count}} Dinge sind blockiert.",
"unsaved": "Diese Liste folgt dem Formular, wie es jetzt ist, auch mit nicht gespeicherten Änderungen.",
"unsavedConn": "Werkzeuge, Gedächtnis und Wissen folgen dem Formular, wie es jetzt ist. Die Zeilen zu Verbindungen stammen vom gespeicherten Agenten und der gespeicherten Verbindung.",
"state": {
"allowed": "Kann",
"asks": "Fragt zuerst",
"off": "Aus",
"blocked": "Blockiert",
"info": "Hinweis"
},
"more_one": "und {{count}} weitere",
"more_other": "und {{count}} weitere",
"fixHere": "Hier beheben",
"fixOpen": "Die Seite öffnen, die es behebt",
"model": {
"fixed": "Läuft mit {{model}}.",
"any": "Nutzt das Modell, das in jedem Chat gewählt wird."
},
"tools": {
"none": "Keine Werkzeuge sind an. Er kann nur aus seinem Wissen antworten.",
"some_one": "Kann {{count}} Werkzeug nutzen: {{names}}.",
"some_other": "Kann {{count}} Werkzeuge nutzen: {{names}}.",
"blocked": "{{name}} ist an, kann aber jetzt nicht arbeiten. {{reason}}"
},
"skills": {
"some_one": "Kann {{count}} Skill nutzen: {{names}}.",
"some_other": "Kann {{count}} Skills nutzen: {{names}}."
},
"files": {
"all": "Kann alle Ihre Dateien erreichen.",
"folders_one": "Kann nur den {{count}} gewählten Ordner erreichen.",
"folders_other": "Kann nur die {{count}} gewählten Ordner erreichen."
},
"grants": {
"parentOff": "{{name}} ist erlaubt, funktioniert aber nicht, solange {{parent}} aus ist. Schalten Sie {{parent}} ein.",
"connection": "{{name}} ist für den Agenten erlaubt, aber nicht auf dieser Verbindung: {{connection}}",
"some_one": "Hat {{count}} zusätzliches Recht: {{names}}.",
"some_other": "Hat {{count}} zusätzliche Rechte: {{names}}.",
"unattended_one": "Wenn niemand zusieht, darf er {{count}} Recht selbst nutzen: {{names}}.",
"unattended_other": "Wenn niemand zusieht, darf er {{count}} Rechte selbst nutzen: {{names}}."
},
"memory": {
"userOff": "Das Gedächtnis ist in Ihren Einstellungen aus. Dieser Agent liest und speichert daher keine Notizen, egal was er hier wählt.",
"none": "Behält und liest keine Notizen.",
"mine_and_own": "Liest Ihre Notizen und seine eigenen und speichert in seinen eigenen.",
"own": "Liest und speichert nur seine eigenen Notizen.",
"mine": "Liest Ihre Notizen und speichert darin.",
"asBefore": "Das Gedächtnis arbeitet wie bisher: Er liest Notizen, wenn ein Add-on Memory nennt.",
"noGrant": "Er soll Notizen speichern, aber das Recht, sich Dinge zu merken, ist aus. Er speichert also nichts. Schalten Sie es unter Gedächtnis ein.",
"level": {
"auto": "Notizen werden sofort gespeichert.",
"ask": "Jede Notiz wartet auf Ihre Bestätigung.",
"always": "Jede Notiz wartet auf Sie, und nichts lockert das.",
"never": "Er speichert keine Notizen."
}
},
"knowledge": {
"some_one": "Kann {{count}} Sammlung durchsuchen: {{names}}.",
"some_other": "Kann {{count}} Sammlungen durchsuchen: {{names}}.",
"none": "Keine Wissenssammlungen zum Durchsuchen."
},
"connections": {
"unsaved": "Noch in keiner Messaging-App genutzt. Speichern Sie den Agenten und fügen Sie dann eine Verbindung hinzu.",
"none": "In keiner Messaging-App genutzt.",
"loading": "{{name}} wird geprüft ...",
"error": "{{name}} konnte nicht geprüft werden. Öffnen Sie sie, um die Berechtigungen zu sehen.",
"summary": "{{name}}: {{allowed}} erlaubt, {{blocked}} nicht erlaubt.",
"blocked": "{{name}} kann {{capability}} nicht nutzen. {{message}}"
}
},
"try": {
"open": "Ausprobieren",
"title": "Diesen Agenten ausprobieren",
"desc": "Senden Sie eine Nachricht an einen Test-Chat mit den Einstellungen dieses Formulars. Nichts wird gespeichert.",
"noModel": "Kein Chat-Modell ist installiert. Installieren oder wählen Sie zuerst ein Modell.",
"defaultModel": "Kein Modell ist gesetzt, daher nutzt der Test {{model}}.",
"skipped": "Der Test nutzt nicht: {{what}}. Das gehört zu einem gespeicherten Agenten.",
"skip": {
"grants": "Berechtigungen",
"memory": "Gedächtnis",
"knowledge": "Wissenssammlungen",
"folders": "geteilte Ordner"
},
"empty": "Senden Sie eine Nachricht, um zu beginnen.",
"role": {
"user": "Sie",
"assistant": "Agent",
"tool": "Werkzeug"
},
"toolRan": "Werkzeug genutzt: {{name}}",
"working": "Arbeitet ...",
"label": "Nachricht an den Agenten",
"placeholder": "Fragen Sie etwas, das dieser Agent können soll",
"send": "Senden",
"stop": "Stopp",
"deleted": "Der Test-Chat wird gelöscht, wenn Sie dieses Fenster schließen.",
"chatTitle": "Test von {{name}}",
"failed": "Der Test ist fehlgeschlagen: {{message}}",
"slow": "Der Agent arbeitet noch. Stoppen Sie ihn oder schließen Sie das Fenster."
}
},
"run": {
@@ -196,7 +196,8 @@
"Files": "Files",
"Tools": "Tools",
"Notes": "Memory",
"Connections": "Connections"
"Connections": "Connections",
"Effective": "Effective view"
},
"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.",
@@ -266,14 +267,16 @@
"capabilities": "Capabilities",
"memory": "Memory and knowledge",
"advanced": "Advanced",
"connections": "Connections"
"connections": "Connections",
"effective": "Effective view"
},
"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.",
"connections": "Which connections this agent may use."
"connections": "Which connections this agent may use.",
"effective": "What this agent can do right now."
},
"header": {
"unnamed": "Unnamed agent",
@@ -333,6 +336,110 @@
"perChat": "None ticked: each chat chooses its own.",
"summary_one": "{{count}} connection",
"summary_other": "{{count}} connections"
},
"more": "More actions",
"eff": {
"summaryOk": "Nothing is blocked.",
"summaryBlocked_one": "{{count}} thing is blocked.",
"summaryBlocked_other": "{{count}} things are blocked.",
"unsaved": "This list follows the form as it is now, including changes you have not saved.",
"unsavedConn": "Tools, memory and knowledge follow the form as it is now. Connection lines come from the saved agent and the saved connection.",
"state": {
"allowed": "Can",
"asks": "Asks first",
"off": "Off",
"blocked": "Blocked",
"info": "Note"
},
"more_one": "and {{count}} more",
"more_other": "and {{count}} more",
"fixHere": "Fix it here",
"fixOpen": "Open the page that fixes it",
"model": {
"fixed": "Runs on {{model}}.",
"any": "Uses the model picked in each chat."
},
"tools": {
"none": "No tools are on. It can only answer from what it knows.",
"some_one": "Can use {{count}} tool: {{names}}.",
"some_other": "Can use {{count}} tools: {{names}}.",
"blocked": "{{name}} is on but cannot work now. {{reason}}"
},
"skills": {
"some_one": "Can use {{count}} skill: {{names}}.",
"some_other": "Can use {{count}} skills: {{names}}."
},
"files": {
"all": "Can reach all your files.",
"folders_one": "Can reach only the {{count}} folder you chose.",
"folders_other": "Can reach only the {{count}} folders you chose."
},
"grants": {
"parentOff": "{{name}} is allowed, but it cannot work while {{parent}} is off. Turn {{parent}} on.",
"connection": "{{name}} is allowed for the agent, but not on this connection: {{connection}}",
"some_one": "Has {{count}} extra right: {{names}}.",
"some_other": "Has {{count}} extra rights: {{names}}.",
"unattended_one": "When nobody is watching, it may use {{count}} right on its own: {{names}}.",
"unattended_other": "When nobody is watching, it may use {{count}} rights on its own: {{names}}."
},
"memory": {
"userOff": "Memory is off in your Settings, so this agent reads and saves no notes, whatever it chooses here.",
"none": "Keeps and reads no notes.",
"mine_and_own": "Reads your notes and its own, and saves to its own.",
"own": "Reads and saves only its own notes.",
"mine": "Reads your notes and saves to them.",
"asBefore": "Memory works as before: it reads notes when an add-on names Memory.",
"noGrant": "It is set to save notes, but the right to remember things is off, so it saves none. Turn it on in Memory.",
"level": {
"auto": "Notes are saved at once.",
"ask": "Each note waits for you to confirm it.",
"always": "Every note waits for you, and nothing loosens that.",
"never": "It saves no notes."
}
},
"knowledge": {
"some_one": "Can search {{count}} collection: {{names}}.",
"some_other": "Can search {{count}} collections: {{names}}.",
"none": "No knowledge collections to search."
},
"connections": {
"unsaved": "Not used in messaging apps yet. Save the agent, then add a connection.",
"none": "Not used in any messaging app.",
"loading": "Checking {{name}}...",
"error": "Could not check {{name}}. Open it to see its permissions.",
"summary": "{{name}}: {{allowed}} allowed, {{blocked}} not allowed.",
"blocked": "{{name}} cannot use {{capability}}. {{message}}"
}
},
"try": {
"open": "Try it",
"title": "Try this agent",
"desc": "Send a message to a test chat that uses the settings in this form. Nothing is saved.",
"noModel": "No chat model is installed. Install or pick a model first.",
"defaultModel": "No model is set, so the test uses {{model}}.",
"skipped": "The test does not use: {{what}}. Those belong to a saved agent.",
"skip": {
"grants": "permissions",
"memory": "memory",
"knowledge": "knowledge collections",
"folders": "shared folders"
},
"empty": "Send a message to start.",
"role": {
"user": "You",
"assistant": "Agent",
"tool": "Tool"
},
"toolRan": "Used a tool: {{name}}",
"working": "Working...",
"label": "Message to the agent",
"placeholder": "Ask something this agent should handle",
"send": "Send",
"stop": "Stop",
"deleted": "The test chat is deleted when you close this panel.",
"chatTitle": "Test of {{name}}",
"failed": "The test failed: {{message}}",
"slow": "The agent is still working. Stop it or close the panel."
}
},
"status": {
@@ -83,7 +83,8 @@
"Files": "Archivos",
"Tools": "Herramientas",
"Notes": "Memoria",
"Connections": "Conexiones"
"Connections": "Conexiones",
"Effective": "Vista efectiva"
},
"name": "Name",
"description": "Description",
@@ -159,14 +160,16 @@
"capabilities": "Capacidades",
"memory": "Memoria y conocimiento",
"advanced": "Avanzado",
"connections": "Conexiones"
"connections": "Conexiones",
"effective": "Vista efectiva"
},
"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.",
"connections": "Qué conexiones puede usar este agente."
"connections": "Qué conexiones puede usar este agente.",
"effective": "Lo que este agente puede hacer ahora."
},
"header": {
"unnamed": "Agente sin nombre",
@@ -226,6 +229,110 @@
"perChat": "Ninguna marcada: cada chat elige la suya.",
"summary_one": "{{count}} conexión",
"summary_other": "{{count}} conexiones"
},
"more": "Más acciones",
"eff": {
"summaryOk": "Nada está bloqueado.",
"summaryBlocked_one": "{{count}} cosa está bloqueada.",
"summaryBlocked_other": "{{count}} cosas están bloqueadas.",
"unsaved": "Esta lista sigue el formulario tal como está ahora, con los cambios sin guardar.",
"unsavedConn": "Herramientas, memoria y conocimiento siguen el formulario tal como está ahora. Las líneas de conexiones vienen del agente guardado y de la conexión guardada.",
"state": {
"allowed": "Puede",
"asks": "Pregunta antes",
"off": "Desactivado",
"blocked": "Bloqueado",
"info": "Nota"
},
"more_one": "y {{count}} más",
"more_other": "y {{count}} más",
"fixHere": "Corregirlo aquí",
"fixOpen": "Abrir la página que lo corrige",
"model": {
"fixed": "Se ejecuta con {{model}}.",
"any": "Usa el modelo elegido en cada chat."
},
"tools": {
"none": "No hay herramientas activadas. Solo puede responder con lo que sabe.",
"some_one": "Puede usar {{count}} herramienta: {{names}}.",
"some_other": "Puede usar {{count}} herramientas: {{names}}.",
"blocked": "{{name}} está activado pero no puede funcionar ahora. {{reason}}"
},
"skills": {
"some_one": "Puede usar {{count}} habilidad: {{names}}.",
"some_other": "Puede usar {{count}} habilidades: {{names}}."
},
"files": {
"all": "Puede acceder a todos sus archivos.",
"folders_one": "Solo puede acceder a la {{count}} carpeta elegida.",
"folders_other": "Solo puede acceder a las {{count}} carpetas elegidas."
},
"grants": {
"parentOff": "{{name}} está permitido, pero no funciona mientras {{parent}} esté desactivado. Active {{parent}}.",
"connection": "{{name}} está permitido para el agente, pero no en esta conexión: {{connection}}",
"some_one": "Tiene {{count}} permiso adicional: {{names}}.",
"some_other": "Tiene {{count}} permisos adicionales: {{names}}.",
"unattended_one": "Cuando nadie mira, puede usar {{count}} permiso por su cuenta: {{names}}.",
"unattended_other": "Cuando nadie mira, puede usar {{count}} permisos por su cuenta: {{names}}."
},
"memory": {
"userOff": "La memoria está desactivada en sus ajustes, así que este agente no lee ni guarda notas, elija lo que elija aquí.",
"none": "No guarda ni lee notas.",
"mine_and_own": "Lee sus notas y las suyas, y guarda en las suyas.",
"own": "Lee y guarda solo sus propias notas.",
"mine": "Lee sus notas y guarda en ellas.",
"asBefore": "La memoria funciona como antes: lee notas cuando un complemento nombra Memory.",
"noGrant": "Está configurado para guardar notas, pero el permiso de recordar cosas está desactivado, así que no guarda ninguna. Actívelo en Memoria.",
"level": {
"auto": "Las notas se guardan al instante.",
"ask": "Cada nota espera su confirmación.",
"always": "Cada nota espera su confirmación y nada lo relaja.",
"never": "No guarda notas."
}
},
"knowledge": {
"some_one": "Puede buscar en {{count}} colección: {{names}}.",
"some_other": "Puede buscar en {{count}} colecciones: {{names}}.",
"none": "No hay colecciones de conocimiento donde buscar."
},
"connections": {
"unsaved": "Aún no se usa en aplicaciones de mensajería. Guarde el agente y añada una conexión.",
"none": "No se usa en ninguna aplicación de mensajería.",
"loading": "Comprobando {{name}}...",
"error": "No se pudo comprobar {{name}}. Ábrala para ver sus permisos.",
"summary": "{{name}}: {{allowed}} permitidos, {{blocked}} no permitidos.",
"blocked": "{{name}} no puede usar {{capability}}. {{message}}"
}
},
"try": {
"open": "Probar",
"title": "Probar este agente",
"desc": "Envíe un mensaje a un chat de prueba que usa los ajustes de este formulario. No se guarda nada.",
"noModel": "No hay ningún modelo de chat instalado. Instale o elija un modelo primero.",
"defaultModel": "No hay modelo definido, así que la prueba usa {{model}}.",
"skipped": "La prueba no usa: {{what}}. Eso pertenece a un agente guardado.",
"skip": {
"grants": "permisos",
"memory": "memoria",
"knowledge": "colecciones de conocimiento",
"folders": "carpetas compartidas"
},
"empty": "Envíe un mensaje para empezar.",
"role": {
"user": "Usted",
"assistant": "Agente",
"tool": "Herramienta"
},
"toolRan": "Usó una herramienta: {{name}}",
"working": "Trabajando...",
"label": "Mensaje al agente",
"placeholder": "Pregunte algo que este agente deba resolver",
"send": "Enviar",
"stop": "Detener",
"deleted": "El chat de prueba se elimina al cerrar este panel.",
"chatTitle": "Prueba de {{name}}",
"failed": "La prueba falló: {{message}}",
"slow": "El agente sigue trabajando. Deténgalo o cierre el panel."
}
},
"run": {
@@ -83,7 +83,8 @@
"Files": "Fichiers",
"Tools": "Outils",
"Notes": "Mémoire",
"Connections": "Connexions"
"Connections": "Connexions",
"Effective": "Vue effective"
},
"name": "Name",
"description": "Description",
@@ -159,14 +160,16 @@
"capabilities": "Capacités",
"memory": "Mémoire et connaissances",
"advanced": "Avancé",
"connections": "Connexions"
"connections": "Connexions",
"effective": "Vue effective"
},
"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.",
"connections": "Les connexions que cet agent peut utiliser."
"connections": "Les connexions que cet agent peut utiliser.",
"effective": "Ce que cet agent peut faire en ce moment."
},
"header": {
"unnamed": "Agent sans nom",
@@ -226,6 +229,110 @@
"perChat": "Aucune cochée : chaque discussion choisit la sienne.",
"summary_one": "{{count}} connexion",
"summary_other": "{{count}} connexions"
},
"more": "Plus d'actions",
"eff": {
"summaryOk": "Rien n'est bloqué.",
"summaryBlocked_one": "{{count}} chose est bloquée.",
"summaryBlocked_other": "{{count}} choses sont bloquées.",
"unsaved": "Cette liste suit le formulaire tel qu'il est maintenant, avec les changements non enregistrés.",
"unsavedConn": "Les outils, la mémoire et les connaissances suivent le formulaire tel qu'il est maintenant. Les lignes de connexion viennent de l'agent enregistré et de la connexion enregistrée.",
"state": {
"allowed": "Peut",
"asks": "Demande d'abord",
"off": "Désactivé",
"blocked": "Bloqué",
"info": "Remarque"
},
"more_one": "et {{count}} de plus",
"more_other": "et {{count}} de plus",
"fixHere": "Corriger ici",
"fixOpen": "Ouvrir la page qui le corrige",
"model": {
"fixed": "Fonctionne avec {{model}}.",
"any": "Utilise le modèle choisi dans chaque discussion."
},
"tools": {
"none": "Aucun outil n'est activé. Il ne peut répondre qu'avec ce qu'il sait.",
"some_one": "Peut utiliser {{count}} outil : {{names}}.",
"some_other": "Peut utiliser {{count}} outils : {{names}}.",
"blocked": "{{name}} est activé mais ne peut pas fonctionner pour l'instant. {{reason}}"
},
"skills": {
"some_one": "Peut utiliser {{count}} compétence : {{names}}.",
"some_other": "Peut utiliser {{count}} compétences : {{names}}."
},
"files": {
"all": "Peut accéder à tous vos fichiers.",
"folders_one": "Ne peut accéder qu'au {{count}} dossier choisi.",
"folders_other": "Ne peut accéder qu'aux {{count}} dossiers choisis."
},
"grants": {
"parentOff": "{{name}} est autorisé, mais ne fonctionne pas tant que {{parent}} est désactivé. Activez {{parent}}.",
"connection": "{{name}} est autorisé pour l'agent, mais pas sur cette connexion : {{connection}}",
"some_one": "A {{count}} droit supplémentaire : {{names}}.",
"some_other": "A {{count}} droits supplémentaires : {{names}}.",
"unattended_one": "Quand personne ne regarde, il peut utiliser {{count}} droit seul : {{names}}.",
"unattended_other": "Quand personne ne regarde, il peut utiliser {{count}} droits seul : {{names}}."
},
"memory": {
"userOff": "La mémoire est désactivée dans vos réglages : cet agent ne lit ni n'enregistre de notes, quoi qu'il choisisse ici.",
"none": "Ne garde et ne lit aucune note.",
"mine_and_own": "Lit vos notes et les siennes, et enregistre dans les siennes.",
"own": "Lit et enregistre seulement ses propres notes.",
"mine": "Lit vos notes et y enregistre.",
"asBefore": "La mémoire fonctionne comme avant : il lit les notes quand un module nomme Memory.",
"noGrant": "Il doit enregistrer des notes, mais le droit de mémoriser est désactivé, donc il n'en enregistre aucune. Activez-le dans Mémoire.",
"level": {
"auto": "Les notes sont enregistrées tout de suite.",
"ask": "Chaque note attend votre confirmation.",
"always": "Chaque note attend votre confirmation, et rien ne l'assouplit.",
"never": "Il n'enregistre aucune note."
}
},
"knowledge": {
"some_one": "Peut chercher dans {{count}} collection : {{names}}.",
"some_other": "Peut chercher dans {{count}} collections : {{names}}.",
"none": "Aucune collection de connaissances où chercher."
},
"connections": {
"unsaved": "Pas encore utilisé dans une messagerie. Enregistrez l'agent, puis ajoutez une connexion.",
"none": "Utilisé dans aucune messagerie.",
"loading": "Vérification de {{name}}...",
"error": "Impossible de vérifier {{name}}. Ouvrez-la pour voir ses autorisations.",
"summary": "{{name}} : {{allowed}} autorisés, {{blocked}} non autorisés.",
"blocked": "{{name}} ne peut pas utiliser {{capability}}. {{message}}"
}
},
"try": {
"open": "Essayer",
"title": "Essayer cet agent",
"desc": "Envoyez un message à une discussion de test qui utilise les réglages de ce formulaire. Rien n'est enregistré.",
"noModel": "Aucun modèle de discussion n'est installé. Installez ou choisissez d'abord un modèle.",
"defaultModel": "Aucun modèle n'est défini, le test utilise donc {{model}}.",
"skipped": "Le test n'utilise pas : {{what}}. Cela appartient à un agent enregistré.",
"skip": {
"grants": "autorisations",
"memory": "mémoire",
"knowledge": "collections de connaissances",
"folders": "dossiers partagés"
},
"empty": "Envoyez un message pour commencer.",
"role": {
"user": "Vous",
"assistant": "Agent",
"tool": "Outil"
},
"toolRan": "A utilisé un outil : {{name}}",
"working": "En cours...",
"label": "Message à l'agent",
"placeholder": "Demandez quelque chose que cet agent doit savoir faire",
"send": "Envoyer",
"stop": "Arrêter",
"deleted": "La discussion de test est supprimée à la fermeture de ce panneau.",
"chatTitle": "Test de {{name}}",
"failed": "Le test a échoué : {{message}}",
"slow": "L'agent travaille encore. Arrêtez-le ou fermez le panneau."
}
},
"run": {
@@ -83,7 +83,8 @@
"Files": "File",
"Tools": "Alat",
"Notes": "Memori",
"Connections": "Koneksi"
"Connections": "Koneksi",
"Effective": "Tampilan efektif"
},
"name": "Name",
"description": "Description",
@@ -159,14 +160,16 @@
"capabilities": "Kemampuan",
"memory": "Memori dan pengetahuan",
"advanced": "Lanjutan",
"connections": "Koneksi"
"connections": "Koneksi",
"effective": "Tampilan efektif"
},
"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.",
"connections": "Koneksi mana yang boleh dipakai agen ini."
"connections": "Koneksi mana yang boleh dipakai agen ini.",
"effective": "Apa yang bisa dilakukan agen ini sekarang."
},
"header": {
"unnamed": "Agen tanpa nama",
@@ -226,6 +229,110 @@
"perChat": "Tidak ada yang dicentang: tiap obrolan memilih sendiri.",
"summary_one": "{{count}} koneksi",
"summary_other": "{{count}} koneksi"
},
"more": "Tindakan lain",
"eff": {
"summaryOk": "Tidak ada yang diblokir.",
"summaryBlocked_one": "{{count}} hal diblokir.",
"summaryBlocked_other": "{{count}} hal diblokir.",
"unsaved": "Daftar ini mengikuti formulir saat ini, termasuk perubahan yang belum disimpan.",
"unsavedConn": "Alat, memori, dan pengetahuan mengikuti formulir saat ini. Baris koneksi berasal dari agen dan koneksi yang tersimpan.",
"state": {
"allowed": "Bisa",
"asks": "Bertanya dulu",
"off": "Mati",
"blocked": "Diblokir",
"info": "Catatan"
},
"more_one": "dan {{count}} lagi",
"more_other": "dan {{count}} lagi",
"fixHere": "Perbaiki di sini",
"fixOpen": "Buka halaman yang memperbaikinya",
"model": {
"fixed": "Berjalan dengan {{model}}.",
"any": "Memakai model yang dipilih di tiap obrolan."
},
"tools": {
"none": "Tidak ada alat yang aktif. Agen hanya bisa menjawab dari yang diketahuinya.",
"some_one": "Bisa memakai {{count}} alat: {{names}}.",
"some_other": "Bisa memakai {{count}} alat: {{names}}.",
"blocked": "{{name}} aktif tetapi belum bisa bekerja. {{reason}}"
},
"skills": {
"some_one": "Bisa memakai {{count}} keahlian: {{names}}.",
"some_other": "Bisa memakai {{count}} keahlian: {{names}}."
},
"files": {
"all": "Bisa mengakses semua berkas Anda.",
"folders_one": "Hanya bisa mengakses {{count}} folder yang dipilih.",
"folders_other": "Hanya bisa mengakses {{count}} folder yang dipilih."
},
"grants": {
"parentOff": "{{name}} diizinkan, tetapi tidak bekerja selama {{parent}} mati. Nyalakan {{parent}}.",
"connection": "{{name}} diizinkan untuk agen, tetapi tidak pada koneksi ini: {{connection}}",
"some_one": "Punya {{count}} hak tambahan: {{names}}.",
"some_other": "Punya {{count}} hak tambahan: {{names}}.",
"unattended_one": "Saat tidak ada yang mengawasi, agen boleh memakai {{count}} hak sendiri: {{names}}.",
"unattended_other": "Saat tidak ada yang mengawasi, agen boleh memakai {{count}} hak sendiri: {{names}}."
},
"memory": {
"userOff": "Memori mati di pengaturan Anda, jadi agen ini tidak membaca atau menyimpan catatan, apa pun pilihannya di sini.",
"none": "Tidak menyimpan dan tidak membaca catatan.",
"mine_and_own": "Membaca catatan Anda dan miliknya, dan menyimpan ke miliknya.",
"own": "Hanya membaca dan menyimpan catatannya sendiri.",
"mine": "Membaca catatan Anda dan menyimpan ke sana.",
"asBefore": "Memori bekerja seperti sebelumnya: membaca catatan saat add-on menyebut Memory.",
"noGrant": "Agen diatur menyimpan catatan, tetapi hak untuk mengingat mati, jadi tidak ada yang disimpan. Nyalakan di Memori.",
"level": {
"auto": "Catatan disimpan langsung.",
"ask": "Setiap catatan menunggu konfirmasi Anda.",
"always": "Setiap catatan menunggu Anda, dan tidak ada yang melonggarkannya.",
"never": "Tidak menyimpan catatan."
}
},
"knowledge": {
"some_one": "Bisa mencari di {{count}} koleksi: {{names}}.",
"some_other": "Bisa mencari di {{count}} koleksi: {{names}}.",
"none": "Tidak ada koleksi pengetahuan untuk dicari."
},
"connections": {
"unsaved": "Belum dipakai di aplikasi pesan. Simpan agen, lalu tambahkan koneksi.",
"none": "Tidak dipakai di aplikasi pesan mana pun.",
"loading": "Memeriksa {{name}}...",
"error": "Tidak bisa memeriksa {{name}}. Buka untuk melihat izinnya.",
"summary": "{{name}}: {{allowed}} diizinkan, {{blocked}} tidak diizinkan.",
"blocked": "{{name}} tidak bisa memakai {{capability}}. {{message}}"
}
},
"try": {
"open": "Coba",
"title": "Coba agen ini",
"desc": "Kirim pesan ke obrolan uji yang memakai pengaturan di formulir ini. Tidak ada yang disimpan.",
"noModel": "Belum ada model obrolan terpasang. Pasang atau pilih model dulu.",
"defaultModel": "Model belum diatur, jadi uji memakai {{model}}.",
"skipped": "Uji tidak memakai: {{what}}. Itu milik agen yang tersimpan.",
"skip": {
"grants": "izin",
"memory": "memori",
"knowledge": "koleksi pengetahuan",
"folders": "folder bersama"
},
"empty": "Kirim pesan untuk memulai.",
"role": {
"user": "Anda",
"assistant": "Agen",
"tool": "Alat"
},
"toolRan": "Memakai alat: {{name}}",
"working": "Bekerja...",
"label": "Pesan untuk agen",
"placeholder": "Tanyakan sesuatu yang harus bisa ditangani agen ini",
"send": "Kirim",
"stop": "Berhenti",
"deleted": "Obrolan uji dihapus saat panel ini ditutup.",
"chatTitle": "Uji {{name}}",
"failed": "Uji gagal: {{message}}",
"slow": "Agen masih bekerja. Hentikan atau tutup panel."
}
},
"run": {
@@ -83,7 +83,8 @@
"Files": "File",
"Tools": "Strumenti",
"Notes": "Memoria",
"Connections": "Connessioni"
"Connections": "Connessioni",
"Effective": "Vista effettiva"
},
"name": "Name",
"description": "Description",
@@ -159,14 +160,16 @@
"capabilities": "Capacità",
"memory": "Memoria e conoscenza",
"advanced": "Avanzate",
"connections": "Connessioni"
"connections": "Connessioni",
"effective": "Vista effettiva"
},
"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.",
"connections": "Quali connessioni può usare questo agente."
"connections": "Quali connessioni può usare questo agente.",
"effective": "Cosa può fare questo agente adesso."
},
"header": {
"unnamed": "Agente senza nome",
@@ -226,6 +229,110 @@
"perChat": "Nessuna selezionata: ogni chat sceglie la sua.",
"summary_one": "{{count}} connessione",
"summary_other": "{{count}} connessioni"
},
"more": "Altre azioni",
"eff": {
"summaryOk": "Niente è bloccato.",
"summaryBlocked_one": "{{count}} cosa è bloccata.",
"summaryBlocked_other": "{{count}} cose sono bloccate.",
"unsaved": "Questo elenco segue il modulo com'è ora, con le modifiche non salvate.",
"unsavedConn": "Strumenti, memoria e conoscenza seguono il modulo com'è ora. Le righe delle connessioni vengono dall'agente salvato e dalla connessione salvata.",
"state": {
"allowed": "Può",
"asks": "Chiede prima",
"off": "Disattivato",
"blocked": "Bloccato",
"info": "Nota"
},
"more_one": "e altri {{count}}",
"more_other": "e altri {{count}}",
"fixHere": "Correggi qui",
"fixOpen": "Apri la pagina che lo corregge",
"model": {
"fixed": "Usa {{model}}.",
"any": "Usa il modello scelto in ogni chat."
},
"tools": {
"none": "Nessuno strumento è attivo. Può rispondere solo con ciò che sa.",
"some_one": "Può usare {{count}} strumento: {{names}}.",
"some_other": "Può usare {{count}} strumenti: {{names}}.",
"blocked": "{{name}} è attivo ma ora non può funzionare. {{reason}}"
},
"skills": {
"some_one": "Può usare {{count}} competenza: {{names}}.",
"some_other": "Può usare {{count}} competenze: {{names}}."
},
"files": {
"all": "Può raggiungere tutti i tuoi file.",
"folders_one": "Può raggiungere solo la {{count}} cartella scelta.",
"folders_other": "Può raggiungere solo le {{count}} cartelle scelte."
},
"grants": {
"parentOff": "{{name}} è consentito, ma non funziona finché {{parent}} è disattivato. Attiva {{parent}}.",
"connection": "{{name}} è consentito per l'agente, ma non su questa connessione: {{connection}}",
"some_one": "Ha {{count}} permesso in più: {{names}}.",
"some_other": "Ha {{count}} permessi in più: {{names}}.",
"unattended_one": "Quando nessuno guarda, può usare {{count}} permesso da solo: {{names}}.",
"unattended_other": "Quando nessuno guarda, può usare {{count}} permessi da solo: {{names}}."
},
"memory": {
"userOff": "La memoria è disattivata nelle tue impostazioni, quindi questo agente non legge né salva note, qualunque cosa scelga qui.",
"none": "Non tiene e non legge note.",
"mine_and_own": "Legge le tue note e le sue, e salva nelle sue.",
"own": "Legge e salva solo le proprie note.",
"mine": "Legge le tue note e salva in esse.",
"asBefore": "La memoria funziona come prima: legge le note quando un add-on nomina Memory.",
"noGrant": "È impostato per salvare note, ma il permesso di ricordare è disattivato, quindi non ne salva. Attivalo in Memoria.",
"level": {
"auto": "Le note vengono salvate subito.",
"ask": "Ogni nota aspetta la tua conferma.",
"always": "Ogni nota aspetta te e nulla lo allenta.",
"never": "Non salva note."
}
},
"knowledge": {
"some_one": "Può cercare in {{count}} raccolta: {{names}}.",
"some_other": "Può cercare in {{count}} raccolte: {{names}}.",
"none": "Nessuna raccolta di conoscenza in cui cercare."
},
"connections": {
"unsaved": "Non ancora usato nelle app di messaggistica. Salva l'agente, poi aggiungi una connessione.",
"none": "Non usato in nessuna app di messaggistica.",
"loading": "Controllo di {{name}}...",
"error": "Impossibile controllare {{name}}. Aprila per vedere i suoi permessi.",
"summary": "{{name}}: {{allowed}} consentiti, {{blocked}} non consentiti.",
"blocked": "{{name}} non può usare {{capability}}. {{message}}"
}
},
"try": {
"open": "Prova",
"title": "Prova questo agente",
"desc": "Invia un messaggio a una chat di prova che usa le impostazioni di questo modulo. Non viene salvato nulla.",
"noModel": "Nessun modello di chat installato. Installa o scegli prima un modello.",
"defaultModel": "Nessun modello impostato, quindi la prova usa {{model}}.",
"skipped": "La prova non usa: {{what}}. Appartengono a un agente salvato.",
"skip": {
"grants": "permessi",
"memory": "memoria",
"knowledge": "raccolte di conoscenza",
"folders": "cartelle condivise"
},
"empty": "Invia un messaggio per iniziare.",
"role": {
"user": "Tu",
"assistant": "Agente",
"tool": "Strumento"
},
"toolRan": "Ha usato uno strumento: {{name}}",
"working": "Al lavoro...",
"label": "Messaggio all'agente",
"placeholder": "Chiedi qualcosa che questo agente deve saper gestire",
"send": "Invia",
"stop": "Ferma",
"deleted": "La chat di prova viene eliminata alla chiusura di questo pannello.",
"chatTitle": "Prova di {{name}}",
"failed": "La prova non è riuscita: {{message}}",
"slow": "L'agente sta ancora lavorando. Fermalo o chiudi il pannello."
}
},
"run": {
@@ -83,7 +83,8 @@
"Files": "파일",
"Tools": "도구",
"Notes": "메모리",
"Connections": "연결"
"Connections": "연결",
"Effective": "유효 보기"
},
"name": "Name",
"description": "Description",
@@ -159,14 +160,16 @@
"capabilities": "기능",
"memory": "메모리와 지식",
"advanced": "고급",
"connections": "연결"
"connections": "연결",
"effective": "유효 보기"
},
"lead": {
"identity": "에이전트가 누구이고 어떻게 행동하는지 정합니다.",
"capabilities": "에이전트가 사용하고 접근할 수 있는 것을 정합니다.",
"memory": "에이전트가 기억하는 것과 찾아볼 수 있는 것을 정합니다.",
"advanced": "추가 서버와 내보내기.",
"connections": "이 에이전트가 사용할 수 있는 연결입니다."
"connections": "이 에이전트가 사용할 수 있는 연결입니다.",
"effective": "이 에이전트가 지금 할 수 있는 일입니다."
},
"header": {
"unnamed": "이름 없는 에이전트",
@@ -226,6 +229,110 @@
"perChat": "선택 없음: 채팅마다 직접 고릅니다.",
"summary_one": "연결 {{count}}개",
"summary_other": "연결 {{count}}개"
},
"more": "더 많은 작업",
"eff": {
"summaryOk": "막힌 것이 없습니다.",
"summaryBlocked_one": "{{count}}개가 막혀 있습니다.",
"summaryBlocked_other": "{{count}}개가 막혀 있습니다.",
"unsaved": "이 목록은 저장하지 않은 변경을 포함해 지금의 양식을 따릅니다.",
"unsavedConn": "도구, 메모리, 지식은 지금의 양식을 따릅니다. 연결 줄은 저장된 에이전트와 저장된 연결에서 옵니다.",
"state": {
"allowed": "가능",
"asks": "먼저 물어봄",
"off": "꺼짐",
"blocked": "막힘",
"info": "참고"
},
"more_one": "외 {{count}}개",
"more_other": "외 {{count}}개",
"fixHere": "여기서 고치기",
"fixOpen": "고칠 수 있는 페이지 열기",
"model": {
"fixed": "{{model}}로 실행됩니다.",
"any": "채팅마다 고른 모델을 씁니다."
},
"tools": {
"none": "켜진 도구가 없습니다. 아는 것만으로 답할 수 있습니다.",
"some_one": "도구 {{count}}개를 쓸 수 있습니다: {{names}}.",
"some_other": "도구 {{count}}개를 쓸 수 있습니다: {{names}}.",
"blocked": "{{name}}은(는) 켜져 있지만 지금은 동작하지 않습니다. {{reason}}"
},
"skills": {
"some_one": "스킬 {{count}}개를 쓸 수 있습니다: {{names}}.",
"some_other": "스킬 {{count}}개를 쓸 수 있습니다: {{names}}."
},
"files": {
"all": "내 파일 전체에 접근할 수 있습니다.",
"folders_one": "고른 폴더 {{count}}개에만 접근할 수 있습니다.",
"folders_other": "고른 폴더 {{count}}개에만 접근할 수 있습니다."
},
"grants": {
"parentOff": "{{name}}은(는) 허용되지만 {{parent}}이(가) 꺼져 있는 동안은 동작하지 않습니다. {{parent}}을(를) 켜세요.",
"connection": "{{name}}은(는) 에이전트에는 허용되지만 이 연결에서는 아닙니다: {{connection}}",
"some_one": "추가 권한 {{count}}개가 있습니다: {{names}}.",
"some_other": "추가 권한 {{count}}개가 있습니다: {{names}}.",
"unattended_one": "아무도 보지 않을 때 권한 {{count}}개를 스스로 쓸 수 있습니다: {{names}}.",
"unattended_other": "아무도 보지 않을 때 권한 {{count}}개를 스스로 쓸 수 있습니다: {{names}}."
},
"memory": {
"userOff": "설정에서 메모리가 꺼져 있어, 여기서 무엇을 고르든 이 에이전트는 메모를 읽거나 저장하지 않습니다.",
"none": "메모를 보관하거나 읽지 않습니다.",
"mine_and_own": "내 메모와 자기 메모를 읽고 자기 메모에 저장합니다.",
"own": "자기 메모만 읽고 저장합니다.",
"mine": "내 메모를 읽고 거기에 저장합니다.",
"asBefore": "메모리는 이전처럼 동작합니다. 애드온이 Memory를 지정하면 메모를 읽습니다.",
"noGrant": "메모를 저장하도록 설정되어 있지만 기억할 권한이 꺼져 있어 저장하지 않습니다. 메모리에서 켜세요.",
"level": {
"auto": "메모가 바로 저장됩니다.",
"ask": "메모마다 확인을 기다립니다.",
"always": "메모마다 확인을 기다리며 이를 완화하는 것은 없습니다.",
"never": "메모를 저장하지 않습니다."
}
},
"knowledge": {
"some_one": "컬렉션 {{count}}개를 검색할 수 있습니다: {{names}}.",
"some_other": "컬렉션 {{count}}개를 검색할 수 있습니다: {{names}}.",
"none": "검색할 지식 컬렉션이 없습니다."
},
"connections": {
"unsaved": "아직 메시징 앱에서 쓰이지 않습니다. 에이전트를 저장한 뒤 연결을 추가하세요.",
"none": "어떤 메시징 앱에서도 쓰이지 않습니다.",
"loading": "{{name}} 확인 중...",
"error": "{{name}}을(를) 확인할 수 없습니다. 열어서 권한을 확인하세요.",
"summary": "{{name}}: 허용 {{allowed}}개, 불허 {{blocked}}개.",
"blocked": "{{name}}은(는) {{capability}}을(를) 쓸 수 없습니다. {{message}}"
}
},
"try": {
"open": "체험",
"title": "이 에이전트 체험",
"desc": "이 양식의 설정을 쓰는 테스트 채팅에 메시지를 보냅니다. 아무것도 저장되지 않습니다.",
"noModel": "설치된 채팅 모델이 없습니다. 먼저 모델을 설치하거나 고르세요.",
"defaultModel": "모델이 정해지지 않아 테스트는 {{model}}을(를) 씁니다.",
"skipped": "테스트는 {{what}}을(를) 쓰지 않습니다. 저장된 에이전트의 것입니다.",
"skip": {
"grants": "권한",
"memory": "메모리",
"knowledge": "지식 컬렉션",
"folders": "공유 폴더"
},
"empty": "메시지를 보내 시작하세요.",
"role": {
"user": "나",
"assistant": "에이전트",
"tool": "도구"
},
"toolRan": "도구 사용: {{name}}",
"working": "작업 중...",
"label": "에이전트에게 보낼 메시지",
"placeholder": "이 에이전트가 처리해야 할 것을 물어보세요",
"send": "보내기",
"stop": "중지",
"deleted": "이 패널을 닫으면 테스트 채팅이 삭제됩니다.",
"chatTitle": "{{name}} 테스트",
"failed": "테스트 실패: {{message}}",
"slow": "에이전트가 아직 작업 중입니다. 중지하거나 패널을 닫으세요."
}
},
"run": {
@@ -83,7 +83,8 @@
"Files": "Arquivos",
"Tools": "Ferramentas",
"Notes": "Memória",
"Connections": "Conexões"
"Connections": "Conexões",
"Effective": "Visão efetiva"
},
"name": "Name",
"description": "Description",
@@ -159,14 +160,16 @@
"capabilities": "Capacidades",
"memory": "Memória e conhecimento",
"advanced": "Avançado",
"connections": "Conexões"
"connections": "Conexões",
"effective": "Visão efetiva"
},
"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.",
"connections": "Quais conexões este agente pode usar."
"connections": "Quais conexões este agente pode usar.",
"effective": "O que este agente pode fazer agora."
},
"header": {
"unnamed": "Agente sem nome",
@@ -226,6 +229,110 @@
"perChat": "Nenhuma marcada: cada chat escolhe a sua.",
"summary_one": "{{count}} conexão",
"summary_other": "{{count}} conexões"
},
"more": "Mais ações",
"eff": {
"summaryOk": "Nada está bloqueado.",
"summaryBlocked_one": "{{count}} coisa está bloqueada.",
"summaryBlocked_other": "{{count}} coisas estão bloqueadas.",
"unsaved": "Esta lista segue o formulário como está agora, com as alterações não salvas.",
"unsavedConn": "Ferramentas, memória e conhecimento seguem o formulário como está agora. As linhas de conexões vêm do agente salvo e da conexão salva.",
"state": {
"allowed": "Pode",
"asks": "Pergunta antes",
"off": "Desligado",
"blocked": "Bloqueado",
"info": "Nota"
},
"more_one": "e mais {{count}}",
"more_other": "e mais {{count}}",
"fixHere": "Corrigir aqui",
"fixOpen": "Abrir a página que corrige",
"model": {
"fixed": "Roda com {{model}}.",
"any": "Usa o modelo escolhido em cada conversa."
},
"tools": {
"none": "Nenhuma ferramenta está ligada. Só pode responder com o que sabe.",
"some_one": "Pode usar {{count}} ferramenta: {{names}}.",
"some_other": "Pode usar {{count}} ferramentas: {{names}}.",
"blocked": "{{name}} está ligado, mas não pode funcionar agora. {{reason}}"
},
"skills": {
"some_one": "Pode usar {{count}} habilidade: {{names}}.",
"some_other": "Pode usar {{count}} habilidades: {{names}}."
},
"files": {
"all": "Pode acessar todos os seus arquivos.",
"folders_one": "Só pode acessar a {{count}} pasta escolhida.",
"folders_other": "Só pode acessar as {{count}} pastas escolhidas."
},
"grants": {
"parentOff": "{{name}} é permitido, mas não funciona enquanto {{parent}} estiver desligado. Ligue {{parent}}.",
"connection": "{{name}} é permitido para o agente, mas não nesta conexão: {{connection}}",
"some_one": "Tem {{count}} permissão extra: {{names}}.",
"some_other": "Tem {{count}} permissões extras: {{names}}.",
"unattended_one": "Quando ninguém está olhando, pode usar {{count}} permissão por conta própria: {{names}}.",
"unattended_other": "Quando ninguém está olhando, pode usar {{count}} permissões por conta própria: {{names}}."
},
"memory": {
"userOff": "A memória está desligada nas suas configurações, então este agente não lê nem salva notas, seja qual for a escolha aqui.",
"none": "Não guarda nem lê notas.",
"mine_and_own": "Lê as suas notas e as próprias, e salva nas próprias.",
"own": "Lê e salva só as próprias notas.",
"mine": "Lê as suas notas e salva nelas.",
"asBefore": "A memória funciona como antes: lê notas quando um complemento cita Memory.",
"noGrant": "Está configurado para salvar notas, mas a permissão de lembrar está desligada, então não salva nenhuma. Ligue em Memória.",
"level": {
"auto": "As notas são salvas na hora.",
"ask": "Cada nota espera a sua confirmação.",
"always": "Cada nota espera por você, e nada afrouxa isso.",
"never": "Não salva notas."
}
},
"knowledge": {
"some_one": "Pode pesquisar {{count}} coleção: {{names}}.",
"some_other": "Pode pesquisar {{count}} coleções: {{names}}.",
"none": "Nenhuma coleção de conhecimento para pesquisar."
},
"connections": {
"unsaved": "Ainda não usado em apps de mensagens. Salve o agente e depois adicione uma conexão.",
"none": "Não usado em nenhum app de mensagens.",
"loading": "Verificando {{name}}...",
"error": "Não foi possível verificar {{name}}. Abra-a para ver as permissões.",
"summary": "{{name}}: {{allowed}} permitidos, {{blocked}} não permitidos.",
"blocked": "{{name}} não pode usar {{capability}}. {{message}}"
}
},
"try": {
"open": "Testar",
"title": "Testar este agente",
"desc": "Envie uma mensagem a uma conversa de teste que usa as configurações deste formulário. Nada é salvo.",
"noModel": "Nenhum modelo de conversa instalado. Instale ou escolha um modelo primeiro.",
"defaultModel": "Nenhum modelo definido, então o teste usa {{model}}.",
"skipped": "O teste não usa: {{what}}. Isso pertence a um agente salvo.",
"skip": {
"grants": "permissões",
"memory": "memória",
"knowledge": "coleções de conhecimento",
"folders": "pastas compartilhadas"
},
"empty": "Envie uma mensagem para começar.",
"role": {
"user": "Você",
"assistant": "Agente",
"tool": "Ferramenta"
},
"toolRan": "Usou uma ferramenta: {{name}}",
"working": "Trabalhando...",
"label": "Mensagem para o agente",
"placeholder": "Pergunte algo que este agente deve resolver",
"send": "Enviar",
"stop": "Parar",
"deleted": "A conversa de teste é excluída quando você fecha este painel.",
"chatTitle": "Teste de {{name}}",
"failed": "O teste falhou: {{message}}",
"slow": "O agente ainda está trabalhando. Pare-o ou feche o painel."
}
},
"run": {
@@ -83,7 +83,8 @@
"Files": "文件",
"Tools": "工具",
"Notes": "记忆",
"Connections": "连接"
"Connections": "连接",
"Effective": "生效视图"
},
"name": "Name",
"description": "Description",
@@ -159,14 +160,16 @@
"capabilities": "能力",
"memory": "记忆与知识",
"advanced": "高级",
"connections": "连接"
"connections": "连接",
"effective": "生效视图"
},
"lead": {
"identity": "智能体是谁,以及它如何行事。",
"capabilities": "智能体可以使用和访问的内容。",
"memory": "智能体记住什么,以及可以查找什么。",
"advanced": "额外的服务器和导出。",
"connections": "此代理可以使用哪些连接。"
"connections": "此代理可以使用哪些连接。",
"effective": "这个智能体现在能做什么。"
},
"header": {
"unnamed": "未命名的智能体",
@@ -226,6 +229,110 @@
"perChat": "未勾选:每个聊天自行选择。",
"summary_one": "{{count}} 个连接",
"summary_other": "{{count}} 个连接"
},
"more": "更多操作",
"eff": {
"summaryOk": "没有被阻止的内容。",
"summaryBlocked_one": "{{count}} 项被阻止。",
"summaryBlocked_other": "{{count}} 项被阻止。",
"unsaved": "此列表按表单当前的样子生成,包括尚未保存的更改。",
"unsavedConn": "工具、记忆和知识按表单当前的样子生成。连接行来自已保存的智能体和已保存的连接。",
"state": {
"allowed": "可以",
"asks": "先询问",
"off": "关闭",
"blocked": "已阻止",
"info": "说明"
},
"more_one": "以及另外 {{count}} 项",
"more_other": "以及另外 {{count}} 项",
"fixHere": "在此修复",
"fixOpen": "打开可修复它的页面",
"model": {
"fixed": "使用 {{model}} 运行。",
"any": "使用每次聊天中选择的模型。"
},
"tools": {
"none": "没有开启任何工具。它只能凭已有知识回答。",
"some_one": "可以使用 {{count}} 个工具:{{names}}。",
"some_other": "可以使用 {{count}} 个工具:{{names}}。",
"blocked": "{{name}} 已开启,但现在无法工作。{{reason}}"
},
"skills": {
"some_one": "可以使用 {{count}} 个技能:{{names}}。",
"some_other": "可以使用 {{count}} 个技能:{{names}}。"
},
"files": {
"all": "可以访问你的所有文件。",
"folders_one": "只能访问你选择的 {{count}} 个文件夹。",
"folders_other": "只能访问你选择的 {{count}} 个文件夹。"
},
"grants": {
"parentOff": "{{name}} 已允许,但在 {{parent}} 关闭时无法工作。请打开 {{parent}}。",
"connection": "{{name}} 对智能体已允许,但在此连接上不行:{{connection}}",
"some_one": "另有 {{count}} 项权限:{{names}}。",
"some_other": "另有 {{count}} 项权限:{{names}}。",
"unattended_one": "无人看管时,它可以自行使用 {{count}} 项权限:{{names}}。",
"unattended_other": "无人看管时,它可以自行使用 {{count}} 项权限:{{names}}。"
},
"memory": {
"userOff": "你的设置中记忆已关闭,因此无论在此选择什么,这个智能体都不会读取或保存笔记。",
"none": "不保存也不读取笔记。",
"mine_and_own": "读取你的笔记和它自己的笔记,并保存到它自己的笔记。",
"own": "只读取并保存它自己的笔记。",
"mine": "读取你的笔记并保存到其中。",
"asBefore": "记忆照旧工作:当附加组件提到 Memory 时读取笔记。",
"noGrant": "它被设置为保存笔记,但“记住内容”的权限已关闭,所以什么都不会保存。请在记忆中打开。",
"level": {
"auto": "笔记会立即保存。",
"ask": "每条笔记都等你确认。",
"always": "每条笔记都等你确认,没有任何放宽。",
"never": "它不保存笔记。"
}
},
"knowledge": {
"some_one": "可以搜索 {{count}} 个集合:{{names}}。",
"some_other": "可以搜索 {{count}} 个集合:{{names}}。",
"none": "没有可搜索的知识集合。"
},
"connections": {
"unsaved": "尚未用于消息应用。请先保存智能体,再添加连接。",
"none": "未用于任何消息应用。",
"loading": "正在检查 {{name}}...",
"error": "无法检查 {{name}}。打开它查看权限。",
"summary": "{{name}}:允许 {{allowed}} 项,不允许 {{blocked}} 项。",
"blocked": "{{name}} 无法使用 {{capability}}。{{message}}"
}
},
"try": {
"open": "试用",
"title": "试用这个智能体",
"desc": "向使用此表单设置的测试聊天发送一条消息。不会保存任何内容。",
"noModel": "尚未安装聊天模型。请先安装或选择一个模型。",
"defaultModel": "未设置模型,因此测试使用 {{model}}。",
"skipped": "测试不使用:{{what}}。这些属于已保存的智能体。",
"skip": {
"grants": "权限",
"memory": "记忆",
"knowledge": "知识集合",
"folders": "共享文件夹"
},
"empty": "发送一条消息开始。",
"role": {
"user": "你",
"assistant": "智能体",
"tool": "工具"
},
"toolRan": "使用了工具:{{name}}",
"working": "处理中...",
"label": "给智能体的消息",
"placeholder": "问一件这个智能体应能处理的事",
"send": "发送",
"stop": "停止",
"deleted": "关闭此面板时,测试聊天会被删除。",
"chatTitle": "{{name}} 的测试",
"failed": "测试失败:{{message}}",
"slow": "智能体仍在工作。请停止它或关闭面板。"
}
},
"run": {
@@ -0,0 +1,47 @@
/* eslint-disable no-unused-vars -- components used only inside JSX look unused to this config, which has no eslint-plugin-react */
import { Link } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import Icon from '../Icon'
import { blockedCount } from '../../utils/agentEffective'
import './agentparts.css'
const ICON = { allowed: 'check', asks: 'info', off: 'minus', blocked: 'warning', info: 'info' }
// The Effective view: what the agent can do right now, line by line, from the
// form as it stands. Each line names the section it explains and, when
// something is blocked, the one place that fixes it. Nothing here changes the
// form: a fix on this page only moves the reader to the section.
export default function AgentEffective({ lines, dirty, isEdit, hasConnections, onGoTo }) {
const { t } = useTranslation('agents')
const blocked = blockedCount(lines)
return (
<div className="aed-eff" data-testid="agent-effective">
<p className="aed-eff__sum" role="status" data-testid="agent-effective-summary" data-blocked={blocked || undefined}>
{blocked ? t('create.eff.summaryBlocked', { count: blocked }) : t('create.eff.summaryOk')}
</p>
{dirty && <p className="ag-note" data-testid="agent-effective-unsaved">{t(isEdit && hasConnections ? 'create.eff.unsavedConn' : 'create.eff.unsaved')}</p>}
<ul className="aed-eff__list">
{lines.map((l) => (
<li key={l.id} className="aed-eff__row" data-state={l.state} data-line={l.id} data-code={l.code || undefined} data-testid="agent-effective-line">
<Icon name={ICON[l.state] || 'info'} className="aed-eff__icon" />
<div className="aed-eff__text">
<span className={['allowed', 'info'].includes(l.state) ? 'sr-only' : 'aed-eff__state'}>{t(`create.eff.state.${l.state}`)}</span>{' '}
<span>{t(`create.eff.${l.text}`, { ...l.vars, count: l.vars?.count, defaultValue: l.text })}</span>
{l.vars?.more > 0 && <span className="aed-eff__more">{' '}{t('create.eff.more', { count: l.vars.more })}</span>}
{l.code && <code className="aed-eff__code">{l.code}</code>}
</div>
<div className="aed-eff__acts">
{l.fix?.kind === 'link' && <Link className="aed-eff__link" to={l.fix.to} data-testid="agent-effective-fix">{t('create.eff.fixOpen')}</Link>}
{l.fix?.kind === 'section' && (
<button type="button" className="aed-eff__link" onClick={() => onGoTo(l.fix.section)} data-testid="agent-effective-fix">{t('create.eff.fixHere')}</button>
)}
{!(l.fix?.kind === 'section' && l.fix.section === l.section) && (
<button type="button" className="aed-eff__link aed-eff__link--quiet" onClick={() => onGoTo(l.section)} data-testid="agent-effective-goto">{t(`create.nav.${l.section}`)}</button>
)}
</div>
</li>
))}
</ul>
</div>
)
}
@@ -0,0 +1,136 @@
/* eslint-disable no-unused-vars -- components used only inside JSX look unused to this config, which has no eslint-plugin-react */
import { useCallback, useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { useTranslation } from 'react-i18next'
import Icon from '../Icon'
import { Callout } from '../ui'
import { chatsApi } from '../../utils/api'
import { notApplied, tryLines, tryRequest } from '../../utils/agentTryIt'
import { useSheetFocus } from './useSheetFocus'
import './agentparts.css'
const POLL_MS = 1000
const GIVE_UP_MS = 120_000
// The Try it sheet: send a message to a test chat that uses the settings now
// in the form, without saving the agent. The chat is created on the first
// message and deleted when the sheet closes.
export default function AgentTryIt({ form, tools, models, onClose }) {
const { t } = useTranslation('agents')
const { sheetRef, closeRef } = useSheetFocus(onClose)
const [text, setText] = useState('')
const [lines, setLines] = useState([])
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const chatRef = useRef('')
const liveRef = useRef(true)
const taRef = useRef(null)
const request = tryRequest({ form, tools, models })
const skipped = notApplied(form)
// Delete the test chat on close, also when the page goes away.
const cleanup = useCallback(() => {
const id = chatRef.current
chatRef.current = ''
if (id) chatsApi.stop(id).catch(() => {}).then(() => chatsApi.delete(id)).catch(() => {})
}, [])
useEffect(() => {
liveRef.current = true
const bye = () => cleanup()
window.addEventListener('pagehide', bye)
return () => { liveRef.current = false; window.removeEventListener('pagehide', bye); cleanup() }
}, [cleanup])
const wait = async (id) => {
const until = Date.now() + GIVE_UP_MS
for (;;) {
await new Promise((r) => setTimeout(r, POLL_MS))
if (!liveRef.current || chatRef.current !== id) return
const chat = await chatsApi.get(id)
const msgs = await chatsApi.messages(id)
if (!liveRef.current) return
setLines(tryLines(msgs.messages))
if (!chat.running) return
if (Date.now() > until) { setError(t('create.try.slow')); return }
}
}
const send = async (e) => {
e?.preventDefault?.()
const msg = text.trim()
if (!msg || busy || !request) return
setBusy(true)
setError('')
try {
if (!chatRef.current) {
const chat = await chatsApi.create(request.model, t('create.try.chatTitle', { name: form.name || t('create.header.unnamed') }), undefined, false, request.switches)
chatRef.current = chat.id
if (request.settings.system_prompt) await chatsApi.update(chat.id, { settings: request.settings })
}
const id = chatRef.current
setText('')
setLines((l) => [...l, { seq: `local-${l.length}`, role: 'user', text: msg }])
await chatsApi.send(id, msg)
await wait(id)
} catch (err) {
setError(t('create.try.failed', { message: err.message }))
} finally {
if (liveRef.current) { setBusy(false); taRef.current?.focus() }
}
}
const stop = () => { if (chatRef.current) chatsApi.stop(chatRef.current).catch(() => {}) }
return createPortal(
<div className="dk-sheet-veil" data-state="open" onMouseDown={onClose}>
<div ref={sheetRef} className="dk-sheet dk-sheet--wide" role="dialog" aria-modal="true" aria-labelledby="agent-try-title" data-state="open" data-testid="agent-try" onMouseDown={(e) => e.stopPropagation()}>
<span className="dk-sheet-grip" aria-hidden="true" />
<div className="dk-sheet-head">
<div>
<h2 className="dk-sheet-title" id="agent-try-title">{t('create.try.title')}</h2>
<p className="dk-sheet-desc">{t('create.try.desc')}</p>
</div>
<button ref={closeRef} type="button" className="dk-btn dk-btn--ghost dk-btn--icon dk-btn--sm" aria-label={t('create.close')} onClick={onClose}><Icon name="close" /></button>
</div>
<div className="dk-sheet-body aed-try">
{!request && <Callout tone="warn" testId="agent-try-nomodel">{t('create.try.noModel')}</Callout>}
{request?.usedDefaultModel && <p className="ag-note" data-testid="agent-try-model">{t('create.try.defaultModel', { model: request.model })}</p>}
{skipped.length > 0 && (
<p className="ag-note" data-testid="agent-try-skipped">
{t('create.try.skipped', { what: skipped.map((k) => t(`create.try.skip.${k}`)).join(', ') })}
</p>
)}
<ol className="aed-try__log" aria-live="polite" data-testid="agent-try-log">
{lines.length === 0 && <li className="aed-try__empty">{t('create.try.empty')}</li>}
{lines.map((l) => (
<li key={l.seq} className="aed-try__line" data-role={l.role}>
<span className="aed-try__who">{t(`create.try.role.${l.role}`)}</span>
<span className="aed-try__text">{l.role === 'tool' ? t('create.try.toolRan', { name: l.text || '' }) : l.text}</span>
</li>
))}
{busy && <li className="aed-try__empty" data-testid="agent-try-working">{t('create.try.working')}</li>}
</ol>
{error && <Callout tone="error" role="alert" testId="agent-try-error">{error}</Callout>}
<form className="aed-try__form" onSubmit={send}>
<label className="sr-only" htmlFor="agent-try-input">{t('create.try.label')}</label>
<textarea
id="agent-try-input" ref={taRef} className="textarea" rows={3} value={text} placeholder={t('create.try.placeholder')}
onChange={(e) => setText(e.target.value)} disabled={!request}
onKeyDown={(e) => { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) send(e) }}
data-testid="agent-try-input"
/>
<div className="aed-try__acts">
{busy && <button type="button" className="dk-btn dk-btn--secondary" onClick={stop} data-testid="agent-try-stop"><Icon name="stop" /> {t('create.try.stop')}</button>}
<button type="submit" className="dk-btn dk-btn--primary" disabled={!request || busy || !text.trim()} data-testid="agent-try-send"><Icon name="send" /> {t('create.try.send')}</button>
</div>
</form>
</div>
<div className="dk-sheet-foot">
<p className="ag-note">{t('create.try.deleted')}</p>
<button type="button" className="dk-btn dk-btn--secondary" onClick={onClose}>{t('create.close')}</button>
</div>
</div>
</div>,
document.body,
)
}
@@ -75,3 +75,39 @@
@media (max-width: 640px) {
.aed-card .am__cards { grid-template-columns: minmax(0, 1fr); }
}
/* The Effective view: one row per thing the agent can or cannot do. */
.aed-eff { display: grid; gap: var(--sp-3); min-width: 0; }
.aed-eff__sum { margin: 0; font-weight: 600; color: var(--dk-text); }
.aed-eff__sum[data-blocked] { color: var(--dk-warn-text); }
.aed-eff__list { display: grid; gap: var(--sp-2); margin: 0; padding: 0; list-style: none; min-width: 0; }
.aed-eff__row {
display: grid; grid-template-columns: auto minmax(0, 1fr); gap: var(--sp-1) var(--sp-3); align-items: start; 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-eff__row[data-state="blocked"] { background: var(--dk-warn-wash, var(--dk-hover)); box-shadow: inset 0 0 0 1px var(--dk-warn-text); }
.aed-eff__icon { margin-top: 3px; color: var(--dk-muted); }
.aed-eff__row[data-state="allowed"] .aed-eff__icon { color: var(--dk-ok-text); }
.aed-eff__row[data-state="blocked"] .aed-eff__icon { color: var(--dk-warn-text); }
.aed-eff__text { min-width: 0; overflow-wrap: anywhere; color: var(--dk-text); }
.aed-eff__state { font-size: var(--fs-meta); font-weight: 600; text-transform: none; color: var(--dk-muted); }
.aed-eff__row[data-state="blocked"] .aed-eff__state { color: var(--dk-warn-text); }
.aed-eff__more { color: var(--dk-muted); }
.aed-eff__code { margin-left: var(--sp-2); padding: 0 6px; border-radius: var(--r-sm); background: var(--dk-hover); color: var(--dk-muted); font-family: var(--font-mono); font-size: var(--fs-meta); }
.aed-eff__acts { grid-column: 2; display: flex; flex-wrap: wrap; gap: var(--sp-1) var(--sp-3); min-width: 0; }
.aed-eff__link { padding: 0; border: 0; background: none; font: inherit; font-size: var(--fs-small); color: var(--dk-accent-text); cursor: pointer; text-decoration: none; text-align: left; }
.aed-eff__link:hover { text-decoration: underline; }
.aed-eff__link:focus-visible { outline: 2px solid var(--focus-ring-color); outline-offset: 2px; border-radius: var(--r-sm); }
.aed-eff__link--quiet { color: var(--dk-muted); }
/* The Try it sheet. */
.aed-try { display: grid; gap: var(--sp-3); min-width: 0; }
.aed-try__log { display: grid; gap: var(--sp-2); margin: 0; padding: var(--sp-3); list-style: none; min-width: 0; max-height: 40vh; overflow-y: auto; border-radius: var(--r-md); box-shadow: inset 0 0 0 1px var(--dk-edge); }
.aed-try__line { display: grid; gap: 2px; min-width: 0; }
.aed-try__who { font-size: var(--fs-meta); font-weight: 600; color: var(--dk-muted); }
.aed-try__text { min-width: 0; white-space: pre-wrap; overflow-wrap: anywhere; color: var(--dk-text); }
.aed-try__line[data-role="tool"] .aed-try__text { color: var(--dk-muted); font-size: var(--fs-small); }
.aed-try__empty { color: var(--dk-muted); font-size: var(--fs-small); }
.aed-try__form { display: grid; gap: var(--sp-2); min-width: 0; }
.aed-try__form .textarea { width: 100%; max-width: 100%; box-sizing: border-box; }
.aed-try__acts { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: var(--sp-2); }
@@ -0,0 +1,38 @@
import { useEffect, useRef } from 'react'
// Focus handling of a modal sheet of the agent form: focus goes to the close
// button on open, Tab stays inside, Escape closes, and focus returns to the
// opener on close. Attach `sheetRef` to the dialog and `closeRef` to its
// close button.
export function useSheetFocus(onClose) {
const sheetRef = useRef(null)
const closeRef = useRef(null)
const openerRef = useRef(null)
// The parent passes a fresh onClose on every render. Read it through a ref
// so the effect below runs once per open: re-running it would move focus to
// the opener and back to Close, pulling it out of the control in use.
const onCloseRef = useRef(onClose)
useEffect(() => { onCloseRef.current = onClose })
useEffect(() => {
openerRef.current = document.activeElement
closeRef.current?.focus()
const onKey = (e) => {
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); onCloseRef.current(); return }
if (e.key !== 'Tab' || !sheetRef.current) return
const focusable = Array.from(sheetRef.current.querySelectorAll('button:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'))
if (focusable.length === 0) return
const first = focusable[0]
const last = focusable[focusable.length - 1]
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus() }
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus() }
}
window.addEventListener('keydown', onKey, true)
return () => {
window.removeEventListener('keydown', onKey, true)
const el = openerRef.current
if (el && document.contains(el)) el.focus?.()
}
}, [])
return { sheetRef, closeRef }
}
+27
View File
@@ -0,0 +1,27 @@
import { useEffect, useMemo, useState } from 'react'
import { chatConnectorsApi } from '../utils/api'
// What each messaging connection of a saved agent lets its senders do, as the
// server's access preview answers it (the same permission chain as the
// connection's own page). The preview reads the saved agent and the saved
// connection, so it is asked once per set of connections, not per keystroke.
// connections: the rows of useAgentTools ({ id, name, to }).
// Returns [{ id, name, to, status: loading | ok | error, view }].
export function useAgentAccess(agentId, connections) {
const key = agentId ? connections.map((c) => c.id).join('|') : ''
const [views, setViews] = useState({})
useEffect(() => {
if (!key) return undefined
let live = true
key.split('|').forEach((id) => {
chatConnectorsApi.accessPreview(id, { agent_id: agentId })
.then((view) => { if (live) setViews((v) => ({ ...v, [id]: { status: 'ok', view } })) })
.catch(() => { if (live) setViews((v) => ({ ...v, [id]: { status: 'error' } })) })
})
return () => { live = false }
}, [key, agentId])
return useMemo(
() => (agentId ? connections : []).map((c) => ({ ...c, status: views[c.id]?.status || 'loading', view: views[c.id]?.view })),
[agentId, connections, views],
)
}
+38 -29
View File
@@ -8,6 +8,8 @@ import { useAgentTools } from '../hooks/useAgentTools'
import { useModels } from '../hooks/useModels'
import { useToolGroups } from '../hooks/useToolGroups'
import { useMemorySettings } from '../hooks/useMemorySettings'
import { useAgentAccess } from '../hooks/useAgentAccess'
import { effectiveLines, flatRows, blockedCount } from '../utils/agentEffective'
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'
@@ -43,6 +45,13 @@ import AgentPresets, { AppliedNotice } from '../components/agents/AgentPresets'
import AgentTemplates from '../components/agents/AgentTemplates'
// eslint-disable-next-line no-unused-vars
import AgentConnections from '../components/agents/AgentConnections'
// eslint-disable-next-line no-unused-vars
import AgentEffective from '../components/agents/AgentEffective'
// eslint-disable-next-line no-unused-vars
import AgentTryIt from '../components/agents/AgentTryIt'
// eslint-disable-next-line no-unused-vars
import ActionMenu from '../components/ActionMenu'
import { useSheetFocus } from '../components/agents/useSheetFocus'
// 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.
@@ -51,6 +60,7 @@ const GROUPS = [
{ id: 'capabilities', icon: 'plug', parts: ['Tools', 'Skills', 'Files'] },
{ id: 'memory', icon: 'memory', parts: ['Notes', 'MemorySettings'] },
{ id: 'connections', icon: 'plug', parts: ['Connections'], bare: true },
{ id: 'effective', icon: 'eye', parts: ['Effective'], bare: true },
{ id: 'advanced', icon: 'server', parts: ['MCP', 'export'], collapsible: true },
]
const secDomId = (gid) => `aed-sec-${gid}`
@@ -91,6 +101,7 @@ export default function AgentCreate() {
const [activeSec, setActiveSec] = useState(GROUPS[0].id)
const [savedConfig, setSavedConfig] = useState(null)
const [previewOpen, setPreviewOpen] = useState(false)
const [tryOpen, setTryOpen] = useState(false)
const [previewTab, setPreviewTab] = useState('config')
const [form, setForm] = useState(emptyAgent())
// null, a row index, or 'new' for a row being added
@@ -161,6 +172,8 @@ export default function AgentCreate() {
// The person's own memory switch, read only here: an agent cannot widen it.
const memoryUser = useMemorySettings()
const toolGroups = useToolGroups(tools, { commandState: false, forAgent: true })
// What the server says each connection of this agent lets its senders do.
const access = useAgentAccess(form.id || '', tools.connections)
// The definition exactly as it is saved. The preview shows this object and
// Save sends it.
@@ -278,6 +291,14 @@ export default function AgentCreate() {
const baseline = savedConfig ? agentPayload(savedConfig) : {}
const diff = diffConfig(baseline, payload)
const changedKeys = new Set(savedConfig ? diff.map(d => d.key) : [])
const effective = effectiveLines({
form,
rows: flatRows(toolGroups),
skills: (tools.skills || []).filter(s => s.enabled && !s.gone).map(s => ({ on: true, id: s.name, label: s.name })),
memory: memoryUser.settings,
access,
isEdit,
})
const countOf = (n, key) => (n ? t(key, { count: n }) : t('create.noneYet'))
const foldInfo = (s) => {
@@ -305,6 +326,11 @@ export default function AgentCreate() {
summary = n ? t('create.connections.summary', { count: n }) : t('create.noneYet')
state = n ? 'ready' : 'empty'
}
else if (s.id === 'Effective') {
const n = blockedCount(effective)
summary = n ? t('create.eff.summaryBlocked', { count: n }) : t('create.eff.summaryOk')
state = n ? 'needs' : 'ready'
}
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)
@@ -464,6 +490,8 @@ export default function AgentCreate() {
</div>
</>
)
case 'Effective':
return <AgentEffective lines={effective} dirty={dirty} isEdit={isEdit} hasConnections={access.length > 0} onGoTo={goTo} />
case 'Connections':
return <AgentConnections addons={tools.addons} bindings={tools.connections} onChange={tools.setConnections} />
case 'Tools':
@@ -639,7 +667,14 @@ export default function AgentCreate() {
{t('create.preview')}
{diff.length > 0 && <span className="dk-badge dk-badge--count">{diff.length}</span>}
</Button>
<Button icon="flask" onClick={() => setTryOpen(true)} data-testid="agent-try-open">{t('create.try.open')}</Button>
<Button type="submit" variant="primary" icon="save" busy={saving}>{saveLabel}</Button>
{isEdit && savedConfig && (
<ActionMenu
ariaLabel={t('create.more')}
items={[{ key: 'export', icon: 'download', label: t('create.exportButton'), onClick: () => downloadAgentFile(savedConfig, addToast, t) }]}
/>
)}
</div>
<ul className="aed-chips" aria-label={t('create.header.summary')}>
<li className="aed-chip"><span className="aed-chip__k">{t('create.sections.ModelSettings')}</span><span className="aed-chip__v">{form.model || t('create.anyModel')}</span></li>
@@ -745,6 +780,8 @@ export default function AgentCreate() {
/>
)}
{tryOpen && <AgentTryIt form={form} tools={tools} models={chatModels} onClose={() => setTryOpen(false)} />}
{previewOpen && (
<PreviewSheet
tab={previewTab}
@@ -766,35 +803,7 @@ export default function AgentCreate() {
// eslint-disable-next-line no-unused-vars
function PreviewSheet({ tab, onTab, payload, diff, isEdit, saving, onSave, onClose }) {
const { t } = useTranslation('agents')
const sheetRef = useRef(null)
const closeRef = useRef(null)
const openerRef = useRef(null)
// The parent passes a fresh onClose on every render. Read it through a ref
// so the effect below runs once per open: re-running it would move focus to
// the opener and back to Close, pulling it out of the control in use.
const onCloseRef = useRef(onClose)
useEffect(() => { onCloseRef.current = onClose })
useEffect(() => {
openerRef.current = document.activeElement
closeRef.current?.focus()
const onKey = (e) => {
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); onCloseRef.current(); return }
if (e.key !== 'Tab' || !sheetRef.current) return
const focusable = Array.from(sheetRef.current.querySelectorAll('button:not([disabled]), [tabindex]:not([tabindex="-1"])'))
if (focusable.length === 0) return
const first = focusable[0]
const last = focusable[focusable.length - 1]
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus() }
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus() }
}
window.addEventListener('keydown', onKey, true)
return () => {
window.removeEventListener('keydown', onKey, true)
const el = openerRef.current
if (el && document.contains(el)) el.focus?.()
}
}, [])
const { sheetRef, closeRef } = useSheetFocus(onClose)
const shown = useCallback((v) => displayValue(maskSecrets(v)), [])
const json = useMemo(() => JSON.stringify(maskSecrets(payload), null, 2), [payload])
+174
View File
@@ -0,0 +1,174 @@
// The Effective view of the agent form: a plain list of what the agent can do
// right now, worked out from the form as it stands (saved or not). No React,
// so a node test drives it. The component turns each line into words with the
// `text` key and its `vars`.
//
// A line: { id, state, text, vars, section, code?, fix? }
// state allowed | asks | off | blocked | info
// section the card of the form the line explains (a key of GROUPS)
// code the reason code, when a server check or a switch gave one
// fix { kind: 'section', section } to fix it on this form, or
// { kind: 'link', to } to fix it on another page
import { MEMORY_GRANT, memorySaves, stricterLevel } from './chatAgents.js'
const TOOL_GROUPS = new Set(['builtins', 'addons', 'servers', 'connectors'])
const list = (x) => (Array.isArray(x) ? x : [])
// The order lines are shown in: what needs the person first.
const RANK = { blocked: 0, allowed: 1, asks: 2, info: 3, off: 4 }
// How many names a line spells out before "and N more".
export const NAME_LIMIT = 5
// The rows of the tool groups as one flat list, each marked as a tool or a
// grant, with the options of a row following it.
export function flatRows(groups) {
const out = []
for (const g of list(groups)) {
const kind = TOOL_GROUPS.has(g.key) ? 'tool' : 'grant'
for (const r of list(g.rows)) {
out.push({ ...r, kind, group: g.key })
for (const o of list(r.options)) out.push({ ...o, kind: 'grant', group: g.key, parent: r.id, parentLabel: r.label })
}
}
return out
}
// "A, B, C" and how many more were left out.
function names(items) {
const shown = items.slice(0, NAME_LIMIT)
return { names: shown.join(', '), more: Math.max(items.length - shown.length, 0) }
}
const unattended = (name) => name.startsWith('act.')
// The reason and the fix of a refusal the server named in an access preview.
// open_agent is this form; the rest are other pages.
export function fixOf(fix, binding) {
if (!fix) return null
if (fix.kind === 'open_agent') return { kind: 'section', section: 'capabilities' }
if (fix.kind === 'open_server_setting') return { kind: 'link', to: '/app/settings' }
return { kind: 'link', to: binding?.to || '/app/chats/connectors' }
}
// The order of the chain, so the refusal nearest the agent comes first: a
// problem on this form is shown before one on the connection or the sender.
const CHAIN = ['no_agent', 'agent_unavailable', 'server_setting_off', 'tools_switch_off', 'agent_permission_off', 'connection_permission_off', 'sender_not_listed', 'sender_needs_pairing', 'channel_not_trusted', 'role_not_granted', 'kind_not_running', 'context_rule', 'unknown_capability']
// What an access preview says about one connection: how many capabilities some
// kind of sender may use, and the refusals, nearest the agent first, one per
// capability (the refusal of the sender kind that gets furthest).
export function summariseAccess(view) {
const caps = new Map()
for (const s of list(view?.subjects)) {
for (const d of list(s.decisions)) {
const have = caps.get(d.capability)
if (d.allowed) { caps.set(d.capability, { allowed: true }); continue }
if (have?.allowed) continue
const rank = CHAIN.indexOf(d.code)
if (!have || (rank >= 0 && rank > CHAIN.indexOf(have.code))) caps.set(d.capability, { allowed: false, code: d.code, message: d.message || d.reason || '', fix: d.fix, blockedBy: d.blocked_by, capability: d.capability })
}
}
const all = [...caps.values()]
const blocked = all.filter((c) => !c.allowed).sort((a, b) => CHAIN.indexOf(a.code) - CHAIN.indexOf(b.code))
return { allowed: all.filter((c) => c.allowed).length, blocked }
}
// Builds the lines.
// form the agent form
// rows flatRows(toolGroups)
// skills the rows of the Skills list ({ on })
// memory the person's memory settings ({ enabled, save_mode }) or null
// access one entry per connection of this agent: { id, name, to, status, view }
// isEdit the agent exists on the server
export function effectiveLines({ form, rows, skills, memory, access, isEdit }) {
const lines = []
const add = (l) => lines.push(l)
// Model.
add(form.model
? { id: 'model', state: 'info', text: 'model.fixed', vars: { model: form.model }, section: 'identity' }
: { id: 'model', state: 'info', text: 'model.any', section: 'identity' })
// Tools: what is on and works, and what is on and cannot work.
const tools = rows.filter((r) => r.kind === 'tool' && r.on)
const working = tools.filter((r) => r.usable !== false)
if (working.length === 0) add({ id: 'tools', state: 'off', text: 'tools.none', section: 'capabilities' })
else add({ id: 'tools', state: 'allowed', text: 'tools.some', vars: { count: working.length, ...names(working.map((r) => r.label)) }, section: 'capabilities' })
for (const r of tools.filter((x) => x.usable === false)) {
add({ id: `tool-${r.id}`, state: 'blocked', text: 'tools.blocked', vars: { name: r.label, reason: r.reason || '' }, section: 'capabilities', code: 'tool_unusable', fix: { kind: 'section', section: 'capabilities' } })
}
// Skills.
const skillOn = list(skills).filter((s) => s.on)
if (skillOn.length) add({ id: 'skills', state: 'allowed', text: 'skills.some', vars: { count: skillOn.length, ...names(skillOn.map((s) => s.label || s.id)) }, section: 'capabilities' })
// Files.
const files = tools.find((r) => r.id === 'files')
if (files && files.usable !== false) {
const n = list(form.folders).length
add({ id: 'files', state: 'allowed', text: n ? 'files.folders' : 'files.all', vars: { count: n }, section: 'capabilities' })
}
// Grants. Memory has its own line; a grant under a switch that is off cannot
// work; one a connection does not allow yet is named with the connection.
const grants = rows.filter((r) => r.kind === 'grant' && r.on && !r.gone && r.id !== MEMORY_GRANT)
const held = []
const acts = []
for (const g of grants) {
if (g.blocked) {
add({ id: `grant-${g.id}`, state: 'blocked', text: 'grants.parentOff', vars: { name: g.label, parent: g.parentLabel || '' }, section: 'capabilities', code: 'tools_switch_off', fix: { kind: 'section', section: 'capabilities' } })
continue
}
for (const n of list(g.notes)) {
add({ id: `grant-${g.id}-${n.key}`, state: 'blocked', text: 'grants.connection', vars: { name: g.label, connection: n.text }, section: 'connections', code: 'connection_permission_off', fix: n.link?.to ? { kind: 'link', to: n.link.to } : null })
}
;(unattended(g.id) ? acts : held).push(g.label)
}
if (held.length) add({ id: 'grants', state: 'allowed', text: 'grants.some', vars: { count: held.length, ...names(held) }, section: 'capabilities' })
if (acts.length) add({ id: 'acts', state: 'allowed', text: 'grants.unattended', vars: { count: acts.length, ...names(acts) }, section: 'capabilities' })
// Memory.
const choice = form.memory || ''
const level = stricterLevel(memory?.save_mode, form.memory_save)
if (memory && memory.enabled === false) {
add({ id: 'memory-off', state: 'off', text: 'memory.userOff', section: 'memory', code: 'memory_off_user', fix: { kind: 'link', to: '/app/account?tab=memory' } })
} else if (choice === 'none') {
add({ id: 'memory', state: 'off', text: 'memory.none', section: 'memory' })
} else {
add({ id: 'memory', state: 'allowed', text: `memory.${choice || 'asBefore'}`, section: 'memory' })
const saves = memorySaves(choice) || choice === ''
const granted = list(form.builtins).includes(MEMORY_GRANT)
if (saves && !granted && choice) add({ id: 'memory-grant', state: 'blocked', text: 'memory.noGrant', section: 'memory', code: 'agent_permission_off', fix: { kind: 'section', section: 'memory' } })
else if (saves) add({ id: 'memory-level', state: level === 'auto' ? 'allowed' : level === 'never' ? 'off' : 'asks', text: `memory.level.${level}`, section: 'memory' })
}
// Knowledge.
const cols = list(form.collections)
if (cols.length) add({ id: 'knowledge', state: 'allowed', text: 'knowledge.some', vars: { count: cols.length, ...names(cols) }, section: 'memory' })
else add({ id: 'knowledge', state: 'off', text: 'knowledge.none', section: 'memory' })
// Connections.
if (!isEdit) {
add({ id: 'connections', state: 'info', text: 'connections.unsaved', section: 'connections' })
} else if (access.length === 0) {
add({ id: 'connections', state: 'info', text: 'connections.none', section: 'connections' })
}
for (const a of access) {
if (a.status === 'loading') { add({ id: `conn-${a.id}`, state: 'info', text: 'connections.loading', vars: { name: a.name }, section: 'connections' }); continue }
if (a.status === 'error') { add({ id: `conn-${a.id}`, state: 'info', text: 'connections.error', vars: { name: a.name }, section: 'connections', fix: { kind: 'link', to: a.to } }); continue }
const sum = summariseAccess(a.view)
add({ id: `conn-${a.id}`, state: sum.allowed ? 'allowed' : 'off', text: 'connections.summary', vars: { name: a.name, allowed: sum.allowed, blocked: sum.blocked.length }, section: 'connections' })
for (const b of sum.blocked.slice(0, 2)) {
add({ id: `conn-${a.id}-${b.capability}`, state: 'blocked', text: 'connections.blocked', vars: { name: a.name, capability: rows.find((r) => r.id === b.capability)?.label || b.capability, message: b.message }, section: 'connections', code: b.code, fix: fixOf(b.fix, a) })
}
}
return lines
.map((l, i) => ({ l, i }))
.sort((x, y) => RANK[x.l.state] - RANK[y.l.state] || x.i - y.i)
.map((x) => x.l)
}
// The count of lines that are blocked, for the summary and the nav dot.
export const blockedCount = (lines) => lines.filter((l) => l.state === 'blocked').length
+52
View File
@@ -0,0 +1,52 @@
// "Try it" on the agent form: a test chat that uses the settings now in the
// form, saved or not. No endpoint runs a draft agent, so the form builds a
// plain chat from what a chat can be started with: the model, the tool
// switches (add-ons, connectors, built-in groups, skills) and the agent's
// instructions as the chat's system prompt. The chat is deleted when the
// panel closes. No React, so a node test drives it.
import { draftSwitches } from './chatDraftSwitches.js'
import { connectorRef } from './chatMCP.js'
const list = (x) => (Array.isArray(x) ? x : [])
// The create request of the test chat, or null when there is no model to run.
// form the agent form
// tools the rows of useAgentTools
// models the chat models the server lists
export function tryRequest({ form, tools, models }) {
const model = (form.model || '').trim() || list(models)[0]?.id || ''
if (!model) return null
const live = (x) => !x.gone
const switches = draftSwitches({
addons: list(tools.addons).filter(live),
mcpOff: list(tools.connectors).filter((c) => live(c) && c.off).map((c) => connectorRef(c.id)),
builtins: list(tools.builtins),
skills: list(tools.skills).filter(live),
})
return {
model,
usedDefaultModel: !(form.model || '').trim(),
switches,
// Added to the chat's instructions, as the agent's own would be.
settings: { system_prompt: form.instructions || '' },
}
}
// What the test chat does not use from the form, as locale keys, so the panel
// says it. The permissions (grants), the memory choice, the knowledge
// collections and the shared folders belong to a saved agent.
export function notApplied(form) {
const out = []
if (list(form.builtins).length) out.push('grants')
if (form.memory) out.push('memory')
if (list(form.collections).length) out.push('knowledge')
if (list(form.folders).length) out.push('folders')
return out
}
// The lines of a transcript worth showing: what was said, and that a tool ran.
export function tryLines(messages) {
return list(messages)
.filter((m) => (m.role === 'user' || m.role === 'assistant') ? !!(m.text || '').trim() : m.role === 'tool')
.map((m) => ({ seq: m.seq, role: m.role, text: m.role === 'tool' ? String(m.text || '').split('\n')[0] : m.text }))
}
+3
View File
@@ -527,6 +527,9 @@ export const chatConnectorsApi = {
kinds: () => fetchJSON('/api/chats/connectors/kinds'),
enable: (id) => postJSON(`/api/chats/connectors/${enc2(id)}/enable`, {}),
disable: (id) => postJSON(`/api/chats/connectors/${enc2(id)}/disable`, {}),
// What each kind of sender may do on a connection, for the state in the body
// (agent_id, caps, allow...). Writes nothing; fields left out keep the saved value.
accessPreview: (id, body) => postJSON(`/api/chats/connectors/${enc2(id)}/access/preview`, body || {}),
}
// Triggers: work that starts by itself, as the user, in a new chat.
@@ -0,0 +1,150 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { effectiveLines, flatRows, summariseAccess, blockedCount, fixOf } from '../../src/utils/agentEffective.js'
import { tryRequest, notApplied, tryLines } from '../../src/utils/agentTryIt.js'
import { emptyAgent, newAgent } from '../../src/utils/chatAgents.js'
const groups = [
{ key: 'builtins', rows: [
{ id: 'web', label: 'Look things up online', on: true, usable: true },
{ id: 'files', label: 'Your files', on: true, usable: true },
{ id: 'shell', label: 'Run commands', on: true, usable: false, reason: 'Turned off by the server.' },
{ id: 'triggers', label: 'Triggers', on: false, usable: true, options: [{ id: 'triggers.propose', label: 'Also propose', on: true, blocked: true }] },
] },
{ key: 'messaging', rows: [{ id: 'talkback.channels', label: 'Reply on known channels', on: true, notes: [{ key: 'c1', text: 'Slack Bender does not allow this yet.', link: { to: '/app/chats/connectors' } }] }] },
{ key: 'unattended', rows: [{ id: 'act.send', label: 'Send messages on its own', on: true }] },
]
const base = (over = {}) => ({ form: { ...newAgent(), ...over }, rows: flatRows(groups), skills: [], memory: { enabled: true, save_mode: 'ask' }, access: [], isEdit: false })
const byId = (lines, id) => lines.find((l) => l.id === id)
test('effective: tools that work are listed, one that cannot is blocked with a fix on this form', () => {
const lines = effectiveLines(base())
assert.equal(byId(lines, 'tools').state, 'allowed')
assert.equal(byId(lines, 'tools').vars.count, 2)
const shell = byId(lines, 'tool-shell')
assert.equal(shell.state, 'blocked')
assert.deepEqual(shell.fix, { kind: 'section', section: 'capabilities' })
assert.equal(shell.vars.reason, 'Turned off by the server.')
})
test('effective: a right under a switch that is off is blocked, and a connection that lacks it is named', () => {
const lines = effectiveLines(base())
assert.equal(byId(lines, 'grant-triggers.propose').code, 'tools_switch_off')
const conn = byId(lines, 'grant-talkback.channels-c1')
assert.equal(conn.state, 'blocked')
assert.deepEqual(conn.fix, { kind: 'link', to: '/app/chats/connectors' })
assert.ok(blockedCount(lines) >= 3)
assert.equal(lines[0].state, 'blocked')
})
test('effective: rights that act on their own are said apart', () => {
const lines = effectiveLines(base())
assert.equal(byId(lines, 'acts').vars.names, 'Send messages on its own')
})
test('effective: no tool on says so', () => {
const lines = effectiveLines({ ...base(), rows: [] })
assert.equal(byId(lines, 'tools').state, 'off')
})
test('effective: memory is off in Settings says so with the fix, whatever the choice', () => {
const lines = effectiveLines({ ...base(), memory: { enabled: false, save_mode: 'ask' } })
const m = byId(lines, 'memory-off')
assert.equal(m.code, 'memory_off_user')
assert.equal(m.fix.to, '/app/account?tab=memory')
assert.equal(byId(lines, 'memory'), undefined)
})
test('effective: the save level is the stricter of the person and the agent', () => {
const lines = effectiveLines(base({ memory_save: 'always' }))
assert.equal(byId(lines, 'memory-level').text, 'memory.level.always')
assert.equal(effectiveLines({ ...base({ memory_save: 'never' }) }).find((l) => l.id === 'memory-level').text, 'memory.level.never')
assert.equal(byId(effectiveLines(base()), 'memory-level').text, 'memory.level.ask')
assert.equal(byId(effectiveLines({ ...base(), memory: { enabled: true, save_mode: 'auto' } }), 'memory-level').state, 'allowed')
})
test('effective: a choice that saves without the right to remember is blocked', () => {
const lines = effectiveLines(base({ builtins: [] }))
assert.equal(byId(lines, 'memory-grant').state, 'blocked')
assert.equal(effectiveLines(base({ memory: 'none' })).find((l) => l.id === 'memory').text, 'memory.none')
})
test('effective: knowledge collections and files scope', () => {
const lines = effectiveLines(base({ collections: ['handbook', 'notes'], folders: [{ path: '/A' }] }))
assert.equal(byId(lines, 'knowledge').vars.count, 2)
assert.equal(byId(lines, 'files').text, 'files.folders')
assert.equal(byId(effectiveLines(base()), 'knowledge').state, 'off')
})
test('effective: more than five names are cut and counted', () => {
const rows = flatRows([{ key: 'builtins', rows: Array.from({ length: 8 }, (_, i) => ({ id: `g${i}`, label: `G${i}`, on: true, usable: true })) }])
const t = byId(effectiveLines({ ...base(), rows }), 'tools')
assert.equal(t.vars.count, 8)
assert.equal(t.vars.more, 3)
})
const view = { subjects: [
{ id: 'anyone', decisions: [
{ capability: 'triggers.propose', allowed: false, code: 'sender_needs_pairing', message: 'Pair the sender.', fix: { kind: 'pair_sender' } },
{ capability: 'memory.write', allowed: false, code: 'agent_permission_off', message: 'The agent has no such right.', fix: { kind: 'open_agent', target: 'a1' } },
{ capability: 'files.upload', allowed: true },
] },
{ id: 'paired', decisions: [
{ capability: 'triggers.propose', allowed: true },
{ capability: 'memory.write', allowed: false, code: 'agent_permission_off', message: 'The agent has no such right.', fix: { kind: 'open_agent', target: 'a1' } },
] },
] }
test('access: a capability some sender may use is allowed; refusals come nearest the agent first', () => {
const s = summariseAccess(view)
assert.equal(s.allowed, 2)
assert.deepEqual(s.blocked.map((b) => b.capability), ['memory.write'])
assert.deepEqual(fixOf({ kind: 'open_agent' }), { kind: 'section', section: 'capabilities' })
assert.deepEqual(fixOf({ kind: 'open_server_setting' }), { kind: 'link', to: '/app/settings' })
assert.deepEqual(fixOf({ kind: 'open_connection' }, { to: '/app/x' }), { kind: 'link', to: '/app/x' })
assert.equal(fixOf(null), null)
})
test('effective: connection lines come from the preview and say plainly what is blocked', () => {
const lines = effectiveLines({ ...base(), isEdit: true, access: [{ id: 'b1', name: 'Slack Bender', to: '/app/x', status: 'ok', view }] })
assert.equal(byId(lines, 'conn-b1').vars.allowed, 2)
const blocked = byId(lines, 'conn-b1-memory.write')
assert.equal(blocked.code, 'agent_permission_off')
assert.equal(blocked.vars.message, 'The agent has no such right.')
assert.equal(byId(effectiveLines({ ...base(), isEdit: true, rows: [{ id: 'memory.write', label: 'Remember things', kind: 'grant' }], access: [{ id: 'b1', name: 'S', to: '/x', status: 'ok', view }] }), 'conn-b1-memory.write').vars.capability, 'Remember things')
assert.deepEqual(blocked.fix, { kind: 'section', section: 'capabilities' })
assert.equal(byId(effectiveLines({ ...base(), isEdit: true, access: [{ id: 'b1', name: 'S', to: '/x', status: 'error' }] }), 'conn-b1').text, 'connections.error')
assert.equal(byId(effectiveLines(base()), 'connections').text, 'connections.unsaved')
assert.equal(byId(effectiveLines({ ...base(), isEdit: true }), 'connections').text, 'connections.none')
})
test('try it: the request uses the form model, the switches and the instructions', () => {
const tools = {
addons: [{ id: 'office', off: false }, { id: 'tracker', off: true }, { id: 'old', off: false, gone: true }],
connectors: [{ id: 'm1', off: true }, { id: 'm2', off: false }],
builtins: [{ group: 'files', off: true }, { group: 'saved_agents', off: false, opt_in: true }],
skills: [],
}
const r = tryRequest({ form: { ...emptyAgent(), model: 'qwen3-8b', instructions: 'Be brief.' }, tools, models: [{ id: 'other' }] })
assert.equal(r.model, 'qwen3-8b')
assert.equal(r.usedDefaultModel, false)
assert.deepEqual(r.switches, { addons_off: ['tracker'], mcp_off: ['connector:m1'], builtins_off: ['files'], builtins_on: ['saved_agents'] })
assert.equal(r.settings.system_prompt, 'Be brief.')
})
test('try it: no model set uses the first chat model and says so; none at all gives no request', () => {
const tools = { addons: [], connectors: [], builtins: [], skills: [] }
const r = tryRequest({ form: emptyAgent(), tools, models: [{ id: 'first' }, { id: 'second' }] })
assert.equal(r.model, 'first')
assert.equal(r.usedDefaultModel, true)
assert.equal(r.switches, null)
assert.equal(tryRequest({ form: emptyAgent(), tools, models: [] }), null)
})
test('try it: names what the test does not use, and keeps only readable lines', () => {
assert.deepEqual(notApplied(emptyAgent()), [])
assert.deepEqual(notApplied(newAgent()), ['grants', 'memory'])
assert.deepEqual(notApplied({ ...newAgent(), collections: ['a'], folders: [{}] }), ['grants', 'memory', 'knowledge', 'folders'])
const lines = tryLines([{ seq: 1, role: 'user', text: 'hi' }, { seq: 2, role: 'reasoning', text: 'x' }, { seq: 3, role: 'tool', text: 'web_search\n{...}' }, { seq: 4, role: 'assistant', text: ' ' }, { seq: 5, role: 'assistant', text: 'ok' }])
assert.deepEqual(lines.map((l) => [l.role, l.text]), [['user', 'hi'], ['tool', 'web_search'], ['assistant', 'ok']])
})