diff --git a/core/http/react-ui/e2e/addon-navigation.spec.js b/core/http/react-ui/e2e/addon-navigation.spec.js index 46379d162..b9d14688c 100644 --- a/core/http/react-ui/e2e/addon-navigation.spec.js +++ b/core/http/react-ui/e2e/addon-navigation.spec.js @@ -2,37 +2,38 @@ import { test, expect } from '@playwright/test' import fs from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' -import { ADMIN, MEMBER, app, sessionFor, mockAddons } from './addons-fixtures.js' +import { ADMIN, MEMBER, app, installedAddon, release, sessionFor, mockAddons } from './addons-fixtures.js' import { buildAddonBundles, installStudioAddons, packagedApp } from './studio-addon.js' import { FACE_MODEL, clipFile, faces, match, mockIdentity, people, photoFile } from './identity-fixtures.js' // Studio and Talk are packaged add-ons, not built-in tiles: the specs install // the real packages. No page is built in today, so `builtin` is empty unless a // spec makes one up to check the built-in rules. -async function setup(page, { apps = [], pins = ['studio', 'talk'], addons = true, builtin = [], studio = true, user = MEMBER } = {}) { +async function setup(page, { apps = [], pins = ['studio', 'talk'], addons = true, builtin = [], studio = true, user = MEMBER, installed, releases } = {}) { let saved = pins await page.route('**/api/auth/status', route => route.fulfill({ json: sessionFor(user, { addons }) })) await page.route('**/api/features', route => route.fulfill({ json: { addon_navigation: true } })) await page.route('**/api/addons/builtin', route => route.fulfill({ json: { builtin } })) // mockAddons installs the Apps routes with the real response shape ({ apps }). // The packages are served from the build output, so a page that opens one runs it. - if (studio) await installStudioAddons(page, { apps: [packagedApp('studio'), packagedApp('talk'), ...apps] }) - else await mockAddons(page, { apps }) + let mocked = null + if (studio) mocked = await installStudioAddons(page, { apps: [packagedApp('studio'), packagedApp('talk'), ...apps] }) + else mocked = await mockAddons(page, { apps, installed, releases }) await page.route('**/api/addons/pins', async route => { if (route.request().method() === 'PUT') saved = route.request().postDataJSON() await route.fulfill({ json: saved }) }) - return { saved: () => saved } + return { saved: () => saved, dispatchLog: () => (mocked ? mocked.log.dispatch : []), mocked: () => mocked } } test.describe('Add-on navigation', () => { - test('the sidebar lists the pinned apps under Apps, in pin order, and no Show all link', async ({ page }) => { + test('the sidebar lists the pinned apps under Add-ons, in pin order, and no Show all link', async ({ page }) => { await setup(page) await page.goto('/app') const nav = page.getByRole('navigation') await expect(nav.getByRole('link', { name: 'Chat' })).toBeVisible() // Apps, then the pinned apps in pin order; Apps is the only add-on entry. - await expect(nav.getByRole('link', { name: 'Apps', exact: true })).toHaveCount(1) + await expect(nav.getByRole('link', { name: 'Add-ons', exact: true })).toHaveCount(1) const pinned = nav.getByTestId('apps-pinned-link') await expect(pinned).toHaveCount(2) await expect(pinned.nth(0)).toHaveAttribute('data-app', 'studio') @@ -42,8 +43,8 @@ test.describe('Add-on navigation', () => { await expect(nav.getByTestId('apps-show-all')).toHaveCount(0) await expect(nav.getByText('Show all')).toHaveCount(0) const group = await nav.locator('a.nav-item').evaluateAll(els => els.map(e => e.textContent.trim())) - const at = group.indexOf('Apps') - expect(group.slice(at, at + 4)).toEqual(['Apps', 'Studio', 'Talk']) + const at = group.indexOf('Add-ons') + expect(group.slice(at, at + 4)).toEqual(['Add-ons', 'Studio', 'Talk']) await expect(pinned.nth(0)).toHaveAttribute('href', /\/app\/addons\/studio\//) // There is no Pinned section. await expect(page.getByTestId('pinned-nav')).toHaveCount(0) @@ -59,12 +60,12 @@ test.describe('Add-on navigation', () => { await page.unroute('**/api/addons/pins') await page.route('**/api/addons/pins', route => route.fulfill({ json: [] })) await page.goto('/app') - await expect(nav.getByRole('link', { name: 'Apps', exact: true })).toHaveCount(1) + await expect(nav.getByRole('link', { name: 'Add-ons', exact: true })).toHaveCount(1) await expect(nav.getByTestId('apps-show-all')).toHaveCount(0) await expect(nav.getByTestId('apps-pinned-link')).toHaveCount(0) }) - test('the icon rail shows Apps only, centred, with no pinned app', async ({ page }) => { + test('the icon rail shows Add-ons only, centred, with no pinned app', async ({ page }) => { await setup(page) for (const size of [{ width: 1280, height: 800, collapsed: true }, { width: 1000, height: 800, collapsed: false }]) { await page.setViewportSize({ width: size.width, height: size.height }) @@ -85,72 +86,136 @@ test.describe('Add-on navigation', () => { } }) - test('the Apps entry opens the drawer, where pinned add-ons are shown and their order can be changed', async ({ page }) => { + test('the Add-ons entry opens the drawer, where pinned add-ons are shown and their order can be changed', async ({ page }) => { const server = await setup(page, { pins: ['studio', 'talk'] }) await page.goto('/app') - await page.getByRole('navigation').getByRole('link', { name: 'Apps' }).click() + await page.getByRole('navigation').getByRole('link', { name: 'Add-ons', exact: true }).click() await expect(page).toHaveURL(/\/app\/addons$/) await expect(page.getByTestId('addon-tile')).toHaveCount(2) - await expect(page.getByTestId('pin-toggle')).toHaveCount(2) - for (const toggle of await page.getByTestId('pin-toggle').all()) { - await expect(toggle).toHaveAttribute('aria-pressed', 'true') - await expect(toggle).toHaveText('Pinned') - } - await page.locator('[data-testid="addon-tile"][data-app="studio"]').getByTestId('pin-later').click() + // A bare pin button or pair of arrows is gone: each card has one overflow menu. + await expect(page.getByTestId('pin-toggle')).toHaveCount(0) + await expect(page.getByTestId('pin-later')).toHaveCount(0) + await expect(page.getByRole('button', { name: /^Actions for / })).toHaveCount(2) + const studio = page.locator('[data-testid="addon-tile"][data-app="studio"]') + await studio.getByRole('button', { name: /^Actions for / }).click() + await expect(page.getByRole('menuitem', { name: 'Remove from sidebar' })).toBeVisible() + await page.getByRole('menuitem', { name: /^Move .* later$/ }).click() await expect.poll(() => server.saved()).toEqual(['talk', 'studio']) }) - test('the drawer lists tiles, searches them and pins from the keyboard', async ({ page }) => { - const server = await setup(page, { pins: [], apps: [app({ id: 'notes', title: 'Notes', state: 'running', mainPage: '/app/addons/notes/home' })] }) + test('the card menu offers Show in sidebar, Details and Remove, and pins from the keyboard', async ({ page }) => { + const server = await setup(page, { pins: [], user: ADMIN, apps: [app({ id: 'notes', title: 'Notes', state: 'running', mainPage: '/app/addons/notes/home' })] }) await page.goto('/app/addons') await expect(page.getByTestId('addon-tile')).toHaveCount(3) await page.getByTestId('addon-search').fill('note') await expect(page.getByTestId('addon-tile')).toHaveCount(1) - const toggle = page.getByTestId('pin-toggle').first() - await expect(toggle).toHaveAttribute('aria-pressed', 'false') - await expect(toggle).toHaveAccessibleName(/Pin Notes/) - await expect(toggle).toHaveText('Pin') - await toggle.focus() + const menu = page.getByRole('button', { name: 'Actions for Notes' }) + await menu.focus() await page.keyboard.press('Enter') - await expect(toggle).toHaveAttribute('aria-pressed', 'true') - await expect(toggle).toHaveText('Pinned') - expect(server.saved()).toEqual(['notes']) + await expect(page.getByRole('menuitem')).toHaveText(['Show in sidebar', 'Details', 'Remove']) + await page.keyboard.press('Enter') + await expect.poll(() => server.saved()).toEqual(['notes']) + await menu.click() + await expect(page.getByRole('menuitem', { name: 'Remove from sidebar' })).toBeVisible() }) - test('pinned apps come first in the grid, in pin order, and the detail view has the same pin control', async ({ page }) => { - const server = await setup(page, { + test('a reader sees no Remove in the card menu', async ({ page }) => { + await setup(page, { pins: [], apps: [app({ id: 'notes', title: 'Notes', state: 'running', mainPage: '/app/addons/notes/home' })] }) + await page.goto('/app/addons') + await page.getByRole('button', { name: 'Actions for Notes' }).click() + await expect(page.getByRole('menuitem', { name: 'Show in sidebar' })).toBeVisible() + await expect(page.getByRole('menuitem', { name: 'Remove', exact: true })).toHaveCount(0) + }) + + test('the card says what the add-on does and what it adds', async ({ page }) => { + await setup(page, { pins: [], studio: false, apps: [app({ id: 'notes', title: 'Notes', description: 'Keep notes.', state: 'running', mainPage: '/app/addons/notes/home', tools: ['add', 'list'], toolCount: 2 }), app({ id: 'quiet', title: 'Quiet', description: 'Works in the background.', hasUI: false, mainPage: undefined, tools: [], toolCount: 0 })] }) + await page.goto('/app/addons') + const notes = page.locator('[data-testid="addon-tile"][data-app="notes"]') + await expect(notes).toContainText('Keep notes.') + await expect(notes.getByTestId('tile-adds')).toContainText('Adds a page') + await expect(notes.getByTestId('tile-adds')).toContainText('2 chat tools') + await expect(page.locator('[data-testid="addon-tile"][data-app="quiet"]').getByTestId('tile-adds')).toHaveCount(0) + }) + + test('pinned apps come first in the grid, in pin order', async ({ page }) => { + await setup(page, { pins: ['notes'], - apps: [app({ id: 'notes', title: 'Notes', state: 'running', mainPage: '/app/addons/notes/home' }), app({ id: 'zeta', title: 'Zeta', enabled: false, state: 'stopped' })], + apps: [app({ id: 'zeta', title: 'Zeta', enabled: false, state: 'stopped' }), app({ id: 'notes', title: 'Notes', state: 'running', mainPage: '/app/addons/notes/home' })], }) await page.setViewportSize({ width: 1280, height: 800 }) await page.goto('/app/addons') const tiles = page.getByTestId('addon-tile') await expect(tiles.first()).toHaveAttribute('data-app', 'notes') - await expect(tiles.first().getByTestId('pin-toggle')).toHaveText('Pinned') - await expect(tiles.nth(1).getByTestId('pin-toggle')).toHaveText('Pin') - // The tile pin and the detail pin are one state. + await tiles.first().getByRole('button', { name: /^Actions for / }).click() + await expect(page.getByRole('menuitem', { name: 'Remove from sidebar' })).toBeVisible() + }) + + test('selecting a card opens the preview pane with what the add-on is, adds and offers', async ({ page }) => { + await setup(page, { pins: [], studio: false, user: ADMIN, apps: [app({ id: 'indexer', title: 'Indexer', description: 'Indexes your files.', enabled: false, state: 'stopped', hasUI: false, mainPage: undefined, tools: ['query', 'export'], toolCount: 2, version: '0.4.0', admin: { canStart: true, canStop: false, managementUrl: '/app/addons?manage=indexer' } })], installed: [installedAddon({ id: 'indexer', title: 'Indexer', sourceId: 'main', enabled: false, state: 'stopped', version: '0.4.0' })] }) + await page.setViewportSize({ width: 1360, height: 860 }) + await page.goto('/app/addons') + await expect(page.getByTestId('addon-detail')).toHaveCount(0) + await page.getByRole('button', { name: /^Indexer, Off/ }).click() + const pane = page.getByTestId('addon-detail') + await expect(pane).toBeVisible() + await expect(page).toHaveURL(/detail=indexer/) + await expect(pane.getByRole('heading', { name: 'Indexer' })).toBeVisible() + await expect(pane.getByRole('img', { name: 'State: Off' })).toBeVisible() + await expect(pane).toContainText('Indexes your files.') + await expect(pane.getByTestId('app-tools')).toContainText('2 chat tools') + await expect(pane.getByTestId('app-tools')).toContainText('query, export') + await expect(pane).toContainText('Runs in the background') + await expect(pane.getByTestId('preview-facts')).toContainText('0.4.0') + await expect(pane.getByTestId('preview-source')).toHaveText('main') + await expect(pane.getByRole('button', { name: 'Turn on' })).toBeVisible() + await expect(pane.getByRole('button', { name: 'Remove' })).toBeVisible() + // No screenshot slot unless the add-on provides images. + await expect(pane.getByTestId('addon-shots')).toHaveCount(0) + await pane.getByRole('button', { name: 'Close' }).click() + await expect(page.getByTestId('addon-detail')).toHaveCount(0) + }) + + test('the preview pane shows screenshot slots only for an add-on that ships images', async ({ page }) => { + await setup(page, { pins: [], studio: false, apps: [app({ id: 'notes', title: 'Notes', screenshots: ['/api/addons/apps/notes/icon?generation=3'] })] }) + await page.goto('/app/addons?detail=notes') + await expect(page.getByTestId('addon-detail').getByTestId('addon-shots').getByRole('listitem')).toHaveCount(1) + }) + + test('a member sees the pane without Remove and without the source', async ({ page }) => { + await setup(page, { pins: [], studio: false, apps: [app({ id: 'notes', title: 'Notes' })] }) + await page.goto('/app/addons?detail=notes') + const pane = page.getByTestId('addon-detail') + await expect(pane.getByRole('heading', { name: 'Notes' })).toBeVisible() + await expect(pane.getByRole('button', { name: 'Remove' })).toHaveCount(0) + await expect(pane.getByTestId('preview-source')).toHaveCount(0) + }) + + test('the pane menu is the same sidebar control as the card menu', async ({ page }) => { + const server = await setup(page, { pins: ['notes'], studio: false, apps: [app({ id: 'notes', title: 'Notes' }), app({ id: 'zeta', title: 'Zeta', enabled: false, state: 'stopped' })] }) + await page.setViewportSize({ width: 1360, height: 860 }) await page.goto('/app/addons?detail=zeta') const detail = page.getByTestId('addon-detail') - const head = detail.getByTestId('pin-toggle') - await expect(head).toHaveText('Pin') - await head.click() - await expect(head).toHaveText('Pinned') - await expect(head).toHaveAttribute('aria-pressed', 'true') + await detail.getByRole('button', { name: 'Actions for Zeta' }).click() + await page.getByRole('menuitem', { name: 'Show in sidebar' }).click() await expect.poll(() => server.saved()).toEqual(['notes', 'zeta']) - await head.click() - await expect(head).toHaveText('Pin') + await detail.getByRole('button', { name: 'Actions for Zeta' }).click() + await page.getByRole('menuitem', { name: 'Remove from sidebar' }).click() await expect.poll(() => server.saved()).toEqual(['notes']) }) - test('the first-run card shows to an administrator with nothing installed and leads to Browse', async ({ page }) => { - await setup(page, { user: ADMIN, studio: false, pins: [] }) + test('the first-run card offers three featured add-ons and a way to find more', async ({ page }) => { + await setup(page, { user: ADMIN, studio: false, pins: [], releases: ['notes', 'tables', 'voice', 'extra'].map(id => release(id, { description: `${id} does a thing.`, ui: id === 'notes' ? { mainPage: '/app/addons/notes/home' } : undefined })) }) await page.goto('/app/addons') const card = page.getByTestId('addons-first-run') await expect(card).toBeVisible() - await expect(card.getByRole('listitem')).toHaveCount(3) - await expect(card).toContainText('official library is registered by default') - // One way into Browse while the card shows: the bar's button waits. - await expect(page.getByRole('link', { name: 'Browse add-ons' })).toHaveCount(1) + await expect(card.getByRole('heading', { name: 'Add your first add-on' })).toBeVisible() + const featured = card.getByTestId('featured').getByRole('listitem') + await expect(featured).toHaveCount(3) + await expect(featured.first()).toContainText('does a thing.') + await expect(featured.first()).toContainText('Adds a page') + await expect(card.getByRole('button', { name: 'Install and turn on' })).toHaveCount(3) + // One way into Find add-ons while the card shows: the bar's button waits. + await expect(page.getByRole('link', { name: 'Find add-ons' })).toHaveCount(1) await expect(page.getByTestId('addons-add')).toHaveCount(0) await expect(page.getByTestId('addon-tile')).toHaveCount(0) await card.getByTestId('addons-first-run-browse').click() @@ -158,6 +223,27 @@ test.describe('Add-on navigation', () => { await expect(page.getByTestId('addons-first-run')).toHaveCount(0) }) + test('Install and turn on asks once, installs, then turns the add-on on', async ({ page }) => { + const state = await setup(page, { user: ADMIN, studio: false, pins: [], releases: [release('notes', { name: 'Notes' })] }) + await page.goto('/app/addons') + await page.getByRole('button', { name: 'Install and turn on' }).click() + const dialog = page.getByRole('alertdialog').or(page.getByRole('dialog')) + await expect(dialog).toContainText('Install and turn on Notes?') + await dialog.getByRole('button', { name: 'Install and turn on' }).click() + await expect.poll(() => state.dispatchLog().filter(d => ['install', 'enable'].includes(d.action)).map(d => d.action)).toEqual(['install', 'enable']) + }) + + test('a search with no result says no add-on matches, not that none are installed', async ({ page }) => { + await setup(page, { user: ADMIN, studio: false, pins: [], apps: [app({ id: 'notes', title: 'Notes' })] }) + await page.goto('/app/addons') + await page.getByTestId('addon-search').fill('zzz') + await expect(page.getByRole('heading', { name: 'No add-on matches' })).toBeVisible() + await expect(page.getByText('No add-ons yet')).toHaveCount(0) + await expect(page.getByTestId('addons-first-run')).toHaveCount(0) + await page.getByRole('button', { name: 'Clear search' }).click() + await expect(page.getByTestId('addon-tile')).toHaveCount(1) + }) + test('the first-run card is hidden once an add-on is installed', async ({ page }) => { await setup(page, { user: ADMIN, pins: [] }) await page.goto('/app/addons') @@ -169,7 +255,7 @@ test.describe('Add-on navigation', () => { test('the first-run card is hidden from a caller who cannot install add-ons', async ({ page }) => { await setup(page, { studio: false, pins: [] }) await page.goto('/app/addons') - await expect(page.getByRole('heading', { name: 'No apps yet' })).toBeVisible() + await expect(page.getByRole('heading', { name: 'No add-ons yet' })).toBeVisible() await expect(page.getByTestId('addons-first-run')).toHaveCount(0) }) @@ -184,7 +270,8 @@ test.describe('Add-on navigation', () => { test('a pinned tile can be moved later and the new order is saved', async ({ page }) => { const server = await setup(page, { pins: ['studio', 'talk'] }) await page.goto('/app/addons') - await page.getByTestId('addon-tile').filter({ hasText: 'Studio' }).getByTestId('pin-later').click() + await page.getByTestId('addon-tile').filter({ hasText: 'Studio' }).getByRole('button', { name: /^Actions for / }).click() + await page.getByRole('menuitem', { name: /^Move .* later$/ }).click() await expect.poll(() => server.saved()).toEqual(['talk', 'studio']) }) @@ -193,7 +280,8 @@ test.describe('Add-on navigation', () => { const eight = nine.slice(0, 8).map(a => a.id) await setup(page, { apps: nine, pins: eight }) await page.goto('/app/addons') - await page.getByTestId('addon-tile').filter({ hasText: 'App 8' }).getByTestId('pin-toggle').click() + await page.getByTestId('addon-tile').filter({ hasText: 'App 8' }).getByRole('button', { name: /^Actions for / }).click() + await page.getByRole('menuitem', { name: 'Show in sidebar' }).click() await expect(page.getByRole('alertdialog').or(page.getByRole('dialog'))).toContainText(/unpin/i) }) @@ -258,19 +346,17 @@ test.describe('Add-on navigation', () => { await setup(page, { apps: [app({ id: 'notes', title: 'Notes', enabled: false, state: 'stopped', admin: { canStart: true, canStop: false, managementUrl: '/app/addons?manage=notes' } })] }) await page.route('**/api/auth/status', route => route.fulfill({ json: sessionFor({ ...MEMBER, role: 'admin' }, { addons: true }) })) await page.goto('/app/addons?detail=notes') - await expect(page.getByTestId('addon-detail').getByRole('button', { name: 'Start' })).toBeVisible() + await expect(page.getByTestId('addon-detail').getByRole('button', { name: 'Turn on' })).toBeVisible() }) - test('the pin control has a visible focus state and a name', async ({ page }) => { + test('the card menu has a visible focus state and a name', async ({ page }) => { await setup(page) await page.goto('/app/addons') - const toggle = page.getByTestId('pin-toggle').first() - await toggle.focus() - const outline = await toggle.evaluate(el => getComputedStyle(el).outlineStyle) - expect(outline).toBe('solid') - const width = await toggle.evaluate(el => parseFloat(getComputedStyle(el).outlineWidth)) - expect(width).toBeGreaterThan(0) - await expect(toggle).toHaveAccessibleName(/(Pin|Unpin) /) + const trigger = page.getByRole('button', { name: /^Actions for / }).first() + await trigger.focus() + await expect(trigger).toBeFocused() + await expect(trigger).toHaveAttribute('aria-haspopup', 'menu') + await expect(trigger).toHaveAccessibleName(/^Actions for .+/) }) test('a pin of an add-on that is no longer listed is not drawn in the drawer and shows no error', async ({ page }) => { @@ -424,6 +510,63 @@ test.describe('Add-on navigation: the Voice recognition page', () => { await expect(page.getByRole('button', { name: /Enrol a speaker/ })).toHaveCount(1) }) + test('an add-on that is installed but off says it is off, by its name, and offers Turn on', async ({ page }) => { + const server = await setup(page, { user: ADMIN, studio: false, pins: [], apps: [app({ id: 'fine-tuning', title: 'Fine-tuning', enabled: false, state: 'stopped', mainPage: undefined, admin: { canStart: true, canStop: false, managementUrl: '/app/addons?manage=fine-tuning' } })] }) + await page.goto('/app/addons/fine-tuning/jobs') + const state = page.getByTestId('addon-unavailable') + await expect(state).toHaveAttribute('data-state', 'off') + await expect(page.getByRole('heading', { level: 1 })).toHaveText('Fine-tuning') + await expect(state.getByRole('heading', { name: 'Fine-tuning is off' })).toBeVisible() + await expect(state).not.toContainText('fine-tuning') + await state.getByRole('button', { name: 'Turn on' }).click() + await expect.poll(() => server.mocked().log.start.length).toBe(1) + expect(server.mocked().log.start[0].id).toBe('fine-tuning') + }) + + test('an off add-on shows a reader who can turn it on, and no button', async ({ page }) => { + await setup(page, { studio: false, pins: [], apps: [app({ id: 'fine-tuning', title: 'Fine-tuning', enabled: false, state: 'stopped', mainPage: undefined })] }) + await page.goto('/app/addons/fine-tuning/jobs') + const state = page.getByTestId('addon-unavailable') + await expect(state.getByRole('heading', { name: 'Fine-tuning is off' })).toBeVisible() + await expect(state).toContainText('An administrator can turn it on.') + await expect(state.getByRole('button', { name: 'Turn on' })).toHaveCount(0) + }) + + test('an add-on that is not installed says so and sends an administrator to Find add-ons', async ({ page }) => { + await setup(page, { user: ADMIN, studio: false, pins: [], apps: [] }) + await page.goto('/app/addons/fine-tuning/jobs') + const state = page.getByTestId('addon-unavailable') + await expect(state).toHaveAttribute('data-state', 'not-installed') + await expect(page.getByRole('heading', { level: 1 })).toHaveText('Fine-tuning') + await expect(state.getByRole('heading', { name: 'Fine-tuning is not installed' })).toBeVisible() + await expect(state.getByRole('link', { name: 'Find add-ons' })).toHaveAttribute('href', '/app/addons?tab=browse&q=fine-tuning') + }) + + test('a reader of an add-on that is not installed gets no link to Find add-ons', async ({ page }) => { + await setup(page, { studio: false, pins: [], apps: [] }) + await page.goto('/app/addons/fine-tuning/jobs') + const state = page.getByTestId('addon-unavailable') + await expect(state.getByRole('heading', { name: 'Fine-tuning is not installed' })).toBeVisible() + await expect(state).toContainText('An administrator can install it.') + await expect(state.getByRole('link', { name: 'Find add-ons' })).toHaveCount(0) + }) + + test('a failed add-on shows its reason and, for an administrator, a link to its details', async ({ page }) => { + await setup(page, { user: ADMIN, studio: false, pins: [], apps: [app({ id: 'fine-tuning', title: 'Fine-tuning', state: 'failed', mainPage: undefined, ui: { entry: '/x.js', sdk: { min: 1, max: 1 }, pages: ['jobs'] } })] }) + await page.goto('/app/addons/fine-tuning/jobs') + const state = page.getByTestId('addon-unavailable') + await expect(state).toHaveAttribute('data-state', 'failed') + await expect(state.getByRole('heading', { name: 'Fine-tuning failed to start' })).toBeVisible() + await expect(state).toContainText('stopped responding') + await expect(state.getByRole('link', { name: 'Details' })).toHaveAttribute('href', '/app/addons?detail=fine-tuning') + }) + + test('the page names a headless add-on by a capitalised id, not the bare identifier', async ({ page }) => { + await setup(page, { user: ADMIN, studio: false, pins: [], apps: [app({ id: 'indexer', title: 'indexer', enabled: false, state: 'stopped', mainPage: undefined, admin: { canStart: true, canStop: false, managementUrl: '/app/addons?manage=indexer' } })] }) + await page.goto('/app/addons/indexer/home') + await expect(page.getByRole('heading', { level: 1 })).toHaveText('Indexer') + }) + test('an old Voice link ends in the add-on shell, which says the add-on is not available', async ({ page }) => { await setup(page, { apps: [] }) await asAdmin(page) diff --git a/core/http/react-ui/e2e/addons-backend-library.spec.js b/core/http/react-ui/e2e/addons-backend-library.spec.js index f757ac682..1546f61c7 100644 --- a/core/http/react-ui/e2e/addons-backend-library.spec.js +++ b/core/http/react-ui/e2e/addons-backend-library.spec.js @@ -52,7 +52,11 @@ test.describe('Library backends', () => { await mockBackends(page) await page.goto('/app/addons?tab=browse') await expect(page.locator('.cat-item')).toHaveCount(2) - await expect(page.locator('.cat-item', { hasText: 'Tables' }).getByTestId('entry-kind')).toHaveText('Add-on') + // A backend says what it is on the entry; an add-on is the default and says it under Details. + const tables = page.locator('.cat-item', { hasText: 'Tables' }) + await expect(tables.getByTestId('entry-kind')).toHaveCount(0) + await tables.getByText('Details', { exact: true }).click() + await expect(tables.getByTestId('details-kind')).toHaveText('Add-on') const row = page.locator('.cat-item', { hasText: 'cpu-local-store' }) await expect(row.getByTestId('entry-kind')).toHaveText('Backend') await expect(row.getByTestId('entry-kind')).toHaveAttribute('data-kind', 'backend') @@ -83,7 +87,7 @@ test.describe('Library backends', () => { expect(state.log.dispatch.filter(d => d.action === 'install')).toHaveLength(0) // Once installed the entry says so and offers no second install. await expect(row.getByText('Installed', { exact: true })).toBeVisible() - await expect(row.getByRole('button', { name: 'Install' })).toBeDisabled() + await expect(row.getByRole('button', { name: 'Install' })).toHaveCount(0) }) test('browse: a refusal of the host is shown with its own reason', async ({ page }) => { @@ -121,7 +125,7 @@ test.describe('Library backends', () => { await mockAddons(page, { installed: [], releases: [backend()] }) await mockBackends(page) await page.goto('/app/addons?tab=browse') - await page.locator('.cat-item', { hasText: 'cpu-local-store' }).getByRole('button', { name: 'Details' }).click() + await page.locator('.cat-item', { hasText: 'cpu-local-store' }).getByText('Details', { exact: true }).click() await expect(page.getByTestId('details-kind')).toHaveText('Backend') await expect(page.getByText('local-store', { exact: true })).toBeVisible() await expect(page.getByText('workflows/w', { exact: false })).toBeVisible() diff --git a/core/http/react-ui/e2e/addons-foundation.spec.js b/core/http/react-ui/e2e/addons-foundation.spec.js index 7b518d7c6..c712f4086 100644 --- a/core/http/react-ui/e2e/addons-foundation.spec.js +++ b/core/http/react-ui/e2e/addons-foundation.spec.js @@ -65,23 +65,23 @@ test.describe('Add-ons foundation through real servers', () => { const { context, page } = await browserAs(browser, node, 'member') await page.goto('/app/addons') await expect(card(page)).toBeVisible({ timeout: 30_000 }) - await expect(card(page).locator('.ad-tile__state')).toHaveText('Running') + await expect(card(page).locator('.ad-tile__state')).toHaveText('On') await context.close() } }) - test('foundation: a member sees the Apps list only and the server refuses every management request', async ({ browser }) => { + test('foundation: a member sees the add-on list only and the server refuses every management request', async ({ browser }) => { for (const node of ['front-a', 'front-b', 'owner']) { const { context, page } = await browserAs(browser, node, 'member') await page.goto('/app/addons') await expect(card(page)).toBeVisible({ timeout: 30_000 }) await expect(page.getByRole('tablist')).toHaveCount(0) - await expect(page.getByRole('button', { name: /^(Start|Stop)$/ })).toHaveCount(0) + await expect(page.getByRole('button', { name: /^Turn (on|off)$/ })).toHaveCount(0) await expect(page.getByRole('link', { name: 'Manage' })).toHaveCount(0) // The detail sheet hides them as well. await page.goto(`/app/addons?detail=${ID}`) await expect(sheetCard(page)).toBeVisible({ timeout: 30_000 }) - await expect(page.getByRole('button', { name: /^(Start|Stop)$/ })).toHaveCount(0) + await expect(page.getByRole('button', { name: /^Turn (on|off)$/ })).toHaveCount(0) await expect(page.getByRole('link', { name: 'Manage' })).toHaveCount(0) // The page hides the controls; the server is what refuses. for (const action of ['install', 'upgrade', 'enable', 'disable', 'uninstall', 'catalog-sources', 'addons-installed']) { @@ -116,15 +116,15 @@ test.describe('Add-ons foundation through real servers', () => { await expect(card(page)).toBeVisible({ timeout: 30_000 }) await expect(page.getByRole('tablist')).toBeVisible() await page.goto(`/app/addons?detail=${ID}`) - await sheetCard(page).getByRole('button', { name: 'Stop' }).click() - await expect(sheetCard(page).getByRole('img', { name: 'State: Stopped' })).toBeVisible({ timeout: 30_000 }) + await sheetCard(page).getByRole('button', { name: 'Turn off' }).click() + await expect(sheetCard(page).getByRole('img', { name: 'State: Off' })).toBeVisible({ timeout: 30_000 }) // The other frontend and the owner see the same stopped add-on: the // administrator in the list with its state, a member not at all, because // the owner lists a disabled add-on to administrators only. for (const node of ['front-a', 'owner']) { const other = await browserAs(browser, node, 'admin') await other.page.goto(`/app/addons?detail=${ID}`) - await expect(sheetCard(other.page).getByRole('img', { name: 'State: Stopped' })).toBeVisible({ timeout: 30_000 }) + await expect(sheetCard(other.page).getByRole('img', { name: 'State: Off' })).toBeVisible({ timeout: 30_000 }) await other.context.close() const member = await browserAs(browser, node, 'member') await member.page.goto('/app/addons') @@ -132,8 +132,8 @@ test.describe('Add-ons foundation through real servers', () => { await expect(card(member.page)).toHaveCount(0) await member.context.close() } - await sheetCard(page).getByRole('button', { name: 'Start' }).click() - await expect(sheetCard(page).getByRole('img', { name: 'State: Running' })).toBeVisible({ timeout: 30_000 }) + await sheetCard(page).getByRole('button', { name: 'Turn on' }).click() + await expect(sheetCard(page).getByRole('img', { name: 'State: On' })).toBeVisible({ timeout: 30_000 }) await context.close() }) diff --git a/core/http/react-ui/e2e/addons-gallery.spec.js b/core/http/react-ui/e2e/addons-gallery.spec.js index da558b041..eefaae22b 100644 --- a/core/http/react-ui/e2e/addons-gallery.spec.js +++ b/core/http/react-ui/e2e/addons-gallery.spec.js @@ -1,8 +1,8 @@ import { test, expect } from '@playwright/test' import { ADMIN, MEMBER, HEADLESS, app, installedAddon, release, sessionFor, mockAddons, ok, fail } from './addons-fixtures.js' -// The add-ons page (src/pages/Addons.jsx): Apps for everyone, Browse, -// Installed and Libraries for administrators. The server is the authority for +// The add-ons page (src/pages/Addons.jsx): My add-ons for everyone, Find add-ons, +// Installed and Sources for administrators. The server is the authority for // every answer, so these specs drive the page against a stand-in that gives // the same refusals the real routes give, and assert what the page sent. @@ -33,10 +33,10 @@ test.describe('Add-ons: apps', () => { await page.goto('/app/addons') const notes = page.locator('[data-app="notes"]') await expect(notes.locator('.ad-tile__title')).toHaveText('Notes') - await expect(notes.locator('.ad-tile__state')).toHaveText('Running') + await expect(notes.locator('.ad-tile__state')).toHaveText('On') await expect(notes.getByRole('link')).toHaveAttribute('href', '/app/addons/notes/home') await expect(notes.locator('img.ad-icon')).toHaveAttribute('src', '/api/addons/apps/notes/icon?generation=3') - await expect(page.locator('[data-app="broken"] .ad-tile__state')).toHaveText('Stopped, failed') + await expect(page.locator('[data-app="broken"] .ad-tile__state')).toHaveText('Off, failed') await expect(page.locator('[data-app="broken"]').getByRole('link')).toHaveCount(0) await expect(page.locator('[data-app="indexer"]').getByRole('link')).toHaveCount(0) // Every image is served by the host itself, never from another origin. @@ -50,7 +50,7 @@ test.describe('Add-ons: apps', () => { await mockAddons(page, { apps: [HEADLESS] }) await page.goto('/app/addons?detail=indexer') const card = detail(page, 'indexer') - await expect(card.getByTestId('app-tools')).toContainText('3 tools') + await expect(card.getByTestId('app-tools')).toContainText('3 chat tools') await expect(card.getByTestId('app-tools')).toContainText('query, export, reindex') await expect(card.getByText('Runs in the background')).toBeVisible() await expect(card.getByRole('link')).toHaveCount(0) @@ -62,13 +62,13 @@ test.describe('Add-ons: apps', () => { await page.goto('/app/addons?tab=libraries') await expect(page.locator('[data-app="notes"]')).toBeVisible() await expect(page.getByRole('tablist')).toHaveCount(0) - await expect(page.getByRole('button', { name: /^(Start|Stop)$/ })).toHaveCount(0) + await expect(page.getByRole('button', { name: /^Turn (on|off)$/ })).toHaveCount(0) await expect(page.getByRole('link', { name: 'Manage' })).toHaveCount(0) await expect(page.getByText('Add a library')).toHaveCount(0) - // The detail sheet offers the reader nothing to start, stop or manage either. + // The preview pane offers the reader nothing to turn on, turn off or manage either. await page.goto('/app/addons?detail=notes') await expect(detail(page, 'notes')).toBeVisible() - await expect(page.getByRole('button', { name: /^(Start|Stop)$/ })).toHaveCount(0) + await expect(page.getByRole('button', { name: /^Turn (on|off)$/ })).toHaveCount(0) await expect(page.getByRole('link', { name: 'Manage' })).toHaveCount(0) }) @@ -77,10 +77,9 @@ test.describe('Add-ons: apps', () => { const state = await mockAddons(page, { apps: [app({ id: 'indexer', title: 'Indexer', enabled: false, state: 'stopped', generation: 4, mainPage: undefined, admin: { canStart: true, canStop: false, managementUrl: '/app/addons?manage=indexer' } })] }) await page.goto('/app/addons?detail=indexer') const card = detail(page, 'indexer') - await expect(card.getByRole('img', { name: 'State: Stopped' })).toBeVisible() - await expect(card.getByRole('link', { name: 'Manage' })).toHaveAttribute('href', '/app/addons?manage=indexer') - await card.getByRole('button', { name: 'Start' }).click() - await expect(card.getByRole('img', { name: 'State: Running' })).toBeVisible() + await expect(card.getByRole('img', { name: 'State: Off' })).toBeVisible() + await card.getByRole('button', { name: 'Turn on' }).click() + await expect(card.getByRole('img', { name: 'State: On' })).toBeVisible() expect(state.log.start).toHaveLength(1) expect(state.log.start[0].generation).toBe(4) expect(state.log.start[0].operationId).toMatch(UUID) @@ -101,7 +100,7 @@ test.describe('Add-ons: apps', () => { }, }) await page.goto('/app/addons?detail=indexer') - await detail(page, 'indexer').getByRole('button', { name: 'Start' }).click() + await detail(page, 'indexer').getByRole('button', { name: 'Turn on' }).click() await expect.poll(() => reads, { timeout: 15_000 }).toBeGreaterThanOrEqual(2) expect(state.log.start).toHaveLength(1) const ids = new Set([state.log.start[0].operationId, ...state.log.dispatch.filter(d => d.action === 'operation-status').map(d => d.operationId)]) @@ -150,7 +149,7 @@ test.describe('Add-ons: apps', () => { }) await page.goto('/app/addons?detail=indexer') const card = detail(page, 'indexer') - await card.getByRole('button', { name: 'Start' }).click() + await card.getByRole('button', { name: 'Turn on' }).click() const notice = card.getByTestId('unknown-outcome') await expect(notice).toBeVisible({ timeout: 40_000 }) expect(state.log.start).toHaveLength(1) @@ -158,23 +157,23 @@ test.describe('Add-ons: apps', () => { lose = false // The list now shows the app running, but a new action on it is blocked // until the unknown one is read or dismissed. - await expect(card.getByRole('button', { name: 'Stop' })).toBeVisible({ timeout: 15_000 }) - await expect(card.getByRole('button', { name: 'Stop' })).toBeDisabled() + await expect(card.getByRole('button', { name: 'Turn off' })).toBeVisible({ timeout: 15_000 }) + await expect(card.getByRole('button', { name: 'Turn off' })).toBeDisabled() expect(state.log.stop).toHaveLength(0) await notice.getByRole('button', { name: 'Check status' }).click() await expect(notice).toHaveCount(0) expect(state.log.start).toHaveLength(1) // Stop is a new operation. - await card.getByRole('button', { name: 'Stop' }).click() + await card.getByRole('button', { name: 'Turn off' }).click() await expect.poll(() => state.log.stop.length).toBe(1) expect(state.log.stop[0].operationId).toMatch(UUID) expect(state.log.stop[0].operationId).not.toBe(firstId) - await expect(card.getByRole('button', { name: 'Start' })).toBeVisible() + await expect(card.getByRole('button', { name: 'Turn on' })).toBeVisible() // Start again: a fresh ID, applied by the owner, not replayed as a no-op. - await card.getByRole('button', { name: 'Start' }).click() + await card.getByRole('button', { name: 'Turn on' }).click() await expect.poll(() => state.log.start.length).toBe(2) expect(state.log.start[1].operationId).not.toBe(firstId) - await expect(card.getByRole('img', { name: 'State: Running' })).toBeVisible() + await expect(card.getByRole('img', { name: 'State: On' })).toBeVisible() await expect(card.getByRole('alert')).toHaveCount(0) expect(state.apps[0].enabled).toBe(true) }) @@ -191,13 +190,13 @@ test.describe('Add-ons: apps', () => { }) await page.goto('/app/addons?detail=indexer') const card = detail(page, 'indexer') - await card.getByRole('button', { name: 'Start' }).click() + await card.getByRole('button', { name: 'Turn on' }).click() const notice = card.getByTestId('unknown-outcome') await expect(notice).toBeVisible({ timeout: 40_000 }) - await expect(card.getByRole('button', { name: 'Start' })).toBeDisabled() + await expect(card.getByRole('button', { name: 'Turn on' })).toBeDisabled() await notice.getByRole('button', { name: 'Dismiss' }).click() await expect(notice).toHaveCount(0) - await card.getByRole('button', { name: 'Start' }).click() + await card.getByRole('button', { name: 'Turn on' }).click() await expect.poll(() => state.log.start.length).toBe(2) expect(state.log.start[1].operationId).not.toBe(state.log.start[0].operationId) }) @@ -211,9 +210,9 @@ test.describe('Add-ons: apps', () => { }) await page.goto('/app/addons?detail=indexer') const card = detail(page, 'indexer') - await card.getByRole('button', { name: 'Start' }).click() + await card.getByRole('button', { name: 'Turn on' }).click() await expect(card.getByRole('alert')).toContainText('did not change') - await expect(card.getByRole('img', { name: 'State: Stopped' })).toBeVisible() + await expect(card.getByRole('img', { name: 'State: Off' })).toBeVisible() }) test('apps: a refused start shows the server denial and refreshes the list', async ({ page }) => { @@ -224,8 +223,8 @@ test.describe('Add-ons: apps', () => { }) await page.goto('/app/addons?detail=indexer') const before = state.log.appsList - await detail(page, 'indexer').getByRole('button', { name: 'Start' }).click() - await expect(page.getByRole('alert').filter({ hasText: 'The server refused this' })).toBeVisible() + await detail(page, 'indexer').getByRole('button', { name: 'Turn on' }).click() + await expect(page.getByRole('alert').filter({ hasText: 'addon_error' })).toBeVisible() await expect.poll(() => state.log.appsList).toBeGreaterThan(before) }) @@ -324,6 +323,9 @@ test.describe('Add-ons: browse', () => { await expect(page.locator('.cat-item')).toHaveCount(24) await expect(page.getByText('31 results')).toBeVisible() const tables = page.locator('.cat-item', { hasText: 'Tables' }).first() + // Trust and platform facts are folded into the entry, behind Details. + await expect(tables.getByText('linux/amd64')).toBeHidden() + await tables.getByText('Details', { exact: true }).click() await expect(tables.getByText('Signature required')).toBeVisible() await expect(tables.getByText('linux/amd64')).toBeVisible() await expect(page.getByRole('group', { name: 'Libraries' }).getByRole('button', { name: 'Team' })).toBeVisible() @@ -331,51 +333,101 @@ test.describe('Add-ons: browse', () => { await expect(page).toHaveURL(/page=2/) await expect(page.locator('.cat-item')).toHaveCount(7) expect(state.log.dispatch.filter(d => d.action === 'catalog-search').at(-1).arguments.offset).toBe(24) - await page.getByRole('searchbox').or(page.getByLabel('Search the libraries')).first().fill('tables') + await page.getByRole('searchbox').or(page.getByLabel('Search add-ons in your sources')).first().fill('tables') await expect(page.locator('.cat-item')).toHaveCount(1) await expect(page).toHaveURL(/q=tables/) expect(state.log.dispatch.filter(d => d.action === 'catalog-search').at(-1).arguments.text).toBe('tables') await shoot(page, 'browse') }) - test('browse: installing asks first and leaves the add-on disabled until it is enabled', async ({ page }) => { + test('browse: Install and turn on asks once, installs and turns the add-on on', async ({ page }) => { await signIn(page, ADMIN) - const state = await mockAddons(page, { installed: [], releases: [release('tables', { name: 'Tables' })] }) + const state = await mockAddons(page, { installed: [], releases: [release('tables', { name: 'Tables', description: 'Spreadsheets.', ui: { mainPage: '/app/addons/tables/home' } })] }) await page.goto('/app/addons?tab=browse') const row = page.locator('.cat-item', { hasText: 'Tables' }) - await row.getByRole('button', { name: 'Install' }).click() + await expect(row).toContainText('Spreadsheets.') + await expect(row.getByTestId('entry-adds')).toHaveText('Adds a page') + // One button on the entry, and Details is folded into it. + await expect(row.getByRole('button')).toHaveCount(1) + await row.getByRole('button', { name: 'Install and turn on' }).click() const dialog = page.getByRole('alertdialog') - await expect(dialog).toContainText('Install Tables?') - await expect(dialog).toContainText('does not start the add-on') + await expect(dialog).toContainText('Install and turn on Tables?') await expect(dialog).toContainText('sha256:aaaaaaaaaaaa') expect(state.log.dispatch.filter(d => d.action === 'install')).toHaveLength(0) - await dialog.getByRole('button', { name: 'Install' }).click() - await expect(row.getByText('It is not enabled')).toBeVisible() + await dialog.getByRole('button', { name: 'Install and turn on' }).click() + // The same dialog stays open and shows the two steps, then the result. + await expect(dialog.getByTestId('flow-steps').getByRole('listitem')).toHaveCount(2) + await expect(dialog).toContainText('Tables is installed and on.') + await expect(dialog.getByTestId('flow-steps').locator('[data-step="install"]')).toHaveAttribute('data-state', 'done') + await expect(dialog.getByTestId('flow-steps').locator('[data-step="turn-on"]')).toHaveAttribute('data-state', 'done') + await dialog.getByRole('button', { name: 'Close' }).click() + await expect(page.getByRole('alertdialog')).toHaveCount(0) + await expect(row.getByText('Tables is installed and on.')).toBeVisible() const install = state.log.dispatch.filter(d => d.action === 'install') expect(install).toHaveLength(1) expect(install[0].operationId).toMatch(UUID) expect(install[0].arguments).toEqual({ sourceId: 'main', version: '1.0.0' }) - expect(state.log.dispatch.filter(d => d.action === 'enable')).toHaveLength(0) - // Activation is its own step, on the Installed tab. - await tab(page, 'Installed').click() - const installed = page.locator('[data-addon="tables"]') - await expect(installed.getByText('Disabled')).toBeVisible() - await installed.getByRole('button', { name: 'Enable' }).click() - await expect(installed.getByText('Enabled', { exact: true })).toBeVisible() - expect(state.log.dispatch.filter(d => d.action === 'enable')).toHaveLength(1) - expect(state.log.dispatch.find(d => d.action === 'enable').generation).toBe(1) + const enable = state.log.dispatch.filter(d => d.action === 'enable') + expect(enable).toHaveLength(1) + expect(enable[0].operationId).toMatch(UUID) + expect(enable[0].operationId).not.toBe(install[0].operationId) + expect(state.installed.find(a => a.id === 'tables').enabled).toBe(true) }) + + test('browse: a failed turn on keeps the install, says why in the dialog and can be tried again', async ({ page }) => { + await signIn(page, ADMIN) + let attempts = 0 + const state = await mockAddons(page, { + installed: [], + releases: [release('tables', { name: 'Tables' })], + dispatch: (body) => { + if (body.action !== 'enable') return null + attempts += 1 + return attempts === 1 ? fail(403, 'addon_error') : null + }, + }) + await page.goto('/app/addons?tab=browse') + const row = page.locator('.cat-item', { hasText: 'Tables' }) + await row.getByRole('button', { name: 'Install and turn on' }).click() + const dialog = page.getByRole('alertdialog') + await dialog.getByRole('button', { name: 'Install and turn on' }).click() + await expect(dialog.getByTestId('install-dialog-failed')).toContainText('Tables is installed but did not turn on.') + await expect(dialog.getByTestId('flow-steps').locator('[data-step="install"]')).toHaveAttribute('data-state', 'done') + await expect(dialog.getByTestId('flow-steps').locator('[data-step="turn-on"]')).toHaveAttribute('data-state', 'failed') + // Trying again turns it on; it does not install a second time. + await dialog.getByRole('button', { name: 'Try again' }).click() + await expect(dialog).toContainText('Tables is installed and on.') + expect(state.log.dispatch.filter(d => d.action === 'install')).toHaveLength(1) + expect(state.log.dispatch.filter(d => d.action === 'enable')).toHaveLength(2) + }) + + test('browse: an add-on that is installed but off offers Turn on and nothing else', async ({ page }) => { + await signIn(page, ADMIN) + const state = await mockAddons(page, { installed: [installedAddon({ id: 'tables', title: 'Tables', enabled: false, state: 'stopped', generation: 1, version: '1.0.0' })], releases: [release('tables', { name: 'Tables' })] }) + await page.goto('/app/addons?tab=browse') + const row = page.locator('.cat-item', { hasText: 'Tables' }) + await expect(row.getByRole('button')).toHaveCount(1) + await row.getByRole('button', { name: 'Turn on' }).click() + await expect.poll(() => state.log.dispatch.filter(d => d.action === 'enable').length).toBe(1) + expect(state.log.dispatch.filter(d => d.action === 'install')).toHaveLength(0) + }) +}) + +// What the Installed tab used to show is the Details of an add-on in My add-ons: +// the list read for an administrator and its installed record go together. +const details = (id, over = {}, installedOver = {}) => ({ + apps: [app({ id, title: installedOver.title || id, admin: { canStart: false, canStop: true, managementUrl: `/app/addons?manage=${id}` }, ...over })], + installed: [installedAddon({ id, title: installedOver.title || id, ...installedOver })], }) test.describe('Add-ons: installed', () => { test('installed: health is separate from enabled and the details show trust, compatibility and storage', async ({ page }) => { await signIn(page, ADMIN) - await mockAddons(page, { installed: [installedAddon({ state: 'failed', restarts: 3, verification: 'sha256+sigstore' })] }) - await page.goto('/app/addons?tab=installed') - const row = page.locator('[data-addon="notes"]') - await expect(row.getByText('Enabled', { exact: true })).toBeVisible() - await expect(row.getByRole('img', { name: 'State: Failed' })).toBeVisible() - await row.getByRole('button', { name: 'Details' }).click() + await mockAddons(page, details('notes', { state: 'failed' }, { state: 'failed', restarts: 3, verification: 'sha256+sigstore', title: 'Notes' })) + await page.goto('/app/addons?manage=notes') + const pane = page.getByTestId('addon-detail') + await expect(pane.getByRole('heading', { name: 'Notes' })).toBeVisible() + await expect(pane.getByRole('img', { name: 'State: Failed' }).first()).toBeVisible() const sheet = page.getByTestId('addon-manage') await expect(sheet.getByText('Wanted state')).toBeVisible() await expect(sheet.getByRole('img', { name: 'State: Failed' })).toBeVisible() @@ -394,14 +446,18 @@ test.describe('Add-ons: installed', () => { test('installed: an update is shown only from the owner update field and upgrade sends that version', async ({ page }) => { await signIn(page, ADMIN) const candidate = 'sha256:' + 'b'.repeat(64) - const state = await mockAddons(page, { installed: [ - installedAddon({ update: { available: true, state: 'update-available', version: '1.3.0', digest: candidate, checkedAt: '2026-10-01T00:00:00Z' } }), - installedAddon({ id: 'plain', title: 'Plain', version: '9.9.9' }), - ] }) - await page.goto('/app/addons?tab=installed') - await expect(page.locator('[data-addon="notes"]').getByText('Update 1.3.0')).toBeVisible() - await expect(page.locator('[data-addon="plain"]').getByText(/Update/)).toHaveCount(0) - await page.locator('[data-addon="notes"]').getByRole('button', { name: 'Details' }).click() + const admin = { canStart: false, canStop: true, managementUrl: '/app/addons?manage=notes' } + const state = await mockAddons(page, { + apps: [app({ update: 'update-pending', admin, title: 'Notes' }), app({ id: 'plain', title: 'Plain', admin: { ...admin, managementUrl: '/app/addons?manage=plain' } })], + installed: [ + installedAddon({ update: { available: true, state: 'update-available', version: '1.3.0', digest: candidate, checkedAt: '2026-10-01T00:00:00Z' } }), + installedAddon({ id: 'plain', title: 'Plain', version: '9.9.9' }), + ], + }) + await page.goto('/app/addons') + await expect(page.locator('[data-testid="addon-tile"][data-app="notes"]').getByTestId('update-chip')).toHaveText('Update pending') + await expect(page.locator('[data-testid="addon-tile"][data-app="plain"]').getByTestId('update-chip')).toHaveCount(0) + await page.goto('/app/addons?manage=notes') await page.getByTestId('addon-manage').getByRole('button', { name: 'Upgrade' }).click() // The confirmation names the version and digest the owner resolved and // nothing is sent until it is confirmed. @@ -419,97 +475,79 @@ test.describe('Add-ons: installed', () => { expect(sent.confirm).toBe(sent.operationId) expect(state.log.dispatch.filter(d => d.action === 'upgrade')).toHaveLength(0) // An add-on without the field says the status is unknown and has no Upgrade. - await page.goto('/app/addons?tab=installed&manage=plain') + await page.goto('/app/addons?manage=plain') const sheet = page.getByTestId('addon-manage') await expect(sheet.getByText('The update status is not known')).toBeVisible() await expect(sheet.getByRole('button', { name: 'Upgrade' })).toHaveCount(0) }) - test('installed: uninstalling warns that data is kept before anything is submitted', async ({ page }) => { + test('installed: removing warns that data is kept before anything is submitted', async ({ page }) => { await signIn(page, ADMIN) - const state = await mockAddons(page) - await page.goto('/app/addons?tab=installed&manage=notes') - await page.getByTestId('addon-manage').getByRole('button', { name: 'Uninstall' }).click() + const state = await mockAddons(page, details('notes', {}, { title: 'Notes' })) + await page.goto('/app/addons?detail=notes') + await page.getByTestId('addon-detail').getByRole('button', { name: 'Remove' }).click() const dialog = page.getByRole('alertdialog') - await expect(dialog).toContainText('data, identity and grants are kept') + await expect(dialog).toContainText('Remove Notes?') + await expect(dialog).toContainText('Its data is kept') expect(state.log.dispatch.filter(d => d.action === 'uninstall')).toHaveLength(0) await dialog.getByRole('button', { name: 'Cancel' }).click() expect(state.log.dispatch.filter(d => d.action === 'uninstall')).toHaveLength(0) - await page.getByTestId('addon-manage').getByRole('button', { name: 'Uninstall' }).click() - await page.getByRole('alertdialog').getByRole('button', { name: 'Uninstall' }).click() + await page.getByTestId('addon-detail').getByRole('button', { name: 'Remove' }).click() + await page.getByRole('alertdialog').getByRole('button', { name: 'Remove' }).click() await expect.poll(() => state.log.dispatch.filter(d => d.action === 'uninstall').length).toBe(1) }) - test('installed: Escape cancels the uninstall dialog only, then closes the sheet, and focus returns', async ({ page }) => { + test('installed: Escape cancels the remove dialog only, then closes the pane, and focus returns to the card', async ({ page }) => { await signIn(page, ADMIN) - await mockAddons(page) - await page.goto('/app/addons?tab=installed') - const manage = page.locator('[data-addon="notes"]').getByRole('button', { name: 'Details' }) - await manage.click() - const sheet = page.getByTestId('addon-manage') - await expect(sheet).toBeVisible() - const uninstall = sheet.getByRole('button', { name: 'Uninstall' }) - await uninstall.focus() + await mockAddons(page, details('notes', {}, { title: 'Notes' })) + await page.goto('/app/addons') + const card = page.locator('[data-testid="addon-tile"][data-app="notes"] .ad-tile__open') + await card.click() + const pane = page.getByTestId('addon-detail') + await expect(pane).toBeVisible() + const remove = pane.getByRole('button', { name: 'Remove' }) + await remove.focus() await page.keyboard.press('Enter') const dialog = page.getByRole('alertdialog') await expect(dialog).toBeVisible() await page.keyboard.press('Escape') await expect(dialog).toHaveCount(0) - await expect(sheet).toBeVisible() - await expect(uninstall).toBeFocused() + await expect(pane).toBeVisible() + await expect(remove).toBeFocused() await page.keyboard.press('Escape') - await expect(sheet).toHaveCount(0) - await expect(manage).toBeFocused() - }) - - test('installed: an unknown enable blocks other actions on the add-on until it is read', async ({ page }) => { - test.setTimeout(90_000) - await signIn(page, ADMIN) - let phase = 'prepared' - const state = await mockAddons(page, { - installed: [installedAddon({ enabled: false, state: 'stopped' })], - dispatch: (body, st) => { - if (body.action === 'enable' && st.log.dispatch.filter(d => d.action === 'enable').length === 1) { - return fail(503, 'outcome_unknown', { operationId: body.operationId }) - } - if (body.action === 'operation-status') return ok({ OperationID: body.operationId, AddonID: body.addonId, Phase: phase, Generation: 3 }) - return null - }, - }) - await page.goto('/app/addons?tab=installed') - const row = page.locator('[data-addon="notes"]') - await row.getByRole('button', { name: 'Enable' }).click() - const notice = row.getByTestId('unknown-outcome') - await expect(notice).toBeVisible({ timeout: 40_000 }) - await expect(row.getByRole('button', { name: 'Enable' })).toBeDisabled() - phase = 'failed' - await notice.getByRole('button', { name: 'Check status' }).click() - await expect(notice).toHaveCount(0) - await expect(row.getByRole('alert')).toContainText('did not complete') - // The failed operation is over: a new click is a new operation. - await row.getByRole('button', { name: 'Enable' }).click() - await expect.poll(() => state.log.dispatch.filter(d => d.action === 'enable').length).toBe(2) - const ids = state.log.dispatch.filter(d => d.action === 'enable').map(d => d.operationId) - expect(ids[1]).not.toBe(ids[0]) + await expect(pane).toHaveCount(0) + await expect(card).toBeFocused() }) test('installed: a headless add-on keeps its details, health and tools', async ({ page }) => { await signIn(page, ADMIN) - await mockAddons(page, { installed: [installedAddon({ id: 'indexer', title: 'indexer', hasUI: false, mainPage: undefined, icon: undefined, tools: ['query', 'export'], toolCount: 2, state: 'running' })] }) - await page.goto('/app/addons?tab=installed&manage=indexer') + await mockAddons(page, details('indexer', { hasUI: false, mainPage: undefined, icon: undefined, tools: ['query', 'export'], toolCount: 2, state: 'running' }, { hasUI: false, mainPage: undefined, icon: undefined, tools: ['query', 'export'], toolCount: 2, state: 'running', title: 'indexer' })) + await page.goto('/app/addons?manage=indexer') const sheet = page.getByTestId('addon-manage') await expect(sheet.getByText('Headless, no pages')).toBeVisible() await expect(sheet.getByText('2: query, export')).toBeVisible() - await expect(sheet.getByRole('img', { name: 'State: Running' })).toBeVisible() + await expect(sheet.getByRole('img', { name: 'State: On' })).toBeVisible() await expect(sheet.getByRole('link', { name: 'Open' })).toHaveCount(0) }) - test('installed: a refusal from the server is shown even though the tab was offered', async ({ page }) => { + test('installed: a refusal of the installed list leaves the pane without Details', async ({ page }) => { await signIn(page, ADMIN) - await mockAddons(page, { dispatch: (body) => (body.action === 'addons-installed' ? fail(403, 'addon_error') : null) }) + await mockAddons(page, { ...details('notes', {}, { title: 'Notes' }), dispatch: (body) => (body.action === 'addons-installed' ? fail(403, 'addon_error') : null) }) + await page.goto('/app/addons?detail=notes') + await expect(page.getByTestId('addon-detail').getByRole('heading', { name: 'Notes' })).toBeVisible() + await expect(page.getByTestId('preview-source')).toHaveText('Not known') + await expect(page.getByTestId('addon-more')).toHaveCount(0) + }) + + test('installed: an old Installed address lands on My add-ons and the management link opens the details', async ({ page }) => { + await signIn(page, ADMIN) + await mockAddons(page, details('notes', {}, { title: 'Notes' })) await page.goto('/app/addons?tab=installed') - await expect(page.getByRole('alert').filter({ hasText: 'The server refused this' })).toBeVisible() - await expect(page.locator('[data-addon]')).toHaveCount(0) + await expect(tab(page, 'My add-ons')).toHaveAttribute('aria-selected', 'true') + await expect(page.getByRole('tab', { name: 'Installed' })).toHaveCount(0) + await expect(page.getByTestId('addon-tile')).toHaveCount(1) + await page.goto('/app/addons?manage=notes') + await expect(page.getByTestId('addon-more')).toHaveAttribute('open', '') }) }) @@ -551,8 +589,8 @@ test.describe('Add-ons: libraries', () => { // A removal goes through the library route, the same one the other kinds use. const removal = state.log.library.find(c => c.method === 'DELETE') expect(removal.id).toBe('main') - await tab(page, 'Installed').click() - await expect(page.locator('[data-addon="notes"]')).toBeVisible() + await tab(page, 'My add-ons').click() + await expect(page.locator('[data-testid="addon-tile"][data-app="notes"]')).toBeVisible() expect(state.installed).toHaveLength(1) }) @@ -579,20 +617,20 @@ test.describe('Add-ons: access, keyboard, layout and language', () => { await signIn(page, ADMIN) const state = await mockAddons(page, { apps: [app({ admin })] }) await page.goto('/app/addons') - await tab(page, 'Apps').focus() + await tab(page, 'My add-ons').focus() await page.keyboard.press('ArrowRight') - await expect(tab(page, 'Browse')).toBeFocused() - await expect(tab(page, 'Browse')).toHaveAttribute('aria-selected', 'true') + await expect(tab(page, 'Find add-ons')).toBeFocused() + await expect(tab(page, 'Find add-ons')).toHaveAttribute('aria-selected', 'true') await expect(page).toHaveURL(/tab=browse/) await page.keyboard.press('End') - await expect(tab(page, 'Libraries')).toHaveAttribute('aria-selected', 'true') + await expect(tab(page, 'Sources')).toHaveAttribute('aria-selected', 'true') await page.keyboard.press('Home') - await expect(tab(page, 'Apps')).toHaveAttribute('aria-selected', 'true') + await expect(tab(page, 'My add-ons')).toHaveAttribute('aria-selected', 'true') // The selected tab is the only one in the tab order. - await expect(tab(page, 'Browse')).toHaveAttribute('tabindex', '-1') + await expect(tab(page, 'Find add-ons')).toHaveAttribute('tabindex', '-1') // Stop is reachable and operable with the keyboard alone, in the detail sheet. await page.goto('/app/addons?detail=notes') - const stop = detail(page, 'notes').getByRole('button', { name: 'Stop' }) + const stop = detail(page, 'notes').getByRole('button', { name: 'Turn off' }) await stop.focus() await expect(stop).toBeFocused() await page.keyboard.press('Enter') @@ -610,7 +648,7 @@ test.describe('Add-ons: access, keyboard, layout and language', () => { const lefts = await page.locator('.ad-tile').evaluateAll(cards => cards.map(c => Math.round(c.getBoundingClientRect().left))) expect(new Set(lefts).size).toBe(1) await shoot(page, 'apps-narrow') - await page.goto('/app/addons?tab=installed&manage=notes') + await page.goto('/app/addons?manage=notes') await expect(page.getByTestId('addon-manage')).toBeVisible() const sheetOverflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth) expect(sheetOverflow).toBeLessThanOrEqual(0) @@ -623,7 +661,7 @@ test.describe('Add-ons: access, keyboard, layout and language', () => { await page.addInitScript(() => localStorage.setItem('localai-theme', 'light')) await page.goto('/app/addons') await page.goto('/app/addons?detail=notes') - const read = () => page.locator('.ad-card').first().evaluate(el => { + const read = () => page.locator('.ad-pane').first().evaluate(el => { const css = getComputedStyle(el) const chip = getComputedStyle(el.querySelector('.ad-chip')) return { card: css.backgroundColor, chip: chip.color, chipBg: chip.backgroundColor } @@ -643,10 +681,10 @@ test.describe('Add-ons: access, keyboard, layout and language', () => { await mockAddons(page) await page.addInitScript(() => localStorage.setItem('localai-language', 'it')) await page.goto('/app/addons') - await expect(page.getByRole('tab', { name: 'Sfoglia' })).toBeVisible() - await expect(page.getByRole('tab', { name: 'Librerie' })).toBeVisible() + await expect(page.getByRole('tab', { name: 'Trova componenti' })).toBeVisible() + await expect(page.getByRole('tab', { name: 'Fonti' })).toBeVisible() await page.goto('/app/addons?detail=notes') - await expect(detail(page, 'notes').getByRole('img', { name: 'Stato: In esecuzione' })).toBeVisible() + await expect(detail(page, 'notes').getByRole('img', { name: 'Stato: Attivo' })).toBeVisible() }) test('hub: under the legacy switch the Add-ons tab appears only for an account that holds the feature', async ({ page }) => { diff --git a/core/http/react-ui/e2e/addons-recovery.spec.js b/core/http/react-ui/e2e/addons-recovery.spec.js index 3d849062c..29b851b57 100644 --- a/core/http/react-ui/e2e/addons-recovery.spec.js +++ b/core/http/react-ui/e2e/addons-recovery.spec.js @@ -12,7 +12,7 @@ import { randomUUID } from 'node:crypto' const creds = JSON.parse(fs.readFileSync(process.env.ADDONS_FIXTURE, 'utf8')) const BASE = process.env.ADDONS_UI_URL const shots = process.env.ADDONS_SHOTS -const MANAGE = '/app/addons?tab=installed&manage=tables' +const MANAGE = '/app/addons?manage=tables' async function shoot(page, name, target = 'recovery-panel') { if (!shots) return @@ -68,11 +68,11 @@ async function openManage(page) { await expect(page.getByTestId('recovery-panel')).toBeVisible({ timeout: 25_000 }) } -// The sheet's own enable and disable buttons: the real lifecycle. +// The pane's own Turn on and Turn off buttons: the real lifecycle. async function setEnabled(page, enabled) { - const sheet = page.getByTestId('addon-manage') - await sheet.getByRole('button', { name: enabled ? 'Enable' : 'Disable' }).click() - await expect(sheet.getByRole('button', { name: enabled ? 'Disable' : 'Enable' })).toBeVisible({ timeout: 30_000 }) + const sheet = page.getByTestId('addon-detail') + await sheet.getByRole('button', { name: enabled ? 'Turn on' : 'Turn off' }).click() + await expect(sheet.getByRole('button', { name: enabled ? 'Turn off' : 'Turn on' })).toBeVisible({ timeout: 30_000 }) } test.describe.configure({ mode: 'serial' }) @@ -154,7 +154,7 @@ test.describe('Add-on data recovery through the real owner', () => { await expect(dialog).toBeVisible() await expect(dialog).toContainText('Changes made after the backup are lost') await expect(dialog).toContainText('nobody gets back access that was taken away') - await expect(dialog).toContainText('Stop the add-on first') + await expect(dialog).toContainText('Turn the add-on off first') await shoot(admin.page, 'recovery-restore-running-1280', 'recovery-restore-dialog') // Escape closes the dialog and gives the focus back. await admin.page.keyboard.press('Escape') @@ -237,7 +237,7 @@ test.describe('Add-on data recovery through the real owner', () => { await admin.page.getByTestId('recovery-purge').click() const dialog = admin.page.getByTestId('recovery-purge-dialog') await expect(dialog).toBeVisible() - await expect(dialog).toContainText('Stop the add-on first') + await expect(dialog).toContainText('Turn the add-on off first') await admin.page.keyboard.press('Escape') await setEnabled(admin.page, false) await admin.page.getByTestId('recovery-purge').click() diff --git a/core/http/react-ui/e2e/addons-upgrade.spec.js b/core/http/react-ui/e2e/addons-upgrade.spec.js index 5ce3f1c48..23c083b78 100644 --- a/core/http/react-ui/e2e/addons-upgrade.spec.js +++ b/core/http/react-ui/e2e/addons-upgrade.spec.js @@ -13,7 +13,7 @@ import { randomUUID } from 'node:crypto' const creds = JSON.parse(fs.readFileSync(process.env.ADDONS_FIXTURE, 'utf8')) const BASE = process.env.ADDONS_UI_URL const shots = process.env.ADDONS_SHOTS -const MANAGE = '/app/addons?tab=installed&manage=tables' +const MANAGE = '/app/addons?manage=tables' async function shoot(page, name, target, { settle = 350 } = {}) { if (!shots) return @@ -128,8 +128,8 @@ test.describe('Add-on updates and the explicit upgrade through the real owner', await expect(admin.page.getByTestId('update-upgrade')).toBeVisible() await shoot(admin.page, 'updates-available-1280', 'manage-updates') // The installed list carries the badge. - await admin.page.goto('/app/addons?tab=installed') - await expect(admin.page.locator('[data-addon="tables"]').getByText('Update 0.1.0-dev')).toBeVisible({ timeout: 25_000 }) + await admin.page.goto('/app/addons') + await expect(admin.page.locator('[data-testid="addon-tile"][data-app="tables"]').getByTestId('update-chip')).toBeVisible({ timeout: 25_000 }) await shoot(admin.page, 'updates-badge-1280') await admin.context.close() @@ -138,7 +138,7 @@ test.describe('Add-on updates and the explicit upgrade through the real owner', await state.memberPage.goto('/app/addons') const card = state.memberPage.locator('[data-app="tables"]') await expect(card.getByTestId('update-chip')).toBeVisible({ timeout: 25_000 }) - await expect(card.getByRole('img', { name: 'State: Running' })).toBeVisible() + await expect(card.locator('.ad-tile__state')).toHaveText('On') await shoot(state.memberPage, 'updates-pending-chip-1280', 'update-chip') const read = await pageFetch(state.memberPage, '/api/addons/tables/update') expect(read.status).toBe(200) @@ -308,8 +308,9 @@ test.describe('Add-on updates and the explicit upgrade through the real owner', await expect(admin.page.getByTestId('update-times')).toContainText('Library last read') await expect(admin.page.getByTestId('update-upgrade')).toHaveCount(0) await shoot(admin.page, 'updates-check-failed-1280', 'manage-updates') - await admin.page.goto('/app/addons?tab=installed') - await expect(admin.page.locator('[data-addon="tables"]').getByText('Update check failed')).toBeVisible({ timeout: 25_000 }) + await admin.page.goto('/app/addons') + await expect(admin.page.locator('[data-testid="addon-tile"][data-app="tables"]')).toBeVisible({ timeout: 25_000 }) + await expect(admin.page.getByTestId('update-chip')).toHaveCount(0) await admin.context.close() // The member's list shows no mark for a check that failed. await state.memberPage.goto('/app/addons') diff --git a/core/http/react-ui/e2e/chat-addons.spec.js b/core/http/react-ui/e2e/chat-addons.spec.js index c29a5ad03..71970356b 100644 --- a/core/http/react-ui/e2e/chat-addons.spec.js +++ b/core/http/react-ui/e2e/chat-addons.spec.js @@ -2,7 +2,7 @@ import { test, expect } from './coverage-fixtures.js' import { chatOf, pair, mockChat, answerWith, CHATS_KEY } from './chat-fixtures.js' import { MEMBER, app, sessionFor, mockAddons } from './addons-fixtures.js' -// The add-ons control in the chat header lists the add-ons that ship tools, +// The add-ons control in the chat composer, next to the MCP control, lists the add-ons that ship tools, // one switch each, and shows how many are on. The /addons command of the // composer flips all of them and stays in step with it. @@ -57,6 +57,32 @@ test.describe('Chat add-ons control', () => { await expect(page.getByTestId('chat-addons-menu')).toHaveCount(0) }) + test('sits right after the MCP control in the composer, not in the chat header', async ({ page }) => { + await setup(page, [NOTES]) + await openChat(page, [chatOf('c1', 'Place', 'qwen3-8b', pair('hi', 'hello'))]) + await expect(button(page)).toBeVisible() + await expect(page.getByTestId('chat-header').getByTestId('chat-addons')).toHaveCount(0) + const beside = await page.getByTestId('chat-addons').evaluate(el => { + const before = el.previousElementSibling + return Boolean(before && before.classList.contains('chat-mcp-dropdown') && before.textContent.includes('MCP') && el.parentElement === before.parentElement) + }) + expect(beside).toBe(true) + }) + + test('says in one line that an add-on that is off does nothing in this chat', async ({ page }) => { + await setup(page, [NOTES, INDEXER, VIEWER]) + await openChat(page, [chatOf('c1', 'Line', 'qwen3-8b', pair('hi', 'hello'))]) + await button(page).click() + const menu = page.getByTestId('chat-addons-menu') + const note = menu.getByTestId('chat-addons-note') + await expect(note).toHaveText('An add-on that is off here does nothing in this chat.') + // One line: a single text node paragraph, tied to the control by its description. + await expect(button(page)).toHaveAttribute('aria-describedby', await note.getAttribute('id')) + // Only the add-ons that ship tools are listed under it. + await expect(items(page)).toHaveCount(2) + await expect(menu).not.toContainText('Viewer') + }) + test('shows nothing when no add-on ships tools', async ({ page }) => { await setup(page, [VIEWER, STOPPED]) await openChat(page, [chatOf('c1', 'None', 'qwen3-8b', pair('hi', 'hello'))]) @@ -103,7 +129,7 @@ test.describe('Chat add-ons control', () => { await expect(page.getByTestId('chat-addons-count')).toHaveCount(0) }) - test('/addons flips all add-ons and the header control shows it, in step with the switches', async ({ page }) => { + test('/addons flips all add-ons and the composer control shows it, in step with the switches', async ({ page }) => { await setup(page, [NOTES, INDEXER]) await openChat(page, [chatOf('c1', 'Slash', 'qwen3-8b', pair('hi', 'hello'))]) await input(page).fill('/addons') diff --git a/core/http/react-ui/e2e/navigation.spec.js b/core/http/react-ui/e2e/navigation.spec.js index 68752fc9d..74970923d 100644 --- a/core/http/react-ui/e2e/navigation.spec.js +++ b/core/http/react-ui/e2e/navigation.spec.js @@ -20,7 +20,7 @@ test.describe('Navigation', () => { await expect(models.locator('.nav-label')).toHaveText('Models') }) - test('the sidebar has Chat, Models and Apps, no Settings and no Create tier', async ({ page }) => { + test('the sidebar has Chat, Models and Add-ons, no Settings and no Create tier', async ({ page }) => { await page.goto('/app') await expect(page.locator('.sidebar-section-title', { hasText: 'Create' })).toHaveCount(0) for (const href of ['/app/chat', '/app/models', '/app/addons']) { diff --git a/core/http/react-ui/e2e/not-found.spec.js b/core/http/react-ui/e2e/not-found.spec.js index 6c3c7cbb0..2ee6f85a6 100644 --- a/core/http/react-ui/e2e/not-found.spec.js +++ b/core/http/react-ui/e2e/not-found.spec.js @@ -9,7 +9,7 @@ test.describe('404', () => { await expect(nf).toContainText('/app/no-such-page') await expect(nf.getByRole('link', { name: 'Go home' })).toHaveAttribute('href', '/app') const places = nf.getByRole('navigation', { name: 'Where to go' }) - for (const name of ['Home', 'Models', 'Chat', 'Apps', 'Build', 'Operate']) { + for (const name of ['Home', 'Models', 'Chat', 'Add-ons', 'Build', 'Operate']) { await expect(places.getByRole('link', { name })).toBeVisible() } // Settings is a tab of the Operate hub, so the places do not list it. diff --git a/core/http/react-ui/inline-style-baseline.txt b/core/http/react-ui/inline-style-baseline.txt index 960e7a870..c5356ba1e 100644 --- a/core/http/react-ui/inline-style-baseline.txt +++ b/core/http/react-ui/inline-style-baseline.txt @@ -1 +1 @@ -182 +174 diff --git a/core/http/react-ui/public/locales/de/addons.json b/core/http/react-ui/public/locales/de/addons.json index 7c46a4dbe..c840f1096 100644 --- a/core/http/react-ui/public/locales/de/addons.json +++ b/core/http/react-ui/public/locales/de/addons.json @@ -37,13 +37,11 @@ "emptyTitle": "Noch keine Apps", "firstRun": { "browse": "Browse add-ons", - "browseBody": "Pick an add-on from a library and install it.", - "browseTitle": "Browse and install.", - "libraryBody": "The official library is registered by default. You can add more under Libraries.", - "libraryTitle": "Choose a library.", - "startBody": "Start an installed add-on from its tile. Add-ons that were running start again with the server.", - "startTitle": "Start it.", - "title": "Welcome to Apps" + "title": "Welcome to Apps", + "lead": "Add-ons give LocalAI pages and chat tools. Start with one of these, or find more.", + "featuredLabel": "Featured add-ons", + "featuredFailed": "The list of add-ons could not be read.", + "noFeatured": "Your sources do not list an add-on yet. Add a source, then find add-ons." }, "headless": "Läuft im Hintergrund und hat keine eigene Seite.", "listLabel": "Apps", @@ -51,27 +49,26 @@ "manage": "Verwalten", "open": "Öffnen", "stale": "Die Liste konnte nicht aktualisiert werden. Die Anzeige ist möglicherweise veraltet.", - "start": "Starten", - "stop": "Stoppen", + "start": "Einschalten", + "stop": "Ausschalten", "toolsMore_one": "und {{count}} weiteres", "toolsMore_other": "und {{count}} weitere", "tools_one": "{{count}} Werkzeug", "tools_other": "{{count}} Werkzeuge", "version": "Version {{version}}", - "working": "Warte auf die Bestätigung des Betreibers." + "working": "Warte auf die Bestätigung des Betreibers.", + "noMatchTitle": "No add-on matches", + "noMatchBody": "Nothing in My add-ons matches \"{{query}}\". Try other words.", + "clearSearch": "Clear search" }, "browse": { - "confirmBody": "Version {{version}} aus der Bibliothek {{library}} wird heruntergeladen und geprüft, bevor sie installiert wird.", - "confirmNotEnabled": "Die Installation startet das Add-on nicht. Du aktivierst es in einem eigenen Schritt.", "confirmTitle": "{{name}} installieren?", "details": "Details", - "detailsLead": "Bibliothekseintrag für {{id}}", "emptyBody": "Füge im Tab Bibliotheken eine Bibliothek hinzu und aktualisiere sie.", "emptyTitle": "Keine Add-ons gefunden", "fromLibrary": "aus {{library}}", "install": "Installieren", "installedDetail": "Version {{version}}, Generation {{generation}}", - "installedNotEnabled": "Installiert. Es ist nicht aktiviert: Aktiviere es im Tab Installiert, wenn du bereit bist.", "installedOther": "Installiert {{version}} (andere Version)", "installedSame": "Installiert {{version}}", "installedState": "Installiert", @@ -87,7 +84,8 @@ "confirmBackendNote": "A backend runs native code with the rights of LocalAI. Install it only from a library you trust.", "backendFailed": "The backend was not installed.", "alias": "Alias", - "kindLabel": "Kind" + "kindLabel": "Kind", + "installedOn": "{{name}} is installed and on." }, "catalog": { "allLibraries": "Alle Bibliotheken", @@ -117,13 +115,13 @@ "pin": "Pin {{name}}", "pinLabel": "Pin", "pinned": "Pinned", - "running": "Running", + "running": "An", "search": "Search add-ons", "status": { "failed": "failed", "starting": "starting" }, - "stopped": "Stopped", + "stopped": "Aus", "unpin": "Unpin {{name}}" }, "errors": { @@ -135,21 +133,9 @@ "unknownOutcome": "Das Ergebnis konnte nicht bestätigt werden. Prüfe den Status, bevor du es erneut versuchst." }, "installed": { - "disable": "Deaktivieren", - "disabled": "Deaktiviert", - "emptyBody": "Installiere ein Add-on über den Tab Durchsuchen.", - "emptyTitle": "Nichts ist installiert", - "enable": "Aktivieren", - "enabled": "Aktiviert", - "generation": "Generation {{generation}}", - "listLabel": "Installierte Add-ons", - "loading": "Installierte Add-ons werden geladen", - "manage": "Details", - "manageLead": "Verwaltungsansicht von {{id}}", - "missing": "{{id}} ist nicht installiert.", - "uninstall": "Deinstallieren", - "uninstallBody": "Das Add-on wird gestoppt und sein Paket nicht mehr verwendet. Seine Daten, Identität und Berechtigungen bleiben erhalten, eine erneute Installation übernimmt sie. Es wird nichts gelöscht.", - "uninstallTitle": "{{name}} deinstallieren?" + "disabled": "Aus", + "enabled": "An", + "missing": "{{id}} ist nicht installiert." }, "libraries": { "add": "Bibliothek hinzufügen", @@ -169,7 +155,8 @@ "urlCredentials": "Die Adresse darf keinen Benutzernamen und kein Passwort enthalten.", "urlQuery": "Die Adresse darf keinen Query-String und kein Fragment enthalten.", "either": "Use the exact identity or the pattern, not both.", - "urlEmpty": "Enter the address of the index." + "urlEmpty": "Enter the address of the index.", + "mirrors": "Each mirror must start with https://, http://, github:, oci:// or file://, and at most 8 are allowed." }, "fields": { "enabled": "Aktiviert", @@ -182,14 +169,16 @@ "url": "Katalog-URL", "identityRegex": "Signer identity pattern (optional)", "kind": "Kind", - "format": "Index format" + "format": "Index format", + "mirrors": "Mirrors" }, "hints": { "id": "Kleinbuchstaben, Ziffern und Bindestriche. Kann später nicht geändert werden.", "reference": "Der Name einer Zugangsdaten-Konfiguration des Betreibers. Füge hier nie ein Token oder Passwort ein.", "url": "Eine https-Adresse ohne Zugangsdaten und ohne Query-String.", "identityRegex": "An anchored pattern, from ^ to $, used instead of the exact identity. Backend libraries use it so builds from master and from tags both verify.", - "format": "A library index uses the shared library entry shape. A gallery index is a model or backend gallery, as the gallery flags name it." + "format": "A library index uses the shared library entry shape. A gallery index is a model or backend gallery, as the gallery flags name it.", + "mirrors": "Fallback addresses, one per line, tried in order when the main address cannot be fetched." }, "lastRefresh": "Aktualisiert {{when}}, {{count}} Einträge", "lead": "Eine Bibliothek ist ein Katalogdokument, das Add-ons auflistet. Zugangsdaten werden hier nie eingetippt: Eine private Bibliothek nennt eine Zugangsdaten-Referenz, die der Betreiber eingerichtet hat.", @@ -241,7 +230,13 @@ "turnOff": "Turn off", "turnOn": "Turn on", "turnedOff": "Turned off {{name}}.", - "turnedOn": "Turned on {{name}}." + "turnedOn": "Turned on {{name}}.", + "manage": "Manage libraries", + "manageTitle": { + "backend": "Backend libraries", + "model": "Model libraries", + "addon": "Add-on libraries" + } }, "manage": { "api": "Add-on-API", @@ -322,7 +317,18 @@ }, "layer": { "close": "Schließen" - } + }, + "off": { + "title": "{{name}} is off", + "body": "Turn it on to use its page.", + "reader": "An administrator can turn it on." + }, + "notInstalled": { + "title": "{{name}} is not installed", + "body": "Find it in Find add-ons, then install and turn it on.", + "reader": "An administrator can install it." + }, + "failedTitle": "{{name}} failed to start" }, "slots": { "unavailable": "Nicht verfügbar", @@ -332,18 +338,17 @@ "state": { "failed": "Fehlgeschlagen", "label": "Zustand: {{state}}", - "running": "Läuft", + "running": "An", "starting": "Startet", - "stopped": "Gestoppt", + "stopped": "Aus", "update-pending": "Update ausstehend" }, "supporting": "Apps, die dein Betreiber für dich ausführt, und die Bibliotheken, aus denen sie stammen.", "tabs": { - "apps": "Apps", - "browse": "Durchsuchen", - "installed": "Installiert", + "apps": "Meine Add-ons", + "browse": "Add-ons finden", "label": "Bereiche der Add-ons", - "libraries": "Bibliotheken" + "libraries": "Quellen" }, "title": "Add-ons", "trust": { @@ -588,5 +593,64 @@ "network": "Das Netzwerk ist nicht erreichbar.", "unavailable": "Der Add-on-Eigentümer ist nicht verfügbar. Bitte erneut versuchen." } + }, + "install": { + "stepsLabel": "Install steps", + "step": { + "resolving": "Resolve the library entry", + "verifying": "Verify the signature", + "size": "Check the image size", + "fetching": "Fetch the image", + "writing": "Write the metadata" + }, + "state": { + "pending": "Waiting", + "active": "Running", + "done": "Done", + "failed": "Failed" + } + }, + "settingsView": { + "loadFailed": "The library sources could not be read.", + "none": "No library sources of this kind.", + "listLabel": "Library sources", + "mirrors": "Mirrors: {{list}}" + }, + "card": { + "showInSidebar": "In der Seitenleiste zeigen", + "hideFromSidebar": "Remove from sidebar", + "details": "Details", + "remove": "Entfernen", + "menu": "Actions for {{name}}", + "addsPage": "Fügt eine Seite hinzu", + "chatTools_one": "{{count}} chat tool", + "chatTools_other": "{{count}} chat tools", + "openNamed": "Open {{name}}", + "removeTitle": "Remove {{name}}?", + "removeBody": "{{name}} turns off and is removed from this server. Its data is kept, so installing it again picks it up. Nothing is deleted." + }, + "preview": { + "screenshots": "Screenshots", + "adds": "What it adds", + "page": "A page of its own in LocalAI", + "addsNothing": "Nothing you can see. It works in the background.", + "version": "Version", + "id": "Identifier", + "sourceUnknown": "Not known", + "moreDetails": "Health, trust and updates" + }, + "flow": { + "installAndTurnOn": "Installieren und einschalten", + "installing": "Installing {{name}}...", + "turningOn": "Turning on {{name}}...", + "installFailed": "{{name}} was not installed.", + "turnOnFailed": "{{name}} is installed but did not turn on.", + "confirmTitle": "Install and turn on {{name}}?", + "confirmBody": "Version {{version}} from the source {{library}} is downloaded and verified, installed, and turned on.", + "step": { + "install": "Download, verify and install", + "turn-on": "Turn on" + }, + "seeInList": "See it in My add-ons" } } diff --git a/core/http/react-ui/public/locales/de/chat.json b/core/http/react-ui/public/locales/de/chat.json index 83963d5da..ebc248451 100644 --- a/core/http/react-ui/public/locales/de/chat.json +++ b/core/http/react-ui/public/locales/de/chat.json @@ -122,5 +122,13 @@ }, "message": { "you": "You" + }, + "addons": { + "note": "An add-on that is off here does nothing in this chat.", + "label": "Add-ons", + "title": "Add-on tools for this chat", + "heading": "Add-on tools", + "selectAll": "Select all", + "deselectAll": "Deselect all" } } diff --git a/core/http/react-ui/public/locales/de/nav.json b/core/http/react-ui/public/locales/de/nav.json index 5c19954c5..fcf1a84a7 100644 --- a/core/http/react-ui/public/locales/de/nav.json +++ b/core/http/react-ui/public/locales/de/nav.json @@ -61,7 +61,12 @@ "thisMachine": "Dieser Rechner", "failover": "Failover", "p2p": "P2P", - "addons": "Add-ons" + "addons": "Add-ons", + "voiceLibrary": "Voice Library", + "logs": "Logs", + "trafficModels": "Models", + "gpuHost": "GPU and host", + "prometheus": "Prometheus" }, "footer": { "github": "GitHub", @@ -71,7 +76,9 @@ }, "console": { "automation": "Automatisierung", - "training": "Training" + "training": "Training", + "expandNavigation": "Expand {{section}} navigation", + "collapseNavigation": "Collapse {{section}} navigation" }, "hub": { "status": "Status", diff --git a/core/http/react-ui/public/locales/en/addons.json b/core/http/react-ui/public/locales/en/addons.json index d3f4cbba0..27e17d0c2 100644 --- a/core/http/react-ui/public/locales/en/addons.json +++ b/core/http/react-ui/public/locales/en/addons.json @@ -33,50 +33,47 @@ } }, "apps": { - "emptyBody": "When an administrator installs and enables an add-on, it appears here.", - "emptyTitle": "No apps yet", + "emptyBody": "When an administrator installs and turns on an add-on, it appears here.", + "emptyTitle": "No add-ons yet", "firstRun": { - "browse": "Browse add-ons", - "browseBody": "Pick an add-on from a library and install it.", - "browseTitle": "Browse and install.", - "libraryBody": "The official library is registered by default. You can add more under Libraries.", - "libraryTitle": "Choose a library.", - "startBody": "Start an installed add-on from its tile. Add-ons that were running start again with the server.", - "startTitle": "Start it.", - "title": "Welcome to Apps" + "browse": "Find add-ons", + "title": "Add your first add-on", + "lead": "Add-ons give LocalAI pages and chat tools. Start with one of these, or find more.", + "featuredLabel": "Featured add-ons", + "featuredFailed": "The list of add-ons could not be read.", + "noFeatured": "Your sources do not list an add-on yet. Add a source, then find add-ons." }, "headless": "Runs in the background and has no page of its own.", - "listLabel": "Apps", - "loading": "Loading apps", + "listLabel": "My add-ons", + "loading": "Loading add-ons", "manage": "Manage", "open": "Open", "stale": "The list could not be refreshed. What you see may be out of date.", - "start": "Start", - "stop": "Stop", + "start": "Turn on", + "stop": "Turn off", "toolsMore_one": "and {{count}} more", "toolsMore_other": "and {{count}} more", "tools_one": "{{count}} tool", "tools_other": "{{count}} tools", "version": "Version {{version}}", - "working": "Waiting for the owner to confirm." + "working": "Waiting for the owner to confirm.", + "noMatchTitle": "No add-on matches", + "noMatchBody": "Nothing in My add-ons matches \"{{query}}\". Try other words.", + "clearSearch": "Clear search" }, "browse": { - "confirmBody": "Version {{version}} from the library {{library}} will be downloaded and verified before it is installed.", - "confirmNotEnabled": "Installing does not start the add-on. You enable it as a separate step.", "confirmTitle": "Install {{name}}?", "details": "Details", - "detailsLead": "Library entry for {{id}}", - "emptyBody": "Add a library on the Libraries tab, then refresh it.", + "emptyBody": "Add a source on the Sources tab, then refresh it.", "emptyTitle": "No library entries found", "fromLibrary": "from {{library}}", "install": "Install", "installedDetail": "Version {{version}}, generation {{generation}}", - "installedNotEnabled": "Installed. It is not enabled: enable it on the Installed tab when you are ready.", "installedOther": "Installed {{version}} (another version)", "installedSame": "Installed {{version}}", "installedState": "Installed", "installing": "Installing", - "search": "Search the libraries", + "search": "Search add-ons in your sources", "kind": { "addon": "Add-on", "backend": "Backend" @@ -87,7 +84,8 @@ "confirmBackendNote": "A backend runs native code with the rights of LocalAI. Install it only from a library you trust.", "backendFailed": "The backend was not installed.", "alias": "Alias", - "kindLabel": "Kind" + "kindLabel": "Kind", + "installedOn": "{{name}} is installed and on." }, "catalog": { "allLibraries": "All libraries", @@ -106,8 +104,8 @@ "search": "Search" }, "drawer": { - "add": "Add a new add-on", - "browse": "Browse add-ons", + "add": "Find add-ons", + "browse": "Find add-ons", "builtinDetail": "This add-on ships with LocalAI.", "close": "Close", "limitBody": "The sidebar shows at most eight pinned add-ons. Unpin one first.", @@ -117,13 +115,13 @@ "pin": "Pin {{name}}", "pinLabel": "Pin", "pinned": "Pinned", - "running": "Running", + "running": "On", "search": "Search add-ons", "status": { "failed": "failed", "starting": "starting" }, - "stopped": "Stopped", + "stopped": "Off", "unpin": "Unpin {{name}}" }, "errors": { @@ -135,21 +133,9 @@ "unknownOutcome": "The result could not be confirmed. Check the status before you try again." }, "installed": { - "disable": "Disable", - "disabled": "Disabled", - "emptyBody": "Install an add-on from the Browse tab.", - "emptyTitle": "Nothing is installed", - "enable": "Enable", - "enabled": "Enabled", - "generation": "Generation {{generation}}", - "listLabel": "Installed add-ons", - "loading": "Loading installed add-ons", - "manage": "Details", - "manageLead": "Management view of {{id}}", - "missing": "{{id}} is not installed.", - "uninstall": "Uninstall", - "uninstallBody": "The add-on stops and its package is removed from use. Its data, identity and grants are kept, so installing it again picks them up. Nothing is deleted.", - "uninstallTitle": "Uninstall {{name}}?" + "disabled": "Off", + "enabled": "On", + "missing": "{{id}} is not installed." }, "libraries": { "add": "Add a library", @@ -236,8 +222,8 @@ "skipped_one": "{{count}} entry skipped: kind {{kinds}} is not read by this version", "skipped_other": "{{count}} entries skipped: kind {{kinds}} is not read by this version", "state": { - "enabled": "Enabled", - "disabled": "Disabled", + "enabled": "On", + "disabled": "Off", "removed": "Removed", "suspended": "Off (official libraries are off)" }, @@ -287,7 +273,7 @@ "title": "This add-on is not available" }, "reasons": { - "disabled": "The add-on was stopped. Start it again from the Add-ons page.", + "disabled": "The add-on is off. Turn it on from the Add-ons page.", "removed": "The add-on is not installed, or you can no longer use it.", "failed": "The add-on stopped responding. It is restarted automatically; reload to try again.", "updated": "The add-on was updated. Reload to use the new version.", @@ -331,7 +317,18 @@ }, "layer": { "close": "Close" - } + }, + "off": { + "title": "{{name}} is off", + "body": "Turn it on to use its page.", + "reader": "An administrator can turn it on." + }, + "notInstalled": { + "title": "{{name}} is not installed", + "body": "Find it in Find add-ons, then install and turn it on.", + "reader": "An administrator can install it." + }, + "failedTitle": "{{name}} failed to start" }, "slots": { "unavailable": "Unavailable", @@ -341,18 +338,17 @@ "state": { "failed": "Failed", "label": "State: {{state}}", - "running": "Running", + "running": "On", "starting": "Starting", - "stopped": "Stopped", + "stopped": "Off", "update-pending": "Update pending" }, - "supporting": "Apps your owner runs for you, and the libraries they come from.", + "supporting": "Add-ons extend LocalAI with pages and chat tools. Find one, turn it on, and use it.", "tabs": { - "apps": "Apps", - "browse": "Browse", - "installed": "Installed", + "apps": "My add-ons", + "browse": "Find add-ons", "label": "Add-ons sections", - "libraries": "Libraries" + "libraries": "Sources" }, "title": "Add-ons", "trust": { @@ -438,7 +434,7 @@ "purged": "The retained data was purged.", "purgeLead": "Purging removes retained data of this add-on for good. It is not the deletion of a database and not an uninstall.", "purgeTitle": "Purge retained data", - "purgeBody": "This removes what you select below and cannot be undone. The add-on must be uninstalled or disabled.", + "purgeBody": "This removes what you select below and cannot be undone. The add-on must be uninstalled or off.", "scope": "What to remove", "scopeData": "The data and every share of this add-on", "scopeBackups": "All backups ({{count}})", @@ -450,7 +446,7 @@ "warnAccess": "Shares are checked again: nobody gets back access that was taken away since the backup, or whose account is gone.", "warnProposals": "Proposals that were waiting are cancelled. Operations with an unknown outcome are looked up and never applied again.", "warnCopy": "The data that is replaced is kept as a recovery copy until you purge it.", - "stopFirst": "Stop the add-on first: disable it, then restore.", + "stopFirst": "Turn the add-on off first, then restore.", "mapping": "This backup was made in local-owner mode. Account ID that takes its data", "typeToConfirm": "Type {{id}} to confirm", "typeToConfirmLabel": "Type the add-on ID to confirm", @@ -502,7 +498,7 @@ }, "errors": { "needs_recovery": "The data needs recovery first: restore a verified backup or purge it.", - "addon_running": "Stop the add-on first.", + "addon_running": "Turn the add-on off first.", "busy": "Another recovery operation is running for this add-on.", "backup_not_found": "That backup does not exist any more.", "not_installed": "The add-on is not installed.", @@ -619,5 +615,42 @@ "none": "No library sources of this kind.", "listLabel": "Library sources", "mirrors": "Mirrors: {{list}}" + }, + "card": { + "showInSidebar": "Show in sidebar", + "hideFromSidebar": "Remove from sidebar", + "details": "Details", + "remove": "Remove", + "menu": "Actions for {{name}}", + "addsPage": "Adds a page", + "chatTools_one": "{{count}} chat tool", + "chatTools_other": "{{count}} chat tools", + "openNamed": "Open {{name}}", + "removeTitle": "Remove {{name}}?", + "removeBody": "{{name}} turns off and is removed from this server. Its data is kept, so installing it again picks it up. Nothing is deleted." + }, + "preview": { + "screenshots": "Screenshots", + "adds": "What it adds", + "page": "A page of its own in LocalAI", + "addsNothing": "Nothing you can see. It works in the background.", + "version": "Version", + "id": "Identifier", + "sourceUnknown": "Not known", + "moreDetails": "Health, trust and updates" + }, + "flow": { + "installAndTurnOn": "Install and turn on", + "installing": "Installing {{name}}...", + "turningOn": "Turning on {{name}}...", + "installFailed": "{{name}} was not installed.", + "turnOnFailed": "{{name}} is installed but did not turn on.", + "confirmTitle": "Install and turn on {{name}}?", + "confirmBody": "Version {{version}} from the source {{library}} is downloaded and verified, installed, and turned on.", + "step": { + "install": "Download, verify and install", + "turn-on": "Turn on" + }, + "seeInList": "See it in My add-ons" } } diff --git a/core/http/react-ui/public/locales/en/chat.json b/core/http/react-ui/public/locales/en/chat.json index e73a41372..b3f4353e1 100644 --- a/core/http/react-ui/public/locales/en/chat.json +++ b/core/http/react-ui/public/locales/en/chat.json @@ -325,6 +325,7 @@ "title": "Add-on tools for this chat", "heading": "Add-on tools", "selectAll": "Select all", - "deselectAll": "Deselect all" + "deselectAll": "Deselect all", + "note": "An add-on that is off here does nothing in this chat." } } diff --git a/core/http/react-ui/public/locales/en/nav.json b/core/http/react-ui/public/locales/en/nav.json index 673111fba..0e74e971b 100644 --- a/core/http/react-ui/public/locales/en/nav.json +++ b/core/http/react-ui/public/locales/en/nav.json @@ -66,7 +66,7 @@ "trafficModels": "Models", "gpuHost": "GPU and host", "prometheus": "Prometheus", - "addons": "Apps" + "addons": "Add-ons" }, "footer": { "github": "GitHub", @@ -99,7 +99,7 @@ "import": "Add a model from a file, a URL or a Hugging Face repository.", "voices": "Identify speakers and keep a library of voice profiles.", "faces": "Detect, compare and match faces.", - "addons": "Install add-ons and open the apps they provide." + "addons": "Install, turn on and open add-ons." } } } diff --git a/core/http/react-ui/public/locales/es/addons.json b/core/http/react-ui/public/locales/es/addons.json index 8cf122a32..a88124c7e 100644 --- a/core/http/react-ui/public/locales/es/addons.json +++ b/core/http/react-ui/public/locales/es/addons.json @@ -37,13 +37,11 @@ "emptyTitle": "Aún no hay apps", "firstRun": { "browse": "Browse add-ons", - "browseBody": "Pick an add-on from a library and install it.", - "browseTitle": "Browse and install.", - "libraryBody": "The official library is registered by default. You can add more under Libraries.", - "libraryTitle": "Choose a library.", - "startBody": "Start an installed add-on from its tile. Add-ons that were running start again with the server.", - "startTitle": "Start it.", - "title": "Welcome to Apps" + "title": "Welcome to Apps", + "lead": "Add-ons give LocalAI pages and chat tools. Start with one of these, or find more.", + "featuredLabel": "Featured add-ons", + "featuredFailed": "The list of add-ons could not be read.", + "noFeatured": "Your sources do not list an add-on yet. Add a source, then find add-ons." }, "headless": "Se ejecuta en segundo plano y no tiene página propia.", "listLabel": "Apps", @@ -51,27 +49,26 @@ "manage": "Gestionar", "open": "Abrir", "stale": "No se pudo actualizar la lista. Lo que ves puede estar desactualizado.", - "start": "Iniciar", - "stop": "Detener", + "start": "Activar", + "stop": "Desactivar", "toolsMore_one": "y {{count}} más", "toolsMore_other": "y {{count}} más", "tools_one": "{{count}} herramienta", "tools_other": "{{count}} herramientas", "version": "Versión {{version}}", - "working": "Esperando la confirmación del propietario." + "working": "Esperando la confirmación del propietario.", + "noMatchTitle": "No add-on matches", + "noMatchBody": "Nothing in My add-ons matches \"{{query}}\". Try other words.", + "clearSearch": "Clear search" }, "browse": { - "confirmBody": "La versión {{version}} de la biblioteca {{library}} se descargará y verificará antes de instalarse.", - "confirmNotEnabled": "Instalar no inicia el complemento. Lo habilitas en un paso aparte.", "confirmTitle": "¿Instalar {{name}}?", "details": "Detalles", - "detailsLead": "Entrada de biblioteca de {{id}}", "emptyBody": "Añade una biblioteca en la pestaña Bibliotecas y actualízala.", "emptyTitle": "No se encontraron complementos", "fromLibrary": "de {{library}}", "install": "Instalar", "installedDetail": "Versión {{version}}, generación {{generation}}", - "installedNotEnabled": "Instalado. No está habilitado: habilítalo en la pestaña Instalados cuando estés listo.", "installedOther": "Instalado {{version}} (otra versión)", "installedSame": "Instalado {{version}}", "installedState": "Instalado", @@ -87,7 +84,8 @@ "confirmBackendNote": "A backend runs native code with the rights of LocalAI. Install it only from a library you trust.", "backendFailed": "The backend was not installed.", "alias": "Alias", - "kindLabel": "Kind" + "kindLabel": "Kind", + "installedOn": "{{name}} is installed and on." }, "catalog": { "allLibraries": "Todas las bibliotecas", @@ -117,13 +115,13 @@ "pin": "Pin {{name}}", "pinLabel": "Pin", "pinned": "Pinned", - "running": "Running", + "running": "Activado", "search": "Search add-ons", "status": { "failed": "failed", "starting": "starting" }, - "stopped": "Stopped", + "stopped": "Desactivado", "unpin": "Unpin {{name}}" }, "errors": { @@ -135,21 +133,9 @@ "unknownOutcome": "No se pudo confirmar el resultado. Comprueba el estado antes de volver a intentarlo." }, "installed": { - "disable": "Deshabilitar", - "disabled": "Deshabilitado", - "emptyBody": "Instala un complemento desde la pestaña Explorar.", - "emptyTitle": "No hay nada instalado", - "enable": "Habilitar", - "enabled": "Habilitado", - "generation": "Generación {{generation}}", - "listLabel": "Complementos instalados", - "loading": "Cargando complementos instalados", - "manage": "Detalles", - "manageLead": "Vista de gestión de {{id}}", - "missing": "{{id}} no está instalado.", - "uninstall": "Desinstalar", - "uninstallBody": "El complemento se detiene y su paquete deja de usarse. Sus datos, identidad y permisos se conservan, así que al instalarlo de nuevo los recupera. No se elimina nada.", - "uninstallTitle": "¿Desinstalar {{name}}?" + "disabled": "Desactivado", + "enabled": "Activado", + "missing": "{{id}} no está instalado." }, "libraries": { "add": "Añadir una biblioteca", @@ -169,7 +155,8 @@ "urlCredentials": "La dirección no debe contener usuario ni contraseña.", "urlQuery": "La dirección no debe contener cadena de consulta ni fragmento.", "either": "Use the exact identity or the pattern, not both.", - "urlEmpty": "Enter the address of the index." + "urlEmpty": "Enter the address of the index.", + "mirrors": "Each mirror must start with https://, http://, github:, oci:// or file://, and at most 8 are allowed." }, "fields": { "enabled": "Habilitada", @@ -182,14 +169,16 @@ "url": "URL del catálogo", "identityRegex": "Signer identity pattern (optional)", "kind": "Kind", - "format": "Index format" + "format": "Index format", + "mirrors": "Mirrors" }, "hints": { "id": "Letras minúsculas, dígitos y guiones. No se puede cambiar después.", "reference": "El nombre de una credencial que configuró el propietario. Nunca pegues aquí un token ni una contraseña.", "url": "Una dirección https sin credenciales ni cadena de consulta.", "identityRegex": "An anchored pattern, from ^ to $, used instead of the exact identity. Backend libraries use it so builds from master and from tags both verify.", - "format": "A library index uses the shared library entry shape. A gallery index is a model or backend gallery, as the gallery flags name it." + "format": "A library index uses the shared library entry shape. A gallery index is a model or backend gallery, as the gallery flags name it.", + "mirrors": "Fallback addresses, one per line, tried in order when the main address cannot be fetched." }, "lastRefresh": "Actualizada {{when}}, {{count}} entradas", "lead": "Una biblioteca es un documento de catálogo que enumera complementos. Las credenciales nunca se escriben aquí: una biblioteca privada nombra una credencial que configuró el propietario.", @@ -241,7 +230,13 @@ "turnOff": "Turn off", "turnOn": "Turn on", "turnedOff": "Turned off {{name}}.", - "turnedOn": "Turned on {{name}}." + "turnedOn": "Turned on {{name}}.", + "manage": "Manage libraries", + "manageTitle": { + "backend": "Backend libraries", + "model": "Model libraries", + "addon": "Add-on libraries" + } }, "manage": { "api": "API del complemento", @@ -322,7 +317,18 @@ }, "layer": { "close": "Cerrar" - } + }, + "off": { + "title": "{{name}} is off", + "body": "Turn it on to use its page.", + "reader": "An administrator can turn it on." + }, + "notInstalled": { + "title": "{{name}} is not installed", + "body": "Find it in Find add-ons, then install and turn it on.", + "reader": "An administrator can install it." + }, + "failedTitle": "{{name}} failed to start" }, "slots": { "unavailable": "No disponible", @@ -332,18 +338,17 @@ "state": { "failed": "Con error", "label": "Estado: {{state}}", - "running": "En ejecución", + "running": "Activado", "starting": "Iniciando", - "stopped": "Detenido", + "stopped": "Desactivado", "update-pending": "Actualización pendiente" }, "supporting": "Las apps que tu propietario ejecuta por ti y las bibliotecas de las que provienen.", "tabs": { - "apps": "Apps", - "browse": "Explorar", - "installed": "Instalados", + "apps": "Mis complementos", + "browse": "Buscar complementos", "label": "Secciones de complementos", - "libraries": "Bibliotecas" + "libraries": "Fuentes" }, "title": "Complementos", "trust": { @@ -588,5 +593,64 @@ "network": "No se puede acceder a la red.", "unavailable": "El propietario del complemento no está disponible. Inténtalo de nuevo." } + }, + "install": { + "stepsLabel": "Install steps", + "step": { + "resolving": "Resolve the library entry", + "verifying": "Verify the signature", + "size": "Check the image size", + "fetching": "Fetch the image", + "writing": "Write the metadata" + }, + "state": { + "pending": "Waiting", + "active": "Running", + "done": "Done", + "failed": "Failed" + } + }, + "settingsView": { + "loadFailed": "The library sources could not be read.", + "none": "No library sources of this kind.", + "listLabel": "Library sources", + "mirrors": "Mirrors: {{list}}" + }, + "card": { + "showInSidebar": "Mostrar en la barra lateral", + "hideFromSidebar": "Remove from sidebar", + "details": "Detalles", + "remove": "Quitar", + "menu": "Actions for {{name}}", + "addsPage": "Añade una página", + "chatTools_one": "{{count}} chat tool", + "chatTools_other": "{{count}} chat tools", + "openNamed": "Open {{name}}", + "removeTitle": "Remove {{name}}?", + "removeBody": "{{name}} turns off and is removed from this server. Its data is kept, so installing it again picks it up. Nothing is deleted." + }, + "preview": { + "screenshots": "Screenshots", + "adds": "What it adds", + "page": "A page of its own in LocalAI", + "addsNothing": "Nothing you can see. It works in the background.", + "version": "Version", + "id": "Identifier", + "sourceUnknown": "Not known", + "moreDetails": "Health, trust and updates" + }, + "flow": { + "installAndTurnOn": "Instalar y activar", + "installing": "Installing {{name}}...", + "turningOn": "Turning on {{name}}...", + "installFailed": "{{name}} was not installed.", + "turnOnFailed": "{{name}} is installed but did not turn on.", + "confirmTitle": "Install and turn on {{name}}?", + "confirmBody": "Version {{version}} from the source {{library}} is downloaded and verified, installed, and turned on.", + "step": { + "install": "Download, verify and install", + "turn-on": "Turn on" + }, + "seeInList": "See it in My add-ons" } } diff --git a/core/http/react-ui/public/locales/es/chat.json b/core/http/react-ui/public/locales/es/chat.json index d8a73a553..8567dfa1e 100644 --- a/core/http/react-ui/public/locales/es/chat.json +++ b/core/http/react-ui/public/locales/es/chat.json @@ -122,5 +122,13 @@ }, "message": { "you": "You" + }, + "addons": { + "note": "An add-on that is off here does nothing in this chat.", + "label": "Add-ons", + "title": "Add-on tools for this chat", + "heading": "Add-on tools", + "selectAll": "Select all", + "deselectAll": "Deselect all" } } diff --git a/core/http/react-ui/public/locales/es/nav.json b/core/http/react-ui/public/locales/es/nav.json index 546f2d943..048418465 100644 --- a/core/http/react-ui/public/locales/es/nav.json +++ b/core/http/react-ui/public/locales/es/nav.json @@ -61,7 +61,12 @@ "thisMachine": "Esta máquina", "failover": "Failover", "p2p": "P2P", - "addons": "Complementos" + "addons": "Complementos", + "voiceLibrary": "Voice Library", + "logs": "Logs", + "trafficModels": "Models", + "gpuHost": "GPU and host", + "prometheus": "Prometheus" }, "footer": { "github": "GitHub", @@ -71,7 +76,9 @@ }, "console": { "automation": "Automatización", - "training": "Entrenamiento" + "training": "Entrenamiento", + "expandNavigation": "Expand {{section}} navigation", + "collapseNavigation": "Collapse {{section}} navigation" }, "hub": { "status": "Estado", diff --git a/core/http/react-ui/public/locales/fr/addons.json b/core/http/react-ui/public/locales/fr/addons.json index e094a32f2..33f4d828d 100644 --- a/core/http/react-ui/public/locales/fr/addons.json +++ b/core/http/react-ui/public/locales/fr/addons.json @@ -37,13 +37,11 @@ "emptyTitle": "Aucune application pour le moment", "firstRun": { "browse": "Parcourir les modules", - "browseBody": "Choisissez un module dans une bibliothèque et installez-le.", - "browseTitle": "Parcourir et installer.", - "libraryBody": "La bibliothèque officielle est enregistrée par défaut. Vous pouvez en ajouter d'autres dans Bibliothèques.", - "libraryTitle": "Choisir une bibliothèque.", - "startBody": "Démarrez un module installé depuis sa vignette. Les modules qui étaient en cours d'exécution redémarrent avec le serveur.", - "startTitle": "Le démarrer.", - "title": "Bienvenue dans Applications" + "title": "Bienvenue dans Applications", + "lead": "Add-ons give LocalAI pages and chat tools. Start with one of these, or find more.", + "featuredLabel": "Featured add-ons", + "featuredFailed": "The list of add-ons could not be read.", + "noFeatured": "Your sources do not list an add-on yet. Add a source, then find add-ons." }, "headless": "S'exécute en arrière-plan et n'a pas de page propre.", "listLabel": "Applications", @@ -51,8 +49,8 @@ "manage": "Gérer", "open": "Ouvrir", "stale": "La liste n'a pas pu être actualisée. Ce que vous voyez est peut-être périmé.", - "start": "Démarrer", - "stop": "Arrêter", + "start": "Activer", + "stop": "Désactiver", "toolsMore_one": "et {{count}} de plus", "toolsMore_many": "et {{count}} de plus", "toolsMore_other": "et {{count}} de plus", @@ -60,25 +58,36 @@ "tools_many": "{{count}} d'outils", "tools_other": "{{count}} outils", "version": "Version {{version}}", - "working": "En attente de la confirmation du propriétaire." + "working": "En attente de la confirmation du propriétaire.", + "noMatchTitle": "No add-on matches", + "noMatchBody": "Nothing in My add-ons matches \"{{query}}\". Try other words.", + "clearSearch": "Clear search" }, "browse": { - "confirmBody": "La version {{version}} de la bibliothèque {{library}} sera téléchargée et vérifiée avant d'être installée.", - "confirmNotEnabled": "L'installation ne démarre pas le module. Vous l'activez dans une étape distincte.", "confirmTitle": "Installer {{name}} ?", "details": "Détails", - "detailsLead": "Entrée de bibliothèque pour {{id}}", "emptyBody": "Ajoutez une bibliothèque dans l'onglet Bibliothèques, puis actualisez-la.", "emptyTitle": "Aucun module trouvé", "fromLibrary": "depuis {{library}}", "install": "Installer", "installedDetail": "Version {{version}}, génération {{generation}}", - "installedNotEnabled": "Installé. Il n'est pas activé : activez-le dans l'onglet Installés quand vous êtes prêt.", "installedOther": "{{version}} installée (autre version)", "installedSame": "{{version}} installée", "installedState": "Installé", "installing": "Installation en cours", - "search": "Rechercher dans les bibliothèques" + "search": "Rechercher dans les bibliothèques", + "kind": { + "addon": "Add-on", + "backend": "Backend" + }, + "installedBackend": "Installed", + "installedBackendNote": "Installed as a backend on this node. Models can use it now.", + "confirmBackendBody": "Version {{version}} of the backend from the library {{library}} will be downloaded, checked against its digest and signature, and installed on this node. The backend gallery is not changed.", + "confirmBackendNote": "A backend runs native code with the rights of LocalAI. Install it only from a library you trust.", + "backendFailed": "The backend was not installed.", + "alias": "Alias", + "kindLabel": "Kind", + "installedOn": "{{name}} is installed and on." }, "catalog": { "allLibraries": "Toutes les bibliothèques", @@ -109,13 +118,13 @@ "pin": "Épingler {{name}}", "pinLabel": "Épingler", "pinned": "Épinglé", - "running": "En cours d'exécution", + "running": "Activé", "search": "Rechercher des modules", "status": { "failed": "en échec", "starting": "démarrage" }, - "stopped": "Arrêté", + "stopped": "Désactivé", "unpin": "Désépingler {{name}}" }, "errors": { @@ -127,21 +136,9 @@ "unknownOutcome": "Le résultat n'a pas pu être confirmé. Vérifiez l'état avant de réessayer." }, "installed": { - "disable": "Désactiver", "disabled": "Désactivé", - "emptyBody": "Installez un module depuis l'onglet Parcourir.", - "emptyTitle": "Rien n'est installé", - "enable": "Activer", "enabled": "Activé", - "generation": "Génération {{generation}}", - "listLabel": "Modules installés", - "loading": "Chargement des modules installés", - "manage": "Détails", - "manageLead": "Vue de gestion de {{id}}", - "missing": "{{id}} n'est pas installé.", - "uninstall": "Désinstaller", - "uninstallBody": "Le module s'arrête et son paquet n'est plus utilisé. Ses données, son identité et ses autorisations sont conservées : le réinstaller les retrouve. Rien n'est supprimé.", - "uninstallTitle": "Désinstaller {{name}} ?" + "missing": "{{id}} n'est pas installé." }, "libraries": { "add": "Ajouter une bibliothèque", @@ -159,7 +156,10 @@ "required": "Obligatoire lorsqu'une signature est exigée.", "url": "Saisissez une adresse http ou https.", "urlCredentials": "L'adresse ne doit contenir ni nom d'utilisateur ni mot de passe.", - "urlQuery": "L'adresse ne doit contenir ni chaîne de requête ni fragment." + "urlQuery": "L'adresse ne doit contenir ni chaîne de requête ni fragment.", + "either": "Use the exact identity or the pattern, not both.", + "urlEmpty": "Enter the address of the index.", + "mirrors": "Each mirror must start with https://, http://, github:, oci:// or file://, and at most 8 are allowed." }, "fields": { "enabled": "Activée", @@ -169,12 +169,19 @@ "name": "Nom", "reference": "Référence d'identifiant (facultatif)", "requireSignature": "Exiger une signature", - "url": "URL du catalogue" + "url": "URL du catalogue", + "identityRegex": "Signer identity pattern (optional)", + "kind": "Kind", + "format": "Index format", + "mirrors": "Mirrors" }, "hints": { "id": "Minuscules, chiffres et traits d'union. Il ne peut plus changer ensuite.", "reference": "Le nom d'un identifiant configuré par le propriétaire. Ne collez jamais un jeton ou un mot de passe ici.", - "url": "Une adresse https sans identifiants ni chaîne de requête." + "url": "Une adresse https sans identifiants ni chaîne de requête.", + "identityRegex": "An anchored pattern, from ^ to $, used instead of the exact identity. Backend libraries use it so builds from master and from tags both verify.", + "format": "A library index uses the shared library entry shape. A gallery index is a model or backend gallery, as the gallery flags name it.", + "mirrors": "Fallback addresses, one per line, tried in order when the main address cannot be fetched." }, "lastRefresh": "Actualisée {{when}}, {{count}} entrées", "lead": "Une bibliothèque est un document de catalogue qui liste des modules. Les identifiants ne se saisissent jamais ici : une bibliothèque privée nomme un identifiant configuré par le propriétaire.", @@ -190,7 +197,49 @@ "removeTitle": "Retirer {{name}} ?", "removed": "{{name}} retirée.", "save": "Enregistrer", - "saved": "{{name}} enregistrée." + "saved": "{{name}} enregistrée.", + "default": "Default", + "emptyKindBody": "Add a library of this kind, or pick another filter.", + "emptyKindTitle": "No libraries of this kind", + "formats": { + "library": "Library index", + "gallery": "Gallery index" + }, + "gallery": "Gallery index", + "kind": { + "addon": "Add-on", + "backend": "Backend", + "model": "Model" + }, + "kindFilter": "Filter by kind", + "kinds": { + "all": "All", + "addon": "Add-ons", + "backend": "Backends", + "model": "Models" + }, + "officialOff": "Official libraries are off in Settings. The default libraries stay listed, shown as off, and are not read. Libraries you added are not affected.", + "removeDefaultBody": "This is a default library that comes with LocalAI. It is removed from the list and stays removed at the next start. You can restore it from the list. Installed items are not touched.", + "removedDefault": "Removed {{name}}. It stays removed at the next start; restore it to bring it back.", + "restore": "Restore", + "skipped_one": "{{count}} entry skipped: kind {{kinds}} is not read by this version", + "skipped_other": "{{count}} entries skipped: kind {{kinds}} is not read by this version", + "state": { + "enabled": "On", + "disabled": "Off", + "removed": "Removed", + "suspended": "Off (official libraries are off)" + }, + "turnOff": "Turn off", + "turnOn": "Turn on", + "turnedOff": "Turned off {{name}}.", + "turnedOn": "Turned on {{name}}.", + "manage": "Manage libraries", + "manageTitle": { + "backend": "Backend libraries", + "model": "Model libraries", + "addon": "Add-on libraries" + } }, "manage": { "api": "API des modules", @@ -272,7 +321,18 @@ }, "layer": { "close": "Fermer" - } + }, + "off": { + "title": "{{name}} is off", + "body": "Turn it on to use its page.", + "reader": "An administrator can turn it on." + }, + "notInstalled": { + "title": "{{name}} is not installed", + "body": "Find it in Find add-ons, then install and turn it on.", + "reader": "An administrator can install it." + }, + "failedTitle": "{{name}} failed to start" }, "slots": { "unavailable": "Indisponible", @@ -282,18 +342,17 @@ "state": { "failed": "En échec", "label": "État : {{state}}", - "running": "En cours d'exécution", + "running": "Activé", "starting": "Démarrage", - "stopped": "Arrêté", + "stopped": "Désactivé", "update-pending": "Mise à jour en attente" }, "supporting": "Les applications que votre propriétaire exécute pour vous, et les bibliothèques dont elles proviennent.", "tabs": { - "apps": "Applications", - "browse": "Parcourir", - "installed": "Installés", + "apps": "Mes add-ons", + "browse": "Trouver des add-ons", "label": "Sections des modules", - "libraries": "Bibliothèques" + "libraries": "Sources" }, "title": "Modules", "trust": { @@ -538,5 +597,64 @@ "network": "Le réseau est injoignable.", "unavailable": "Le propriétaire du module n'est pas disponible. Réessayez." } + }, + "install": { + "stepsLabel": "Install steps", + "step": { + "resolving": "Resolve the library entry", + "verifying": "Verify the signature", + "size": "Check the image size", + "fetching": "Fetch the image", + "writing": "Write the metadata" + }, + "state": { + "pending": "Waiting", + "active": "Running", + "done": "Done", + "failed": "Failed" + } + }, + "settingsView": { + "loadFailed": "The library sources could not be read.", + "none": "No library sources of this kind.", + "listLabel": "Library sources", + "mirrors": "Mirrors: {{list}}" + }, + "card": { + "showInSidebar": "Afficher dans la barre latérale", + "hideFromSidebar": "Remove from sidebar", + "details": "Détails", + "remove": "Supprimer", + "menu": "Actions for {{name}}", + "addsPage": "Ajoute une page", + "chatTools_one": "{{count}} chat tool", + "chatTools_other": "{{count}} chat tools", + "openNamed": "Open {{name}}", + "removeTitle": "Remove {{name}}?", + "removeBody": "{{name}} turns off and is removed from this server. Its data is kept, so installing it again picks it up. Nothing is deleted." + }, + "preview": { + "screenshots": "Screenshots", + "adds": "What it adds", + "page": "A page of its own in LocalAI", + "addsNothing": "Nothing you can see. It works in the background.", + "version": "Version", + "id": "Identifier", + "sourceUnknown": "Not known", + "moreDetails": "Health, trust and updates" + }, + "flow": { + "installAndTurnOn": "Installer et activer", + "installing": "Installing {{name}}...", + "turningOn": "Turning on {{name}}...", + "installFailed": "{{name}} was not installed.", + "turnOnFailed": "{{name}} is installed but did not turn on.", + "confirmTitle": "Install and turn on {{name}}?", + "confirmBody": "Version {{version}} from the source {{library}} is downloaded and verified, installed, and turned on.", + "step": { + "install": "Download, verify and install", + "turn-on": "Turn on" + }, + "seeInList": "See it in My add-ons" } } diff --git a/core/http/react-ui/public/locales/fr/chat.json b/core/http/react-ui/public/locales/fr/chat.json index 5718ff8d4..0aa5e4cc1 100644 --- a/core/http/react-ui/public/locales/fr/chat.json +++ b/core/http/react-ui/public/locales/fr/chat.json @@ -131,5 +131,13 @@ }, "message": { "you": "Vous" + }, + "addons": { + "note": "An add-on that is off here does nothing in this chat.", + "label": "Add-ons", + "title": "Add-on tools for this chat", + "heading": "Add-on tools", + "selectAll": "Select all", + "deselectAll": "Deselect all" } } diff --git a/core/http/react-ui/public/locales/fr/nav.json b/core/http/react-ui/public/locales/fr/nav.json index 195712b24..566c428ab 100644 --- a/core/http/react-ui/public/locales/fr/nav.json +++ b/core/http/react-ui/public/locales/fr/nav.json @@ -37,7 +37,8 @@ "memory": "Collections de documents que les agents peuvent consulter.", "quantize": "Réduisez un modèle pour l'exécuter sur du matériel plus modeste.", "skills": "Instructions réutilisables que les agents chargent à la demande.", - "voices": "Identifiez les locuteurs et conservez une bibliothèque de profils vocaux." + "voices": "Identifiez les locuteurs et conservez une bibliothèque de profils vocaux.", + "addons": "Install, turn on and open add-ons." } }, "operate": { @@ -89,7 +90,8 @@ "logs": "Journaux", "p2p": "P2P", "prometheus": "Prometheus", - "trafficModels": "Modèles" + "trafficModels": "Modèles", + "addons": "Add-ons" }, "footer": { "github": "GitHub", diff --git a/core/http/react-ui/public/locales/id/addons.json b/core/http/react-ui/public/locales/id/addons.json index d8c89219b..e42492047 100644 --- a/core/http/react-ui/public/locales/id/addons.json +++ b/core/http/react-ui/public/locales/id/addons.json @@ -37,13 +37,11 @@ "emptyTitle": "Belum ada aplikasi", "firstRun": { "browse": "Browse add-ons", - "browseBody": "Pick an add-on from a library and install it.", - "browseTitle": "Browse and install.", - "libraryBody": "The official library is registered by default. You can add more under Libraries.", - "libraryTitle": "Choose a library.", - "startBody": "Start an installed add-on from its tile. Add-ons that were running start again with the server.", - "startTitle": "Start it.", - "title": "Welcome to Apps" + "title": "Welcome to Apps", + "lead": "Add-ons give LocalAI pages and chat tools. Start with one of these, or find more.", + "featuredLabel": "Featured add-ons", + "featuredFailed": "The list of add-ons could not be read.", + "noFeatured": "Your sources do not list an add-on yet. Add a source, then find add-ons." }, "headless": "Berjalan di latar belakang dan tidak punya halaman sendiri.", "listLabel": "Aplikasi", @@ -51,27 +49,26 @@ "manage": "Kelola", "open": "Buka", "stale": "Daftar tidak dapat diperbarui. Yang Anda lihat mungkin sudah usang.", - "start": "Mulai", - "stop": "Hentikan", + "start": "Aktifkan", + "stop": "Nonaktifkan", "toolsMore_one": "dan {{count}} lagi", "toolsMore_other": "dan {{count}} lagi", "tools_one": "{{count}} alat", "tools_other": "{{count}} alat", "version": "Versi {{version}}", - "working": "Menunggu konfirmasi pemilik." + "working": "Menunggu konfirmasi pemilik.", + "noMatchTitle": "No add-on matches", + "noMatchBody": "Nothing in My add-ons matches \"{{query}}\". Try other words.", + "clearSearch": "Clear search" }, "browse": { - "confirmBody": "Versi {{version}} dari pustaka {{library}} akan diunduh dan diverifikasi sebelum dipasang.", - "confirmNotEnabled": "Pemasangan tidak menjalankan add-on. Anda mengaktifkannya sebagai langkah terpisah.", "confirmTitle": "Pasang {{name}}?", "details": "Detail", - "detailsLead": "Entri pustaka untuk {{id}}", "emptyBody": "Tambahkan pustaka di tab Pustaka, lalu segarkan.", "emptyTitle": "Add-on tidak ditemukan", "fromLibrary": "dari {{library}}", "install": "Pasang", "installedDetail": "Versi {{version}}, generasi {{generation}}", - "installedNotEnabled": "Terpasang. Belum diaktifkan: aktifkan di tab Terpasang saat Anda siap.", "installedOther": "Terpasang {{version}} (versi lain)", "installedSame": "Terpasang {{version}}", "installedState": "Terpasang", @@ -87,7 +84,8 @@ "confirmBackendNote": "A backend runs native code with the rights of LocalAI. Install it only from a library you trust.", "backendFailed": "The backend was not installed.", "alias": "Alias", - "kindLabel": "Kind" + "kindLabel": "Kind", + "installedOn": "{{name}} is installed and on." }, "catalog": { "allLibraries": "Semua pustaka", @@ -117,13 +115,13 @@ "pin": "Pin {{name}}", "pinLabel": "Pin", "pinned": "Pinned", - "running": "Running", + "running": "Aktif", "search": "Search add-ons", "status": { "failed": "failed", "starting": "starting" }, - "stopped": "Stopped", + "stopped": "Nonaktif", "unpin": "Unpin {{name}}" }, "errors": { @@ -135,21 +133,9 @@ "unknownOutcome": "Hasil tidak dapat dikonfirmasi. Periksa statusnya sebelum mencoba lagi." }, "installed": { - "disable": "Nonaktifkan", "disabled": "Nonaktif", - "emptyBody": "Pasang add-on dari tab Jelajahi.", - "emptyTitle": "Belum ada yang terpasang", - "enable": "Aktifkan", "enabled": "Aktif", - "generation": "Generasi {{generation}}", - "listLabel": "Add-on terpasang", - "loading": "Memuat add-on terpasang", - "manage": "Detail", - "manageLead": "Tampilan pengelolaan {{id}}", - "missing": "{{id}} tidak terpasang.", - "uninstall": "Copot", - "uninstallBody": "Add-on berhenti dan paketnya tidak lagi dipakai. Data, identitas, dan izinnya dipertahankan, jadi memasangnya lagi akan memakainya kembali. Tidak ada yang dihapus.", - "uninstallTitle": "Copot {{name}}?" + "missing": "{{id}} tidak terpasang." }, "libraries": { "add": "Tambah pustaka", @@ -169,7 +155,8 @@ "urlCredentials": "Alamat tidak boleh berisi nama pengguna atau kata sandi.", "urlQuery": "Alamat tidak boleh berisi query string atau fragmen.", "either": "Use the exact identity or the pattern, not both.", - "urlEmpty": "Enter the address of the index." + "urlEmpty": "Enter the address of the index.", + "mirrors": "Each mirror must start with https://, http://, github:, oci:// or file://, and at most 8 are allowed." }, "fields": { "enabled": "Aktif", @@ -182,14 +169,16 @@ "url": "URL katalog", "identityRegex": "Signer identity pattern (optional)", "kind": "Kind", - "format": "Index format" + "format": "Index format", + "mirrors": "Mirrors" }, "hints": { "id": "Huruf kecil, angka, dan tanda hubung. Tidak dapat diubah nanti.", "reference": "Nama kredensial yang dikonfigurasi pemilik. Jangan pernah menempelkan token atau kata sandi di sini.", "url": "Alamat https tanpa kredensial dan tanpa query string.", "identityRegex": "An anchored pattern, from ^ to $, used instead of the exact identity. Backend libraries use it so builds from master and from tags both verify.", - "format": "A library index uses the shared library entry shape. A gallery index is a model or backend gallery, as the gallery flags name it." + "format": "A library index uses the shared library entry shape. A gallery index is a model or backend gallery, as the gallery flags name it.", + "mirrors": "Fallback addresses, one per line, tried in order when the main address cannot be fetched." }, "lastRefresh": "Disegarkan {{when}}, {{count}} entri", "lead": "Pustaka adalah dokumen katalog yang mendaftar add-on. Kredensial tidak pernah diketik di sini: pustaka privat menyebut kredensial yang dikonfigurasi pemilik.", @@ -241,7 +230,13 @@ "turnOff": "Turn off", "turnOn": "Turn on", "turnedOff": "Turned off {{name}}.", - "turnedOn": "Turned on {{name}}." + "turnedOn": "Turned on {{name}}.", + "manage": "Manage libraries", + "manageTitle": { + "backend": "Backend libraries", + "model": "Model libraries", + "addon": "Add-on libraries" + } }, "manage": { "api": "API add-on", @@ -322,7 +317,18 @@ }, "layer": { "close": "Tutup" - } + }, + "off": { + "title": "{{name}} is off", + "body": "Turn it on to use its page.", + "reader": "An administrator can turn it on." + }, + "notInstalled": { + "title": "{{name}} is not installed", + "body": "Find it in Find add-ons, then install and turn it on.", + "reader": "An administrator can install it." + }, + "failedTitle": "{{name}} failed to start" }, "slots": { "unavailable": "Tidak tersedia", @@ -332,18 +338,17 @@ "state": { "failed": "Gagal", "label": "Status: {{state}}", - "running": "Berjalan", + "running": "Aktif", "starting": "Memulai", - "stopped": "Berhenti", + "stopped": "Nonaktif", "update-pending": "Pembaruan tertunda" }, "supporting": "Aplikasi yang dijalankan pemilik untuk Anda, beserta pustaka asalnya.", "tabs": { - "apps": "Aplikasi", - "browse": "Jelajahi", - "installed": "Terpasang", + "apps": "Add-on saya", + "browse": "Cari add-on", "label": "Bagian add-on", - "libraries": "Pustaka" + "libraries": "Sumber" }, "title": "Add-on", "trust": { @@ -588,5 +593,64 @@ "network": "Jaringan tidak dapat dijangkau.", "unavailable": "Pemilik add-on tidak tersedia. Coba lagi." } + }, + "install": { + "stepsLabel": "Install steps", + "step": { + "resolving": "Resolve the library entry", + "verifying": "Verify the signature", + "size": "Check the image size", + "fetching": "Fetch the image", + "writing": "Write the metadata" + }, + "state": { + "pending": "Waiting", + "active": "Running", + "done": "Done", + "failed": "Failed" + } + }, + "settingsView": { + "loadFailed": "The library sources could not be read.", + "none": "No library sources of this kind.", + "listLabel": "Library sources", + "mirrors": "Mirrors: {{list}}" + }, + "card": { + "showInSidebar": "Tampilkan di bilah samping", + "hideFromSidebar": "Remove from sidebar", + "details": "Detail", + "remove": "Hapus", + "menu": "Actions for {{name}}", + "addsPage": "Menambah satu halaman", + "chatTools_one": "{{count}} chat tool", + "chatTools_other": "{{count}} chat tools", + "openNamed": "Open {{name}}", + "removeTitle": "Remove {{name}}?", + "removeBody": "{{name}} turns off and is removed from this server. Its data is kept, so installing it again picks it up. Nothing is deleted." + }, + "preview": { + "screenshots": "Screenshots", + "adds": "What it adds", + "page": "A page of its own in LocalAI", + "addsNothing": "Nothing you can see. It works in the background.", + "version": "Version", + "id": "Identifier", + "sourceUnknown": "Not known", + "moreDetails": "Health, trust and updates" + }, + "flow": { + "installAndTurnOn": "Pasang dan aktifkan", + "installing": "Installing {{name}}...", + "turningOn": "Turning on {{name}}...", + "installFailed": "{{name}} was not installed.", + "turnOnFailed": "{{name}} is installed but did not turn on.", + "confirmTitle": "Install and turn on {{name}}?", + "confirmBody": "Version {{version}} from the source {{library}} is downloaded and verified, installed, and turned on.", + "step": { + "install": "Download, verify and install", + "turn-on": "Turn on" + }, + "seeInList": "See it in My add-ons" } } diff --git a/core/http/react-ui/public/locales/id/chat.json b/core/http/react-ui/public/locales/id/chat.json index 1cd45b9d6..39a6916e6 100644 --- a/core/http/react-ui/public/locales/id/chat.json +++ b/core/http/react-ui/public/locales/id/chat.json @@ -122,5 +122,13 @@ }, "message": { "you": "You" + }, + "addons": { + "note": "An add-on that is off here does nothing in this chat.", + "label": "Add-ons", + "title": "Add-on tools for this chat", + "heading": "Add-on tools", + "selectAll": "Select all", + "deselectAll": "Deselect all" } } diff --git a/core/http/react-ui/public/locales/id/nav.json b/core/http/react-ui/public/locales/id/nav.json index aa3ac639e..69422ce44 100644 --- a/core/http/react-ui/public/locales/id/nav.json +++ b/core/http/react-ui/public/locales/id/nav.json @@ -62,7 +62,11 @@ "thisMachine": "Mesin ini", "failover": "Failover", "p2p": "P2P", - "addons": "Add-on" + "addons": "Add-on", + "logs": "Logs", + "trafficModels": "Models", + "gpuHost": "GPU and host", + "prometheus": "Prometheus" }, "footer": { "github": "GitHub", @@ -72,7 +76,9 @@ }, "console": { "automation": "Automasi", - "training": "Pelatihan" + "training": "Pelatihan", + "expandNavigation": "Expand {{section}} navigation", + "collapseNavigation": "Collapse {{section}} navigation" }, "hub": { "status": "Status", diff --git a/core/http/react-ui/public/locales/it/addons.json b/core/http/react-ui/public/locales/it/addons.json index 27b18c933..f31a146ea 100644 --- a/core/http/react-ui/public/locales/it/addons.json +++ b/core/http/react-ui/public/locales/it/addons.json @@ -37,13 +37,11 @@ "emptyTitle": "Ancora nessuna app", "firstRun": { "browse": "Browse add-ons", - "browseBody": "Pick an add-on from a library and install it.", - "browseTitle": "Browse and install.", - "libraryBody": "The official library is registered by default. You can add more under Libraries.", - "libraryTitle": "Choose a library.", - "startBody": "Start an installed add-on from its tile. Add-ons that were running start again with the server.", - "startTitle": "Start it.", - "title": "Welcome to Apps" + "title": "Welcome to Apps", + "lead": "Add-ons give LocalAI pages and chat tools. Start with one of these, or find more.", + "featuredLabel": "Featured add-ons", + "featuredFailed": "The list of add-ons could not be read.", + "noFeatured": "Your sources do not list an add-on yet. Add a source, then find add-ons." }, "headless": "Gira in background e non ha una pagina propria.", "listLabel": "App", @@ -51,27 +49,26 @@ "manage": "Gestisci", "open": "Apri", "stale": "Non è stato possibile aggiornare l'elenco. Quanto vedi potrebbe non essere aggiornato.", - "start": "Avvia", - "stop": "Ferma", + "start": "Attiva", + "stop": "Disattiva", "toolsMore_one": "e altri {{count}}", "toolsMore_other": "e altri {{count}}", "tools_one": "{{count}} strumento", "tools_other": "{{count}} strumenti", "version": "Versione {{version}}", - "working": "In attesa della conferma del proprietario." + "working": "In attesa della conferma del proprietario.", + "noMatchTitle": "No add-on matches", + "noMatchBody": "Nothing in My add-ons matches \"{{query}}\". Try other words.", + "clearSearch": "Clear search" }, "browse": { - "confirmBody": "La versione {{version}} della libreria {{library}} verrà scaricata e verificata prima dell'installazione.", - "confirmNotEnabled": "L'installazione non avvia il componente. Lo abiliti in un passaggio separato.", "confirmTitle": "Installare {{name}}?", "details": "Dettagli", - "detailsLead": "Voce di libreria per {{id}}", "emptyBody": "Aggiungi una libreria nella scheda Librerie, poi aggiornala.", "emptyTitle": "Nessun componente trovato", "fromLibrary": "da {{library}}", "install": "Installa", "installedDetail": "Versione {{version}}, generazione {{generation}}", - "installedNotEnabled": "Installato. Non è abilitato: abilitalo nella scheda Installati quando sei pronto.", "installedOther": "Installato {{version}} (altra versione)", "installedSame": "Installato {{version}}", "installedState": "Installato", @@ -87,7 +84,8 @@ "confirmBackendNote": "A backend runs native code with the rights of LocalAI. Install it only from a library you trust.", "backendFailed": "The backend was not installed.", "alias": "Alias", - "kindLabel": "Kind" + "kindLabel": "Kind", + "installedOn": "{{name}} is installed and on." }, "catalog": { "allLibraries": "Tutte le librerie", @@ -117,13 +115,13 @@ "pin": "Pin {{name}}", "pinLabel": "Pin", "pinned": "Pinned", - "running": "Running", + "running": "Attivo", "search": "Search add-ons", "status": { "failed": "failed", "starting": "starting" }, - "stopped": "Stopped", + "stopped": "Disattivato", "unpin": "Unpin {{name}}" }, "errors": { @@ -135,21 +133,9 @@ "unknownOutcome": "Non è stato possibile confermare il risultato. Controlla lo stato prima di riprovare." }, "installed": { - "disable": "Disabilita", - "disabled": "Disabilitato", - "emptyBody": "Installa un componente dalla scheda Sfoglia.", - "emptyTitle": "Nulla è installato", - "enable": "Abilita", - "enabled": "Abilitato", - "generation": "Generazione {{generation}}", - "listLabel": "Componenti installati", - "loading": "Caricamento dei componenti installati", - "manage": "Dettagli", - "manageLead": "Vista di gestione di {{id}}", - "missing": "{{id}} non è installato.", - "uninstall": "Disinstalla", - "uninstallBody": "Il componente si ferma e il suo pacchetto non viene più usato. I suoi dati, la sua identità e i suoi permessi vengono conservati, quindi reinstallarlo li ritrova. Nulla viene eliminato.", - "uninstallTitle": "Disinstallare {{name}}?" + "disabled": "Disattivato", + "enabled": "Attivo", + "missing": "{{id}} non è installato." }, "libraries": { "add": "Aggiungi una libreria", @@ -169,7 +155,8 @@ "urlCredentials": "L'indirizzo non deve contenere nome utente o password.", "urlQuery": "L'indirizzo non deve contenere una stringa di query o un frammento.", "either": "Use the exact identity or the pattern, not both.", - "urlEmpty": "Enter the address of the index." + "urlEmpty": "Enter the address of the index.", + "mirrors": "Each mirror must start with https://, http://, github:, oci:// or file://, and at most 8 are allowed." }, "fields": { "enabled": "Abilitata", @@ -182,14 +169,16 @@ "url": "URL del catalogo", "identityRegex": "Signer identity pattern (optional)", "kind": "Kind", - "format": "Index format" + "format": "Index format", + "mirrors": "Mirrors" }, "hints": { "id": "Lettere minuscole, cifre e trattini. Non può cambiare in seguito.", "reference": "Il nome di una credenziale configurata dal proprietario. Non incollare mai un token o una password qui.", "url": "Un indirizzo https senza credenziali né stringa di query.", "identityRegex": "An anchored pattern, from ^ to $, used instead of the exact identity. Backend libraries use it so builds from master and from tags both verify.", - "format": "A library index uses the shared library entry shape. A gallery index is a model or backend gallery, as the gallery flags name it." + "format": "A library index uses the shared library entry shape. A gallery index is a model or backend gallery, as the gallery flags name it.", + "mirrors": "Fallback addresses, one per line, tried in order when the main address cannot be fetched." }, "lastRefresh": "Aggiornata {{when}}, {{count}} voci", "lead": "Una libreria è un documento di catalogo che elenca componenti. Le credenziali non si digitano mai qui: una libreria privata indica una credenziale configurata dal proprietario.", @@ -241,7 +230,13 @@ "turnOff": "Turn off", "turnOn": "Turn on", "turnedOff": "Turned off {{name}}.", - "turnedOn": "Turned on {{name}}." + "turnedOn": "Turned on {{name}}.", + "manage": "Manage libraries", + "manageTitle": { + "backend": "Backend libraries", + "model": "Model libraries", + "addon": "Add-on libraries" + } }, "manage": { "api": "API del componente", @@ -322,7 +317,18 @@ }, "layer": { "close": "Chiudi" - } + }, + "off": { + "title": "{{name}} is off", + "body": "Turn it on to use its page.", + "reader": "An administrator can turn it on." + }, + "notInstalled": { + "title": "{{name}} is not installed", + "body": "Find it in Find add-ons, then install and turn it on.", + "reader": "An administrator can install it." + }, + "failedTitle": "{{name}} failed to start" }, "slots": { "unavailable": "Non disponibile", @@ -332,18 +338,17 @@ "state": { "failed": "Errore", "label": "Stato: {{state}}", - "running": "In esecuzione", + "running": "Attivo", "starting": "In avvio", - "stopped": "Fermo", + "stopped": "Disattivato", "update-pending": "Aggiornamento in attesa" }, "supporting": "Le app che il proprietario esegue per te e le librerie da cui provengono.", "tabs": { - "apps": "App", - "browse": "Sfoglia", - "installed": "Installati", + "apps": "I miei componenti", + "browse": "Trova componenti", "label": "Sezioni dei componenti aggiuntivi", - "libraries": "Librerie" + "libraries": "Fonti" }, "title": "Componenti aggiuntivi", "trust": { @@ -588,5 +593,64 @@ "network": "La rete non è raggiungibile.", "unavailable": "Il proprietario del componente non è disponibile. Riprova." } + }, + "install": { + "stepsLabel": "Install steps", + "step": { + "resolving": "Resolve the library entry", + "verifying": "Verify the signature", + "size": "Check the image size", + "fetching": "Fetch the image", + "writing": "Write the metadata" + }, + "state": { + "pending": "Waiting", + "active": "Running", + "done": "Done", + "failed": "Failed" + } + }, + "settingsView": { + "loadFailed": "The library sources could not be read.", + "none": "No library sources of this kind.", + "listLabel": "Library sources", + "mirrors": "Mirrors: {{list}}" + }, + "card": { + "showInSidebar": "Mostra nella barra laterale", + "hideFromSidebar": "Remove from sidebar", + "details": "Dettagli", + "remove": "Rimuovi", + "menu": "Actions for {{name}}", + "addsPage": "Aggiunge una pagina", + "chatTools_one": "{{count}} chat tool", + "chatTools_other": "{{count}} chat tools", + "openNamed": "Open {{name}}", + "removeTitle": "Remove {{name}}?", + "removeBody": "{{name}} turns off and is removed from this server. Its data is kept, so installing it again picks it up. Nothing is deleted." + }, + "preview": { + "screenshots": "Screenshots", + "adds": "What it adds", + "page": "A page of its own in LocalAI", + "addsNothing": "Nothing you can see. It works in the background.", + "version": "Version", + "id": "Identifier", + "sourceUnknown": "Not known", + "moreDetails": "Health, trust and updates" + }, + "flow": { + "installAndTurnOn": "Installa e attiva", + "installing": "Installing {{name}}...", + "turningOn": "Turning on {{name}}...", + "installFailed": "{{name}} was not installed.", + "turnOnFailed": "{{name}} is installed but did not turn on.", + "confirmTitle": "Install and turn on {{name}}?", + "confirmBody": "Version {{version}} from the source {{library}} is downloaded and verified, installed, and turned on.", + "step": { + "install": "Download, verify and install", + "turn-on": "Turn on" + }, + "seeInList": "See it in My add-ons" } } diff --git a/core/http/react-ui/public/locales/it/chat.json b/core/http/react-ui/public/locales/it/chat.json index 3d3221d14..a1e575af4 100644 --- a/core/http/react-ui/public/locales/it/chat.json +++ b/core/http/react-ui/public/locales/it/chat.json @@ -122,5 +122,13 @@ }, "message": { "you": "You" + }, + "addons": { + "note": "An add-on that is off here does nothing in this chat.", + "label": "Add-ons", + "title": "Add-on tools for this chat", + "heading": "Add-on tools", + "selectAll": "Select all", + "deselectAll": "Deselect all" } } diff --git a/core/http/react-ui/public/locales/it/nav.json b/core/http/react-ui/public/locales/it/nav.json index 9a3b22c8f..0c5dd9334 100644 --- a/core/http/react-ui/public/locales/it/nav.json +++ b/core/http/react-ui/public/locales/it/nav.json @@ -61,7 +61,12 @@ "thisMachine": "Questa macchina", "failover": "Failover", "p2p": "P2P", - "addons": "Componenti aggiuntivi" + "addons": "Componenti aggiuntivi", + "voiceLibrary": "Voice Library", + "logs": "Logs", + "trafficModels": "Models", + "gpuHost": "GPU and host", + "prometheus": "Prometheus" }, "footer": { "github": "GitHub", @@ -71,7 +76,9 @@ }, "console": { "automation": "Automazione", - "training": "Addestramento" + "training": "Addestramento", + "expandNavigation": "Expand {{section}} navigation", + "collapseNavigation": "Collapse {{section}} navigation" }, "hub": { "status": "Stato", diff --git a/core/http/react-ui/public/locales/ko/addons.json b/core/http/react-ui/public/locales/ko/addons.json index 3620ffdf5..61965e907 100644 --- a/core/http/react-ui/public/locales/ko/addons.json +++ b/core/http/react-ui/public/locales/ko/addons.json @@ -37,13 +37,11 @@ "emptyTitle": "아직 앱이 없습니다", "firstRun": { "browse": "Browse add-ons", - "browseBody": "Pick an add-on from a library and install it.", - "browseTitle": "Browse and install.", - "libraryBody": "The official library is registered by default. You can add more under Libraries.", - "libraryTitle": "Choose a library.", - "startBody": "Start an installed add-on from its tile. Add-ons that were running start again with the server.", - "startTitle": "Start it.", - "title": "Welcome to Apps" + "title": "Welcome to Apps", + "lead": "Add-ons give LocalAI pages and chat tools. Start with one of these, or find more.", + "featuredLabel": "Featured add-ons", + "featuredFailed": "The list of add-ons could not be read.", + "noFeatured": "Your sources do not list an add-on yet. Add a source, then find add-ons." }, "headless": "백그라운드에서 실행되며 자체 페이지가 없습니다.", "listLabel": "앱", @@ -51,27 +49,26 @@ "manage": "관리", "open": "열기", "stale": "목록을 새로 고칠 수 없습니다. 표시된 내용이 오래되었을 수 있습니다.", - "start": "시작", - "stop": "중지", + "start": "켜기", + "stop": "끄기", "toolsMore_one": "and {{count}} more", "toolsMore_other": "외 {{count}}개", "tools_one": "{{count}} tool", "tools_other": "도구 {{count}}개", "version": "버전 {{version}}", - "working": "소유자의 확인을 기다리는 중입니다." + "working": "소유자의 확인을 기다리는 중입니다.", + "noMatchTitle": "No add-on matches", + "noMatchBody": "Nothing in My add-ons matches \"{{query}}\". Try other words.", + "clearSearch": "Clear search" }, "browse": { - "confirmBody": "라이브러리 {{library}}의 {{version}} 버전을 내려받아 검증한 뒤 설치합니다.", - "confirmNotEnabled": "설치해도 애드온은 시작되지 않습니다. 활성화는 별도의 단계입니다.", "confirmTitle": "{{name}}을(를) 설치할까요?", "details": "자세히", - "detailsLead": "{{id}}의 라이브러리 항목", "emptyBody": "라이브러리 탭에서 라이브러리를 추가한 다음 새로 고치세요.", "emptyTitle": "애드온을 찾을 수 없습니다", "fromLibrary": "{{library}}에서", "install": "설치", "installedDetail": "버전 {{version}}, 세대 {{generation}}", - "installedNotEnabled": "설치되었습니다. 아직 활성화되지 않았습니다. 준비되면 설치됨 탭에서 활성화하세요.", "installedOther": "{{version}} 설치됨(다른 버전)", "installedSame": "{{version}} 설치됨", "installedState": "설치됨", @@ -87,7 +84,8 @@ "confirmBackendNote": "A backend runs native code with the rights of LocalAI. Install it only from a library you trust.", "backendFailed": "The backend was not installed.", "alias": "Alias", - "kindLabel": "Kind" + "kindLabel": "Kind", + "installedOn": "{{name}} is installed and on." }, "catalog": { "allLibraries": "모든 라이브러리", @@ -117,13 +115,13 @@ "pin": "Pin {{name}}", "pinLabel": "Pin", "pinned": "Pinned", - "running": "Running", + "running": "켜짐", "search": "Search add-ons", "status": { "failed": "failed", "starting": "starting" }, - "stopped": "Stopped", + "stopped": "꺼짐", "unpin": "Unpin {{name}}" }, "errors": { @@ -135,21 +133,9 @@ "unknownOutcome": "결과를 확인할 수 없습니다. 다시 시도하기 전에 상태를 확인하세요." }, "installed": { - "disable": "비활성화", - "disabled": "비활성화됨", - "emptyBody": "찾아보기 탭에서 애드온을 설치하세요.", - "emptyTitle": "설치된 항목이 없습니다", - "enable": "활성화", - "enabled": "활성화됨", - "generation": "세대 {{generation}}", - "listLabel": "설치된 애드온", - "loading": "설치된 애드온을 불러오는 중", - "manage": "자세히", - "manageLead": "{{id}} 관리 보기", - "missing": "{{id}}은(는) 설치되어 있지 않습니다.", - "uninstall": "제거", - "uninstallBody": "애드온이 중지되고 패키지는 더 이상 사용되지 않습니다. 데이터, 신원, 권한은 보존되므로 다시 설치하면 이어서 사용합니다. 아무것도 삭제되지 않습니다.", - "uninstallTitle": "{{name}}을(를) 제거할까요?" + "disabled": "꺼짐", + "enabled": "켜짐", + "missing": "{{id}}은(는) 설치되어 있지 않습니다." }, "libraries": { "add": "라이브러리 추가", @@ -169,7 +155,8 @@ "urlCredentials": "주소에 사용자 이름이나 비밀번호를 넣을 수 없습니다.", "urlQuery": "주소에 쿼리 문자열이나 프래그먼트를 넣을 수 없습니다.", "either": "Use the exact identity or the pattern, not both.", - "urlEmpty": "Enter the address of the index." + "urlEmpty": "Enter the address of the index.", + "mirrors": "Each mirror must start with https://, http://, github:, oci:// or file://, and at most 8 are allowed." }, "fields": { "enabled": "활성화됨", @@ -182,14 +169,16 @@ "url": "카탈로그 URL", "identityRegex": "Signer identity pattern (optional)", "kind": "Kind", - "format": "Index format" + "format": "Index format", + "mirrors": "Mirrors" }, "hints": { "id": "소문자, 숫자, 하이픈. 나중에 변경할 수 없습니다.", "reference": "소유자가 구성한 자격 증명의 이름. 토큰이나 비밀번호를 여기에 붙여넣지 마세요.", "url": "자격 증명과 쿼리 문자열이 없는 https 주소.", "identityRegex": "An anchored pattern, from ^ to $, used instead of the exact identity. Backend libraries use it so builds from master and from tags both verify.", - "format": "A library index uses the shared library entry shape. A gallery index is a model or backend gallery, as the gallery flags name it." + "format": "A library index uses the shared library entry shape. A gallery index is a model or backend gallery, as the gallery flags name it.", + "mirrors": "Fallback addresses, one per line, tried in order when the main address cannot be fetched." }, "lastRefresh": "{{when}}에 새로 고침, 항목 {{count}}개", "lead": "라이브러리는 애드온을 나열하는 카탈로그 문서입니다. 자격 증명은 여기에 입력하지 않습니다. 비공개 라이브러리는 소유자가 구성한 자격 증명을 이름으로 가리킵니다.", @@ -241,7 +230,13 @@ "turnOff": "Turn off", "turnOn": "Turn on", "turnedOff": "Turned off {{name}}.", - "turnedOn": "Turned on {{name}}." + "turnedOn": "Turned on {{name}}.", + "manage": "Manage libraries", + "manageTitle": { + "backend": "Backend libraries", + "model": "Model libraries", + "addon": "Add-on libraries" + } }, "manage": { "api": "애드온 API", @@ -322,7 +317,18 @@ }, "layer": { "close": "닫기" - } + }, + "off": { + "title": "{{name}} is off", + "body": "Turn it on to use its page.", + "reader": "An administrator can turn it on." + }, + "notInstalled": { + "title": "{{name}} is not installed", + "body": "Find it in Find add-ons, then install and turn it on.", + "reader": "An administrator can install it." + }, + "failedTitle": "{{name}} failed to start" }, "slots": { "unavailable": "사용할 수 없음", @@ -332,18 +338,17 @@ "state": { "failed": "실패", "label": "상태: {{state}}", - "running": "실행 중", + "running": "켜짐", "starting": "시작 중", - "stopped": "중지됨", + "stopped": "꺼짐", "update-pending": "업데이트 대기 중" }, "supporting": "소유자가 대신 실행하는 앱과 그 출처 라이브러리입니다.", "tabs": { - "apps": "앱", - "browse": "찾아보기", - "installed": "설치됨", + "apps": "내 애드온", + "browse": "애드온 찾기", "label": "애드온 섹션", - "libraries": "라이브러리" + "libraries": "소스" }, "title": "애드온", "trust": { @@ -588,5 +593,64 @@ "network": "네트워크에 연결할 수 없습니다.", "unavailable": "애드온 소유자를 사용할 수 없습니다. 다시 시도하세요." } + }, + "install": { + "stepsLabel": "Install steps", + "step": { + "resolving": "Resolve the library entry", + "verifying": "Verify the signature", + "size": "Check the image size", + "fetching": "Fetch the image", + "writing": "Write the metadata" + }, + "state": { + "pending": "Waiting", + "active": "Running", + "done": "Done", + "failed": "Failed" + } + }, + "settingsView": { + "loadFailed": "The library sources could not be read.", + "none": "No library sources of this kind.", + "listLabel": "Library sources", + "mirrors": "Mirrors: {{list}}" + }, + "card": { + "showInSidebar": "사이드바에 표시", + "hideFromSidebar": "Remove from sidebar", + "details": "자세히", + "remove": "제거", + "menu": "Actions for {{name}}", + "addsPage": "페이지를 추가합니다", + "chatTools_one": "{{count}} chat tool", + "chatTools_other": "{{count}} chat tools", + "openNamed": "Open {{name}}", + "removeTitle": "Remove {{name}}?", + "removeBody": "{{name}} turns off and is removed from this server. Its data is kept, so installing it again picks it up. Nothing is deleted." + }, + "preview": { + "screenshots": "Screenshots", + "adds": "What it adds", + "page": "A page of its own in LocalAI", + "addsNothing": "Nothing you can see. It works in the background.", + "version": "Version", + "id": "Identifier", + "sourceUnknown": "Not known", + "moreDetails": "Health, trust and updates" + }, + "flow": { + "installAndTurnOn": "설치하고 켜기", + "installing": "Installing {{name}}...", + "turningOn": "Turning on {{name}}...", + "installFailed": "{{name}} was not installed.", + "turnOnFailed": "{{name}} is installed but did not turn on.", + "confirmTitle": "Install and turn on {{name}}?", + "confirmBody": "Version {{version}} from the source {{library}} is downloaded and verified, installed, and turned on.", + "step": { + "install": "Download, verify and install", + "turn-on": "Turn on" + }, + "seeInList": "See it in My add-ons" } } diff --git a/core/http/react-ui/public/locales/ko/chat.json b/core/http/react-ui/public/locales/ko/chat.json index 5d7063315..7d8859fd5 100644 --- a/core/http/react-ui/public/locales/ko/chat.json +++ b/core/http/react-ui/public/locales/ko/chat.json @@ -122,5 +122,13 @@ }, "message": { "you": "You" + }, + "addons": { + "note": "An add-on that is off here does nothing in this chat.", + "label": "Add-ons", + "title": "Add-on tools for this chat", + "heading": "Add-on tools", + "selectAll": "Select all", + "deselectAll": "Deselect all" } } diff --git a/core/http/react-ui/public/locales/ko/nav.json b/core/http/react-ui/public/locales/ko/nav.json index 48420181e..2cf50a4b8 100644 --- a/core/http/react-ui/public/locales/ko/nav.json +++ b/core/http/react-ui/public/locales/ko/nav.json @@ -61,7 +61,12 @@ "thisMachine": "이 머신", "failover": "페일오버", "p2p": "P2P", - "addons": "애드온" + "addons": "애드온", + "voiceLibrary": "Voice Library", + "logs": "Logs", + "trafficModels": "Models", + "gpuHost": "GPU and host", + "prometheus": "Prometheus" }, "footer": { "github": "GitHub", @@ -71,7 +76,9 @@ }, "console": { "automation": "자동화", - "training": "학습" + "training": "학습", + "expandNavigation": "Expand {{section}} navigation", + "collapseNavigation": "Collapse {{section}} navigation" }, "hub": { "status": "상태", diff --git a/core/http/react-ui/public/locales/pt-BR/addons.json b/core/http/react-ui/public/locales/pt-BR/addons.json index b2e2a5c56..2f187eb8d 100644 --- a/core/http/react-ui/public/locales/pt-BR/addons.json +++ b/core/http/react-ui/public/locales/pt-BR/addons.json @@ -37,13 +37,11 @@ "emptyTitle": "Ainda não há apps", "firstRun": { "browse": "Browse add-ons", - "browseBody": "Pick an add-on from a library and install it.", - "browseTitle": "Browse and install.", - "libraryBody": "The official library is registered by default. You can add more under Libraries.", - "libraryTitle": "Choose a library.", - "startBody": "Start an installed add-on from its tile. Add-ons that were running start again with the server.", - "startTitle": "Start it.", - "title": "Welcome to Apps" + "title": "Welcome to Apps", + "lead": "Add-ons give LocalAI pages and chat tools. Start with one of these, or find more.", + "featuredLabel": "Featured add-ons", + "featuredFailed": "The list of add-ons could not be read.", + "noFeatured": "Your sources do not list an add-on yet. Add a source, then find add-ons." }, "headless": "Roda em segundo plano e não tem página própria.", "listLabel": "Apps", @@ -51,27 +49,26 @@ "manage": "Gerenciar", "open": "Abrir", "stale": "Não foi possível atualizar a lista. O que você vê pode estar desatualizado.", - "start": "Iniciar", - "stop": "Parar", + "start": "Ativar", + "stop": "Desativar", "toolsMore_one": "e mais {{count}}", "toolsMore_other": "e mais {{count}}", "tools_one": "{{count}} ferramenta", "tools_other": "{{count}} ferramentas", "version": "Versão {{version}}", - "working": "Aguardando a confirmação do proprietário." + "working": "Aguardando a confirmação do proprietário.", + "noMatchTitle": "No add-on matches", + "noMatchBody": "Nothing in My add-ons matches \"{{query}}\". Try other words.", + "clearSearch": "Clear search" }, "browse": { - "confirmBody": "A versão {{version}} da biblioteca {{library}} será baixada e verificada antes de ser instalada.", - "confirmNotEnabled": "Instalar não inicia o complemento. Você o habilita em uma etapa separada.", "confirmTitle": "Instalar {{name}}?", "details": "Detalhes", - "detailsLead": "Entrada de biblioteca de {{id}}", "emptyBody": "Adicione uma biblioteca na aba Bibliotecas e depois atualize-a.", "emptyTitle": "Nenhum complemento encontrado", "fromLibrary": "de {{library}}", "install": "Instalar", "installedDetail": "Versão {{version}}, geração {{generation}}", - "installedNotEnabled": "Instalado. Não está habilitado: habilite-o na aba Instalados quando estiver pronto.", "installedOther": "Instalado {{version}} (outra versão)", "installedSame": "Instalado {{version}}", "installedState": "Instalado", @@ -87,7 +84,8 @@ "confirmBackendNote": "A backend runs native code with the rights of LocalAI. Install it only from a library you trust.", "backendFailed": "The backend was not installed.", "alias": "Alias", - "kindLabel": "Kind" + "kindLabel": "Kind", + "installedOn": "{{name}} is installed and on." }, "catalog": { "allLibraries": "Todas as bibliotecas", @@ -117,13 +115,13 @@ "pin": "Pin {{name}}", "pinLabel": "Pin", "pinned": "Pinned", - "running": "Running", + "running": "Ativado", "search": "Search add-ons", "status": { "failed": "failed", "starting": "starting" }, - "stopped": "Stopped", + "stopped": "Desativado", "unpin": "Unpin {{name}}" }, "errors": { @@ -135,21 +133,9 @@ "unknownOutcome": "Não foi possível confirmar o resultado. Verifique o estado antes de tentar de novo." }, "installed": { - "disable": "Desabilitar", - "disabled": "Desabilitado", - "emptyBody": "Instale um complemento pela aba Explorar.", - "emptyTitle": "Nada está instalado", - "enable": "Habilitar", - "enabled": "Habilitado", - "generation": "Geração {{generation}}", - "listLabel": "Complementos instalados", - "loading": "Carregando complementos instalados", - "manage": "Detalhes", - "manageLead": "Visão de gerenciamento de {{id}}", - "missing": "{{id}} não está instalado.", - "uninstall": "Desinstalar", - "uninstallBody": "O complemento para e seu pacote deixa de ser usado. Seus dados, identidade e permissões são mantidos, então instalá-lo de novo os recupera. Nada é excluído.", - "uninstallTitle": "Desinstalar {{name}}?" + "disabled": "Desativado", + "enabled": "Ativado", + "missing": "{{id}} não está instalado." }, "libraries": { "add": "Adicionar uma biblioteca", @@ -169,7 +155,8 @@ "urlCredentials": "O endereço não deve conter nome de usuário nem senha.", "urlQuery": "O endereço não deve conter string de consulta nem fragmento.", "either": "Use the exact identity or the pattern, not both.", - "urlEmpty": "Enter the address of the index." + "urlEmpty": "Enter the address of the index.", + "mirrors": "Each mirror must start with https://, http://, github:, oci:// or file://, and at most 8 are allowed." }, "fields": { "enabled": "Habilitada", @@ -182,14 +169,16 @@ "url": "URL do catálogo", "identityRegex": "Signer identity pattern (optional)", "kind": "Kind", - "format": "Index format" + "format": "Index format", + "mirrors": "Mirrors" }, "hints": { "id": "Letras minúsculas, dígitos e hifens. Não pode mudar depois.", "reference": "O nome de uma credencial que o proprietário configurou. Nunca cole um token ou senha aqui.", "url": "Um endereço https sem credenciais nem string de consulta.", "identityRegex": "An anchored pattern, from ^ to $, used instead of the exact identity. Backend libraries use it so builds from master and from tags both verify.", - "format": "A library index uses the shared library entry shape. A gallery index is a model or backend gallery, as the gallery flags name it." + "format": "A library index uses the shared library entry shape. A gallery index is a model or backend gallery, as the gallery flags name it.", + "mirrors": "Fallback addresses, one per line, tried in order when the main address cannot be fetched." }, "lastRefresh": "Atualizada {{when}}, {{count}} entradas", "lead": "Uma biblioteca é um documento de catálogo que lista complementos. Credenciais nunca são digitadas aqui: uma biblioteca privada indica uma credencial que o proprietário configurou.", @@ -241,7 +230,13 @@ "turnOff": "Turn off", "turnOn": "Turn on", "turnedOff": "Turned off {{name}}.", - "turnedOn": "Turned on {{name}}." + "turnedOn": "Turned on {{name}}.", + "manage": "Manage libraries", + "manageTitle": { + "backend": "Backend libraries", + "model": "Model libraries", + "addon": "Add-on libraries" + } }, "manage": { "api": "API do complemento", @@ -322,7 +317,18 @@ }, "layer": { "close": "Fechar" - } + }, + "off": { + "title": "{{name}} is off", + "body": "Turn it on to use its page.", + "reader": "An administrator can turn it on." + }, + "notInstalled": { + "title": "{{name}} is not installed", + "body": "Find it in Find add-ons, then install and turn it on.", + "reader": "An administrator can install it." + }, + "failedTitle": "{{name}} failed to start" }, "slots": { "unavailable": "Indisponível", @@ -332,18 +338,17 @@ "state": { "failed": "Com falha", "label": "Estado: {{state}}", - "running": "Em execução", + "running": "Ativado", "starting": "Iniciando", - "stopped": "Parado", + "stopped": "Desativado", "update-pending": "Atualização pendente" }, "supporting": "Apps que o proprietário executa para você e as bibliotecas de onde vêm.", "tabs": { - "apps": "Apps", - "browse": "Explorar", - "installed": "Instalados", + "apps": "Meus complementos", + "browse": "Encontrar complementos", "label": "Seções de complementos", - "libraries": "Bibliotecas" + "libraries": "Fontes" }, "title": "Complementos", "trust": { @@ -588,5 +593,64 @@ "network": "A rede não está acessível.", "unavailable": "O proprietário do complemento não está disponível. Tente novamente." } + }, + "install": { + "stepsLabel": "Install steps", + "step": { + "resolving": "Resolve the library entry", + "verifying": "Verify the signature", + "size": "Check the image size", + "fetching": "Fetch the image", + "writing": "Write the metadata" + }, + "state": { + "pending": "Waiting", + "active": "Running", + "done": "Done", + "failed": "Failed" + } + }, + "settingsView": { + "loadFailed": "The library sources could not be read.", + "none": "No library sources of this kind.", + "listLabel": "Library sources", + "mirrors": "Mirrors: {{list}}" + }, + "card": { + "showInSidebar": "Mostrar na barra lateral", + "hideFromSidebar": "Remove from sidebar", + "details": "Detalhes", + "remove": "Remover", + "menu": "Actions for {{name}}", + "addsPage": "Adiciona uma página", + "chatTools_one": "{{count}} chat tool", + "chatTools_other": "{{count}} chat tools", + "openNamed": "Open {{name}}", + "removeTitle": "Remove {{name}}?", + "removeBody": "{{name}} turns off and is removed from this server. Its data is kept, so installing it again picks it up. Nothing is deleted." + }, + "preview": { + "screenshots": "Screenshots", + "adds": "What it adds", + "page": "A page of its own in LocalAI", + "addsNothing": "Nothing you can see. It works in the background.", + "version": "Version", + "id": "Identifier", + "sourceUnknown": "Not known", + "moreDetails": "Health, trust and updates" + }, + "flow": { + "installAndTurnOn": "Instalar e ativar", + "installing": "Installing {{name}}...", + "turningOn": "Turning on {{name}}...", + "installFailed": "{{name}} was not installed.", + "turnOnFailed": "{{name}} is installed but did not turn on.", + "confirmTitle": "Install and turn on {{name}}?", + "confirmBody": "Version {{version}} from the source {{library}} is downloaded and verified, installed, and turned on.", + "step": { + "install": "Download, verify and install", + "turn-on": "Turn on" + }, + "seeInList": "See it in My add-ons" } } 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 4be78db8e..7c7c1ca74 100644 --- a/core/http/react-ui/public/locales/pt-BR/chat.json +++ b/core/http/react-ui/public/locales/pt-BR/chat.json @@ -128,5 +128,13 @@ }, "message": { "you": "Você" + }, + "addons": { + "note": "An add-on that is off here does nothing in this chat.", + "label": "Add-ons", + "title": "Add-on tools for this chat", + "heading": "Add-on tools", + "selectAll": "Select all", + "deselectAll": "Deselect all" } } diff --git a/core/http/react-ui/public/locales/pt-BR/nav.json b/core/http/react-ui/public/locales/pt-BR/nav.json index 8533d8e2e..2bfa43be9 100644 --- a/core/http/react-ui/public/locales/pt-BR/nav.json +++ b/core/http/react-ui/public/locales/pt-BR/nav.json @@ -62,7 +62,11 @@ "thisMachine": "Esta máquina", "failover": "Failover", "p2p": "P2P", - "addons": "Complementos" + "addons": "Complementos", + "logs": "Logs", + "trafficModels": "Models", + "gpuHost": "GPU and host", + "prometheus": "Prometheus" }, "footer": { "github": "GitHub", diff --git a/core/http/react-ui/public/locales/zh-CN/addons.json b/core/http/react-ui/public/locales/zh-CN/addons.json index 81c1a50be..8b21f8da8 100644 --- a/core/http/react-ui/public/locales/zh-CN/addons.json +++ b/core/http/react-ui/public/locales/zh-CN/addons.json @@ -37,13 +37,11 @@ "emptyTitle": "还没有应用", "firstRun": { "browse": "Browse add-ons", - "browseBody": "Pick an add-on from a library and install it.", - "browseTitle": "Browse and install.", - "libraryBody": "The official library is registered by default. You can add more under Libraries.", - "libraryTitle": "Choose a library.", - "startBody": "Start an installed add-on from its tile. Add-ons that were running start again with the server.", - "startTitle": "Start it.", - "title": "Welcome to Apps" + "title": "Welcome to Apps", + "lead": "Add-ons give LocalAI pages and chat tools. Start with one of these, or find more.", + "featuredLabel": "Featured add-ons", + "featuredFailed": "The list of add-ons could not be read.", + "noFeatured": "Your sources do not list an add-on yet. Add a source, then find add-ons." }, "headless": "在后台运行,没有自己的页面。", "listLabel": "应用", @@ -51,27 +49,26 @@ "manage": "管理", "open": "打开", "stale": "无法刷新列表,当前显示的内容可能已过时。", - "start": "启动", - "stop": "停止", + "start": "打开", + "stop": "关闭", "toolsMore_one": "and {{count}} more", "toolsMore_other": "另有 {{count}} 个", "tools_one": "{{count}} tool", "tools_other": "{{count}} 个工具", "version": "版本 {{version}}", - "working": "正在等待所有者确认。" + "working": "正在等待所有者确认。", + "noMatchTitle": "No add-on matches", + "noMatchBody": "Nothing in My add-ons matches \"{{query}}\". Try other words.", + "clearSearch": "Clear search" }, "browse": { - "confirmBody": "将从库 {{library}} 下载 {{version}} 版本,并在安装前进行验证。", - "confirmNotEnabled": "安装不会启动附加组件,启用是单独的一步。", "confirmTitle": "安装 {{name}}?", "details": "详情", - "detailsLead": "{{id}} 的库条目", "emptyBody": "在“库”标签页添加一个库,然后刷新它。", "emptyTitle": "未找到附加组件", "fromLibrary": "来自 {{library}}", "install": "安装", "installedDetail": "版本 {{version}},第 {{generation}} 代", - "installedNotEnabled": "已安装,但尚未启用:准备好后请在“已安装”标签页启用。", "installedOther": "已安装 {{version}}(其他版本)", "installedSame": "已安装 {{version}}", "installedState": "已安装", @@ -87,7 +84,8 @@ "confirmBackendNote": "A backend runs native code with the rights of LocalAI. Install it only from a library you trust.", "backendFailed": "The backend was not installed.", "alias": "Alias", - "kindLabel": "Kind" + "kindLabel": "Kind", + "installedOn": "{{name}} is installed and on." }, "catalog": { "allLibraries": "所有库", @@ -117,13 +115,13 @@ "pin": "Pin {{name}}", "pinLabel": "Pin", "pinned": "Pinned", - "running": "Running", + "running": "已打开", "search": "Search add-ons", "status": { "failed": "failed", "starting": "starting" }, - "stopped": "Stopped", + "stopped": "已关闭", "unpin": "Unpin {{name}}" }, "errors": { @@ -135,21 +133,9 @@ "unknownOutcome": "无法确认结果。请先检查状态,再重试。" }, "installed": { - "disable": "禁用", - "disabled": "已禁用", - "emptyBody": "请在“浏览”标签页安装附加组件。", - "emptyTitle": "尚未安装任何内容", - "enable": "启用", - "enabled": "已启用", - "generation": "第 {{generation}} 代", - "listLabel": "已安装的附加组件", - "loading": "正在加载已安装的附加组件", - "manage": "详情", - "manageLead": "{{id}} 的管理视图", - "missing": "{{id}} 未安装。", - "uninstall": "卸载", - "uninstallBody": "附加组件将停止,其软件包不再使用。其数据、身份和授权会保留,再次安装时会沿用。不会删除任何内容。", - "uninstallTitle": "卸载 {{name}}?" + "disabled": "已关闭", + "enabled": "已打开", + "missing": "{{id}} 未安装。" }, "libraries": { "add": "添加库", @@ -169,7 +155,8 @@ "urlCredentials": "地址不能包含用户名或密码。", "urlQuery": "地址不能包含查询字符串或片段。", "either": "Use the exact identity or the pattern, not both.", - "urlEmpty": "Enter the address of the index." + "urlEmpty": "Enter the address of the index.", + "mirrors": "Each mirror must start with https://, http://, github:, oci:// or file://, and at most 8 are allowed." }, "fields": { "enabled": "已启用", @@ -182,14 +169,16 @@ "url": "目录 URL", "identityRegex": "Signer identity pattern (optional)", "kind": "Kind", - "format": "Index format" + "format": "Index format", + "mirrors": "Mirrors" }, "hints": { "id": "小写字母、数字和连字符,之后不能更改。", "reference": "所有者已配置的凭据名称。切勿在此粘贴令牌或密码。", "url": "不含凭据和查询字符串的 https 地址。", "identityRegex": "An anchored pattern, from ^ to $, used instead of the exact identity. Backend libraries use it so builds from master and from tags both verify.", - "format": "A library index uses the shared library entry shape. A gallery index is a model or backend gallery, as the gallery flags name it." + "format": "A library index uses the shared library entry shape. A gallery index is a model or backend gallery, as the gallery flags name it.", + "mirrors": "Fallback addresses, one per line, tried in order when the main address cannot be fetched." }, "lastRefresh": "{{when}} 已刷新,{{count}} 个条目", "lead": "库是列出附加组件的目录文档。凭据绝不在此输入:私有库只引用所有者配置的凭据。", @@ -241,7 +230,13 @@ "turnOff": "Turn off", "turnOn": "Turn on", "turnedOff": "Turned off {{name}}.", - "turnedOn": "Turned on {{name}}." + "turnedOn": "Turned on {{name}}.", + "manage": "Manage libraries", + "manageTitle": { + "backend": "Backend libraries", + "model": "Model libraries", + "addon": "Add-on libraries" + } }, "manage": { "api": "附加组件 API", @@ -322,7 +317,18 @@ }, "layer": { "close": "关闭" - } + }, + "off": { + "title": "{{name}} is off", + "body": "Turn it on to use its page.", + "reader": "An administrator can turn it on." + }, + "notInstalled": { + "title": "{{name}} is not installed", + "body": "Find it in Find add-ons, then install and turn it on.", + "reader": "An administrator can install it." + }, + "failedTitle": "{{name}} failed to start" }, "slots": { "unavailable": "不可用", @@ -332,18 +338,17 @@ "state": { "failed": "失败", "label": "状态:{{state}}", - "running": "运行中", + "running": "已打开", "starting": "启动中", - "stopped": "已停止", + "stopped": "已关闭", "update-pending": "等待更新" }, "supporting": "所有者为你运行的应用,以及它们的来源库。", "tabs": { - "apps": "应用", - "browse": "浏览", - "installed": "已安装", + "apps": "我的附加组件", + "browse": "查找附加组件", "label": "附加组件分区", - "libraries": "库" + "libraries": "来源" }, "title": "附加组件", "trust": { @@ -588,5 +593,64 @@ "network": "无法连接到网络。", "unavailable": "附加组件所有者不可用。请重试。" } + }, + "install": { + "stepsLabel": "Install steps", + "step": { + "resolving": "Resolve the library entry", + "verifying": "Verify the signature", + "size": "Check the image size", + "fetching": "Fetch the image", + "writing": "Write the metadata" + }, + "state": { + "pending": "Waiting", + "active": "Running", + "done": "Done", + "failed": "Failed" + } + }, + "settingsView": { + "loadFailed": "The library sources could not be read.", + "none": "No library sources of this kind.", + "listLabel": "Library sources", + "mirrors": "Mirrors: {{list}}" + }, + "card": { + "showInSidebar": "在侧边栏中显示", + "hideFromSidebar": "Remove from sidebar", + "details": "详情", + "remove": "移除", + "menu": "Actions for {{name}}", + "addsPage": "添加一个页面", + "chatTools_one": "{{count}} chat tool", + "chatTools_other": "{{count}} chat tools", + "openNamed": "Open {{name}}", + "removeTitle": "Remove {{name}}?", + "removeBody": "{{name}} turns off and is removed from this server. Its data is kept, so installing it again picks it up. Nothing is deleted." + }, + "preview": { + "screenshots": "Screenshots", + "adds": "What it adds", + "page": "A page of its own in LocalAI", + "addsNothing": "Nothing you can see. It works in the background.", + "version": "Version", + "id": "Identifier", + "sourceUnknown": "Not known", + "moreDetails": "Health, trust and updates" + }, + "flow": { + "installAndTurnOn": "安装并打开", + "installing": "Installing {{name}}...", + "turningOn": "Turning on {{name}}...", + "installFailed": "{{name}} was not installed.", + "turnOnFailed": "{{name}} is installed but did not turn on.", + "confirmTitle": "Install and turn on {{name}}?", + "confirmBody": "Version {{version}} from the source {{library}} is downloaded and verified, installed, and turned on.", + "step": { + "install": "Download, verify and install", + "turn-on": "Turn on" + }, + "seeInList": "See it in My add-ons" } } 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 71a6b5590..56381d065 100644 --- a/core/http/react-ui/public/locales/zh-CN/chat.json +++ b/core/http/react-ui/public/locales/zh-CN/chat.json @@ -122,5 +122,13 @@ }, "message": { "you": "You" + }, + "addons": { + "note": "An add-on that is off here does nothing in this chat.", + "label": "Add-ons", + "title": "Add-on tools for this chat", + "heading": "Add-on tools", + "selectAll": "Select all", + "deselectAll": "Deselect all" } } diff --git a/core/http/react-ui/public/locales/zh-CN/nav.json b/core/http/react-ui/public/locales/zh-CN/nav.json index a26c1293d..700107889 100644 --- a/core/http/react-ui/public/locales/zh-CN/nav.json +++ b/core/http/react-ui/public/locales/zh-CN/nav.json @@ -61,7 +61,12 @@ "thisMachine": "本机", "failover": "故障转移", "p2p": "P2P", - "addons": "附加组件" + "addons": "附加组件", + "voiceLibrary": "Voice Library", + "logs": "Logs", + "trafficModels": "Models", + "gpuHost": "GPU and host", + "prometheus": "Prometheus" }, "footer": { "github": "GitHub", @@ -71,7 +76,9 @@ }, "console": { "automation": "自动化", - "training": "训练" + "training": "训练", + "expandNavigation": "Expand {{section}} navigation", + "collapseNavigation": "Collapse {{section}} navigation" }, "hub": { "status": "状态", diff --git a/core/http/react-ui/src/addons/AddonRoute.jsx b/core/http/react-ui/src/addons/AddonRoute.jsx index df4ba68c9..d005b95c6 100644 --- a/core/http/react-ui/src/addons/AddonRoute.jsx +++ b/core/http/react-ui/src/addons/AddonRoute.jsx @@ -6,7 +6,12 @@ import PageHeader from '../components/PageHeader' import CatalogEmpty from '../components/catalog/CatalogEmpty' import LoadingSpinner from '../components/LoadingSpinner' import ConfirmDialog from '../components/ConfirmDialog' -import { addonDisplayName, localizedText } from '../components/addons/addonState' +import Icon from '../components/Icon' +import { useAuth } from '../context/AuthContext' +import { addonsApi } from '../utils/addonsClient' +import { addonDisplayName, appActions, localizedText } from '../components/addons/addonState' +import { describeOutcome } from '../components/addons/errors' +import { useAddonActions } from '../components/addons/useAddonActions' import AddonErrorBoundary from './AddonErrorBoundary' import { useAddonRegistry } from './useAddonRegistry' import { appStatus } from './registryState' @@ -45,7 +50,14 @@ export default function AddonRoute() { const app = registry.apps.find(a => a.id === addonId) || null const instance = registry.instances.find(i => i.id === addonId) || null const status = appStatus(app) - const title = (app && localizedText(app, 'title', i18n.language)) || addonDisplayName(addonId) + // The add-on's own name. A list entry with no title carries its id as the + // title, and an add-on that is not listed has only its id: both say the id + // with a capital first letter, never the bare identifier. + const listedTitle = app ? localizedText(app, 'title', i18n.language) : '' + const title = listedTitle && listedTitle !== addonId ? listedTitle : addonDisplayName(addonId) + const { isAdmin } = useAuth() + // Turning on an add-on from this page is the same action as on My add-ons. + const actions = useAddonActions(addonsApi, { onSettled: () => { refresh() } }) const pages = app && app.ui ? app.ui.pages || [] : [] const subpath = splat || '' const resolved = subpath ? resolveSubpath(subpath, pages) : null @@ -135,19 +147,74 @@ export default function AddonRoute() { // enabled, failed, no UI, bundle not loaded, no such page). Once the page // mounts it draws its own title, even to say it is not enabled on this // server, and the shell draws none. - const unavailable = (reason, extra) => ( -
- - -
+ // What the page says depends on what the add-on is: off (installed and + // turned off), not installed, or failed. Anything else keeps the generic + // title and the reason of the shell. + const unavailable = (reason, extra) => { + const known = REASONS.includes(reason) ? reason : 'import_failed' + let heading = t('shell.unavailable.title') + let text = t(`shell.reasons.${known}`) + let acts = extra + let state = '' + const control = appActions(app) + if (known === 'disabled' && app) { + state = 'off' + heading = t('shell.off.title', { name: title }) + text = control.start ? t('shell.off.body') : t('shell.off.reader') + const startKey = `start:${addonId}:${app.generation}` + const working = actions.state[startKey]?.phase === 'working' + const outcome = actions.state[startKey] + acts = ( + <> + {control.start && ( + + )} + {outcome && outcome.outcome && describeOutcome(outcome.outcome, t, outcome.error) && ( + {describeOutcome(outcome.outcome, t, outcome.error)} + )} + + ) + } else if (known === 'removed' && !app && registry.allowed) { + state = 'not-installed' + heading = t('shell.notInstalled.title', { name: title }) + text = isAdmin ? t('shell.notInstalled.body') : t('shell.notInstalled.reader') + acts = isAdmin ? {t('drawer.browse')} : null + } else if (known === 'failed') { + state = 'failed' + heading = t('shell.failedTitle', { name: title }) + acts = ( + <> {extra} - {back} -
-
- {leaveDialog} - {reloadDialog} -
- ) + {isAdmin && {t('card.details')}} + + ) + } + return ( +
+ + +
+ {acts} + {back} +
+
+ {leaveDialog} + {reloadDialog} +
+ ) + } const reloadButton = ( + + {app ? {children} :

{t('drawer.builtinDetail')}

} + + + ) +} + +function PreviewBody({ app, language, actions, onStartStop, onRemove, isAdmin, source, links, children }) { + const { t } = useTranslation('addons') + const description = localizedText(app, 'description', language) + const added = addedByAddon(app) + const shots = screenshotsOf(app) + const control = appActions(app) + const startKey = `start:${app.id}:${app.generation}` + const stopKey = `stop:${app.id}:${app.generation}` + const pending = Boolean(actions.unknown[app.id]) + const working = actions.state[startKey]?.phase === 'working' || actions.state[stopKey]?.phase === 'working' + const locked = working || pending + const result = actions.state[startKey]?.phase !== 'working' ? actions.state[startKey] : null + const stopResult = actions.state[stopKey]?.phase !== 'working' ? actions.state[stopKey] : null + const outcome = result || stopResult + return ( +
+ {description &&

{description}

} + {shots.length > 0 && ( + + )} +
+

{t('preview.adds')}

+ + {!added.page &&

{t('apps.headless')}

} + {links.length > 0 && ( +
+ {links.map(link => {link.label})} +
+ )} +
+
+ {app.mainPage && ( + + {t('apps.open')} + + )} + {control.start && ( + + )} + {control.stop && ( + + )} + {onRemove && ( + + )} +
+ {working &&

{t('apps.working')}

} + {pending + ? + : outcome && outcome.outcome && describeOutcome(outcome.outcome, t, outcome.error) && ( +

{describeOutcome(outcome.outcome, t, outcome.error)}

+ )} +
+
{t('preview.version')}
{app.version}
+ {isAdmin && <>
{t('trust.library')}
{source ? `${source.sourceId || '—'}${source.channel ? ` · ${source.channel}` : ''}` : t('preview.sourceUnknown')}
} +
{t('preview.id')}
{app.id}
+
+ {children} +
+ ) +} diff --git a/core/http/react-ui/src/components/addons/AddonTile.jsx b/core/http/react-ui/src/components/addons/AddonTile.jsx index f6f0882b7..391027335 100644 --- a/core/http/react-ui/src/components/addons/AddonTile.jsx +++ b/core/http/react-ui/src/components/addons/AddonTile.jsx @@ -3,41 +3,63 @@ import { Link } from 'react-router-dom' import { useTranslation } from 'react-i18next' import Icon from '../Icon' import { apiUrl } from '../../utils/basePath' -import { AppIcon } from './AddonBits' -import PinControl from './PinControl' +import { AppIcon, UpdateChip } from './AddonBits' +import AddonMenu from './AddonMenu' +import { addedByAddon } from './addonState' -// One tile of the drawer: icon, name, state and the pin control. A running -// tile opens the add-on; every other tile opens the detail view. The pin -// control is a real button with a name and a state. -export default function AddonTile({ tile, pinned, onPin, onMove, onOpenDetail }) { +// One add-on card: icon, name, state, what it does and what it adds. The card +// body selects the add-on (its preview opens); a running add-on with a page +// also has an Open link. Pinning, reordering, details and removal sit in the +// overflow menu, so nothing on the card is a bare icon. +export default function AddonTile({ tile, pinned, selected = false, onPin, onMove, onOpenDetail, onRemove }) { const { t } = useTranslation('addons') const { t: tn } = useTranslation('nav') const title = tile.titleKey ? tn(tile.titleKey) : tile.title const stateText = tile.state === 'running' ? t('drawer.running') : t('drawer.stopped') const line = tile.statusLine ? t(`drawer.status.${tile.statusLine}`) : '' - const body = ( - <> - {tile.source === 'builtin' - ? - : } - - {title} - {stateText}{line ? `, ${line}` : ''} - - - ) + const added = tile.app ? addedByAddon(tile.app) : { page: tile.source === 'builtin', tools: 0 } return ( -
  • - {tile.target - ? {body} - : } - {pinned && ( - - - +
  • + +
    + onPin(tile.id)} + onMove={pinned ? (delta) => onMove(tile.id, delta) : undefined} + onDetails={() => onOpenDetail(tile.id)} + onRemove={onRemove && tile.app ? () => onRemove(tile) : undefined} + /> +
    + {tile.target && ( +
    + + {t('apps.open')} + +
    )} - onPin(tile.id)} />
  • ) } diff --git a/core/http/react-ui/src/components/addons/AppsView.jsx b/core/http/react-ui/src/components/addons/AppsView.jsx index 466c93a82..5cec07da8 100644 --- a/core/http/react-ui/src/components/addons/AppsView.jsx +++ b/core/http/react-ui/src/components/addons/AppsView.jsx @@ -11,15 +11,20 @@ import { navigationItems } from '../../addons/registryState' import { useAuth } from '../../context/AuthContext' import { FEATURE_ADDONS } from '../../utils/capabilities' import SideSheet from '../SideSheet' +import AddonPreview from './AddonPreview' import AddonSlot from '../../addons/AddonSlot' import { useHubAuth } from '../hub/useHubAuth' import AddonTile from './AddonTile' -import PinControl from './PinControl' +import ConfirmDialog from '../ConfirmDialog' import { useNavigation } from '../../context/NavigationContext' import { buildTiles, filterTiles, resolvePins } from '../../utils/addonTiles' -import { apiUrl } from '../../utils/basePath' -import { StateChip, AppIcon, UnknownNotice, UpdateChip } from './AddonBits' -import { appActions, localizedText, showFirstRun, toolSummary } from './addonState' +import { UnknownNotice } from './AddonBits' +import { OperatorFacts, UpgradeSummary } from './OperatorFacts' +import { AddonsError } from '../../utils/addonsApi' +import { newOperationId } from '../catalog/catalogState' +import { updateView, upgradeBody } from './updateState' +import FirstRun from './FirstRun' +import { listState } from './addonState' import { describeError, describeOutcome } from './errors' import { useAddonActions } from './useAddonActions' @@ -28,7 +33,7 @@ import { useAddonActions } from './useAddonActions' // cadence, slower in a hidden tab and backing off while the owner is // unreachable. Start and stop are offered to administrators and the server // refuses everyone else whatever this view shows. -export default function AppsView({ api }) { +export default function AppsView({ api, adapter }) { const { t, i18n } = useTranslation('addons') const [state, setState] = useState({ status: 'loading', apps: [], error: null, stale: false }) const [reload, setReload] = useState(0) @@ -82,7 +87,24 @@ export default function AppsView({ api }) { return () => { alive = false; ctl.abort(); clearTimeout(timer) } }, [api, reload, canList]) - const actions = useAddonActions(api, { onSettled: () => reloadRef.current() }) + // The installed record of an add-on, for the administrator's Details in the + // pane. A change reads it again. + const [installedInfo, setInstalledInfo] = useState({}) + const [installedReload, setInstalledReload] = useState(0) + const [checks, setChecks] = useState({}) + const [upgraded, setUpgraded] = useState({}) + const actions = useAddonActions(api, { + onSettled: (key, result) => { + reloadRef.current() + setInstalledReload(n => n + 1) + // The key names the generation the click saw, which the list moves past + // on success, so the outcome of an upgrade is kept by add-on. + const [verb, id] = String(key).split(':') + if (verb === 'upgrade' && result && result.outcome === 'completed') setUpgraded(u => ({ ...u, [id]: true })) + if (verb === 'upgrade' && result && result.outcome !== 'completed') setUpgraded(u => { const n = { ...u }; delete n[id]; return n }) + }, + adapter, + }) const startStop = useCallback((app, verb) => { actions.run(`${verb}:${app.id}:${app.generation}`, { @@ -97,13 +119,63 @@ export default function AppsView({ api }) { }) }, [actions, api]) + // Remove is the typed uninstall action: the data is kept, so installing the + // add-on again picks it up. The owner refuses anyone but an administrator. + const [removing, setRemoving] = useState(null) + const remove = useCallback((tile) => { + actions.run(`uninstall:${tile.id}:${tile.app.generation}`, { + addonId: tile.id, + submit: (operationId, signal) => adapter.submitLifecycle({ operationId, action: 'uninstall', addonId: tile.id, generation: 0 }, signal), + verify: async (signal) => !(await api.installed(signal)).some(a => a.id === tile.id), + }) + }, [actions, adapter, api]) + + // The explicit upgrade: the digest of the installed package this page showed + // and the digest of the candidate the owner's check resolved. The owner + // checks both again and refuses before it changes anything. + const [confirmUpgrade, setConfirmUpgrade] = useState(null) + const upgrade = useCallback((addon) => { + actions.run(`upgrade:${addon.id}:${addon.generation}`, { + addonId: addon.id, + submit: (operationId, signal) => { + const body = upgradeBody(addon, operationId) + if (!body) return Promise.reject(new AddonsError({ kind: 'invalid' })) + return api.updates.upgrade(addon.id, body, signal) + }, + verify: async (signal) => { + const now = (await api.installed(signal)).find(a => a.id === addon.id) + return Boolean(now) && now.generation > addon.generation + }, + }) + }, [actions, api]) + // Asks the owner to read the library again. It never applies anything. + const aborter = useRef(null) + useEffect(() => { + aborter.current = new AbortController() + return () => aborter.current.abort() + }, []) + const check = useCallback(async (addon) => { + const signal = aborter.current.signal + setChecks(c => ({ ...c, [addon.id]: { phase: 'working' } })) + try { + const status = await api.updates.check(addon.id, { operationId: newOperationId() }, signal) + setChecks(c => ({ ...c, [addon.id]: { phase: 'done', throttled: Boolean(status && status.throttled) } })) + setInstalledReload(n => n + 1) + } catch (error) { + if (error && error.name === 'AbortError') return + setChecks(c => ({ ...c, [addon.id]: { phase: 'error', error } })) + } + }, [api]) + const nav = useNavigation() // Under the legacy switch the Build hub row draws the app.nav slot instead. const legacy = useHubAuth().features.addon_navigation === false const [query, setQuery] = useState('') const [params, setParams] = useSearchParams() const [limit, setLimit] = useState(false) - const detailId = params.get('detail') || '' + // The management link of an add-on (?manage=id) opens its Details. + const manageId = isAdmin ? (params.get('manage') || '') : '' + const detailId = params.get('detail') || manageId const { t: tn } = useTranslation('nav') const tiles = useMemo(() => buildTiles(state.apps, nav.builtin, { language: i18n.language }), [state.apps, nav.builtin, i18n.language]) @@ -118,13 +190,25 @@ export default function AppsView({ api }) { }, [tiles, query, titleOf, pinOrder]) const detail = tiles.find(x => x.id === detailId) || null + const detailApp = detail ? detail.app : null + const detailAppId = detailApp ? detailApp.id : '' + const detailAppGeneration = detailApp ? detailApp.generation : 0 + useEffect(() => { + if (!isAdmin || !detailAppId) return undefined + const ctl = new AbortController() + api.installed(ctl.signal) + .then(list => setInstalledInfo(Object.fromEntries(list.map(a => [a.id, a])))) + .catch(() => { /* the pane says the source is not known */ }) + return () => ctl.abort() + }, [api, isAdmin, detailAppId, detailAppGeneration, installedReload]) + const onPin = async (id) => { if (pinnedIds.has(id)) { await nav.unpin(id); return } const result = await nav.pin(id) if (result.needsUnpin) setLimit(true) } - const openDetail = (id) => setParams(prev => { const out = new URLSearchParams(prev); out.set('detail', id); return out }) - const closeDetail = () => setParams(prev => { const out = new URLSearchParams(prev); out.delete('detail'); return out }, { replace: true }) + const openDetail = (id) => setParams(prev => { const out = new URLSearchParams(prev); out.set('detail', id); out.delete('manage'); return out }) + const closeDetail = () => setParams(prev => { const out = new URLSearchParams(prev); out.delete('detail'); out.delete('manage'); return out }, { replace: true }) if (state.status === 'loading') { return
    @@ -141,7 +225,8 @@ export default function AppsView({ api }) { ) } - const firstRun = showFirstRun({ apps: state.apps, isAdmin, canList }) + const view = listState({ apps: state.apps, shownCount: shown.length, query, isAdmin, canList }) + const firstRun = view === 'first-run' return (
    {state.stale && ( @@ -150,58 +235,104 @@ export default function AppsView({ api }) { {t('apps.stale')}
    )} -
    - setQuery(e.target.value)} - /> - {/* One way into Browse per screen: the first-run card carries it while - nothing is installed, and the bar carries it afterwards. */} - {isAdmin && !firstRun && ( - {t('drawer.add')} + {Object.entries(actions.state).filter(([key, r]) => key.startsWith('uninstall:') && r.outcome && r.outcome !== 'completed' && !actions.unknown[key.split(':')[1]]).map(([key, r]) => ( +

    {describeOutcome(r.outcome, t, r.error)}

    + ))} + {Object.keys(actions.unknown).filter(id => actions.unknown[id].key.startsWith('uninstall:')).map(id => ( + + ))} +
    +
    +
    + setQuery(e.target.value)} + /> + {/* One way into Browse per screen: the first-run card carries it while + nothing is installed, and the bar carries it afterwards. */} + {isAdmin && !firstRun && ( + {t('drawer.add')} + )} +
    + {view === 'first-run' ? ( + + ) : view === 'no-match' ? ( + +
    + + {isAdmin && {t('drawer.browse')}} +
    +
    + ) : view === 'none' ? ( + + ) : ( +
      + {shown.map(tile => ( + + ))} +
    + )} +
    + {detail && ( + onPin(detail.id)} + onMove={(delta) => nav.move(detail.id, delta)} + onClose={() => { const id = detail.id; closeDetail(); requestAnimationFrame(() => document.querySelector(`[data-testid="addon-tile"][data-app="${CSS.escape(id)}"] .ad-tile__open`)?.focus()) }} + actions={actions} + onStartStop={startStop} + onRemove={isAdmin && detail.app ? () => setRemoving(detail) : undefined} + isAdmin={isAdmin} + source={installedInfo[detail.id]} + links={detail.app ? navigationItems([detail.app], { language: i18n.language }) : []} + > + {isAdmin && installedInfo[detail.id] && ( +
    + {t('preview.moreDetails')} + check(installedInfo[detail.id])} + onUpgrade={() => setConfirmUpgrade(installedInfo[detail.id])} + /> +
    + )} +
    )}
    - {firstRun ? ( -
    -

    {t('apps.firstRun.title')}

    -
      -
    1. {t('apps.firstRun.libraryTitle')} {t('apps.firstRun.libraryBody')}
    2. -
    3. {t('apps.firstRun.browseTitle')} {t('apps.firstRun.browseBody')}
    4. -
    5. {t('apps.firstRun.startTitle')} {t('apps.firstRun.startBody')}
    6. -
    -
    - {t('apps.firstRun.browse')} -
    -
    - ) : shown.length === 0 ? ( - - ) : ( -
      - {shown.map(tile => ( - - ))} -
    - )} {!legacy &&
    } - {detail && ( - onPin(detail.id)} />} closeLabel={t('drawer.close')} testId="addon-detail"> - {detail.app - ? ( - <> -
    - {navigationItems([detail.app], { language: i18n.language }).map(link => ( - {link.label} - ))} - - ) - :

    {t('drawer.builtinDetail')}

    } -
    + {t('card.removeBody', { name: removing.title })}

    : null} + confirmLabel={t('card.remove')} + onConfirm={() => { const x = removing; setRemoving(null); closeDetail(); remove(x) }} + onCancel={() => setRemoving(null)} + /> + {manageId && state.status === 'ready' && !detail && ( +
    {t('installed.missing', { id: manageId })}
    )} + : null} + confirmLabel={t('updates.confirm.action')} + onConfirm={() => { const a = confirmUpgrade; setConfirmUpgrade(null); upgrade(a) }} + onCancel={() => setConfirmUpgrade(null)} + /> {limit && ( setLimit(false)} closeLabel={t('drawer.close')} testId="pin-limit">

    {t('drawer.limitBody')}

    @@ -210,83 +341,3 @@ export default function AppsView({ api }) {
    ) } - -function AppCard({ app, t, language, actions, onStartStop }) { - const title = localizedText(app, 'title', language) - const description = localizedText(app, 'description', language) - const control = appActions(app) - const tools = toolSummary(app) - const startKey = `start:${app.id}:${app.generation}` - const stopKey = `stop:${app.id}:${app.generation}` - const pending = Boolean(actions.unknown[app.id]) - const working = actions.state[startKey]?.phase === 'working' || actions.state[stopKey]?.phase === 'working' - const locked = working || pending - const result = actions.state[startKey]?.phase !== 'working' ? actions.state[startKey] : null - const stopResult = actions.state[stopKey]?.phase !== 'working' ? actions.state[stopKey] : null - const outcome = result || stopResult - const titleId = `app-${app.id}` - return ( -
  • -
    - -
    -

    {title}

    -

    - {app.id} - - {t('apps.version', { version: app.version })} -

    -
    -
    - - {app.update === 'update-pending' && } -
    -
    - {description &&

    {description}

    } - {!app.mainPage && tools.count > 0 && ( -

    -

    - )} - {!app.mainPage && !app.hasUI &&

    {t('apps.headless')}

    } -
    - {app.mainPage && ( - - {t('apps.open')} - - )} - {control.start && ( - - )} - {control.stop && ( - - )} - {control.manage && ( - - {t('apps.manage')} - - )} -
    - {working &&

    {t('apps.working')}

    } - {pending - ? - : outcome && outcome.outcome && } -
  • - ) -} - -function ActionResult({ outcome, t }) { - const text = describeOutcome(outcome.outcome, t, outcome.error) - if (!text) return null - return

    {text}

    -} diff --git a/core/http/react-ui/src/components/addons/BrowseView.jsx b/core/http/react-ui/src/components/addons/BrowseView.jsx index bcf4ce10a..1545e77a4 100644 --- a/core/http/react-ui/src/components/addons/BrowseView.jsx +++ b/core/http/react-ui/src/components/addons/BrowseView.jsx @@ -4,18 +4,20 @@ import { useSearchParams } from 'react-router-dom' import { useTranslation } from 'react-i18next' import Icon from '../Icon' import ConfirmDialog from '../ConfirmDialog' -import SideSheet from '../SideSheet' import CatalogView from '../catalog/CatalogView' import { paramsToQuery, queryToParams } from '../catalog/catalogState' import { AppIcon, Fact, InstallSteps, UnknownNotice } from './AddonBits' +import FlowStatus from './InstallFlow' +import InstallDialog from './InstallDialog' import { shortDigest } from './addonState' import { updateView } from './updateState' import { describeError, describeOutcome } from './errors' import { useAddonActions } from './useAddonActions' -// Library entries for this host. Install does not activate: the add-on is -// installed and stays disabled until an administrator enables it on the -// Installed tab, so every step that starts a process is its own decision. +// Library entries for this host. An add-on entry has one button, Install and +// turn on: one confirmation, then the install and the turn on run one after +// the other. An add-on that is installed but off offers Turn on. A backend has +// no on and off, so its button stays Install. export default function BrowseView({ api, adapter }) { const { t } = useTranslation('addons') const [params, setParams] = useSearchParams() @@ -26,7 +28,7 @@ export default function BrowseView({ api, adapter }) { const [backendWork, setBackendWork] = useState({}) const [reload, setReload] = useState(0) const [confirm, setConfirm] = useState(null) - const [details, setDetails] = useState(null) + const [dialog, setDialog] = useState(null) const query = paramsToQuery(k => params.get(k)) const onQueryChange = useCallback((next) => { @@ -55,7 +57,7 @@ export default function BrowseView({ api, adapter }) { return () => ctl.abort() }, [api, reload]) - const actions = useAddonActions(api, { onSettled: () => setReload(n => n + 1) }) + const actions = useAddonActions(api, { onSettled: () => setReload(n => n + 1), adapter }) const installBackend = async (entry) => { setConfirm(null) @@ -73,26 +75,21 @@ export default function BrowseView({ api, adapter }) { setReload(n => n + 1) } - const install = (entry) => { - if (entry.kind === 'backend') return installBackend(entry) - setConfirm(null) - actions.run(`install:${entry.key}`, { - addonId: entry.id, - submit: (operationId, signal) => adapter.submitLifecycle({ operationId, action: 'install', addonId: entry.id, sourceId: entry.sourceId, version: entry.version }, signal), - // A completed status is not proof for this click: the owner answers a - // replayed operation with its old status and applies nothing. - verify: async (signal) => (await api.installed(signal)).some(a => a.id === entry.id), - }) - } + // A backend has its own job and its own confirmation. An add-on goes through + // the one dialog of Install and turn on, which stays open for the steps. + const install = (entry) => installBackend(entry) + const openInstall = (entry) => { if (entry.kind === 'backend') setConfirm(entry); else setDialog(entry) } const renderEntry = (entry) => { const isBackend = entry.kind === 'backend' const have = isBackend ? installedBackends[entry.id] : installed[entry.id] const key = `install:${entry.key}` const bw = isBackend ? backendWork[key] : null - const st = isBackend ? null : actions.state[key] + const flow = isBackend ? null : actions.flows[`flow:${entry.key}`] const pending = !isBackend && Boolean(actions.unknown[entry.id]) - const working = isBackend ? bw?.phase === 'working' : st?.phase === 'working' + const working = isBackend ? bw?.phase === 'working' : ['install', 'turn-on'].includes(flow?.step) + const addsPage = Boolean(entry.ui && entry.ui.mainPage) + const off = !isBackend && have && have.enabled === false return (
    @@ -107,21 +104,16 @@ export default function BrowseView({ api, adapter }) {

    {entry.description &&

    {entry.description}

    }
    - {t(`browse.kind.${entry.kind}`, { defaultValue: entry.kind })} - {entry.platform.os}/{entry.platform.arch} - - {entry.policy.requireSignature ? t('trust.signatureRequired') : t('trust.digestOnly')} - - {entry.channel && {entry.channel}} + {isBackend && {t(`browse.kind.${entry.kind}`, { defaultValue: entry.kind })}} + {!isBackend && addsPage && {t('card.addsPage')}} {isBackend && have && {t('browse.installedBackend')}} {!isBackend && have && {have.version === entry.version ? t('browse.installedSame', { version: have.version }) : t('browse.installedOther', { version: have.version })}} {!isBackend && have && updateView(have.update).state === 'update-available' && {t('updates.badge.available', { version: updateView(have.update).version })}}
    + {pending && } - {!pending && st && !working && st.outcome !== 'completed' && ( -

    {describeOutcome(st.outcome, t, st.error)}

    - )} - {st && st.outcome === 'completed' &&

    {t('browse.installedNotEnabled')}

    } + {!pending && !(dialog && dialog.key === entry.key) && actions.installAndTurnOn(entry, { skipInstall: flow.failedStep === 'turn-on' }) : undefined} />} + {flow && flow.step === 'done' && !(dialog && dialog.key === entry.key) &&

    {t('browse.installedOn', { name: entry.title })}

    } {bw && bw.phase !== 'done' && } {bw?.phase === 'failed' && (

    @@ -131,12 +123,21 @@ export default function BrowseView({ api, adapter }) { {bw?.phase === 'done' &&

    {t('browse.installedBackendNote')}

    }
    - - + {isBackend && !have && ( + + )} + {!isBackend && !have && ( + + )} + {off && ( + + )}
    ) @@ -155,42 +156,36 @@ export default function BrowseView({ api, adapter }) { emptyTitle={t('browse.emptyTitle')} emptyText={t('browse.emptyBody')} /> - {details && setDetails(null)} />} + setDialog(null)} /> -

    {confirm.kind === 'backend' ? t('browse.confirmBackendBody', { version: confirm.version, library: confirm.sourceId }) : t('browse.confirmBody', { version: confirm.version, library: confirm.sourceId })}

    +

    {t('browse.confirmBackendBody', { version: confirm.version, library: confirm.sourceId })}

    {t('trust.digest')}
    {shortDigest(confirm.digest)}
    {t('trust.verification')}
    {confirm.policy.requireSignature ? t('trust.signatureRequired') : t('trust.digestOnly')}
    -

    {confirm.kind === 'backend' ? t('browse.confirmBackendNote') : t('browse.confirmNotEnabled')}

    +

    {t('browse.confirmBackendNote')}

    ) : null} confirmLabel={t('browse.install')} - onConfirm={() => install(confirm)} + onConfirm={() => { const e = confirm; setConfirm(null); install(e) }} onCancel={() => setConfirm(null)} /> ) } -function EntryDetails({ adapter, entry, onClose }) { +// The details of an entry, folded into the entry itself: what it is made for, +// how it is verified and which build it is. They come from the library entry +// the list already has. +function EntryFacts({ entry: e, have, isBackend }) { const { t } = useTranslation('addons') - const [state, setState] = useState({ status: 'loading', entry: null, error: null }) - useEffect(() => { - const ctl = new AbortController() - adapter.details(entry.key, ctl.signal) - .then(full => setState({ status: 'ready', entry: full, error: null })) - .catch(error => { if (!ctl.signal.aborted) setState({ status: 'error', entry: null, error }) }) - return () => ctl.abort() - }, [adapter, entry.key]) - const e = state.entry || entry return ( - - {state.status === 'error' &&

    {describeError(state.error, t)}

    } +
    + {t('browse.details')}
    {t('browse.kindLabel')}
    {t(`browse.kind.${e.kind}`, { defaultValue: e.kind })}
    {t('trust.version')}
    {e.version}
    @@ -201,12 +196,8 @@ function EntryDetails({ adapter, entry, onClose }) {
    {t('trust.verification')}
    {e.policy.requireSignature ? t('trust.signatureRequired') : t('trust.digestOnly')}
    {e.policy.requireSignature && (<>
    {t('trust.issuer')}
    {e.policy.issuer}
    {t('trust.identity')}
    {e.policy.identity || e.policy.identityRegex}
    )}
    {t('trust.digest')}
    {e.digest}
    - {e.installed && (<>
    {t('browse.installedState')}
    {t('browse.installedDetail', { version: e.installed.version, generation: e.installed.generation })}
    )} + {!isBackend && have && (<>
    {t('browse.installedState')}
    {t('browse.installedDetail', { version: have.version, generation: have.generation })}
    )}
    - {e.installed && updateView(e.installed.update).state === 'update-available' && ( -

    {t('updates.browseUpdate', { version: updateView(e.installed.update).version })}

    - )} - {e.description &&

    {e.description}

    } - +
    ) } diff --git a/core/http/react-ui/src/components/addons/FirstRun.jsx b/core/http/react-ui/src/components/addons/FirstRun.jsx new file mode 100644 index 000000000..843e71ec7 --- /dev/null +++ b/core/http/react-ui/src/components/addons/FirstRun.jsx @@ -0,0 +1,71 @@ +/* eslint-disable no-unused-vars -- components used only inside JSX look unused to this config, which has no eslint-plugin-react */ +import { useEffect, useState } from 'react' +import { Link } from 'react-router-dom' +import { useTranslation } from 'react-i18next' +import Icon from '../Icon' +import LoadingSpinner from '../LoadingSpinner' +import { AppIcon } from './AddonBits' +import FlowStatus from './InstallFlow' +import InstallDialog from './InstallDialog' +import { featuredEntries } from './addonState' +import { describeError } from './errors' + +// Nothing is installed yet. An administrator sees three add-ons from the +// sources, each with one button that installs it and turns it on, and a link +// to everything else. +export default function FirstRun({ adapter, actions }) { + const { t } = useTranslation('addons') + const [state, setState] = useState({ status: 'loading', entries: [], error: null }) + const [confirm, setConfirm] = useState(null) + + useEffect(() => { + const ctl = new AbortController() + adapter.search({ text: '', sourceId: '', page: 1 }, ctl.signal) + .then(out => setState({ status: 'ready', entries: featuredEntries(out.entries), error: null })) + .catch(error => { if (!ctl.signal.aborted) setState({ status: 'error', entries: [], error }) }) + return () => ctl.abort() + }, [adapter]) + + const flowOf = (entry) => actions.flows[`flow:${entry.key}`] + const busy = (entry) => ['install', 'turn-on'].includes(flowOf(entry)?.step) || Boolean(actions.unknown[entry.id]) + + return ( +
    +

    {t('apps.firstRun.title')}

    +

    {t('apps.firstRun.lead')}

    + {state.status === 'loading' &&
    } + {state.status === 'error' &&

    {t('apps.firstRun.featuredFailed')} {describeError(state.error, t)}

    } + {state.status === 'ready' && state.entries.length === 0 &&

    {t('apps.firstRun.noFeatured')}

    } + {state.entries.length > 0 && ( +
      + {state.entries.map(entry => ( +
    • +
      + +
      +

      {entry.title}

      +

      {t('apps.version', { version: entry.version })}

      +
      +
      + {entry.description &&

      {entry.description}

      } + {entry.ui && entry.ui.mainPage &&

      } + actions.installAndTurnOn(entry, { skipInstall: flowOf(entry).failedStep === 'turn-on' }) : undefined} /> +
      + +
      +
    • + ))} +
    + )} +
    + {t('apps.firstRun.browse')} + {state.status === 'ready' && state.entries.length === 0 && ( + {t('tabs.libraries')} + )} +
    + setConfirm(null)} /> +
    + ) +} diff --git a/core/http/react-ui/src/components/addons/InstallDialog.jsx b/core/http/react-ui/src/components/addons/InstallDialog.jsx new file mode 100644 index 000000000..06d24abc6 --- /dev/null +++ b/core/http/react-ui/src/components/addons/InstallDialog.jsx @@ -0,0 +1,115 @@ +/* eslint-disable no-unused-vars -- components used only inside JSX look unused to this config, which has no eslint-plugin-react */ +import { useEffect, useRef } from 'react' +import { Link } from 'react-router-dom' +import { useTranslation } from 'react-i18next' +import Icon from '../Icon' +import { pushLayer } from '../../utils/modalLayers' +import { UnknownNotice } from './AddonBits' +import { shortDigest } from './addonState' +import { describeOutcome } from './errors' + +// The one dialog of Install and turn on. It asks once, then stays open and +// shows the two steps while they run: the install, then the turn on. It ends +// on the result, or on the reason a step failed with a way to try again. +export default function InstallDialog({ entry, flow, actions, onClose }) { + const { t } = useTranslation('addons') + const ref = useRef(null) + const step = flow ? flow.step : 'ask' + const running = step === 'install' || step === 'turn-on' + const closeRef = useRef(onClose) + closeRef.current = onClose + + useEffect(() => { + if (!entry) return undefined + const opener = document.activeElement + const layer = pushLayer() + const dialog = ref.current + dialog?.querySelector('button:not([disabled])')?.focus() + const onKey = (e) => { + if (!layer.isTop()) return + if (e.key === 'Escape') { + if (dialog?.dataset.running === 'true') return + e.preventDefault(); closeRef.current(); return + } + if (e.key !== 'Tab' || !dialog) return + const items = Array.from(dialog.querySelectorAll('button:not([disabled]), a[href]')) + if (items.length === 0) return + const first = items[0] + const last = items[items.length - 1] + if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus() } + else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus() } + } + document.addEventListener('keydown', onKey) + return () => { + document.removeEventListener('keydown', onKey) + layer.pop() + if (opener && opener !== document.body && document.contains(opener)) opener.focus?.() + } + }, [entry]) + + if (!entry) return null + const start = () => actions.installAndTurnOn(entry) + const retry = () => actions.installAndTurnOn(entry, { skipInstall: flow.failedStep === 'turn-on' }) + const stepState = (id) => { + if (step === 'ask') return 'pending' + if (step === 'done') return 'done' + if (id === 'install') return step === 'install' ? 'active' : step === 'failed' && flow.failedStep === 'install' ? 'failed' : 'done' + return step === 'turn-on' ? 'active' : step === 'failed' && flow.failedStep === 'turn-on' ? 'failed' : 'pending' + } + const reason = step === 'failed' ? describeOutcome(flow.outcome, t, flow.error) : '' + return ( +
    +
    e.stopPropagation()}> +
    + {t('flow.confirmTitle', { name: entry.title })} +
    +
    + {step === 'ask' && ( + <> +

    {t('flow.confirmBody', { version: entry.version, library: entry.sourceId })}

    +
    +
    {t('trust.digest')}
    {shortDigest(entry.digest)}
    +
    {t('trust.verification')}
    {entry.policy.requireSignature ? t('trust.signatureRequired') : t('trust.digestOnly')}
    +
    + + )} + {step !== 'ask' && ( +
      + {['install', 'turn-on'].map(id => { + const state = stepState(id) + return ( +
    1. + + {t(`flow.step.${id}`)} + {t(`install.state.${state}`)} +
    2. + ) + })} +
    + )} + {step === 'done' && ( +

    + {t('browse.installedOn', { name: entry.title })} + {entry.ui && entry.ui.mainPage && <> {t('flow.seeInList')}} +

    + )} + {step === 'failed' && ( +

    + {flow.failedStep === 'install' ? t('flow.installFailed', { name: entry.title }) : t('flow.turnOnFailed', { name: entry.title })} + {reason ? ` ${reason}` : ''} +

    + )} + {step === 'failed' && actions.unknown[entry.id] && } +
    +
    + {step === 'ask' && } + {step === 'ask' && } + {running && } + {step === 'failed' && } + {step === 'failed' && !actions.unknown[entry.id] && } + {step === 'done' && } +
    +
    +
    + ) +} diff --git a/core/http/react-ui/src/components/addons/InstallFlow.jsx b/core/http/react-ui/src/components/addons/InstallFlow.jsx new file mode 100644 index 000000000..96e5f49fc --- /dev/null +++ b/core/http/react-ui/src/components/addons/InstallFlow.jsx @@ -0,0 +1,31 @@ +import { useTranslation } from 'react-i18next' +import Icon from '../Icon' +import { describeOutcome } from './errors' + +// Where an "Install and turn on" is, drawn inline under the add-on it works +// on. The text says it; the icon only supports it. +export default function FlowStatus({ flow, name, onRetry }) { + const { t } = useTranslation('addons') + if (!flow || flow.step === 'done') return null + if (flow.step === 'install' || flow.step === 'turn-on') { + return ( +

    + {flow.step === 'install' ? t('flow.installing', { name }) : t('flow.turningOn', { name })} +

    + ) + } + const reason = describeOutcome(flow.outcome, t, flow.error) + return ( +
    + + {flow.failedStep === 'install' ? t('flow.installFailed', { name }) : t('flow.turnOnFailed', { name })} + {reason ? ` ${reason}` : ''} + + {onRetry && ( + + )} +
    + ) +} diff --git a/core/http/react-ui/src/components/addons/InstalledView.jsx b/core/http/react-ui/src/components/addons/InstalledView.jsx deleted file mode 100644 index 894755c9e..000000000 --- a/core/http/react-ui/src/components/addons/InstalledView.jsx +++ /dev/null @@ -1,423 +0,0 @@ -/* eslint-disable no-unused-vars -- components used only inside JSX look unused to this config, which has no eslint-plugin-react */ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { Link } from 'react-router-dom' -import { useTranslation } from 'react-i18next' -import Icon from '../Icon' -import LoadingSpinner from '../LoadingSpinner' -import ConfirmDialog from '../ConfirmDialog' -import SideSheet from '../SideSheet' -import CatalogEmpty from '../catalog/CatalogEmpty' -import { apiUrl } from '../../utils/basePath' -import { StateChip, AppIcon, Fact, UnknownNotice } from './AddonBits' -import { formatBytes, rangeText, shortDigest } from './addonState' -import { describeError, describeOutcome } from './errors' -import { useAddonActions } from './useAddonActions' -import { updateView, upgradeBody, formatWhen, UPDATE_TYPES } from './updateState' -import { newOperationId } from '../catalog/catalogState' -import { AddonsError } from '../../utils/addonsApi' -import RecoveryPanel from './RecoveryPanel' - -// Everything the owner knows of what is installed: verified package facts, -// the desired state (enabled) and the observed one (health) apart, retained -// generations, data size and, when the owner can say so, an update. A headless -// add-on is managed here exactly like one with pages. -export default function InstalledView({ api, adapter, manage, onManage, onChanged }) { - const { t, i18n } = useTranslation('addons') - const [state, setState] = useState({ status: 'loading', addons: [], error: null }) - const [reload, setReload] = useState(0) - const [confirm, setConfirm] = useState(null) - const [confirmUpgrade, setConfirmUpgrade] = useState(null) - // What the last check and the last upgrade of each add-on said, by add-on. - const [checks, setChecks] = useState({}) - const [upgraded, setUpgraded] = useState({}) - const aborter = useRef(null) - useEffect(() => { - aborter.current = new AbortController() - return () => aborter.current.abort() - }, []) - - useEffect(() => { - const ctl = new AbortController() - api.installed(ctl.signal) - .then(addons => setState({ status: 'ready', addons, error: null })) - .catch(error => { if (!ctl.signal.aborted && error.name !== 'AbortError') setState(s => ({ status: s.status === 'ready' ? 'ready' : 'error', addons: s.addons, error })) }) - return () => ctl.abort() - }, [api, reload]) - - const actions = useAddonActions(api, { - onSettled: (key, result) => { - setReload(n => n + 1) - if (onChanged) onChanged() - // The key names the generation the click saw, which the list moves past - // on success, so the outcome of an upgrade is kept by add-on. - const [verb, id] = String(key).split(':') - if (verb === 'upgrade' && result && result.outcome === 'completed') setUpgraded(u => ({ ...u, [id]: true })) - if (verb === 'upgrade' && result && result.outcome !== 'completed') setUpgraded(u => { const n = { ...u }; delete n[id]; return n }) - }, - }) - - const lifecycle = useCallback((addon, action) => { - actions.run(`${action}:${addon.id}:${addon.generation}`, { - addonId: addon.id, - submit: (operationId, signal) => adapter.submitLifecycle({ - operationId, - action, - addonId: addon.id, - generation: action === 'enable' || action === 'disable' ? addon.generation : 0, - }, signal), - // A completed status is not proof for this click: the owner answers a - // replayed operation with its old status and applies nothing. - verify: async (signal) => { - const now = (await api.installed(signal)).find(a => a.id === addon.id) - if (action === 'uninstall') return !now - if (!now) return false - if (action === 'enable') return now.enabled === true - return now.enabled === false - }, - }) - }, [actions, adapter, api]) - - // The explicit upgrade: the digest of the installed package this page - // showed and the digest of the candidate the owner's check resolved. The - // owner checks both again and refuses before it changes anything. - const upgrade = useCallback((addon) => { - actions.run(`upgrade:${addon.id}:${addon.generation}`, { - addonId: addon.id, - submit: (operationId, signal) => { - const body = upgradeBody(addon, operationId) - if (!body) return Promise.reject(new AddonsError({ kind: 'invalid' })) - return api.updates.upgrade(addon.id, body, signal) - }, - verify: async (signal) => { - const now = (await api.installed(signal)).find(a => a.id === addon.id) - return Boolean(now) && now.generation > addon.generation - }, - }) - }, [actions, api]) - - // Asks the owner to read the library again. It never applies anything. - const check = useCallback(async (addon) => { - const signal = aborter.current.signal - setChecks(c => ({ ...c, [addon.id]: { phase: 'working' } })) - try { - const status = await api.updates.check(addon.id, { operationId: newOperationId() }, signal) - setChecks(c => ({ ...c, [addon.id]: { phase: 'done', throttled: Boolean(status && status.throttled) } })) - setReload(n => n + 1) - } catch (error) { - if (error && error.name === 'AbortError') return - setChecks(c => ({ ...c, [addon.id]: { phase: 'error', error } })) - } - }, [api]) - - const selected = useMemo(() => state.addons.find(a => a.id === manage) || null, [state.addons, manage]) - - if (state.status === 'loading') return
    - if (state.status === 'error') { - return ( -
    - - {describeError(state.error, t)} - -
    - ) - } - if (state.addons.length === 0) { - return - } - - return ( - <> -
      - {state.addons.map(addon => ( -
    • - -
    • - ))} -
    - {selected && ( - check(selected)} - onUpgrade={() => setConfirmUpgrade(selected)} - onLifecycle={lifecycle} - onUninstall={() => setConfirm(selected)} - onClose={() => onManage('')} - /> - )} - {manage && !selected && state.status === 'ready' && ( -
    {t('installed.missing', { id: manage })}
    - )} - {t('installed.uninstallBody')}

    : null} - confirmLabel={t('installed.uninstall')} - onConfirm={() => { const a = confirm; setConfirm(null); lifecycle(a, 'uninstall') }} - onCancel={() => setConfirm(null)} - /> - : null} - confirmLabel={t('updates.confirm.action')} - onConfirm={() => { const a = confirmUpgrade; setConfirmUpgrade(null); upgrade(a) }} - onCancel={() => setConfirmUpgrade(null)} - /> - - ) -} - -function busy(actions, addon, verbs) { - if (actions.unknown[addon.id]) return true - return verbs.some(v => actions.state[`${v}:${addon.id}:${addon.generation}`]?.phase === 'working') -} - -function latest(actions, addon, verbs) { - for (const v of verbs) { - const s = actions.state[`${v}:${addon.id}:${addon.generation}`] - if (s && s.phase !== 'working' && s.outcome) return s - } - return null -} - -// The words for the outcome of an action. A typed refusal of the update -// routes has its own words; everything else is the shared description. -function outcomeText(result, t) { - const type = result && result.error && result.error.type - if (type && UPDATE_TYPES.has(type)) return t(`updates.errors.${type}`) - return describeOutcome(result.outcome, t, result.error) -} - -function UpdateFact({ view, t }) { - switch (view.state) { - case 'update-available': return {t('updates.badge.available', { version: view.version })} - case 'incompatible': return {t('updates.badge.incompatible', { version: view.version })} - case 'check-failed': return {t('updates.badge.failed')} - case 'integrity-error': return {t('updates.badge.integrity')} - default: return null - } -} - -function InstalledRow({ addon, t, actions, onLifecycle, onManage, upgraded }) { - const working = busy(actions, addon, ['enable', 'disable', 'upgrade', 'uninstall']) - const result = latest(actions, addon, ['enable', 'disable', 'upgrade', 'uninstall']) - const update = updateView(addon.update) - return ( -
    - -
    -

    {addon.title}

    -

    - {addon.id} - - {t('apps.version', { version: addon.version })} - - {t('installed.generation', { generation: addon.generation })} -

    -
    - {addon.enabled ? t('installed.enabled') : t('installed.disabled')} - - {addon.recovery && {t(`recovery.state.${addon.recovery}`)}} - {t(`trust.mode.${addon.verification === 'sha256+sigstore' ? 'signed' : 'digest'}`)} - {addon.platform.os}/{addon.platform.arch} - -
    - {working &&

    {t('apps.working')}

    } - {upgraded && !working &&

    {t('updates.done', { version: addon.version })}

    } - {actions.unknown[addon.id] - ? - : result && result.outcome !== 'completed' && ( -

    {outcomeText(result, t)}

    - )} -
    -
    - {addon.enabled ? ( - - ) : ( - - )} - -
    -
    - ) -} - -function ManageSheet({ addon, api, t, language, actions, check, upgraded, onCheck, onUpgrade, onLifecycle, onUninstall, onClose }) { - const working = busy(actions, addon, ['enable', 'disable', 'upgrade', 'uninstall']) - const compat = addon.compatibility || {} - return ( - - - {addon.enabled ? ( - - ) : ( - - )} - - )} - > -
    -

    {t('manage.health')}

    -
    -
    {t('manage.desired')}
    {addon.enabled ? t('installed.enabled') : t('installed.disabled')}
    -
    {t('manage.observed')}
    -
    {t('manage.restarts')}
    {addon.restarts}
    -
    - {addon.enabled && addon.state === 'failed' &&

    {t('manage.failedNote', { count: addon.restarts })}

    } - {addon.mainPage && {t('apps.open')}} -
    - -
    -

    {t('manage.trust')}

    -
    -
    {t('trust.verification')}
    {t(`trust.mode.${addon.verification === 'sha256+sigstore' ? 'signed' : 'digest'}`)}
    -
    {t('trust.library')}
    {addon.sourceId || '—'}
    - {addon.channel && (<>
    {t('trust.channel')}
    {addon.channel}
    )} - {addon.selection && (<>
    {t('trust.selection')}
    {addon.selection}
    )} -
    {t('trust.digest')}
    {shortDigest(addon.digest)}
    -
    -
    - -
    -

    {t('manage.compatibility')}

    -
    -
    {t('trust.platform')}
    {addon.platform.os}/{addon.platform.arch}
    -
    {t('manage.host')}
    {rangeText(compat.host)}
    -
    {t('manage.api')}
    {rangeText(compat.api)}
    -
    {t('manage.protocol')}
    {rangeText(compat.protocol)}
    -
    {t('manage.dataFormat')}
    {rangeText(compat.dataFormat)}
    -
    {t('manage.tools')}
    {addon.toolCount}{addon.tools?.length ? `: ${addon.tools.join(', ')}` : ''}
    -
    {t('manage.interface')}
    {addon.hasUI ? t('manage.hasUI') : t('manage.headless')}
    -
    -
    - -
    -

    {t('manage.storage')}

    -
    -
    {t('manage.data')}
    {formatBytes(addon.storage?.bytes)}{addon.storage?.truncated ? ` ${t('manage.atLeast')}` : ''}
    -
    {t('manage.generations')}
    {addon.generations}
    -
    -
    - -
    -

    {t('recovery.title')}

    - -
    - -
    -

    {t('updates.title')}

    - -
    - -
    -

    {t('manage.logs')}

    -

    {t('manage.logsNone')}

    -
    -
    - ) -} - -// The owner's update check of one add-on: what it says, how old it is, and the -// two actions. Check only reads; Upgrade opens a confirmation first. -function UpdatePanel({ addon, t, language, actions, check, upgraded, working, onCheck, onUpgrade }) { - const view = updateView(addon.update) - const checking = check && check.phase === 'working' - const upgrading = actions.state[`upgrade:${addon.id}:${addon.generation}`]?.phase === 'working' - const result = latest(actions, addon, ['upgrade']) - const checkedAt = formatWhen(view.checkedAt, language) - const lastSuccess = formatWhen(view.lastSuccess, language) - return ( - <> -

    - {t(`updates.state.${view.state}`, { version: view.version })} -

    -
    -
    {t('updates.installed')}
    -
    {addon.version} {shortDigest(addon.digest)}
    - {view.version && (view.state === 'update-available' || view.state === 'incompatible' || view.state === 'integrity-error') && ( - <> -
    {t('updates.candidate')}
    -
    {view.version} {view.digest && {shortDigest(view.digest)}}
    - - )} -
    {t('trust.library')}
    {addon.sourceId || '—'}{addon.channel ? ` · ${addon.channel}` : addon.selection ? ` · ${addon.selection}` : ''}
    -
    - {view.reasons.length > 0 && ( -
    -

    {t('updates.reasons')}

    -
      {view.reasons.map(r =>
    • {t(`updates.reason.${r}`)}
    • )}
    -
    - )} - {view.state === 'check-failed' && view.error &&

    {t(`updates.class.${view.error}`)}

    } -

    - {checkedAt ? t('updates.checkedAt', { when: checkedAt }) : t('updates.neverChecked')} - {lastSuccess ? ` · ${t('updates.lastSuccess', { when: lastSuccess })}` : ''} -

    - {view.stale &&

    {t('updates.stale')}

    } - {check && check.phase === 'done' && check.throttled &&

    {t('updates.throttled')}

    } - {check && check.phase === 'error' &&

    {describeError(check.error, t)}

    } - {upgrading &&

    {t('updates.progress', { name: addon.title })}

    } - {upgraded && !working &&

    {t('updates.done', { version: addon.version })}

    } - {actions.unknown[addon.id] - ? - : result && result.outcome !== 'completed' &&

    {outcomeText(result, t)}

    } -
    - - {view.canUpgrade && ( - - )} -
    - - ) -} - -// What the confirmation shows before an upgrade: which library and channel, -// which version and digest goes to which, and that the data is backed up. -function UpgradeSummary({ addon, t }) { - const view = updateView(addon.update) - return ( -
    -
    -
    {t('updates.confirm.library')}
    {addon.sourceId || '—'}
    -
    {t('updates.confirm.channel')}
    {addon.channel || addon.selection || '—'}
    -
    {t('updates.confirm.from')}
    {addon.version} {shortDigest(addon.digest)}
    -
    {t('updates.confirm.to')}
    {view.version} {shortDigest(view.digest)}
    -
    -

    {t('updates.confirm.body')}

    -

    {t('updates.confirm.data')}

    -
    - ) -} diff --git a/core/http/react-ui/src/components/addons/OperatorFacts.jsx b/core/http/react-ui/src/components/addons/OperatorFacts.jsx new file mode 100644 index 000000000..2d8649009 --- /dev/null +++ b/core/http/react-ui/src/components/addons/OperatorFacts.jsx @@ -0,0 +1,175 @@ +/* eslint-disable no-unused-vars -- components used only inside JSX look unused to this config, which has no eslint-plugin-react */ +import { useTranslation } from 'react-i18next' +import Icon from '../Icon' +import { StateChip, Fact, UnknownNotice } from './AddonBits' +import { formatBytes, rangeText, shortDigest } from './addonState' +import { describeError, describeOutcome } from './errors' +import { updateView, formatWhen, UPDATE_TYPES } from './updateState' +import RecoveryPanel from './RecoveryPanel' + +// What an administrator needs to run an add-on, shown in the preview pane under +// Details: health, trust, compatibility, storage, data recovery and updates. +// It reads the installed record of the add-on, which the host serves to +// administrators only; the host checks that again on every call. +export function OperatorFacts({ addon, api, language, actions, check, upgraded, onCheck, onUpgrade }) { + const { t } = useTranslation('addons') + const working = busy(actions, addon, ['start', 'stop', 'enable', 'disable', 'upgrade', 'uninstall']) + const compat = addon.compatibility || {} + return ( +
    +
    +

    {t('manage.health')}

    +
    +
    {t('manage.desired')}
    {addon.enabled ? t('installed.enabled') : t('installed.disabled')}
    +
    {t('manage.observed')}
    +
    {t('manage.restarts')}
    {addon.restarts}
    +
    + {addon.enabled && addon.state === 'failed' &&

    {t('manage.failedNote', { count: addon.restarts })}

    } +
    + +
    +

    {t('manage.trust')}

    +
    +
    {t('trust.verification')}
    {t(`trust.mode.${addon.verification === 'sha256+sigstore' ? 'signed' : 'digest'}`)}
    +
    {t('trust.library')}
    {addon.sourceId || '—'}
    + {addon.channel && (<>
    {t('trust.channel')}
    {addon.channel}
    )} + {addon.selection && (<>
    {t('trust.selection')}
    {addon.selection}
    )} +
    {t('trust.digest')}
    {shortDigest(addon.digest)}
    +
    +
    + +
    +

    {t('manage.compatibility')}

    +
    +
    {t('trust.platform')}
    {addon.platform.os}/{addon.platform.arch}
    +
    {t('manage.host')}
    {rangeText(compat.host)}
    +
    {t('manage.api')}
    {rangeText(compat.api)}
    +
    {t('manage.protocol')}
    {rangeText(compat.protocol)}
    +
    {t('manage.dataFormat')}
    {rangeText(compat.dataFormat)}
    +
    {t('manage.tools')}
    {addon.toolCount}{addon.tools?.length ? `: ${addon.tools.join(', ')}` : ''}
    +
    {t('manage.interface')}
    {addon.hasUI ? t('manage.hasUI') : t('manage.headless')}
    +
    +
    + +
    +

    {t('manage.storage')}

    +
    +
    {t('manage.data')}
    {formatBytes(addon.storage?.bytes)}{addon.storage?.truncated ? ` ${t('manage.atLeast')}` : ''}
    +
    {t('manage.generations')}
    {addon.generations}
    +
    +
    + +
    +

    {t('recovery.title')}

    + +
    + +
    +

    {t('updates.title')}

    + +
    + +
    +

    {t('manage.logs')}

    +

    {t('manage.logsNone')}

    +
    +
    + ) +} + +function busy(actions, addon, verbs) { + if (actions.unknown[addon.id]) return true + return verbs.some(v => actions.state[`${v}:${addon.id}:${addon.generation}`]?.phase === 'working') +} + +function latest(actions, addon, verbs) { + for (const v of verbs) { + const s = actions.state[`${v}:${addon.id}:${addon.generation}`] + if (s && s.phase !== 'working' && s.outcome) return s + } + return null +} + +// The words for the outcome of an action. A typed refusal of the update +// routes has its own words; everything else is the shared description. +function outcomeText(result, t) { + const type = result && result.error && result.error.type + if (type && UPDATE_TYPES.has(type)) return t(`updates.errors.${type}`) + return describeOutcome(result.outcome, t, result.error) +} + +// The owner's update check of one add-on: what it says, how old it is, and the +// two actions. Check only reads; Upgrade opens a confirmation first. +function UpdatePanel({ addon, t, language, actions, check, upgraded, working, onCheck, onUpgrade }) { + const view = updateView(addon.update) + const checking = check && check.phase === 'working' + const upgrading = actions.state[`upgrade:${addon.id}:${addon.generation}`]?.phase === 'working' + const result = latest(actions, addon, ['upgrade']) + const checkedAt = formatWhen(view.checkedAt, language) + const lastSuccess = formatWhen(view.lastSuccess, language) + return ( + <> +

    + {t(`updates.state.${view.state}`, { version: view.version })} +

    +
    +
    {t('updates.installed')}
    +
    {addon.version} {shortDigest(addon.digest)}
    + {view.version && (view.state === 'update-available' || view.state === 'incompatible' || view.state === 'integrity-error') && ( + <> +
    {t('updates.candidate')}
    +
    {view.version} {view.digest && {shortDigest(view.digest)}}
    + + )} +
    {t('trust.library')}
    {addon.sourceId || '—'}{addon.channel ? ` · ${addon.channel}` : addon.selection ? ` · ${addon.selection}` : ''}
    +
    + {view.reasons.length > 0 && ( +
    +

    {t('updates.reasons')}

    +
      {view.reasons.map(r =>
    • {t(`updates.reason.${r}`)}
    • )}
    +
    + )} + {view.state === 'check-failed' && view.error &&

    {t(`updates.class.${view.error}`)}

    } +

    + {checkedAt ? t('updates.checkedAt', { when: checkedAt }) : t('updates.neverChecked')} + {lastSuccess ? ` · ${t('updates.lastSuccess', { when: lastSuccess })}` : ''} +

    + {view.stale &&

    {t('updates.stale')}

    } + {check && check.phase === 'done' && check.throttled &&

    {t('updates.throttled')}

    } + {check && check.phase === 'error' &&

    {describeError(check.error, t)}

    } + {upgrading &&

    {t('updates.progress', { name: addon.title })}

    } + {upgraded && !working &&

    {t('updates.done', { version: addon.version })}

    } + {actions.unknown[addon.id] + ? + : result && result.outcome !== 'completed' &&

    {outcomeText(result, t)}

    } +
    + + {view.canUpgrade && ( + + )} +
    + + ) +} + +// What the confirmation shows before an upgrade: which library and channel, +// which version and digest goes to which, and that the data is backed up. +export function UpgradeSummary({ addon, t }) { + const view = updateView(addon.update) + return ( +
    +
    +
    {t('updates.confirm.library')}
    {addon.sourceId || '—'}
    +
    {t('updates.confirm.channel')}
    {addon.channel || addon.selection || '—'}
    +
    {t('updates.confirm.from')}
    {addon.version} {shortDigest(addon.digest)}
    +
    {t('updates.confirm.to')}
    {view.version} {shortDigest(view.digest)}
    +
    +

    {t('updates.confirm.body')}

    +

    {t('updates.confirm.data')}

    +
    + ) +} diff --git a/core/http/react-ui/src/components/addons/PinControl.jsx b/core/http/react-ui/src/components/addons/PinControl.jsx deleted file mode 100644 index e095f8405..000000000 --- a/core/http/react-ui/src/components/addons/PinControl.jsx +++ /dev/null @@ -1,25 +0,0 @@ -import { useTranslation } from 'react-i18next' - -// The pin control of an add-on, on its tile and in its detail view: a button -// with a visible word and a pin that is filled when the add-on is pinned. The -// accessible name keeps the add-on's name so a list of them stays unambiguous. -export default function PinControl({ name, pinned, onToggle, variant = 'tile' }) { - const { t } = useTranslation('addons') - return ( - - ) -} diff --git a/core/http/react-ui/src/components/addons/addonState.js b/core/http/react-ui/src/components/addons/addonState.js index d2d27aa73..553932dce 100644 --- a/core/http/react-ui/src/components/addons/addonState.js +++ b/core/http/react-ui/src/components/addons/addonState.js @@ -1,10 +1,12 @@ // Pure helpers for the add-on pages. No React and no network. import { updateView } from './updateState.js' -// Tabs. Apps is for everyone; the other three are management and the server -// checks administration again on every call they make. -export const TABS = ['apps', 'browse', 'installed', 'libraries'] -export const ADMIN_TABS = new Set(['browse', 'installed', 'libraries']) +// Tabs: My add-ons is for everyone; Find add-ons and Sources are management and +// the server checks administration again on every call they make. What the old +// Installed tab showed is now the Details of an add-on in My add-ons, so an old +// ?tab=installed address lands on My add-ons. +export const TABS = ['apps', 'browse', 'libraries'] +export const ADMIN_TABS = new Set(['browse', 'libraries']) export function resolveTab(requested, isAdmin) { if (requested && TABS.includes(requested) && (isAdmin || !ADMIN_TABS.has(requested))) return requested @@ -21,7 +23,25 @@ export function showFirstRun({ apps, isAdmin, canList = true }) { return Boolean(isAdmin) && Boolean(canList) && Array.isArray(apps) && apps.length === 0 } -// The management link of an app opens the Installed tab on that add-on. +// What the My add-ons list shows. Nothing installed is a first run for someone +// who can install, and plain emptiness for anyone else. A search that finds +// nothing says so: it never claims that nothing is installed. +export function listState({ apps, shownCount, query, isAdmin, canList = true }) { + if (showFirstRun({ apps, isAdmin, canList })) return 'first-run' + if (shownCount > 0) return 'list' + if (String(query || '').trim() && Array.isArray(apps) && apps.length > 0) return 'no-match' + return 'none' +} + +// The add-ons to offer when nothing is installed: the first entries of the +// sources that install as add-ons and are not installed yet. The sources do +// not mark any entry as featured, so this is their own order. +export function featuredEntries(entries, installedIds = [], limit = 3) { + const have = new Set(installedIds) + return (Array.isArray(entries) ? entries : []).filter(e => e && e.kind === 'addon' && !have.has(e.id)).slice(0, limit) +} + +// The management link of an app opens the Details of that add-on. export function resolveManage(params, isAdmin) { if (!isAdmin) return '' const id = params.get('manage') || '' @@ -82,6 +102,22 @@ export function toolSummary(app, limit = 4) { return { shown: tools.slice(0, limit), more: Math.max(0, count - Math.min(limit, tools.length)), count } } +// What an add-on adds to LocalAI, from the facts the list already carries: a +// page (an add-on with a UI) and chat tools (the tools it offers the model). +// Nothing here is guessed: no field, no line. +export function addedByAddon(app) { + const tools = toolSummary(app) + return { page: Boolean(app && (app.hasUI || app.mainPage)), tools: tools.count, toolNames: tools.shown, toolsMore: tools.more } +} + +// Screenshots an add-on ships, when the host lists them. The list carries none +// today, so the preview shows no slot; a path is used only when it is served by +// the host itself, as icons are. +export function screenshotsOf(app, limit = 6) { + const list = Array.isArray(app && app.screenshots) ? app.screenshots : [] + return list.filter(src => typeof src === 'string' && src.startsWith('/') && !src.startsWith('//')).slice(0, limit) +} + export function shortDigest(digest) { const text = String(digest || '') const [algorithm, value] = text.includes(':') ? text.split(':') : ['', text] diff --git a/core/http/react-ui/src/components/addons/addons.css b/core/http/react-ui/src/components/addons/addons.css index 4ceaec694..afecbe3c0 100644 --- a/core/http/react-ui/src/components/addons/addons.css +++ b/core/http/react-ui/src/components/addons/addons.css @@ -212,24 +212,22 @@ .ad-drawer__bar { display: flex; flex-wrap: wrap; gap: var(--dk-space-2, 8px); align-items: center; margin-bottom: var(--dk-space-4, 16px); } .ad-drawer__search { flex: 1 1 14rem; min-width: 0; } -.ad-tiles { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr)); gap: var(--dk-space-3, 12px); } -.ad-tile { position: relative; display: flex; align-items: stretch; border: 1px solid var(--dk-edge); border-radius: var(--dk-radius-1); background: var(--dk-surface, transparent); } -.ad-tile__open { flex: 1; min-width: 0; display: flex; gap: var(--dk-space-3, 12px); align-items: center; padding: var(--dk-space-3, 12px); color: inherit; text-decoration: none; background: none; border: 0; text-align: start; cursor: pointer; } +.ad-tiles { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(17rem, 1fr)); gap: var(--dk-space-3, 12px); } +.ad-tile { position: relative; display: flex; flex-direction: column; min-width: 0; border: 1px solid var(--dk-edge); border-radius: var(--dk-radius-1); background: var(--dk-card, var(--dk-surface, transparent)); } +.ad-tile[data-selected='true'] { border-color: var(--dk-accent); box-shadow: 0 0 0 1px var(--dk-accent); } +.ad-tile__open { flex: 1; min-width: 0; display: flex; gap: var(--dk-space-3, 12px); align-items: flex-start; padding: var(--dk-space-3, 12px) 2.75rem var(--dk-space-3, 12px) var(--dk-space-3, 12px); color: inherit; text-decoration: none; background: none; border: 0; text-align: start; cursor: pointer; border-radius: inherit; } .ad-tile__open:hover { background: var(--dk-hover); } -.ad-tile__text { display: flex; flex-direction: column; min-width: 0; } +.ad-tile__text { display: flex; flex-direction: column; gap: 2px; min-width: 0; } .ad-tile__title { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.ad-tile__state { font-size: 0.8125rem; color: var(--dk-text-muted, var(--dk-text)); } +.ad-tile__state { font-size: var(--dk-text-12, 0.75rem); color: var(--dk-muted, var(--dk-text)); } +.ad-tile__state[data-state='running'] { color: var(--dk-ok-text); } +.ad-tile__desc { margin-top: var(--dk-space-1, 4px); font-size: var(--dk-text-13, 0.8125rem); line-height: 1.45; display: -webkit-box; -webkit-line-clamp: 2; line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; overflow-wrap: anywhere; } +.ad-tile__adds { display: flex; flex-wrap: wrap; gap: 2px var(--dk-space-3, 12px); margin-top: var(--dk-space-1, 4px); font-size: var(--dk-text-12, 0.75rem); color: var(--dk-muted); } +.ad-tile__add { display: inline-flex; align-items: center; gap: 4px; } .ad-tile[data-state='stopped'] .ad-icon { opacity: 0.5; } -.ad-tile__order { flex: none; display: flex; flex-direction: column; justify-content: center; } -.ad-tile__move { background: none; border: 0; padding: 0.125rem 0.5rem; color: var(--dk-text-muted, var(--dk-text)); cursor: pointer; } -.ad-tile__move:focus-visible { outline: 2px solid var(--dk-accent); outline-offset: -2px; } -.ad-tile__pin { flex: none; width: 4.25rem; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 0.25rem; padding: 0 0.25rem; font: inherit; font-size: 0.75rem; background: none; border: 0; border-inline-start: 1px solid var(--dk-edge); color: var(--dk-text-muted, var(--dk-text)); cursor: pointer; } -.ad-tile__pin:hover { background: var(--dk-hover); } -.ad-pin__icon { width: 1.125rem; height: 1.125rem; flex: none; } -.ad-pin__label { white-space: nowrap; } -.ad-tile__pin[aria-pressed='true'], .ad-pin[aria-pressed='true'] { color: var(--dk-accent); } -.ad-pin { gap: 0.375rem; } -.ad-tile__open:focus-visible, .ad-tile__pin:focus-visible { outline: 2px solid var(--dk-accent); outline-offset: -2px; } +.ad-tile__side { position: absolute; top: var(--dk-space-2, 8px); right: var(--dk-space-2, 8px); } +.ad-tile__foot { display: flex; padding: 0 var(--dk-space-3, 12px) var(--dk-space-3, 12px); } +.ad-tile__open:focus-visible { outline: 2px solid var(--dk-accent); outline-offset: -2px; } .nav-item--muted { opacity: 0.55; } .nav-item--sub { padding-inline-start: 2.25rem; } @media (max-width: 480px) { .ad-tiles { grid-template-columns: minmax(0, 1fr); } } @@ -243,3 +241,51 @@ .ad-steps__item[data-state="done"] { color: var(--dk-ok-text); } .ad-steps__item[data-state="failed"] { color: var(--dk-error-text); font-weight: 600; } .ad-steps__pct { font-weight: 400; color: var(--dk-muted); } + +/* ---- Preview pane ---------------------------------------------------------- */ + +.ad-split { display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--dk-space-4, 16px); align-items: start; } +.ad-split[data-open='true'] { grid-template-columns: minmax(0, 1fr) minmax(20rem, 24rem); } +.ad-split__list { min-width: 0; display: flex; flex-direction: column; gap: var(--dk-space-3, 12px); } +.ad-pane { display: flex; flex-direction: column; min-width: 0; border: 1px solid var(--dk-edge); border-radius: var(--dk-radius-3, 12px); background: var(--dk-card, var(--dk-surface)); position: sticky; top: var(--dk-space-4, 16px); max-height: calc(100vh - 2 * var(--dk-space-4, 16px)); overflow: auto; outline: none; } +.ad-pane__inner { display: contents; } +.ad-pane__head { display: flex; align-items: flex-start; gap: var(--dk-space-3, 12px); padding: var(--dk-space-4, 16px); border-bottom: 1px solid var(--dk-edge); } +.ad-pane__lead { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: var(--dk-space-1, 4px); align-items: flex-start; } +.ad-pane__title { margin: 0; font-size: var(--dk-text-17, 1.0625rem); font-weight: var(--dk-weight-bold, 700); overflow-wrap: anywhere; } +.ad-pane__close { flex: none; display: inline-grid; place-items: center; width: 2rem; height: 2rem; border: 0; border-radius: var(--dk-radius-2, 8px); background: none; color: var(--dk-muted); cursor: pointer; } +.ad-pane__close:hover { background: var(--dk-hover); color: var(--dk-text); } +.ad-pane__close:focus-visible { outline: 2px solid var(--dk-accent); outline-offset: -2px; } +.ad-pane__body { display: flex; flex-direction: column; gap: var(--dk-space-4, 16px); padding: var(--dk-space-4, 16px); min-width: 0; } +.ad-pane__text { margin: 0; font-size: var(--dk-text-13, 0.8125rem); line-height: 1.55; overflow-wrap: anywhere; } +.ad-pane__head + .ad-pane__text { padding: var(--dk-space-4, 16px); } +.ad-pane__section { display: flex; flex-direction: column; gap: var(--dk-space-2, 8px); } +.ad-pane__acts { display: flex; flex-wrap: wrap; gap: var(--dk-space-2, 8px); } +.ad-pane__facts { margin: 0; } +.ad-adds { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--dk-space-2, 8px); font-size: var(--dk-text-13, 0.8125rem); } +.ad-adds li { display: flex; gap: var(--dk-space-2, 8px); align-items: flex-start; overflow-wrap: anywhere; } +.ad-adds .lai-icon { margin-top: 2px; color: var(--dk-muted); } +.ad-shots { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr)); gap: var(--dk-space-2, 8px); } +.ad-shots img { display: block; width: 100%; aspect-ratio: 16 / 10; object-fit: cover; border: 1px solid var(--dk-edge); border-radius: var(--dk-radius-2, 8px); background: var(--dk-inset); } +@media (max-width: 1179px) { + .ad-split[data-open='true'] { grid-template-columns: minmax(0, 1fr); } + .ad-pane { position: fixed; inset: 0; z-index: 60; max-height: none; border-radius: 0; border: 0; } +} + +/* ---- First run and install flow ------------------------------------------- */ + +.ad-first { gap: var(--dk-space-3, 12px); } +.ad-featured { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 15rem), 1fr)); gap: var(--dk-space-3, 12px); } +.ad-featured__item { display: flex; flex-direction: column; gap: var(--dk-space-2, 8px); min-width: 0; padding: var(--dk-space-3, 12px); border: 1px solid var(--dk-edge); border-radius: var(--dk-radius-2, 8px); background: var(--dk-surface, transparent); } +.ad-flow { margin: 0; display: flex; flex-wrap: wrap; align-items: center; gap: var(--dk-space-2, 8px); font-size: var(--dk-text-13, 0.8125rem); color: var(--dk-muted); } +.ad-flow--failed { color: var(--dk-error-text); } +.ad-entry__details { margin-top: var(--dk-space-2, 8px); font-size: var(--dk-text-13, 0.8125rem); } +.ad-entry__details > summary { cursor: pointer; color: var(--dk-muted); width: fit-content; } +.ad-entry__details > summary:hover { color: var(--dk-text); } +.ad-entry__details > summary:focus-visible { outline: 2px solid var(--dk-accent); outline-offset: 2px; } +.ad-entry__details[open] > summary { margin-bottom: var(--dk-space-2, 8px); } +.ad-steps--stack { flex-direction: column; flex-wrap: nowrap; gap: var(--dk-space-2, 8px); margin: 0; } +.ad-tile__update { font-size: var(--dk-text-12, 0.75rem); color: var(--dk-warn-text); } +.ad-pane__more > summary { cursor: pointer; font-weight: var(--dk-weight-semibold, 600); width: fit-content; } +.ad-pane__more > summary:focus-visible { outline: 2px solid var(--dk-accent); outline-offset: 2px; } +.ad-pane__more[open] > summary { margin-bottom: var(--dk-space-3, 12px); } +.ad-facts-body { display: flex; flex-direction: column; gap: var(--dk-space-4, 16px); } diff --git a/core/http/react-ui/src/components/addons/installFlow.js b/core/http/react-ui/src/components/addons/installFlow.js new file mode 100644 index 000000000..6d69325d6 --- /dev/null +++ b/core/http/react-ui/src/components/addons/installFlow.js @@ -0,0 +1,27 @@ +// Pure of React and of the network: the caller hands in the runner and the clients. +// The two steps of Install and turn on. `run` is the hook's own action runner, +// so each step has its own operation identity and keeps the unknown-outcome +// rule; `mark` receives where the flow is. Pure of React, so it is tested +// with a fake runner. +export async function runInstallAndTurnOn({ run, api, adapter, mark }, entry, { skipInstall = false } = {}) { + if (!skipInstall) { + mark({ step: 'install' }) + const installed = await run(`install:${entry.key}`, { + addonId: entry.id, + submit: (operationId, signal) => adapter.submitLifecycle({ operationId, action: 'install', addonId: entry.id, sourceId: entry.sourceId, version: entry.version }, signal), + verify: async (signal) => (await api.installed(signal)).some(a => a.id === entry.id), + }) + if (!installed) return null + if (installed.outcome !== 'completed') { mark({ step: 'failed', failedStep: 'install', outcome: installed.outcome, error: installed.error }); return installed } + } + mark({ step: 'turn-on' }) + // Generation 0 sets no precondition: the add-on was just installed. + const on = await run(`enable:${entry.id}:0`, { + addonId: entry.id, + submit: (operationId, signal) => adapter.submitLifecycle({ operationId, action: 'enable', addonId: entry.id, generation: 0 }, signal), + verify: async (signal) => (await api.installed(signal)).some(a => a.id === entry.id && a.enabled === true), + }) + if (!on) return null + mark(on.outcome === 'completed' ? { step: 'done' } : { step: 'failed', failedStep: 'turn-on', outcome: on.outcome, error: on.error }) + return on +} diff --git a/core/http/react-ui/src/components/addons/useAddonActions.js b/core/http/react-ui/src/components/addons/useAddonActions.js index d23e5db66..cfdc8d18a 100644 --- a/core/http/react-ui/src/components/addons/useAddonActions.js +++ b/core/http/react-ui/src/components/addons/useAddonActions.js @@ -1,6 +1,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { createActionBook, effectOutcome } from './actionBook' import { runLifecycle, settleOperation } from './lifecycleFlow' +import { runInstallAndTurnOn } from './installFlow' // Runs user actions that change an add-on, one operation identity per action. // @@ -18,7 +19,7 @@ import { runLifecycle, settleOperation } from './lifecycleFlow' // unknown[addonId] is set while an outcome is unknown: the earlier operation // may still be applied, so no other action on that add-on is submitted until // `checkStatus(addonId)` reads it as final or the user calls `dismiss(addonId)`. -export function useAddonActions(api, { onSettled } = {}) { +export function useAddonActions(api, { onSettled, adapter } = {}) { const book = useRef(null) if (book.current === null) book.current = createActionBook() const abort = useRef(null) @@ -26,6 +27,7 @@ export function useAddonActions(api, { onSettled } = {}) { settled.current = onSettled const [state, setState] = useState({}) const [unknown, setUnknown] = useState({}) + const [flows, setFlows] = useState({}) useEffect(() => { abort.current = new AbortController() @@ -124,6 +126,25 @@ export function useAddonActions(api, { onSettled } = {}) { }) }, [setUnknownFor]) + // Install an add-on and turn it on as one action of the user. Each step is + // an ordinary action with its own operation identity, so an unknown outcome + // keeps the rules above. flows[entry.key] says where it is: step 'install' + // or 'turn-on' while it runs, 'done' at the end, or 'failed' with the step + // that failed and the outcome. `skipInstall` turns on an add-on that is + // already installed (the retry after a failed turn on). + const installAndTurnOn = useCallback((entry, options = {}) => { + const flow = `flow:${entry.key}` + return runInstallAndTurnOn({ run, api, adapter, mark: (value) => setFlows(f => ({ ...f, [flow]: value })) }, entry, options) + }, [run, api, adapter]) + + const clearFlow = useCallback((entry) => { + setFlows(f => { + const next = { ...f } + delete next[`flow:${entry.key}`] + return next + }) + }, []) + const clear = useCallback((key) => { setState(s => { const next = { ...s } @@ -132,5 +153,5 @@ export function useAddonActions(api, { onSettled } = {}) { }) }, []) - return { state, unknown, run, checkStatus, dismiss, clear, book: book.current } + return { state, unknown, flows, run, installAndTurnOn, clearFlow, checkStatus, dismiss, clear, book: book.current } } diff --git a/core/http/react-ui/src/components/chat/AddonToolsMenu.jsx b/core/http/react-ui/src/components/chat/AddonToolsMenu.jsx index fd38a435e..2abca4b22 100644 --- a/core/http/react-ui/src/components/chat/AddonToolsMenu.jsx +++ b/core/http/react-ui/src/components/chat/AddonToolsMenu.jsx @@ -1,17 +1,19 @@ -import { useState, useEffect, useRef } from 'react' +import { useState, useEffect, useRef, useId } from 'react' import { useTranslation } from 'react-i18next' import Icon from '../Icon' import { AppIcon } from '../addons/AddonBits' import { apiUrl } from '../../utils/basePath' -// The add-ons that offer tools, as a list with one switch each, in the chat -// header. It reuses the MCP dropdown's button, badge and menu (see -// UnifiedMCPDropdown). `addons` holds only add-ons that ship tools; the page -// renders nothing when there are none. +// The add-ons that offer tools, as a list with one switch each, next to the MCP +// control of the composer. It reuses the MCP dropdown's button, badge and menu +// (see UnifiedMCPDropdown). `addons` holds only add-ons that ship tools; the +// control renders nothing when there are none. One line says what an add-on +// that is off does in this chat. export default function AddonToolsMenu({ addons, enabledIds, onToggle, onToggleAll }) { const { t } = useTranslation('chat') const [open, setOpen] = useState(false) const ref = useRef(null) + const noteId = useId() useEffect(() => { if (!open) return undefined @@ -37,6 +39,7 @@ export default function AddonToolsMenu({ addons, enabledIds, onToggle, onToggleA title={t('addons.title')} aria-haspopup="true" aria-expanded={open} + aria-describedby={open ? noteId : undefined} data-testid="chat-addons-button" onClick={() => setOpen(v => !v)} > @@ -51,6 +54,7 @@ export default function AddonToolsMenu({ addons, enabledIds, onToggle, onToggleA {all ? t('addons.deselectAll') : t('addons.selectAll')} +

    {t('addons.note')}

    {addons.map(a => (