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)