From 81106dd0d2d9bb1611ba4ecfc3c19c9b76874dde Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Tue, 3 Nov 2026 14:10:52 +0000 Subject: [PATCH] feat(ui): edit several folders on a saved agent The Files section of the agent editor is now a list of rows. Each row has a folder picker, a read or write switch and a remove button. An Add folder button stops at eight. A folder inside another one shows the mode of the outer folder, because that is the mode it works at. The agent chips, the Tools menu line and the Files page marks read the whole list. The strings are in all nine locales. Assisted-by: Claude:claude-sonnet-5-5 eslint playwright --- core/http/react-ui/e2e/agents-edit.spec.js | 98 ++++++++++-- core/http/react-ui/e2e/agents-fixtures.js | 45 ++++-- .../react-ui/public/locales/de/agents.json | 13 +- .../http/react-ui/public/locales/de/chat.json | 3 +- .../react-ui/public/locales/en/agents.json | 13 +- .../http/react-ui/public/locales/en/chat.json | 3 +- .../react-ui/public/locales/es/agents.json | 13 +- .../http/react-ui/public/locales/es/chat.json | 3 +- .../react-ui/public/locales/fr/agents.json | 13 +- .../http/react-ui/public/locales/fr/chat.json | 3 +- .../react-ui/public/locales/id/agents.json | 13 +- .../http/react-ui/public/locales/id/chat.json | 3 +- .../react-ui/public/locales/it/agents.json | 13 +- .../http/react-ui/public/locales/it/chat.json | 3 +- .../react-ui/public/locales/ko/agents.json | 13 +- .../http/react-ui/public/locales/ko/chat.json | 3 +- .../react-ui/public/locales/pt-BR/agents.json | 13 +- .../react-ui/public/locales/pt-BR/chat.json | 3 +- .../react-ui/public/locales/zh-CN/agents.json | 13 +- .../react-ui/public/locales/zh-CN/chat.json | 3 +- .../src/components/agents/AgentBits.jsx | 9 +- .../src/components/chat/AgentFolderChip.jsx | 10 +- core/http/react-ui/src/pages/AgentCreate.jsx | 150 +++++++++++------- core/http/react-ui/src/utils/chatAgents.js | 104 ++++++++---- .../react-ui/tests/unit/chatagents.test.mjs | 58 ++++--- 25 files changed, 455 insertions(+), 163 deletions(-) diff --git a/core/http/react-ui/e2e/agents-edit.spec.js b/core/http/react-ui/e2e/agents-edit.spec.js index 1372e2897b..01e00a9c03 100644 --- a/core/http/react-ui/e2e/agents-edit.spec.js +++ b/core/http/react-ui/e2e/agents-edit.spec.js @@ -208,7 +208,7 @@ test.describe('Agent folder', () => { await page.getByRole('button', { name: 'Save changes' }).first().click() await expect(page).toHaveURL(/\/app\/agents$/) - expect(s.calls.find(c => c.method === 'PUT').body.folder).toEqual({ path: '/Projects/Acme', mode: 'read' }) + expect(s.calls.find(c => c.method === 'PUT').body.folders).toEqual([{ path: '/Projects/Acme', mode: 'read' }]) await expect(page.locator('[data-agent="code-reviewer"]').getByTestId('agent-folder-chip')).toContainText('Acme, read only') }) @@ -221,12 +221,12 @@ test.describe('Agent folder', () => { await page.goto('/app/agents/a2/edit') await fold(page, 'Files').locator('.ag-fold__button').click() - await expect(page.getByTestId('agent-folder-mode-read')).toHaveAttribute('aria-pressed', 'true') - await page.getByTestId('agent-folder-mode-write').click() + await expect(page.getByTestId('agent-folder-mode-0-read')).toHaveAttribute('aria-pressed', 'true') + await page.getByTestId('agent-folder-mode-0-write').click() await expect(fold(page, 'Files')).toContainText('1 changed') await page.getByRole('button', { name: 'Save changes' }).first().click() await expect(page).toHaveURL(/\/app\/agents$/) - expect(s.calls.find(c => c.method === 'PUT').body.folder).toEqual({ path: '/Projects/Acme', mode: 'write' }) + expect(s.calls.find(c => c.method === 'PUT').body.folders).toEqual([{ path: '/Projects/Acme', mode: 'write' }]) const row = page.locator('[data-agent="code-reviewer"]').getByTestId('agent-folder-chip') await expect(row).toContainText('Acme, read and change') await expect(row.locator('[data-icon="folder"]')).toHaveCount(1) @@ -238,14 +238,14 @@ test.describe('Agent folder', () => { await page.getByLabel('Description').fill('Reviews and lists.') await page.getByRole('button', { name: 'Save changes' }).first().click() await expect(page).toHaveURL(/\/app\/agents$/) - expect(s.calls.find(c => c.method === 'PUT').body).not.toHaveProperty('folder') + expect(s.calls.find(c => c.method === 'PUT').body).not.toHaveProperty('folders') await page.goto('/app/agents/a2/edit') await fold(page, 'Files').locator('.ag-fold__button').click() await page.getByTestId('agent-folder-scope-all').click() await page.getByRole('button', { name: 'Save changes' }).first().click() await expect(page).toHaveURL(/\/app\/agents$/) - expect(s.calls.filter(c => c.method === 'PUT')[1].body.folder).toEqual({ path: '' }) + expect(s.calls.filter(c => c.method === 'PUT')[1].body.folders).toEqual([]) await expect(page.locator('[data-agent="code-reviewer"]').getByTestId('agent-folder-chip')).toHaveCount(0) }) @@ -267,7 +267,7 @@ test.describe('Agent folder', () => { await page.getByLabel('Description').fill('Still saves.') await page.getByRole('button', { name: 'Save changes' }).first().click() await expect(page).toHaveURL(/\/app\/agents$/) - expect(s.calls.find(c => c.method === 'PUT').body).not.toHaveProperty('folder') + expect(s.calls.find(c => c.method === 'PUT').body).not.toHaveProperty('folders') // Choosing another folder sends it with the mode the agent had. await page.goto('/app/agents/a2/edit') @@ -277,7 +277,7 @@ test.describe('Agent folder', () => { await page.getByTestId('agent-folder-use').click() await page.getByRole('button', { name: 'Save changes' }).first().click() await expect(page).toHaveURL(/\/app\/agents$/) - expect(s.calls.filter(c => c.method === 'PUT')[1].body.folder).toEqual({ path: '/Projects', mode: 'write' }) + expect(s.calls.filter(c => c.method === 'PUT')[1].body.folders).toEqual([{ path: '/Projects', mode: 'write' }]) }) test('the picker cannot choose Chats or LocalAI, and all the files can be chosen', async ({ page }) => { @@ -300,7 +300,87 @@ test.describe('Agent folder', () => { await expect(page.getByTestId('agent-folder-path')).toHaveText('Home') await page.getByRole('button', { name: 'Create Agent' }).first().click() await expect(page).toHaveURL(/\/app\/agents$/) - expect(s.calls.find(c => c.method === 'POST').body.folder).toEqual({ path: '/', mode: 'read' }) + expect(s.calls.find(c => c.method === 'POST').body.folders).toEqual([{ path: '/', mode: 'read' }]) + }) + + test('several folders: add, per-row mode, remove, and the cap of eight', async ({ page }) => { + const names = ['DirA', 'DirB', 'DirC', 'DirD', 'DirE', 'DirF', 'DirG', 'DirH'] + const files = { ...HOME_FOLDERS } + files['/'] = [...HOME_FOLDERS['/'], ...names.map(n => ({ path: `/${n}`, name: n, kind: 'dir' }))] + for (const n of names) files[`/${n}`] = [] + const s = await installAgents(page, { files }) + await page.goto('/app/agents/new') + await page.getByLabel('Name', { exact: false }).first().fill('Many') + await fold(page, 'Files').locator('.ag-fold__button').click() + await page.getByTestId('agent-folder-scope-one').click() + const pick = async (name, choose) => { + await choose.click() + const dialog = page.getByTestId('agent-folder-dialog') + await dialog.getByRole('button', { name: new RegExp(`^${name}`) }).click() + await dialog.getByTestId('agent-folder-use').click() + } + await pick('DirA', page.getByTestId('agent-folder-choose')) + await page.getByTestId('agent-folder-mode-0-write').click() + await page.getByTestId('agent-folder-add').click() + { + const d = page.getByTestId('agent-folder-dialog') + await d.getByRole('button', { name: /^DirB/ }).click() + await d.getByTestId('agent-folder-use').click() + } + await expect(page.getByTestId('agent-folder-row')).toHaveCount(2) + await expect(page.getByTestId('agent-folder-count')).toHaveText('2 of 8 folders') + // The second row keeps its own mode. + await expect(page.getByTestId('agent-folder-mode-1-read')).toHaveAttribute('aria-pressed', 'true') + await expect(page.getByTestId('agent-folder-mode-0-write')).toHaveAttribute('aria-pressed', 'true') + for (const n of ['DirC', 'DirD', 'DirE', 'DirF', 'DirG', 'DirH']) { + await page.getByTestId('agent-folder-add').click() + const d = page.getByTestId('agent-folder-dialog') + await d.getByRole('button', { name: new RegExp(`^${n}`) }).click() + await d.getByTestId('agent-folder-use').click() + } + await expect(page.getByTestId('agent-folder-row')).toHaveCount(8) + await expect(page.getByTestId('agent-folder-add')).toBeDisabled() + await page.getByTestId('agent-folder-remove').last().click() + await expect(page.getByTestId('agent-folder-add')).toBeEnabled() + await page.getByRole('button', { name: 'Create Agent' }).first().click() + await expect(page).toHaveURL(/\/app\/agents$/) + const body = s.calls.find(c => c.method === 'POST').body + expect(body.folders).toHaveLength(7) + expect(body.folders[0]).toEqual({ path: '/DirA', mode: 'write' }) + expect(body.folders[1]).toEqual({ path: '/DirB', mode: 'read' }) + await expect(page.locator('[data-agent="Many"]').getByTestId('agent-folder-chip')).toContainText('+6') + }) + + test('a row added and cancelled goes away, and a folder inside another shows the outer mode', async ({ page }) => { + await installAgents(page, { files: HOME_FOLDERS, agents: withFolder(ACME_READ) }) + await page.goto('/app/agents/a2/edit') + await fold(page, 'Files').locator('.ag-fold__button').click() + await page.getByTestId('agent-folder-add').click() + await page.getByTestId('agent-folder-dialog').getByRole('button', { name: 'Cancel' }).first().click() + await expect(page.getByTestId('agent-folder-row')).toHaveCount(1) + + // /Projects holds /Projects/Acme: the inner folder works at the outer mode. + await page.getByTestId('agent-folder-add').click() + const dialog = page.getByTestId('agent-folder-dialog') + await dialog.getByRole('button', { name: /^Projects/ }).click() + await dialog.getByTestId('agent-folder-use').click() + await page.getByTestId('agent-folder-mode-1-write').click() + await expect(page.getByTestId('agent-folder-effective')).toHaveText('Inside a folder that can be changed, so this one can be changed too.') + }) + + test('an agent with several folders reads them back, and a not-chosen row blocks the save', async ({ page }) => { + const rows = [ACME_READ, { path: '/Projects/Beta', name: 'Beta', mode: 'write', state: 'ok', effective: 'write' }] + const s = await installAgents(page, { files: HOME_FOLDERS, agents: withFolder(ACME_READ).map(a => (a.id === 'a2' ? { ...a, folders: rows } : a)) }) + await page.goto('/app/agents/a2/edit') + await expect(fold(page, 'Files')).toContainText('2 folders') + await fold(page, 'Files').locator('.ag-fold__button').click() + await expect(page.getByTestId('agent-folder-row')).toHaveCount(2) + await page.getByTestId('agent-folder-add').click() + await page.getByTestId('agent-folder-dialog').getByRole('button', { name: 'Cancel' }).first().click() + await page.getByTestId('agent-folder-remove').first().click() + await page.getByRole('button', { name: 'Save changes' }).first().click() + await expect(page).toHaveURL(/\/app\/agents$/) + expect(s.calls.find(c => c.method === 'PUT').body.folders).toEqual([{ path: '/Projects/Beta', mode: 'write' }]) }) test('a folder that cannot be read says so and tries again', async ({ page }) => { diff --git a/core/http/react-ui/e2e/agents-fixtures.js b/core/http/react-ui/e2e/agents-fixtures.js index c327c6ca63..20cab401e0 100644 --- a/core/http/react-ui/e2e/agents-fixtures.js +++ b/core/http/react-ui/e2e/agents-fixtures.js @@ -75,6 +75,25 @@ function folderOf(write, before) { return { path: write.path, ...(name ? { name } : {}), mode: write.mode || 'read', state: 'ok' } } +// The rows of an agent as the server reads them back after a write: `folders` +// when sent, else the one `folder`. The first row is also `folder`. A row +// inside another takes the outer mode as `effective`. undefined: no change. +function applyFolders(a, body) { + const { folder, folders, ...rest } = body + let rows + if (folders) rows = folders.filter(r => r.path).map(r => folderOf(r)) + else if (folder) rows = folder.path ? [folderOf(folder)] : [] + if (rows) { + const holds = (o, p) => o === '/' || p === o || p.startsWith(o + '/') + rows = rows.map((r, i) => { + const outer = rows.filter((o, j) => j !== i && o.path !== r.path && holds(o.path, r.path)).sort((x, y) => x.path.length - y.path.length)[0] + return { ...r, effective: (outer || r).mode } + }) + if (rows.length) { a.folder = rows[0]; a.folders = rows } else { delete a.folder; delete a.folders } + } + return rest +} + const BODIES = { citations: 'Cite every claim.\n\n1. Number the sources.\n2. List them at the end.\n' } export async function installAgents(page, { @@ -113,11 +132,7 @@ export async function installAgents(page, { if (!found) return err(route, 404, 'no such agent') if (req.method() === 'DELETE') { s.agents = s.agents.filter(a => a.id !== id); return route.fulfill({ status: 204, body: '' }) } if (req.method() === 'PUT') { - const { folder, ...rest } = req.postDataJSON() - Object.assign(found, rest) - const next = folderOf(folder, found.folder) - if (next) found.folder = next - else delete found.folder + Object.assign(found, applyFolders(found, req.postDataJSON())) return route.fulfill({ json: found }) } return route.fulfill({ json: found }) @@ -126,9 +141,8 @@ export async function installAgents(page, { const req = route.request() if (req.method() === 'POST') { rec(req) - const { folder, ...b } = req.postDataJSON() - const a = { id: `a${s.agents.length + 1}`, mcp_servers: [], addons: [], collections: [], skills: [], network: 'off', ...b } - if (folder?.path) a.folder = folderOf(folder) + const a = { id: `a${s.agents.length + 1}`, mcp_servers: [], addons: [], collections: [], skills: [], network: 'off' } + Object.assign(a, applyFolders(a, req.postDataJSON())) s.agents.push(a) return route.fulfill({ status: 201, json: a }) } @@ -144,11 +158,15 @@ export async function installAgents(page, { const b = req.postDataJSON() const def = b && b.kind === 'localai-agent' ? b.agent : b if (!def || typeof def !== 'object' || !def.name) return err(route, 400, 'the body is not a valid agent file') - const { folder, ...rest } = def + const { folder, folders, ...rest } = def const a = { id: `a${s.agents.length + 1}`, mcp_servers: [], addons: [], collections: [], skills: [], ...rest } - // A version 2 file names a folder by its path; one that is not found here - // leaves the agent with the mode and no folder, as the server does. - if (folder) a.folder = files?.[folder.path] ? folderOf(folder) : { mode: folder.mode, state: 'unset' } + // A version 2 or 3 file names folders by path; one that is not found here + // stays a row with the mode and no folder, as the server does. + const named = folders || (folder ? [folder] : []) + if (named.length) { + a.folders = named.map(r => (files?.[r.path] ? folderOf(r) : { mode: r.mode, state: 'unset' })) + a.folder = a.folders[0] + } s.agents.push(a) return route.fulfill({ status: 201, json: { agent: a, ...(importWarnings.length ? { warnings: importWarnings } : {}) } }) }) @@ -157,8 +175,9 @@ export async function installAgents(page, { const id = new URL(route.request().url()).pathname.split('/')[4] const found = s.agents.find(a => a.id === id) if (!found) return err(route, 404, 'no such agent') - const { name, description, instructions, model, mcp_servers, addons, collections, skills, folder } = found + const { name, description, instructions, model, mcp_servers, addons, collections, skills, folder, folders } = found const agent = { name, description, instructions, model, mcp_servers, addons, collections, skills } + if (folders?.length > 1) return route.fulfill({ json: { version: 3, kind: 'localai-agent', agent: { ...agent, folders: folders.map(r => ({ path: r.path || '', mode: r.mode })) } } }) if (folder) return route.fulfill({ json: { version: 2, kind: 'localai-agent', agent: { ...agent, folder: { path: folder.path || '', mode: folder.mode } } } }) return route.fulfill({ json: { version: 1, kind: 'localai-agent', agent } }) }) diff --git a/core/http/react-ui/public/locales/de/agents.json b/core/http/react-ui/public/locales/de/agents.json index 51d2cc2ec7..a7ef3439b8 100644 --- a/core/http/react-ui/public/locales/de/agents.json +++ b/core/http/react-ui/public/locales/de/agents.json @@ -450,7 +450,7 @@ }, "folder": { "all": "Alle meine Dateien", - "one": "Ein Ordner", + "one": "Gewählte Ordner", "scopeLabel": "Dateien, die dieser Agent erreicht", "allHelp": "Chats mit diesem Agenten erreichen alle deine Dateien, wie ein Chat ohne Agent.", "choose": "Ordner wählen", @@ -482,6 +482,15 @@ "exportNote": "Die Datei nennt den Ordner {{path}}. Wo du sie importierst, erhält der Agent den Ordner mit diesem Pfad, falls es ihn gibt, oder fragt dich nach einem.", "chip": "{{name}}, {{mode}}", "chipMissing": "Ordner fehlt", - "chipUnset": "Kein Ordner gewählt" + "chipUnset": "Kein Ordner gewählt", + "add": "Ordner hinzufügen", + "remove": "{{path}} entfernen", + "removeRow": "Entfernen", + "count": "{{n}} von {{max}} Ordnern", + "summaryMany": "{{n}} Ordner", + "effectiveRead": "Liegt in einem Ordner, der nur gelesen werden kann, daher kann dieser nur gelesen werden.", + "effectiveWrite": "Liegt in einem Ordner, der geändert werden kann, daher kann auch dieser geändert werden.", + "notMounted": "{{n}} geteilte Ordner passen nicht in das Zugriffslimit eines Chats und sind nicht eingebunden.", + "exportNoteMany": "Die Datei nennt {{n}} Ordner. Beim Import erhält der Agent die Ordner mit diesen Pfaden, die es gibt, und bittet dich, die anderen zu wählen." } } diff --git a/core/http/react-ui/public/locales/de/chat.json b/core/http/react-ui/public/locales/de/chat.json index 38137a9cdf..5eb551de46 100644 --- a/core/http/react-ui/public/locales/de/chat.json +++ b/core/http/react-ui/public/locales/de/chat.json @@ -640,7 +640,8 @@ "chipMissing": "Ordner fehlt", "missing": "Der Ordner dieses Agenten existiert nicht mehr. Wähle im Agenten einen anderen Ordner.", "startError": "Der Ordner dieses Agenten fehlt, daher kann der Chat nicht starten. Wähle einen anderen Ordner für den Agenten.", - "editAgent": "Agent bearbeiten" + "editAgent": "Agent bearbeiten", + "toolsMany": "Dateien: nur {{n}} Ordner: {{list}}. Geteilte Ordner und Skills bleiben lesbar." }, "origin": { "banner": "Diese Unterhaltung wurde von {{title}} gestartet.", diff --git a/core/http/react-ui/public/locales/en/agents.json b/core/http/react-ui/public/locales/en/agents.json index 57b83b474e..6eb7f79a9f 100644 --- a/core/http/react-ui/public/locales/en/agents.json +++ b/core/http/react-ui/public/locales/en/agents.json @@ -689,7 +689,7 @@ }, "folder": { "all": "All my files", - "one": "One folder", + "one": "Chosen folders", "scopeLabel": "Files this agent can reach", "allHelp": "Chats with this agent can reach all your files, as a chat without an agent can.", "choose": "Choose folder", @@ -721,6 +721,15 @@ "exportNote": "The file names the folder {{path}}. Where you import it, the agent gets the folder with that path, if there is one, or asks you to choose one.", "chip": "{{name}}, {{mode}}", "chipMissing": "Folder missing", - "chipUnset": "No folder chosen" + "chipUnset": "No folder chosen", + "add": "Add folder", + "remove": "Remove {{path}}", + "removeRow": "Remove", + "count": "{{n}} of {{max}} folders", + "summaryMany": "{{n}} folders", + "effectiveRead": "Inside a folder that can only be read, so this one can only be read.", + "effectiveWrite": "Inside a folder that can be changed, so this one can be changed too.", + "notMounted": "{{n}} shared folders do not fit the access limit of a chat and are not mounted.", + "exportNoteMany": "The file names {{n}} folders. Where you import it, the agent gets the folders with those paths that exist, and asks you to choose the others." } } diff --git a/core/http/react-ui/public/locales/en/chat.json b/core/http/react-ui/public/locales/en/chat.json index b84ef170b5..4d8a912b83 100644 --- a/core/http/react-ui/public/locales/en/chat.json +++ b/core/http/react-ui/public/locales/en/chat.json @@ -787,7 +787,8 @@ "chipMissing": "Folder missing", "missing": "The folder of this agent is gone. Choose another folder in the agent.", "startError": "This agent's folder is missing, so the chat cannot start. Choose another folder for the agent.", - "editAgent": "Edit agent" + "editAgent": "Edit agent", + "toolsMany": "Files: only {{n}} folders: {{list}}. Shared folders and skills stay readable." }, "origin": { "banner": "This conversation was started by {{title}}.", diff --git a/core/http/react-ui/public/locales/es/agents.json b/core/http/react-ui/public/locales/es/agents.json index ca4e6a0ecb..5e4c59a533 100644 --- a/core/http/react-ui/public/locales/es/agents.json +++ b/core/http/react-ui/public/locales/es/agents.json @@ -450,7 +450,7 @@ }, "folder": { "all": "Todos mis archivos", - "one": "Una carpeta", + "one": "Carpetas elegidas", "scopeLabel": "Archivos que este agente puede alcanzar", "allHelp": "Los chats con este agente alcanzan todos tus archivos, como un chat sin agente.", "choose": "Elegir carpeta", @@ -482,6 +482,15 @@ "exportNote": "El archivo indica la carpeta {{path}}. Donde lo importes, el agente recibe la carpeta con esa ruta, si existe, o te pide que elijas una.", "chip": "{{name}}, {{mode}}", "chipMissing": "Falta la carpeta", - "chipUnset": "Ninguna carpeta elegida" + "chipUnset": "Ninguna carpeta elegida", + "add": "Añadir carpeta", + "remove": "Quitar {{path}}", + "removeRow": "Quitar", + "count": "{{n}} de {{max}} carpetas", + "summaryMany": "{{n}} carpetas", + "effectiveRead": "Está dentro de una carpeta de solo lectura, así que esta solo se puede leer.", + "effectiveWrite": "Está dentro de una carpeta que se puede cambiar, así que esta también se puede cambiar.", + "notMounted": "{{n}} carpetas compartidas no caben en el límite de acceso de un chat y no se montan.", + "exportNoteMany": "El archivo nombra {{n}} carpetas. Donde lo importes, el agente recibe las carpetas con esas rutas que existan y te pide elegir las demás." } } diff --git a/core/http/react-ui/public/locales/es/chat.json b/core/http/react-ui/public/locales/es/chat.json index edc8e7b0eb..04128fe500 100644 --- a/core/http/react-ui/public/locales/es/chat.json +++ b/core/http/react-ui/public/locales/es/chat.json @@ -640,7 +640,8 @@ "chipMissing": "Falta la carpeta", "missing": "La carpeta de este agente ya no existe. Elige otra carpeta en el agente.", "startError": "Falta la carpeta de este agente, así que el chat no puede empezar. Elige otra carpeta para el agente.", - "editAgent": "Editar agente" + "editAgent": "Editar agente", + "toolsMany": "Archivos: solo {{n}} carpetas: {{list}}. Las carpetas compartidas y las skills siguen siendo legibles." }, "origin": { "banner": "Esta conversación la inició {{title}}.", diff --git a/core/http/react-ui/public/locales/fr/agents.json b/core/http/react-ui/public/locales/fr/agents.json index e1792b88ab..ad98a3e561 100644 --- a/core/http/react-ui/public/locales/fr/agents.json +++ b/core/http/react-ui/public/locales/fr/agents.json @@ -450,7 +450,7 @@ }, "folder": { "all": "Tous mes fichiers", - "one": "Un dossier", + "one": "Dossiers choisis", "scopeLabel": "Fichiers que cet agent peut atteindre", "allHelp": "Les chats avec cet agent atteignent tous vos fichiers, comme un chat sans agent.", "choose": "Choisir un dossier", @@ -482,6 +482,15 @@ "exportNote": "Le fichier nomme le dossier {{path}}. Là où vous l'importez, l'agent reçoit le dossier de ce chemin, s'il existe, ou vous demande d'en choisir un.", "chip": "{{name}}, {{mode}}", "chipMissing": "Dossier manquant", - "chipUnset": "Aucun dossier choisi" + "chipUnset": "Aucun dossier choisi", + "add": "Ajouter un dossier", + "remove": "Retirer {{path}}", + "removeRow": "Retirer", + "count": "{{n}} dossiers sur {{max}}", + "summaryMany": "{{n}} dossiers", + "effectiveRead": "Dans un dossier en lecture seule : celui-ci ne peut qu'être lu.", + "effectiveWrite": "Dans un dossier modifiable : celui-ci peut aussi être modifié.", + "notMounted": "{{n}} dossiers partagés ne tiennent pas dans la limite d'accès d'un chat et ne sont pas montés.", + "exportNoteMany": "Le fichier nomme {{n}} dossiers. Là où vous l'importez, l'agent reçoit les dossiers de ces chemins qui existent et vous demande de choisir les autres." } } diff --git a/core/http/react-ui/public/locales/fr/chat.json b/core/http/react-ui/public/locales/fr/chat.json index 68bc5bbc72..ddf4a01ab2 100644 --- a/core/http/react-ui/public/locales/fr/chat.json +++ b/core/http/react-ui/public/locales/fr/chat.json @@ -638,7 +638,8 @@ "chipMissing": "Dossier manquant", "missing": "Le dossier de cet agent n'existe plus. Choisissez un autre dossier dans l'agent.", "startError": "Le dossier de cet agent est manquant, le chat ne peut donc pas démarrer. Choisissez un autre dossier pour l'agent.", - "editAgent": "Modifier l'agent" + "editAgent": "Modifier l'agent", + "toolsMany": "Fichiers : seulement {{n}} dossiers : {{list}}. Les dossiers partagés et les skills restent lisibles." }, "origin": { "banner": "Cette conversation a été démarrée par {{title}}.", diff --git a/core/http/react-ui/public/locales/id/agents.json b/core/http/react-ui/public/locales/id/agents.json index ed464e2d22..b69c9d59ab 100644 --- a/core/http/react-ui/public/locales/id/agents.json +++ b/core/http/react-ui/public/locales/id/agents.json @@ -450,7 +450,7 @@ }, "folder": { "all": "Semua file saya", - "one": "Satu folder", + "one": "Folder terpilih", "scopeLabel": "File yang dapat dijangkau agen ini", "allHelp": "Chat dengan agen ini menjangkau semua file Anda, seperti chat tanpa agen.", "choose": "Pilih folder", @@ -482,6 +482,15 @@ "exportNote": "File ini menyebut folder {{path}}. Di tempat Anda mengimpornya, agen mendapat folder dengan jalur itu, jika ada, atau meminta Anda memilih satu.", "chip": "{{name}}, {{mode}}", "chipMissing": "Folder hilang", - "chipUnset": "Belum ada folder" + "chipUnset": "Belum ada folder", + "add": "Tambah folder", + "remove": "Hapus {{path}}", + "removeRow": "Hapus", + "count": "{{n}} dari {{max}} folder", + "summaryMany": "{{n}} folder", + "effectiveRead": "Ada di dalam folder yang hanya bisa dibaca, jadi folder ini hanya bisa dibaca.", + "effectiveWrite": "Ada di dalam folder yang bisa diubah, jadi folder ini juga bisa diubah.", + "notMounted": "{{n}} folder bersama tidak muat dalam batas akses sebuah chat dan tidak dipasang.", + "exportNoteMany": "File ini menyebut {{n}} folder. Di tempat Anda mengimpornya, agen mendapat folder dengan path itu yang ada, dan meminta Anda memilih sisanya." } } diff --git a/core/http/react-ui/public/locales/id/chat.json b/core/http/react-ui/public/locales/id/chat.json index 4e1803e4e7..12333637cd 100644 --- a/core/http/react-ui/public/locales/id/chat.json +++ b/core/http/react-ui/public/locales/id/chat.json @@ -640,7 +640,8 @@ "chipMissing": "Folder hilang", "missing": "Folder agen ini sudah tidak ada. Pilih folder lain di agen.", "startError": "Folder agen ini hilang, jadi chat tidak bisa dimulai. Pilih folder lain untuk agen.", - "editAgent": "Edit agen" + "editAgent": "Edit agen", + "toolsMany": "File: hanya {{n}} folder: {{list}}. Folder bersama dan skill tetap bisa dibaca." }, "origin": { "banner": "Percakapan ini dimulai oleh {{title}}.", diff --git a/core/http/react-ui/public/locales/it/agents.json b/core/http/react-ui/public/locales/it/agents.json index 281084dea5..9399a73fc8 100644 --- a/core/http/react-ui/public/locales/it/agents.json +++ b/core/http/react-ui/public/locales/it/agents.json @@ -450,7 +450,7 @@ }, "folder": { "all": "Tutti i miei file", - "one": "Una cartella", + "one": "Cartelle scelte", "scopeLabel": "File che questo agente può raggiungere", "allHelp": "Le chat con questo agente raggiungono tutti i tuoi file, come una chat senza agente.", "choose": "Scegli cartella", @@ -482,6 +482,15 @@ "exportNote": "Il file indica la cartella {{path}}. Dove lo importi, l'agente riceve la cartella con quel percorso, se esiste, oppure ti chiede di sceglierne una.", "chip": "{{name}}, {{mode}}", "chipMissing": "Cartella mancante", - "chipUnset": "Nessuna cartella scelta" + "chipUnset": "Nessuna cartella scelta", + "add": "Aggiungi cartella", + "remove": "Rimuovi {{path}}", + "removeRow": "Rimuovi", + "count": "{{n}} di {{max}} cartelle", + "summaryMany": "{{n}} cartelle", + "effectiveRead": "È dentro una cartella di sola lettura, quindi questa si può solo leggere.", + "effectiveWrite": "È dentro una cartella modificabile, quindi anche questa si può modificare.", + "notMounted": "{{n}} cartelle condivise non rientrano nel limite di accesso di una chat e non sono montate.", + "exportNoteMany": "Il file nomina {{n}} cartelle. Dove lo importi, l'agente riceve le cartelle con quei percorsi che esistono e ti chiede di scegliere le altre." } } diff --git a/core/http/react-ui/public/locales/it/chat.json b/core/http/react-ui/public/locales/it/chat.json index 86dafebc26..b4a725df28 100644 --- a/core/http/react-ui/public/locales/it/chat.json +++ b/core/http/react-ui/public/locales/it/chat.json @@ -640,7 +640,8 @@ "chipMissing": "Cartella mancante", "missing": "La cartella di questo agente non esiste più. Scegli un'altra cartella nell'agente.", "startError": "La cartella di questo agente manca, quindi la chat non può partire. Scegli un'altra cartella per l'agente.", - "editAgent": "Modifica agente" + "editAgent": "Modifica agente", + "toolsMany": "File: solo {{n}} cartelle: {{list}}. Le cartelle condivise e le skill restano leggibili." }, "origin": { "banner": "Questa conversazione è stata avviata da {{title}}.", diff --git a/core/http/react-ui/public/locales/ko/agents.json b/core/http/react-ui/public/locales/ko/agents.json index 2f534d40eb..5dfe536126 100644 --- a/core/http/react-ui/public/locales/ko/agents.json +++ b/core/http/react-ui/public/locales/ko/agents.json @@ -450,7 +450,7 @@ }, "folder": { "all": "내 모든 파일", - "one": "폴더 하나", + "one": "선택한 폴더", "scopeLabel": "이 에이전트가 접근할 수 있는 파일", "allHelp": "이 에이전트와의 채팅은 에이전트 없는 채팅처럼 모든 파일에 접근합니다.", "choose": "폴더 선택", @@ -482,6 +482,15 @@ "exportNote": "파일에 폴더 {{path}}이(가) 적혀 있습니다. 가져오는 곳에 같은 경로의 폴더가 있으면 에이전트가 그 폴더를 사용하고, 없으면 폴더를 선택하라고 요청합니다.", "chip": "{{name}}, {{mode}}", "chipMissing": "폴더 없음", - "chipUnset": "선택한 폴더 없음" + "chipUnset": "선택한 폴더 없음", + "add": "폴더 추가", + "remove": "{{path}} 제거", + "removeRow": "제거", + "count": "{{max}}개 중 {{n}}개 폴더", + "summaryMany": "폴더 {{n}}개", + "effectiveRead": "읽기 전용 폴더 안에 있어서 이 폴더도 읽기만 할 수 있습니다.", + "effectiveWrite": "변경할 수 있는 폴더 안에 있어서 이 폴더도 변경할 수 있습니다.", + "notMounted": "공유 폴더 {{n}}개가 채팅의 접근 한도에 맞지 않아 마운트되지 않았습니다.", + "exportNoteMany": "이 파일에는 폴더 {{n}}개가 적혀 있습니다. 가져오는 곳에서는 해당 경로에 실제로 있는 폴더가 에이전트에 지정되고, 나머지는 직접 고르라고 안내합니다." } } diff --git a/core/http/react-ui/public/locales/ko/chat.json b/core/http/react-ui/public/locales/ko/chat.json index 7758e0dc7d..389d17f9d9 100644 --- a/core/http/react-ui/public/locales/ko/chat.json +++ b/core/http/react-ui/public/locales/ko/chat.json @@ -640,7 +640,8 @@ "chipMissing": "폴더 없음", "missing": "이 에이전트의 폴더가 더 이상 없습니다. 에이전트에서 다른 폴더를 선택하세요.", "startError": "이 에이전트의 폴더가 없어 채팅을 시작할 수 없습니다. 에이전트에 다른 폴더를 선택하세요.", - "editAgent": "에이전트 편집" + "editAgent": "에이전트 편집", + "toolsMany": "파일: 폴더 {{n}}개만: {{list}}. 공유 폴더와 스킬은 계속 읽을 수 있습니다." }, "origin": { "banner": "이 대화는 {{title}}에서 시작되었습니다.", diff --git a/core/http/react-ui/public/locales/pt-BR/agents.json b/core/http/react-ui/public/locales/pt-BR/agents.json index 8d14bc2ec4..0c4c785aee 100644 --- a/core/http/react-ui/public/locales/pt-BR/agents.json +++ b/core/http/react-ui/public/locales/pt-BR/agents.json @@ -450,7 +450,7 @@ }, "folder": { "all": "Todos os meus arquivos", - "one": "Uma pasta", + "one": "Pastas escolhidas", "scopeLabel": "Arquivos que este agente pode alcançar", "allHelp": "Os chats com este agente alcançam todos os seus arquivos, como um chat sem agente.", "choose": "Escolher pasta", @@ -482,6 +482,15 @@ "exportNote": "O arquivo indica a pasta {{path}}. Onde você importar, o agente recebe a pasta com esse caminho, se existir, ou pede que você escolha uma.", "chip": "{{name}}, {{mode}}", "chipMissing": "Pasta ausente", - "chipUnset": "Nenhuma pasta escolhida" + "chipUnset": "Nenhuma pasta escolhida", + "add": "Adicionar pasta", + "remove": "Remover {{path}}", + "removeRow": "Remover", + "count": "{{n}} de {{max}} pastas", + "summaryMany": "{{n}} pastas", + "effectiveRead": "Está dentro de uma pasta somente leitura, então esta só pode ser lida.", + "effectiveWrite": "Está dentro de uma pasta que pode ser alterada, então esta também pode ser alterada.", + "notMounted": "{{n}} pastas compartilhadas não cabem no limite de acesso de um chat e não são montadas.", + "exportNoteMany": "O arquivo cita {{n}} pastas. Onde você importar, o agente recebe as pastas com esses caminhos que existirem e pede que você escolha as outras." } } diff --git a/core/http/react-ui/public/locales/pt-BR/chat.json b/core/http/react-ui/public/locales/pt-BR/chat.json index 1cae7146d0..3fbd2ab043 100644 --- a/core/http/react-ui/public/locales/pt-BR/chat.json +++ b/core/http/react-ui/public/locales/pt-BR/chat.json @@ -643,7 +643,8 @@ "chipMissing": "Pasta ausente", "missing": "A pasta deste agente não existe mais. Escolha outra pasta no agente.", "startError": "A pasta deste agente está ausente, então o chat não pode começar. Escolha outra pasta para o agente.", - "editAgent": "Editar agente" + "editAgent": "Editar agente", + "toolsMany": "Arquivos: apenas {{n}} pastas: {{list}}. Pastas compartilhadas e skills continuam legíveis." }, "origin": { "banner": "Esta conversa foi iniciada por {{title}}.", diff --git a/core/http/react-ui/public/locales/zh-CN/agents.json b/core/http/react-ui/public/locales/zh-CN/agents.json index 7c7ac2f0d4..a1ae21df73 100644 --- a/core/http/react-ui/public/locales/zh-CN/agents.json +++ b/core/http/react-ui/public/locales/zh-CN/agents.json @@ -450,7 +450,7 @@ }, "folder": { "all": "我的所有文件", - "one": "一个文件夹", + "one": "选定的文件夹", "scopeLabel": "此智能体可访问的文件", "allHelp": "与此智能体的对话可以访问你的所有文件,就像没有智能体的对话一样。", "choose": "选择文件夹", @@ -482,6 +482,15 @@ "exportNote": "文件中记录了文件夹 {{path}}。在导入的地方,如果存在相同路径的文件夹,智能体就会使用它,否则会请你选择一个。", "chip": "{{name}},{{mode}}", "chipMissing": "文件夹缺失", - "chipUnset": "未选择文件夹" + "chipUnset": "未选择文件夹", + "add": "添加文件夹", + "remove": "移除 {{path}}", + "removeRow": "移除", + "count": "{{n}} / {{max}} 个文件夹", + "summaryMany": "{{n}} 个文件夹", + "effectiveRead": "位于只读文件夹内,因此这个文件夹也只能读取。", + "effectiveWrite": "位于可修改的文件夹内,因此这个文件夹也可以修改。", + "notMounted": "有 {{n}} 个共享文件夹超出了聊天的访问上限,未挂载。", + "exportNoteMany": "文件列出了 {{n}} 个文件夹。导入时,智能体会使用这些路径中存在的文件夹,并请你为其余的重新选择。" } } diff --git a/core/http/react-ui/public/locales/zh-CN/chat.json b/core/http/react-ui/public/locales/zh-CN/chat.json index 11b361ae75..dae076c005 100644 --- a/core/http/react-ui/public/locales/zh-CN/chat.json +++ b/core/http/react-ui/public/locales/zh-CN/chat.json @@ -640,7 +640,8 @@ "chipMissing": "文件夹缺失", "missing": "此智能体的文件夹已不存在。请在智能体中选择其他文件夹。", "startError": "此智能体的文件夹缺失,因此无法开始对话。请为智能体选择其他文件夹。", - "editAgent": "编辑智能体" + "editAgent": "编辑智能体", + "toolsMany": "文件:仅限 {{n}} 个文件夹:{{list}}。共享文件夹和技能仍可读取。" }, "origin": { "banner": "此对话由{{title}}发起。", diff --git a/core/http/react-ui/src/components/agents/AgentBits.jsx b/core/http/react-ui/src/components/agents/AgentBits.jsx index 2dba222eac..6e07b8e005 100644 --- a/core/http/react-ui/src/components/agents/AgentBits.jsx +++ b/core/http/react-ui/src/components/agents/AgentBits.jsx @@ -2,7 +2,7 @@ import { Link, NavLink } from 'react-router-dom' import { useTranslation } from 'react-i18next' import { recordStats, stripOf, STRIP_LENGTH } from '../../utils/agentRuns' -import { folderName, folderState } from '../../utils/chatAgents' +import { folderName, folderRows, folderState } from '../../utils/chatAgents' import { useAuth } from '../../context/AuthContext' import { useHubAuth } from '../hub/useHubAuth' // eslint-disable-next-line no-unused-vars @@ -116,10 +116,11 @@ export function FolderChip({ agent, all = false, meta = false }) { ) } - const read = agent.folder.mode !== 'write' - const name = folderName(agent.folder.path) || t('folder.home') + const rows = folderRows(agent) + const read = rows.every(f => (f.effective || f.mode) !== 'write') + const name = (folderName(rows[0].path) || t('folder.home')) + (rows.length > 1 ? ` +${rows.length - 1}` : '') const mode = t(read ? 'folder.read' : 'folder.write') - const where = `${agent.folder.path === '/' ? t('folder.home') : agent.folder.path} · ${mode}` + const where = rows.map(f => `${f.path === '/' ? t('folder.home') : f.path} · ${t((f.effective || f.mode) === 'write' ? 'folder.write' : 'folder.read')}`).join('; ') if (meta) { return ( diff --git a/core/http/react-ui/src/components/chat/AgentFolderChip.jsx b/core/http/react-ui/src/components/chat/AgentFolderChip.jsx index c83f55d861..645d09d76e 100644 --- a/core/http/react-ui/src/components/chat/AgentFolderChip.jsx +++ b/core/http/react-ui/src/components/chat/AgentFolderChip.jsx @@ -2,7 +2,7 @@ import { useTranslation } from 'react-i18next' // eslint-disable-next-line no-unused-vars import { Link } from 'react-router-dom' import Icon from '../Icon' -import { folderName, folderState } from '../../utils/chatAgents' +import { folderName, folderRows, folderState } from '../../utils/chatAgents' // The folder that a chat with this agent works in, as a chip of the composer // next to the agent: its name, with a lock when the chat can only read it. A @@ -19,10 +19,12 @@ export default function AgentFolderChip({ agent }) { ) } - const read = agent.folder.mode !== 'write' - const name = folderName(agent.folder.path) || t('agentFolder.home') + const rows = folderRows(agent) + const read = rows.every(f => (f.effective || f.mode) !== 'write') + const name = (folderName(rows[0].path) || t('agentFolder.home')) + (rows.length > 1 ? ` +${rows.length - 1}` : '') + const paths = rows.map(f => (f.path === '/' ? t('agentFolder.home') : f.path)).join(', ') return ( - + {t('agentFolder.chip', { name, mode: t(read ? 'agentFolder.read' : 'agentFolder.write') })} ) diff --git a/core/http/react-ui/src/pages/AgentCreate.jsx b/core/http/react-ui/src/pages/AgentCreate.jsx index cc2dcd0d19..28bbca4525 100644 --- a/core/http/react-ui/src/pages/AgentCreate.jsx +++ b/core/http/react-ui/src/pages/AgentCreate.jsx @@ -7,7 +7,7 @@ import { agentCollectionsApi, chatAgentsApi, chatMCPConnectorsApi, chatSkillsApi import { useLibraryFacts } from '../hooks/useLibraryFacts' import { useAddonToolAddons } from '../addons/useAddonRegistry' import { AGENT_TEMPLATES, diffConfig, displayValue, maskSecrets, summarise } from '../utils/agentConfigTools' -import { addEntry, agentPayload, emptyAgent, folderState, removeEntry } from '../utils/chatAgents' +import { addEntry, agentPayload, effectiveModes, emptyAgent, folderRows, folderState, foldersUnchosen, MAX_FOLDERS, removeEntry } from '../utils/chatAgents' import { downloadAgentFile } from '../utils/agentFiles' import { joinAgentMCP, splitAgentMCP } from '../utils/chatMCP' import './agents.css' @@ -93,7 +93,8 @@ export default function AgentCreate() { const [previewOpen, setPreviewOpen] = useState(false) const [previewTab, setPreviewTab] = useState('config') const [form, setForm] = useState(emptyAgent()) - const [choosingFolder, setChoosingFolder] = useState(false) + // null, a row index, or 'new' for a row being added + const [choosingFolder, setChoosingFolder] = useState(null) // Snapshot of the form as first loaded, for the unsaved-changes guard. const initialFormRef = useRef(null) const [skills, setSkills] = useState([]) @@ -129,10 +130,10 @@ export default function AgentCreate() { let initialForm = emptyAgent() if (isEdit) { const agent = await chatAgentsApi.get(id) - initialForm = { ...initialForm, ...agent } + initialForm = { ...initialForm, ...agent, folders: folderRows(agent) } setSavedConfig(agent) } else if (imported) { - initialForm = { ...initialForm, ...imported.agent } + initialForm = { ...initialForm, ...imported.agent, folders: folderRows(imported.agent) } } else if (templateId) { // A starting point fills the form; nothing is saved until the person saves. const tpl = AGENT_TEMPLATES.find(x => x.id === templateId) @@ -156,12 +157,12 @@ export default function AgentCreate() { // The definition exactly as it is saved. The preview shows this object and // Save sends it. - // The folder as saved: a save sends `folder` only when the form changed it, - // so an untouched folder is kept, also a missing one. - const initialFolder = isEdit ? (savedConfig?.folder || null) : null - const payload = useMemo(() => agentPayload(form, { initialFolder }), [form, initialFolder]) - // "One folder" picked, and no folder chosen now or before. - const folderUnchosen = !!form.folder && !form.folder.path && !initialFolder + // The folders as saved: a save sends `folders` only when the form changed + // them, so untouched folders are kept, also a missing one. + const initialFolders = useMemo(() => (isEdit ? folderRows(savedConfig) : []), [isEdit, savedConfig]) + const payload = useMemo(() => agentPayload(form, { initialFolders }), [form, initialFolders]) + // A folder row was added or changed and has no folder chosen. + const folderUnchosen = foldersUnchosen(initialFolders, form.folders) const handleSubmit = async (e) => { e?.preventDefault?.() @@ -235,7 +236,7 @@ export default function AgentCreate() { if (s.id === 'MemorySettings') { keys = ['collections']; summary = countOf(form.collections.length, 'create.countCollections'); state = form.collections.length ? 'ready' : 'empty' } else if (s.id === 'Skills') { keys = ['skills']; summary = countOf(form.skills.length, 'create.countSkills'); state = form.skills.length ? 'ready' : 'empty' } else if (s.id === 'Addons') { keys = ['addons']; summary = countOf(form.addons.length, 'create.countAddons'); state = form.addons.length ? 'ready' : 'empty' } - else if (s.id === 'Files') { keys = ['folder']; ({ summary, state } = folderFold()) } + else if (s.id === 'Files') { keys = ['folders']; ({ summary, state } = folderFold()) } 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) @@ -249,21 +250,29 @@ export default function AgentCreate() { // What the Files fold says when closed, and whether it needs the person. function folderFold() { - const f = form.folder - if (!f) return { summary: t('folder.all'), state: 'ready' } - if (f.path) return { summary: t(f.mode === 'write' ? 'folder.summaryWrite' : 'folder.summaryRead', { path: shownPath(f.path) }), state: 'ready' } - const st = folderState({ folder: f }) - if (st === 'missing') return { summary: t('folder.summaryMissing'), state: 'needs' } - if (st === 'unset') return { summary: t('folder.summaryUnset'), state: 'needs' } - return { summary: t('folder.needsChoice'), state: 'needs' } + const rows = form.folders || [] + if (!rows.length) return { summary: t('folder.all'), state: 'ready' } + const open = rows.find(r => !r.path) + if (open) { + const st = folderState({ folder: open }) + if (st === 'missing') return { summary: t('folder.summaryMissing'), state: 'needs' } + if (st === 'unset') return { summary: t('folder.summaryUnset'), state: 'needs' } + return { summary: t('folder.needsChoice'), state: 'needs' } + } + if (rows.length > 1) return { summary: t('folder.summaryMany', { n: rows.length }), state: 'ready' } + const f = rows[0] + return { summary: t(f.mode === 'write' ? 'folder.summaryWrite' : 'folder.summaryRead', { path: shownPath(f.path) }), state: 'ready' } } function shownPath(path) { return path === '/' ? t('folder.home') : path } - // All my files, or one folder: back to the saved folder when there is one. + // All my files, or chosen folders: back to the saved rows when there are some. const setScope = (scope) => { - if (scope === 'all') updateField('folder', null) - else if (!form.folder) updateField('folder', initialFolder || { mode: 'read' }) + if (scope === 'all') updateField('folders', []) + else if (!(form.folders || []).length) updateField('folders', initialFolders.length ? initialFolders : [{ mode: 'read' }]) } + const setRow = (i, patch) => updateField('folders', form.folders.map((r, j) => (j === i ? { ...r, ...patch } : r))) + const removeRow = (i) => updateField('folders', form.folders.filter((_, j) => j !== i)) + const addRow = () => { updateField('folders', [...form.folders, { mode: 'read' }]); setChoosingFolder('new') } const toggleSection = (sid) => setOpenSections(prev => { const next = new Set(prev) @@ -354,48 +363,71 @@ export default function AgentCreate() { ) case 'Files': { - const f = form.folder - const st = f ? folderState({ folder: f }) : 'none' + const rows = form.folders || [] + const effective = effectiveModes(rows) return ( <> - {f && ( + {rows.length > 0 && (
- {f.path ? ( -
- {shownPath(f.path)} - -
- ) : st === 'missing' || st === 'unset' ? ( - setChoosingFolder(true)}>{t('folder.chooseOther')}} - > - {t(st === 'missing' ? 'folder.missing' : 'folder.unset')} - - ) : ( -
- -
- )} -
- {t('folder.modeLabel')} - updateField('folder', { ...f, mode })} - options={[{ id: 'read', label: t('folder.read') }, { id: 'write', label: t('folder.write') }]} - /> + {rows.map((f, i) => { + const st = f.path ? 'ok' : folderState({ folder: f }) + const forced = f.path && effective[i] !== (f.mode === 'write' ? 'write' : 'read') + return ( +
+ {f.path ? ( +
+ {shownPath(f.path)} + +
+ ) : st === 'missing' || st === 'unset' ? ( + + + + + )} + > + {t(st === 'missing' ? 'folder.missing' : 'folder.unset')} + + ) : ( +
+ +
+ )} +
+ {t('folder.modeLabel')} + setRow(i, { mode })} + options={[{ id: 'read', label: t('folder.read') }, { id: 'write', label: t('folder.write') }]} + /> + {forced &&

{t(effective[i] === 'write' ? 'folder.effectiveWrite' : 'folder.effectiveRead')}

} +
+
+ ) + })} +
+ + {t('folder.count', { n: rows.length, max: MAX_FOLDERS })}
+ {savedConfig?.folder_notice?.not_mounted > 0 && ( +

{t('folder.notMounted', { n: savedConfig.folder_notice.not_mounted })}

+ )}

{t('folder.help')}

{t('folder.commands')}

{isEdit &&

{t('folder.nextMessage')}

}
)} - {!f &&

{t('folder.allHelp')}

} + {!rows.length &&

{t('folder.allHelp')}

} ) } @@ -505,7 +537,7 @@ export default function AgentCreate() { {openSections.has('export') && (

{t('create.exportHelp')}

- {savedConfig.folder?.path &&

{t('folder.exportNote', { path: shownPath(savedConfig.folder.path) })}

} + {savedConfig.folder?.path &&

{folderRows(savedConfig).length > 1 ? t('folder.exportNoteMany', { n: folderRows(savedConfig).length }) : t('folder.exportNote', { path: shownPath(savedConfig.folder.path) })}

} @@ -521,11 +553,19 @@ export default function AgentCreate() {
- {choosingFolder && ( + {choosingFolder !== null && ( setChoosingFolder(false)} - onChoose={(path) => { setChoosingFolder(false); updateField('folder', { path, mode: form.folder?.mode === 'write' ? 'write' : 'read', state: 'ok' }) }} + start={(form.folders || [])[choosingFolder === 'new' ? form.folders.length - 1 : choosingFolder]?.path || '/'} + onCancel={() => { + // A row that was just added and never chosen goes away again. + if (choosingFolder === 'new') removeRow(form.folders.length - 1) + setChoosingFolder(null) + }} + onChoose={(path) => { + const at = choosingFolder === 'new' ? form.folders.length - 1 : choosingFolder + setChoosingFolder(null) + setRow(at, { path, state: 'ok' }) + }} /> )} diff --git a/core/http/react-ui/src/utils/chatAgents.js b/core/http/react-ui/src/utils/chatAgents.js index 09a838df1d..8e1b931b23 100644 --- a/core/http/react-ui/src/utils/chatAgents.js +++ b/core/http/react-ui/src/utils/chatAgents.js @@ -3,7 +3,7 @@ export const LIST_KEYS = ['mcp_servers', 'addons', 'collections', 'skills'] export function emptyAgent() { - return { id: '', name: '', description: '', instructions: '', model: '', mcp_servers: [], addons: [], collections: [], skills: [], network: 'off', folder: null } + return { id: '', name: '', description: '', instructions: '', model: '', mcp_servers: [], addons: [], collections: [], skills: [], network: 'off', folders: [] } } // Adds a name to a list: trimmed, not empty, once. @@ -19,8 +19,8 @@ export function removeEntry(list, name) { // The body of a create or replace request. The server refuses a bad one with the // reason, so this only trims and drops what cannot be meant. With -// { initialFolder } (the folder as read, or null) the body carries `folder` -// when the form changed it; without, it never does. +// { initialFolders } (the folder rows as read) the body carries `folders` +// when the form changed them; without, it never does. export function agentPayload(form, opts) { const body = { name: (form.name || '').trim(), @@ -31,9 +31,9 @@ export function agentPayload(form, opts) { network: ['off', 'registries', 'all'].includes(form.network) ? form.network : 'off', } for (const k of LIST_KEYS) body[k] = (form[k] || []).map(x => x.trim()).filter((x, i, a) => x && a.indexOf(x) === i) - if (opts && 'initialFolder' in opts) { - const folder = folderPayload(opts.initialFolder, form.folder) - if (folder) body.folder = folder + if (opts && 'initialFolders' in opts) { + const folders = foldersPayload(opts.initialFolders, form.folders) + if (folders) body.folders = folders } return body } @@ -59,11 +59,28 @@ export function grantSummary(agent) { export const FOLDER_MODES = ['read', 'write'] -// The folder state of an agent: 'none', 'ok', 'missing' or 'unset'. -export function folderState(agent) { +// An agent has up to MAX_FOLDERS folders. The server sends them as `folders` +// (the first is also `folder`). Where a folder is inside another one, it works +// at the mode of the outer one (`effective`). +export const MAX_FOLDERS = 8 + +// The folder rows of an agent as the server sent them. +export function folderRows(agent) { + if (Array.isArray(agent?.folders) && agent.folders.length) return agent.folders const f = agent?.folder - if (!f || typeof f !== 'object') return 'none' - return ['ok', 'missing', 'unset'].includes(f.state) ? f.state : 'ok' + return f && typeof f === 'object' ? [f] : [] +} + +const stateOf = (f) => (['ok', 'missing', 'unset'].includes(f?.state) ? f.state : 'ok') + +// The folder state of an agent: 'none', 'ok', 'missing' or 'unset'. With +// several folders the worst one counts: the agent does not start before all +// are fixed. +export function folderState(agent) { + const rows = folderRows(agent) + if (!rows.length) return 'none' + const states = rows.map(stateOf) + return states.includes('missing') ? 'missing' : states.includes('unset') ? 'unset' : 'ok' } // The last name of a folder path; '' for "/" (all the files). @@ -72,20 +89,39 @@ export function folderName(path) { return parts.length ? parts[parts.length - 1] : '' } -// The folder field of a create or replace request, or undefined when the -// folder did not change, so a save keeps the stored folder. `initial` is the -// folder as read from the server (or null), `current` what the form holds now -// (null = all my files). Clearing sends an empty path. -export function folderPayload(initial, current) { - const a = initial && typeof initial === 'object' ? initial : null - const b = current && typeof current === 'object' ? current : null - if (!b) return a ? { path: '' } : undefined - // Nothing chosen yet: a missing or unset folder the person did not replace - // is kept as it is. - if (!b.path) return undefined - const mode = FOLDER_MODES.includes(b.mode) ? b.mode : 'read' - if (a && a.state !== 'missing' && a.state !== 'unset' && a.path === b.path && a.mode === mode) return undefined - return { path: b.path, mode } +const holds = (outer, inner) => outer === '/' || inner === outer || inner.startsWith(outer + '/') + +// The mode each row works at, by the server's rule: a folder inside another +// folder of the list takes the mode of the outermost one; a row without a +// path keeps its own. Returns a list of 'read' | 'write' in row order. +export function effectiveModes(rows) { + const modeOf = (r) => (r?.mode === 'write' ? 'write' : 'read') + return (rows || []).map((r, i) => { + if (!r?.path) return modeOf(r) + let outer = -1 + rows.forEach((o, j) => { + if (j === i || !o?.path || o.path === r.path || !holds(o.path, r.path)) return + if (outer < 0 || o.path.length < rows[outer].path.length) outer = j + }) + return modeOf(outer >= 0 ? rows[outer] : r) + }) +} + +// The folders field of a create or replace request, or undefined when the +// rows did not change, so a save keeps the stored folders. `initial` is the +// rows as read from the server, `current` what the form holds now. No rows +// where there were some sends [] (all my files). A row without a path cannot +// be sent: the editor stops the save first (see foldersUnchosen). +export function foldersPayload(initial, current) { + const key = (rows) => JSON.stringify((rows || []).map(r => [r.path || '', r.mode === 'write' ? 'write' : 'read'])) + if (key(initial) === key(current)) return undefined + const rows = (current || []).filter(r => r.path) + return rows.map(r => ({ path: r.path, mode: FOLDER_MODES.includes(r.mode) ? r.mode : 'read' })) +} + +// Whether the rows were changed and one has no folder chosen. +export function foldersUnchosen(initial, current) { + return foldersPayload(initial, current) !== undefined && (current || []).some(r => !r.path) } // Whether a folder of a listing can be the folder of an agent: a folder of @@ -103,10 +139,13 @@ export function folderPickable(entry) { export function agentFolderMarks(agents) { const marks = new Map() for (const a of agents || []) { - if (folderState(a) !== 'ok' || !a.folder.path) continue - const list = marks.get(a.folder.path) || [] - list.push({ id: a.id, name: a.name, mode: a.folder.mode === 'write' ? 'write' : 'read' }) - marks.set(a.folder.path, list) + if (folderState(a) !== 'ok') continue + for (const f of folderRows(a)) { + if (!f.path) continue + const list = marks.get(f.path) || [] + list.push({ id: a.id, name: a.name, mode: (f.effective || f.mode) === 'write' ? 'write' : 'read' }) + marks.set(f.path, list) + } } for (const list of marks.values()) list.sort((x, y) => x.name.localeCompare(y.name)) return marks @@ -121,6 +160,11 @@ export function isFolderMissingError(err) { // The line of the Tools menu of a scoped chat: which files its tools reach. export function agentFolderLine(agent, t) { if (folderState(agent) !== 'ok') return '' - const path = agent.folder.path === '/' ? t('agentFolder.home') : agent.folder.path - return t(agent.folder.mode === 'write' ? 'agentFolder.toolsWrite' : 'agentFolder.toolsRead', { path }) + const rows = folderRows(agent) + const line = (f) => { + const path = f.path === '/' ? t('agentFolder.home') : f.path + return t((f.effective || f.mode) === 'write' ? 'agentFolder.toolsWrite' : 'agentFolder.toolsRead', { path }) + } + if (rows.length === 1) return line(rows[0]) + return t('agentFolder.toolsMany', { n: rows.length, list: rows.map(f => `${f.path === '/' ? t('agentFolder.home') : f.path} (${t((f.effective || f.mode) === 'write' ? 'agentFolder.write' : 'agentFolder.read')})`).join(', ') }) } diff --git a/core/http/react-ui/tests/unit/chatagents.test.mjs b/core/http/react-ui/tests/unit/chatagents.test.mjs index 3a06a1e2b4..6b8bf20ada 100644 --- a/core/http/react-ui/tests/unit/chatagents.test.mjs +++ b/core/http/react-ui/tests/unit/chatagents.test.mjs @@ -1,6 +1,6 @@ import test from 'node:test' import assert from 'node:assert/strict' -import { addEntry, agentFolderMarks, agentPayload, emptyAgent, folderName, folderPayload, folderPickable, folderState, grantSummary, isFolderMissingError, removeEntry } from '../../src/utils/chatAgents.js' +import { addEntry, agentFolderMarks, agentPayload, emptyAgent, effectiveModes, folderName, folderPickable, folderRows, folderState, foldersPayload, foldersUnchosen, MAX_FOLDERS, grantSummary, isFolderMissingError, removeEntry } from '../../src/utils/chatAgents.js' test('chat agents: an entry is trimmed and added once', () => { assert.deepEqual(addEntry([], ' office '), ['office']) @@ -38,31 +38,46 @@ test('chat agents: the network of unattended runs is off unless chosen, and only const ACME = { path: '/Projects/Acme', name: 'Acme', mode: 'read', state: 'ok' } -test('chat agents: folder omitted when unchanged', () => { - const form = { ...emptyAgent(), name: 'a', folder: ACME } - assert.equal('folder' in agentPayload(form, { initialFolder: ACME }), false) - assert.equal('folder' in agentPayload(form, { initialFolder: { ...ACME } }), false) - assert.equal('folder' in agentPayload({ ...emptyAgent(), name: 'a' }, { initialFolder: null }), false) - // Without the saved folder the body never names one: the dirty check and +test('chat agents: folders omitted when unchanged', () => { + const form = { ...emptyAgent(), name: 'a', folders: [ACME] } + assert.equal('folders' in agentPayload(form, { initialFolders: [ACME] }), false) + assert.equal('folders' in agentPayload(form, { initialFolders: [{ ...ACME }] }), false) + assert.equal('folders' in agentPayload({ ...emptyAgent(), name: 'a' }, { initialFolders: [] }), false) + // Without the saved rows the body never names folders: the dirty check and // the preview baseline use it. - assert.equal('folder' in agentPayload(form), false) - // A missing or unset folder the person did not replace is kept. - const gone = { mode: 'read', state: 'missing' } - assert.equal('folder' in agentPayload({ ...form, folder: gone }, { initialFolder: gone }), false) - assert.equal(folderPayload({ mode: 'write', state: 'unset' }, { mode: 'read', state: 'unset' }), undefined) + assert.equal('folders' in agentPayload(form), false) + // A missing or unset row the person did not touch is kept. + const gone = [{ mode: 'read', state: 'missing' }] + assert.equal('folders' in agentPayload({ ...form, folders: gone }, { initialFolders: gone }), false) }) -test('chat agents: empty path clears', () => { - assert.deepEqual(agentPayload({ ...emptyAgent(), name: 'a', folder: null }, { initialFolder: ACME }).folder, { path: '' }) - assert.deepEqual(folderPayload({ mode: 'read', state: 'missing' }, null), { path: '' }) +test('chat agents: removing every row clears, and a row without a path is not sent', () => { + assert.deepEqual(agentPayload({ ...emptyAgent(), name: 'a', folders: [] }, { initialFolders: [ACME] }).folders, []) + assert.deepEqual(foldersPayload([{ mode: 'read', state: 'missing' }], []), []) + assert.deepEqual(foldersPayload([ACME], [ACME, { mode: 'read' }]), [{ path: '/Projects/Acme', mode: 'read' }]) + assert.equal(foldersUnchosen([ACME], [ACME, { mode: 'read' }]), true) + assert.equal(foldersUnchosen([ACME], [ACME]), false) + assert.equal(foldersUnchosen([{ mode: 'read', state: 'unset' }], [{ mode: 'read', state: 'unset' }]), false) }) -test('chat agents: mode read maps to "read"', () => { - assert.deepEqual(folderPayload(null, { path: '/Projects/Acme', mode: 'read' }), { path: '/Projects/Acme', mode: 'read' }) - assert.deepEqual(folderPayload(ACME, { ...ACME, mode: 'write' }), { path: '/Projects/Acme', mode: 'write' }) - assert.deepEqual(folderPayload(null, { path: '/', mode: 'nonsense' }), { path: '/', mode: 'read' }) - assert.deepEqual(folderPayload({ mode: 'read', state: 'missing' }, { path: '/Other', mode: 'read' }), { path: '/Other', mode: 'read' }) - assert.deepEqual(folderPayload(ACME, { path: '/Other', mode: 'read' }), { path: '/Other', mode: 'read' }) +test('chat agents: each row keeps its own mode, and a nonsense mode is read', () => { + assert.deepEqual(foldersPayload([], [{ path: '/A', mode: 'write' }, { path: '/B', mode: 'read' }]), [{ path: '/A', mode: 'write' }, { path: '/B', mode: 'read' }]) + assert.deepEqual(foldersPayload([ACME], [{ ...ACME, mode: 'write' }]), [{ path: '/Projects/Acme', mode: 'write' }]) + assert.deepEqual(foldersPayload(null, [{ path: '/', mode: 'nonsense' }]), [{ path: '/', mode: 'read' }]) + assert.equal(MAX_FOLDERS, 8) +}) + +test('chat agents: a folder inside another takes the outer mode', () => { + assert.deepEqual(effectiveModes([{ path: '/P', mode: 'write' }, { path: '/P/A', mode: 'read' }]), ['write', 'write']) + assert.deepEqual(effectiveModes([{ path: '/P', mode: 'read' }, { path: '/P/A', mode: 'write' }]), ['read', 'read']) + assert.deepEqual(effectiveModes([{ path: '/P', mode: 'read' }, { path: '/Pq', mode: 'write' }]), ['read', 'write']) + assert.deepEqual(effectiveModes([{ path: '/', mode: 'read' }, { path: '/A', mode: 'write' }]), ['read', 'read']) +}) + +test('chat agents: rows come from folders, or from the first folder', () => { + assert.deepEqual(folderRows({ folder: ACME }), [ACME]) + assert.deepEqual(folderRows({ folder: ACME, folders: [ACME, { ...ACME, path: '/B' }] }).length, 2) + assert.deepEqual(folderRows({}), []) }) test('chat agents: the folder state, name and what the picker may choose', () => { @@ -70,6 +85,7 @@ test('chat agents: the folder state, name and what the picker may choose', () => assert.equal(folderState({ folder: ACME }), 'ok') assert.equal(folderState({ folder: { mode: 'read', state: 'missing' } }), 'missing') assert.equal(folderState({ folder: { mode: 'read', state: 'unset' } }), 'unset') + assert.equal(folderState({ folders: [ACME, { mode: 'read', state: 'missing' }] }), 'missing') assert.equal(folderName('/Projects/Acme'), 'Acme') assert.equal(folderName('/'), '') assert.equal(folderPickable({ kind: 'dir', path: '/Projects' }), true)