mirror of
https://github.com/mudler/LocalAI.git
synced 2026-11-06 04:23:15 -05:00
feat(agents): one-page agent editor with a sticky summary header
The agent form was an accordion of nine folds with two Save buttons and no summary. It is now one page: a sticky header with the agent name, mode, unsaved-change count, model, tool and memory chips and a single Save, a section nav that scrolls to Identity, Capabilities, Memory and knowledge and Advanced, and one card per section. Nothing the form saves changed, and the API is untouched. The parts keep their data-section ids, test ids and summary lines. The folds are gone, so the e2e specs no longer click them open; the "needs a name" and "changed" checks now read the card header (data-group). The nav wraps on a phone instead of scrolling sideways, and the page has no horizontal overflow from 320 px up. Assisted-by: Claude:claude-sonnet-5-5
This commit is contained in:
18 files changed
+510
-169
No files matched your search
@@ -44,7 +44,6 @@ test.describe('Memory section of the agent form', () => {
|
||||
test('a new agent starts with Mine and its own, the permission to save, and sends them', async ({ page }) => {
|
||||
const s = await serve(page)
|
||||
await page.goto('/app/agents/new')
|
||||
await fold(page, 'Notes').locator('.ag-fold__button').click()
|
||||
const choice = page.getByTestId('agent-memory-choice')
|
||||
await expect(choice.getByTestId('agent-memory-mine_and_own').locator('input')).toBeChecked()
|
||||
await expect(choice.getByTestId('agent-memory-mine_and_own')).toContainText('Recommended')
|
||||
@@ -63,7 +62,6 @@ test.describe('Memory section of the agent form', () => {
|
||||
test('an agent saved before shows As before, and picking a choice sends it with the grant', async ({ page }) => {
|
||||
const s = await serve(page)
|
||||
await page.goto('/app/agents/a2/edit')
|
||||
await fold(page, 'Notes').locator('.ag-fold__button').click()
|
||||
await expect(page.getByTestId('agent-memory-before')).toContainText('As before')
|
||||
await expect(fold(page, 'Notes')).toContainText('As before')
|
||||
await expect(page.getByTestId('agent-memory-choice').locator('input[type=radio]:checked')).toHaveCount(0)
|
||||
@@ -74,7 +72,6 @@ test.describe('Memory section of the agent form', () => {
|
||||
expect('memory' in s.calls.find(c => c.method === 'PUT').body).toBe(false)
|
||||
|
||||
await page.goto('/app/agents/a2/edit')
|
||||
await fold(page, 'Notes').locator('.ag-fold__button').click()
|
||||
await page.getByTestId('agent-memory-mine').click()
|
||||
await expect(page.getByTestId('agent-memory-mine-warn')).toContainText('You confirm each one')
|
||||
await expect(page.getByTestId('agent-memory-before')).toHaveCount(0)
|
||||
@@ -89,7 +86,6 @@ test.describe('Memory section of the agent form', () => {
|
||||
test('None leaves the permissions alone and the save switch is hidden', async ({ page }) => {
|
||||
const s = await serve(page, { agents: [{ ...AGENTS[1], memory: 'own', builtins: ['skills.write'] }] })
|
||||
await page.goto('/app/agents/a2/edit')
|
||||
await fold(page, 'Notes').locator('.ag-fold__button').click()
|
||||
await expect(page.getByTestId('agent-memory-own').locator('input')).toBeChecked()
|
||||
await page.getByTestId('agent-memory-none').click()
|
||||
await expect(page.getByTestId('agent-memory-saves')).toHaveCount(0)
|
||||
@@ -103,7 +99,6 @@ test.describe('Memory section of the agent form', () => {
|
||||
test('says that memory is off in the settings of the person', async ({ page }) => {
|
||||
await serve(page, { enabled: false })
|
||||
await page.goto('/app/agents/a2/edit')
|
||||
await fold(page, 'Notes').locator('.ag-fold__button').click()
|
||||
await expect(page.getByTestId('agent-memory-user-off')).toContainText('Memory is off in your settings')
|
||||
await shot(page, 'agent-memory-user-off', { fullPage: true })
|
||||
})
|
||||
@@ -112,7 +107,6 @@ test.describe('Memory section of the agent form', () => {
|
||||
test.describe('The save rule of the agent', () => {
|
||||
const open = async (page, path) => {
|
||||
await page.goto(path)
|
||||
await fold(page, 'Notes').locator('.ag-fold__button').click()
|
||||
}
|
||||
|
||||
test('offers three cards, never auto, says what is in force, and sends nothing until it is changed', async ({ page }) => {
|
||||
|
||||
@@ -17,18 +17,14 @@ test.describe('Agent form', () => {
|
||||
await page.getByLabel('Name', { exact: false }).first().fill('Reviewer')
|
||||
await page.getByLabel('Description').fill('Checks a change')
|
||||
|
||||
await fold(page, 'PromptsGoals').locator('.ag-fold__button').click()
|
||||
await page.getByLabel('Instructions').fill('Answer in French.')
|
||||
|
||||
await fold(page, 'MemorySettings').locator('.ag-fold__button').click()
|
||||
await page.getByRole('combobox', { name: 'Collections +' }).fill('handbook')
|
||||
await page.getByRole('combobox', { name: 'Collections +' }).press('Enter')
|
||||
await expect(page.getByTestId('agent-collections').getByRole('listitem')).toHaveText(['handbook'])
|
||||
|
||||
await fold(page, 'Skills').locator('.ag-fold__button').click()
|
||||
await page.getByTestId('agent-skills-panel').getByRole('switch', { name: /citations/ }).click()
|
||||
|
||||
await fold(page, 'Tools').locator('.ag-fold__button').click()
|
||||
await page.getByTestId('agent-tools-panel').getByRole('switch', { name: /Docs/ }).click()
|
||||
|
||||
await page.getByRole('button', { name: 'Create Agent' }).first().click()
|
||||
@@ -44,7 +40,6 @@ test.describe('Agent form', () => {
|
||||
test('warns when the first option turns off the messaging defaults, and adds the missing ones in one click', async ({ page }) => {
|
||||
const s = await installAgents(page)
|
||||
await page.goto('/app/agents/a2/edit')
|
||||
await fold(page, 'Tools').locator('.ag-fold__button').click()
|
||||
await expect(page.getByTestId('agent-grants-notice')).toHaveCount(0)
|
||||
await page.getByTestId('agent-tools-panel').getByRole('switch', { name: 'Remember things' }).click()
|
||||
const notice = page.getByTestId('agent-grants-notice')
|
||||
@@ -60,7 +55,6 @@ test.describe('Agent form', () => {
|
||||
test('shows no warning for an agent that already had permissions', async ({ page }) => {
|
||||
await installAgents(page, { agents: [{ ...AGENTS[1], builtins: ['memory.write'] }] })
|
||||
await page.goto('/app/agents/a2/edit')
|
||||
await fold(page, 'Tools').locator('.ag-fold__button').click()
|
||||
await page.getByTestId('agent-tools-panel').getByRole('switch', { name: 'Also propose from messaging apps' }).click()
|
||||
await expect(page.getByTestId('agent-grants-notice')).toHaveCount(0)
|
||||
})
|
||||
@@ -68,7 +62,6 @@ test.describe('Agent form', () => {
|
||||
test('each capability is one switch with its options under it, greyed with a reason while the switch is off', async ({ page }) => {
|
||||
const s = await installAgents(page, { agents: [{ ...AGENTS[1], builtins: ['triggers.propose', 'act.files'], builtins_off: ['triggers'] }] })
|
||||
await page.goto('/app/agents/a2/edit')
|
||||
await fold(page, 'Tools').locator('.ag-fold__button').click()
|
||||
const panel = page.getByTestId('agent-tools-panel')
|
||||
// The three trigger switches have names that cannot be mixed up.
|
||||
const triggers = panel.getByRole('switch', { name: 'Create and manage triggers' })
|
||||
@@ -98,7 +91,6 @@ test.describe('Agent form', () => {
|
||||
test('saved agents is one switch: turning it off takes the grant away too, turning it on adds only the group', async ({ page }) => {
|
||||
const s = await installAgents(page, { agents: [{ ...AGENTS[1], builtins: ['memory.write', 'saved_agents'] }, { ...AGENTS[0], id: 'a9', name: 'plain' }] })
|
||||
await page.goto('/app/agents/a2/edit')
|
||||
await fold(page, 'Tools').locator('.ag-fold__button').click()
|
||||
const sw = page.getByTestId('agent-tools-panel').getByRole('switch', { name: 'Saved agents' })
|
||||
await expect(sw).toBeChecked()
|
||||
await expect(page.getByTestId('agent-tools-panel').getByText('Call your saved agents')).toHaveCount(0)
|
||||
@@ -106,7 +98,6 @@ test.describe('Agent form', () => {
|
||||
await page.getByRole('button', { name: 'Save changes' }).first().click()
|
||||
await expect.poll(() => s.calls.find(c => c.method === 'PUT')?.body).toMatchObject({ builtins: ['memory.write'], builtins_on: [] })
|
||||
await page.goto('/app/agents/a9/edit')
|
||||
await fold(page, 'Tools').locator('.ag-fold__button').click()
|
||||
await page.getByTestId('agent-tools-panel').getByRole('switch', { name: 'Saved agents' }).click()
|
||||
await page.getByRole('button', { name: 'Save changes' }).first().click()
|
||||
await expect.poll(() => s.calls.filter(c => c.method === 'PUT').at(-1)?.body).toMatchObject({ builtins: [], builtins_on: ['saved_agents'] })
|
||||
@@ -120,7 +111,6 @@ test.describe('Agent form', () => {
|
||||
]
|
||||
await installAgents(page, { bindings, agents: [{ ...AGENTS[1], builtins: ['triggers.propose', 'memory.write'] }] })
|
||||
await page.goto('/app/agents/a2/edit')
|
||||
await fold(page, 'Tools').locator('.ag-fold__button').click()
|
||||
const panel = page.getByTestId('agent-tools-panel')
|
||||
const notes = panel.getByTestId('agent-tools-conn-note')
|
||||
await expect(notes).toHaveCount(3)
|
||||
@@ -137,7 +127,6 @@ test.describe('Agent form', () => {
|
||||
const s = await installAgents(page)
|
||||
await page.goto('/app/agents/new')
|
||||
await page.getByLabel('Name', { exact: false }).first().fill('Builder')
|
||||
await fold(page, 'Tools').locator('.ag-fold__button').click()
|
||||
await expect(page.getByTestId('agent-network')).toHaveValue('off')
|
||||
await page.getByTestId('agent-network').selectOption('registries')
|
||||
await page.getByRole('button', { name: 'Create Agent' }).first().click()
|
||||
@@ -148,7 +137,7 @@ test.describe('Agent form', () => {
|
||||
test('needs a name, and says so on the fold', async ({ page }) => {
|
||||
const s = await installAgents(page)
|
||||
await page.goto('/app/agents/new')
|
||||
await expect(fold(page, 'BasicInfo')).toContainText('Needs a name')
|
||||
await expect(page.locator('[data-group="identity"]')).toContainText('Needs a name')
|
||||
await page.getByRole('button', { name: 'Create Agent' }).first().click()
|
||||
expect(s.calls.some(c => c.method === 'POST')).toBe(false)
|
||||
})
|
||||
@@ -166,7 +155,7 @@ test.describe('Agent form', () => {
|
||||
await page.goto('/app/agents/a1/edit')
|
||||
await expect(page.getByLabel('Name', { exact: false }).first()).toHaveValue('research-assistant')
|
||||
await page.getByLabel('Description').fill('Searches and cites.')
|
||||
await expect(fold(page, 'BasicInfo')).toContainText('1 changed')
|
||||
await expect(page.locator('[data-group="identity"]')).toContainText('1 changed')
|
||||
|
||||
await page.getByTestId('agent-preview-open').click()
|
||||
const sheet = page.getByTestId('agent-preview')
|
||||
@@ -185,11 +174,9 @@ test.describe('Agent form', () => {
|
||||
test('the tools list is the list of the chat: connectors by name, one that was deleted stays, and the servers are free text', async ({ page }) => {
|
||||
const s = await installAgents(page)
|
||||
await page.goto('/app/agents/a1/edit')
|
||||
await fold(page, 'Tools').locator('.ag-fold__button').click()
|
||||
const picker = page.getByTestId('agent-tools-panel')
|
||||
await expect(picker.getByRole('switch', { name: /Docs/ })).toBeChecked()
|
||||
await expect(picker.getByRole('switch', { name: /Parked/ })).not.toBeChecked()
|
||||
await fold(page, 'MCP').locator('.ag-fold__button').click()
|
||||
await expect(page.getByTestId('agent-mcp').getByRole('listitem')).toHaveText(['crm'])
|
||||
await expect(page.locator('datalist option[value="mail"]')).toHaveCount(1)
|
||||
await expect(picker.getByTestId('agent-tools-item').filter({ hasText: 'Parked' })).toContainText('off')
|
||||
@@ -203,7 +190,6 @@ test.describe('Agent form', () => {
|
||||
test('every switch of the tools list says what it does, and the switches are saved with the agent', async ({ page }) => {
|
||||
const s = await installAgents(page)
|
||||
await page.goto('/app/agents/a1/edit')
|
||||
await fold(page, 'Tools').locator('.ag-fold__button').click()
|
||||
const panel = page.getByTestId('agent-tools-panel')
|
||||
// The same words as the Tools menu of a chat: a title, a description, and
|
||||
// the add-on's declared title instead of its id. Eight rows are the built-in
|
||||
@@ -290,7 +276,6 @@ test.describe('Agent form', () => {
|
||||
test('the Export fold downloads the definition of a saved agent', async ({ page }) => {
|
||||
await installAgents(page)
|
||||
await page.goto('/app/agents/a1/edit')
|
||||
await fold(page, 'export').locator('.ag-fold__button').click()
|
||||
const download = page.waitForEvent('download')
|
||||
await page.getByRole('button', { name: 'Export agent' }).click()
|
||||
expect((await download).suggestedFilename()).toBe('research-assistant.json')
|
||||
@@ -307,7 +292,6 @@ test.describe('Agent folder', () => {
|
||||
const s = await installAgents(page, { files: HOME_FOLDERS })
|
||||
await page.goto('/app/agents/a2/edit')
|
||||
await expect(fold(page, 'Files')).toContainText('All my files')
|
||||
await fold(page, 'Files').locator('.ag-fold__button').click()
|
||||
await page.getByTestId('agent-folder-scope-one').click()
|
||||
// Nothing chosen yet: the fold needs the person, and Save says so.
|
||||
await expect(fold(page, 'Files')).toContainText('Choose a folder, or pick All my files.')
|
||||
@@ -339,7 +323,6 @@ test.describe('Agent folder', () => {
|
||||
await expect(chip.locator('[data-icon="lock"]')).toHaveCount(1)
|
||||
|
||||
await page.goto('/app/agents/a2/edit')
|
||||
await fold(page, 'Files').locator('.ag-fold__button').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')
|
||||
@@ -360,7 +343,6 @@ test.describe('Agent 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$/)
|
||||
@@ -381,7 +363,6 @@ test.describe('Agent folder', () => {
|
||||
|
||||
// The fold says so; the save of another field keeps working and keeps it.
|
||||
await expect(fold(page, 'Files')).toContainText('Folder missing')
|
||||
await fold(page, 'Files').locator('.ag-fold__button').click()
|
||||
await expect(page.getByTestId('agent-folder-missing')).toContainText('This folder is gone. Choose another folder to use this agent.')
|
||||
await page.getByLabel('Description').fill('Still saves.')
|
||||
await page.getByRole('button', { name: 'Save changes' }).first().click()
|
||||
@@ -390,7 +371,6 @@ test.describe('Agent folder', () => {
|
||||
|
||||
// Choosing another folder sends it with the mode the agent had.
|
||||
await page.goto('/app/agents/a2/edit')
|
||||
await fold(page, 'Files').locator('.ag-fold__button').click()
|
||||
await page.getByTestId('agent-folder-missing').getByRole('button', { name: 'Choose another folder' }).click()
|
||||
await page.getByTestId('agent-folder-dialog').getByRole('button', { name: 'Projects' }).click()
|
||||
await page.getByTestId('agent-folder-use').click()
|
||||
@@ -403,7 +383,6 @@ test.describe('Agent folder', () => {
|
||||
const s = await installAgents(page, { files: HOME_FOLDERS })
|
||||
await page.goto('/app/agents/new')
|
||||
await page.getByLabel('Name', { exact: false }).first().fill('Reader')
|
||||
await fold(page, 'Files').locator('.ag-fold__button').click()
|
||||
await page.getByTestId('agent-folder-scope-one').click()
|
||||
await page.getByTestId('agent-folder-choose').click()
|
||||
const dialog = page.getByTestId('agent-folder-dialog')
|
||||
@@ -430,7 +409,6 @@ test.describe('Agent folder', () => {
|
||||
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()
|
||||
@@ -473,7 +451,6 @@ test.describe('Agent folder', () => {
|
||||
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)
|
||||
@@ -492,7 +469,6 @@ test.describe('Agent folder', () => {
|
||||
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()
|
||||
@@ -510,7 +486,6 @@ test.describe('Agent folder', () => {
|
||||
return route.fallback()
|
||||
})
|
||||
await page.goto('/app/agents/new')
|
||||
await fold(page, 'Files').locator('.ag-fold__button').click()
|
||||
await page.getByTestId('agent-folder-scope-one').click()
|
||||
await page.getByTestId('agent-folder-choose').click()
|
||||
const dialog = page.getByTestId('agent-folder-dialog')
|
||||
@@ -524,7 +499,6 @@ test.describe('Agent folder', () => {
|
||||
test('the Export fold says that the file names the folder', async ({ page }) => {
|
||||
const s = await installAgents(page, { files: HOME_FOLDERS, agents: withFolder(ACME_READ) })
|
||||
await page.goto('/app/agents/a2/edit')
|
||||
await fold(page, 'export').locator('.ag-fold__button').click()
|
||||
await expect(page.getByTestId('agent-export-folder')).toContainText('The file names the folder /Projects/Acme.')
|
||||
const download = page.waitForEvent('download')
|
||||
await page.getByRole('button', { name: 'Export agent' }).click()
|
||||
@@ -536,7 +510,6 @@ test.describe('Agent folder', () => {
|
||||
test('no copy of the folder fold names an internal path', 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').getByRole('button', { name: 'Change', exact: true }).click()
|
||||
await expect(page.getByTestId('folder-picker')).toBeVisible()
|
||||
const text = await page.locator('body').innerText()
|
||||
@@ -554,7 +527,6 @@ const aliasModels = [
|
||||
{ id: 'chained', capabilities: [] },
|
||||
]
|
||||
const openModel = async page => {
|
||||
await fold(page, 'ModelSettings').locator('.ag-fold__button').click()
|
||||
return page.getByTestId('home-model-chip')
|
||||
}
|
||||
|
||||
|
||||
@@ -140,7 +140,6 @@ test.describe('The connections an agent names', () => {
|
||||
}))
|
||||
await page.goto('/app/agents/new')
|
||||
await page.getByLabel('Name', { exact: false }).first().fill('Reviewer')
|
||||
await page.locator('[data-section="Tools"] .ag-fold__button').click()
|
||||
const panel = page.getByTestId('agent-tools-panel')
|
||||
const group = panel.getByRole('group', { name: 'Connections for chats of this agent' })
|
||||
await expect(group).toBeVisible()
|
||||
|
||||
@@ -55,7 +55,6 @@ test.describe('Skills, agents, triggers and connectors against the real server',
|
||||
await page.goto('/app/agents/new')
|
||||
await page.getByLabel('Name', { exact: false }).first().fill(name)
|
||||
await page.getByLabel('Description').fill('Reviews text')
|
||||
await page.locator('[data-section="PromptsGoals"] .ag-fold__button').click()
|
||||
await page.getByLabel('Instructions').fill('Be brief.')
|
||||
await page.getByRole('button', { name: 'Create Agent' }).first().click()
|
||||
await expect(page).toHaveURL(/\/app\/agents$/)
|
||||
|
||||
@@ -263,7 +263,6 @@ test.describe('Agent form: the memory switch of the person', () => {
|
||||
await installAgents(page)
|
||||
await memory(page, false)
|
||||
await page.goto('/app/agents/new')
|
||||
await page.locator('[data-section="Tools"] .ag-fold__button').click()
|
||||
const line = page.getByTestId('agent-memory-off')
|
||||
await expect(line).toContainText('Memory is off in your settings')
|
||||
await expect(line.getByRole('link', { name: 'Settings, Memory' })).toHaveAttribute('href', /\/app\/account\?tab=memory/)
|
||||
@@ -274,7 +273,6 @@ test.describe('Agent form: the memory switch of the person', () => {
|
||||
await installAgents(page)
|
||||
await memory(page, true)
|
||||
await page.goto('/app/agents/new')
|
||||
await page.locator('[data-section="Tools"] .ag-fold__button').click()
|
||||
await expect(page.getByTestId('agent-tools-panel')).toBeVisible()
|
||||
await expect(page.getByTestId('agent-memory-off')).toHaveCount(0)
|
||||
})
|
||||
|
||||
@@ -97,7 +97,7 @@ async function chatSetup(page) {
|
||||
}
|
||||
|
||||
const PAGES = [
|
||||
{ name: 'agent editor', url: '/app/agents/a1/edit', ready: '[data-testid=agent-edit]', setup: agentsSetup, open: '.ag-fold__button' },
|
||||
{ name: 'agent editor', url: '/app/agents/a1/edit', ready: '[data-testid=agent-edit]', setup: agentsSetup },
|
||||
{ name: 'new agent', url: '/app/agents/new', ready: '[data-testid=agent-edit]', setup: agentsSetup },
|
||||
{ name: 'agents list', url: '/app/agents', ready: '.ag-home, .page', setup: agentsSetup },
|
||||
{ name: 'agent page', url: '/app/agents/a1', ready: '.page', setup: agentsSetup },
|
||||
@@ -211,7 +211,6 @@ for (const width of [1280, 1100, 390]) {
|
||||
await page.route(/\/api\/chats\/connectors\/kinds$/, r => r.fulfill({ json: { kinds: [{ kind: 'slack', addon: 'slack', title: 'Slack' }, { kind: 'telegram', addon: 'telegram', title: 'Telegram' }], capabilities: [] } }))
|
||||
await page.goto(`/app/agents/${aid}/edit`)
|
||||
await page.locator('[data-testid=agent-edit]').waitFor()
|
||||
for (const b of await page.locator('.ag-fold__button').all()) await b.click()
|
||||
await page.waitForTimeout(300)
|
||||
const r = await overflow(page)
|
||||
expect(r.bad, `elements past the right edge (document ${r.scrollWidth} in ${r.clientWidth})`).toEqual([])
|
||||
|
||||
@@ -73,7 +73,6 @@ test.describe('Add-on page render smoke', () => {
|
||||
test.describe('Folder picker render smoke', () => {
|
||||
test('renders the folder picker of the agent editor', async ({ page }) => {
|
||||
await page.goto('/app/agents/new')
|
||||
await page.locator('[data-section="Files"] .ag-fold__button').click()
|
||||
await page.getByTestId('agent-folder-scope-one').click()
|
||||
await page.getByTestId('agent-folder-choose').click()
|
||||
const picker = page.getByTestId('folder-picker')
|
||||
|
||||
@@ -151,7 +151,31 @@
|
||||
"files_upload": "Dateien aus Messaging-Apps empfangen"
|
||||
},
|
||||
"memoryOffUser": "Das Gedächtnis ist in deinen Einstellungen aus. Dieser Agent liest und speichert deshalb keine Notizen, egal was du hier einstellst.",
|
||||
"memoryOffFix": "Einstellungen, Gedächtnis"
|
||||
"memoryOffFix": "Einstellungen, Gedächtnis",
|
||||
"nav": {
|
||||
"label": "Abschnitte",
|
||||
"identity": "Identität",
|
||||
"capabilities": "Fähigkeiten",
|
||||
"memory": "Gedächtnis und Wissen",
|
||||
"advanced": "Erweitert"
|
||||
},
|
||||
"lead": {
|
||||
"identity": "Wer der Agent ist und wie er sich verhält.",
|
||||
"capabilities": "Was der Agent nutzen und erreichen darf.",
|
||||
"memory": "Woran sich der Agent erinnert und was er nachschlagen kann.",
|
||||
"advanced": "Zusätzliche Server und Export."
|
||||
},
|
||||
"header": {
|
||||
"unnamed": "Unbenannter Agent",
|
||||
"modeEdit": "Agent bearbeiten",
|
||||
"modeCreate": "Neuer Agent",
|
||||
"summary": "Übersicht dieses Agenten"
|
||||
},
|
||||
"status": {
|
||||
"clean": "Keine ungespeicherten Änderungen",
|
||||
"new": "Noch nicht erstellt",
|
||||
"unsaved": "Ungespeicherte Änderungen ({{count}})"
|
||||
}
|
||||
},
|
||||
"run": {
|
||||
"workingLine": "Working"
|
||||
|
||||
@@ -254,7 +254,31 @@
|
||||
"files_upload": "Receive files from messaging apps"
|
||||
},
|
||||
"memoryOffUser": "Memory is off in your settings, so this agent reads and saves no notes, whatever you set here.",
|
||||
"memoryOffFix": "Settings, Memory"
|
||||
"memoryOffFix": "Settings, Memory",
|
||||
"nav": {
|
||||
"label": "Sections",
|
||||
"identity": "Identity",
|
||||
"capabilities": "Capabilities",
|
||||
"memory": "Memory and knowledge",
|
||||
"advanced": "Advanced"
|
||||
},
|
||||
"lead": {
|
||||
"identity": "Who the agent is and how it behaves.",
|
||||
"capabilities": "What the agent may use and reach.",
|
||||
"memory": "What the agent remembers and what it can look up.",
|
||||
"advanced": "Extra servers and export."
|
||||
},
|
||||
"header": {
|
||||
"unnamed": "Unnamed agent",
|
||||
"modeEdit": "Edit agent",
|
||||
"modeCreate": "New agent",
|
||||
"summary": "Summary of this agent"
|
||||
},
|
||||
"status": {
|
||||
"clean": "No unsaved changes",
|
||||
"new": "Not created yet",
|
||||
"unsaved": "Unsaved changes ({{count}})"
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"title": "Status",
|
||||
|
||||
@@ -151,7 +151,31 @@
|
||||
"files_upload": "Recibir archivos de aplicaciones de mensajería"
|
||||
},
|
||||
"memoryOffUser": "La memoria está desactivada en tus ajustes, así que este agente no lee ni guarda notas, hagas lo que hagas aquí.",
|
||||
"memoryOffFix": "Ajustes, Memoria"
|
||||
"memoryOffFix": "Ajustes, Memoria",
|
||||
"nav": {
|
||||
"label": "Secciones",
|
||||
"identity": "Identidad",
|
||||
"capabilities": "Capacidades",
|
||||
"memory": "Memoria y conocimiento",
|
||||
"advanced": "Avanzado"
|
||||
},
|
||||
"lead": {
|
||||
"identity": "Quién es el agente y cómo se comporta.",
|
||||
"capabilities": "Lo que el agente puede usar y alcanzar.",
|
||||
"memory": "Lo que el agente recuerda y lo que puede consultar.",
|
||||
"advanced": "Servidores adicionales y exportación."
|
||||
},
|
||||
"header": {
|
||||
"unnamed": "Agente sin nombre",
|
||||
"modeEdit": "Editar agente",
|
||||
"modeCreate": "Agente nuevo",
|
||||
"summary": "Resumen de este agente"
|
||||
},
|
||||
"status": {
|
||||
"clean": "Sin cambios sin guardar",
|
||||
"new": "Aún no creado",
|
||||
"unsaved": "Cambios sin guardar ({{count}})"
|
||||
}
|
||||
},
|
||||
"run": {
|
||||
"workingLine": "Working"
|
||||
|
||||
@@ -151,7 +151,31 @@
|
||||
"files_upload": "Recevoir des fichiers des applications de messagerie"
|
||||
},
|
||||
"memoryOffUser": "La mémoire est désactivée dans vos réglages : cet agent ne lit ni n'enregistre de notes, quoi que vous choisissiez ici.",
|
||||
"memoryOffFix": "Réglages, Mémoire"
|
||||
"memoryOffFix": "Réglages, Mémoire",
|
||||
"nav": {
|
||||
"label": "Sections",
|
||||
"identity": "Identité",
|
||||
"capabilities": "Capacités",
|
||||
"memory": "Mémoire et connaissances",
|
||||
"advanced": "Avancé"
|
||||
},
|
||||
"lead": {
|
||||
"identity": "Qui est l'agent et comment il se comporte.",
|
||||
"capabilities": "Ce que l'agent peut utiliser et atteindre.",
|
||||
"memory": "Ce dont l'agent se souvient et ce qu'il peut consulter.",
|
||||
"advanced": "Serveurs supplémentaires et export."
|
||||
},
|
||||
"header": {
|
||||
"unnamed": "Agent sans nom",
|
||||
"modeEdit": "Modifier l'agent",
|
||||
"modeCreate": "Nouvel agent",
|
||||
"summary": "Résumé de cet agent"
|
||||
},
|
||||
"status": {
|
||||
"clean": "Aucune modification non enregistrée",
|
||||
"new": "Pas encore créé",
|
||||
"unsaved": "Modifications non enregistrées ({{count}})"
|
||||
}
|
||||
},
|
||||
"run": {
|
||||
"workingLine": "Working"
|
||||
|
||||
@@ -151,7 +151,31 @@
|
||||
"files_upload": "Menerima file dari aplikasi pesan"
|
||||
},
|
||||
"memoryOffUser": "Memori nonaktif di pengaturan Anda, jadi agen ini tidak membaca atau menyimpan catatan, apa pun yang Anda atur di sini.",
|
||||
"memoryOffFix": "Pengaturan, Memori"
|
||||
"memoryOffFix": "Pengaturan, Memori",
|
||||
"nav": {
|
||||
"label": "Bagian",
|
||||
"identity": "Identitas",
|
||||
"capabilities": "Kemampuan",
|
||||
"memory": "Memori dan pengetahuan",
|
||||
"advanced": "Lanjutan"
|
||||
},
|
||||
"lead": {
|
||||
"identity": "Siapa agen ini dan bagaimana ia bersikap.",
|
||||
"capabilities": "Apa yang boleh digunakan dan dijangkau agen.",
|
||||
"memory": "Apa yang diingat agen dan apa yang bisa dicarinya.",
|
||||
"advanced": "Server tambahan dan ekspor."
|
||||
},
|
||||
"header": {
|
||||
"unnamed": "Agen tanpa nama",
|
||||
"modeEdit": "Ubah agen",
|
||||
"modeCreate": "Agen baru",
|
||||
"summary": "Ringkasan agen ini"
|
||||
},
|
||||
"status": {
|
||||
"clean": "Tidak ada perubahan yang belum disimpan",
|
||||
"new": "Belum dibuat",
|
||||
"unsaved": "Perubahan belum disimpan ({{count}})"
|
||||
}
|
||||
},
|
||||
"run": {
|
||||
"workingLine": "Working"
|
||||
|
||||
@@ -151,7 +151,31 @@
|
||||
"files_upload": "Ricevere file dalle app di messaggistica"
|
||||
},
|
||||
"memoryOffUser": "La memoria è disattivata nelle tue impostazioni, quindi questo agente non legge né salva note, qualunque cosa imposti qui.",
|
||||
"memoryOffFix": "Impostazioni, Memoria"
|
||||
"memoryOffFix": "Impostazioni, Memoria",
|
||||
"nav": {
|
||||
"label": "Sezioni",
|
||||
"identity": "Identità",
|
||||
"capabilities": "Capacità",
|
||||
"memory": "Memoria e conoscenza",
|
||||
"advanced": "Avanzate"
|
||||
},
|
||||
"lead": {
|
||||
"identity": "Chi è l'agente e come si comporta.",
|
||||
"capabilities": "Cosa può usare e raggiungere l'agente.",
|
||||
"memory": "Cosa ricorda l'agente e cosa può consultare.",
|
||||
"advanced": "Server aggiuntivi ed esportazione."
|
||||
},
|
||||
"header": {
|
||||
"unnamed": "Agente senza nome",
|
||||
"modeEdit": "Modifica agente",
|
||||
"modeCreate": "Nuovo agente",
|
||||
"summary": "Riepilogo di questo agente"
|
||||
},
|
||||
"status": {
|
||||
"clean": "Nessuna modifica non salvata",
|
||||
"new": "Non ancora creato",
|
||||
"unsaved": "Modifiche non salvate ({{count}})"
|
||||
}
|
||||
},
|
||||
"run": {
|
||||
"workingLine": "Working"
|
||||
|
||||
@@ -151,7 +151,31 @@
|
||||
"files_upload": "메신저 앱에서 파일 받기"
|
||||
},
|
||||
"memoryOffUser": "설정에서 메모리가 꺼져 있어 여기서 어떻게 정하든 이 에이전트는 메모를 읽거나 저장하지 않습니다.",
|
||||
"memoryOffFix": "설정, 메모리"
|
||||
"memoryOffFix": "설정, 메모리",
|
||||
"nav": {
|
||||
"label": "섹션",
|
||||
"identity": "정체성",
|
||||
"capabilities": "기능",
|
||||
"memory": "메모리와 지식",
|
||||
"advanced": "고급"
|
||||
},
|
||||
"lead": {
|
||||
"identity": "에이전트가 누구이고 어떻게 행동하는지 정합니다.",
|
||||
"capabilities": "에이전트가 사용하고 접근할 수 있는 것을 정합니다.",
|
||||
"memory": "에이전트가 기억하는 것과 찾아볼 수 있는 것을 정합니다.",
|
||||
"advanced": "추가 서버와 내보내기."
|
||||
},
|
||||
"header": {
|
||||
"unnamed": "이름 없는 에이전트",
|
||||
"modeEdit": "에이전트 편집",
|
||||
"modeCreate": "새 에이전트",
|
||||
"summary": "이 에이전트 요약"
|
||||
},
|
||||
"status": {
|
||||
"clean": "저장하지 않은 변경 사항 없음",
|
||||
"new": "아직 만들지 않음",
|
||||
"unsaved": "저장하지 않은 변경 사항 ({{count}})"
|
||||
}
|
||||
},
|
||||
"run": {
|
||||
"workingLine": "Working"
|
||||
|
||||
@@ -151,7 +151,31 @@
|
||||
"files_upload": "Receber arquivos de aplicativos de mensagens"
|
||||
},
|
||||
"memoryOffUser": "A memória está desativada nas suas configurações, então este agente não lê nem salva notas, não importa o que você defina aqui.",
|
||||
"memoryOffFix": "Configurações, Memória"
|
||||
"memoryOffFix": "Configurações, Memória",
|
||||
"nav": {
|
||||
"label": "Seções",
|
||||
"identity": "Identidade",
|
||||
"capabilities": "Capacidades",
|
||||
"memory": "Memória e conhecimento",
|
||||
"advanced": "Avançado"
|
||||
},
|
||||
"lead": {
|
||||
"identity": "Quem é o agente e como ele se comporta.",
|
||||
"capabilities": "O que o agente pode usar e alcançar.",
|
||||
"memory": "O que o agente lembra e o que ele pode consultar.",
|
||||
"advanced": "Servidores extras e exportação."
|
||||
},
|
||||
"header": {
|
||||
"unnamed": "Agente sem nome",
|
||||
"modeEdit": "Editar agente",
|
||||
"modeCreate": "Novo agente",
|
||||
"summary": "Resumo deste agente"
|
||||
},
|
||||
"status": {
|
||||
"clean": "Nenhuma alteração não salva",
|
||||
"new": "Ainda não criado",
|
||||
"unsaved": "Alterações não salvas ({{count}})"
|
||||
}
|
||||
},
|
||||
"run": {
|
||||
"workingLine": "Working"
|
||||
|
||||
@@ -151,7 +151,31 @@
|
||||
"files_upload": "从消息应用接收文件"
|
||||
},
|
||||
"memoryOffUser": "你的设置中记忆已关闭,因此无论这里如何设置,此智能体都不会读取或保存笔记。",
|
||||
"memoryOffFix": "设置,记忆"
|
||||
"memoryOffFix": "设置,记忆",
|
||||
"nav": {
|
||||
"label": "分区",
|
||||
"identity": "身份",
|
||||
"capabilities": "能力",
|
||||
"memory": "记忆与知识",
|
||||
"advanced": "高级"
|
||||
},
|
||||
"lead": {
|
||||
"identity": "智能体是谁,以及它如何行事。",
|
||||
"capabilities": "智能体可以使用和访问的内容。",
|
||||
"memory": "智能体记住什么,以及可以查找什么。",
|
||||
"advanced": "额外的服务器和导出。"
|
||||
},
|
||||
"header": {
|
||||
"unnamed": "未命名的智能体",
|
||||
"modeEdit": "编辑智能体",
|
||||
"modeCreate": "新智能体",
|
||||
"summary": "此智能体的摘要"
|
||||
},
|
||||
"status": {
|
||||
"clean": "没有未保存的更改",
|
||||
"new": "尚未创建",
|
||||
"unsaved": "未保存的更改({{count}})"
|
||||
}
|
||||
},
|
||||
"run": {
|
||||
"workingLine": "Working"
|
||||
|
||||
@@ -23,7 +23,7 @@ import UnsavedChangesGuard from '../components/UnsavedChangesGuard'
|
||||
import { CAP_CHAT } from '../utils/capabilities'
|
||||
import Icon from '../components/Icon'
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
import { Button, Callout, LoadError, Page, PageHeader, Segmented, SkeletonRows } from '../components/ui'
|
||||
import { Button, Callout, LoadError, Page, Segmented, SkeletonRows } from '../components/ui'
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
import AgentFolderDialog from '../components/agents/AgentFolderDialog'
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
@@ -34,18 +34,23 @@ import { switchCounts, switchRows } from '../utils/chatSwitchRows'
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
import { SkillsPanel } from '../components/chat/SkillsMenu'
|
||||
|
||||
// The sections of the form. Each folds; Basic info is open at first.
|
||||
const SECTIONS = [
|
||||
{ id: 'BasicInfo', icon: 'info' },
|
||||
{ id: 'ModelSettings', icon: 'brain' },
|
||||
{ id: 'PromptsGoals', icon: 'target' },
|
||||
{ id: 'Files', icon: 'folder' },
|
||||
{ id: 'Notes', icon: 'memory' },
|
||||
{ id: 'MemorySettings', icon: 'database' },
|
||||
{ id: 'Skills', icon: 'sparkles' },
|
||||
{ id: 'Tools', icon: 'plug' },
|
||||
{ id: 'MCP', icon: 'server' },
|
||||
// The cards of the page, in order. `bare` parts have no heading of their own:
|
||||
// the card heading says what they are. `export` exists only for a saved agent.
|
||||
const GROUPS = [
|
||||
{ id: 'identity', icon: 'info', parts: ['BasicInfo', 'ModelSettings', 'PromptsGoals'], bare: true },
|
||||
{ id: 'capabilities', icon: 'plug', parts: ['Tools', 'Skills', 'Files'] },
|
||||
{ id: 'memory', icon: 'memory', parts: ['Notes', 'MemorySettings'] },
|
||||
{ id: 'advanced', icon: 'server', parts: ['MCP', 'export'] },
|
||||
]
|
||||
const secDomId = (gid) => `aed-sec-${gid}`
|
||||
|
||||
// Two letters for the avatar tile of the header: the start of the name.
|
||||
function monogram(name) {
|
||||
const words = (name || '').match(/[\p{L}\p{N}]+/gu) || []
|
||||
if (!words.length) return ''
|
||||
const letters = words.length > 1 ? words[0][0] + words[1][0] : words[0].slice(0, 2)
|
||||
return letters.toUpperCase()
|
||||
}
|
||||
|
||||
// The fields each fold summarises on its closed line.
|
||||
const SUMMARY_FIELDS = {
|
||||
@@ -71,8 +76,8 @@ export default function AgentCreate() {
|
||||
const [loadError, setLoadError] = useState(null)
|
||||
const [attempt, setAttempt] = useState(0)
|
||||
const [saving, setSaving] = useState(false)
|
||||
// Sections fold. Basics is open at first; the rest open on demand.
|
||||
const [openSections, setOpenSections] = useState(() => new Set(['BasicInfo']))
|
||||
// The section the reader is in, for the section nav.
|
||||
const [activeSec, setActiveSec] = useState(GROUPS[0].id)
|
||||
const [savedConfig, setSavedConfig] = useState(null)
|
||||
const [previewOpen, setPreviewOpen] = useState(false)
|
||||
const [previewTab, setPreviewTab] = useState('config')
|
||||
@@ -120,7 +125,6 @@ export default function AgentCreate() {
|
||||
}
|
||||
initialFormRef.current = initialForm
|
||||
setForm(initialForm)
|
||||
if (imported) setOpenSections(new Set(SECTIONS.map(s => s.id)))
|
||||
} catch (err) {
|
||||
// An agent that could not be read is said in place of the form: an
|
||||
// empty form would save over it.
|
||||
@@ -154,16 +158,24 @@ export default function AgentCreate() {
|
||||
// by default on its messaging connections. Say so, and offer the fix.
|
||||
const missingGrants = isEdit ? missingConnectorGrants(savedConfig?.builtins, form.builtins) : []
|
||||
|
||||
// Bring a field into view below the sticky header, then focus it.
|
||||
const focusField = (fieldId) => {
|
||||
const el = document.getElementById(fieldId)
|
||||
if (!el) return
|
||||
el.scrollIntoView({ block: 'center' })
|
||||
el.focus({ preventScroll: true })
|
||||
}
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e?.preventDefault?.()
|
||||
if (!payload.name) {
|
||||
addToast(t('create.needsName'), 'warning')
|
||||
setOpenSections(prev => new Set([...prev, 'BasicInfo']))
|
||||
focusField('ag-name')
|
||||
return
|
||||
}
|
||||
if (folderUnchosen) {
|
||||
addToast(t('folder.needsChoice'), 'warning')
|
||||
setOpenSections(prev => new Set([...prev, 'Files']))
|
||||
document.querySelector('[data-section="Files"]')?.scrollIntoView({ block: 'start' })
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
@@ -184,6 +196,31 @@ export default function AgentCreate() {
|
||||
}
|
||||
}
|
||||
|
||||
// Mark the section in view in the nav: the last one whose top has passed
|
||||
// the line under the sticky header.
|
||||
useEffect(() => {
|
||||
if (loading || loadError) return undefined
|
||||
let frame = 0
|
||||
const update = () => {
|
||||
frame = 0
|
||||
let current = GROUPS[0].id
|
||||
for (const g of GROUPS) {
|
||||
const el = document.getElementById(secDomId(g.id))
|
||||
if (el && el.getBoundingClientRect().top <= 180) current = g.id
|
||||
}
|
||||
setActiveSec(current)
|
||||
}
|
||||
const onScroll = () => { if (!frame) frame = requestAnimationFrame(update) }
|
||||
window.addEventListener('scroll', onScroll, { passive: true })
|
||||
window.addEventListener('resize', onScroll)
|
||||
update()
|
||||
return () => {
|
||||
window.removeEventListener('scroll', onScroll)
|
||||
window.removeEventListener('resize', onScroll)
|
||||
if (frame) cancelAnimationFrame(frame)
|
||||
}
|
||||
}, [loading, loadError])
|
||||
|
||||
const mcp = splitAgentMCP(form.mcp_servers)
|
||||
if (loading || loadError) {
|
||||
return (
|
||||
@@ -265,16 +302,8 @@ export default function AgentCreate() {
|
||||
const removeRow = (i) => updateField('folders', form.folders.filter((_, j) => j !== i))
|
||||
const addRow = () => { updateField('folders', [...form.folders, { mode: 'read' }]); setChoosingFolder('new') }
|
||||
|
||||
const toggleSection = (sid) => setOpenSections(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(sid)) next.delete(sid)
|
||||
else next.add(sid)
|
||||
return next
|
||||
})
|
||||
|
||||
const applyTemplate = (tpl) => {
|
||||
setForm(prev => ({ ...prev, name: tpl.name, description: tpl.description, instructions: tpl.instructions }))
|
||||
setOpenSections(prev => new Set([...prev, 'BasicInfo', 'PromptsGoals']))
|
||||
}
|
||||
|
||||
const renderSection = (sid) => {
|
||||
@@ -464,112 +493,147 @@ export default function AgentCreate() {
|
||||
onChange={(v) => updateField('mcp_servers', joinAgentMCP(v, mcp.connectors))} testId="agent-mcp" />
|
||||
</>
|
||||
)
|
||||
case 'export':
|
||||
return (
|
||||
<>
|
||||
<p className="ag-note">{t('create.exportHelp')}</p>
|
||||
{savedConfig?.folder?.path && <p className="ag-note ag-note--bottom" data-testid="agent-export-folder">{folderRows(savedConfig).length > 1 ? t('folder.exportNoteMany', { n: folderRows(savedConfig).length }) : t('folder.exportNote', { path: shownPath(savedConfig.folder.path) })}</p>}
|
||||
<Button icon="download" onClick={() => downloadAgentFile(savedConfig, addToast, t)}>
|
||||
{t('create.exportButton')}
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const title = isEdit ? t('create.titleEdit', { name: savedConfig?.name || '' }) : imported ? t('create.titleImport') : t('create.titleCreate')
|
||||
const saveLabel = isEdit ? t('create.save') : imported ? t('create.titleImport') : t('create.titleCreate')
|
||||
const headName = (form.name || '').trim()
|
||||
const visibleGroups = GROUPS.map(g => ({ ...g, parts: g.parts.filter(pid => pid !== 'export' || (isEdit && savedConfig)) }))
|
||||
|
||||
// A card needs the person when one of its parts does.
|
||||
const groupNeeds = (g) => g.parts.some(pid => pid !== 'export' && foldInfo({ id: pid }).state === 'needs')
|
||||
|
||||
const goTo = (gid) => {
|
||||
const el = document.getElementById(secDomId(gid))
|
||||
if (!el) return
|
||||
const calm = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
|
||||
el.scrollIntoView({ behavior: calm ? 'auto' : 'smooth', block: 'start' })
|
||||
setActiveSec(gid)
|
||||
}
|
||||
|
||||
const status = dirty
|
||||
? t('create.status.unsaved', { count: Math.max(diff.length, 1) })
|
||||
: t(isEdit ? 'create.status.clean' : 'create.status.new')
|
||||
const toolsInfo = foldInfo({ id: 'Tools' })
|
||||
const memoryInfo = foldInfo({ id: 'Notes' })
|
||||
|
||||
return (
|
||||
<Page className="ag-page" data-testid="agent-edit">
|
||||
<Page className="ag-page aed-page" data-testid="agent-edit">
|
||||
<UnsavedChangesGuard when={dirty && !saving} />
|
||||
<form onSubmit={handleSubmit} noValidate className="ag-edit">
|
||||
<div className="ag-edit__head">
|
||||
<button type="button" className="ag-back" onClick={leave}><Icon name="arrow-left" /> {isEdit ? savedConfig?.name : t('agent.back')}</button>
|
||||
<PageHeader
|
||||
title={title}
|
||||
supporting={isEdit ? t('create.subEdit') : t('create.subCreate')}
|
||||
actions={(
|
||||
<div className="ag-edit__acts">
|
||||
<Button variant="ghost" onClick={leave}>{t('create.discard')}</Button>
|
||||
<Button icon="eye" onClick={() => { setPreviewTab('config'); setPreviewOpen(true) }} data-testid="agent-preview-open">
|
||||
{t('create.preview')}
|
||||
{diff.length > 0 && <span className="dk-badge dk-badge--count">{diff.length}</span>}
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" icon="save" busy={saving}>{saveLabel}</Button>
|
||||
<form onSubmit={handleSubmit} noValidate className="ag-edit aed">
|
||||
<button type="button" className="ag-back" onClick={leave}><Icon name="arrow-left" /> {isEdit ? savedConfig?.name : t('agent.back')}</button>
|
||||
<header className="aed-bar" data-testid="agent-edit-header">
|
||||
<div className="aed-bar__who">
|
||||
<span className="aed-avatar" aria-hidden="true">{monogram(headName) || <Icon name="sparkles" />}</span>
|
||||
<div className="aed-bar__text">
|
||||
<h1 className="aed-bar__name" data-empty={headName ? undefined : ''}>{headName || t('create.header.unnamed')}</h1>
|
||||
<p className="aed-bar__sub">{isEdit ? t('create.header.modeEdit') : imported ? t('create.titleImport') : t('create.header.modeCreate')}</p>
|
||||
<p className="aed-bar__sub aed-bar__sub--status" aria-hidden="true" data-dirty={dirty || undefined}>{status}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="aed-bar__acts">
|
||||
<span className="aed-status" role="status" data-dirty={dirty || undefined} data-testid="agent-edit-status">{status}</span>
|
||||
<Button variant="ghost" onClick={leave}>{t('create.discard')}</Button>
|
||||
<Button icon="eye" onClick={() => { setPreviewTab('config'); setPreviewOpen(true) }} data-testid="agent-preview-open">
|
||||
{t('create.preview')}
|
||||
{diff.length > 0 && <span className="dk-badge dk-badge--count">{diff.length}</span>}
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" icon="save" busy={saving}>{saveLabel}</Button>
|
||||
</div>
|
||||
<ul className="aed-chips" aria-label={t('create.header.summary')}>
|
||||
<li className="aed-chip"><span className="aed-chip__k">{t('create.sections.ModelSettings')}</span><span className="aed-chip__v">{form.model || t('create.anyModel')}</span></li>
|
||||
<li className="aed-chip"><span className="aed-chip__k">{t('create.sections.Tools')}</span><span className="aed-chip__v">{toolsInfo.summary}</span></li>
|
||||
<li className="aed-chip"><span className="aed-chip__k">{t('create.sections.Notes')}</span><span className="aed-chip__v">{memoryInfo.summary}</span></li>
|
||||
</ul>
|
||||
</header>
|
||||
|
||||
<div className="aed-layout">
|
||||
<nav className="aed-nav" aria-label={t('create.nav.label')}>
|
||||
{visibleGroups.map(g => (
|
||||
<a
|
||||
key={g.id}
|
||||
href={`#${secDomId(g.id)}`}
|
||||
className="aed-nav__item"
|
||||
aria-current={activeSec === g.id ? 'true' : undefined}
|
||||
data-needs={groupNeeds(g) || undefined}
|
||||
data-testid={`agent-nav-${g.id}`}
|
||||
onClick={(e) => { e.preventDefault(); goTo(g.id) }}
|
||||
>
|
||||
<span className="aed-nav__dot" aria-hidden="true" />
|
||||
{t(`create.nav.${g.id}`)}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="aed-main">
|
||||
{imported && (
|
||||
<div className="ag-import-note" role="note" data-testid="agent-import-note">
|
||||
<p>{imported.legacy ? t('create.importLegacy') : t('create.importNew')}</p>
|
||||
{imported.dropped.length > 0 && <p>{t('create.importDropped', { keys: imported.dropped.join(', ') })}</p>}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{imported && (
|
||||
<div className="ag-import-note" role="note" data-testid="agent-import-note">
|
||||
<p>{imported.legacy ? t('create.importLegacy') : t('create.importNew')}</p>
|
||||
{imported.dropped.length > 0 && <p>{t('create.importDropped', { keys: imported.dropped.join(', ') })}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isEdit && !imported && (
|
||||
<section className="ag-start" aria-labelledby="ag-start-h" data-testid="agent-start">
|
||||
<h2 id="ag-start-h" className="ag-h3">{t('create.startFrom')}</h2>
|
||||
<div className="ag-start__row">
|
||||
{AGENT_TEMPLATES.map(tpl => (
|
||||
<button key={tpl.id} type="button" className="dk-chip" onClick={() => applyTemplate(tpl)} data-template={tpl.id}>
|
||||
{t(`templates.${tpl.id}.label`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div className="ag-folds">
|
||||
{SECTIONS.map(s => {
|
||||
const open = openSections.has(s.id)
|
||||
const info = foldInfo(s)
|
||||
return (
|
||||
<section key={s.id} className="ag-fold" data-open={open || undefined} data-section={s.id}>
|
||||
<h2 className="ag-fold__h">
|
||||
<button
|
||||
type="button"
|
||||
className="ag-fold__button"
|
||||
aria-expanded={open}
|
||||
aria-controls={`ag-fold-${s.id}`}
|
||||
onClick={() => toggleSection(s.id)}
|
||||
>
|
||||
<span className="ag-ready" data-state={info.state} aria-hidden="true"><Icon name={info.state === 'needs' ? 'warning' : 'check'} /></span>
|
||||
<span className="ag-fold__title">{t(`create.sections.${s.id}`)}</span>
|
||||
<span className="ag-fold__sum">{info.summary}</span>
|
||||
{info.changed > 0 ? <span className="ag-badge-changed">{t('create.changed', { count: info.changed })}</span> : <span />}
|
||||
<Icon name="chevron-down" className="ag-fold__chev" />
|
||||
</button>
|
||||
</h2>
|
||||
{open && (
|
||||
<div className="ag-fold__body" id={`ag-fold-${s.id}`}>
|
||||
{renderSection(s.id)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
})}
|
||||
{isEdit && savedConfig && (
|
||||
<section className="ag-fold" data-open={openSections.has('export') || undefined} data-section="export">
|
||||
<h2 className="ag-fold__h">
|
||||
<button type="button" className="ag-fold__button" aria-expanded={openSections.has('export')} aria-controls="ag-fold-export" onClick={() => toggleSection('export')}>
|
||||
<span className="ag-ready" data-state="ready" aria-hidden="true"><Icon name="check" /></span>
|
||||
<span className="ag-fold__title">{t('create.sections.export')}</span>
|
||||
<span className="ag-fold__sum">{t('create.exportSummary')}</span>
|
||||
<span />
|
||||
<Icon name="chevron-down" className="ag-fold__chev" />
|
||||
</button>
|
||||
</h2>
|
||||
{openSections.has('export') && (
|
||||
<div className="ag-fold__body" id="ag-fold-export">
|
||||
<p className="ag-note">{t('create.exportHelp')}</p>
|
||||
{savedConfig.folder?.path && <p className="ag-note ag-note--bottom" data-testid="agent-export-folder">{folderRows(savedConfig).length > 1 ? t('folder.exportNoteMany', { n: folderRows(savedConfig).length }) : t('folder.exportNote', { path: shownPath(savedConfig.folder.path) })}</p>}
|
||||
<Button icon="download" onClick={() => downloadAgentFile(savedConfig, addToast, t)}>
|
||||
{t('create.exportButton')}
|
||||
</Button>
|
||||
{!isEdit && !imported && (
|
||||
<section className="ag-start" aria-labelledby="ag-start-h" data-testid="agent-start">
|
||||
<h2 id="ag-start-h" className="ag-h3">{t('create.startFrom')}</h2>
|
||||
<div className="ag-start__row">
|
||||
{AGENT_TEMPLATES.map(tpl => (
|
||||
<button key={tpl.id} type="button" className="dk-chip" onClick={() => applyTemplate(tpl)} data-template={tpl.id}>
|
||||
{t(`templates.${tpl.id}.label`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div className="ag-foot">
|
||||
<Button variant="ghost" onClick={leave}>{t('create.discard')}</Button>
|
||||
<Button type="submit" variant="primary" icon="save" busy={saving}>{saveLabel}</Button>
|
||||
{visibleGroups.map(g => {
|
||||
// What the card says about itself: the first part that needs the
|
||||
// person, and how many of its fields differ from the saved agent.
|
||||
const infos = g.parts.filter(pid => pid !== 'export').map(pid => foldInfo({ id: pid }))
|
||||
const needs = infos.find(i => i.state === 'needs')
|
||||
const changed = infos.reduce((n, i) => n + i.changed, 0)
|
||||
return (
|
||||
<section key={g.id} id={secDomId(g.id)} className="aed-card" aria-labelledby={`${secDomId(g.id)}-h`} data-group={g.id}>
|
||||
<div className="aed-card__head">
|
||||
<h2 id={`${secDomId(g.id)}-h`} className="aed-card__title">{t(`create.nav.${g.id}`)}</h2>
|
||||
<p className="aed-card__lead">{t(`create.lead.${g.id}`)}</p>
|
||||
{needs && <span className="aed-card__needs" data-testid="agent-card-needs">{needs.summary}</span>}
|
||||
{changed > 0 && <span className="ag-badge-changed">{t('create.changed', { count: changed })}</span>}
|
||||
</div>
|
||||
{g.parts.map(pid => {
|
||||
const info = pid === 'export' ? null : foldInfo({ id: pid })
|
||||
return (
|
||||
<div key={pid} className="aed-part" data-section={pid} data-bare={g.bare || undefined}>
|
||||
{!g.bare && (
|
||||
<div className="aed-part__head">
|
||||
{info && <span className="ag-ready" data-state={info.state} aria-hidden="true"><Icon name={info.state === 'needs' ? 'warning' : 'check'} /></span>}
|
||||
<h3 className="aed-part__title">{t(`create.sections.${pid}`)}</h3>
|
||||
{info && <span className="aed-part__sum">{info.summary}</span>}
|
||||
{info && info.changed > 0 && <span className="ag-badge-changed">{t('create.changed', { count: info.changed })}</span>}
|
||||
</div>
|
||||
)}
|
||||
<div className="aed-part__body" data-pair={pid === 'BasicInfo' || undefined}>
|
||||
{renderSection(pid)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</section>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
|
||||
@@ -367,3 +367,104 @@ a.ag-run-row:hover { background: color-mix(in srgb, var(--dk-hover) 45%, transpa
|
||||
.ag-grants__text { display: flex; flex-direction: column; min-width: 0; }
|
||||
.ag-grants__title { font-weight: 500; }
|
||||
.ag-grants__tag { margin-left: var(--sp-2); font-size: var(--fs-small); color: var(--dk-muted); }
|
||||
|
||||
/* ---- Agent editor: one page, sticky summary header, section nav ------------
|
||||
Everything is prefixed aed-. Grid children get min-width: 0 so a long name
|
||||
or model id wraps instead of widening the page. */
|
||||
.aed { gap: var(--sp-3); min-width: 0; }
|
||||
.aed-bar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: var(--sp-2) var(--sp-4);
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
border: 1px solid var(--dk-edge);
|
||||
border-radius: var(--r-lg);
|
||||
background: var(--dk-card);
|
||||
box-shadow: 0 1px 0 var(--dk-edge), 0 8px 16px -12px rgba(0, 0, 0, .35);
|
||||
}
|
||||
.aed-bar__who { display: flex; align-items: center; gap: var(--sp-3); min-width: 0; }
|
||||
.aed-avatar {
|
||||
flex: none; display: grid; place-items: center; width: 40px; height: 40px;
|
||||
border-radius: var(--r-md); background: var(--dk-accent-wash); color: var(--dk-accent-text);
|
||||
font-weight: 600; font-size: var(--fs-small); letter-spacing: .02em;
|
||||
}
|
||||
.aed-avatar .lai-icon { width: 18px; height: 18px; }
|
||||
.aed-bar__text { min-width: 0; }
|
||||
.aed-bar__name {
|
||||
margin: 0; font: 600 var(--fs-section)/var(--lh-section) var(--font-sans); color: var(--dk-text);
|
||||
overflow-wrap: anywhere; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
|
||||
}
|
||||
.aed-bar__name[data-empty] { color: var(--dk-muted); font-weight: 500; }
|
||||
.aed-bar__sub { margin: 0; font-size: var(--fs-small); color: var(--dk-muted); }
|
||||
.aed-bar__sub--status { display: none; }
|
||||
.aed-bar__sub--status[data-dirty] { color: var(--dk-warn-text); font-weight: 500; }
|
||||
.aed-bar__acts { display: flex; align-items: center; justify-content: flex-end; gap: var(--sp-2); flex-wrap: wrap; min-width: 0; }
|
||||
.aed-status { font-size: var(--fs-small); color: var(--dk-muted); white-space: nowrap; }
|
||||
.aed-status[data-dirty] { color: var(--dk-warn-text); font-weight: 500; }
|
||||
.aed-chips { grid-column: 1 / -1; display: flex; flex-wrap: wrap; gap: var(--sp-2); margin: 0; padding: 0; list-style: none; min-width: 0; }
|
||||
.aed-chip {
|
||||
display: inline-flex; align-items: baseline; gap: 6px; min-width: 0; max-width: 100%;
|
||||
padding: 3px 10px; border-radius: var(--r-xl); background: var(--dk-hover);
|
||||
font-size: var(--fs-meta);
|
||||
}
|
||||
.aed-chip__k { color: var(--dk-muted); white-space: nowrap; }
|
||||
.aed-chip__v { min-width: 0; color: var(--dk-text); font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.aed-layout { display: grid; grid-template-columns: 180px minmax(0, 1fr); gap: var(--sp-6); align-items: start; min-width: 0; }
|
||||
.aed-nav { position: sticky; top: 148px; display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||
.aed-nav__item {
|
||||
display: flex; align-items: center; gap: var(--sp-2); min-height: 36px; padding: 0 var(--sp-3);
|
||||
border-left: 2px solid transparent; border-radius: 0 var(--r-md) var(--r-md) 0;
|
||||
color: var(--dk-muted); font-size: var(--fs-small); font-weight: 500; text-decoration: none;
|
||||
}
|
||||
.aed-nav__item:hover { background: var(--dk-hover); color: var(--dk-text); }
|
||||
.aed-nav__item[aria-current="true"] { border-left-color: var(--dk-accent-text); background: var(--dk-accent-wash); color: var(--dk-accent-text); }
|
||||
.aed-nav__dot { flex: none; width: 7px; height: 7px; border-radius: 50%; background: var(--dk-ok-text); }
|
||||
.aed-nav__item[data-needs] .aed-nav__dot { background: var(--dk-warn-text); }
|
||||
|
||||
.aed-main { display: flex; flex-direction: column; gap: var(--sp-5); min-width: 0; }
|
||||
.aed-card {
|
||||
display: flex; flex-direction: column; gap: var(--sp-5); min-width: 0;
|
||||
padding: var(--sp-5); border: 1px solid var(--dk-edge); border-radius: var(--r-lg); background: var(--dk-card);
|
||||
scroll-margin-top: 148px;
|
||||
}
|
||||
.aed-card__head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px var(--sp-3); }
|
||||
.aed-card__title { margin: 0; font: 600 var(--fs-section)/var(--lh-section) var(--font-sans); color: var(--dk-text); }
|
||||
.aed-card__lead { margin: 0; font-size: var(--fs-small); color: var(--dk-muted); }
|
||||
.aed-part { display: flex; flex-direction: column; gap: var(--sp-3); min-width: 0; }
|
||||
.aed-part:not([data-bare]) { padding-top: var(--sp-5); border-top: 1px solid var(--dk-edge); scroll-margin-top: 148px; }
|
||||
.aed-part__head { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2) var(--sp-3); min-width: 0; }
|
||||
.aed-part__title { margin: 0; font: 600 var(--fs-body)/var(--lh-body) var(--font-sans); color: var(--dk-text); }
|
||||
.aed-part__sum { min-width: 0; font-size: var(--fs-small); color: var(--dk-muted); overflow-wrap: anywhere; }
|
||||
.aed-part__body { min-width: 0; }
|
||||
.aed-part__body .form-group { margin-bottom: 0; }
|
||||
.aed-part__body[data-pair] { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--sp-4); }
|
||||
.aed-part__body .input, .aed-part__body .textarea { width: 100%; max-width: 100%; box-sizing: border-box; }
|
||||
.aed-card .ag-switches { margin-bottom: var(--sp-4); }
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.aed-layout { grid-template-columns: minmax(0, 1fr); gap: var(--sp-3); }
|
||||
.aed-nav { position: static; flex-direction: row; flex-wrap: wrap; gap: var(--sp-2); }
|
||||
.aed-nav__item { flex: none; border: 1px solid var(--dk-edge); border-radius: var(--r-xl); padding: 0 var(--sp-3); }
|
||||
.aed-nav__item[aria-current="true"] { border-color: var(--dk-accent-text); }
|
||||
.aed-card, .aed-part:not([data-bare]) { scroll-margin-top: 140px; }
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.aed-bar { grid-template-columns: minmax(0, 1fr); padding: var(--sp-2) var(--sp-3); gap: var(--sp-2); }
|
||||
.aed-bar__acts { justify-content: flex-start; }
|
||||
.aed-bar__acts { flex-wrap: wrap; }
|
||||
.aed-bar__acts > .dk-btn--primary { flex: 1 1 auto; justify-content: center; }
|
||||
.aed-status { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
|
||||
.aed-bar__sub--status { display: block; }
|
||||
.aed-chips { display: none; }
|
||||
.aed-avatar { width: 32px; height: 32px; }
|
||||
.aed-bar__sub:not(.aed-bar__sub--status) { display: none; }
|
||||
.aed-card { padding: var(--sp-4); }
|
||||
.aed-part__body[data-pair] { grid-template-columns: minmax(0, 1fr); }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) { .aed-nav__item { transition: none; } }
|
||||
.aed-card__needs { font-size: var(--fs-meta); font-weight: 500; color: var(--dk-warn-text); }
|
||||
Reference in new issue
Block a user