feat(addons): add-ons UI direction B: one name, Turn on and Turn off, preview pane (#12858)

* refactor(addons): one name and one verb pair in the add-ons UI

The sidebar, the page and the tabs now say Add-ons. The tabs are My
add-ons, Find add-ons and Sources. Turn on and Turn off replace Enable,
Start, Disable and Stop wherever they label a button, and the state words
read On and Off. The subtitle no longer names the owner.

Other locales get the new keys; the main labels are translated and the
rest carry the English text until a translator refines them. A unit test
checks that every locale has the keys of English.

Assisted-by: Claude:claude-sonnet-5-5 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(addons): show what an add-on does on its card and move actions to a menu

A card now shows the add-on description, an "Adds a page" line and the
number of chat tools when they apply. The pin button and the bare arrows
are replaced by one overflow menu with Show in sidebar, Details and
Remove. Reordering a pinned add-on stays in the same menu. Remove asks
first and runs the typed uninstall action; only administrators see it.

Assisted-by: Claude:claude-sonnet-5-5 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(addons): add a preview pane for the selected add-on

Selecting a card, or choosing Details in its menu, opens a pane beside
the list. It shows the name and state, the description, what the add-on
adds, Turn on or Turn off and Remove, and its version and source. The pane
replaces the modal sheet and uses the data the list already has. The
source comes from the installed list that the host already serves to
administrators. Screenshot slots show only when the host lists images for
the add-on; today it lists none, so no slot is drawn.

Assisted-by: Claude:claude-sonnet-5-5 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(addons): clear first-run and empty states for My add-ons

With nothing installed, an administrator now sees three add-ons from the
sources, each with Install and turn on, and a link to Find add-ons. A
search that finds nothing says that no add-on matches and offers to clear
the search, instead of saying that nothing is installed. Install and turn
on is one action of the user: it installs, then turns the add-on on, with
a separate operation identity for each step and the progress or the reason
for a failure shown on the card.

Assisted-by: Claude:claude-sonnet-5-5 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(addons): one button and folded details on Find add-ons entries

Each entry shows its description, an "Adds a page" label when the library
entry declares a main page, and one button: Install and turn on. An
add-on that is installed but off offers Turn on. Details is folded into
the entry and shows the trust and build facts the entry already carries,
so the separate details sheet is gone. A backend keeps a plain Install,
because a backend has no on or off.

The library entry carries no tool count, so the entry cannot say how many
chat tools an add-on adds. That label appears on My add-ons, where the
list knows it.

Assisted-by: Claude:claude-sonnet-5-5 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(addons): install and turn on in one dialog with inline progress

Install and turn on now asks once. The dialog then stays open and shows
two steps, the install and the turn on, and ends on the result or on the
reason a step failed with a way to try again. A failed turn on keeps the
install and the retry only turns the add-on on. Each step is its own
action with its own operation identity, so an unknown outcome still
blocks further actions on the add-on until it is read or dismissed. The
first-run cards use the same dialog. The two-step flow is a plain function
with unit tests.

Assisted-by: Claude:claude-sonnet-5-5 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* refactor(addons): fold the Installed tab into My add-ons

The page now has three tabs: My add-ons, Find add-ons and Sources. What
the Installed tab showed is in the preview pane of each add-on, under
More details for administrators: health, trust, compatibility, storage,
data recovery and updates, with Check for updates and Upgrade. Remove
and Turn on or Turn off are on the card and in the pane. An old
?tab=installed address opens My add-ons, and the management link
?manage=ID opens the details of that add-on. A card shows an update mark
when the host reports one. Closing the pane returns focus to its card.

Assisted-by: Claude:claude-sonnet-5-5 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* docs(addons): describe the new add-ons page labels

Update the add-ons feature page and the pages that point at it: the
sidebar item and the page are called Add-ons, the tabs are My add-ons,
Find add-ons and Sources, Turn on and Turn off replace Start and Stop,
the card menu replaces the pin control, and the preview pane holds what
the Installed tab showed.

Assisted-by: Claude:claude-sonnet-5-5 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(addons): say what is wrong on the page of an add-on that cannot open

The page of an add-on that cannot open now depends on the add-on. One
that is installed but off says "<name> is off" and offers Turn on to an
administrator. One that is not installed says so and sends an
administrator to Find add-ons. One that failed shows the reason and a link
to its details. A reader gets a line that says who can fix it, and no
button. The page title is the name of the add-on, with a capital first
letter when the host lists no title, and no longer the bare identifier.

Assisted-by: Claude:claude-sonnet-5-5 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(chat): put the add-on tools control next to MCP and say what off means

The add-on tools control moves from the chat header to the composer, right
after the MCP control, because both decide what the model may call in this
chat. Its menu starts with one line: an add-on that is off here does
nothing in this chat. Only add-ons that ship tools appear in it, as
before.

Assisted-by: Claude:claude-sonnet-5-5 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* chore(addons): clear lint warnings of the new components

Add the lint header the add-on components use for JSX-only imports, drop
an unneeded directive, stop exporting a helper from a component file and
extract the dependencies of an effect.

Assisted-by: Claude:claude-sonnet-5-5 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(addons): update the backend library specs for the folded entry details

A backend says its kind on the entry, an add-on says it under Details,
Details is a fold of the entry and an installed backend has no Install
button.

Assisted-by: Claude:claude-sonnet-5-5 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
mudler-agentandEttore Di Giacinto authored and GitHub committed 2026-10-23 15:25:37 +02:00
1 parent 0a44303c21
commit c6687ce67c
66 files changed
+2850 -1439

No files matched your search

+203 -60
View File
@@ -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)
@@ -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()
@@ -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()
})
+169 -131
View File
@@ -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 }) => {
@@ -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()
@@ -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')
+28 -2
View File
@@ -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')
+1 -1
View File
@@ -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']) {
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -1 +1 @@
182
174
+107 -43
View File
@@ -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"
}
}
@@ -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"
}
}
@@ -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",
@@ -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"
}
}
@@ -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."
}
}
@@ -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."
}
}
}
+107 -43
View File
@@ -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"
}
}
@@ -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"
}
}
@@ -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",
+159 -41
View File
@@ -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"
}
}
@@ -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"
}
}
@@ -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",
+105 -41
View File
@@ -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"
}
}
@@ -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"
}
}
@@ -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",
+107 -43
View File
@@ -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"
}
}
@@ -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"
}
}
@@ -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",
+107 -43
View File
@@ -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"
}
}
@@ -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"
}
}
@@ -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": "상태",
@@ -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"
}
}
@@ -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"
}
}
@@ -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",
@@ -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"
}
}
@@ -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"
}
}
@@ -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": "状态",
+82 -15
View File
@@ -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) => (
<div className="page ad-page ad-page--shell" data-testid="addon-unavailable" data-reason={reason}>
<PageHeader title={title} />
<CatalogEmpty icon="puzzle" title={t('shell.unavailable.title')} text={t(`shell.reasons.${REASONS.includes(reason) ? reason : 'import_failed'}`)} role="status">
<div className="ad-shell-actions">
// 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 && (
<button
type="button"
className="dk-btn dk-btn--primary dk-btn--sm"
disabled={working}
data-testid="addon-turn-on"
onClick={() => actions.run(startKey, {
addonId,
submit: (operationId, signal) => addonsApi.apps.start(addonId, { operationId, generation: app.generation }, signal),
verify: async (signal) => (await addonsApi.apps.list(signal)).find(a => a.id === addonId)?.enabled === true,
})}
>
<Icon name={working ? 'spinner' : 'toggle-on'} spin={working} /> {t('apps.start')}
</button>
)}
{outcome && outcome.outcome && describeOutcome(outcome.outcome, t, outcome.error) && (
<span className="ad-card__note" data-tone="error" role="alert">{describeOutcome(outcome.outcome, t, outcome.error)}</span>
)}
</>
)
} 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 ? <Link className="dk-btn dk-btn--primary dk-btn--sm" to={`/app/addons?tab=browse&q=${encodeURIComponent(addonId)}`} data-testid="addon-find">{t('drawer.browse')}</Link> : null
} else if (known === 'failed') {
state = 'failed'
heading = t('shell.failedTitle', { name: title })
acts = (
<>
{extra}
{back}
</div>
</CatalogEmpty>
{leaveDialog}
{reloadDialog}
</div>
)
{isAdmin && <Link className="dk-btn dk-btn--ghost dk-btn--sm" to={`/app/addons?detail=${encodeURIComponent(addonId)}`} data-testid="addon-details">{t('card.details')}</Link>}
</>
)
}
return (
<div className="page ad-page ad-page--shell" data-testid="addon-unavailable" data-reason={reason} data-state={state || undefined}>
<PageHeader title={title} />
<CatalogEmpty icon="puzzle" title={heading} text={text} role="status">
<div className="ad-shell-actions">
{acts}
{back}
</div>
</CatalogEmpty>
{leaveDialog}
{reloadDialog}
</div>
)
}
const reloadButton = (
<button type="button" className="dk-btn dk-btn--secondary dk-btn--sm" onClick={onReload} disabled={reloading} data-testid="addon-reload">
{reloading ? t('shell.reloading') : t('shell.reload')}
@@ -168,7 +235,7 @@ export default function AddonRoute() {
if (instance && (instance.status === 'unavailable' || instance.status === 'error')) {
return unavailable(instance.reason || status, reloadButton)
}
if (!isStale && status !== 'ready') return unavailable(status === 'incompatible' ? 'incompatible_sdk' : status, status === 'incompatible' ? null : reloadButton)
if (!isStale && status !== 'ready') return unavailable(status === 'incompatible' ? 'incompatible_sdk' : status, status === 'incompatible' || status === 'disabled' ? null : reloadButton)
// No page in the address: the main page, or the first declared one.
if (!isStale && !subpath) {
@@ -0,0 +1,20 @@
/* 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 ActionMenu from '../ActionMenu'
// The overflow menu of an add-on card or its preview: Show in sidebar,
// Details and Remove. Reordering a pinned add-on lives here too, so the card
// carries no bare arrows. `onDetails` is left out where the details are
// already on screen; `onRemove` is left out for someone who cannot remove.
export default function AddonMenu({ name, pinned, onPin, onMove, onDetails, onRemove, compact = true }) {
const { t } = useTranslation('addons')
const items = [
{ key: 'pin', icon: 'pin', label: pinned ? t('card.hideFromSidebar') : t('card.showInSidebar'), onClick: onPin },
{ key: 'earlier', icon: 'chevron-up', label: t('drawer.moveEarlier', { name }), hidden: !pinned || !onMove, onClick: () => onMove(-1) },
{ key: 'later', icon: 'chevron-down', label: t('drawer.moveLater', { name }), hidden: !pinned || !onMove, onClick: () => onMove(1) },
{ key: 'details', icon: 'info', label: t('card.details'), hidden: !onDetails, onClick: onDetails },
{ divider: true, key: 'divider', hidden: !onRemove },
{ key: 'remove', icon: 'trash', label: t('card.remove'), danger: true, hidden: !onRemove, onClick: onRemove },
]
return <ActionMenu items={items} ariaLabel={t('card.menu', { name })} compact={compact} />
}
@@ -0,0 +1,128 @@
/* 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 { apiUrl } from '../../utils/basePath'
import { StateChip, AppIcon, UnknownNotice, UpdateChip } from './AddonBits'
import AddonMenu from './AddonMenu'
import { addedByAddon, appActions, localizedText, screenshotsOf } from './addonState'
import { describeOutcome } from './errors'
// The preview of one add-on, beside the list: what it is, what it adds, and
// what to do with it. It uses what the list already carries. `children` hold
// the operator facts an administrator may open (health, trust, updates).
export default function AddonPreview({ tile, language, title, pinned, onPin, onMove, onClose, actions, onStartStop, onRemove, isAdmin, source, links = [], children }) {
const { t } = useTranslation('addons')
const ref = useRef(null)
const app = tile.app
useEffect(() => { ref.current?.focus({ preventScroll: true }) }, [tile.id])
const onKeyDown = (e) => {
if (e.key === 'Escape' && !e.defaultPrevented) { e.preventDefault(); onClose() }
}
return (
<aside className="ad-pane" ref={ref} tabIndex={-1} data-testid="addon-detail" aria-labelledby="ad-pane-title" onKeyDown={onKeyDown}>
<div className="ad-pane__inner" data-app={tile.id}>
<div className="ad-pane__head">
{tile.source === 'builtin'
? <span className="ad-icon ad-icon--empty" aria-hidden="true"><Icon name={tile.iconName} /></span>
: <AppIcon src={tile.icon ? apiUrl(tile.icon) : ''} />}
<div className="ad-pane__lead">
<h2 className="ad-pane__title" id="ad-pane-title">{title}</h2>
{app && (
<div className="ad-card__chips">
<StateChip app={app} />
{app.update === 'update-pending' && <UpdateChip />}
</div>
)}
</div>
<AddonMenu name={title} pinned={pinned} onPin={onPin} onMove={pinned ? onMove : undefined} onRemove={onRemove} />
<button type="button" className="ad-pane__close" aria-label={t('drawer.close')} onClick={onClose}><Icon name="close" /></button>
</div>
{app ? <PreviewBody app={app} language={language} actions={actions} onStartStop={onStartStop} onRemove={onRemove} isAdmin={isAdmin} source={source} links={links}>{children}</PreviewBody> : <p className="ad-pane__text">{t('drawer.builtinDetail')}</p>}
</div>
</aside>
)
}
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 (
<div className="ad-pane__body">
{description && <p className="ad-pane__text">{description}</p>}
{shots.length > 0 && (
<ul className="ad-shots" data-testid="addon-shots" aria-label={t('preview.screenshots')}>
{shots.map(src => <li key={src}><img src={apiUrl(src)} alt="" loading="lazy" decoding="async" /></li>)}
</ul>
)}
<section className="ad-pane__section" aria-labelledby="ad-pane-adds">
<h3 className="ad-section__title" id="ad-pane-adds">{t('preview.adds')}</h3>
<ul className="ad-adds">
{added.page && <li data-testid="app-page"><Icon name="file" aria-hidden="true" /> <span>{t('preview.page')}</span></li>}
{added.tools > 0 && (
<li data-testid="app-tools">
<Icon name="wrench" aria-hidden="true" />
<span>
{t('card.chatTools', { count: added.tools })}
{added.toolNames.length > 0 && <>{': '}<span className="dk-mono">{added.toolNames.join(', ')}</span>{added.toolsMore > 0 ? ` ${t('apps.toolsMore', { count: added.toolsMore })}` : ''}</>}
</span>
</li>
)}
{!added.page && added.tools === 0 && <li className="ad-quiet">{t('preview.addsNothing')}</li>}
</ul>
{!added.page && <p className="ad-quiet">{t('apps.headless')}</p>}
{links.length > 0 && (
<div className="ad-pane__acts">
{links.map(link => <Link key={link.path} className="dk-btn dk-btn--ghost dk-btn--sm" to={link.path}>{link.label}</Link>)}
</div>
)}
</section>
<div className="ad-pane__acts">
{app.mainPage && (
<Link className="dk-btn dk-btn--primary dk-btn--sm" to={app.mainPage}>
<Icon name="external-link" /> {t('apps.open')}
</Link>
)}
{control.start && (
<button type="button" className={`dk-btn dk-btn--sm${app.mainPage ? '' : ' dk-btn--primary'}`} disabled={locked} onClick={() => onStartStop(app, 'start')}>
<Icon name={working ? 'spinner' : 'toggle-on'} spin={working} /> {t('apps.start')}
</button>
)}
{control.stop && (
<button type="button" className="dk-btn dk-btn--ghost dk-btn--sm" disabled={locked} onClick={() => onStartStop(app, 'stop')}>
<Icon name={working ? 'spinner' : 'toggle-off'} spin={working} /> {t('apps.stop')}
</button>
)}
{onRemove && (
<button type="button" className="dk-btn dk-btn--danger dk-btn--sm" disabled={locked} onClick={onRemove} data-testid="pane-remove">
<Icon name="trash" /> {t('card.remove')}
</button>
)}
</div>
{working && <p className="ad-card__note" role="status">{t('apps.working')}</p>}
{pending
? <UnknownNotice addonId={app.id} actions={actions} className="ad-card__note" />
: outcome && outcome.outcome && describeOutcome(outcome.outcome, t, outcome.error) && (
<p className="ad-card__note" data-tone="error" role="alert">{describeOutcome(outcome.outcome, t, outcome.error)}</p>
)}
<dl className="dk-kv ad-kv ad-pane__facts" data-testid="preview-facts">
<dt>{t('preview.version')}</dt><dd>{app.version}</dd>
{isAdmin && <><dt>{t('trust.library')}</dt><dd data-testid="preview-source">{source ? `${source.sourceId || '—'}${source.channel ? ` · ${source.channel}` : ''}` : t('preview.sourceUnknown')}</dd></>}
<dt>{t('preview.id')}</dt><dd className="dk-mono">{app.id}</dd>
</dl>
{children}
</div>
)
}
@@ -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'
? <span className="ad-icon ad-icon--empty" aria-hidden="true"><Icon name={tile.iconName} /></span>
: <AppIcon src={tile.icon ? apiUrl(tile.icon) : ''} />}
<span className="ad-tile__text">
<span className="ad-tile__title">{title}</span>
<span className="ad-tile__state" data-state={tile.state}>{stateText}{line ? `, ${line}` : ''}</span>
</span>
</>
)
const added = tile.app ? addedByAddon(tile.app) : { page: tile.source === 'builtin', tools: 0 }
return (
<li className="ad-tile" data-testid="addon-tile" data-app={tile.id} data-state={tile.state}>
{tile.target
? <Link className="ad-tile__open" to={tile.target}>{body}</Link>
: <button type="button" className="ad-tile__open" onClick={() => onOpenDetail(tile.id)} aria-label={`${title}, ${stateText}`}>{body}</button>}
{pinned && (
<span className="ad-tile__order">
<button type="button" className="ad-tile__move" data-testid="pin-earlier" aria-label={t('drawer.moveEarlier', { name: title })} onClick={() => onMove(tile.id, -1)}><Icon name="chevron-up" aria-hidden="true" /></button>
<button type="button" className="ad-tile__move" data-testid="pin-later" aria-label={t('drawer.moveLater', { name: title })} onClick={() => onMove(tile.id, 1)}><Icon name="chevron-down" aria-hidden="true" /></button>
<li className="ad-tile" data-testid="addon-tile" data-app={tile.id} data-state={tile.state} data-selected={selected ? 'true' : undefined}>
<button
type="button"
className="ad-tile__open"
aria-pressed={selected}
aria-label={`${title}, ${stateText}${line ? `, ${line}` : ''}`}
onClick={() => onOpenDetail(tile.id)}
>
{tile.source === 'builtin'
? <span className="ad-icon ad-icon--empty" aria-hidden="true"><Icon name={tile.iconName} /></span>
: <AppIcon src={tile.icon ? apiUrl(tile.icon) : ''} />}
<span className="ad-tile__text">
<span className="ad-tile__title">{title}</span>
<span className="ad-tile__state" data-state={tile.state}>{stateText}{line ? `, ${line}` : ''}</span>
{tile.app && tile.app.update === 'update-pending' && <span className="ad-tile__update"><UpdateChip /></span>}
{tile.description && <span className="ad-tile__desc">{tile.description}</span>}
{(added.page || added.tools > 0) && (
<span className="ad-tile__adds" data-testid="tile-adds">
{added.page && <span className="ad-tile__add"><Icon name="file" aria-hidden="true" /> {t('card.addsPage')}</span>}
{added.tools > 0 && <span className="ad-tile__add"><Icon name="wrench" aria-hidden="true" /> {t('card.chatTools', { count: added.tools })}</span>}
</span>
)}
</span>
</button>
<div className="ad-tile__side">
<AddonMenu
name={title}
pinned={pinned}
onPin={() => onPin(tile.id)}
onMove={pinned ? (delta) => onMove(tile.id, delta) : undefined}
onDetails={() => onOpenDetail(tile.id)}
onRemove={onRemove && tile.app ? () => onRemove(tile) : undefined}
/>
</div>
{tile.target && (
<div className="ad-tile__foot">
<Link className="dk-btn dk-btn--sm" to={tile.target} aria-label={t('card.openNamed', { name: title })}>
<Icon name="external-link" /> {t('apps.open')}
</Link>
</div>
)}
<PinControl name={title} pinned={pinned} onToggle={() => onPin(tile.id)} />
</li>
)
}
@@ -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 <div className="op-loading" role="status" aria-label={t('apps.loading')}><LoadingSpinner size="lg" /></div>
@@ -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 (
<div className="ad-apps">
{state.stale && (
@@ -150,58 +235,104 @@ export default function AppsView({ api }) {
<span>{t('apps.stale')}</span>
</div>
)}
<div className="ad-drawer__bar">
<input
type="search"
className="dk-input ad-drawer__search"
data-testid="addon-search"
aria-label={t('drawer.search')}
placeholder={t('drawer.search')}
value={query}
onChange={e => 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 && (
<Link className="dk-btn dk-btn--primary dk-btn--sm" to="/app/addons?tab=browse" data-testid="addons-add"><Icon name="plus" /> {t('drawer.add')}</Link>
{Object.entries(actions.state).filter(([key, r]) => key.startsWith('uninstall:') && r.outcome && r.outcome !== 'completed' && !actions.unknown[key.split(':')[1]]).map(([key, r]) => (
<p key={key} className="ad-card__note" data-tone="error" role="alert" data-testid="remove-error">{describeOutcome(r.outcome, t, r.error)}</p>
))}
{Object.keys(actions.unknown).filter(id => actions.unknown[id].key.startsWith('uninstall:')).map(id => (
<UnknownNotice key={id} addonId={id} actions={actions} className="ad-card__note" />
))}
<div className="ad-split" data-open={detail ? 'true' : 'false'}>
<div className="ad-split__list">
<div className="ad-drawer__bar">
<input
type="search"
className="dk-input ad-drawer__search"
data-testid="addon-search"
aria-label={t('drawer.search')}
placeholder={t('drawer.search')}
value={query}
onChange={e => 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 && (
<Link className="dk-btn dk-btn--primary dk-btn--sm" to="/app/addons?tab=browse" data-testid="addons-add"><Icon name="plus" /> {t('drawer.add')}</Link>
)}
</div>
{view === 'first-run' ? (
<FirstRun adapter={adapter} actions={actions} />
) : view === 'no-match' ? (
<CatalogEmpty icon="search" title={t('apps.noMatchTitle')} text={t('apps.noMatchBody', { query: query.trim() })} role="status">
<div className="ad-section__acts">
<button type="button" className="dk-btn dk-btn--sm" onClick={() => setQuery('')}>{t('apps.clearSearch')}</button>
{isAdmin && <Link className="dk-btn dk-btn--ghost dk-btn--sm" to="/app/addons?tab=browse">{t('drawer.browse')}</Link>}
</div>
</CatalogEmpty>
) : view === 'none' ? (
<CatalogEmpty icon="puzzle" title={t('apps.emptyTitle')} text={t('apps.emptyBody')} />
) : (
<ul className="ad-tiles" aria-label={t('apps.listLabel')}>
{shown.map(tile => (
<AddonTile key={tile.id} tile={tile} pinned={pinnedIds.has(tile.id)} selected={tile.id === detailId} onPin={onPin} onMove={nav.move} onOpenDetail={openDetail} onRemove={isAdmin ? setRemoving : undefined} />
))}
</ul>
)}
</div>
{detail && (
<AddonPreview
tile={detail}
title={titleOf(detail)}
language={i18n.language}
pinned={pinnedIds.has(detail.id)}
onPin={() => 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] && (
<details className="ad-pane__more" open={manageId === detail.id} data-testid="addon-more">
<summary>{t('preview.moreDetails')}</summary>
<OperatorFacts
addon={installedInfo[detail.id]}
api={api}
language={i18n.language}
actions={actions}
check={checks[detail.id]}
upgraded={Boolean(upgraded[detail.id])}
onCheck={() => check(installedInfo[detail.id])}
onUpgrade={() => setConfirmUpgrade(installedInfo[detail.id])}
/>
</details>
)}
</AddonPreview>
)}
</div>
{firstRun ? (
<section className="ad-card ad-first" data-testid="addons-first-run" aria-labelledby="ad-first-title">
<h2 className="ad-card__title" id="ad-first-title">{t('apps.firstRun.title')}</h2>
<ol className="ad-first__steps">
<li><strong>{t('apps.firstRun.libraryTitle')}</strong> {t('apps.firstRun.libraryBody')}</li>
<li><strong>{t('apps.firstRun.browseTitle')}</strong> {t('apps.firstRun.browseBody')}</li>
<li><strong>{t('apps.firstRun.startTitle')}</strong> {t('apps.firstRun.startBody')}</li>
</ol>
<div className="ad-section__acts">
<Link className="dk-btn dk-btn--primary" to="/app/addons?tab=browse" data-testid="addons-first-run-browse"><Icon name="store" /> {t('apps.firstRun.browse')}</Link>
</div>
</section>
) : shown.length === 0 ? (
<CatalogEmpty icon="puzzle" title={t('apps.emptyTitle')} text={t('apps.emptyBody')} />
) : (
<ul className="ad-tiles" aria-label={t('apps.listLabel')}>
{shown.map(tile => (
<AddonTile key={tile.id} tile={tile} pinned={pinnedIds.has(tile.id)} onPin={onPin} onMove={nav.move} onOpenDetail={openDetail} />
))}
</ul>
)}
{!legacy && <div className="ad-nav-slot"><AddonSlot slot="app.nav" context={null} /></div>}
{detail && (
<SideSheet title={titleOf(detail)} onClose={closeDetail} headActions={<PinControl variant="head" name={titleOf(detail)} pinned={pinnedIds.has(detail.id)} onToggle={() => onPin(detail.id)} />} closeLabel={t('drawer.close')} testId="addon-detail">
{detail.app
? (
<>
<ul className="ad-grid"><AppCard app={detail.app} t={t} language={i18n.language} actions={actions} onStartStop={startStop} /></ul>
{navigationItems([detail.app], { language: i18n.language }).map(link => (
<Link key={link.path} className="dk-btn dk-btn--ghost dk-btn--sm" to={link.path}>{link.label}</Link>
))}
</>
)
: <p>{t('drawer.builtinDetail')}</p>}
</SideSheet>
<ConfirmDialog
open={Boolean(removing)}
danger
title={removing ? t('card.removeTitle', { name: removing.title }) : ''}
message={removing ? <p>{t('card.removeBody', { name: removing.title })}</p> : null}
confirmLabel={t('card.remove')}
onConfirm={() => { const x = removing; setRemoving(null); closeDetail(); remove(x) }}
onCancel={() => setRemoving(null)}
/>
{manageId && state.status === 'ready' && !detail && (
<div className="op-notice" role="status"><Icon name="info" /><span>{t('installed.missing', { id: manageId })}</span></div>
)}
<ConfirmDialog
open={Boolean(confirmUpgrade)}
title={confirmUpgrade ? t('updates.confirm.title', { name: confirmUpgrade.title, version: updateView(confirmUpgrade.update).version }) : ''}
message={confirmUpgrade ? <UpgradeSummary addon={confirmUpgrade} t={t} /> : null}
confirmLabel={t('updates.confirm.action')}
onConfirm={() => { const a = confirmUpgrade; setConfirmUpgrade(null); upgrade(a) }}
onCancel={() => setConfirmUpgrade(null)}
/>
{limit && (
<SideSheet title={t('drawer.limitTitle')} onClose={() => setLimit(false)} closeLabel={t('drawer.close')} testId="pin-limit">
<p>{t('drawer.limitBody')}</p>
@@ -210,83 +341,3 @@ export default function AppsView({ api }) {
</div>
)
}
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 (
<li className="ad-card" data-app={app.id} aria-labelledby={titleId}>
<div className="ad-card__head">
<AppIcon src={app.icon ? apiUrl(app.icon) : ''} />
<div className="ad-card__lead">
<h3 className="ad-card__title" id={titleId}>{title}</h3>
<p className="ad-card__meta">
<span className="dk-mono">{app.id}</span>
<span aria-hidden="true">·</span>
<span>{t('apps.version', { version: app.version })}</span>
</p>
</div>
<div className="ad-card__chips">
<StateChip app={app} />
{app.update === 'update-pending' && <UpdateChip />}
</div>
</div>
{description && <p className="ad-card__text">{description}</p>}
{!app.mainPage && tools.count > 0 && (
<p className="ad-card__tools" data-testid="app-tools">
<Icon name="wrench" aria-hidden="true" />
<span>
{t('apps.tools', { count: tools.count })}
{': '}
<span className="dk-mono">{tools.shown.join(', ')}</span>
{tools.more > 0 ? ` ${t('apps.toolsMore', { count: tools.more })}` : ''}
</span>
</p>
)}
{!app.mainPage && !app.hasUI && <p className="ad-card__quiet">{t('apps.headless')}</p>}
<div className="ad-card__acts">
{app.mainPage && (
<Link className="dk-btn dk-btn--primary dk-btn--sm" to={app.mainPage}>
<Icon name="external-link" /> {t('apps.open')}
</Link>
)}
{control.start && (
<button type="button" className="dk-btn dk-btn--sm" disabled={locked} onClick={() => onStartStop(app, 'start')}>
<Icon name={working ? 'spinner' : 'play'} spin={working} /> {t('apps.start')}
</button>
)}
{control.stop && (
<button type="button" className="dk-btn dk-btn--ghost dk-btn--sm" disabled={locked} onClick={() => onStartStop(app, 'stop')}>
<Icon name={working ? 'spinner' : 'stop'} spin={working} /> {t('apps.stop')}
</button>
)}
{control.manage && (
<Link className="dk-btn dk-btn--ghost dk-btn--sm" to={control.manage}>
<Icon name="settings" /> {t('apps.manage')}
</Link>
)}
</div>
{working && <p className="ad-card__note" role="status">{t('apps.working')}</p>}
{pending
? <UnknownNotice addonId={app.id} actions={actions} className="ad-card__note" />
: outcome && outcome.outcome && <ActionResult outcome={outcome} t={t} />}
</li>
)
}
function ActionResult({ outcome, t }) {
const text = describeOutcome(outcome.outcome, t, outcome.error)
if (!text) return null
return <p className="ad-card__note" data-tone="error" role="alert">{text}</p>
}
@@ -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 (
<article className="ad-entry" aria-labelledby={`entry-${entry.key}`}>
<AppIcon src="" />
@@ -107,21 +104,16 @@ export default function BrowseView({ api, adapter }) {
</p>
{entry.description && <p className="ad-entry__text">{entry.description}</p>}
<div className="ad-facts">
<Fact icon={isBackend ? 'cpu' : 'puzzle'}><span data-testid="entry-kind" data-kind={entry.kind}>{t(`browse.kind.${entry.kind}`, { defaultValue: entry.kind })}</span></Fact>
<Fact icon="cpu">{entry.platform.os}/{entry.platform.arch}</Fact>
<Fact icon={entry.policy.requireSignature ? 'shield' : 'info'} tone={entry.policy.requireSignature ? 'ok' : undefined}>
{entry.policy.requireSignature ? t('trust.signatureRequired') : t('trust.digestOnly')}
</Fact>
{entry.channel && <Fact icon="tag">{entry.channel}</Fact>}
{isBackend && <Fact icon="cpu"><span data-testid="entry-kind" data-kind={entry.kind}>{t(`browse.kind.${entry.kind}`, { defaultValue: entry.kind })}</span></Fact>}
{!isBackend && addsPage && <Fact icon="file"><span data-testid="entry-adds">{t('card.addsPage')}</span></Fact>}
{isBackend && have && <Fact icon="check-circle" tone="ok">{t('browse.installedBackend')}</Fact>}
{!isBackend && have && <Fact icon="check-circle" tone="ok">{have.version === entry.version ? t('browse.installedSame', { version: have.version }) : t('browse.installedOther', { version: have.version })}</Fact>}
{!isBackend && have && updateView(have.update).state === 'update-available' && <Fact icon="arrow-up" tone="warn">{t('updates.badge.available', { version: updateView(have.update).version })}</Fact>}
</div>
<EntryFacts entry={entry} have={have} isBackend={isBackend} />
{pending && <UnknownNotice addonId={entry.id} actions={actions} />}
{!pending && st && !working && st.outcome !== 'completed' && (
<p className="ad-entry__note" data-tone="error" role="alert">{describeOutcome(st.outcome, t, st.error)}</p>
)}
{st && st.outcome === 'completed' && <p className="ad-entry__note" data-tone="ok" role="status">{t('browse.installedNotEnabled')}</p>}
{!pending && !(dialog && dialog.key === entry.key) && <FlowStatus flow={flow} name={entry.title} onRetry={flow?.step === 'failed' ? () => actions.installAndTurnOn(entry, { skipInstall: flow.failedStep === 'turn-on' }) : undefined} />}
{flow && flow.step === 'done' && !(dialog && dialog.key === entry.key) && <p className="ad-entry__note" data-tone="ok" role="status">{t('browse.installedOn', { name: entry.title })}</p>}
{bw && bw.phase !== 'done' && <InstallSteps job={bw.job} signed={entry.policy.requireSignature} />}
{bw?.phase === 'failed' && (
<p className="ad-entry__note" data-tone="error" role="alert">
@@ -131,12 +123,21 @@ export default function BrowseView({ api, adapter }) {
{bw?.phase === 'done' && <p className="ad-entry__note" data-tone="ok" role="status">{t('browse.installedBackendNote')}</p>}
</div>
<div className="ad-entry__acts">
<button type="button" className="dk-btn dk-btn--ghost dk-btn--sm" onClick={() => setDetails(entry)}>
<Icon name="info" /> {t('browse.details')}
</button>
<button type="button" className="dk-btn dk-btn--primary dk-btn--sm" disabled={working || pending || Boolean(have)} onClick={() => setConfirm(entry)}>
<Icon name={working ? 'spinner' : 'download'} spin={working} /> {working ? t('browse.installing') : t('browse.install')}
</button>
{isBackend && !have && (
<button type="button" className="dk-btn dk-btn--primary dk-btn--sm" disabled={working} onClick={() => setConfirm(entry)}>
<Icon name={working ? 'spinner' : 'download'} spin={working} /> {working ? t('browse.installing') : t('browse.install')}
</button>
)}
{!isBackend && !have && (
<button type="button" className="dk-btn dk-btn--primary dk-btn--sm" disabled={working || pending} onClick={() => openInstall(entry)}>
<Icon name={working ? 'spinner' : 'download'} spin={working} /> {t('flow.installAndTurnOn')}
</button>
)}
{off && (
<button type="button" className="dk-btn dk-btn--primary dk-btn--sm" disabled={working || pending} onClick={() => actions.installAndTurnOn(entry, { skipInstall: true })}>
<Icon name={working ? 'spinner' : 'toggle-on'} spin={working} /> {t('apps.start')}
</button>
)}
</div>
</article>
)
@@ -155,42 +156,36 @@ export default function BrowseView({ api, adapter }) {
emptyTitle={t('browse.emptyTitle')}
emptyText={t('browse.emptyBody')}
/>
{details && <EntryDetails adapter={adapter} entry={details} onClose={() => setDetails(null)} />}
<InstallDialog entry={dialog} flow={dialog ? actions.flows[`flow:${dialog.key}`] : undefined} actions={actions} onClose={() => setDialog(null)} />
<ConfirmDialog
open={Boolean(confirm)}
title={confirm ? t('browse.confirmTitle', { name: confirm.title }) : ''}
message={confirm ? (
<div className="ad-confirm">
<p>{confirm.kind === 'backend' ? t('browse.confirmBackendBody', { version: confirm.version, library: confirm.sourceId }) : t('browse.confirmBody', { version: confirm.version, library: confirm.sourceId })}</p>
<p>{t('browse.confirmBackendBody', { version: confirm.version, library: confirm.sourceId })}</p>
<dl className="dk-kv">
<dt>{t('trust.digest')}</dt><dd className="dk-mono">{shortDigest(confirm.digest)}</dd>
<dt>{t('trust.verification')}</dt><dd>{confirm.policy.requireSignature ? t('trust.signatureRequired') : t('trust.digestOnly')}</dd>
</dl>
<p>{confirm.kind === 'backend' ? t('browse.confirmBackendNote') : t('browse.confirmNotEnabled')}</p>
<p>{t('browse.confirmBackendNote')}</p>
</div>
) : 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 (
<SideSheet title={e.title} description={t('browse.detailsLead', { id: e.id })} onClose={onClose} closeLabel={t('catalog.close')} labelId="entry-details-title">
{state.status === 'error' && <p className="op-error" role="alert">{describeError(state.error, t)}</p>}
<details className="ad-entry__details" data-testid="entry-details">
<summary>{t('browse.details')}</summary>
<dl className="dk-kv ad-kv">
<dt>{t('browse.kindLabel')}</dt><dd data-testid="details-kind">{t(`browse.kind.${e.kind}`, { defaultValue: e.kind })}</dd>
<dt>{t('trust.version')}</dt><dd>{e.version}</dd>
@@ -201,12 +196,8 @@ function EntryDetails({ adapter, entry, onClose }) {
<dt>{t('trust.verification')}</dt><dd>{e.policy.requireSignature ? t('trust.signatureRequired') : t('trust.digestOnly')}</dd>
{e.policy.requireSignature && (<><dt>{t('trust.issuer')}</dt><dd className="dk-mono ad-wrap">{e.policy.issuer}</dd><dt>{t('trust.identity')}</dt><dd className="dk-mono ad-wrap">{e.policy.identity || e.policy.identityRegex}</dd></>)}
<dt>{t('trust.digest')}</dt><dd className="dk-mono ad-wrap">{e.digest}</dd>
{e.installed && (<><dt>{t('browse.installedState')}</dt><dd>{t('browse.installedDetail', { version: e.installed.version, generation: e.installed.generation })}</dd></>)}
{!isBackend && have && (<><dt>{t('browse.installedState')}</dt><dd>{t('browse.installedDetail', { version: have.version, generation: have.generation })}</dd></>)}
</dl>
{e.installed && updateView(e.installed.update).state === 'update-available' && (
<p className="ad-entry__note" role="status" data-testid="details-update">{t('updates.browseUpdate', { version: updateView(e.installed.update).version })}</p>
)}
{e.description && <p className="ad-entry__text">{e.description}</p>}
</SideSheet>
</details>
)
}
@@ -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 (
<section className="ad-card ad-first" data-testid="addons-first-run" aria-labelledby="ad-first-title">
<h2 className="ad-card__title" id="ad-first-title">{t('apps.firstRun.title')}</h2>
<p className="ad-card__text">{t('apps.firstRun.lead')}</p>
{state.status === 'loading' && <div className="op-loading" role="status" aria-label={t('catalog.loading')}><LoadingSpinner /></div>}
{state.status === 'error' && <p className="ad-card__note" data-tone="error" role="alert">{t('apps.firstRun.featuredFailed')} {describeError(state.error, t)}</p>}
{state.status === 'ready' && state.entries.length === 0 && <p className="ad-card__note" data-testid="first-run-none">{t('apps.firstRun.noFeatured')}</p>}
{state.entries.length > 0 && (
<ul className="ad-featured" aria-label={t('apps.firstRun.featuredLabel')} data-testid="featured">
{state.entries.map(entry => (
<li key={entry.key} className="ad-featured__item" data-addon={entry.id}>
<div className="ad-card__head">
<AppIcon src="" />
<div className="ad-card__lead">
<h3 className="ad-card__title">{entry.title}</h3>
<p className="ad-card__meta">{t('apps.version', { version: entry.version })}</p>
</div>
</div>
{entry.description && <p className="ad-card__text">{entry.description}</p>}
{entry.ui && entry.ui.mainPage && <p className="ad-tile__adds"><span className="ad-tile__add"><Icon name="file" aria-hidden="true" /> {t('card.addsPage')}</span></p>}
<FlowStatus flow={confirm && confirm.key === entry.key ? undefined : flowOf(entry)} name={entry.title} onRetry={flowOf(entry)?.step === 'failed' ? () => actions.installAndTurnOn(entry, { skipInstall: flowOf(entry).failedStep === 'turn-on' }) : undefined} />
<div className="ad-card__acts">
<button type="button" className="dk-btn dk-btn--primary dk-btn--sm" disabled={busy(entry)} onClick={() => setConfirm(entry)}>
<Icon name={busy(entry) ? 'spinner' : 'download'} spin={busy(entry)} /> {t('flow.installAndTurnOn')}
</button>
</div>
</li>
))}
</ul>
)}
<div className="ad-section__acts">
<Link className="dk-btn dk-btn--sm" to="/app/addons?tab=browse" data-testid="addons-first-run-browse"><Icon name="store" /> {t('apps.firstRun.browse')}</Link>
{state.status === 'ready' && state.entries.length === 0 && (
<Link className="dk-btn dk-btn--ghost dk-btn--sm" to="/app/addons?tab=libraries">{t('tabs.libraries')}</Link>
)}
</div>
<InstallDialog entry={confirm} flow={confirm ? actions.flows[`flow:${confirm.key}`] : undefined} actions={actions} onClose={() => setConfirm(null)} />
</section>
)
}
@@ -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 (
<div className="confirm-dialog-backdrop" onClick={running ? undefined : onClose}>
<div ref={ref} className="confirm-dialog" role="alertdialog" aria-modal="true" aria-labelledby="install-dialog-title" data-testid="install-dialog" data-running={running ? 'true' : 'false'} data-step={step} onClick={(e) => e.stopPropagation()}>
<div className="confirm-dialog-header">
<span id="install-dialog-title" className="confirm-dialog-title">{t('flow.confirmTitle', { name: entry.title })}</span>
</div>
<div className="confirm-dialog-body ad-confirm">
{step === 'ask' && (
<>
<p>{t('flow.confirmBody', { version: entry.version, library: entry.sourceId })}</p>
<dl className="dk-kv">
<dt>{t('trust.digest')}</dt><dd className="dk-mono">{shortDigest(entry.digest)}</dd>
<dt>{t('trust.verification')}</dt><dd>{entry.policy.requireSignature ? t('trust.signatureRequired') : t('trust.digestOnly')}</dd>
</dl>
</>
)}
{step !== 'ask' && (
<ol className="ad-steps ad-steps--stack" data-testid="flow-steps" aria-label={t('install.stepsLabel')}>
{['install', 'turn-on'].map(id => {
const state = stepState(id)
return (
<li key={id} className="ad-steps__item" data-step={id} data-state={state}>
<Icon name={state === 'done' ? 'check-circle' : state === 'failed' ? 'alert-circle' : state === 'active' ? 'spinner' : 'circle'} spin={state === 'active'} />
<span>{t(`flow.step.${id}`)}</span>
<span className="dk-sr-only">{t(`install.state.${state}`)}</span>
</li>
)
})}
</ol>
)}
{step === 'done' && (
<p className="ad-entry__note" data-tone="ok" role="status">
{t('browse.installedOn', { name: entry.title })}
{entry.ui && entry.ui.mainPage && <> <Link to="/app/addons">{t('flow.seeInList')}</Link></>}
</p>
)}
{step === 'failed' && (
<p className="ad-entry__note" data-tone="error" role="alert" data-testid="install-dialog-failed">
{flow.failedStep === 'install' ? t('flow.installFailed', { name: entry.title }) : t('flow.turnOnFailed', { name: entry.title })}
{reason ? ` ${reason}` : ''}
</p>
)}
{step === 'failed' && actions.unknown[entry.id] && <UnknownNotice addonId={entry.id} actions={actions} />}
</div>
<div className="confirm-dialog-actions">
{step === 'ask' && <button type="button" className="btn btn-secondary btn-sm" onClick={onClose}>{t('catalog.cancel')}</button>}
{step === 'ask' && <button type="button" className="btn btn-primary btn-sm" onClick={start}>{t('flow.installAndTurnOn')}</button>}
{running && <button type="button" className="btn btn-primary btn-sm" disabled>{step === 'install' ? t('flow.installing', { name: entry.title }) : t('flow.turningOn', { name: entry.title })}</button>}
{step === 'failed' && <button type="button" className="btn btn-secondary btn-sm" onClick={onClose}>{t('catalog.close')}</button>}
{step === 'failed' && !actions.unknown[entry.id] && <button type="button" className="btn btn-primary btn-sm" onClick={retry}>{t('catalog.retry')}</button>}
{step === 'done' && <button type="button" className="btn btn-primary btn-sm" onClick={onClose}>{t('catalog.close')}</button>}
</div>
</div>
</div>
)
}
@@ -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 (
<p className="ad-flow" role="status" data-testid="flow-progress" data-step={flow.step}>
<Icon name="spinner" spin /> {flow.step === 'install' ? t('flow.installing', { name }) : t('flow.turningOn', { name })}
</p>
)
}
const reason = describeOutcome(flow.outcome, t, flow.error)
return (
<div className="ad-flow ad-flow--failed" role="alert" data-testid="flow-failed" data-step={flow.failedStep}>
<span>
{flow.failedStep === 'install' ? t('flow.installFailed', { name }) : t('flow.turnOnFailed', { name })}
{reason ? ` ${reason}` : ''}
</span>
{onRetry && (
<button type="button" className="dk-btn dk-btn--sm" onClick={onRetry}>
<Icon name="refresh" /> {t('catalog.retry')}
</button>
)}
</div>
)
}
@@ -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 <div className="op-loading" role="status" aria-label={t('installed.loading')}><LoadingSpinner size="lg" /></div>
if (state.status === 'error') {
return (
<div className="op-error" role="alert">
<Icon name="alert-circle" />
<span>{describeError(state.error, t)}</span>
<button type="button" className="dk-btn dk-btn--ghost dk-btn--sm" onClick={() => { setState({ status: 'loading', addons: [], error: null }); setReload(n => n + 1) }}>
<Icon name="refresh" /> {t('catalog.retry')}
</button>
</div>
)
}
if (state.addons.length === 0) {
return <CatalogEmpty icon="puzzle" title={t('installed.emptyTitle')} text={t('installed.emptyBody')} />
}
return (
<>
<ul className="cat-list" aria-label={t('installed.listLabel')}>
{state.addons.map(addon => (
<li key={addon.id} className="cat-item">
<InstalledRow addon={addon} t={t} actions={actions} onLifecycle={lifecycle} onManage={onManage} upgraded={Boolean(upgraded[addon.id])} />
</li>
))}
</ul>
{selected && (
<ManageSheet
addon={selected}
api={api}
t={t}
language={i18n.language}
actions={actions}
check={checks[selected.id]}
upgraded={Boolean(upgraded[selected.id])}
onCheck={() => check(selected)}
onUpgrade={() => setConfirmUpgrade(selected)}
onLifecycle={lifecycle}
onUninstall={() => setConfirm(selected)}
onClose={() => onManage('')}
/>
)}
{manage && !selected && state.status === 'ready' && (
<div className="op-notice" role="status"><Icon name="info" /><span>{t('installed.missing', { id: manage })}</span></div>
)}
<ConfirmDialog
open={Boolean(confirm)}
danger
title={confirm ? t('installed.uninstallTitle', { name: confirm.title }) : ''}
message={confirm ? <p>{t('installed.uninstallBody')}</p> : null}
confirmLabel={t('installed.uninstall')}
onConfirm={() => { const a = confirm; setConfirm(null); lifecycle(a, 'uninstall') }}
onCancel={() => setConfirm(null)}
/>
<ConfirmDialog
open={Boolean(confirmUpgrade)}
title={confirmUpgrade ? t('updates.confirm.title', { name: confirmUpgrade.title, version: updateView(confirmUpgrade.update).version }) : ''}
message={confirmUpgrade ? <UpgradeSummary addon={confirmUpgrade} t={t} /> : 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 <Fact icon="arrow-up" tone="warn">{t('updates.badge.available', { version: view.version })}</Fact>
case 'incompatible': return <Fact icon="alert-circle" tone="warn">{t('updates.badge.incompatible', { version: view.version })}</Fact>
case 'check-failed': return <Fact icon="alert-circle" tone="error">{t('updates.badge.failed')}</Fact>
case 'integrity-error': return <Fact icon="alert-circle" tone="error">{t('updates.badge.integrity')}</Fact>
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 (
<article className="ad-entry" aria-labelledby={`inst-${addon.id}`} data-addon={addon.id}>
<AppIcon src={addon.icon ? apiUrl(addon.icon) : ''} />
<div className="ad-entry__body">
<h3 className="ad-entry__title" id={`inst-${addon.id}`}>{addon.title}</h3>
<p className="ad-entry__meta">
<span className="dk-mono">{addon.id}</span>
<span aria-hidden="true">·</span>
<span>{t('apps.version', { version: addon.version })}</span>
<span aria-hidden="true">·</span>
<span>{t('installed.generation', { generation: addon.generation })}</span>
</p>
<div className="ad-facts">
<span className="ad-fact" data-tone={addon.enabled ? 'ok' : undefined}>{addon.enabled ? t('installed.enabled') : t('installed.disabled')}</span>
<StateChip app={addon} />
{addon.recovery && <Fact icon="alert-circle" tone={addon.recovery === 'needs-recovery' ? 'error' : 'warn'}>{t(`recovery.state.${addon.recovery}`)}</Fact>}
<Fact icon={addon.verification === 'sha256+sigstore' ? 'shield' : 'info'} tone={addon.verification === 'sha256+sigstore' ? 'ok' : undefined}>{t(`trust.mode.${addon.verification === 'sha256+sigstore' ? 'signed' : 'digest'}`)}</Fact>
<Fact icon="cpu">{addon.platform.os}/{addon.platform.arch}</Fact>
<UpdateFact view={update} t={t} />
</div>
{working && <p className="ad-entry__note" role="status">{t('apps.working')}</p>}
{upgraded && !working && <p className="ad-entry__note" data-tone="ok" role="status" data-testid="upgrade-done">{t('updates.done', { version: addon.version })}</p>}
{actions.unknown[addon.id]
? <UnknownNotice addonId={addon.id} actions={actions} />
: result && result.outcome !== 'completed' && (
<p className="ad-entry__note" data-tone="error" role="alert" data-testid="upgrade-error">{outcomeText(result, t)}</p>
)}
</div>
<div className="ad-entry__acts">
{addon.enabled ? (
<button type="button" className="dk-btn dk-btn--ghost dk-btn--sm" disabled={working} onClick={() => onLifecycle(addon, 'disable')}>
<Icon name="stop" /> {t('installed.disable')}
</button>
) : (
<button type="button" className="dk-btn dk-btn--primary dk-btn--sm" disabled={working} onClick={() => onLifecycle(addon, 'enable')}>
<Icon name="play" /> {t('installed.enable')}
</button>
)}
<button type="button" className="dk-btn dk-btn--ghost dk-btn--sm" onClick={() => onManage(addon.id)}>
<Icon name="settings" /> {t('installed.manage')}
</button>
</div>
</article>
)
}
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 (
<SideSheet
title={addon.title}
description={addon.description || t('installed.manageLead', { id: addon.id })}
onClose={onClose}
closeLabel={t('catalog.close')}
labelId="manage-title"
testId="addon-manage"
footer={(
<>
<button type="button" className="dk-btn dk-btn--danger dk-btn--sm" disabled={working} onClick={onUninstall}>
<Icon name="trash" /> {t('installed.uninstall')}
</button>
{addon.enabled ? (
<button type="button" className="dk-btn dk-btn--sm" disabled={working} onClick={() => onLifecycle(addon, 'disable')}>
<Icon name="stop" /> {t('installed.disable')}
</button>
) : (
<button type="button" className="dk-btn dk-btn--primary dk-btn--sm" disabled={working} onClick={() => onLifecycle(addon, 'enable')}>
<Icon name="play" /> {t('installed.enable')}
</button>
)}
</>
)}
>
<section className="ad-section" aria-labelledby="manage-health">
<h3 id="manage-health" className="ad-section__title">{t('manage.health')}</h3>
<dl className="dk-kv ad-kv">
<dt>{t('manage.desired')}</dt><dd>{addon.enabled ? t('installed.enabled') : t('installed.disabled')}</dd>
<dt>{t('manage.observed')}</dt><dd><StateChip app={addon} /></dd>
<dt>{t('manage.restarts')}</dt><dd>{addon.restarts}</dd>
</dl>
{addon.enabled && addon.state === 'failed' && <p className="ad-entry__note" data-tone="error" role="alert">{t('manage.failedNote', { count: addon.restarts })}</p>}
{addon.mainPage && <Link className="dk-btn dk-btn--ghost dk-btn--sm" to={addon.mainPage}><Icon name="external-link" /> {t('apps.open')}</Link>}
</section>
<section className="ad-section" aria-labelledby="manage-trust">
<h3 id="manage-trust" className="ad-section__title">{t('manage.trust')}</h3>
<dl className="dk-kv ad-kv">
<dt>{t('trust.verification')}</dt><dd>{t(`trust.mode.${addon.verification === 'sha256+sigstore' ? 'signed' : 'digest'}`)}</dd>
<dt>{t('trust.library')}</dt><dd>{addon.sourceId || '—'}</dd>
{addon.channel && (<><dt>{t('trust.channel')}</dt><dd>{addon.channel}</dd></>)}
{addon.selection && (<><dt>{t('trust.selection')}</dt><dd>{addon.selection}</dd></>)}
<dt>{t('trust.digest')}</dt><dd className="dk-mono ad-wrap" title={addon.digest}>{shortDigest(addon.digest)}</dd>
</dl>
</section>
<section className="ad-section" aria-labelledby="manage-compat">
<h3 id="manage-compat" className="ad-section__title">{t('manage.compatibility')}</h3>
<dl className="dk-kv ad-kv">
<dt>{t('trust.platform')}</dt><dd>{addon.platform.os}/{addon.platform.arch}</dd>
<dt>{t('manage.host')}</dt><dd>{rangeText(compat.host)}</dd>
<dt>{t('manage.api')}</dt><dd>{rangeText(compat.api)}</dd>
<dt>{t('manage.protocol')}</dt><dd>{rangeText(compat.protocol)}</dd>
<dt>{t('manage.dataFormat')}</dt><dd>{rangeText(compat.dataFormat)}</dd>
<dt>{t('manage.tools')}</dt><dd>{addon.toolCount}{addon.tools?.length ? `: ${addon.tools.join(', ')}` : ''}</dd>
<dt>{t('manage.interface')}</dt><dd>{addon.hasUI ? t('manage.hasUI') : t('manage.headless')}</dd>
</dl>
</section>
<section className="ad-section" aria-labelledby="manage-storage">
<h3 id="manage-storage" className="ad-section__title">{t('manage.storage')}</h3>
<dl className="dk-kv ad-kv">
<dt>{t('manage.data')}</dt><dd>{formatBytes(addon.storage?.bytes)}{addon.storage?.truncated ? ` ${t('manage.atLeast')}` : ''}</dd>
<dt>{t('manage.generations')}</dt><dd>{addon.generations}</dd>
</dl>
</section>
<section className="ad-section" aria-labelledby="manage-recovery" data-testid="manage-recovery">
<h3 id="manage-recovery" className="ad-section__title">{t('recovery.title')}</h3>
<RecoveryPanel api={api} addon={addon} />
</section>
<section className="ad-section" aria-labelledby="manage-updates" data-testid="manage-updates">
<h3 id="manage-updates" className="ad-section__title">{t('updates.title')}</h3>
<UpdatePanel addon={addon} t={t} language={language} actions={actions} check={check} upgraded={upgraded} working={working} onCheck={onCheck} onUpgrade={onUpgrade} />
</section>
<section className="ad-section" aria-labelledby="manage-logs">
<h3 id="manage-logs" className="ad-section__title">{t('manage.logs')}</h3>
<p className="ad-quiet">{t('manage.logsNone')}</p>
</section>
</SideSheet>
)
}
// 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 (
<>
<p data-testid="update-state" data-state={view.state} className={view.state === 'check-failed' || view.state === 'integrity-error' ? 'ad-entry__note' : undefined} data-tone={view.tone === 'error' ? 'error' : undefined}>
{t(`updates.state.${view.state}`, { version: view.version })}
</p>
<dl className="dk-kv ad-kv">
<dt>{t('updates.installed')}</dt>
<dd>{addon.version} <span className="dk-mono ad-wrap" title={addon.digest}>{shortDigest(addon.digest)}</span></dd>
{view.version && (view.state === 'update-available' || view.state === 'incompatible' || view.state === 'integrity-error') && (
<>
<dt>{t('updates.candidate')}</dt>
<dd data-testid="update-candidate">{view.version} {view.digest && <span className="dk-mono ad-wrap" title={view.digest}>{shortDigest(view.digest)}</span>}</dd>
</>
)}
<dt>{t('trust.library')}</dt><dd>{addon.sourceId || '—'}{addon.channel ? ` · ${addon.channel}` : addon.selection ? ` · ${addon.selection}` : ''}</dd>
</dl>
{view.reasons.length > 0 && (
<div data-testid="update-reasons">
<p className="ad-quiet">{t('updates.reasons')}</p>
<ul className="ad-reasons">{view.reasons.map(r => <li key={r}>{t(`updates.reason.${r}`)}</li>)}</ul>
</div>
)}
{view.state === 'check-failed' && view.error && <p className="ad-entry__note" data-tone="error" role="status" data-testid="update-error">{t(`updates.class.${view.error}`)}</p>}
<p className="ad-quiet" data-testid="update-times">
{checkedAt ? t('updates.checkedAt', { when: checkedAt }) : t('updates.neverChecked')}
{lastSuccess ? ` · ${t('updates.lastSuccess', { when: lastSuccess })}` : ''}
</p>
{view.stale && <p className="ad-quiet" data-testid="update-stale">{t('updates.stale')}</p>}
{check && check.phase === 'done' && check.throttled && <p className="ad-quiet" role="status">{t('updates.throttled')}</p>}
{check && check.phase === 'error' && <p className="ad-entry__note" data-tone="error" role="alert">{describeError(check.error, t)}</p>}
{upgrading && <p className="ad-entry__note" role="status" data-testid="upgrade-progress">{t('updates.progress', { name: addon.title })}</p>}
{upgraded && !working && <p className="ad-entry__note" data-tone="ok" role="status">{t('updates.done', { version: addon.version })}</p>}
{actions.unknown[addon.id]
? <UnknownNotice addonId={addon.id} actions={actions} />
: result && result.outcome !== 'completed' && <p className="ad-entry__note" data-tone="error" role="alert">{outcomeText(result, t)}</p>}
<div className="ad-section__acts">
<button type="button" className="dk-btn dk-btn--sm" disabled={checking || working} onClick={onCheck} data-testid="update-check">
<Icon name={checking ? 'spinner' : 'refresh'} spin={checking} /> {checking ? t('updates.checking') : t('updates.checkNow')}
</button>
{view.canUpgrade && (
<button type="button" className="dk-btn dk-btn--primary dk-btn--sm" disabled={working} onClick={onUpgrade} data-testid="update-upgrade">
<Icon name="arrow-up" /> {t('updates.upgrade')}
</button>
)}
</div>
</>
)
}
// 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 (
<div data-testid="upgrade-confirm">
<dl className="dk-kv ad-kv">
<dt>{t('updates.confirm.library')}</dt><dd>{addon.sourceId || '—'}</dd>
<dt>{t('updates.confirm.channel')}</dt><dd>{addon.channel || addon.selection || '—'}</dd>
<dt>{t('updates.confirm.from')}</dt><dd>{addon.version} <span className="dk-mono">{shortDigest(addon.digest)}</span></dd>
<dt>{t('updates.confirm.to')}</dt><dd>{view.version} <span className="dk-mono">{shortDigest(view.digest)}</span></dd>
</dl>
<p>{t('updates.confirm.body')}</p>
<p>{t('updates.confirm.data')}</p>
</div>
)
}
@@ -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 (
<div className="ad-facts-body" data-testid="addon-manage">
<section className="ad-section" aria-labelledby="manage-health">
<h3 id="manage-health" className="ad-section__title">{t('manage.health')}</h3>
<dl className="dk-kv ad-kv">
<dt>{t('manage.desired')}</dt><dd>{addon.enabled ? t('installed.enabled') : t('installed.disabled')}</dd>
<dt>{t('manage.observed')}</dt><dd><StateChip app={addon} /></dd>
<dt>{t('manage.restarts')}</dt><dd>{addon.restarts}</dd>
</dl>
{addon.enabled && addon.state === 'failed' && <p className="ad-entry__note" data-tone="error" role="alert">{t('manage.failedNote', { count: addon.restarts })}</p>}
</section>
<section className="ad-section" aria-labelledby="manage-trust">
<h3 id="manage-trust" className="ad-section__title">{t('manage.trust')}</h3>
<dl className="dk-kv ad-kv">
<dt>{t('trust.verification')}</dt><dd>{t(`trust.mode.${addon.verification === 'sha256+sigstore' ? 'signed' : 'digest'}`)}</dd>
<dt>{t('trust.library')}</dt><dd>{addon.sourceId || '—'}</dd>
{addon.channel && (<><dt>{t('trust.channel')}</dt><dd>{addon.channel}</dd></>)}
{addon.selection && (<><dt>{t('trust.selection')}</dt><dd>{addon.selection}</dd></>)}
<dt>{t('trust.digest')}</dt><dd className="dk-mono ad-wrap" title={addon.digest}>{shortDigest(addon.digest)}</dd>
</dl>
</section>
<section className="ad-section" aria-labelledby="manage-compat">
<h3 id="manage-compat" className="ad-section__title">{t('manage.compatibility')}</h3>
<dl className="dk-kv ad-kv">
<dt>{t('trust.platform')}</dt><dd>{addon.platform.os}/{addon.platform.arch}</dd>
<dt>{t('manage.host')}</dt><dd>{rangeText(compat.host)}</dd>
<dt>{t('manage.api')}</dt><dd>{rangeText(compat.api)}</dd>
<dt>{t('manage.protocol')}</dt><dd>{rangeText(compat.protocol)}</dd>
<dt>{t('manage.dataFormat')}</dt><dd>{rangeText(compat.dataFormat)}</dd>
<dt>{t('manage.tools')}</dt><dd>{addon.toolCount}{addon.tools?.length ? `: ${addon.tools.join(', ')}` : ''}</dd>
<dt>{t('manage.interface')}</dt><dd>{addon.hasUI ? t('manage.hasUI') : t('manage.headless')}</dd>
</dl>
</section>
<section className="ad-section" aria-labelledby="manage-storage">
<h3 id="manage-storage" className="ad-section__title">{t('manage.storage')}</h3>
<dl className="dk-kv ad-kv">
<dt>{t('manage.data')}</dt><dd>{formatBytes(addon.storage?.bytes)}{addon.storage?.truncated ? ` ${t('manage.atLeast')}` : ''}</dd>
<dt>{t('manage.generations')}</dt><dd>{addon.generations}</dd>
</dl>
</section>
<section className="ad-section" aria-labelledby="manage-recovery" data-testid="manage-recovery">
<h3 id="manage-recovery" className="ad-section__title">{t('recovery.title')}</h3>
<RecoveryPanel api={api} addon={addon} />
</section>
<section className="ad-section" aria-labelledby="manage-updates" data-testid="manage-updates">
<h3 id="manage-updates" className="ad-section__title">{t('updates.title')}</h3>
<UpdatePanel addon={addon} t={t} language={language} actions={actions} check={check} upgraded={upgraded} working={working} onCheck={onCheck} onUpgrade={onUpgrade} />
</section>
<section className="ad-section" aria-labelledby="manage-logs">
<h3 id="manage-logs" className="ad-section__title">{t('manage.logs')}</h3>
<p className="ad-quiet">{t('manage.logsNone')}</p>
</section>
</div>
)
}
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 (
<>
<p data-testid="update-state" data-state={view.state} className={view.state === 'check-failed' || view.state === 'integrity-error' ? 'ad-entry__note' : undefined} data-tone={view.tone === 'error' ? 'error' : undefined}>
{t(`updates.state.${view.state}`, { version: view.version })}
</p>
<dl className="dk-kv ad-kv">
<dt>{t('updates.installed')}</dt>
<dd>{addon.version} <span className="dk-mono ad-wrap" title={addon.digest}>{shortDigest(addon.digest)}</span></dd>
{view.version && (view.state === 'update-available' || view.state === 'incompatible' || view.state === 'integrity-error') && (
<>
<dt>{t('updates.candidate')}</dt>
<dd data-testid="update-candidate">{view.version} {view.digest && <span className="dk-mono ad-wrap" title={view.digest}>{shortDigest(view.digest)}</span>}</dd>
</>
)}
<dt>{t('trust.library')}</dt><dd>{addon.sourceId || '—'}{addon.channel ? ` · ${addon.channel}` : addon.selection ? ` · ${addon.selection}` : ''}</dd>
</dl>
{view.reasons.length > 0 && (
<div data-testid="update-reasons">
<p className="ad-quiet">{t('updates.reasons')}</p>
<ul className="ad-reasons">{view.reasons.map(r => <li key={r}>{t(`updates.reason.${r}`)}</li>)}</ul>
</div>
)}
{view.state === 'check-failed' && view.error && <p className="ad-entry__note" data-tone="error" role="status" data-testid="update-error">{t(`updates.class.${view.error}`)}</p>}
<p className="ad-quiet" data-testid="update-times">
{checkedAt ? t('updates.checkedAt', { when: checkedAt }) : t('updates.neverChecked')}
{lastSuccess ? ` · ${t('updates.lastSuccess', { when: lastSuccess })}` : ''}
</p>
{view.stale && <p className="ad-quiet" data-testid="update-stale">{t('updates.stale')}</p>}
{check && check.phase === 'done' && check.throttled && <p className="ad-quiet" role="status">{t('updates.throttled')}</p>}
{check && check.phase === 'error' && <p className="ad-entry__note" data-tone="error" role="alert">{describeError(check.error, t)}</p>}
{upgrading && <p className="ad-entry__note" role="status" data-testid="upgrade-progress">{t('updates.progress', { name: addon.title })}</p>}
{upgraded && !working && <p className="ad-entry__note" data-tone="ok" role="status">{t('updates.done', { version: addon.version })}</p>}
{actions.unknown[addon.id]
? <UnknownNotice addonId={addon.id} actions={actions} />
: result && result.outcome !== 'completed' && <p className="ad-entry__note" data-tone="error" role="alert">{outcomeText(result, t)}</p>}
<div className="ad-section__acts">
<button type="button" className="dk-btn dk-btn--sm" disabled={checking || working} onClick={onCheck} data-testid="update-check">
<Icon name={checking ? 'spinner' : 'refresh'} spin={checking} /> {checking ? t('updates.checking') : t('updates.checkNow')}
</button>
{view.canUpgrade && (
<button type="button" className="dk-btn dk-btn--primary dk-btn--sm" disabled={working} onClick={onUpgrade} data-testid="update-upgrade">
<Icon name="arrow-up" /> {t('updates.upgrade')}
</button>
)}
</div>
</>
)
}
// 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 (
<div data-testid="upgrade-confirm">
<dl className="dk-kv ad-kv">
<dt>{t('updates.confirm.library')}</dt><dd>{addon.sourceId || '—'}</dd>
<dt>{t('updates.confirm.channel')}</dt><dd>{addon.channel || addon.selection || '—'}</dd>
<dt>{t('updates.confirm.from')}</dt><dd>{addon.version} <span className="dk-mono">{shortDigest(addon.digest)}</span></dd>
<dt>{t('updates.confirm.to')}</dt><dd>{view.version} <span className="dk-mono">{shortDigest(view.digest)}</span></dd>
</dl>
<p>{t('updates.confirm.body')}</p>
<p>{t('updates.confirm.data')}</p>
</div>
)
}
@@ -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 (
<button
type="button"
className={variant === 'tile' ? 'ad-tile__pin' : 'dk-btn dk-btn--sm ad-pin'}
data-testid="pin-toggle"
data-pinned={pinned ? 'true' : 'false'}
aria-pressed={pinned}
aria-label={pinned ? t('drawer.unpin', { name }) : t('drawer.pin', { name })}
onClick={onToggle}
>
<svg className="ad-pin__icon lai-icon" viewBox="0 0 24 24" focusable="false" aria-hidden="true"
fill={pinned ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 3h6l-1 6 3 3v2H7v-2l3-3zM12 14v7" />
</svg>
<span className="ad-pin__label">{pinned ? t('drawer.pinned') : t('drawer.pinLabel')}</span>
</button>
)
}
+41 -5
View File
@@ -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]
@@ -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); }
+27
View File
@@ -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
}
+23 -2
View File
@@ -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 }
}
@@ -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')}
</button>
</div>
<p className="chat-addons-note" id={noteId} data-testid="chat-addons-note">{t('addons.note')}</p>
{addons.map(a => (
<label key={a.id} className="chat-mcp-server-item" data-testid="chat-addon-item" data-addon={a.id}>
<input type="checkbox" checked={enabledIds.includes(a.id)} onChange={() => onToggle(a.id)} aria-label={a.title} />
@@ -10,7 +10,7 @@ import Icon from '../Icon'
export default function ChatHeader({
historyMenu, manageMode, name, onRename, renaming, setRenaming,
contextPercent, contextTokens, contextSize,
onFind, findOpen, onSettings, settingsOpen, moreItems, addonsMenu,
onFind, findOpen, onSettings, settingsOpen, moreItems,
}) {
const { t } = useTranslation('chat')
const inputRef = useRef(null)
@@ -94,7 +94,6 @@ export default function ChatHeader({
)}
<div className="cx-hdr">
{addonsMenu}
{onFind && (
<button
type="button"
+7 -18
View File
@@ -8,22 +8,22 @@ import { useAuth } from '../context/AuthContext'
import { addonsApi, addonsCatalog } from '../utils/addonsClient'
import AppsView from '../components/addons/AppsView'
import BrowseView from '../components/addons/BrowseView'
import InstalledView from '../components/addons/InstalledView'
import LibrariesView from '../components/addons/LibrariesView'
import { resolveManage, resolveTab, visibleTabs } from '../components/addons/addonState'
import '../components/addons/addons.css'
const TAB_ICONS = { apps: 'puzzle', browse: 'store', installed: 'boxes', libraries: 'book' }
const TAB_ICONS = { apps: 'puzzle', browse: 'store', libraries: 'book' }
// Add-ons. Everyone sees the apps they may use. Administrators also browse
// libraries, manage what is installed and edit libraries. Hiding a tab is a
// convenience: the owner checks administration again on every call.
// Add-ons. Everyone sees the add-ons they may use. Administrators also find
// add-ons and edit sources; what is installed, and how it runs, is in the Details
// of each add-on. Hiding a tab is a convenience: the owner checks administration
// again on every call.
export default function Addons() {
const { t } = useTranslation('addons')
const { isAdmin } = useAuth()
const [params, setParams] = useSearchParams()
const manage = resolveManage(params, isAdmin)
const tab = manage ? 'installed' : resolveTab(params.get('tab'), isAdmin)
const tab = manage ? 'apps' : resolveTab(params.get('tab'), isAdmin)
const tabs = visibleTabs(isAdmin)
const refs = useRef({})
@@ -37,16 +37,6 @@ export default function Addons() {
})
}, [setParams])
const onManage = useCallback((id) => {
setParams(prev => {
const out = new URLSearchParams(prev)
out.set('tab', 'installed')
if (id) out.set('manage', id)
else out.delete('manage')
return out
}, { replace: true })
}, [setParams])
const onKeyDown = (e) => {
const index = tabs.indexOf(tab)
let next = index
@@ -86,9 +76,8 @@ export default function Addons() {
)}
<div role="tabpanel" id={`ad-panel-${tab}`} aria-labelledby={tabs.length > 1 ? `ad-tab-${tab}` : undefined} aria-label={tabs.length > 1 ? undefined : t('tabs.apps')} className="ad-panel">
{tab === 'apps' && <AppsView api={addonsApi} />}
{tab === 'apps' && <AppsView api={addonsApi} adapter={addonsCatalog} />}
{tab === 'browse' && <BrowseView api={addonsApi} adapter={addonsCatalog} />}
{tab === 'installed' && <InstalledView api={addonsApi} adapter={addonsCatalog} manage={manage} onManage={onManage} />}
{tab === 'libraries' && <LibrariesView adapter={addonsCatalog} />}
</div>
</div>
+12 -9
View File
@@ -1056,6 +1056,17 @@ function ChatPage() {
/>
)
// The add-on tools control sits right after the MCP control: both decide what
// the model may call in this chat.
const addonsControl = (
<AddonToolsMenu
addons={toolAddonList}
enabledIds={enabledAddonIds(activeChat, toolAddonList)}
onToggle={(id) => updateChatSettings(activeChat.id, toggleAddonSettings(activeChat, toolAddonList, id))}
onToggleAll={() => updateChatSettings(activeChat.id, toggleAllAddonSettings(activeChat, toolAddonList))}
/>
)
const mcp = (
<UnifiedMCPDropdown
serverMCPAvailable={mcpAvailable}
@@ -1199,14 +1210,6 @@ function ChatPage() {
onSettings={() => setShowSettings(v => !v)}
settingsOpen={showSettings}
moreItems={moreItems}
addonsMenu={(
<AddonToolsMenu
addons={toolAddonList}
enabledIds={enabledAddonIds(activeChat, toolAddonList)}
onToggle={(id) => updateChatSettings(activeChat.id, toggleAddonSettings(activeChat, toolAddonList, id))}
onToggleAll={() => updateChatSettings(activeChat.id, toggleAllAddonSettings(activeChat, toolAddonList))}
/>
)}
/>
{find.open && (
@@ -1294,7 +1297,7 @@ function ChatPage() {
sendTitle={activeChat.model ? t('input.send') : t('input.selectModelFirst')}
textareaRef={textareaRef}
picker={picker}
mcp={mcp}
mcp={<>{mcp}{addonsControl}</>}
chips={composerChips}
extras={<AddonSlot slot="chat.composer.actions" context={composerSlotContext} />}
files={files}
+3 -2
View File
@@ -622,8 +622,9 @@
.cx-hdr { display: flex; align-items: center; gap: 2px; margin-left: 4px; }
.cx-icobtn--lg { width: 36px; height: 36px; font-size: 18px; }
.cx-icobtn[aria-pressed="true"] { background: var(--dk-hover); color: var(--dk-text); }
.cx-hdr .chat-addons-dropdown { margin-right: 4px; }
.cx-hdr .chat-addons-menu { left: auto; right: 0; min-width: 260px; }
.chat-addons-menu { min-width: 21rem; max-width: calc(100vw - 32px); }
@media (max-width: 640px) { .chat-addons-menu { left: auto; right: 0; min-width: 0; width: max-content; } }
.chat-addons-note { margin: 0; padding: var(--spacing-xs, 4px) var(--spacing-md, 12px); font-size: 0.75rem; color: var(--color-text-secondary); border-bottom: 1px solid var(--color-border-divider); }
.chat-addons-menu .chat-mcp-server-item { align-items: center; }
.chat-addons-menu .ad-icon { width: 20px; height: 20px; font-size: 12px; }
.cx-hdr .action-menu__trigger { width: 36px; height: 36px; color: var(--color-text-secondary); }
+124 -2
View File
@@ -1,4 +1,7 @@
import test from 'node:test'
import { readFileSync, readdirSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import assert from 'node:assert/strict'
import {
normalizeQuery, queryKey, queryToParams, paramsToQuery, totalPages, queryToOffset,
@@ -7,13 +10,14 @@ import {
} from '../../src/components/catalog/catalogState.js'
import {
resolveTab, visibleTabs, resolveManage, stateChip, appActions, localizedText, toolSummary,
shortDigest, updateStatus, formatBytes, rangeText, parseEntryKey,
shortDigest, updateStatus, formatBytes, rangeText, parseEntryKey, addedByAddon, screenshotsOf, listState, featuredEntries,
} from '../../src/components/addons/addonState.js'
import { createAddonsApi, createAddonsCatalogAdapter, AddonsError } from '../../src/utils/addonsApi.js'
import { runLifecycle, settleOperation } from '../../src/components/addons/lifecycleFlow.js'
import { createActionBook, effectOutcome } from '../../src/components/addons/actionBook.js'
import { describeError, describeOutcome } from '../../src/components/addons/errors.js'
import { backendState, libraryOrigin, shortDigest as shortBackendDigest } from '../../src/utils/backendRows.js'
import { runInstallAndTurnOn } from '../../src/components/addons/installFlow.js'
import { installSteps, STEP_ORDER } from '../../src/components/addons/installSteps.js'
const json = (status, body) => ({ ok: status >= 200 && status < 300, status, json: async () => body })
@@ -90,7 +94,10 @@ test('library drafts accept references and refuse secrets before sending', () =>
test('tabs: the server decides, the page only avoids showing what it will refuse', () => {
assert.deepEqual(visibleTabs(false), ['apps'])
assert.deepEqual(visibleTabs(true), ['apps', 'browse', 'installed', 'libraries'])
assert.deepEqual(visibleTabs(true), ['apps', 'browse', 'libraries'])
// The Installed tab is folded into My add-ons; an old address lands there.
assert.equal(resolveTab('installed', true), 'apps')
assert.equal(resolveTab('installed', false), 'apps')
assert.equal(resolveTab('libraries', false), 'apps')
assert.equal(resolveTab('libraries', true), 'libraries')
assert.equal(resolveTab('nope', true), 'apps')
@@ -395,3 +402,118 @@ test('the updates route of the host is read as a list and nothing is sent', asyn
assert.deepEqual(await api.backends.updates(), [{ name: 'cpu-demo', sourceId: 'p', version: '2', digest: 'sha256:b', signed: true }])
assert.deepEqual(calls, [{ url: '/backends/library/updates', method: 'GET' }])
})
const localesDir = join(dirname(fileURLToPath(import.meta.url)), '../../public/locales')
const leafKeys = (o, p = '') => Object.entries(o).flatMap(([k, v]) => (v && typeof v === 'object' ? leafKeys(v, `${p}${k}.`) : [`${p}${k}`]))
// A plural form is spelled per language (_one, _many, ...), so compare the stem.
const stems = (o) => new Set(leafKeys(o).map(k => k.replace(/_(zero|one|two|few|many|other)$/, '')))
test('every locale carries the add-on and navigation keys of English', () => {
for (const file of ['addons', 'nav']) {
const en = stems(JSON.parse(readFileSync(join(localesDir, 'en', `${file}.json`), 'utf8')))
assert.ok(en.size > 20)
for (const lang of readdirSync(localesDir)) {
const have = stems(JSON.parse(readFileSync(join(localesDir, lang, `${file}.json`), 'utf8')))
const missing = [...en].filter(k => !have.has(k))
assert.deepEqual(missing, [], `${lang}/${file}.json lacks keys of English`)
}
}
})
test('every locale carries the chat tools control keys of English, with the one-line note', () => {
const read = (lang) => JSON.parse(readFileSync(join(localesDir, lang, 'chat.json'), 'utf8')).addons
const en = read('en')
assert.ok(en.note && !en.note.includes('\n'))
for (const lang of readdirSync(localesDir)) {
const have = read(lang)
for (const key of Object.keys(en)) assert.ok(have && have[key], `${lang}/chat.json lacks addons.${key}`)
}
})
test('the add-on labels use one name and one verb pair', () => {
const en = JSON.parse(readFileSync(join(localesDir, 'en', 'addons.json'), 'utf8'))
const nav = JSON.parse(readFileSync(join(localesDir, 'en', 'nav.json'), 'utf8'))
assert.equal(nav.items.addons, 'Add-ons')
assert.deepEqual([en.tabs.apps, en.tabs.browse, en.tabs.libraries], ['My add-ons', 'Find add-ons', 'Sources'])
assert.deepEqual([en.apps.start, en.apps.stop], ['Turn on', 'Turn off'])
assert.ok(!/owner/i.test(en.supporting), 'the subtitle does not name the owner')
assert.ok(!/\bapps?\b/i.test(JSON.stringify([...Object.values(en.tabs), en.supporting, nav.items.addons])), 'no label says Apps')
})
test('what an add-on adds comes from its page and its tools, and nothing is guessed', () => {
assert.deepEqual(addedByAddon({ hasUI: true, tools: ['add', 'list'], toolCount: 2 }), { page: true, tools: 2, toolNames: ['add', 'list'], toolsMore: 0 })
assert.deepEqual(addedByAddon({ hasUI: false, tools: [], toolCount: 0 }), { page: false, tools: 0, toolNames: [], toolsMore: 0 })
assert.equal(addedByAddon({ mainPage: '/app/addons/x/home' }).page, true)
const many = addedByAddon({ tools: ['a', 'b', 'c', 'd'], toolCount: 9 })
assert.deepEqual([many.tools, many.toolNames.length, many.toolsMore], [9, 4, 5])
assert.deepEqual(addedByAddon(null), { page: false, tools: 0, toolNames: [], toolsMore: 0 })
})
test('screenshots appear only when the host lists them and they are served by the host', () => {
assert.deepEqual(screenshotsOf({}), [])
assert.deepEqual(screenshotsOf({ screenshots: [] }), [])
assert.deepEqual(screenshotsOf({ screenshots: ['/api/addons/apps/x/shot/0', 'https://elsewhere.example/a.png', '//elsewhere.example/b.png', 7] }), ['/api/addons/apps/x/shot/0'])
assert.equal(screenshotsOf({ screenshots: Array.from({ length: 12 }, (_, i) => `/s/${i}`) }).length, 6)
})
test('the list says first run, no match or none, and a search never claims that nothing is installed', () => {
const one = [{ id: 'notes' }]
assert.equal(listState({ apps: [], shownCount: 0, query: '', isAdmin: true }), 'first-run')
assert.equal(listState({ apps: [], shownCount: 0, query: '', isAdmin: false }), 'none')
assert.equal(listState({ apps: [], shownCount: 0, query: '', isAdmin: true, canList: false }), 'none')
assert.equal(listState({ apps: one, shownCount: 1, query: 'no', isAdmin: true }), 'list')
assert.equal(listState({ apps: one, shownCount: 0, query: ' zzz ', isAdmin: true }), 'no-match')
assert.equal(listState({ apps: one, shownCount: 0, query: 'zzz', isAdmin: false }), 'no-match')
assert.equal(listState({ apps: one, shownCount: 0, query: '', isAdmin: false }), 'none')
})
test('featured add-ons are the first three add-on entries that are not installed', () => {
const entries = [{ id: 'a', kind: 'addon' }, { id: 'b', kind: 'backend' }, { id: 'c', kind: 'addon' }, { id: 'd', kind: 'addon' }, { id: 'e', kind: 'addon' }]
assert.deepEqual(featuredEntries(entries).map(e => e.id), ['a', 'c', 'd'])
assert.deepEqual(featuredEntries(entries, ['a']).map(e => e.id), ['c', 'd', 'e'])
assert.deepEqual(featuredEntries(null), [])
})
test('install and turn on runs the install, then the turn on, each as its own action', async () => {
const entry = { key: 'main/tables/1.0.0', id: 'tables', sourceId: 'main', version: '1.0.0' }
const marks = []
const calls = []
const sent = []
const adapter = { submitLifecycle: async (c) => { sent.push(c); return {} } }
const api = { installed: async () => [{ id: 'tables', enabled: true }] }
const run = async (key, { addonId, submit, verify }) => {
calls.push(key)
await submit('op-' + key, undefined)
assert.equal(await verify(), true)
return { outcome: 'completed', operationId: 'op-' + key }
}
await runInstallAndTurnOn({ run, api, adapter, mark: m => marks.push(m.step) }, entry)
assert.deepEqual(calls, ['install:main/tables/1.0.0', 'enable:tables:0'])
assert.deepEqual(marks, ['install', 'turn-on', 'done'])
assert.deepEqual(sent.map(c => [c.action, c.sourceId || '', c.version || '', c.generation || 0]), [['install', 'main', '1.0.0', 0], ['enable', '', '', 0]])
})
test('install and turn on stops at a failed install and retries only the turn on', async () => {
const entry = { key: 'main/tables/1.0.0', id: 'tables', sourceId: 'main', version: '1.0.0' }
const adapter = { submitLifecycle: async () => ({}) }
const api = { installed: async () => [] }
const marks = []
const failing = async (key) => (key.startsWith('install') ? { outcome: 'denied', error: { message: 'no' } } : { outcome: 'completed' })
const calls = []
const run = (fn) => async (key, o) => { calls.push(key); return fn(key, o) }
await runInstallAndTurnOn({ run: run(failing), api, adapter, mark: m => marks.push(m) }, entry)
assert.deepEqual(calls, ['install:main/tables/1.0.0'])
assert.deepEqual(marks.map(m => m.step), ['install', 'failed'])
assert.equal(marks[1].failedStep, 'install')
// The turn on failed after a good install: a retry skips the install.
calls.length = 0
marks.length = 0
const turnOnFails = async (key) => (key.startsWith('enable') ? { outcome: 'unknown' } : { outcome: 'completed' })
await runInstallAndTurnOn({ run: run(turnOnFails), api, adapter, mark: m => marks.push(m) }, entry)
assert.deepEqual(marks.map(m => `${m.step}:${m.failedStep || ''}:${m.outcome || ''}`), ['install::', 'turn-on::', 'failed:turn-on:unknown'])
calls.length = 0
marks.length = 0
await runInstallAndTurnOn({ run: run(async () => ({ outcome: 'completed' })), api, adapter, mark: m => marks.push(m) }, entry, { skipInstall: true })
assert.deepEqual(calls, ['enable:tables:0'])
assert.deepEqual(marks.map(m => m.step), ['turn-on', 'done'])
})
+1 -1
View File
@@ -228,7 +228,7 @@ resource, because the fixture has no add-on that creates one.
| failed upgrade and enable | A new generation that cannot start leaves the last good one running; killing the owner in the middle of an upgrade or an enable keeps the last good code, the retained data and a usable lifecycle |
| disable, remove, reinstall | Data and grants outlive the removal, and a stale generation and its assets are denied after an upgrade |
| sessions and keys | Ending a session or revoking a key on one frontend refuses it on every node |
| browser | A member sees the Apps list only and the server refuses every management request; Open works through a non-owner frontend; a proxy path prefix works for deep links; a session ended on one frontend loses the assets on the others |
| browser | A member sees the add-on list only and the server refuses every management request; Open works through a non-owner frontend; a proxy path prefix works for deep links; a session ended on one frontend loses the assets on the others |
| service layer | An upgrade that cannot start keeps the last good generation running and the data; a request canceled during the start puts the last good generation back, keeps the operation open and the same operation ID finishes it; one broken add-on does not stop the owner |
| regression | The existing model and backend gallery specs (`core/gallery`), the gallery operation specs (`core/services/galleryop`) and the process adapter specs (`pkg/model`) pass |
+65 -50
View File
@@ -124,7 +124,7 @@ A library source is a place LocalAI reads entries from. There are three kinds:
All sources of all kinds are in one store, `addons/sources.json` under the data path.
`local-ai library` (see the [CLI reference](/reference/cli-reference/#library-sources)), the
HTTP routes below and the Libraries tab work on the same store with the same code, so
HTTP routes below and the Sources tab work on the same store with the same code, so
a change made in one place shows in the others.
A source can be a default: one that LocalAI ships (the official add-on library, the
@@ -135,9 +135,9 @@ you remove a default, LocalAI keeps a record of the removal (a tombstone, `remov
true`), so the default does not come back at the next start. Turn the source on again
to bring it back. A source you added is deleted when you remove it.
### The Libraries tab
### The Sources tab
The Libraries tab of the Add-ons page lists the sources of all three kinds. Use the kind
The Sources tab of the Add-ons page lists the sources of all three kinds. Use the kind
filter to show only add-ons, backends or models. Each source shows its kind, a Default
badge for a shipped default, and its state: enabled, disabled, removed or, while the
official libraries are off, off. Administrators can add a source (kind, format, name and
@@ -166,7 +166,7 @@ in the group Backends and galleries. On the command line it is
When it is off, LocalAI does not read the default sources. The default add-on library,
model gallery index and backend gallery index are not used for search, install or
update checks. They stay in the store, still marked `default`, and the Libraries tab shows
update checks. They stay in the store, still marked `default`, and the Sources tab shows
them as off (`suspended: true` in the routes). Each source keeps its own enabled state, so turning the
setting back on restores exactly what was there. Sources you added are not affected. The
change applies at once, with no restart.
@@ -208,7 +208,7 @@ backends, the way the model library is for the models. The backend gallery
(`backend/index.yaml`) is a generated copy of it. A tagged build before the cutover reads
the gallery by default; a build from master reads the library. See
[The backend library](#the-backend-library). A library entry of kind `backend` can also be
installed by digest from the Browse tab, as described below.
installed by digest from the Find add-ons tab, as described below.
### How a reader treats an unknown kind
@@ -217,7 +217,7 @@ not read the fields of a skipped entry, so a newer kind can carry fields this ve
never seen. The index is not refused. A skipped entry is reported in two places:
- the refresh result and the state of the library have `skipped` (a count) and
`skippedKinds`. The Libraries tab shows them;
`skippedKinds`. The Sources tab shows them;
- the refresh writes a warning to the log.
A kind that is not a plain lowercase name, or that is not text, is a malformed entry
@@ -304,10 +304,11 @@ signer exactly.
### Install a backend from a library
An administrator installs a backend from the Browse tab of the Add-ons page. The entry
An administrator installs a backend from the Find add-ons tab of the Add-ons page. The entry
shows its kind. The page sends the install to the backend route, not to the add-on
lifecycle: an add-on stays disabled until you enable it, a backend is ready to use when
the install ends.
lifecycle. The page installs an add-on and turns it on in one action (Install and turn
on); the install itself leaves an add-on disabled. A backend has no on or off: it is
ready to use when the install ends, and its button says Install.
The route is `POST /backends/library/install`. It is for administrators and exists only
when the add-on owner runs on this instance:
@@ -335,7 +336,7 @@ follow it. The `phase` of the job is the step it is in, in this order: `resolvin
`verifying` (only when the library requires a signature), `size`, `fetching` (with
`progress` and the sizes of the fetch) and `writing`. A job that ends has `processed` set.
It ends with the phase `done` and `message` `completed`, or with `error` set and the
`phase` of the step that failed. The Browse tab shows these steps. Refusals that depend on
`phase` of the step that failed. The Find add-ons tab shows these steps. Refusals that depend on
the entry arrive this way: an unknown entry, a name installed from another origin (a
gallery, another library or a manual install; nothing changes), an image over the size
limit, a library that strict mode refuses. The request itself answers `400` when it does
@@ -377,7 +378,7 @@ backend gallery: 1088 backends (934 concrete backends and 154 meta backends) in
entries, because a backend built for several platforms has one entry for each. LocalAI
registers it on the first start as the source `official-backends`, next to the backend
gallery `localai-backends`, and a source you remove stays removed. To add it again later,
use the Libraries tab with this URL and the policy above:
use the Sources tab with this URL and the policy above:
```
https://raw.githubusercontent.com/mudler/LocalAI/master/gallery/backends/index.yaml
@@ -1940,7 +1941,7 @@ are a later task and will have their own review.
### Built-in add-ons
`GET /api/addons/builtin` lists the page add-ons that ship inside the web bundle, so the
Add-ons drawer can show them next to the installed apps. The answer is
My add-ons page can show them next to the installed add-ons. The answer is
`{"builtin": [...]}`, and each entry has an `id`, a `kind` (always `page`), a `titleKey`,
an `icon` and a `mainPath`, plus a `feature` or `adminOnly` when the entry has a gate.
Today the list is empty: Studio and Talk, which used to be built in, are packaged
@@ -1953,7 +1954,7 @@ sent with `Cache-Control: private, no-store`.
### Pins
Pinned apps are listed in the sidebar under Apps, in pin order. A pinned tile in the add-ons drawer has buttons to move it earlier or later.
Pinned add-ons are listed in the sidebar under Add-ons, in pin order. The menu of a pinned card in My add-ons has **Move earlier** and **Move later**.
`GET /api/addons/pins` returns the caller's pinned add-on IDs as a JSON array, in order.
`PUT /api/addons/pins` takes a JSON array of add-on IDs, replaces the caller's pins with it
@@ -1994,26 +1995,28 @@ serves `/api/addons/builtin` and `/api/addons/pins`.
## Add-ons page and management
The web UI serves one page for add-ons at `/app/addons`, the **Add-ons drawer**. It is
The web UI serves one page for add-ons at `/app/addons`, called **Add-ons**. It is
shown to a caller who holds the `addons` feature, and to anyone who can open a built-in
tile (none today). Studio and Talk are add-ons: they are in the drawer only while they
are installed. Administrators also
reach **Browse**, **Installed** and **Libraries** from the drawer. The tabs are a
tile (none today). Studio and Talk are add-ons: they are listed only while they
are installed. The first tab is **My add-ons**. Administrators also
reach **Find add-ons** and **Sources**. The tabs are a
convenience: the owner checks administration again on every call, so a request that skips
the page is denied the same way (403).
The sidebar has the items **Chat**, **Models** and **Apps**. **Apps** opens the add-ons drawer at
`/app/addons`. The pinned apps are listed under it, in pin order, as indented links with the app icon and title; nothing is listed when no app is
pinned, and the list is hidden when the sidebar is collapsed. The user pins add-ons and changes
their order with the buttons on a pinned tile in the drawer. Pins belong to the user, follow the user across browsers, and are
The sidebar has the items **Chat**, **Models** and **Add-ons**. **Add-ons** opens the page at
`/app/addons`. The add-ons a user shows in the sidebar are listed under it, in order, as indented links with the add-on icon and title; nothing is listed when none is
shown, and the list is hidden when the sidebar is collapsed. The user adds an add-on to the sidebar with **Show in sidebar** in the menu of its card, and
changes the order with **Move earlier** and **Move later** in the same menu. Pins belong to the user, follow the user across browsers, and are
capped at eight. A pin whose add-on is stopped opens the detail view; a pin whose add-on
was removed or is hidden is not drawn. Operators who
need the previous sidebar can set `LOCALAI_LEGACY_NAVIGATION=true` (flag
`--legacy-navigation`); it also brings back the Add-ons tab of the Build hub, which then opens `/app/addons`. The switch is a rollback aid and is removed one release after the
new navigation ships; the UI reads it from the `addon_navigation` key of `/api/features`.
A tab is addressed as `/app/addons?tab=browse|installed|libraries`. The management link of
an app (`/app/addons?manage=ID`) opens the Installed tab with that addon's details open.
A tab is addressed as `/app/addons?tab=browse|libraries`; My add-ons has no `tab`. What the
former Installed tab showed is the **More details** section of an add-on in My add-ons, so
`?tab=installed` opens My add-ons. The management link of an app
(`/app/addons?manage=ID`) opens My add-ons with the details of that add-on open.
### Old links
@@ -2054,13 +2057,13 @@ A link with a model (`/app/image/<model>`) opens `/app/addons/studio/studio/imag
The legacy switch (`LOCALAI_LEGACY_NAVIGATION`) restores the old sidebar and the Add-ons
tab of the Build hub only. These redirects do not depend on it: with the switch on, the
old generator links also open the Studio add-on. The legacy sidebar lists Home, Models, Chat and
the hubs; Studio and Talk are in the drawer, as add-ons.
the hubs; Studio and Talk are in My add-ons, as add-ons.
### The Studio add-on
The Studio add-on (`studio`) is a packaged add-on. It is not shipped with LocalAI: install
it from **Browse add-ons** in the drawer and start it. Once it is usable, it appears in
the drawer and can be pinned. Until then the sidebar, the drawer and the Home `/studio`
it from **Find add-ons** with **Install and turn on**. Once it is usable, it appears in
My add-ons and can be shown in the sidebar. Until then the sidebar, My add-ons and the Home `/studio`
command do not list it, and the old Studio links open the add-on page, which says the
add-on is not available.
@@ -2086,7 +2089,7 @@ the `media` locale files of the web UI, in every language the web UI has.
### The Talk add-on
The Talk add-on (`talk`) is a packaged add-on, separate from Studio. Install it from
**Browse add-ons** and start it. It adds the page `/app/addons/talk/talk` (main page) for
**Find add-ons** with **Install and turn on**. It adds the page `/app/addons/talk/talk` (main page) for
users with the `addons` feature. The page opens a WebRTC session to a realtime pipeline
through `/v1/realtime/calls` and reads the pipeline list from `/api/pipeline-models`, with
the signed-in user's own session; the core keeps both routes. The page uses the MCP servers
@@ -2096,10 +2099,10 @@ file of the web UI; other languages read the English text.
### The Voice add-on
The Voice add-on (`voice`) is a packaged add-on. It is not shipped with LocalAI: install it
from **Browse add-ons** in the drawer and start it. Once it is usable, it appears in the
drawer and can be pinned. The Build hub has no Voices tab.
from **Find add-ons** with **Install and turn on**. Once it is usable, it appears in
My add-ons and can be shown in the sidebar. The Build hub has no Voices tab.
The add-on has one page, `/app/addons/voice/voice`, and one entry in the drawer. Its
The add-on has one page, `/app/addons/voice/voice`, and one entry in My add-ons. Its
sections are tabs of that page, and the tab is the next segment of the address:
| Tab | Path | Who sees it |
@@ -2130,8 +2133,8 @@ views, built from one source directory into each add-on.
### The Face add-on
The Face add-on (`face`) is a packaged add-on, separate from the Voice add-on. It is not
shipped with LocalAI: install it from **Browse add-ons** in the drawer and start it. Once it
is usable, it appears in the drawer and can be pinned. The Build hub has no Faces tab.
shipped with LocalAI: install it from **Find add-ons** with **Install and turn on**. Once it
is usable, it appears in My add-ons and can be shown in the sidebar. The Build hub has no Faces tab.
| Page | Path | Who sees it |
|---|---|---|
@@ -2151,8 +2154,8 @@ same views, built from one source directory into each add-on.
### The Fine-tuning and Quantization add-ons
The Fine-tuning add-on (`fine-tuning`) and the Quantization add-on (`quantization`) are
packaged add-ons. They are not shipped with LocalAI: install them from **Browse add-ons**
in the drawer and start them. Once usable, they appear in the drawer and can be pinned.
packaged add-ons. They are not shipped with LocalAI: install them from **Find add-ons**
with **Install and turn on**. Once usable, they appear in My add-ons and can be shown in the sidebar.
The Build hub has no Fine-Tune or Quantize tab.
| Add-on | Page | Path | Who sees it |
@@ -2169,31 +2172,43 @@ reach the engine through [Host operations](#host-operations), and a tool can con
a job that a tool started. See [Fine-tuning](../fine-tuning/#fine-tuning-is-an-add-on) and
[Quantization](../quantization/#quantization-is-an-add-on).
### The drawer
### My add-ons
The drawer is a grid of tiles with a search box. The built-in tiles (none today) come
first, then the installed add-ons by identifier. Each tile has a pin control, a real
button with a name and a pressed state, so it works from the keyboard; a pinned tile also
has buttons to move it earlier or later. Pinning a ninth add-on opens a dialog that asks
the user to unpin one first. A running tile opens the add-on; every other tile opens a
detail view (`/app/addons?detail=ID`) and never the main page, because the main page
needs a live process. Administrators also see **Browse add-ons** and **Add a new add-on**.
A caller without the `addons` feature sees only the built-in tiles (none today), and the page does not
My add-ons is a grid of cards with a search box. The built-in cards (none today) come
first, then the installed add-ons by identifier. A card shows the icon, the name, the state
(**On**, **Off**, or the state the host observed, such as **Starting** or **Failed**), the
description, **Adds a page** when the add-on has a page and the number of **chat tools** when
it has tools. A running add-on with a page also has an **Open** link. Each card has one
overflow menu with **Show in sidebar** (or **Remove from sidebar**), **Details**, and, for an
administrator, **Remove**. A pinned add-on also has **Move earlier** and **Move later** in the
menu. Showing a ninth add-on in the sidebar opens a dialog that asks the user to remove one
first. Selecting a card, or **Details**, opens the preview pane beside the list
(`/app/addons?detail=ID`) and never the main page, because the main page needs a live
process. The pane shows the name and state, the description, screenshots only when the host
lists images for the add-on (it lists none today, so no slot is drawn), what the add-on adds
(its page and its chat tools), **Turn on** or **Turn off**, **Remove** and the version and
source. For an administrator it also has **More details**: health, trust, compatibility,
storage, data recovery and updates. Administrators also see **Find add-ons**.
A caller without the `addons` feature sees only the built-in cards (none today), and the page does not
call `GET /api/addons/apps` for that caller.
The detail view shows the entry as described next, and the pages the add-on names in
`ui.navigation`. The drawer draws `GET /api/addons/apps`: the icon from the host's icon route, the
With nothing installed, an administrator sees three add-ons from the sources, each with
**Install and turn on**, and a link to **Find add-ons**. A search that matches nothing says
that no add-on matches it.
The pane also lists the pages the add-on names in
`ui.navigation`. The page draws `GET /api/addons/apps`: the icon from the host's icon route, the
title in the interface language (falling back to the default text and then to the
identifier), the description, a state chip (`running`, `starting`, `failed`, `stopped`),
an **update pending** chip when the owner's cached check reports one, an **Open** link only when the entry
has a `mainPage`, and for an add-on without pages a short tool summary. Icons always
has a `mainPage`, and a tool summary. Icons always
come from the same origin; the page never loads an image from a manifest or a library.
The list is read again every 5 seconds (30 seconds in a hidden tab). A failed read
doubles the wait up to 60 seconds, keeps the last list on screen and says it may be
out of date. Leaving the page cancels the pending read. Administrators see **Start**,
**Stop** and **Manage** on an entry that carries the `admin` object. A start or stop
sends a fresh operation ID with the generation that was listed and keeps that ID for
out of date. Leaving the page cancels the pending read. Administrators see **Turn on** and
**Turn off** on an entry that carries the `admin` object. They send
a fresh operation ID with the generation that was listed and keep that ID for
retries of that one action: once the operation is final (completed, failed or refused)
the ID is dropped, so the next click is a new operation and is never answered from an
old one. After `outcome_unknown` the page reads the original
@@ -2925,7 +2940,7 @@ Voice, Face, fine-tuning, quantization, Studio and Talk enable all four. A test
platform is enabled without a recorded check.
Each package also has a catalog template in `gallery/addons/<id>.yaml.tmpl`, so it can be
listed in Browse add-ons once a maintainer renders and publishes its entries. See
listed in Find add-ons once a maintainer renders and publishes its entries. See
`gallery/addons/README.md` for the commands.
Every add-on has its own repository. Tables publishes to `ADDON_OCI_REPOSITORY`. Each
+1 -1
View File
@@ -216,7 +216,7 @@ A user needs the fine-tuning permission. Without it, the page says the account c
### Fine-tuning is an add-on
The Fine-Tune page is the `fine-tuning` add-on. It does not ship with LocalAI. An administrator installs it from **Browse add-ons** in the drawer and starts it. Once it runs, it opens from the drawer at `/app/addons/fine-tuning/jobs` and can be pinned. The Build hub no longer has a Fine-Tune tab.
The Fine-Tune page is the `fine-tuning` add-on. It does not ship with LocalAI. An administrator installs it from **Find add-ons** with **Install and turn on**. Once it is on, it opens from My add-ons at `/app/addons/fine-tuning/jobs` and can be shown in the sidebar. The Build hub no longer has a Fine-Tune tab.
The old link `/app/fine-tune` keeps working. It opens the add-on page while the add-on is installed and usable, and it opens the same page in the core web UI otherwise.
+1 -1
View File
@@ -155,7 +155,7 @@ A user needs the quantization permission. Without it, the page says the account
### Quantization is an add-on
The Quantize page is the `quantization` add-on. It does not ship with LocalAI. An administrator installs it from **Browse add-ons** in the drawer and starts it. Once it runs, it opens from the drawer at `/app/addons/quantization/jobs` and can be pinned. The Build hub no longer has a Quantize tab.
The Quantize page is the `quantization` add-on. It does not ship with LocalAI. An administrator installs it from **Find add-ons** with **Install and turn on**. Once it is on, it opens from My add-ons at `/app/addons/quantization/jobs` and can be shown in the sidebar. The Build hub no longer has a Quantize tab.
The old link `/app/quantize` keeps working. It opens the add-on page while the add-on is installed and usable, and it opens the same page in the core web UI otherwise.
+1 -1
View File
@@ -6,7 +6,7 @@ url = "/features/studio/"
+++
{{% notice note %}}
Studio is an add-on. It is not part of the web bundle: install it from **Browse add-ons** in the [Add-ons](../addons/#the-studio-add-on) drawer and start it. Once it is usable, it is in the drawer and can be pinned, and it opens at `/app/addons/studio/studio`. The old links (`/app/studio`, `/app/image` and the other generator links) open it there.
Studio is an add-on. It is not part of the web bundle: install it from **Find add-ons** in the [Add-ons](../addons/#the-studio-add-on) page with **Install and turn on**. Once it is usable, it is in My add-ons and can be shown in the sidebar, and it opens at `/app/addons/studio/studio`. The old links (`/app/studio`, `/app/image` and the other generator links) open it there.
{{% /notice %}}
Studio is the part of the web UI where you make images, video, 3D objects, speech, sound and transformed audio, and find what you made before. It opens on a front page with a prompt box and your own work. Each type still has its own workspace page (Images, Video, 3D, TTS, Sound, Transform, Diarization) where a run happens.
+1 -1
View File
@@ -331,7 +331,7 @@ deleted is refused like an unknown one. With none selected the tool answers a
Tables ships its own UI as a standalone ES module in the package. It is loaded by
the host after the host was built, so installing or updating Tables changes the
pages without a rebuild of LocalAI. The Add-ons drawer shows a **Tables** tile.
pages without a rebuild of LocalAI. The My add-ons page shows a **Tables** card.
| Where | What |
|---|---|
+8 -6
View File
@@ -1997,14 +1997,16 @@
"apps: an add-on that contributes UI keeps the list current off the page and its disable stops the reads",
"apps: a viewer without the add-ons feature never requests the list",
"browse: searches the libraries, pages and shows trust and platform facts",
"browse: installing asks first and leaves the add-on disabled until it is enabled",
"browse: Install and turn on asks once, installs and turns the add-on on",
"browse: a failed turn on keeps the install, says why in the dialog and can be tried again",
"browse: an add-on that is installed but off offers Turn on and nothing else",
"installed: health is separate from enabled and the details show trust, compatibility and storage",
"installed: an update is shown only from the owner update field and upgrade sends that version",
"installed: uninstalling warns that data is kept before anything is submitted",
"installed: Escape cancels the uninstall dialog only, then closes the sheet, and focus returns",
"installed: an unknown enable blocks other actions on the add-on until it is read",
"installed: removing warns that data is kept before anything is submitted",
"installed: Escape cancels the remove dialog only, then closes the pane, and focus returns to the card",
"installed: a headless add-on keeps its details, health and tools",
"installed: a refusal from the server is shown even though the tab was offered",
"installed: a refusal of the installed list leaves the pane without Details",
"installed: an old Installed address lands on My add-ons and the management link opens the details",
"libraries: a private library is saved by credential reference and never carries a secret",
"libraries: removing a library says installed add-ons stay and leaves them installed",
"libraries: a refresh reports its result and a failed fetch is shown as failed",
@@ -2171,7 +2173,7 @@
"regression: process adapter specs (pkg/model)",
"foundation: a member opens the add-on through each non-owner frontend and its module comes from the owner by digest",
"foundation: the owner and both frontends list the same add-on to the same account",
"foundation: a member sees the Apps list only and the server refuses every management request",
"foundation: a member sees the add-on list only and the server refuses every management request",
"foundation: an account without the add-ons feature gets no apps and no code on any node",
"foundation: an administrator manages through a frontend and the change is the owner's",
"foundation: deep links of the add-on page work behind a proxy path prefix on a non-owner frontend",