diff --git a/core/http/react-ui/e2e/addons-install-file.spec.js b/core/http/react-ui/e2e/addons-install-file.spec.js
new file mode 100644
index 0000000000..03d6d4109f
--- /dev/null
+++ b/core/http/react-ui/e2e/addons-install-file.spec.js
@@ -0,0 +1,195 @@
+import { test, expect } from '@playwright/test'
+import { ADMIN, app, installedAddon, sessionFor, mockAddons } from './addons-fixtures.js'
+
+// Install from a file: the button on the Add-ons page, the dialog with the
+// unsigned notice and the host access step, the upgrade and the downgrade
+// rules, and the link that `local-ai addons install` prints. The owner decides
+// every answer, so these specs drive the page against a stand-in for the file
+// routes and the host-grants routes and record what the page sent.
+
+const shots = process.env.UI_SHOTS_DIR
+
+async function shoot(page, name) {
+ if (!shots) return
+ await page.emulateMedia({ reducedMotion: 'reduce' })
+ await page.waitForTimeout(400)
+ await page.screenshot({ path: `${shots}/${name}.png` })
+}
+
+const ID = 'c'.repeat(32)
+const SHA = 'd'.repeat(64)
+
+const op = (name, title, text, risk, over = {}) => ({ name, title, text, risk, status: 'not_allowed', ...over })
+
+function fileView(over = {}) {
+ return {
+ fileId: ID, name: 'memory-0.2.0-linux-amd64.zip', size: 5364318, sha256: SHA, addonId: 'memory', title: 'Short memory',
+ version: '0.2.0', platform: 'linux/amd64', api: { min: 2, max: 2 }, required: ['workspace.materialize'], optional: ['egress.check'],
+ installed: false, installedVersion: '', relation: 'new', source: 'local-file', origin: 'local file, unsigned', ...over,
+ }
+}
+
+const PREVIEW = {
+ addonId: 'memory', title: 'Short memory', digest: 'sha256:' + SHA, version: '0.2.0', installed: false, official: false, revision: 4, nonce: 'nonce-f',
+ requested: { required: ['workspace.materialize'], optional: ['egress.check'] }, canInstall: true, blockedRequired: [], added: [], blanket: { applies: false },
+ ops: [
+ op('workspace.materialize', 'Copy files in', 'Copy files of a chat into the working area of the add-on.', 'changes data'),
+ op('egress.check', 'Ask about the network', 'Ask the host whether a network connection is allowed.', 'network'),
+ ],
+}
+
+async function open(page, { file = fileView(), installed = [], uploadStatus = 200, uploadBody } = {}) {
+ await page.route('**/api/auth/status', route => route.fulfill({ json: sessionFor(ADMIN, { addons: true }) }))
+ const state = await mockAddons(page, { apps: [], installed })
+ const seen = { uploads: [], described: [], removed: [], preview: [], install: [], dispatch: state.log.dispatch }
+ await page.route(/\/api\/addons\/files(\/[0-9a-f]+)?$/, route => {
+ const req = route.request()
+ const json = (payload, status = 200) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(payload) })
+ if (req.method() === 'POST') {
+ seen.uploads.push({ contentType: req.headers()['content-type'], body: req.postDataBuffer()?.toString('latin1') || '' })
+ return json(uploadBody || file, uploadStatus)
+ }
+ const id = req.url().split('/').pop()
+ if (req.method() === 'DELETE') { seen.removed.push(id); return route.fulfill({ status: 204 }) }
+ seen.described.push(id)
+ return id === ID ? json(file) : json({ error: { type: 'not_found', message: 'No such file' } }, 404)
+ })
+ await page.route(/\/api\/addons\/host-grants(\/.*)?(\?.*)?$/, route => {
+ const req = route.request()
+ const path = new URL(req.url()).pathname.replace('/api/addons/host-grants', '') || '/'
+ const body = req.postData() ? JSON.parse(req.postData()) : null
+ const json = (payload, status = 200) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(payload) })
+ if (path === '/') return json({ revision: 4, locked: false, blanket: { mode: 'off', includeOptional: false, setBy: 'ui', revision: 0 }, floor: { source: 'file', addons: [] }, deny: { file: {}, ui: {} }, addons: [] })
+ if (path === '/preview') { seen.preview.push(body); return json({ ...PREVIEW, installed: file.installed }) }
+ if (path === '/install') {
+ seen.install.push({ body, confirm: req.headers()['x-localai-confirm'] })
+ if (!file.installed) {
+ state.installed.push(installedAddon({ id: 'memory', title: 'Short memory', version: file.version, sourceId: 'local-file', verification: 'sha256', enabled: false, state: 'stopped', generation: 1 }))
+ state.apps.push(app({ id: 'memory', name: 'memory', title: 'Short memory', version: file.version, generation: 1, enabled: false, state: 'stopped', mainPage: undefined, hasUI: false }))
+ }
+ return json({ payload: { OperationID: body.operationId, AddonID: body.addonId, Phase: 'completed', Generation: 1 } })
+ }
+ return json({ error: { type: 'addon_error', message: 'unknown' } }, 404)
+ })
+ return { state, seen }
+}
+
+test.describe('Install from a file', () => {
+ test('uploads the zip, shows the unsigned notice, the digest and the host operations, and sends the approval', async ({ page }) => {
+ const { seen } = await open(page)
+ await page.goto('/app/addons')
+ await page.getByTestId('install-file-input').setInputFiles({ name: 'memory-0.2.0-linux-amd64.zip', mimeType: 'application/zip', buffer: Buffer.from('PK-fake-zip') })
+ const dialog = page.getByTestId('install-dialog')
+ await expect(dialog).toBeVisible()
+ expect(seen.uploads).toHaveLength(1)
+ expect(seen.uploads[0].contentType).toContain('multipart/form-data')
+ expect(seen.uploads[0].body).toContain('name="file"; filename="memory-0.2.0-linux-amd64.zip"')
+ await expect(dialog.getByTestId('install-unsigned')).toContainText('Local file, unsigned')
+ await expect(dialog.getByTestId('install-unsigned')).toContainText('Nothing signs this file')
+ const facts = dialog.getByTestId('install-file-facts')
+ await expect(facts.getByTestId('install-file-sha')).toHaveText(SHA)
+ await expect(facts).toContainText('memory 0.2.0')
+ await expect(facts).toContainText('linux/amd64')
+ // The same step as for a library entry; a file is never official.
+ const step = dialog.getByTestId('install-host-access')
+ await expect(step).toBeVisible()
+ await expect(step.locator('[data-op="workspace.materialize"]').getByRole('switch')).toBeDisabled()
+ await expect(step.getByTestId('hostaccess-not-official')).toHaveCount(0)
+ expect(seen.preview).toEqual([{ sourceId: 'local-file', addonId: 'memory', version: ID }])
+ await step.locator('[data-op="egress.check"]').getByRole('switch').click()
+ await shoot(page, 'install-from-file-dialog')
+ await dialog.getByTestId('install-confirm').click()
+ await expect(dialog.getByTestId('flow-steps')).toBeVisible()
+ await expect.poll(() => seen.install.length).toBe(1)
+ const { body, confirm } = seen.install[0]
+ expect(confirm).toBe('memory@sha256:' + SHA)
+ expect(body).toMatchObject({ action: 'install', sourceId: 'local-file', addonId: 'memory', version: ID })
+ expect(body.downgrade).toBeUndefined()
+ expect(body.approve).toMatchObject({ digest: 'sha256:' + SHA, revision: 4, nonce: 'nonce-f' })
+ expect(body.approve.ops.sort()).toEqual(['egress.check', 'workspace.materialize'])
+ // A fresh install is turned on, like a library install.
+ await expect.poll(() => seen.dispatch.some(d => d.action === 'enable')).toBe(true)
+ })
+
+ test('an upgrade says what it replaces, installs through the upgrade action and does not turn the add-on on', async ({ page }) => {
+ const { seen } = await open(page, { file: fileView({ installed: true, installedVersion: '0.1.0', relation: 'upgrade' }), installed: [installedAddon({ id: 'memory', title: 'Short memory', version: '0.1.0', sourceId: 'local-file', verification: 'sha256', enabled: false, state: 'stopped' })] })
+ await page.goto('/app/addons')
+ await page.getByTestId('install-file-input').setInputFiles({ name: 'm.zip', mimeType: 'application/zip', buffer: Buffer.from('PK') })
+ const dialog = page.getByTestId('install-dialog')
+ await expect(dialog.getByTestId('install-file-upgrade')).toContainText('replaces version 0.1.0 with version 0.2.0')
+ await dialog.getByTestId('install-confirm').click()
+ await expect.poll(() => seen.install.length).toBe(1)
+ expect(seen.install[0].body.action).toBe('upgrade')
+ expect(seen.install[0].body.downgrade).toBeUndefined()
+ await expect(dialog).toContainText('now runs version 0.2.0')
+ expect(seen.dispatch.filter(d => d.action === 'enable')).toHaveLength(0)
+ })
+
+ test('an older version needs a tick and sends the downgrade confirmation', async ({ page }) => {
+ const { seen } = await open(page, { file: fileView({ version: '0.0.9', installed: true, installedVersion: '0.1.0', relation: 'downgrade' }), installed: [installedAddon({ id: 'memory', title: 'Short memory', version: '0.1.0', sourceId: 'local-file', verification: 'sha256', enabled: false, state: 'stopped' })] })
+ await page.goto('/app/addons')
+ await page.getByTestId('install-file-input').setInputFiles({ name: 'm.zip', mimeType: 'application/zip', buffer: Buffer.from('PK') })
+ const dialog = page.getByTestId('install-dialog')
+ await expect(dialog.getByTestId('install-file-downgrade')).toContainText('older than the installed version 0.1.0')
+ await shoot(page, 'install-from-file-downgrade')
+ await expect(dialog.getByTestId('install-confirm')).toBeDisabled()
+ await dialog.getByTestId('install-file-downgrade-confirm').check()
+ await expect(dialog.getByTestId('install-confirm')).toBeEnabled()
+ await dialog.getByTestId('install-confirm').click()
+ await expect.poll(() => seen.install.length).toBe(1)
+ expect(seen.install[0].body.downgrade).toBe(true)
+ expect(seen.install[0].body.action).toBe('upgrade')
+ })
+
+ test('the same version cannot be installed again', async ({ page }) => {
+ await open(page, { file: fileView({ installed: true, installedVersion: '0.2.0', relation: 'same' }), installed: [installedAddon({ id: 'memory', title: 'Short memory', version: '0.2.0', sourceId: 'local-file', verification: 'sha256' })] })
+ await page.goto('/app/addons')
+ await page.getByTestId('install-file-input').setInputFiles({ name: 'm.zip', mimeType: 'application/zip', buffer: Buffer.from('PK') })
+ const dialog = page.getByTestId('install-dialog')
+ await expect(dialog.getByTestId('install-file-same')).toContainText('installed already')
+ await expect(dialog.getByTestId('install-confirm')).toBeDisabled()
+ })
+
+ test('shows the reason the owner gives when it refuses the file', async ({ page }) => {
+ await open(page, { uploadStatus: 400, uploadBody: { error: { type: 'wrong_platform', code: 400, message: 'This file is for linux/arm64; this host is linux/amd64' } } })
+ await page.goto('/app/addons')
+ await page.getByTestId('install-file-input').setInputFiles({ name: 'm.zip', mimeType: 'application/zip', buffer: Buffer.from('PK') })
+ const err = page.getByTestId('install-file-error')
+ await expect(err).toContainText('The file was not accepted.')
+ await expect(err).toContainText('This file is for linux/arm64; this host is linux/amd64')
+ await expect(page.getByTestId('install-dialog')).toHaveCount(0)
+ })
+
+ test('the link of the command line opens the dialog for the staged file, and cancel removes the file', async ({ page }) => {
+ const { seen } = await open(page)
+ await page.goto(`/app/addons?file=${ID}`)
+ const dialog = page.getByTestId('install-dialog')
+ await expect(dialog).toBeVisible()
+ expect(seen.described).toEqual([ID])
+ expect(seen.uploads).toHaveLength(0)
+ await dialog.getByRole('button', { name: 'Cancel' }).click()
+ await expect(dialog).toHaveCount(0)
+ await expect.poll(() => seen.removed).toEqual([ID])
+ await expect(page).not.toHaveURL(/file=/)
+ })
+
+ test('a link to a file that is gone says so', async ({ page }) => {
+ await open(page)
+ await page.goto(`/app/addons?file=${'e'.repeat(32)}`)
+ await expect(page.getByTestId('install-file-error')).toContainText('The file is gone')
+ })
+
+ test('the list and the host access page name an add-on from a file as unsigned', async ({ page }) => {
+ await page.route('**/api/auth/status', route => route.fulfill({ json: sessionFor(ADMIN, { addons: true }) }))
+ const installed = installedAddon({ id: 'memory', title: 'Short memory', version: '0.2.0', sourceId: 'local-file', verification: 'sha256' })
+ await mockAddons(page, { apps: [{ id: 'memory', name: 'memory', title: 'Short memory', description: 'Notes.', version: '0.2.0', generation: 1, enabled: true, state: 'running', hasUI: false, tools: [], toolCount: 0, launchMode: 'always' }], installed: [installed] })
+ await page.route(/\/api\/addons\/host-grants$/, route => route.fulfill({ json: {
+ revision: 1, locked: false, blanket: { mode: 'off', includeOptional: false, setBy: 'ui', revision: 0 }, floor: { source: 'file', addons: [] }, deny: { file: {}, ui: {} },
+ addons: [{ id: 'memory', title: 'Short memory', version: '0.2.0', state: 'running', sourceId: 'local-file', requested: { required: [], optional: [] }, ops: [], noLongerRequested: [], newOptional: [], identityMismatch: false, stopped: '' }],
+ } }))
+ await page.goto('/app/addons')
+ await expect(page.locator('[data-source="local-file"]').first()).toContainText('Local file, unsigned')
+ await page.goto('/app/addons?tab=access')
+ await expect(page.getByTestId('host-local-file')).toContainText('Local file, unsigned')
+ })
+})
diff --git a/core/http/react-ui/public/locales/de/addons.json b/core/http/react-ui/public/locales/de/addons.json
index 9035d05391..e67d8ea382 100644
--- a/core/http/react-ui/public/locales/de/addons.json
+++ b/core/http/react-ui/public/locales/de/addons.json
@@ -897,5 +897,38 @@
"reviewNamed": "Den Host-Zugriff von {{name}} prüfen und freigeben",
"title": "{{name}} {{version}} wartet auf deine Freigabe"
}
+ },
+ "file": {
+ "button": "Aus Datei installieren",
+ "checking": "Die Datei wird geprüft …",
+ "pick": "Eine ZIP-Datei eines Add-ons auswählen",
+ "rejected": "Die Datei wurde nicht akzeptiert.",
+ "gone": "Die Datei ist nicht mehr da. Eine Datei bleibt nach dem Hochladen eine Stunde lang erhalten. Lade sie erneut hoch.",
+ "installTitle": "{{name}} aus einer Datei installieren?",
+ "upgradeTitle": "{{name}} aus einer Datei aktualisieren?",
+ "confirmBody": "Version {{version}} aus der Datei {{name}} wird installiert und dann eingeschaltet.",
+ "confirmBodyUpgrade": "Version {{version}} aus der Datei {{name}} wird installiert. Das Add-on bleibt ein- oder ausgeschaltet, wie es jetzt ist.",
+ "upgradeAction": "Auf {{version}} aktualisieren",
+ "upgraded": "{{name}} läuft jetzt in Version {{version}}.",
+ "unsignedTitle": "Lokale Datei, nicht signiert",
+ "unsignedNotice": "Niemand signiert diese Datei, deshalb kann LocalAI nicht erkennen, wer sie erstellt hat. Installiere sie nur, wenn du ihrer Herkunft vertraust.",
+ "unsignedShort": "Lokale Datei, nicht signiert",
+ "badge": "Lokale Datei, nicht signiert",
+ "sourceValue": "Lokale Datei, nicht signiert",
+ "hostNotice": "Dieses Add-on stammt aus einer lokalen Datei, die niemand signiert. Gib ihm nur den Hostzugriff, den es nachweislich braucht.",
+ "facts": {
+ "file": "Datei",
+ "sha256": "SHA-256 der Datei",
+ "addon": "Add-on",
+ "platform": "Plattform",
+ "api": "Host-API"
+ },
+ "relation": {
+ "upgrade": "Dies ersetzt Version {{installed}} durch Version {{version}}. Die Daten des Add-ons bleiben erhalten.",
+ "same": "Version {{version}} ist bereits installiert. Wähle eine Datei mit einer anderen Version.",
+ "downgradeTitle": "Ältere Version",
+ "downgrade": "Dies installiert Version {{version}}, die älter ist als die installierte Version {{installed}}. Eine ältere Version kann Daten, die eine neuere geschrieben hat, eventuell nicht lesen.",
+ "downgradeConfirm": "Ich möchte die ältere Version installieren"
+ }
}
}
diff --git a/core/http/react-ui/public/locales/en/addons.json b/core/http/react-ui/public/locales/en/addons.json
index e2500fd0cc..711ec53ca2 100644
--- a/core/http/react-ui/public/locales/en/addons.json
+++ b/core/http/react-ui/public/locales/en/addons.json
@@ -897,5 +897,38 @@
"reviewNamed": "Review and approve the host access of {{name}}",
"title": "{{name}} {{version}} waits for your approval"
}
+ },
+ "file": {
+ "button": "Install from file",
+ "checking": "Checking the file…",
+ "pick": "Choose an add-on zip file",
+ "rejected": "The file was not accepted.",
+ "gone": "The file is gone. A file waits for one hour after the upload. Upload it again.",
+ "installTitle": "Install {{name}} from a file?",
+ "upgradeTitle": "Update {{name}} from a file?",
+ "confirmBody": "Version {{version}} from the file {{name}} is installed, then turned on.",
+ "confirmBodyUpgrade": "Version {{version}} from the file {{name}} is installed. The add-on stays on or off as it is now.",
+ "upgradeAction": "Update to {{version}}",
+ "upgraded": "{{name}} now runs version {{version}}.",
+ "unsignedTitle": "Local file, unsigned",
+ "unsignedNotice": "Nothing signs this file, so LocalAI cannot tell who made it. Install it only if you trust where it came from.",
+ "unsignedShort": "Local file, unsigned",
+ "badge": "Local file, unsigned",
+ "sourceValue": "Local file, unsigned",
+ "hostNotice": "This add-on came from a local file that nothing signs. Give it only the host access you are sure it needs.",
+ "facts": {
+ "file": "File",
+ "sha256": "File sha256",
+ "addon": "Add-on",
+ "platform": "Platform",
+ "api": "Host API"
+ },
+ "relation": {
+ "upgrade": "This replaces version {{installed}} with version {{version}}. The data of the add-on is kept.",
+ "same": "Version {{version}} is installed already. Choose a file with another version.",
+ "downgradeTitle": "Older version",
+ "downgrade": "This installs version {{version}}, which is older than the installed version {{installed}}. An older version may not read data that a newer one wrote.",
+ "downgradeConfirm": "I want to install the older version"
+ }
}
}
diff --git a/core/http/react-ui/public/locales/es/addons.json b/core/http/react-ui/public/locales/es/addons.json
index 526b98bcc6..fab6041005 100644
--- a/core/http/react-ui/public/locales/es/addons.json
+++ b/core/http/react-ui/public/locales/es/addons.json
@@ -897,5 +897,38 @@
"reviewNamed": "Revisar y aprobar el acceso al host de {{name}}",
"title": "{{name}} {{version}} espera tu aprobación"
}
+ },
+ "file": {
+ "button": "Instalar desde un archivo",
+ "checking": "Comprobando el archivo…",
+ "pick": "Elegir un archivo zip de un complemento",
+ "rejected": "No se aceptó el archivo.",
+ "gone": "El archivo ya no está. Un archivo se conserva una hora después de subirlo. Súbelo de nuevo.",
+ "installTitle": "¿Instalar {{name}} desde un archivo?",
+ "upgradeTitle": "¿Actualizar {{name}} desde un archivo?",
+ "confirmBody": "La versión {{version}} del archivo {{name}} se instala y después se activa.",
+ "confirmBodyUpgrade": "La versión {{version}} del archivo {{name}} se instala. El complemento sigue activado o desactivado como está ahora.",
+ "upgradeAction": "Actualizar a {{version}}",
+ "upgraded": "{{name}} ahora ejecuta la versión {{version}}.",
+ "unsignedTitle": "Archivo local, sin firma",
+ "unsignedNotice": "Nadie firma este archivo, así que LocalAI no puede saber quién lo creó. Instálalo solo si confías en su origen.",
+ "unsignedShort": "Archivo local, sin firma",
+ "badge": "Archivo local, sin firma",
+ "sourceValue": "Archivo local, sin firma",
+ "hostNotice": "Este complemento viene de un archivo local que nadie firma. Dale solo el acceso al host que estés seguro de que necesita.",
+ "facts": {
+ "file": "Archivo",
+ "sha256": "SHA-256 del archivo",
+ "addon": "Complemento",
+ "platform": "Plataforma",
+ "api": "API del host"
+ },
+ "relation": {
+ "upgrade": "Esto reemplaza la versión {{installed}} por la versión {{version}}. Los datos del complemento se conservan.",
+ "same": "La versión {{version}} ya está instalada. Elige un archivo con otra versión.",
+ "downgradeTitle": "Versión anterior",
+ "downgrade": "Esto instala la versión {{version}}, que es anterior a la versión instalada {{installed}}. Una versión anterior puede no leer los datos que escribió una más nueva.",
+ "downgradeConfirm": "Quiero instalar la versión anterior"
+ }
}
}
diff --git a/core/http/react-ui/public/locales/fr/addons.json b/core/http/react-ui/public/locales/fr/addons.json
index 5cc8f6f46f..f893d9a82d 100644
--- a/core/http/react-ui/public/locales/fr/addons.json
+++ b/core/http/react-ui/public/locales/fr/addons.json
@@ -901,5 +901,38 @@
"reviewNamed": "Examiner et approuver l'accès à l'hôte de {{name}}",
"title": "{{name}} {{version}} attend votre approbation"
}
+ },
+ "file": {
+ "button": "Installer depuis un fichier",
+ "checking": "Vérification du fichier…",
+ "pick": "Choisir un fichier zip de module",
+ "rejected": "Le fichier n’a pas été accepté.",
+ "gone": "Le fichier n’est plus là. Un fichier est conservé une heure après l’envoi. Envoyez-le à nouveau.",
+ "installTitle": "Installer {{name}} depuis un fichier ?",
+ "upgradeTitle": "Mettre à jour {{name}} depuis un fichier ?",
+ "confirmBody": "La version {{version}} du fichier {{name}} est installée, puis activée.",
+ "confirmBodyUpgrade": "La version {{version}} du fichier {{name}} est installée. Le module reste activé ou désactivé comme il l’est maintenant.",
+ "upgradeAction": "Passer à {{version}}",
+ "upgraded": "{{name}} fonctionne maintenant en version {{version}}.",
+ "unsignedTitle": "Fichier local, non signé",
+ "unsignedNotice": "Personne ne signe ce fichier, donc LocalAI ne peut pas savoir qui l’a créé. Ne l’installez que si vous faites confiance à son origine.",
+ "unsignedShort": "Fichier local, non signé",
+ "badge": "Fichier local, non signé",
+ "sourceValue": "Fichier local, non signé",
+ "hostNotice": "Ce module vient d’un fichier local que personne ne signe. Ne lui donnez que l’accès à l’hôte dont vous êtes sûr qu’il a besoin.",
+ "facts": {
+ "file": "Fichier",
+ "sha256": "SHA-256 du fichier",
+ "addon": "Module",
+ "platform": "Plateforme",
+ "api": "API de l’hôte"
+ },
+ "relation": {
+ "upgrade": "Cela remplace la version {{installed}} par la version {{version}}. Les données du module sont conservées.",
+ "same": "La version {{version}} est déjà installée. Choisissez un fichier d’une autre version.",
+ "downgradeTitle": "Version plus ancienne",
+ "downgrade": "Cela installe la version {{version}}, plus ancienne que la version installée {{installed}}. Une version plus ancienne peut ne pas lire les données écrites par une version plus récente.",
+ "downgradeConfirm": "Je veux installer la version plus ancienne"
+ }
}
}
diff --git a/core/http/react-ui/public/locales/id/addons.json b/core/http/react-ui/public/locales/id/addons.json
index 8ec90046ea..969395f66a 100644
--- a/core/http/react-ui/public/locales/id/addons.json
+++ b/core/http/react-ui/public/locales/id/addons.json
@@ -897,5 +897,38 @@
"reviewNamed": "Tinjau dan setujui akses host {{name}}",
"title": "{{name}} {{version}} menunggu persetujuan Anda"
}
+ },
+ "file": {
+ "button": "Pasang dari berkas",
+ "checking": "Memeriksa berkas…",
+ "pick": "Pilih berkas zip add-on",
+ "rejected": "Berkas tidak diterima.",
+ "gone": "Berkas sudah tidak ada. Berkas disimpan satu jam setelah diunggah. Unggah lagi.",
+ "installTitle": "Pasang {{name}} dari berkas?",
+ "upgradeTitle": "Perbarui {{name}} dari berkas?",
+ "confirmBody": "Versi {{version}} dari berkas {{name}} dipasang, lalu dinyalakan.",
+ "confirmBodyUpgrade": "Versi {{version}} dari berkas {{name}} dipasang. Add-on tetap menyala atau mati seperti sekarang.",
+ "upgradeAction": "Perbarui ke {{version}}",
+ "upgraded": "{{name}} sekarang berjalan di versi {{version}}.",
+ "unsignedTitle": "Berkas lokal, tanpa tanda tangan",
+ "unsignedNotice": "Tidak ada yang menandatangani berkas ini, jadi LocalAI tidak bisa tahu siapa yang membuatnya. Pasang hanya jika Anda memercayai asalnya.",
+ "unsignedShort": "Berkas lokal, tanpa tanda tangan",
+ "badge": "Berkas lokal, tanpa tanda tangan",
+ "sourceValue": "Berkas lokal, tanpa tanda tangan",
+ "hostNotice": "Add-on ini berasal dari berkas lokal yang tidak ditandatangani. Beri hanya akses host yang Anda yakini dibutuhkannya.",
+ "facts": {
+ "file": "Berkas",
+ "sha256": "SHA-256 berkas",
+ "addon": "Add-on",
+ "platform": "Platform",
+ "api": "API host"
+ },
+ "relation": {
+ "upgrade": "Ini mengganti versi {{installed}} dengan versi {{version}}. Data add-on tetap disimpan.",
+ "same": "Versi {{version}} sudah terpasang. Pilih berkas dengan versi lain.",
+ "downgradeTitle": "Versi lebih lama",
+ "downgrade": "Ini memasang versi {{version}}, yang lebih lama daripada versi terpasang {{installed}}. Versi yang lebih lama mungkin tidak bisa membaca data yang ditulis versi yang lebih baru.",
+ "downgradeConfirm": "Saya ingin memasang versi yang lebih lama"
+ }
}
}
diff --git a/core/http/react-ui/public/locales/it/addons.json b/core/http/react-ui/public/locales/it/addons.json
index 2cefd05999..aed70c8090 100644
--- a/core/http/react-ui/public/locales/it/addons.json
+++ b/core/http/react-ui/public/locales/it/addons.json
@@ -897,5 +897,38 @@
"reviewNamed": "Controlla e approva l'accesso all'host di {{name}}",
"title": "{{name}} {{version}} aspetta la tua approvazione"
}
+ },
+ "file": {
+ "button": "Installa da file",
+ "checking": "Controllo del file…",
+ "pick": "Scegli un file zip di un componente",
+ "rejected": "Il file non è stato accettato.",
+ "gone": "Il file non c’è più. Un file resta per un’ora dopo il caricamento. Caricalo di nuovo.",
+ "installTitle": "Installare {{name}} da un file?",
+ "upgradeTitle": "Aggiornare {{name}} da un file?",
+ "confirmBody": "La versione {{version}} del file {{name}} viene installata e poi attivata.",
+ "confirmBodyUpgrade": "La versione {{version}} del file {{name}} viene installata. Il componente resta attivo o disattivo come è ora.",
+ "upgradeAction": "Aggiorna a {{version}}",
+ "upgraded": "{{name}} ora esegue la versione {{version}}.",
+ "unsignedTitle": "File locale, non firmato",
+ "unsignedNotice": "Nessuno firma questo file, quindi LocalAI non può sapere chi l’ha creato. Installalo solo se ti fidi della sua origine.",
+ "unsignedShort": "File locale, non firmato",
+ "badge": "File locale, non firmato",
+ "sourceValue": "File locale, non firmato",
+ "hostNotice": "Questo componente arriva da un file locale che nessuno firma. Dagli solo l’accesso all’host che sei sicuro gli serva.",
+ "facts": {
+ "file": "File",
+ "sha256": "SHA-256 del file",
+ "addon": "Componente",
+ "platform": "Piattaforma",
+ "api": "API dell’host"
+ },
+ "relation": {
+ "upgrade": "Questo sostituisce la versione {{installed}} con la versione {{version}}. I dati del componente vengono conservati.",
+ "same": "La versione {{version}} è già installata. Scegli un file con un’altra versione.",
+ "downgradeTitle": "Versione precedente",
+ "downgrade": "Questo installa la versione {{version}}, più vecchia della versione installata {{installed}}. Una versione più vecchia può non leggere i dati scritti da una più nuova.",
+ "downgradeConfirm": "Voglio installare la versione precedente"
+ }
}
}
diff --git a/core/http/react-ui/public/locales/ko/addons.json b/core/http/react-ui/public/locales/ko/addons.json
index 60b2797831..b74470e7e5 100644
--- a/core/http/react-ui/public/locales/ko/addons.json
+++ b/core/http/react-ui/public/locales/ko/addons.json
@@ -897,5 +897,38 @@
"reviewNamed": "{{name}}의 호스트 접근 검토 후 승인",
"title": "{{name}} {{version}}이(가) 승인을 기다립니다"
}
+ },
+ "file": {
+ "button": "파일에서 설치",
+ "checking": "파일을 확인하는 중…",
+ "pick": "애드온 zip 파일 선택",
+ "rejected": "파일이 받아들여지지 않았습니다.",
+ "gone": "파일이 없습니다. 파일은 업로드 후 한 시간 동안 보관됩니다. 다시 업로드하세요.",
+ "installTitle": "파일에서 {{name}}을(를) 설치할까요?",
+ "upgradeTitle": "파일에서 {{name}}을(를) 업데이트할까요?",
+ "confirmBody": "파일 {{name}}의 {{version}} 버전을 설치한 다음 켭니다.",
+ "confirmBodyUpgrade": "파일 {{name}}의 {{version}} 버전을 설치합니다. 애드온은 지금처럼 켜짐 또는 꺼짐 상태를 유지합니다.",
+ "upgradeAction": "{{version}}(으)로 업데이트",
+ "upgraded": "{{name}}이(가) 이제 {{version}} 버전으로 실행됩니다.",
+ "unsignedTitle": "로컬 파일, 서명 없음",
+ "unsignedNotice": "이 파일에는 서명한 사람이 없어서 LocalAI는 누가 만들었는지 알 수 없습니다. 출처를 신뢰할 때만 설치하세요.",
+ "unsignedShort": "로컬 파일, 서명 없음",
+ "badge": "로컬 파일, 서명 없음",
+ "sourceValue": "로컬 파일, 서명 없음",
+ "hostNotice": "이 애드온은 아무도 서명하지 않은 로컬 파일에서 왔습니다. 꼭 필요하다고 확신하는 호스트 접근 권한만 주세요.",
+ "facts": {
+ "file": "파일",
+ "sha256": "파일 SHA-256",
+ "addon": "애드온",
+ "platform": "플랫폼",
+ "api": "호스트 API"
+ },
+ "relation": {
+ "upgrade": "설치된 {{installed}} 버전을 {{version}} 버전으로 바꿉니다. 애드온의 데이터는 유지됩니다.",
+ "same": "{{version}} 버전은 이미 설치되어 있습니다. 다른 버전의 파일을 선택하세요.",
+ "downgradeTitle": "이전 버전",
+ "downgrade": "설치된 {{installed}} 버전보다 오래된 {{version}} 버전을 설치합니다. 이전 버전은 새 버전이 쓴 데이터를 읽지 못할 수 있습니다.",
+ "downgradeConfirm": "이전 버전을 설치하겠습니다"
+ }
}
}
diff --git a/core/http/react-ui/public/locales/pt-BR/addons.json b/core/http/react-ui/public/locales/pt-BR/addons.json
index eb62309fce..9bda43a4ca 100644
--- a/core/http/react-ui/public/locales/pt-BR/addons.json
+++ b/core/http/react-ui/public/locales/pt-BR/addons.json
@@ -897,5 +897,38 @@
"reviewNamed": "Revisar e aprovar o acesso ao host de {{name}}",
"title": "{{name}} {{version}} aguarda sua aprovação"
}
+ },
+ "file": {
+ "button": "Instalar de um arquivo",
+ "checking": "Verificando o arquivo…",
+ "pick": "Escolher um arquivo zip de complemento",
+ "rejected": "O arquivo não foi aceito.",
+ "gone": "O arquivo não está mais lá. Um arquivo fica por uma hora depois do envio. Envie de novo.",
+ "installTitle": "Instalar {{name}} de um arquivo?",
+ "upgradeTitle": "Atualizar {{name}} de um arquivo?",
+ "confirmBody": "A versão {{version}} do arquivo {{name}} é instalada e depois ligada.",
+ "confirmBodyUpgrade": "A versão {{version}} do arquivo {{name}} é instalada. O complemento continua ligado ou desligado como está agora.",
+ "upgradeAction": "Atualizar para {{version}}",
+ "upgraded": "{{name}} agora roda na versão {{version}}.",
+ "unsignedTitle": "Arquivo local, sem assinatura",
+ "unsignedNotice": "Ninguém assina este arquivo, então o LocalAI não sabe quem o criou. Instale só se confiar na origem.",
+ "unsignedShort": "Arquivo local, sem assinatura",
+ "badge": "Arquivo local, sem assinatura",
+ "sourceValue": "Arquivo local, sem assinatura",
+ "hostNotice": "Este complemento veio de um arquivo local que ninguém assina. Dê a ele só o acesso ao host de que você tem certeza que ele precisa.",
+ "facts": {
+ "file": "Arquivo",
+ "sha256": "SHA-256 do arquivo",
+ "addon": "Complemento",
+ "platform": "Plataforma",
+ "api": "API do host"
+ },
+ "relation": {
+ "upgrade": "Isto troca a versão {{installed}} pela versão {{version}}. Os dados do complemento são mantidos.",
+ "same": "A versão {{version}} já está instalada. Escolha um arquivo com outra versão.",
+ "downgradeTitle": "Versão mais antiga",
+ "downgrade": "Isto instala a versão {{version}}, que é mais antiga que a versão instalada {{installed}}. Uma versão mais antiga pode não ler dados escritos por uma mais nova.",
+ "downgradeConfirm": "Quero instalar a versão mais antiga"
+ }
}
}
diff --git a/core/http/react-ui/public/locales/zh-CN/addons.json b/core/http/react-ui/public/locales/zh-CN/addons.json
index ff163c4c09..9d611ffc22 100644
--- a/core/http/react-ui/public/locales/zh-CN/addons.json
+++ b/core/http/react-ui/public/locales/zh-CN/addons.json
@@ -897,5 +897,38 @@
"reviewNamed": "查看并批准 {{name}} 的主机访问",
"title": "{{name}} {{version}} 等待你的批准"
}
+ },
+ "file": {
+ "button": "从文件安装",
+ "checking": "正在检查文件…",
+ "pick": "选择附加组件的 zip 文件",
+ "rejected": "文件未被接受。",
+ "gone": "文件已不存在。文件在上传后保留一小时。请重新上传。",
+ "installTitle": "从文件安装 {{name}}?",
+ "upgradeTitle": "从文件更新 {{name}}?",
+ "confirmBody": "将安装文件 {{name}} 中的 {{version}} 版本,然后启用。",
+ "confirmBodyUpgrade": "将安装文件 {{name}} 中的 {{version}} 版本。附加组件保持现在的开启或关闭状态。",
+ "upgradeAction": "更新到 {{version}}",
+ "upgraded": "{{name}} 现在运行 {{version}} 版本。",
+ "unsignedTitle": "本地文件,未签名",
+ "unsignedNotice": "没有人为此文件签名,因此 LocalAI 无法确定它由谁制作。只有在你信任其来源时才安装。",
+ "unsignedShort": "本地文件,未签名",
+ "badge": "本地文件,未签名",
+ "sourceValue": "本地文件,未签名",
+ "hostNotice": "此附加组件来自没有人签名的本地文件。只授予你确信它需要的主机访问权限。",
+ "facts": {
+ "file": "文件",
+ "sha256": "文件 SHA-256",
+ "addon": "附加组件",
+ "platform": "平台",
+ "api": "主机 API"
+ },
+ "relation": {
+ "upgrade": "这会把 {{installed}} 版本替换为 {{version}} 版本。附加组件的数据会保留。",
+ "same": "{{version}} 版本已安装。请选择另一个版本的文件。",
+ "downgradeTitle": "较旧的版本",
+ "downgrade": "这会安装 {{version}} 版本,它比已安装的 {{installed}} 版本更旧。较旧的版本可能无法读取较新版本写入的数据。",
+ "downgradeConfirm": "我要安装较旧的版本"
+ }
}
}
diff --git a/core/http/react-ui/src/components/addons/AddonRow.jsx b/core/http/react-ui/src/components/addons/AddonRow.jsx
index 516dfd7895..3f4364543d 100644
--- a/core/http/react-ui/src/components/addons/AddonRow.jsx
+++ b/core/http/react-ui/src/components/addons/AddonRow.jsx
@@ -5,6 +5,7 @@ import Button from '../ui/Button'
import { apiUrl } from '../../utils/basePath'
import { AppIcon, ApprovalChip, BuiltinIcon, StateChip, UpdateChip, VersionText } from './AddonBits'
import AddonMenu from './AddonMenu'
+import { isLocalFile } from './fileInstall'
import { addedByAddon, stateChip } from './addonState'
// One installed add-on as a row: identity tile, name and state, one line of
@@ -49,7 +50,7 @@ export default function AddonRow({ tile, pinned, selected = false, attention = '
)}
{!app && {t('meta.builtinLong')}}
{source && source.sourceId && (
- {t(signed ? 'meta.signed' : 'meta.checked', { library: source.sourceId })}
+ {isLocalFile(source.sourceId) ? t('file.badge') : t(signed ? 'meta.signed' : 'meta.checked', { library: source.sourceId })}
)}
{app &&
{t('flow.confirmBody', { version: entry.version, library: entry.sourceId })}
-{t(local.installed ? 'file.confirmBodyUpgrade' : 'file.confirmBody', { version: local.version, name: local.name })}
+ :{t('flow.confirmBody', { version: entry.version, library: entry.sourceId })}
} + {local && ( +{t('file.relation.upgrade', { installed: local.installedVersion, version: local.version })}
+ )} + {local && local.relation === 'same' && ( +{t('file.relation.downgrade', { installed: local.installedVersion, version: local.version })}
+ +{t('hostAccess.stepIntro', { name: entry.title })}
- {host.preview.official === false && ( + {!local && host.preview.official === false && ({t('hostAccess.notOfficial', { library: entry.sourceId })}
)}{t('hostAccess.stepFooter')}
- {t('browse.installedOn', { name: entry.title })} + {local && local.installed ? t('file.upgraded', { name: entry.title, version: local.version }) : t('browse.installedOn', { name: entry.title })} {entry.ui && entry.ui.mainPage && <> {t('flow.seeInList')}>}
)} @@ -168,7 +213,7 @@ export default function InstallDialog({ entry, flow, actions, onClose, installed