diff --git a/.github/workflows/addons-package.yml b/.github/workflows/addons-package.yml
index d69c206bb..043bb97b0 100644
--- a/.github/workflows/addons-package.yml
+++ b/.github/workflows/addons-package.yml
@@ -56,7 +56,7 @@ jobs:
strategy:
fail-fast: true
matrix:
- addon: [tables, voice, face, fine-tuning, quantization]
+ addon: [tables, voice, face, fine-tuning, quantization, studio, talk]
steps:
- uses: actions/checkout@v7
with:
@@ -155,7 +155,7 @@ jobs:
# event it fails the plan. A missing or malformed platform file, or
# any other error, fails the plan on every event.
include='[]'
- for id in tables voice face fine-tuning quantization; do
+ for id in tables voice face fine-tuning quantization studio talk; do
cfg="addons/$id/package/platforms.yaml"
err="matrix-$id.err"
if rows=$(go run ./scripts/addons/cmd/addonpkg matrix --config "$cfg" 2>"$err"); then
diff --git a/addons/studio/cmd/studio/main.go b/addons/studio/cmd/studio/main.go
new file mode 100644
index 000000000..770b860f9
--- /dev/null
+++ b/addons/studio/cmd/studio/main.go
@@ -0,0 +1,15 @@
+// SPDX-License-Identifier: MIT
+
+// Command studio is the executable of the Studio add-on. It serves no tools:
+// the add-on works in the browser, through the SDK and the core API.
+package main
+
+import (
+ "os"
+
+ "github.com/mudler/LocalAI/addons/internal/idle"
+)
+
+func main() {
+ os.Exit(idle.Run(os.Args))
+}
diff --git a/addons/studio/manifest.json b/addons/studio/manifest.json
new file mode 100644
index 000000000..683e0b5f0
--- /dev/null
+++ b/addons/studio/manifest.json
@@ -0,0 +1,25 @@
+{
+ "schemaVersion": 1,
+ "id": "studio",
+ "version": "0.1.0",
+ "executable": "studio",
+ "os": "linux",
+ "arch": "amd64",
+ "host": { "min": 1, "max": 1 },
+ "api": { "min": 1, "max": 1 },
+ "protocol": { "min": 1, "max": 1 },
+ "dataFormat": { "min": 1, "max": 1 },
+ "capabilities": {
+ "ui": {
+ "entry": "ui/studio.js",
+ "css": ["ui/studio.css"],
+ "sdk": { "min": 2, "max": 2 },
+ "pages": ["studio"],
+ "navigation": ["studio"],
+ "mainPage": "studio",
+ "icon": "ui/icon.png",
+ "title": "Studio",
+ "description": "Make images, video, 3D models, speech, sound and transformed audio with the models you have installed, and keep what you make."
+ }
+ }
+}
diff --git a/addons/studio/package/platforms.yaml b/addons/studio/package/platforms.yaml
new file mode 100644
index 000000000..787767424
--- /dev/null
+++ b/addons/studio/package/platforms.yaml
@@ -0,0 +1,38 @@
+# Release platform matrix of the studio add-on package.
+#
+# A platform ships only while "enabled" is true. Enable a platform in the same
+# change that shows its native check passing on a runner of that platform;
+# until then it stays listed and disabled. The release workflow fails closed
+# when no platform is enabled, and it never builds a platform that is not
+# enabled here.
+#
+# runner: the GitHub runner label that builds and checks the platform
+# natively. Required for an enabled platform.
+# native_check: "executable-start": the packaged executable is extracted on
+# the native runner and started. Required for an enabled platform.
+# runtime: files besides the executable that the platform needs at run
+# time, relative to the package root. They are taken from the
+# runtime directory of the build and must exist.
+schema: 1
+addon: studio
+executable: studio
+manifest: addons/studio/manifest.json
+# The directory whose content is laid at the package root (ui/studio.js ...).
+ui: addons/studio/ui/dist
+platforms:
+ - platform: linux/amd64
+ enabled: true
+ runner: ubuntu-24.04
+ native_check: executable-start
+ - platform: linux/arm64
+ enabled: true
+ runner: ubuntu-24.04-arm
+ native_check: executable-start
+ - platform: darwin/arm64
+ enabled: true
+ runner: macos-14
+ native_check: executable-start
+ - platform: darwin/amd64
+ enabled: true
+ runner: macos-15-intel
+ native_check: executable-start
diff --git a/addons/studio/ui/.gitignore b/addons/studio/ui/.gitignore
new file mode 100644
index 000000000..1225aa2fa
--- /dev/null
+++ b/addons/studio/ui/.gitignore
@@ -0,0 +1,2 @@
+# Written by build.mjs; the package build makes it.
+/dist/
diff --git a/addons/studio/ui/build.mjs b/addons/studio/ui/build.mjs
new file mode 100644
index 000000000..3ec2d8535
--- /dev/null
+++ b/addons/studio/ui/build.mjs
@@ -0,0 +1,15 @@
+import { fileURLToPath } from 'node:url'
+import { dirname, resolve } from 'node:path'
+import { buildAddonUI } from '../../internal/uibuild/uibuild.mjs'
+
+const here = dirname(fileURLToPath(import.meta.url))
+
+export async function build() {
+ // The page code keeps the host module names it was written with; the compat
+ // directory answers them through the SDK (addons/internal/host-compat).
+ return buildAddonUI({ root: here, name: 'studio', rgb: [150, 52, 92], compat: resolve(here, '../../internal/host-compat/src') })
+}
+
+if (process.argv[1] === fileURLToPath(import.meta.url)) {
+ build().then((dir) => console.log(`built ${dir}`), (e) => { console.error(e); process.exit(1) })
+}
diff --git a/addons/studio/ui/package.json b/addons/studio/ui/package.json
new file mode 100644
index 000000000..b3c322706
--- /dev/null
+++ b/addons/studio/ui/package.json
@@ -0,0 +1,8 @@
+{
+ "name": "localai-studio-ui",
+ "private": true,
+ "version": "0.1.0",
+ "description": "Native UI of the Studio add-on: a standalone ES module that the host loads after its own build.",
+ "type": "module",
+ "scripts": { "build": "node build.mjs", "test": "node test/run.mjs" }
+}
diff --git a/addons/studio/ui/src/addon.jsx b/addons/studio/ui/src/addon.jsx
new file mode 100644
index 000000000..44a8e245c
--- /dev/null
+++ b/addons/studio/ui/src/addon.jsx
@@ -0,0 +1,27 @@
+import { RouteProvider } from 'react-router-dom'
+import { registerLocales } from '../../../internal/host-compat/src/i18n.js'
+import Studio from './pages/Studio'
+import en from './i18n/media.en.json'
+import it from './i18n/media.it.json'
+import es from './i18n/media.es.json'
+import de from './i18n/media.de.json'
+import zh from './i18n/media.zh-CN.json'
+import id from './i18n/media.id.json'
+import ko from './i18n/media.ko.json'
+import pt from './i18n/media.pt-BR.json'
+
+// The words of the pages are the host's own `media` locale files.
+registerLocales({ media: { en, it, es, de, 'zh-CN': zh, id, ko, 'pt-BR': pt } })
+
+export function createStudio() {
+ // The shell passes the page, the rest of the path (the tab) and the query.
+ function Component({ page, path, search }) {
+ if (page !== 'studio') return null
+ return (
+
+
+
+ )
+ }
+ return { Component, components: {}, chatActions: [], dispose() {} }
+}
diff --git a/core/http/react-ui/src/components/AnimationOptions.jsx b/addons/studio/ui/src/components/AnimationOptions.jsx
similarity index 100%
rename from core/http/react-ui/src/components/AnimationOptions.jsx
rename to addons/studio/ui/src/components/AnimationOptions.jsx
diff --git a/core/http/react-ui/src/components/AnimationViewer.jsx b/addons/studio/ui/src/components/AnimationViewer.jsx
similarity index 100%
rename from core/http/react-ui/src/components/AnimationViewer.jsx
rename to addons/studio/ui/src/components/AnimationViewer.jsx
diff --git a/core/http/react-ui/src/components/GlbViewer.jsx b/addons/studio/ui/src/components/GlbViewer.jsx
similarity index 100%
rename from core/http/react-ui/src/components/GlbViewer.jsx
rename to addons/studio/ui/src/components/GlbViewer.jsx
diff --git a/core/http/react-ui/src/components/RequestPanel.jsx b/addons/studio/ui/src/components/RequestPanel.jsx
similarity index 100%
rename from core/http/react-ui/src/components/RequestPanel.jsx
rename to addons/studio/ui/src/components/RequestPanel.jsx
diff --git a/core/http/react-ui/src/components/audio/Spectrogram.jsx b/addons/studio/ui/src/components/audio/Spectrogram.jsx
similarity index 100%
rename from core/http/react-ui/src/components/audio/Spectrogram.jsx
rename to addons/studio/ui/src/components/audio/Spectrogram.jsx
diff --git a/core/http/react-ui/src/components/studio/HandoffNote.jsx b/addons/studio/ui/src/components/studio/HandoffNote.jsx
similarity index 100%
rename from core/http/react-ui/src/components/studio/HandoffNote.jsx
rename to addons/studio/ui/src/components/studio/HandoffNote.jsx
diff --git a/core/http/react-ui/src/components/studio/InstallNote.jsx b/addons/studio/ui/src/components/studio/InstallNote.jsx
similarity index 100%
rename from core/http/react-ui/src/components/studio/InstallNote.jsx
rename to addons/studio/ui/src/components/studio/InstallNote.jsx
diff --git a/core/http/react-ui/src/components/studio/LineageView.jsx b/addons/studio/ui/src/components/studio/LineageView.jsx
similarity index 100%
rename from core/http/react-ui/src/components/studio/LineageView.jsx
rename to addons/studio/ui/src/components/studio/LineageView.jsx
diff --git a/core/http/react-ui/src/components/studio/StarButton.jsx b/addons/studio/ui/src/components/studio/StarButton.jsx
similarity index 100%
rename from core/http/react-ui/src/components/studio/StarButton.jsx
rename to addons/studio/ui/src/components/studio/StarButton.jsx
diff --git a/core/http/react-ui/src/components/studio/StudioComposer.jsx b/addons/studio/ui/src/components/studio/StudioComposer.jsx
similarity index 100%
rename from core/http/react-ui/src/components/studio/StudioComposer.jsx
rename to addons/studio/ui/src/components/studio/StudioComposer.jsx
diff --git a/core/http/react-ui/src/components/studio/Timeline.jsx b/addons/studio/ui/src/components/studio/Timeline.jsx
similarity index 100%
rename from core/http/react-ui/src/components/studio/Timeline.jsx
rename to addons/studio/ui/src/components/studio/Timeline.jsx
diff --git a/core/http/react-ui/src/components/studio/UpscaleComposer.jsx b/addons/studio/ui/src/components/studio/UpscaleComposer.jsx
similarity index 100%
rename from core/http/react-ui/src/components/studio/UpscaleComposer.jsx
rename to addons/studio/ui/src/components/studio/UpscaleComposer.jsx
diff --git a/core/http/react-ui/src/components/studio/WorkMasonry.jsx b/addons/studio/ui/src/components/studio/WorkMasonry.jsx
similarity index 100%
rename from core/http/react-ui/src/components/studio/WorkMasonry.jsx
rename to addons/studio/ui/src/components/studio/WorkMasonry.jsx
diff --git a/core/http/react-ui/src/components/studio/WorkThumb.jsx b/addons/studio/ui/src/components/studio/WorkThumb.jsx
similarity index 100%
rename from core/http/react-ui/src/components/studio/WorkThumb.jsx
rename to addons/studio/ui/src/components/studio/WorkThumb.jsx
diff --git a/core/http/react-ui/src/components/studio/Workspace.jsx b/addons/studio/ui/src/components/studio/Workspace.jsx
similarity index 100%
rename from core/http/react-ui/src/components/studio/Workspace.jsx
rename to addons/studio/ui/src/components/studio/Workspace.jsx
diff --git a/core/http/react-ui/src/components/studio/studio.css b/addons/studio/ui/src/components/studio/studio.css
similarity index 100%
rename from core/http/react-ui/src/components/studio/studio.css
rename to addons/studio/ui/src/components/studio/studio.css
diff --git a/core/http/react-ui/src/components/studio/workspace.css b/addons/studio/ui/src/components/studio/workspace.css
similarity index 100%
rename from core/http/react-ui/src/components/studio/workspace.css
rename to addons/studio/ui/src/components/studio/workspace.css
diff --git a/core/http/react-ui/src/hooks/use3DHistory.js b/addons/studio/ui/src/hooks/use3DHistory.js
similarity index 100%
rename from core/http/react-ui/src/hooks/use3DHistory.js
rename to addons/studio/ui/src/hooks/use3DHistory.js
diff --git a/core/http/react-ui/src/hooks/useMediaHistory.js b/addons/studio/ui/src/hooks/useMediaHistory.js
similarity index 100%
rename from core/http/react-ui/src/hooks/useMediaHistory.js
rename to addons/studio/ui/src/hooks/useMediaHistory.js
diff --git a/core/http/react-ui/src/hooks/useModelSuggestion.js b/addons/studio/ui/src/hooks/useModelSuggestion.js
similarity index 100%
rename from core/http/react-ui/src/hooks/useModelSuggestion.js
rename to addons/studio/ui/src/hooks/useModelSuggestion.js
diff --git a/core/http/react-ui/src/hooks/useSoundExpansionModel.js b/addons/studio/ui/src/hooks/useSoundExpansionModel.js
similarity index 100%
rename from core/http/react-ui/src/hooks/useSoundExpansionModel.js
rename to addons/studio/ui/src/hooks/useSoundExpansionModel.js
diff --git a/core/http/react-ui/src/hooks/useSpectrogram.js b/addons/studio/ui/src/hooks/useSpectrogram.js
similarity index 97%
rename from core/http/react-ui/src/hooks/useSpectrogram.js
rename to addons/studio/ui/src/hooks/useSpectrogram.js
index c6f2c6f9c..2cd7b70fa 100644
--- a/core/http/react-ui/src/hooks/useSpectrogram.js
+++ b/addons/studio/ui/src/hooks/useSpectrogram.js
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'
import { getSharedAudioContext } from './useAudioPeaks'
import { fftRadix2 } from '../utils/fft'
+import { fetchUrl } from '../utils/api'
// Hann windows are reused across frames and across clips, so cache one per
// size. The window tapers each frame to suppress spectral leakage (the
@@ -42,7 +43,7 @@ export default function useSpectrogram(
async function run() {
try {
- const resp = await fetch(src)
+ const resp = await fetchUrl(src)
const raw = await resp.arrayBuffer()
const ctx = getSharedAudioContext()
if (!ctx) throw new Error('Web Audio API not available')
diff --git a/core/http/react-ui/src/hooks/useStudioHandoff.js b/addons/studio/ui/src/hooks/useStudioHandoff.js
similarity index 95%
rename from core/http/react-ui/src/hooks/useStudioHandoff.js
rename to addons/studio/ui/src/hooks/useStudioHandoff.js
index 7a718a6e1..1af13b04e 100644
--- a/core/http/react-ui/src/hooks/useStudioHandoff.js
+++ b/addons/studio/ui/src/hooks/useStudioHandoff.js
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { readAllMediaHistory } from './useMediaHistory'
-import { apiUrl } from '../utils/basePath'
+import { fetchUrl } from '../utils/api'
import { collectWork, readHandoff, sourceUrlFor } from '../utils/studioWork'
// What the Studio front page hands to a workspace: a prompt, a model, a size, a
@@ -38,7 +38,7 @@ export function useHandoffSource(handoff, enabled = true) {
if (!item || !url) { setState({ status: 'missing', blob: null, item }); return undefined }
let cancelled = false
setState({ status: 'loading', blob: null, item })
- fetch(url.startsWith('http') || url.startsWith('data:') ? url : apiUrl(url))
+ fetchUrl(url)
.then(res => { if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.blob() })
.then(blob => { if (!cancelled) setState({ status: 'ready', blob, item }) })
.catch(() => { if (!cancelled) setState({ status: 'error', blob: null, item }) })
diff --git a/core/http/react-ui/src/hooks/useStudioWork.js b/addons/studio/ui/src/hooks/useStudioWork.js
similarity index 100%
rename from core/http/react-ui/src/hooks/useStudioWork.js
rename to addons/studio/ui/src/hooks/useStudioWork.js
diff --git a/core/http/react-ui/src/hooks/useWorkspace.js b/addons/studio/ui/src/hooks/useWorkspace.js
similarity index 100%
rename from core/http/react-ui/src/hooks/useWorkspace.js
rename to addons/studio/ui/src/hooks/useWorkspace.js
diff --git a/addons/studio/ui/src/i18n/media.de.json b/addons/studio/ui/src/i18n/media.de.json
new file mode 100644
index 000000000..45d63d40e
--- /dev/null
+++ b/addons/studio/ui/src/i18n/media.de.json
@@ -0,0 +1,195 @@
+{
+ "studio": {
+ "tabs": {
+ "images": "Bilder",
+ "video": "Video",
+ "tts": "TTS",
+ "sound": "Audio",
+ "transform": "Transform",
+ "overview": "Overview"
+ },
+ "overview": {
+ "eyebrow": "{{ready}} of {{total}} modalities ready",
+ "title": "Studio",
+ "subtitle": "Generate images, video, 3D, speech and sound with the models on this machine.",
+ "canMake": "What you can make",
+ "running": "Running now",
+ "recent": "Recent outputs",
+ "noModel": "No model installed",
+ "install": "Install a model",
+ "ready": "Ready",
+ "seconds": "{{seconds}}s",
+ "describe": {
+ "images": "Text to image, image to image, reference images",
+ "video": "Text to video and image to video",
+ "threed": "Image to mesh reconstruction",
+ "tts": "Text to speech using your voice library",
+ "sound": "Music and sound effects from a prompt",
+ "transform": "Separation, enhancement and voice conversion"
+ }
+ },
+ "groups": {
+ "create": "Create",
+ "voice": "Voice",
+ "transform": "Transform"
+ }
+ },
+ "image": {
+ "title": "Bildgenerierung",
+ "labels": {
+ "model": "Modell",
+ "prompt": "Prompt",
+ "promptPlaceholder": "Beschreiben Sie das Bild, das Sie generieren möchten...",
+ "negativePrompt": "Negativer Prompt",
+ "negativePromptPlaceholder": "Was vermieden werden soll...",
+ "size": "Größe",
+ "count": "Anzahl (1-4)",
+ "advanced": "Erweiterte Einstellungen",
+ "imageInputs": "Bildeingaben",
+ "steps": "Schritte",
+ "stepsPlaceholder": "20",
+ "seed": "Seed",
+ "seedPlaceholder": "Zufällig",
+ "sourceImage": "Ausgangsbild (img2img)",
+ "refImages": "Referenzbilder",
+ "refImagesAdded_one": "{{count}} Bild hinzugefügt",
+ "refImagesAdded_other": "{{count}} Bilder hinzugefügt"
+ },
+ "actions": {
+ "view": "View",
+ "generate": "Generieren",
+ "generating": "Generieren..."
+ },
+ "empty": "Generierte Bilder erscheinen hier",
+ "toasts": {
+ "noPrompt": "Bitte einen Prompt eingeben",
+ "noModel": "Bitte ein Modell auswählen",
+ "noResults": "Keine Bilder generiert"
+ }
+ },
+ "video": {
+ "title": "Videogenerierung",
+ "labels": {
+ "model": "Modell",
+ "prompt": "Prompt",
+ "promptPlaceholder": "Beschreiben Sie das Video, das Sie generieren möchten...",
+ "duration": "Dauer (s)",
+ "fps": "FPS",
+ "size": "Größe",
+ "advanced": "Erweiterte Einstellungen",
+ "seed": "Seed",
+ "seedPlaceholder": "Zufällig",
+ "frames": "Frames",
+ "referenceMedia": "Referenzmedien",
+ "startImage": "Startbild",
+ "endImage": "Endbild",
+ "avatarAudio": "Avatar-Audio"
+ },
+ "actions": {
+ "generate": "Video generieren",
+ "generating": "Generieren..."
+ },
+ "empty": "Generierte Videos erscheinen hier",
+ "toasts": {
+ "noPrompt": "Bitte einen Prompt eingeben",
+ "noModel": "Bitte ein Modell auswählen",
+ "noResults": "Keine Videos generiert"
+ }
+ },
+ "tts": {
+ "title": "Text zu Sprache",
+ "labels": {
+ "model": "Modell",
+ "voice": "Stimme",
+ "voicePlaceholder": "Optionale Stimm-ID",
+ "input": "Text",
+ "inputPlaceholder": "Text zum Synthetisieren eingeben...",
+ "instructions": "Anweisungen",
+ "instructionsPlaceholder": "Zum Beispiel: Langsam und warmherzig sprechen",
+ "instructionsHint": "Optionale Hinweise zur Sprechweise. Die Unterstützung hängt vom Backend ab."
+ },
+ "actions": {
+ "generate": "Audio generieren",
+ "generating": "Generieren..."
+ },
+ "empty": "Generiertes Audio erscheint hier",
+ "toasts": {
+ "noText": "Bitte Text eingeben",
+ "noModel": "Bitte ein Modell auswählen",
+ "generateFailed": "Generierung fehlgeschlagen"
+ }
+ },
+ "sound": {
+ "title": "Audiogenerierung",
+ "labels": {
+ "model": "Modell",
+ "prompt": "Prompt",
+ "promptPlaceholder": "Beschreiben Sie den Klang, den Sie generieren möchten...",
+ "duration": "Dauer (s)",
+ "language": "Sprache",
+ "vocalLanguage": "Vokalsprache",
+ "lyrics": "Liedtext (optional)",
+ "lyricsPlaceholder": "Liedtext für Vokalgenerierung",
+ "advanced": "Erweiterte Einstellungen",
+ "seed": "Seed",
+ "seedPlaceholder": "Zufällig"
+ },
+ "actions": {
+ "generate": "Generieren",
+ "generating": "Generieren..."
+ },
+ "empty": "Generiertes Audio erscheint hier",
+ "toasts": {
+ "noPrompt": "Bitte einen Prompt eingeben",
+ "noModel": "Bitte ein Modell auswählen",
+ "generateFailed": "Generierung fehlgeschlagen"
+ }
+ },
+ "talk": {
+ "title": "Sprechen",
+ "subtitle": "Sprachunterhaltung in Echtzeit",
+ "actions": {
+ "start": "Sitzung starten",
+ "stop": "Sitzung beenden",
+ "connecting": "Verbinden...",
+ "muted": "Stumm",
+ "mute": "Stummschalten",
+ "unmute": "Stumm aufheben"
+ },
+ "labels": {
+ "model": "Modell",
+ "voice": "Stimme",
+ "voicePlaceholder": "alloy",
+ "language": "Sprache",
+ "languagePlaceholder": "de",
+ "instructions": "Anweisungen",
+ "instructionsPlaceholder": "Persönlichkeit des Assistenten festlegen..."
+ },
+ "status": {
+ "idle": "Inaktiv",
+ "connecting": "Verbinden...",
+ "listening": "Höre zu...",
+ "speaking": "Spricht...",
+ "ended": "Sitzung beendet"
+ },
+ "toasts": {
+ "noModel": "Wählen Sie zuerst ein Modell",
+ "connectFailed": "Verbindung fehlgeschlagen: {{message}}"
+ }
+ },
+ "history": {
+ "title": "Verlauf",
+ "empty": "Noch kein Verlauf",
+ "deleteEntry": "Eintrag löschen",
+ "clear": "Verlauf löschen",
+ "clearTitle": "Gesamten Verlauf löschen",
+ "clearMessage": "Alle Verlaufseinträge entfernen? Diese Aktion kann nicht rückgängig gemacht werden.",
+ "clearConfirm": "Löschen",
+ "cleared": "Verlauf gelöscht"
+ },
+ "request": {
+ "heading": "Request",
+ "copyCurl": "Copy as curl",
+ "copied": "Copied"
+ }
+}
diff --git a/addons/studio/ui/src/i18n/media.en.json b/addons/studio/ui/src/i18n/media.en.json
new file mode 100644
index 000000000..e2426fc02
--- /dev/null
+++ b/addons/studio/ui/src/i18n/media.en.json
@@ -0,0 +1,863 @@
+{
+ "studio": {
+ "tabs": {
+ "images": "Images",
+ "video": "Video",
+ "tts": "TTS",
+ "sound": "Sound",
+ "transform": "Transform",
+ "threed": "3D",
+ "overview": "Overview",
+ "diarization": "Diarization"
+ },
+ "tabState": {
+ "on": "(model installed)",
+ "off": "(no model installed)"
+ },
+ "overview": {
+ "title": "Studio",
+ "subtitle": "Images, video, 3D, speech and sound, made with the models on this machine."
+ },
+ "groups": {
+ "create": "Create",
+ "voice": "Voice",
+ "transform": "Transform"
+ },
+ "composer": {
+ "title": "What do you want to make?",
+ "lede": "Describe it and Studio suggests the type. Alt+1 to 7 picks one.",
+ "startTyping": "start typing",
+ "prompt": "Prompt",
+ "placeholder": {
+ "images": "Describe the image you want…",
+ "video": "Describe the shot: subject, camera move, light…",
+ "tts": "Type the words to speak…",
+ "sound": "Describe the sound or music…"
+ },
+ "fileLead": {
+ "threed": "3D turns one picture into an object. Start from one of your pictures below, or add a file on the next page.",
+ "transform": "Transform cleans up a recording or changes a voice. Start from one of your recordings below, or add a file on the next page.",
+ "diarization": "Diarization finds who spoke and when in a recording. Start from one of your recordings below, or add a file on the next page."
+ },
+ "starters": "Starters",
+ "soundsLike": "Sounds like {{type}}.",
+ "soundsLikeMissing": "Sounds like {{type}}, and no {{type}} model is installed yet.",
+ "switchTo": "Switch to {{type}}",
+ "seeNeeds": "See what it needs",
+ "model": "Model",
+ "noModel": "No model installed",
+ "size": "Size",
+ "count": "How many",
+ "countOption_one": "{{count}} image",
+ "countOption_other": "{{count}} images",
+ "startFrom": "Start from",
+ "sourceNone": "Start from nothing",
+ "sourceOnPage": "Add a file on the next page",
+ "fit": {
+ "fits": "Needs {{need}}, fits with {{amount}} GB to spare",
+ "spill": "Needs {{need}}, {{amount}} GB spills to RAM",
+ "over": "Needs {{need}}, {{amount}} GB more than this machine has"
+ },
+ "whyModel": "Install the {{type}} model first",
+ "whyPrompt": "Write a prompt first",
+ "generate": "Generate",
+ "openType": "Open {{type}}",
+ "firstRun": "Nothing is installed yet. Install the one you want first. You can add the others later, or never."
+ },
+ "starters": {
+ "images": [
+ {
+ "label": "Harbour at first light",
+ "text": "A fishing harbour at first light, pastel houses, long reflections, 35 mm film grain"
+ },
+ {
+ "label": "Paper-cut mountains",
+ "text": "Paper-cut mountains in layered teal and amber, soft haze"
+ },
+ {
+ "label": "Macro fern",
+ "text": "Macro photo of a dew-covered fern, shallow depth of field"
+ }
+ ],
+ "video": [
+ {
+ "label": "Misty forest dolly",
+ "text": "Slow dolly through a misty pine forest at sunrise"
+ },
+ {
+ "label": "Waves on black sand",
+ "text": "Waves rolling onto black sand, drone shot"
+ }
+ ],
+ "tts": [
+ {
+ "label": "Welcome line",
+ "text": "Welcome to the harbour tour. The first boats leave at six, and the bakery opens at five."
+ },
+ {
+ "label": "Release note",
+ "text": "Release 3.2 is out. It adds a faster scheduler and fixes the model unload bug."
+ }
+ ],
+ "sound": [
+ {
+ "label": "Lo-fi piano",
+ "text": "Lo-fi piano with rain on glass"
+ },
+ {
+ "label": "Wind in pines",
+ "text": "Wind through pines, no music"
+ }
+ ],
+ "threed": [],
+ "transform": [],
+ "diarization": []
+ },
+ "install": {
+ "title": "No {{type}} model installed",
+ "size": "is {{size}} to download.",
+ "sizeUnknown": "has no download size listed.",
+ "need": "It needs about {{need}} of memory while it runs.",
+ "needUnknown": "Its memory need is not listed.",
+ "free": "{{free}} free now.",
+ "nothingStarts": "Nothing starts until you install it.",
+ "button": "Install {{model}}",
+ "installing": "Installing {{model}}",
+ "started": "Installing {{model}}",
+ "failed": "The install did not start: {{message}}",
+ "galleryDown": "The model gallery could not be reached, so there is nothing to suggest. Check the connection, or add a model by hand.",
+ "noneListed": "The gallery lists no {{type}} model.",
+ "browse": "Browse all models"
+ },
+ "work": {
+ "title": "Your work",
+ "results_one": "{{count}} result",
+ "results_other": "{{count}} results",
+ "nResults_one": "{{count}} result",
+ "nResults_other": "{{count}} results",
+ "group": "Group related results",
+ "clear": "Clear history",
+ "clearTitle": "Clear your work history?",
+ "clearBody": "This removes the list of results and favourites from this browser. Files already made stay on the server.",
+ "clearConfirm": "Clear history",
+ "filters": "Filter your work",
+ "all": "All",
+ "favourites": "Favourites",
+ "favourite": "Favourite",
+ "open": "Open {{title}}",
+ "openProject": "Open {{title}}, {{count}} results that came from each other",
+ "project": "Project",
+ "emptyTitle": "Nothing here yet",
+ "emptyBody": "What you make shows up here, so you can find it again and build on it.",
+ "emptyFilterTitle": "No {{filter}} yet",
+ "emptyFilterBody": "Pick another filter, or make something.",
+ "fileGone": "The file is no longer on the server",
+ "untitled": {
+ "images": "Picture",
+ "video": "Clip",
+ "threed": "3D object",
+ "tts": "Speech",
+ "sound": "Sound",
+ "transform": "Transformed audio",
+ "diarization": "Recording"
+ }
+ },
+ "lineage": {
+ "back": "Your work",
+ "sub_one": "{{count}} result",
+ "sub_other": "{{count}} results that came from each other",
+ "subOne": "One result",
+ "board": "How these results were made, from the prompt on the left",
+ "prompt": "Prompt",
+ "file": "File",
+ "suggested": "Suggested",
+ "draft": "Draft",
+ "needsModel": "Needs a {{type}} model",
+ "ready": "Ready",
+ "dock": "Selected result",
+ "details": "Show details",
+ "take": "Run as a new take",
+ "branch": "Branch from here",
+ "cancel": "Cancel",
+ "openIn": "Open in {{type}}",
+ "newBranch": "New branch: {{step}}",
+ "fromResult": "from {{title}}",
+ "makeFrom": "Make from this result",
+ "targets": "What to make next",
+ "describe": "Describe it",
+ "keeps": "Keeps the original. Opens the workspace with this filled in; nothing runs until you press Generate there.",
+ "promptLabel": "Prompt",
+ "continue": "Continue from this result",
+ "download": "Download",
+ "took": "took {{seconds}} s",
+ "speakers_one": "{{count}} speaker",
+ "speakers_other": "{{count}} speakers",
+ "noInput": "The original input was not kept, so this cannot be run again as it was.",
+ "noBranch": "Nothing accepts a {{type}} as a starting point yet.",
+ "cannotStart": "The {{page}} page cannot start from a {{what}} yet.",
+ "goneTitle": "That result is no longer in this browser",
+ "goneBody": "It may have been cleared from the history. Go back to your work to see what is left."
+ },
+ "edges": {
+ "take": "take",
+ "animate": "animate",
+ "to-3d": "to 3D",
+ "variation": "variation",
+ "transform": "transform",
+ "diarize": "who spoke",
+ "soundtrack": "soundtrack",
+ "turntable": "turntable",
+ "upscale": "upscale"
+ },
+ "steps": {
+ "animate": {
+ "title": "Animate",
+ "why": "Turn this picture into a short clip",
+ "template": "Slow push-in, small natural motion"
+ },
+ "to-3d": {
+ "title": "Make it 3D",
+ "why": "Build an object you can turn around",
+ "plain": "3D starts from this picture. You choose the model and options on the next page."
+ },
+ "variation": {
+ "title": "Variation",
+ "why": "Another take with edits"
+ },
+ "transform": {
+ "title": "Transform",
+ "why": "Clean up this audio or change the voice",
+ "plain": "Transform works on this audio. You choose the model and options on the next page."
+ },
+ "diarize": {
+ "title": "Find who spoke",
+ "why": "Mark each speaker on a timeline",
+ "plain": "This opens the recording in Diarization. You choose the model on the next page."
+ },
+ "soundtrack": {
+ "title": "Add a soundtrack",
+ "why": "Sound that fits the shot"
+ },
+ "turntable": {
+ "title": "Turntable clip",
+ "why": "Show the object from every side"
+ },
+ "upscale": {
+ "title": "Upscale",
+ "why": "Increase the resolution of this image."
+ }
+ },
+ "kinds": {
+ "video": "video",
+ "mesh": "3D object"
+ },
+ "handoff": {
+ "aResult": "a result from Studio",
+ "loading": "Loading {{name}} as the starting point…",
+ "ready": "Starting from {{name}}.",
+ "missing": "The result this was made from is no longer in this browser's history, so there is no starting point. You can still add a file below.",
+ "error": "{{name}} could not be loaded. The server may have cleaned up the file. You can add a file below.",
+ "remove": "Remove"
+ },
+ "error": {
+ "title": "Studio could not read the installed models",
+ "body": "The list of models did not load, so Studio cannot say which types are ready. Your work below is not affected.",
+ "retry": "Try again"
+ },
+ "workspace": {
+ "sourcesLabel": "Starting points",
+ "lede": {
+ "images": "Text to image, image to image, reference images",
+ "video": "Text to video and image to video",
+ "threed": "One picture in, one 3D object out",
+ "tts": "Text to speech, with a saved voice when the model clones",
+ "sound": "Music and sound from a description",
+ "transform": "Clean up a recording, or change a voice",
+ "diarization": "Find who spoke and when"
+ },
+ "sources": {
+ "startImage": "Start from an image",
+ "endImage": "End image",
+ "chosen": "Chosen file",
+ "audio": "Audio",
+ "reference": "Reference"
+ },
+ "fields": {
+ "prompt": "text",
+ "model": "model",
+ "size": "size",
+ "count": "count",
+ "steps": "steps",
+ "seed": "seed",
+ "negative": "negative prompt",
+ "duration": "duration",
+ "fps": "fps",
+ "frames": "frames",
+ "cfg": "CFG",
+ "quality": "quality",
+ "background": "background",
+ "voice": "voice",
+ "instructions": "delivery",
+ "mode": "mode",
+ "caption": "caption",
+ "lyrics": "lyrics",
+ "bpm": "BPM",
+ "key": "key",
+ "language": "language",
+ "timesignature": "time signature",
+ "instrumental": "instrumental",
+ "think": "think mode",
+ "params": "parameters",
+ "speakers": "speakers",
+ "detail": "detail"
+ },
+ "changedFrom": "Changed from this take:",
+ "edited": "edited",
+ "runAgain": "Run again",
+ "useIn": "Use in",
+ "rerun": "Re-run with edits",
+ "lineage": "Lineage",
+ "download": "Download",
+ "tools": "Result actions",
+ "from": "from",
+ "fromGone": "from a result that is no longer in this browser ({{edge}})",
+ "failed": {
+ "title": "That did not finish",
+ "note": "Your prompt and settings are still here.",
+ "retry": "Try again",
+ "traces": "View traces for details"
+ },
+ "strip": {
+ "title": "Recent results",
+ "allWork": "All work",
+ "noFavourites": "No favourites yet. Star a result to keep it here."
+ },
+ "auto": "Auto",
+ "sound": {
+ "more": "More options",
+ "generated": "Sound generated",
+ "noCaption": "Please enter caption or lyrics"
+ },
+ "transform": {
+ "noModel": "Please select a model",
+ "noAudio": "Please choose an audio file",
+ "done": "Audio transformed",
+ "inputGone": "The audio that run used is no longer on the server. Add it again to run it.",
+ "loadReference": "Load a reference first",
+ "noMic": "This browser does not expose the microphone",
+ "recorded": "Recorded the microphone with the reference echo. Press Transform to test echo cancellation.",
+ "echoFailed": "Echo test failed: {{message}}",
+ "recording": "recording {{seconds}} s",
+ "whyAudio": "Add an audio file first",
+ "waiting": "Your audio, waiting to be transformed",
+ "paramCount_one": "{{count}} parameter",
+ "paramCount_other": "{{count}} parameters"
+ },
+ "threed": {
+ "whyImage": "Add a picture first"
+ },
+ "choose": "Choose",
+ "replace": "Replace",
+ "dropHint": "or drop it here",
+ "diarization": {
+ "whyFile": "Choose a recording first",
+ "hint": "Audio or video, or drop it here",
+ "timeline": "Timeline of {{count}} speakers over {{length}}",
+ "segmentCount_one": "{{count}} segment",
+ "segmentCount_other": "{{count}} segments",
+ "talk": "{{seconds}} s of talk, {{percent}}%",
+ "notKept": "The recording and the transcript are not kept, so only the summary of this run is shown. Run it again with the file to see the timeline.",
+ "empty": "Choose a recording to see who spoke and when"
+ }
+ },
+ "upscale": {
+ "model": "Upscaling model",
+ "scale": "Scale",
+ "source": "Source image",
+ "loading": "Upscaling…",
+ "failed": "Upscale failed: {{message}}",
+ "noResult": "No output image was returned."
+ }
+ },
+ "image": {
+ "title": "Image Generation",
+ "labels": {
+ "model": "Model",
+ "prompt": "Prompt",
+ "promptPlaceholder": "Describe the image you want to generate...",
+ "negativePrompt": "Negative Prompt",
+ "negativePromptPlaceholder": "What to avoid...",
+ "size": "Size",
+ "count": "Count (1-4)",
+ "advanced": "Advanced Settings",
+ "imageInputs": "Image Inputs",
+ "steps": "Steps",
+ "stepsPlaceholder": "20",
+ "seed": "Seed",
+ "seedPlaceholder": "Random",
+ "sourceImage": "Source Image (img2img)",
+ "refImages": "Reference Images",
+ "refImagesAdded_one": "{{count}} image added",
+ "refImagesAdded_other": "{{count}} images added"
+ },
+ "actions": {
+ "view": "View",
+ "generate": "Generate",
+ "generating": "Generating..."
+ },
+ "empty": "Generated images will appear here",
+ "toasts": {
+ "noPrompt": "Please enter a prompt",
+ "noModel": "Please select a model",
+ "noResults": "No images generated"
+ }
+ },
+ "video": {
+ "title": "Video Generation",
+ "labels": {
+ "model": "Model",
+ "prompt": "Prompt",
+ "promptPlaceholder": "Describe the video you want to generate...",
+ "duration": "Duration (seconds)",
+ "fps": "FPS",
+ "size": "Size",
+ "advanced": "Advanced Settings",
+ "seed": "Seed",
+ "seedPlaceholder": "Random",
+ "frames": "Frames",
+ "referenceMedia": "Reference media",
+ "startImage": "Start image",
+ "endImage": "End image",
+ "avatarAudio": "Avatar audio"
+ },
+ "actions": {
+ "generate": "Generate",
+ "generating": "Generating..."
+ },
+ "empty": "Generated video will appear here",
+ "toasts": {
+ "noPrompt": "Please enter a prompt",
+ "noModel": "Please select a model",
+ "noResults": "No video generated"
+ }
+ },
+ "threed": {
+ "title": "3D Generation",
+ "animation": {
+ "skeletonHint": "Generates an animated skeleton, without a mesh or skin.",
+ "preview": "Animated skeleton preview",
+ "pause": "Pause",
+ "play": "Play",
+ "reset": "Reset view",
+ "timeline": "Timeline",
+ "zoom": "Zoom",
+ "inputTooLong": "Input exceeds the {{limit}} byte limit.",
+ "fileTooLarge": "The selected file is too large.",
+ "readFailed": "Could not read the selected file."
+ },
+ "labels": {
+ "model": "Model",
+ "image": "Input image",
+ "quality": "Quality",
+ "quality_auto": "Auto (best available)",
+ "quality_coarse": "Coarse preview (fast)",
+ "quality_512": "512³ fine",
+ "quality_1024": "1024³ cascade (slow)",
+ "background": "Background",
+ "background_auto": "Auto-remove solid background",
+ "background_keep": "Keep original",
+ "background_black": "Remove black",
+ "background_white": "Remove white",
+ "advanced": "Advanced Settings",
+ "steps": "Shape steps",
+ "textureSteps": "Material steps",
+ "guidance": "Guidance",
+ "seed": "Seed",
+ "seedPlaceholder": "Random"
+ },
+ "actions": {
+ "generate": "Generate",
+ "generating": "Generating...",
+ "remesh": "Apply remeshing",
+ "remeshing": "Applying remeshing...",
+ "showOriginal": "Show original",
+ "download": "Download GLB"
+ },
+ "remesh": {
+ "title": "Watertight print remesh",
+ "detail": "Remesh detail",
+ "coarser": "Coarser · faster",
+ "finer": "Finer · slower",
+ "hint": "Detail controls the smallest preserved features. The enclosing offset follows it automatically.",
+ "ready": "Previewing the watertight remeshed model. This is the version that will be downloaded."
+ },
+ "viewer": {
+ "wireframe": "Wireframe",
+ "autoRotate": "Auto-rotate",
+ "noWebgl": "WebGL2 is not available in this browser — download the GLB to view it elsewhere.",
+ "contextLost": "The 3D view lost the GPU context — reload the page to restore it.",
+ "stats": "{{verts}} vertices · {{tris}} triangles",
+ "hint": "drag: rotate · pinch/wheel: zoom · two-finger/right-drag: pan · double-click: reset"
+ },
+ "empty": "Generated 3D model will appear here",
+ "toasts": {
+ "noImage": "Please provide an input image",
+ "noModel": "Please select a model",
+ "noResults": "No model generated"
+ }
+ },
+ "tts": {
+ "title": "Text to Speech",
+ "labels": {
+ "model": "Model",
+ "voice": "Voice",
+ "voicePlaceholder": "Optional speaker or voice ID",
+ "input": "Text",
+ "inputPlaceholder": "Enter text to synthesize...",
+ "instructions": "Instructions",
+ "instructionsPlaceholder": "For example: Speak slowly and warmly",
+ "instructionsHint": "Optional delivery guidance. Support depends on the backend."
+ },
+ "actions": {
+ "generate": "Generate",
+ "generating": "Generating..."
+ },
+ "empty": "Generated audio will appear here",
+ "toasts": {
+ "noText": "Please enter text",
+ "noModel": "Please select a model",
+ "generated": "Speech generated",
+ "generateFailed": "Generation failed"
+ },
+ "voiceLibrary": {
+ "cloningReady": "Voice cloning",
+ "loading": "Loading saved personalities…",
+ "modelDefault": "Use model default",
+ "empty": "No saved personalities yet.",
+ "create": "Create one",
+ "manage": "Manage Personality Library",
+ "namedVoiceHint": "This model does not accept reference-audio profiles. You can still enter a named speaker supported by its backend."
+ }
+ },
+ "voiceLibrary": {
+ "title": "Speech voices",
+ "subtitle": "A recording and its exact transcript. Text-to-speech models that accept reference audio can speak with it.",
+ "loadFailed": "Speech voices could not be loaded",
+ "loading": "Loading saved personalities…",
+ "listLabel": "Saved voice personalities",
+ "status": {
+ "ready": "Ready"
+ },
+ "summary": {
+ "label": "Voice library status",
+ "profiles": "saved personalities",
+ "modelsReady_one": "{{count}} compatible model ready",
+ "modelsReady_other": "{{count}} compatible models ready",
+ "noModels": "No compatible model installed"
+ },
+ "search": {
+ "label": "Search voice profiles",
+ "placeholder": "Search voices, languages, or transcripts"
+ },
+ "filters": {
+ "language": "Filter by language",
+ "allLanguages": "All languages"
+ },
+ "empty": {
+ "title": "No speech voices yet",
+ "body": "Record or upload a consented reference clip, add its exact transcript, and reuse the voice across compatible text-to-speech models."
+ },
+ "noResults": {
+ "title": "No voices match these filters",
+ "body": "Try a different search or show every language."
+ },
+ "detail": {
+ "eyebrow": "Reference recording and exact transcript",
+ "close": "Close",
+ "emptyTitle": "Select a voice to inspect it",
+ "emptyBody": "Reference audio, transcript, compatibility, and consent details will appear here.",
+ "referenceAudio": "Reference audio",
+ "transcript": "Reference transcript"
+ },
+ "metadata": {
+ "language": "Language",
+ "languageUnknown": "Not specified",
+ "duration": "Duration",
+ "sampleRate": "Sample rate",
+ "created": "Created"
+ },
+ "consent": {
+ "label": "Permission",
+ "confirmed": "Consent confirmed",
+ "confirmedAt": "Confirmed {{date}}"
+ },
+ "modelSetup": {
+ "title": "Install a voice-cloning model",
+ "body": "This voice is ready, but it needs a model that accepts reference audio. Choose one verified by this server.",
+ "loading": "Finding compatible models in your galleries…",
+ "backendUnknown": "Backend selected during install",
+ "install": "Install",
+ "installing": "Installing…",
+ "loadFailed": "Compatible recommendations could not be loaded.",
+ "noneAvailable": "No compatible uninstalled model is available in the configured galleries.",
+ "capabilityNote": "Recommendations come from model capability metadata, not a browser-side model list."
+ },
+ "api": {
+ "title": "API usage and compatible models",
+ "summary": "Use this voice outside the browser or check model support",
+ "body": "Send the profile’s stable voice URI with each speech request. LocalAI resolves the private reference audio and transcript at generation time; model YAML is not modified.",
+ "compatibleModels": "Compatible installed models",
+ "noInstalledModels": "None yet. Install one of the server-verified models above before sending this request.",
+ "curlExample": "cURL example",
+ "copy": "Copy request",
+ "endpointNote": "The same model, input, and voice fields also work with POST /tts."
+ },
+ "actions": {
+ "create": "Add a speech voice",
+ "createFirst": "Add your first speech voice",
+ "retry": "Try again",
+ "clearFilters": "Clear filters",
+ "useInTTS": "Use in Text to Speech",
+ "delete": "Delete voice",
+ "installModel": "Install a compatible model",
+ "browseModels": "Browse all models"
+ },
+ "deleteDialog": {
+ "title": "Delete this voice?",
+ "message": "“{{name}}” and its reference audio will be permanently removed. Existing model YAML is not changed.",
+ "deleting": "Deleting…"
+ },
+ "toasts": {
+ "deleted": "Deleted {{name}}",
+ "installStarted": "Installing {{name}}. Progress is available in Operations.",
+ "installFailed": "Could not start installation: {{message}}",
+ "apiCopied": "API request copied",
+ "copyFailed": "Could not copy the API request"
+ }
+ },
+ "voiceCreate": {
+ "title": "Add a speech voice",
+ "subtitle": "A speech voice is one or more clean reference clips and their exact transcripts. Text-to-speech models that accept reference audio can speak with it.",
+ "references": {
+ "add": "Add another reference",
+ "additional": "Reference {{number}}",
+ "remove": "Remove"
+ },
+ "sections": {
+ "reference": {
+ "title": "Reference audio",
+ "body": "Record in the browser or upload an existing clip. Speech should be clean, natural, and from one speaker."
+ },
+ "details": {
+ "title": "Identity and transcript",
+ "body": "Give admins a recognizable name and transcribe the clip exactly, including fillers and punctuation."
+ },
+ "consent": {
+ "title": "Authorization",
+ "body": "Voice cloning can be sensitive. Confirm permission before this reference is saved."
+ }
+ },
+ "audio": {
+ "label": "Voice reference",
+ "normalizing": "Preparing a secure PCM WAV…",
+ "preview": "Normalized reference",
+ "tooLarge": "This file is larger than 50 MiB.",
+ "durationError": "Reference audio must be between 1 second and 2 minutes.",
+ "decodeError": "This browser could not decode the selected audio file.",
+ "qualityReady": "Recommended length",
+ "qualityHint": "6–30 seconds usually produces a stronger clone"
+ },
+ "fields": {
+ "name": "Voice name",
+ "namePlaceholder": "e.g. Documentary narrator",
+ "language": "Language (optional)",
+ "languagePlaceholder": "e.g. en-US",
+ "description": "Description (optional)",
+ "descriptionPlaceholder": "Tone, source, or intended use",
+ "transcript": "Exact transcript",
+ "transcriptPlaceholder": "Type every word spoken in the reference clip…",
+ "transcriptHint": "Match the recording exactly. Do not rewrite grammar or remove pauses and fillers."
+ },
+ "consent": {
+ "title": "I confirm that this voice may be cloned",
+ "body": "I have the speaker’s permission or another lawful basis to store and use this recording for synthesized speech."
+ },
+ "readiness": {
+ "title": "Profile readiness",
+ "body": "A voice becomes available in Text to Speech when every required item is complete.",
+ "audio": "Valid PCM-WAV reference",
+ "quality": "6–30 second quality window",
+ "name": "Recognizable voice name",
+ "transcript": "Exact reference transcript",
+ "consent": "Authorization confirmed"
+ },
+ "privacy": {
+ "title": "Private by default. ",
+ "body": "Reference audio is stored in LocalAI’s data directory with restricted file permissions and is never exposed as a model-folder path."
+ },
+ "actions": {
+ "back": "Back to library",
+ "cancel": "Cancel",
+ "save": "Save voice",
+ "saving": "Saving…"
+ },
+ "toasts": {
+ "created": "{{name}} is ready to use"
+ }
+ },
+ "sound": {
+ "title": "Sound Generation",
+ "labels": {
+ "model": "Model",
+ "prompt": "Prompt",
+ "promptPlaceholder": "Describe the sound you want to generate...",
+ "instrumental": "Instrumental",
+ "bpm": "BPM",
+ "duration": "Duration (seconds)",
+ "language": "Language",
+ "timesignature": "Time Signature",
+ "keyscale": "Key/Scale",
+ "vocalLanguage": "Vocal language",
+ "vocalLanguagePlaceholder": "e.g. English",
+ "caption": "Caption",
+ "lyrics": "Lyrics",
+ "lyricsPlaceholder": "Lyrics for vocal generation",
+ "simple": "Simple",
+ "advanced": "Advanced",
+ "seed": "Seed",
+ "seedPlaceholder": "Random",
+ "thinkMode": "Think mode"
+ },
+ "actions": {
+ "generate": "Generate",
+ "generating": "Generating..."
+ },
+ "empty": "Generated audio will appear here",
+ "toasts": {
+ "noPrompt": "Please enter a prompt",
+ "noModel": "Please select a model",
+ "generateFailed": "Generation failed"
+ },
+ "expansion": {
+ "label": "Prompt expansion model",
+ "placeholder": "Choose a model or backend default",
+ "backendDefault": "Backend default",
+ "choose": "Choose a chat model or Backend default to continue.",
+ "loading": "Loading installed chat models…",
+ "error": "Cannot load chat models. Choose Backend default or return to this tab to retry.",
+ "noChat": "No installed chat models. Choose Backend default to use the audio backend.",
+ "help": "A chat model expands your description into style and lyrics. Backend default uses the audio backend’s existing prompt behavior."
+ }
+ },
+ "audioTransform": {
+ "title": "Audio Transform",
+ "labels": {
+ "model": "Model",
+ "audio": "Audio (required)",
+ "reference": "Reference (optional)",
+ "referenceHelp": "Loopback / far-end signal for echo cancellation, target speaker for voice conversion. Leave empty for unconditional transform.",
+ "advancedParameters": "Advanced parameters",
+ "advancedParametersHelp": "backend-specific (one key=value per line, e.g. noise_gate=true)",
+ "advancedParametersPlaceholder": "# Optional. For LocalVQE:\n# noise_gate=true\n# noise_gate_threshold_dbfs=-50"
+ },
+ "input": {
+ "upload": "Upload",
+ "uploadDescription": "Drop a file here or",
+ "uploadBrowse": "browse",
+ "record": "Record",
+ "startRecording": "Start recording",
+ "stop": "Stop",
+ "encoding": "Encoding...",
+ "clear": "Clear",
+ "microphoneUnavailable": "Microphone capture is unavailable in this browser.",
+ "echoNotice": "Browsers often apply their own WebRTC echo cancellation and noise suppression by default. This usually results in worse performance than running LocalVQE on the raw audio.",
+ "echoTest": "Echo test (record mic while playing reference)",
+ "stopEchoTest": "Stop echo test"
+ },
+ "result": {
+ "audio": "Audio",
+ "reference": "Reference",
+ "inputSpectrum": "Input spectrum",
+ "outputSpectrum": "Output spectrum",
+ "outputSpectrumHint": "Transform to compare attenuation",
+ "output": "Output"
+ },
+ "actions": {
+ "transform": "Transform",
+ "processing": "Processing..."
+ },
+ "empty": "Choose an audio file (and optional reference) to transform"
+ },
+ "talk": {
+ "title": "Talk",
+ "subtitle": "Realtime voice conversation",
+ "actions": {
+ "start": "Start session",
+ "stop": "End session",
+ "connecting": "Connecting...",
+ "muted": "Muted",
+ "mute": "Mute",
+ "unmute": "Unmute"
+ },
+ "labels": {
+ "model": "Model",
+ "voice": "Voice",
+ "voicePlaceholder": "alloy",
+ "language": "Language",
+ "languagePlaceholder": "en",
+ "instructions": "Instructions",
+ "instructionsPlaceholder": "Set the assistant's persona..."
+ },
+ "status": {
+ "idle": "Idle",
+ "connecting": "Connecting...",
+ "listening": "Listening...",
+ "speaking": "Speaking...",
+ "ended": "Session ended"
+ },
+ "toasts": {
+ "noModel": "Select a model first",
+ "connectFailed": "Failed to connect: {{message}}"
+ }
+ },
+ "history": {
+ "title": "History",
+ "empty": "No history yet",
+ "deleteEntry": "Delete entry",
+ "clear": "Clear history",
+ "clearTitle": "Clear all history",
+ "clearMessage": "Remove all history entries? This cannot be undone.",
+ "clearConfirm": "Clear",
+ "cleared": "History cleared"
+ },
+ "request": {
+ "heading": "Request",
+ "copyCurl": "Copy as curl",
+ "copied": "Copied"
+ },
+ "diarization": {
+ "title": "Speaker diarization",
+ "subtitle": "Upload a recording to see who spoke when. Preview clean speech before remembering a voice.",
+ "model": "Model",
+ "recording": "Recording",
+ "optIn": "Prepare speakers to remember",
+ "warning": "Remembered voices are shared globally on this server and are lost when it restarts. Nothing is remembered automatically.",
+ "manage": "Manage remembered voices",
+ "running": "Diarizing…",
+ "run": "Diarize",
+ "unsupported": "Choose a profile-capable model with a speaker encoder, or turn off “Prepare speakers to remember” to run normal diarization.",
+ "previewError": "Could not play this recording. Check that your browser supports its audio format.",
+ "speakers": "Speakers",
+ "segments": "Segments",
+ "duration": "Clean speech: {{seconds}} seconds",
+ "preview": "Preview {{number}}",
+ "insufficient": "Not enough clean speech or no usable profile. Try a recording with longer, non-overlapping speech.",
+ "nameAndRemember": "Name and remember",
+ "name": "Name",
+ "saving": "Saving…",
+ "remember": "Remember",
+ "cancel": "Cancel",
+ "stop": "Stop preview",
+ "missingProfiles": "The backend did not return the requested speaker profiles."
+ }
+}
diff --git a/addons/studio/ui/src/i18n/media.es.json b/addons/studio/ui/src/i18n/media.es.json
new file mode 100644
index 000000000..fb5796b20
--- /dev/null
+++ b/addons/studio/ui/src/i18n/media.es.json
@@ -0,0 +1,195 @@
+{
+ "studio": {
+ "tabs": {
+ "images": "Imágenes",
+ "video": "Video",
+ "tts": "TTS",
+ "sound": "Sonido",
+ "transform": "Transform",
+ "overview": "Overview"
+ },
+ "overview": {
+ "eyebrow": "{{ready}} of {{total}} modalities ready",
+ "title": "Studio",
+ "subtitle": "Generate images, video, 3D, speech and sound with the models on this machine.",
+ "canMake": "What you can make",
+ "running": "Running now",
+ "recent": "Recent outputs",
+ "noModel": "No model installed",
+ "install": "Install a model",
+ "ready": "Ready",
+ "seconds": "{{seconds}}s",
+ "describe": {
+ "images": "Text to image, image to image, reference images",
+ "video": "Text to video and image to video",
+ "threed": "Image to mesh reconstruction",
+ "tts": "Text to speech using your voice library",
+ "sound": "Music and sound effects from a prompt",
+ "transform": "Separation, enhancement and voice conversion"
+ }
+ },
+ "groups": {
+ "create": "Create",
+ "voice": "Voice",
+ "transform": "Transform"
+ }
+ },
+ "image": {
+ "title": "Generación de imágenes",
+ "labels": {
+ "model": "Modelo",
+ "prompt": "Prompt",
+ "promptPlaceholder": "Describe la imagen que quieres generar...",
+ "negativePrompt": "Prompt negativo",
+ "negativePromptPlaceholder": "Qué evitar...",
+ "size": "Tamaño",
+ "count": "Cantidad (1-4)",
+ "advanced": "Configuración avanzada",
+ "imageInputs": "Entradas de imagen",
+ "steps": "Pasos",
+ "stepsPlaceholder": "20",
+ "seed": "Seed",
+ "seedPlaceholder": "Aleatorio",
+ "sourceImage": "Imagen fuente (img2img)",
+ "refImages": "Imágenes de referencia",
+ "refImagesAdded_one": "{{count}} imagen añadida",
+ "refImagesAdded_other": "{{count}} imágenes añadidas"
+ },
+ "actions": {
+ "view": "View",
+ "generate": "Generar",
+ "generating": "Generando..."
+ },
+ "empty": "Las imágenes generadas aparecerán aquí",
+ "toasts": {
+ "noPrompt": "Por favor introduce un prompt",
+ "noModel": "Por favor selecciona un modelo",
+ "noResults": "No se generaron imágenes"
+ }
+ },
+ "video": {
+ "title": "Generación de video",
+ "labels": {
+ "model": "Modelo",
+ "prompt": "Prompt",
+ "promptPlaceholder": "Describe el video que quieres generar...",
+ "duration": "Duración (s)",
+ "fps": "FPS",
+ "size": "Tamaño",
+ "advanced": "Configuración avanzada",
+ "seed": "Seed",
+ "seedPlaceholder": "Aleatorio",
+ "frames": "Fotogramas",
+ "referenceMedia": "Medios de referencia",
+ "startImage": "Imagen inicial",
+ "endImage": "Imagen final",
+ "avatarAudio": "Audio del avatar"
+ },
+ "actions": {
+ "generate": "Generar video",
+ "generating": "Generando..."
+ },
+ "empty": "Los videos generados aparecerán aquí",
+ "toasts": {
+ "noPrompt": "Por favor introduce un prompt",
+ "noModel": "Por favor selecciona un modelo",
+ "noResults": "No se generaron videos"
+ }
+ },
+ "tts": {
+ "title": "Texto a voz",
+ "labels": {
+ "model": "Modelo",
+ "voice": "Voz",
+ "voicePlaceholder": "ID de voz opcional",
+ "input": "Texto",
+ "inputPlaceholder": "Introduce el texto a sintetizar...",
+ "instructions": "Instrucciones",
+ "instructionsPlaceholder": "Por ejemplo: Habla despacio y con calidez",
+ "instructionsHint": "Indicaciones opcionales sobre la locución. La compatibilidad depende del backend."
+ },
+ "actions": {
+ "generate": "Generar audio",
+ "generating": "Generando..."
+ },
+ "empty": "El audio generado aparecerá aquí",
+ "toasts": {
+ "noText": "Por favor introduce texto",
+ "noModel": "Por favor selecciona un modelo",
+ "generateFailed": "Generación fallida"
+ }
+ },
+ "sound": {
+ "title": "Generación de sonido",
+ "labels": {
+ "model": "Modelo",
+ "prompt": "Prompt",
+ "promptPlaceholder": "Describe el sonido que quieres generar...",
+ "duration": "Duración (s)",
+ "language": "Idioma",
+ "vocalLanguage": "Idioma vocal",
+ "lyrics": "Letras (opcional)",
+ "lyricsPlaceholder": "Letras para generación vocal",
+ "advanced": "Configuración avanzada",
+ "seed": "Seed",
+ "seedPlaceholder": "Aleatorio"
+ },
+ "actions": {
+ "generate": "Generar",
+ "generating": "Generando..."
+ },
+ "empty": "El audio generado aparecerá aquí",
+ "toasts": {
+ "noPrompt": "Por favor introduce un prompt",
+ "noModel": "Por favor selecciona un modelo",
+ "generateFailed": "Generación fallida"
+ }
+ },
+ "talk": {
+ "title": "Hablar",
+ "subtitle": "Conversación de voz en tiempo real",
+ "actions": {
+ "start": "Iniciar sesión",
+ "stop": "Terminar sesión",
+ "connecting": "Conectando...",
+ "muted": "Silenciado",
+ "mute": "Silenciar",
+ "unmute": "Activar sonido"
+ },
+ "labels": {
+ "model": "Modelo",
+ "voice": "Voz",
+ "voicePlaceholder": "alloy",
+ "language": "Idioma",
+ "languagePlaceholder": "es",
+ "instructions": "Instrucciones",
+ "instructionsPlaceholder": "Define la personalidad del asistente..."
+ },
+ "status": {
+ "idle": "Inactivo",
+ "connecting": "Conectando...",
+ "listening": "Escuchando...",
+ "speaking": "Hablando...",
+ "ended": "Sesión finalizada"
+ },
+ "toasts": {
+ "noModel": "Selecciona un modelo primero",
+ "connectFailed": "Error al conectar: {{message}}"
+ }
+ },
+ "history": {
+ "title": "Historial",
+ "empty": "Sin historial aún",
+ "deleteEntry": "Eliminar entrada",
+ "clear": "Borrar historial",
+ "clearTitle": "Borrar todo el historial",
+ "clearMessage": "¿Eliminar todas las entradas del historial? Esto no se puede deshacer.",
+ "clearConfirm": "Borrar",
+ "cleared": "Historial borrado"
+ },
+ "request": {
+ "heading": "Request",
+ "copyCurl": "Copy as curl",
+ "copied": "Copied"
+ }
+}
diff --git a/addons/studio/ui/src/i18n/media.id.json b/addons/studio/ui/src/i18n/media.id.json
new file mode 100644
index 000000000..72512a338
--- /dev/null
+++ b/addons/studio/ui/src/i18n/media.id.json
@@ -0,0 +1,242 @@
+{
+ "studio": {
+ "tabs": {
+ "images": "Gambar",
+ "video": "Video",
+ "tts": "TTS",
+ "sound": "Suara",
+ "transform": "Transformasi",
+ "overview": "Ikhtisar"
+ },
+ "overview": {
+ "eyebrow": "{{ready}} dari {{total}} modalitas siap",
+ "title": "Studio",
+ "subtitle": "Hasilkan gambar, video, 3D dan suara dengan model pada mesin ini.",
+ "canMake": "Apa yang dapat Anda buat",
+ "running": "Sedang berjalan",
+ "recent": "Output terbaru",
+ "noModel": "Tidak ada model yang terinstal",
+ "install": "Instal model",
+ "ready": "Siap",
+ "seconds": "{{seconds}}s",
+ "describe": {
+ "images": "Teks ke gambar, gambar ke gambar, gambar referensi",
+ "video": "Teks ke video dan gambar ke video",
+ "threed": "Rekonstruksi mesh dari gambar",
+ "tts": "Teks ke suara menggunakan pustaka suara Anda",
+ "sound": "Musik dan efek suara dari prompt",
+ "transform": "Pemisahan, peningkatan dan konversi suara"
+ }
+ },
+ "groups": {
+ "create": "Buat",
+ "voice": "Suara",
+ "transform": "Transformasi"
+ }
+ },
+ "image": {
+ "title": "Pembuatan Gambar",
+ "labels": {
+ "model": "Model",
+ "prompt": "Prompt",
+ "promptPlaceholder": "Deskripsikan gambar yang ingin Anda buat...",
+ "negativePrompt": "Prompt Negatif",
+ "negativePromptPlaceholder": "Apa yang harus dihindari...",
+ "size": "Ukuran",
+ "count": "Jumlah (1-4)",
+ "advanced": "Pengaturan Tingkat Lanjutan",
+ "imageInputs": "Input Gambar",
+ "steps": "Steps",
+ "stepsPlaceholder": "20",
+ "seed": "Seed",
+ "seedPlaceholder": "Acak",
+ "sourceImage": "Gambar Sumber (img2img)",
+ "refImages": "Gambar Referensi",
+ "refImagesAdded_one": "{{count}} gambar ditambahkan",
+ "refImagesAdded_other": "{{count}} gambar ditambahkan"
+ },
+ "actions": {
+ "view": "Lihat",
+ "generate": "Hasilkan",
+ "generating": "Menghasilkan..."
+ },
+ "empty": "Gambar yang dihasilkan akan muncul di sini",
+ "toasts": {
+ "noPrompt": "Silahkan masukkan prompt",
+ "noModel": "Silahkan pilih model",
+ "noResults": "Tidak ada gambar yang dihasilkan"
+ }
+ },
+ "video": {
+ "title": "Pembuatan Video",
+ "labels": {
+ "model": "Model",
+ "prompt": "Prompt",
+ "promptPlaceholder": "Deskripsikan video yang ingin Anda buat...",
+ "duration": "Durasi (detik)",
+ "fps": "FPS",
+ "size": "Ukuran",
+ "advanced": "Pengaturan Tingkat Lanjutan",
+ "seed": "Seed",
+ "seedPlaceholder": "Acak",
+ "frames": "Frame",
+ "referenceMedia": "Media referensi",
+ "startImage": "Gambar awal",
+ "endImage": "Gambar akhir",
+ "avatarAudio": "Audio avatar"
+ },
+ "actions": {
+ "generate": "Hasilkan",
+ "generating": "Menghasilkan..."
+ },
+ "empty": "Video yang dihasilkan akan muncul di sini",
+ "toasts": {
+ "noPrompt": "Silahkan masukkan prompt",
+ "noModel": "Silahkan pilih model",
+ "noResults": "Tidak ada video yang dihasilkan"
+ }
+ },
+ "tts": {
+ "title": "Teks ke Suara",
+ "labels": {
+ "model": "Model",
+ "voice": "Suara",
+ "voicePlaceholder": "ID suara opsional",
+ "input": "Teks",
+ "inputPlaceholder": "Masukkan teks untuk disintesis...",
+ "instructions": "Instruksi",
+ "instructionsPlaceholder": "Contoh: Ucapkan perlahan dan hangat",
+ "instructionsHint": "Panduan penyampaian opsional. Dukungan bergantung pada backend."
+ },
+ "actions": {
+ "generate": "Hasilkan",
+ "generating": "Menghasilkan..."
+ },
+ "empty": "Audio yang dihasilkan akan muncul di sini",
+ "toasts": {
+ "noText": "Silahkan masukkan teks",
+ "noModel": "Silahkan pilih model",
+ "generateFailed": "Gagal menghasilkan"
+ }
+ },
+ "sound": {
+ "title": "Pembuatan Suara",
+ "labels": {
+ "model": "Model",
+ "prompt": "Prompt",
+ "promptPlaceholder": "Deskripsikan suara yang ingin Anda buat...",
+ "instrumental": "Instrumental",
+ "bpm": "Tempo",
+ "duration": "Durasi (detik)",
+ "language": "Bahasa",
+ "timesignature": "Tanda birama",
+ "keyscale": "Tangga nada",
+ "vocalLanguage": "Bahasa vokal",
+ "vocalLanguagePlaceholder": "cth. Bahasa Indonesia",
+ "caption": "Takarir",
+ "lyrics": "Lirik",
+ "lyricsPlaceholder": "Lirik untuk menghasilkan vokal",
+ "simple": "Sederhana",
+ "advanced": "Tingkat Lanjutan",
+ "seed": "Seed",
+ "seedPlaceholder": "Acak",
+ "thinkMode": "Mode penalaran"
+ },
+ "actions": {
+ "generate": "Hasilkan",
+ "generating": "Menghasilkan..."
+ },
+ "empty": "Suara yang dihasilkan akan muncul di sini",
+ "toasts": {
+ "noPrompt": "Silahkan masukkan prompt",
+ "noModel": "Silahkan pilih model",
+ "generateFailed": "Gagal menghasilkan"
+ }
+ },
+ "audioTransform": {
+ "title": "Transformasi Audio",
+ "labels": {
+ "model": "Model",
+ "audio": "Audio (wajib)",
+ "reference": "Referensi (opsional)",
+ "referenceHelp": "Sinyal loopback / ujung jauh untuk pembatalan gema, atau pembicara target untuk konversi suara. Kosongkan untuk transformasi tanpa syarat.",
+ "advancedParameters": "Parameter tingkat lanjut",
+ "advancedParametersHelp": "Spesifik backend (satu key=value per baris, contoh: noise_gate=true)",
+ "advancedParametersPlaceholder": "# Opsional. Untuk LocalVQE:\n# noise_gate=true\n# noise_gate_threshold_dbfs=-50"
+ },
+ "input": {
+ "upload": "Unggah",
+ "uploadDescription": "Tarik file di sini atau",
+ "uploadBrowse": "jelajahi",
+ "record": "Rekam",
+ "startRecording": "Mulai merekam",
+ "stop": "Selesai",
+ "encoding": "Pengenkodean...",
+ "clear": "Hapus",
+ "microphoneUnavailable": "Perekaman mikrofon tidak tersedia di peramban ini.",
+ "echoNotice": "Peramban sering kali menerapkan pembatalan gema dan penekanan derau WebRTC secara bawaan. Hal ini biasanya menghasilkan performa yang lebih buruk dibandingkan menjalankan LocalVQE pada audio mentah.",
+ "echoTest": "Tes gema (rekam mik saat memutar referensi)",
+ "stopEchoTest": "Hentikan tes gema"
+ },
+ "result": {
+ "audio": "Audio",
+ "reference": "Referensi",
+ "inputSpectrum": "Input spektrum",
+ "outputSpectrum": "Output spektrum",
+ "outputSpectrumHint": "Transformasi untuk membandingkan atenuasi",
+ "output": "Output"
+ },
+ "actions": {
+ "transform": "Transformasi",
+ "processing": "Memproses..."
+ },
+ "empty": "Pilih file audio (dan referensi opsional) untuk ditransformasikan"
+ },
+ "talk": {
+ "title": "Percakapan",
+ "subtitle": "Percakapan suara realtime",
+ "actions": {
+ "start": "Mulai sesi",
+ "stop": "Akhiri sesi",
+ "connecting": "Menghubungkan...",
+ "muted": "Disenyapkan",
+ "mute": "Senyapkan",
+ "unmute": "Aktifkan Suara"
+ },
+ "labels": {
+ "model": "Model",
+ "voice": "Suara",
+ "voicePlaceholder": "alloy",
+ "language": "Bahasa",
+ "languagePlaceholder": "en",
+ "instructions": "Instruksi",
+ "instructionsPlaceholder": "Atur persona asisten..."
+ },
+ "status": {
+ "idle": "Idle",
+ "connecting": "Menghubungkan...",
+ "listening": "Mendengarkan...",
+ "speaking": "Berbicara...",
+ "ended": "Sesi berakhir"
+ },
+ "toasts": {
+ "noModel": "Silahkan pilih model terlebih dahulu",
+ "connectFailed": "Gagal terhubung: {{message}}"
+ }
+ },
+ "history": {
+ "title": "Riwayat",
+ "empty": "Tidak ada riwayat",
+ "deleteEntry": "Hapus entri",
+ "clear": "Hapus riwayat",
+ "clearTitle": "Hapus semua riwayat",
+ "clearMessage": "Hapus semua entri riwayat? Tindakan ini tidak dapat dibatalkan.",
+ "clearConfirm": "Hapus",
+ "cleared": "Riwayat dihapus"
+ },
+ "request": {
+ "heading": "Request",
+ "copyCurl": "Copy as curl",
+ "copied": "Copied"
+ }
+}
diff --git a/addons/studio/ui/src/i18n/media.it.json b/addons/studio/ui/src/i18n/media.it.json
new file mode 100644
index 000000000..efd019e0f
--- /dev/null
+++ b/addons/studio/ui/src/i18n/media.it.json
@@ -0,0 +1,195 @@
+{
+ "studio": {
+ "tabs": {
+ "images": "Immagini",
+ "video": "Video",
+ "tts": "TTS",
+ "sound": "Audio",
+ "transform": "Transform",
+ "overview": "Overview"
+ },
+ "overview": {
+ "eyebrow": "{{ready}} of {{total}} modalities ready",
+ "title": "Studio",
+ "subtitle": "Generate images, video, 3D, speech and sound with the models on this machine.",
+ "canMake": "What you can make",
+ "running": "Running now",
+ "recent": "Recent outputs",
+ "noModel": "No model installed",
+ "install": "Install a model",
+ "ready": "Ready",
+ "seconds": "{{seconds}}s",
+ "describe": {
+ "images": "Text to image, image to image, reference images",
+ "video": "Text to video and image to video",
+ "threed": "Image to mesh reconstruction",
+ "tts": "Text to speech using your voice library",
+ "sound": "Music and sound effects from a prompt",
+ "transform": "Separation, enhancement and voice conversion"
+ }
+ },
+ "groups": {
+ "create": "Create",
+ "voice": "Voice",
+ "transform": "Transform"
+ }
+ },
+ "image": {
+ "title": "Generazione immagini",
+ "labels": {
+ "model": "Modello",
+ "prompt": "Prompt",
+ "promptPlaceholder": "Descrivi l'immagine che vuoi generare...",
+ "negativePrompt": "Prompt negativo",
+ "negativePromptPlaceholder": "Cosa evitare...",
+ "size": "Dimensione",
+ "count": "Quantità (1-4)",
+ "advanced": "Impostazioni avanzate",
+ "imageInputs": "Input immagini",
+ "steps": "Passi",
+ "stepsPlaceholder": "20",
+ "seed": "Seed",
+ "seedPlaceholder": "Casuale",
+ "sourceImage": "Immagine sorgente (img2img)",
+ "refImages": "Immagini di riferimento",
+ "refImagesAdded_one": "{{count}} immagine aggiunta",
+ "refImagesAdded_other": "{{count}} immagini aggiunte"
+ },
+ "actions": {
+ "view": "Visualizza",
+ "generate": "Genera",
+ "generating": "Generazione..."
+ },
+ "empty": "Le immagini generate appariranno qui",
+ "toasts": {
+ "noPrompt": "Inserisci un prompt",
+ "noModel": "Seleziona un modello",
+ "noResults": "Nessuna immagine generata"
+ }
+ },
+ "video": {
+ "title": "Generazione video",
+ "labels": {
+ "model": "Modello",
+ "prompt": "Prompt",
+ "promptPlaceholder": "Descrivi il video che vuoi generare...",
+ "duration": "Durata (s)",
+ "fps": "FPS",
+ "size": "Dimensione",
+ "advanced": "Impostazioni avanzate",
+ "seed": "Seed",
+ "seedPlaceholder": "Casuale",
+ "frames": "Fotogrammi",
+ "referenceMedia": "Media di riferimento",
+ "startImage": "Immagine iniziale",
+ "endImage": "Immagine finale",
+ "avatarAudio": "Audio avatar"
+ },
+ "actions": {
+ "generate": "Genera video",
+ "generating": "Generazione..."
+ },
+ "empty": "I video generati appariranno qui",
+ "toasts": {
+ "noPrompt": "Inserisci un prompt",
+ "noModel": "Seleziona un modello",
+ "noResults": "Nessun video generato"
+ }
+ },
+ "tts": {
+ "title": "Text to Speech",
+ "labels": {
+ "model": "Modello",
+ "voice": "Voce",
+ "voicePlaceholder": "ID voce opzionale",
+ "input": "Testo",
+ "inputPlaceholder": "Inserisci il testo da convertire in voce...",
+ "instructions": "Istruzioni",
+ "instructionsPlaceholder": "Ad esempio: Parla lentamente e con calore",
+ "instructionsHint": "Indicazioni facoltative sullo stile. Il supporto dipende dal backend."
+ },
+ "actions": {
+ "generate": "Genera audio",
+ "generating": "Generazione..."
+ },
+ "empty": "L'audio generato apparirà qui",
+ "toasts": {
+ "noText": "Inserisci del testo",
+ "noModel": "Seleziona un modello",
+ "generateFailed": "Generazione fallita"
+ }
+ },
+ "sound": {
+ "title": "Generazione audio",
+ "labels": {
+ "model": "Modello",
+ "prompt": "Prompt",
+ "promptPlaceholder": "Descrivi il suono che vuoi generare...",
+ "duration": "Durata (s)",
+ "language": "Lingua",
+ "vocalLanguage": "Lingua vocale",
+ "lyrics": "Testi (opzionale)",
+ "lyricsPlaceholder": "Testi per la generazione vocale",
+ "advanced": "Impostazioni avanzate",
+ "seed": "Seed",
+ "seedPlaceholder": "Casuale"
+ },
+ "actions": {
+ "generate": "Genera",
+ "generating": "Generazione..."
+ },
+ "empty": "L'audio generato apparirà qui",
+ "toasts": {
+ "noPrompt": "Inserisci un prompt",
+ "noModel": "Seleziona un modello",
+ "generateFailed": "Generazione fallita"
+ }
+ },
+ "talk": {
+ "title": "Conversazione",
+ "subtitle": "Conversazione vocale in tempo reale",
+ "actions": {
+ "start": "Avvia sessione",
+ "stop": "Termina sessione",
+ "connecting": "Connessione...",
+ "muted": "Muto",
+ "mute": "Disattiva microfono",
+ "unmute": "Riattiva microfono"
+ },
+ "labels": {
+ "model": "Modello",
+ "voice": "Voce",
+ "voicePlaceholder": "alloy",
+ "language": "Lingua",
+ "languagePlaceholder": "it",
+ "instructions": "Istruzioni",
+ "instructionsPlaceholder": "Imposta la personalità dell'assistente..."
+ },
+ "status": {
+ "idle": "Inattivo",
+ "connecting": "Connessione...",
+ "listening": "In ascolto...",
+ "speaking": "Sta parlando...",
+ "ended": "Sessione terminata"
+ },
+ "toasts": {
+ "noModel": "Seleziona prima un modello",
+ "connectFailed": "Connessione fallita: {{message}}"
+ }
+ },
+ "history": {
+ "title": "Cronologia",
+ "empty": "Nessuna cronologia",
+ "deleteEntry": "Elimina voce",
+ "clear": "Cancella cronologia",
+ "clearTitle": "Cancella tutta la cronologia",
+ "clearMessage": "Rimuovere tutte le voci della cronologia? Questa azione non può essere annullata.",
+ "clearConfirm": "Cancella",
+ "cleared": "Cronologia cancellata"
+ },
+ "request": {
+ "heading": "Request",
+ "copyCurl": "Copy as curl",
+ "copied": "Copied"
+ }
+}
diff --git a/addons/studio/ui/src/i18n/media.ko.json b/addons/studio/ui/src/i18n/media.ko.json
new file mode 100644
index 000000000..52e7ebd86
--- /dev/null
+++ b/addons/studio/ui/src/i18n/media.ko.json
@@ -0,0 +1,195 @@
+{
+ "studio": {
+ "tabs": {
+ "images": "이미지",
+ "video": "비디오",
+ "tts": "TTS",
+ "sound": "사운드",
+ "transform": "Transform",
+ "overview": "Overview"
+ },
+ "overview": {
+ "eyebrow": "{{ready}} of {{total}} modalities ready",
+ "title": "Studio",
+ "subtitle": "Generate images, video, 3D, speech and sound with the models on this machine.",
+ "canMake": "What you can make",
+ "running": "Running now",
+ "recent": "Recent outputs",
+ "noModel": "No model installed",
+ "install": "Install a model",
+ "ready": "Ready",
+ "seconds": "{{seconds}}s",
+ "describe": {
+ "images": "Text to image, image to image, reference images",
+ "video": "Text to video and image to video",
+ "threed": "Image to mesh reconstruction",
+ "tts": "Text to speech using your voice library",
+ "sound": "Music and sound effects from a prompt",
+ "transform": "Separation, enhancement and voice conversion"
+ }
+ },
+ "groups": {
+ "create": "Create",
+ "voice": "Voice",
+ "transform": "Transform"
+ }
+ },
+ "image": {
+ "title": "이미지 생성",
+ "labels": {
+ "model": "모델",
+ "prompt": "프롬프트",
+ "promptPlaceholder": "생성할 이미지를 설명하세요...",
+ "negativePrompt": "네거티브 프롬프트",
+ "negativePromptPlaceholder": "피할 요소...",
+ "size": "크기",
+ "count": "개수 (1-4)",
+ "advanced": "고급 설정",
+ "imageInputs": "이미지 입력",
+ "steps": "스텝",
+ "stepsPlaceholder": "20",
+ "seed": "시드",
+ "seedPlaceholder": "랜덤",
+ "sourceImage": "원본 이미지 (img2img)",
+ "refImages": "참조 이미지",
+ "refImagesAdded_one": "이미지 {{count}}개 추가됨",
+ "refImagesAdded_other": "이미지 {{count}}개 추가됨"
+ },
+ "actions": {
+ "view": "View",
+ "generate": "생성",
+ "generating": "생성 중..."
+ },
+ "empty": "생성된 이미지가 여기에 표시됩니다",
+ "toasts": {
+ "noPrompt": "프롬프트를 입력해 주세요",
+ "noModel": "모델을 선택해 주세요",
+ "noResults": "생성된 이미지가 없습니다"
+ }
+ },
+ "video": {
+ "title": "비디오 생성",
+ "labels": {
+ "model": "모델",
+ "prompt": "프롬프트",
+ "promptPlaceholder": "생성할 비디오를 설명하세요...",
+ "duration": "길이 (초)",
+ "fps": "FPS",
+ "size": "크기",
+ "advanced": "고급 설정",
+ "seed": "시드",
+ "seedPlaceholder": "랜덤",
+ "frames": "프레임",
+ "referenceMedia": "참조 미디어",
+ "startImage": "시작 이미지",
+ "endImage": "종료 이미지",
+ "avatarAudio": "아바타 오디오"
+ },
+ "actions": {
+ "generate": "생성",
+ "generating": "생성 중..."
+ },
+ "empty": "생성된 비디오가 여기에 표시됩니다",
+ "toasts": {
+ "noPrompt": "프롬프트를 입력해 주세요",
+ "noModel": "모델을 선택해 주세요",
+ "noResults": "생성된 비디오가 없습니다"
+ }
+ },
+ "tts": {
+ "title": "텍스트 음성 변환",
+ "labels": {
+ "model": "모델",
+ "voice": "음성",
+ "voicePlaceholder": "음성 ID (선택 사항)",
+ "input": "텍스트",
+ "inputPlaceholder": "합성할 텍스트를 입력하세요...",
+ "instructions": "지침",
+ "instructionsPlaceholder": "예: 천천히 따뜻한 어조로 말하기",
+ "instructionsHint": "선택 사항인 말하기 지침입니다. 지원 여부는 백엔드에 따라 다릅니다."
+ },
+ "actions": {
+ "generate": "생성",
+ "generating": "생성 중..."
+ },
+ "empty": "생성된 오디오가 여기에 표시됩니다",
+ "toasts": {
+ "noText": "텍스트를 입력해 주세요",
+ "noModel": "모델을 선택해 주세요",
+ "generateFailed": "생성에 실패했습니다"
+ }
+ },
+ "sound": {
+ "title": "사운드 생성",
+ "labels": {
+ "model": "모델",
+ "prompt": "프롬프트",
+ "promptPlaceholder": "생성할 사운드를 설명하세요...",
+ "duration": "길이 (초)",
+ "language": "언어",
+ "vocalLanguage": "보컬 언어",
+ "lyrics": "가사 (선택 사항)",
+ "lyricsPlaceholder": "보컬 생성을 위한 가사",
+ "advanced": "고급 설정",
+ "seed": "시드",
+ "seedPlaceholder": "랜덤"
+ },
+ "actions": {
+ "generate": "생성",
+ "generating": "생성 중..."
+ },
+ "empty": "생성된 오디오가 여기에 표시됩니다",
+ "toasts": {
+ "noPrompt": "프롬프트를 입력해 주세요",
+ "noModel": "모델을 선택해 주세요",
+ "generateFailed": "생성에 실패했습니다"
+ }
+ },
+ "talk": {
+ "title": "대화",
+ "subtitle": "실시간 음성 대화",
+ "actions": {
+ "start": "세션 시작",
+ "stop": "세션 종료",
+ "connecting": "연결 중...",
+ "muted": "음소거됨",
+ "mute": "음소거",
+ "unmute": "음소거 해제"
+ },
+ "labels": {
+ "model": "모델",
+ "voice": "음성",
+ "voicePlaceholder": "alloy",
+ "language": "언어",
+ "languagePlaceholder": "en",
+ "instructions": "지침",
+ "instructionsPlaceholder": "어시스턴트의 페르소나를 설정하세요..."
+ },
+ "status": {
+ "idle": "대기 중",
+ "connecting": "연결 중...",
+ "listening": "듣는 중...",
+ "speaking": "말하는 중...",
+ "ended": "세션이 종료되었습니다"
+ },
+ "toasts": {
+ "noModel": "먼저 모델을 선택하세요",
+ "connectFailed": "연결에 실패했습니다: {{message}}"
+ }
+ },
+ "history": {
+ "title": "기록",
+ "empty": "아직 기록이 없습니다",
+ "deleteEntry": "항목 삭제",
+ "clear": "기록 지우기",
+ "clearTitle": "모든 기록 지우기",
+ "clearMessage": "모든 기록 항목을 제거하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
+ "clearConfirm": "지우기",
+ "cleared": "기록이 지워졌습니다"
+ },
+ "request": {
+ "heading": "Request",
+ "copyCurl": "Copy as curl",
+ "copied": "Copied"
+ }
+}
diff --git a/addons/studio/ui/src/i18n/media.pt-BR.json b/addons/studio/ui/src/i18n/media.pt-BR.json
new file mode 100644
index 000000000..8d9e11055
--- /dev/null
+++ b/addons/studio/ui/src/i18n/media.pt-BR.json
@@ -0,0 +1,464 @@
+{
+ "studio": {
+ "tabs": {
+ "images": "Imagens",
+ "video": "Vídeo",
+ "tts": "TTS",
+ "sound": "Som",
+ "transform": "Transformar",
+ "threed": "3D",
+ "overview": "Visão geral"
+ },
+ "overview": {
+ "eyebrow": "{{ready}} de {{total}} modalidades prontas",
+ "title": "Studio",
+ "subtitle": "Gere imagens, vídeos, 3D, fala e som com os modelos desta máquina.",
+ "canMake": "O que você pode criar",
+ "running": "Em execução agora",
+ "recent": "Saídas recentes",
+ "noModel": "Nenhum modelo instalado",
+ "install": "Instalar um modelo",
+ "ready": "Pronto",
+ "seconds": "{{seconds}}s",
+ "describe": {
+ "images": "Texto para imagem, imagem para imagem, imagens de referência",
+ "video": "Texto para vídeo e imagem para vídeo",
+ "threed": "Reconstrução de malha a partir de imagem",
+ "tts": "Texto para fala usando sua biblioteca de vozes",
+ "sound": "Música e efeitos sonoros a partir de um prompt",
+ "transform": "Separação, aprimoramento e conversão de voz"
+ }
+ },
+ "groups": {
+ "create": "Criar",
+ "voice": "Voz",
+ "transform": "Transformar"
+ }
+ },
+ "image": {
+ "title": "Geração de Imagens",
+ "labels": {
+ "model": "Modelo",
+ "prompt": "Prompt",
+ "promptPlaceholder": "Descreva a imagem que deseja gerar...",
+ "negativePrompt": "Prompt Negativo",
+ "negativePromptPlaceholder": "O que evitar...",
+ "size": "Tamanho",
+ "count": "Quantidade (1-4)",
+ "advanced": "Configurações Avançadas",
+ "imageInputs": "Entradas de Imagem",
+ "steps": "Etapas",
+ "stepsPlaceholder": "20",
+ "seed": "Semente",
+ "seedPlaceholder": "Aleatória",
+ "sourceImage": "Imagem de Origem (img2img)",
+ "refImages": "Imagens de Referência",
+ "refImagesAdded_one": "{{count}} imagem adicionada",
+ "refImagesAdded_other": "{{count}} imagens adicionadas"
+ },
+ "actions": {
+ "view": "Ver",
+ "generate": "Gerar",
+ "generating": "Gerando..."
+ },
+ "empty": "As imagens geradas aparecerão aqui",
+ "toasts": {
+ "noPrompt": "Digite um prompt",
+ "noModel": "Selecione um modelo",
+ "noResults": "Nenhuma imagem gerada"
+ }
+ },
+ "video": {
+ "title": "Geração de Vídeo",
+ "labels": {
+ "model": "Modelo",
+ "prompt": "Prompt",
+ "promptPlaceholder": "Descreva o vídeo que deseja gerar...",
+ "duration": "Duração (segundos)",
+ "fps": "FPS",
+ "size": "Tamanho",
+ "advanced": "Configurações Avançadas",
+ "seed": "Semente",
+ "seedPlaceholder": "Aleatória",
+ "frames": "Quadros",
+ "referenceMedia": "Mídia de referência",
+ "startImage": "Imagem inicial",
+ "endImage": "Imagem final",
+ "avatarAudio": "Áudio do avatar"
+ },
+ "actions": {
+ "generate": "Gerar",
+ "generating": "Gerando..."
+ },
+ "empty": "O vídeo gerado aparecerá aqui",
+ "toasts": {
+ "noPrompt": "Digite um prompt",
+ "noModel": "Selecione um modelo",
+ "noResults": "Nenhum vídeo gerado"
+ }
+ },
+ "threed": {
+ "title": "Geração 3D",
+ "labels": {
+ "model": "Modelo",
+ "image": "Imagem de entrada",
+ "quality": "Qualidade",
+ "quality_auto": "Automática (melhor disponível)",
+ "quality_coarse": "Pré-visualização grosseira (rápida)",
+ "quality_512": "512³ fino",
+ "quality_1024": "Cascata 1024³ (lenta)",
+ "background": "Fundo",
+ "background_auto": "Remover automaticamente o fundo sólido",
+ "background_keep": "Manter original",
+ "background_black": "Remover preto",
+ "background_white": "Remover branco",
+ "advanced": "Configurações Avançadas",
+ "steps": "Etapas de forma",
+ "textureSteps": "Etapas de material",
+ "guidance": "Orientação",
+ "seed": "Semente",
+ "seedPlaceholder": "Aleatória"
+ },
+ "actions": {
+ "generate": "Gerar",
+ "generating": "Gerando...",
+ "remesh": "Aplicar remalhamento",
+ "remeshing": "Aplicando remalhamento...",
+ "showOriginal": "Mostrar original",
+ "download": "Baixar GLB"
+ },
+ "remesh": {
+ "title": "Remalhamento hermético para impressão",
+ "detail": "Detalhe do remalhamento",
+ "coarser": "Mais grosseiro · mais rápido",
+ "finer": "Mais fino · mais lento",
+ "hint": "O detalhe controla as menores características preservadas. O deslocamento de envolvimento segue automaticamente.",
+ "ready": "Pré-visualizando o modelo remalhado hermético. Esta é a versão que será baixada."
+ },
+ "viewer": {
+ "wireframe": "Wireframe",
+ "autoRotate": "Rotação automática",
+ "noWebgl": "WebGL2 não está disponível neste navegador — baixe o GLB para vê-lo em outro lugar.",
+ "contextLost": "A visualização 3D perdeu o contexto da GPU — recarregue a página para restaurá-la.",
+ "stats": "{{verts}} vértices · {{tris}} triângulos",
+ "hint": "arrastar: girar · pinça/roda: zoom · dois dedos/clique direito: mover · duplo clique: redefinir"
+ },
+ "empty": "O modelo 3D gerado aparecerá aqui",
+ "toasts": {
+ "noImage": "Forneça uma imagem de entrada",
+ "noModel": "Selecione um modelo",
+ "noResults": "Nenhum modelo gerado"
+ }
+ },
+ "tts": {
+ "title": "Texto para Fala",
+ "labels": {
+ "model": "Modelo",
+ "voice": "Voz",
+ "voicePlaceholder": "Falante opcional ou ID de voz",
+ "input": "Texto",
+ "inputPlaceholder": "Digite o texto para sintetizar...",
+ "instructions": "Instruções",
+ "instructionsPlaceholder": "Por exemplo: Fale devagar e com calor",
+ "instructionsHint": "Orientações opcionais de interpretação. O suporte depende do backend."
+ },
+ "actions": {
+ "generate": "Gerar",
+ "generating": "Gerando..."
+ },
+ "empty": "O áudio gerado aparecerá aqui",
+ "toasts": {
+ "noText": "Digite o texto",
+ "noModel": "Selecione um modelo",
+ "generated": "Fala gerada",
+ "generateFailed": "Falha na geração"
+ },
+ "voiceLibrary": {
+ "cloningReady": "Clonagem de voz",
+ "loading": "Carregando vozes salvas…",
+ "modelDefault": "Usar padrão do modelo",
+ "empty": "Nenhuma voz salva ainda.",
+ "create": "Criar uma",
+ "manage": "Gerenciar Biblioteca de Vozes",
+ "namedVoiceHint": "Este modelo não aceita perfis de áudio de referência. Você ainda pode digitar um falante nomeado suportado pelo seu backend."
+ }
+ },
+ "voiceLibrary": {
+ "title": "Biblioteca de Vozes",
+ "subtitle": "Crie e gerencie vozes de referência reutilizáveis para cada modelo instalado que suporta clonagem de voz.",
+ "loading": "Carregando vozes salvas…",
+ "listLabel": "Perfis de voz salvos",
+ "status": {
+ "ready": "Pronto"
+ },
+ "summary": {
+ "label": "Status da biblioteca de vozes",
+ "profiles": "vozes salvas",
+ "modelsReady_one": "{{count}} modelo compatível pronto",
+ "modelsReady_other": "{{count}} modelos compatíveis prontos",
+ "noModels": "Nenhum modelo compatível instalado"
+ },
+ "search": {
+ "label": "Buscar perfis de voz",
+ "placeholder": "Buscar vozes, idiomas ou transcrições"
+ },
+ "filters": {
+ "language": "Filtrar por idioma",
+ "allLanguages": "Todos os idiomas"
+ },
+ "empty": {
+ "title": "Sua biblioteca de vozes está vazia",
+ "body": "Grave ou envie um clipe de referência autorizado, adicione a transcrição exata e reuse-o nos modelos de TTS compatíveis."
+ },
+ "noResults": {
+ "title": "Nenhuma voz corresponde a estes filtros",
+ "body": "Tente uma busca diferente ou mostre todos os idiomas."
+ },
+ "detail": {
+ "eyebrow": "Voz selecionada",
+ "emptyTitle": "Selecione uma voz para inspecioná-la",
+ "emptyBody": "Áudio de referência, transcrição, compatibilidade e detalhes de consentimento aparecerão aqui.",
+ "referenceAudio": "Áudio de referência",
+ "transcript": "Transcrição de referência"
+ },
+ "metadata": {
+ "language": "Idioma",
+ "languageUnknown": "Não especificado",
+ "duration": "Duração",
+ "sampleRate": "Taxa de amostragem",
+ "created": "Criado em"
+ },
+ "consent": {
+ "confirmed": "Consentimento confirmado",
+ "confirmedAt": "Confirmado em {{date}}"
+ },
+ "modelSetup": {
+ "title": "Instalar um modelo de clonagem de voz",
+ "body": "Esta voz está pronta, mas precisa de um modelo que aceite áudio de referência. Escolha um verificado por este servidor.",
+ "loading": "Encontrando modelos compatíveis em suas galerias…",
+ "backendUnknown": "Backend selecionado durante a instalação",
+ "install": "Instalar",
+ "installing": "Instalando…",
+ "loadFailed": "Recomendações compatíveis não puderam ser carregadas.",
+ "noneAvailable": "Nenhum modelo compatível não instalado está disponível nas galerias configuradas.",
+ "capabilityNote": "As recomendações vêm dos metadados de capacidade do modelo, não de uma lista de modelos do lado do navegador."
+ },
+ "api": {
+ "title": "Uso da API e modelos compatíveis",
+ "summary": "Use esta voz fora do navegador ou verifique o suporte do modelo",
+ "body": "Envie a URI de voz estável do perfil com cada requisição de fala. O LocalAI resolve o áudio de referência privado e a transcrição no momento da geração; o YAML do modelo não é modificado.",
+ "compatibleModels": "Modelos compatíveis instalados",
+ "noInstalledModels": "Nenhum ainda. Instale um dos modelos verificados pelo servidor acima antes de enviar esta requisição.",
+ "curlExample": "Exemplo com cURL",
+ "copy": "Copiar requisição",
+ "endpointNote": "Os mesmos campos de modelo, entrada e voz também funcionam com POST /tts."
+ },
+ "actions": {
+ "create": "Criar voz",
+ "createFirst": "Crie sua primeira voz",
+ "retry": "Tentar novamente",
+ "clearFilters": "Limpar filtros",
+ "useInTTS": "Usar em Texto para Fala",
+ "delete": "Excluir voz",
+ "installModel": "Instalar um modelo compatível",
+ "browseModels": "Explorar todos os modelos"
+ },
+ "deleteDialog": {
+ "title": "Excluir esta voz?",
+ "message": "“{{name}}” e seu áudio de referência serão removidos permanentemente. O YAML dos modelos existentes não é alterado.",
+ "deleting": "Excluindo…"
+ },
+ "toasts": {
+ "deleted": "Voz {{name}} excluída",
+ "installStarted": "Instalando {{name}}. O progresso está disponível em Operações.",
+ "installFailed": "Não foi possível iniciar a instalação: {{message}}",
+ "apiCopied": "Requisição de API copiada",
+ "copyFailed": "Não foi possível copiar a requisição de API"
+ }
+ },
+ "voiceCreate": {
+ "eyebrow": "Biblioteca de Vozes",
+ "title": "Criar uma voz reutilizável",
+ "subtitle": "Adicione um clipe de referência limpo e sua transcrição exata. O LocalAI cuida dos parâmetros de clonagem específicos do backend no momento da geração.",
+ "sections": {
+ "reference": {
+ "title": "Áudio de referência",
+ "body": "Grave no navegador ou envie um clipe existente. A fala deve ser limpa, natural e de um único falante."
+ },
+ "details": {
+ "title": "Identidade e transcrição",
+ "body": "Dê aos administradores um nome reconhecível e transcreva o clipe exatamente, incluindo hesitações e pontuação."
+ },
+ "consent": {
+ "title": "Autorização",
+ "body": "A clonagem de voz pode ser sensível. Confirme a permissão antes que esta referência seja salva."
+ }
+ },
+ "audio": {
+ "label": "Referência de voz",
+ "normalizing": "Preparando um PCM WAV seguro…",
+ "preview": "Referência normalizada",
+ "durationError": "O áudio de referência deve ter entre 1 segundo e 2 minutos.",
+ "decodeError": "Este navegador não conseguiu decodificar o arquivo de áudio selecionado.",
+ "qualityReady": "Duração recomendada",
+ "qualityHint": "6–30 segundos geralmente produzem um clone mais forte"
+ },
+ "fields": {
+ "name": "Nome da voz",
+ "namePlaceholder": "ex.: Narrador de documentário",
+ "language": "Idioma (opcional)",
+ "languagePlaceholder": "ex.: pt-BR",
+ "description": "Descrição (opcional)",
+ "descriptionPlaceholder": "Tom, origem ou uso pretendido",
+ "transcript": "Transcrição exata",
+ "transcriptPlaceholder": "Digite cada palavra falada no clipe de referência…",
+ "transcriptHint": "Corresponda à gravação exatamente. Não reescreva a gramática nem remova pausas e hesitações."
+ },
+ "consent": {
+ "title": "Confirmo que esta voz pode ser clonada",
+ "body": "Tenho a permissão do falante ou outra base legal para armazenar e usar esta gravação para fala sintetizada."
+ },
+ "readiness": {
+ "title": "Prontidão do perfil",
+ "body": "Uma voz fica disponível em Texto para Fala quando todos os itens obrigatórios estão completos.",
+ "audio": "Referência PCM-WAV válida",
+ "quality": "Janela de qualidade de 6–30 segundos",
+ "name": "Nome de voz reconhecível",
+ "transcript": "Transcrição de referência exata",
+ "consent": "Autorização confirmada"
+ },
+ "privacy": {
+ "title": "Privada por padrão. ",
+ "body": "O áudio de referência é armazenado no diretório de dados do LocalAI com permissões de arquivo restritas e nunca é exposto como um caminho de pasta de modelo."
+ },
+ "actions": {
+ "back": "Voltar para a biblioteca",
+ "cancel": "Cancelar",
+ "save": "Salvar voz",
+ "saving": "Salvando…"
+ },
+ "toasts": {
+ "created": "{{name}} está pronta para uso"
+ }
+ },
+ "sound": {
+ "title": "Geração de Som",
+ "labels": {
+ "model": "Modelo",
+ "prompt": "Prompt",
+ "promptPlaceholder": "Descreva o som que deseja gerar...",
+ "instrumental": "Instrumental",
+ "bpm": "BPM",
+ "duration": "Duração (segundos)",
+ "language": "Idioma",
+ "timesignature": "Fórmula de Compasso",
+ "keyscale": "Tom/Escala",
+ "vocalLanguage": "Idioma vocal",
+ "vocalLanguagePlaceholder": "ex.: Português",
+ "caption": "Legenda",
+ "lyrics": "Letra",
+ "lyricsPlaceholder": "Letra para geração vocal",
+ "simple": "Simples",
+ "advanced": "Avançado",
+ "seed": "Semente",
+ "seedPlaceholder": "Aleatória",
+ "thinkMode": "Modo de pensamento"
+ },
+ "actions": {
+ "generate": "Gerar",
+ "generating": "Gerando..."
+ },
+ "empty": "O áudio gerado aparecerá aqui",
+ "toasts": {
+ "noPrompt": "Digite um prompt",
+ "noModel": "Selecione um modelo",
+ "generateFailed": "Falha na geração"
+ }
+ },
+ "audioTransform": {
+ "title": "Transformação de Áudio",
+ "labels": {
+ "model": "Modelo",
+ "audio": "Áudio (obrigatório)",
+ "reference": "Referência (opcional)",
+ "referenceHelp": "Sinal de loopback / extremidade distante para cancelamento de eco, falante alvo para conversão de voz. Deixe vazio para transformação incondicional.",
+ "advancedParameters": "Parâmetros avançados",
+ "advancedParametersHelp": "específicos do backend (uma chave=valor por linha, ex.: noise_gate=true)",
+ "advancedParametersPlaceholder": "# Opcional. Para LocalVQE:\n# noise_gate=true\n# noise_gate_threshold_dbfs=-50"
+ },
+ "input": {
+ "upload": "Enviar",
+ "uploadDescription": "Solte um arquivo aqui ou",
+ "uploadBrowse": "navegue",
+ "record": "Gravar",
+ "startRecording": "Iniciar gravação",
+ "stop": "Parar",
+ "encoding": "Codificando...",
+ "clear": "Limpar",
+ "microphoneUnavailable": "A captura de microfone não está disponível neste navegador.",
+ "echoNotice": "Os navegadores costumam aplicar seu próprio cancelamento de eco WebRTC e supressão de ruído por padrão. Isso geralmente resulta em desempenho pior do que executar o LocalVQE no áudio bruto.",
+ "echoTest": "Teste de eco (grave o microfone enquanto reproduz a referência)",
+ "stopEchoTest": "Parar teste de eco"
+ },
+ "result": {
+ "audio": "Áudio",
+ "reference": "Referência",
+ "inputSpectrum": "Espectro de entrada",
+ "outputSpectrum": "Espectro de saída",
+ "outputSpectrumHint": "Transforme para comparar a atenuação",
+ "output": "Saída"
+ },
+ "actions": {
+ "transform": "Transformar",
+ "processing": "Processando..."
+ },
+ "empty": "Escolha um arquivo de áudio (e referência opcional) para transformar"
+ },
+ "talk": {
+ "title": "Falar",
+ "subtitle": "Conversa de voz em tempo real",
+ "actions": {
+ "start": "Iniciar sessão",
+ "stop": "Encerrar sessão",
+ "connecting": "Conectando...",
+ "muted": "Mudo",
+ "mute": "Silenciar",
+ "unmute": "Reativar som"
+ },
+ "labels": {
+ "model": "Modelo",
+ "voice": "Voz",
+ "voicePlaceholder": "alloy",
+ "language": "Idioma",
+ "languagePlaceholder": "pt",
+ "instructions": "Instruções",
+ "instructionsPlaceholder": "Defina a persona do assistente..."
+ },
+ "status": {
+ "idle": "Ocioso",
+ "connecting": "Conectando...",
+ "listening": "Ouvindo...",
+ "speaking": "Falando...",
+ "ended": "Sessão encerrada"
+ },
+ "toasts": {
+ "noModel": "Selecione um modelo primeiro",
+ "connectFailed": "Falha ao conectar: {{message}}"
+ }
+ },
+ "history": {
+ "title": "Histórico",
+ "empty": "Nenhum histórico ainda",
+ "deleteEntry": "Excluir entrada",
+ "clear": "Limpar histórico",
+ "clearTitle": "Limpar todo o histórico",
+ "clearMessage": "Remover todas as entradas do histórico? Esta ação não pode ser desfeita.",
+ "clearConfirm": "Limpar",
+ "cleared": "Histórico limpo"
+ },
+ "request": {
+ "heading": "Requisição",
+ "copyCurl": "Copiar como curl",
+ "copied": "Copiado"
+ }
+}
diff --git a/addons/studio/ui/src/i18n/media.zh-CN.json b/addons/studio/ui/src/i18n/media.zh-CN.json
new file mode 100644
index 000000000..9af8856b7
--- /dev/null
+++ b/addons/studio/ui/src/i18n/media.zh-CN.json
@@ -0,0 +1,195 @@
+{
+ "studio": {
+ "tabs": {
+ "images": "图像",
+ "video": "视频",
+ "tts": "TTS",
+ "sound": "声音",
+ "transform": "Transform",
+ "overview": "Overview"
+ },
+ "overview": {
+ "eyebrow": "{{ready}} of {{total}} modalities ready",
+ "title": "Studio",
+ "subtitle": "Generate images, video, 3D, speech and sound with the models on this machine.",
+ "canMake": "What you can make",
+ "running": "Running now",
+ "recent": "Recent outputs",
+ "noModel": "No model installed",
+ "install": "Install a model",
+ "ready": "Ready",
+ "seconds": "{{seconds}}s",
+ "describe": {
+ "images": "Text to image, image to image, reference images",
+ "video": "Text to video and image to video",
+ "threed": "Image to mesh reconstruction",
+ "tts": "Text to speech using your voice library",
+ "sound": "Music and sound effects from a prompt",
+ "transform": "Separation, enhancement and voice conversion"
+ }
+ },
+ "groups": {
+ "create": "Create",
+ "voice": "Voice",
+ "transform": "Transform"
+ }
+ },
+ "image": {
+ "title": "图像生成",
+ "labels": {
+ "model": "模型",
+ "prompt": "提示词",
+ "promptPlaceholder": "描述您想要生成的图像...",
+ "negativePrompt": "负面提示词",
+ "negativePromptPlaceholder": "想要避免的内容...",
+ "size": "尺寸",
+ "count": "数量 (1-4)",
+ "advanced": "高级设置",
+ "imageInputs": "图像输入",
+ "steps": "步数",
+ "stepsPlaceholder": "20",
+ "seed": "随机种子",
+ "seedPlaceholder": "随机",
+ "sourceImage": "源图像 (img2img)",
+ "refImages": "参考图像",
+ "refImagesAdded_one": "已添加 {{count}} 张图像",
+ "refImagesAdded_other": "已添加 {{count}} 张图像"
+ },
+ "actions": {
+ "view": "View",
+ "generate": "生成",
+ "generating": "生成中..."
+ },
+ "empty": "生成的图像将显示在此处",
+ "toasts": {
+ "noPrompt": "请输入提示词",
+ "noModel": "请选择一个模型",
+ "noResults": "未生成图像"
+ }
+ },
+ "video": {
+ "title": "视频生成",
+ "labels": {
+ "model": "模型",
+ "prompt": "提示词",
+ "promptPlaceholder": "描述您想要生成的视频...",
+ "duration": "时长(秒)",
+ "fps": "FPS",
+ "size": "尺寸",
+ "advanced": "高级设置",
+ "seed": "随机种子",
+ "seedPlaceholder": "随机",
+ "frames": "帧数",
+ "referenceMedia": "参考媒体",
+ "startImage": "起始图像",
+ "endImage": "结束图像",
+ "avatarAudio": "虚拟形象音频"
+ },
+ "actions": {
+ "generate": "生成视频",
+ "generating": "生成中..."
+ },
+ "empty": "生成的视频将显示在此处",
+ "toasts": {
+ "noPrompt": "请输入提示词",
+ "noModel": "请选择一个模型",
+ "noResults": "未生成视频"
+ }
+ },
+ "tts": {
+ "title": "文字转语音",
+ "labels": {
+ "model": "模型",
+ "voice": "声音",
+ "voicePlaceholder": "可选的声音 ID",
+ "input": "文本",
+ "inputPlaceholder": "输入要合成的文本...",
+ "instructions": "朗读指令",
+ "instructionsPlaceholder": "例如:缓慢、温暖地朗读",
+ "instructionsHint": "可选的表达方式指导。是否支持取决于后端。"
+ },
+ "actions": {
+ "generate": "生成音频",
+ "generating": "生成中..."
+ },
+ "empty": "生成的音频将显示在此处",
+ "toasts": {
+ "noText": "请输入文本",
+ "noModel": "请选择一个模型",
+ "generateFailed": "生成失败"
+ }
+ },
+ "sound": {
+ "title": "声音生成",
+ "labels": {
+ "model": "模型",
+ "prompt": "提示词",
+ "promptPlaceholder": "描述您想要生成的声音...",
+ "duration": "时长(秒)",
+ "language": "语言",
+ "vocalLanguage": "声乐语言",
+ "lyrics": "歌词(可选)",
+ "lyricsPlaceholder": "用于声乐生成的歌词",
+ "advanced": "高级设置",
+ "seed": "随机种子",
+ "seedPlaceholder": "随机"
+ },
+ "actions": {
+ "generate": "生成",
+ "generating": "生成中..."
+ },
+ "empty": "生成的音频将显示在此处",
+ "toasts": {
+ "noPrompt": "请输入提示词",
+ "noModel": "请选择一个模型",
+ "generateFailed": "生成失败"
+ }
+ },
+ "talk": {
+ "title": "通话",
+ "subtitle": "实时语音对话",
+ "actions": {
+ "start": "开始会话",
+ "stop": "结束会话",
+ "connecting": "连接中...",
+ "muted": "已静音",
+ "mute": "静音",
+ "unmute": "取消静音"
+ },
+ "labels": {
+ "model": "模型",
+ "voice": "声音",
+ "voicePlaceholder": "alloy",
+ "language": "语言",
+ "languagePlaceholder": "zh",
+ "instructions": "指令",
+ "instructionsPlaceholder": "设置助手的角色..."
+ },
+ "status": {
+ "idle": "空闲",
+ "connecting": "连接中...",
+ "listening": "正在聆听...",
+ "speaking": "正在说话...",
+ "ended": "会话已结束"
+ },
+ "toasts": {
+ "noModel": "请先选择一个模型",
+ "connectFailed": "连接失败:{{message}}"
+ }
+ },
+ "history": {
+ "title": "历史",
+ "empty": "暂无历史",
+ "deleteEntry": "删除条目",
+ "clear": "清除历史",
+ "clearTitle": "清除全部历史",
+ "clearMessage": "删除所有历史条目?此操作无法撤销。",
+ "clearConfirm": "清除",
+ "cleared": "历史已清除"
+ },
+ "request": {
+ "heading": "Request",
+ "copyCurl": "Copy as curl",
+ "copied": "Copied"
+ }
+}
diff --git a/addons/studio/ui/src/index.jsx b/addons/studio/ui/src/index.jsx
new file mode 100644
index 000000000..51e01e319
--- /dev/null
+++ b/addons/studio/ui/src/index.jsx
@@ -0,0 +1,20 @@
+// The entry of the Studio add-on. The pages are the ones that used to be core
+// routes; they keep the host modules they were written against (React, the
+// router, i18next, the shared components), and the stand-ins in
+// addons/internal/host-compat answer those through the SDK. The page code is
+// loaded after the SDK is set, because a stand-in reads the SDK when it starts.
+import { requireHost, setSdk } from '../../../internal/host-compat/src/runtime.js'
+
+// What the pages need from the host beyond SDK 2's first set.
+const NEEDS = [
+ 'ui.LoadingSpinner', 'ui.Modal', 'ui.Lightbox', 'ui.ModelSelector', 'ui.PageHeader', 'ui.ConfirmDialog',
+ 'ui.MediaInput', 'ui.WaveformPlayer', 'hooks.useModels', 'hooks.useResources', 'hooks.useVoiceProfiles',
+ 'hooks.useOperations', 'hooks.useMediaCapture', 'hooks.useObjectUrl', 'navigation.openApp',
+]
+
+export async function createAddon(sdk) {
+ requireHost(sdk, NEEDS)
+ setSdk(sdk)
+ const { createStudio } = await import('./addon.jsx')
+ return createStudio(sdk)
+}
diff --git a/core/http/react-ui/src/pages/AudioTransform.jsx b/addons/studio/ui/src/pages/AudioTransform.jsx
similarity index 99%
rename from core/http/react-ui/src/pages/AudioTransform.jsx
rename to addons/studio/ui/src/pages/AudioTransform.jsx
index bd1f18101..1e37a63cd 100644
--- a/core/http/react-ui/src/pages/AudioTransform.jsx
+++ b/addons/studio/ui/src/pages/AudioTransform.jsx
@@ -7,12 +7,11 @@ import { CAP_AUDIO_TRANSFORM } from '../utils/capabilities'
import WaveformPlayer from '../components/audio/WaveformPlayer'
// eslint-disable-next-line no-unused-vars
import Spectrogram from '../components/audio/Spectrogram'
-import { audioTransformApi } from '../utils/api'
+import { audioTransformApi, fetchUrl } from '../utils/api'
import { useMediaCapture } from '../hooks/useMediaCapture'
import useObjectUrl from '../hooks/useObjectUrl'
import { useMediaHistory } from '../hooks/useMediaHistory'
import { useStudioHandoff, useHandoffSource, blobToFile } from '../hooks/useStudioHandoff'
-import { apiUrl } from '../utils/basePath'
// eslint-disable-next-line no-unused-vars
import HandoffNote from '../components/studio/HandoffNote'
import {
@@ -159,7 +158,7 @@ export default function AudioTransform() {
selectEntry(null)
ws.begin(next)
const fetchFile = async (url, fallback) => {
- const res = await fetch(url.startsWith('http') || url.startsWith('blob:') ? url : apiUrl(url))
+ const res = await fetchUrl(url)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return blobToFile(await res.blob(), url.split('?')[0].split('/').pop() || fallback)
}
diff --git a/core/http/react-ui/src/pages/Diarization.jsx b/addons/studio/ui/src/pages/Diarization.jsx
similarity index 100%
rename from core/http/react-ui/src/pages/Diarization.jsx
rename to addons/studio/ui/src/pages/Diarization.jsx
diff --git a/core/http/react-ui/src/pages/ImageGen.jsx b/addons/studio/ui/src/pages/ImageGen.jsx
similarity index 100%
rename from core/http/react-ui/src/pages/ImageGen.jsx
rename to addons/studio/ui/src/pages/ImageGen.jsx
diff --git a/core/http/react-ui/src/pages/Sound.jsx b/addons/studio/ui/src/pages/Sound.jsx
similarity index 100%
rename from core/http/react-ui/src/pages/Sound.jsx
rename to addons/studio/ui/src/pages/Sound.jsx
diff --git a/core/http/react-ui/src/pages/Studio.jsx b/addons/studio/ui/src/pages/Studio.jsx
similarity index 100%
rename from core/http/react-ui/src/pages/Studio.jsx
rename to addons/studio/ui/src/pages/Studio.jsx
diff --git a/core/http/react-ui/src/pages/StudioOverview.jsx b/addons/studio/ui/src/pages/StudioOverview.jsx
similarity index 97%
rename from core/http/react-ui/src/pages/StudioOverview.jsx
rename to addons/studio/ui/src/pages/StudioOverview.jsx
index bccb094b6..23f95479e 100644
--- a/core/http/react-ui/src/pages/StudioOverview.jsx
+++ b/addons/studio/ui/src/pages/StudioOverview.jsx
@@ -80,8 +80,9 @@ export default function StudioOverview({ modalities, upscalers, modelsLoading, m
return (
+ {/* The add-on shell already titles the page "Studio", so this header
+ carries the supporting line and the key hint only. */}
/ {t('studio.composer.startTyping')}}
/>
diff --git a/core/http/react-ui/src/pages/TTS.jsx b/addons/studio/ui/src/pages/TTS.jsx
similarity index 100%
rename from core/http/react-ui/src/pages/TTS.jsx
rename to addons/studio/ui/src/pages/TTS.jsx
diff --git a/core/http/react-ui/src/pages/ThreeDGen.jsx b/addons/studio/ui/src/pages/ThreeDGen.jsx
similarity index 99%
rename from core/http/react-ui/src/pages/ThreeDGen.jsx
rename to addons/studio/ui/src/pages/ThreeDGen.jsx
index 93515a001..7c1336588 100644
--- a/core/http/react-ui/src/pages/ThreeDGen.jsx
+++ b/addons/studio/ui/src/pages/ThreeDGen.jsx
@@ -15,8 +15,7 @@ import LoadingSpinner from '../components/LoadingSpinner'
import GlbViewer from '../components/GlbViewer'
// eslint-disable-next-line no-unused-vars
import MediaInput from '../components/biometrics/MediaInput'
-import { threeDApi } from '../utils/api'
-import { apiUrl } from '../utils/basePath'
+import { threeDApi, fetchUrl } from '../utils/api'
import { use3DHistory } from '../hooks/use3DHistory'
import { useStudioHandoff, useHandoffSource, blobToImageInput } from '../hooks/useStudioHandoff'
// eslint-disable-next-line no-unused-vars
@@ -177,7 +176,7 @@ export default function ThreeDGen() {
addToast(t('threed.toasts.noResults'), 'warning')
return
}
- const glbResp = await fetch(apiUrl(url))
+ const glbResp = await fetchUrl(url)
if (!glbResp.ok) throw new Error(`fetching the generated GLB failed: HTTP ${glbResp.status}`)
const glb = await glbResp.blob()
const name = url.split('/').pop()
diff --git a/core/http/react-ui/src/pages/VideoGen.jsx b/addons/studio/ui/src/pages/VideoGen.jsx
similarity index 100%
rename from core/http/react-ui/src/pages/VideoGen.jsx
rename to addons/studio/ui/src/pages/VideoGen.jsx
diff --git a/addons/studio/ui/src/style.css b/addons/studio/ui/src/style.css
new file mode 100644
index 000000000..7dac35237
--- /dev/null
+++ b/addons/studio/ui/src/style.css
@@ -0,0 +1 @@
+/* Styles of the Studio add-on. The page style sheets (components/studio/*.css) are added by the build. */
diff --git a/core/http/react-ui/src/utils/animationGlb.js b/addons/studio/ui/src/utils/animationGlb.js
similarity index 100%
rename from core/http/react-ui/src/utils/animationGlb.js
rename to addons/studio/ui/src/utils/animationGlb.js
diff --git a/core/http/react-ui/src/utils/diarization.js b/addons/studio/ui/src/utils/diarization.js
similarity index 100%
rename from core/http/react-ui/src/utils/diarization.js
rename to addons/studio/ui/src/utils/diarization.js
diff --git a/core/http/react-ui/src/utils/fft.js b/addons/studio/ui/src/utils/fft.js
similarity index 100%
rename from core/http/react-ui/src/utils/fft.js
rename to addons/studio/ui/src/utils/fft.js
diff --git a/core/http/react-ui/src/utils/glb.js b/addons/studio/ui/src/utils/glb.js
similarity index 100%
rename from core/http/react-ui/src/utils/glb.js
rename to addons/studio/ui/src/utils/glb.js
diff --git a/core/http/react-ui/src/utils/mediaHistoryStore.js b/addons/studio/ui/src/utils/mediaHistoryStore.js
similarity index 100%
rename from core/http/react-ui/src/utils/mediaHistoryStore.js
rename to addons/studio/ui/src/utils/mediaHistoryStore.js
diff --git a/core/http/react-ui/src/utils/studioWork.js b/addons/studio/ui/src/utils/studioWork.js
similarity index 100%
rename from core/http/react-ui/src/utils/studioWork.js
rename to addons/studio/ui/src/utils/studioWork.js
diff --git a/core/http/react-ui/src/utils/upscale.js b/addons/studio/ui/src/utils/upscale.js
similarity index 100%
rename from core/http/react-ui/src/utils/upscale.js
rename to addons/studio/ui/src/utils/upscale.js
diff --git a/core/http/react-ui/src/utils/voiceEnrollments.js b/addons/studio/ui/src/utils/voiceEnrollments.js
similarity index 100%
rename from core/http/react-ui/src/utils/voiceEnrollments.js
rename to addons/studio/ui/src/utils/voiceEnrollments.js
diff --git a/core/http/react-ui/src/utils/diarization.test.js b/addons/studio/ui/test/diarization.test.mjs
similarity index 98%
rename from core/http/react-ui/src/utils/diarization.test.js
rename to addons/studio/ui/test/diarization.test.mjs
index 1b6a3f592..f91f535ee 100644
--- a/core/http/react-ui/src/utils/diarization.test.js
+++ b/addons/studio/ui/test/diarization.test.mjs
@@ -1,7 +1,7 @@
import assert from 'node:assert/strict'
import test from 'node:test'
-import { axisTicks, clock, hasText, runLength, speakerRows, toRttm, toSrt } from './diarization.js'
+import { axisTicks, clock, hasText, runLength, speakerRows, toRttm, toSrt } from '../src/utils/diarization.js'
const result = {
speakers: [{ id: 'SPEAKER_00', label: '0' }, { id: 'SPEAKER_01', label: '1', name: 'Ada' }],
diff --git a/addons/studio/ui/test/parity.test.mjs b/addons/studio/ui/test/parity.test.mjs
new file mode 100644
index 000000000..995ed1c96
--- /dev/null
+++ b/addons/studio/ui/test/parity.test.mjs
@@ -0,0 +1,53 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { readFileSync } from 'node:fs'
+import { dirname, join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { hasKey, hostUi, iconLiterals, iconNames, literalKeys, sourceFiles, spriteNames, text, unresolvedImports } from '../../../internal/host-compat/test/parity.mjs'
+
+const here = dirname(fileURLToPath(import.meta.url))
+const src = join(here, '../src')
+const files = sourceFiles(src)
+const LANGUAGES = ['en', 'it', 'es', 'de', 'zh-CN', 'id', 'ko', 'pt-BR']
+
+test('the media locale files are byte copies of the host ones', () => {
+ for (const lng of LANGUAGES) {
+ assert.equal(
+ readFileSync(join(src, `i18n/media.${lng}.json`), 'utf8'),
+ readFileSync(join(hostUi, `public/locales/${lng}/media.json`), 'utf8'),
+ lng,
+ )
+ }
+})
+
+test('every literal key the pages read is in the English media locale', () => {
+ const en = JSON.parse(readFileSync(join(src, 'i18n/media.en.json'), 'utf8'))
+ const keys = literalKeys(text(files))
+ assert.ok(keys.size > 150, `found only ${keys.size} literal keys`)
+ const missing = [...keys].filter((k) => !hasKey(en, k))
+ assert.deepEqual(missing, [])
+})
+
+test('every icon the pages name is one the SDK allows', async () => {
+ const allowed = await iconNames()
+ const sprite = spriteNames()
+ const used = iconLiterals(text(files))
+ assert.ok(used.size > 20, `found only ${used.size} icons`)
+ // Each is also a real sprite icon, so none draws the neutral glyph.
+ assert.deepEqual([...used].filter((n) => !sprite.has(n) || !allowed.has(n)), [])
+})
+
+test('the source imports only its own files, the compat stand-ins and the modules they answer', () => {
+ assert.deepEqual(unresolvedImports(src), [])
+})
+
+test('the source holds no direct network call: the pages go through the stand-in API client', () => {
+ const direct = []
+ for (const f of files) {
+ if (f.endsWith('test.mjs')) continue
+ for (const line of readFileSync(f, 'utf8').split('\n')) {
+ if (/\bfetch\(/.test(line) && !/^\s*\/\//.test(line)) direct.push(`${f.slice(src.length)}: ${line.trim()}`)
+ }
+ }
+ assert.deepEqual(direct, [])
+})
diff --git a/addons/studio/ui/test/render.test.mjs b/addons/studio/ui/test/render.test.mjs
new file mode 100644
index 000000000..afc46cecd
--- /dev/null
+++ b/addons/studio/ui/test/render.test.mjs
@@ -0,0 +1,61 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { readFileSync } from 'node:fs'
+import { dirname, join } from 'node:path'
+import { fileURLToPath, pathToFileURL } from 'node:url'
+import { build } from '../build.mjs'
+import { React, renderToString, makeSdk } from '../../../internal/host-compat/test/host.mjs'
+
+const here = dirname(fileURLToPath(import.meta.url))
+
+async function load(overrides) {
+ const dir = await build()
+ const mod = await import(pathToFileURL(join(dir, 'ui/studio.js')).href + `?t=${Date.now()}`)
+ const host = await makeSdk({ addonId: 'studio', pages: ['studio'], overrides })
+ return { mod, ...host }
+}
+
+test('render: createAddon returns a page component and dispose', async () => {
+ const { mod, sdk, controller } = await load()
+ const addon = await mod.createAddon(sdk)
+ assert.equal(typeof addon.Component, 'function')
+ assert.equal(typeof addon.dispose, 'function')
+ addon.dispose()
+ controller.abort('done')
+})
+
+test('render: an unknown page draws nothing', async () => {
+ const { mod, sdk } = await load()
+ const addon = await mod.createAddon(sdk)
+ assert.equal(renderToString(React.createElement(addon.Component, { page: 'nope', path: '', search: '' })), '')
+})
+
+test('render: the overview draws the tab strip with its words from the media locale', async () => {
+ const { mod, sdk } = await load()
+ const addon = await mod.createAddon(sdk)
+ const html = renderToString(React.createElement(addon.Component, { page: 'studio', path: '', search: '' }))
+ assert.match(html, /class="studio-tabs"/)
+ assert.match(html, /data-tab="images"/)
+ assert.match(html, /data-tab="overview"/)
+ assert.doesNotMatch(html, /studio\.tabs\./, 'a key was shown instead of its words')
+})
+
+test('render: every tab draws without throwing', async () => {
+ const { mod, sdk } = await load()
+ const addon = await mod.createAddon(sdk)
+ for (const tab of ['images', 'video', 'threed', 'tts', 'sound', 'transform', 'diarization']) {
+ const html = renderToString(React.createElement(addon.Component, { page: 'studio', path: tab, search: '' }))
+ assert.match(html, new RegExp(`studio-tab-active[^>]*|data-tab="${tab}"`), tab)
+ assert.doesNotMatch(html, /[a-z]+\.[a-z]+\.[a-zA-Z.]+<\/(span|h1|p|button|label)>/, `${tab}: a key was shown instead of its words`)
+ }
+})
+
+test('render: the manifest declares one page, the main page and SDK 2', () => {
+ const m = JSON.parse(readFileSync(join(here, '../../manifest.json'), 'utf8'))
+ assert.equal(m.id, 'studio')
+ assert.deepEqual(m.capabilities.ui.pages, ['studio'])
+ assert.deepEqual(m.capabilities.ui.navigation, ['studio'])
+ assert.equal(m.capabilities.ui.mainPage, 'studio')
+ assert.deepEqual(m.capabilities.ui.sdk, { min: 2, max: 2 })
+ assert.deepEqual(m.capabilities.tools ?? [], [])
+})
diff --git a/addons/studio/ui/test/run.mjs b/addons/studio/ui/test/run.mjs
new file mode 100644
index 000000000..1f73838d5
--- /dev/null
+++ b/addons/studio/ui/test/run.mjs
@@ -0,0 +1,23 @@
+// Runs the node unit tests of the Studio UI package and prints one JSON line
+// per test, the same shape as the host's runner (core/http/react-ui/tests/unit):
+// {"case":"unit: ","status":"passed"|"failed"|"skipped"}
+import { run } from 'node:test'
+import { readdirSync } from 'node:fs'
+import { dirname, join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+const here = dirname(fileURLToPath(import.meta.url))
+const names = readdirSync(here).filter(f => f.endsWith('.test.mjs')).sort()
+let failed = false
+const stream = run({ files: names.map(f => join(here, f)), concurrency: 1 })
+stream.on('test:pass', ({ name, skip, todo, nesting }) => {
+ if (nesting > 0) return
+ console.log(JSON.stringify({ case: `unit: ${name}`, status: skip || todo ? 'skipped' : 'passed' }))
+})
+stream.on('test:fail', ({ name, nesting, details }) => {
+ if (nesting > 0) return
+ failed = true
+ console.log(JSON.stringify({ case: `unit: ${name}`, status: 'failed', error: String(details && details.error && details.error.message || 'failed').slice(0, 400) }))
+})
+stream.on('end', () => { process.exit(failed ? 1 : 0) })
+stream.resume()
diff --git a/core/http/react-ui/src/utils/studioWork.test.js b/addons/studio/ui/test/studioWork.test.mjs
similarity index 99%
rename from core/http/react-ui/src/utils/studioWork.test.js
rename to addons/studio/ui/test/studioWork.test.mjs
index 72f30056e..518dfde48 100644
--- a/core/http/react-ui/src/utils/studioWork.test.js
+++ b/addons/studio/ui/test/studioWork.test.mjs
@@ -5,10 +5,10 @@ import {
MAX_FAVOURITES, TYPE_ORDER, canTake, collectWork, columnCount, countByType, dealColumns, filterWork,
groupWork, handoffPath, layoutLineage, neighbour, projectOf, pruneIds, readHandoff, sourceUrlFor,
suggestNext, suggestType, toggleId, waveBars,
-} from './studioWork.js'
+} from '../src/utils/studioWork.js'
import {
FAVOURITES_KEY, MEDIA_STORAGE_KEYS, clearAllMediaHistory,
-} from './mediaHistoryStore.js'
+} from '../src/utils/mediaHistoryStore.js'
const entry = (id, createdAt, extra = {}) => ({
id, createdAt, prompt: `prompt ${id}`, model: 'm', params: {}, results: [{ url: `/generated/${id}.png` }], ...extra,
diff --git a/core/http/react-ui/src/utils/upscale.test.js b/addons/studio/ui/test/upscale.test.mjs
similarity index 87%
rename from core/http/react-ui/src/utils/upscale.test.js
rename to addons/studio/ui/test/upscale.test.mjs
index 7f3cbe627..fc06fd2c1 100644
--- a/core/http/react-ui/src/utils/upscale.test.js
+++ b/addons/studio/ui/test/upscale.test.mjs
@@ -1,8 +1,11 @@
// SPDX-License-Identifier: MIT
import test from 'node:test'
import assert from 'node:assert/strict'
-import { upscaleModels, sourceImageFile } from './upscale.js'
-import { toWorkItem, EDGE_KINDS } from './studioWork.js'
+import '../../../internal/host-compat/test/register.mjs'
+
+// upscale.js imports a module that lives in the compat directory.
+const { upscaleModels, sourceImageFile } = await import('../src/utils/upscale.js')
+import { toWorkItem, EDGE_KINDS } from '../src/utils/studioWork.js'
test('only enabled upscalers with positive finite scale are offered', () => {
assert.deepEqual(upscaleModels([
diff --git a/addons/talk/cmd/talk/main.go b/addons/talk/cmd/talk/main.go
new file mode 100644
index 000000000..f066e3570
--- /dev/null
+++ b/addons/talk/cmd/talk/main.go
@@ -0,0 +1,15 @@
+// SPDX-License-Identifier: MIT
+
+// Command talk is the executable of the Talk add-on. It serves no tools:
+// the add-on works in the browser, through the SDK and the core API.
+package main
+
+import (
+ "os"
+
+ "github.com/mudler/LocalAI/addons/internal/idle"
+)
+
+func main() {
+ os.Exit(idle.Run(os.Args))
+}
diff --git a/addons/talk/manifest.json b/addons/talk/manifest.json
new file mode 100644
index 000000000..bc47216aa
--- /dev/null
+++ b/addons/talk/manifest.json
@@ -0,0 +1,25 @@
+{
+ "schemaVersion": 1,
+ "id": "talk",
+ "version": "0.1.0",
+ "executable": "talk",
+ "os": "linux",
+ "arch": "amd64",
+ "host": { "min": 1, "max": 1 },
+ "api": { "min": 1, "max": 1 },
+ "protocol": { "min": 1, "max": 1 },
+ "dataFormat": { "min": 1, "max": 1 },
+ "capabilities": {
+ "ui": {
+ "entry": "ui/talk.js",
+ "css": ["ui/talk.css"],
+ "sdk": { "min": 2, "max": 2 },
+ "pages": ["talk"],
+ "navigation": ["talk"],
+ "mainPage": "talk",
+ "icon": "ui/icon.png",
+ "title": "Talk",
+ "description": "Have a spoken conversation with a model: your voice goes in, the answer is spoken back."
+ }
+ }
+}
diff --git a/addons/talk/package/platforms.yaml b/addons/talk/package/platforms.yaml
new file mode 100644
index 000000000..1cdb805a6
--- /dev/null
+++ b/addons/talk/package/platforms.yaml
@@ -0,0 +1,38 @@
+# Release platform matrix of the talk add-on package.
+#
+# A platform ships only while "enabled" is true. Enable a platform in the same
+# change that shows its native check passing on a runner of that platform;
+# until then it stays listed and disabled. The release workflow fails closed
+# when no platform is enabled, and it never builds a platform that is not
+# enabled here.
+#
+# runner: the GitHub runner label that builds and checks the platform
+# natively. Required for an enabled platform.
+# native_check: "executable-start": the packaged executable is extracted on
+# the native runner and started. Required for an enabled platform.
+# runtime: files besides the executable that the platform needs at run
+# time, relative to the package root. They are taken from the
+# runtime directory of the build and must exist.
+schema: 1
+addon: talk
+executable: talk
+manifest: addons/talk/manifest.json
+# The directory whose content is laid at the package root (ui/talk.js ...).
+ui: addons/talk/ui/dist
+platforms:
+ - platform: linux/amd64
+ enabled: true
+ runner: ubuntu-24.04
+ native_check: executable-start
+ - platform: linux/arm64
+ enabled: true
+ runner: ubuntu-24.04-arm
+ native_check: executable-start
+ - platform: darwin/arm64
+ enabled: true
+ runner: macos-14
+ native_check: executable-start
+ - platform: darwin/amd64
+ enabled: true
+ runner: macos-15-intel
+ native_check: executable-start
diff --git a/addons/talk/ui/.gitignore b/addons/talk/ui/.gitignore
new file mode 100644
index 000000000..1225aa2fa
--- /dev/null
+++ b/addons/talk/ui/.gitignore
@@ -0,0 +1,2 @@
+# Written by build.mjs; the package build makes it.
+/dist/
diff --git a/addons/talk/ui/build.mjs b/addons/talk/ui/build.mjs
new file mode 100644
index 000000000..20bd15e17
--- /dev/null
+++ b/addons/talk/ui/build.mjs
@@ -0,0 +1,15 @@
+import { fileURLToPath } from 'node:url'
+import { dirname, resolve } from 'node:path'
+import { buildAddonUI } from '../../internal/uibuild/uibuild.mjs'
+
+const here = dirname(fileURLToPath(import.meta.url))
+
+export async function build() {
+ // The page code keeps the host module names it was written with; the compat
+ // directory answers them through the SDK (addons/internal/host-compat).
+ return buildAddonUI({ root: here, name: 'talk', rgb: [28, 112, 104], compat: resolve(here, '../../internal/host-compat/src') })
+}
+
+if (process.argv[1] === fileURLToPath(import.meta.url)) {
+ build().then((dir) => console.log(`built ${dir}`), (e) => { console.error(e); process.exit(1) })
+}
diff --git a/addons/talk/ui/package.json b/addons/talk/ui/package.json
new file mode 100644
index 000000000..3c1cd9745
--- /dev/null
+++ b/addons/talk/ui/package.json
@@ -0,0 +1,8 @@
+{
+ "name": "localai-talk-ui",
+ "private": true,
+ "version": "0.1.0",
+ "description": "Native UI of the Talk add-on: a standalone ES module that the host loads after its own build.",
+ "type": "module",
+ "scripts": { "build": "node build.mjs", "test": "node test/run.mjs" }
+}
diff --git a/addons/talk/ui/src/addon.jsx b/addons/talk/ui/src/addon.jsx
new file mode 100644
index 000000000..26f5992f4
--- /dev/null
+++ b/addons/talk/ui/src/addon.jsx
@@ -0,0 +1,19 @@
+import { RouteProvider } from 'react-router-dom'
+import { registerLocales } from '../../../internal/host-compat/src/i18n.js'
+import Talk from './pages/Talk'
+import en from './i18n/talk.en.json'
+
+// The words of the page are the host's own `talk` locale file.
+registerLocales({ talk: { en } })
+
+export function createTalk() {
+ function Component({ page, path, search }) {
+ if (page !== 'talk') return null
+ return (
+
+
+
+ )
+ }
+ return { Component, components: {}, chatActions: [], dispose() {} }
+}
diff --git a/core/http/react-ui/src/components/ClientMCPDropdown.jsx b/addons/talk/ui/src/components/ClientMCPDropdown.jsx
similarity index 100%
rename from core/http/react-ui/src/components/ClientMCPDropdown.jsx
rename to addons/talk/ui/src/components/ClientMCPDropdown.jsx
diff --git a/core/http/react-ui/src/components/VoiceVisualizer.jsx b/addons/talk/ui/src/components/VoiceVisualizer.jsx
similarity index 100%
rename from core/http/react-ui/src/components/VoiceVisualizer.jsx
rename to addons/talk/ui/src/components/VoiceVisualizer.jsx
diff --git a/core/http/react-ui/src/components/talk/SessionSheet.jsx b/addons/talk/ui/src/components/talk/SessionSheet.jsx
similarity index 100%
rename from core/http/react-ui/src/components/talk/SessionSheet.jsx
rename to addons/talk/ui/src/components/talk/SessionSheet.jsx
diff --git a/core/http/react-ui/public/locales/en/talk.json b/addons/talk/ui/src/i18n/talk.en.json
similarity index 100%
rename from core/http/react-ui/public/locales/en/talk.json
rename to addons/talk/ui/src/i18n/talk.en.json
diff --git a/addons/talk/ui/src/index.jsx b/addons/talk/ui/src/index.jsx
new file mode 100644
index 000000000..11235bc5b
--- /dev/null
+++ b/addons/talk/ui/src/index.jsx
@@ -0,0 +1,17 @@
+// The entry of the Talk add-on. The page is the one that used to be a core
+// route; it keeps the host modules it was written against and the stand-ins in
+// addons/internal/host-compat answer those through the SDK (see the Studio
+// add-on's entry).
+import { requireHost, setSdk } from '../../../internal/host-compat/src/runtime.js'
+
+const NEEDS = [
+ 'ui.HomeModelPicker', 'ui.PageHeader', 'hooks.useMCPClient', 'navigation.openApp', 'portal',
+]
+
+export async function createAddon(sdk) {
+ requireHost(sdk, NEEDS.filter((n) => n.includes('.')))
+ if (typeof sdk.portal !== 'function') throw new Error('This add-on needs a newer LocalAI: the host has no portal')
+ setSdk(sdk)
+ const { createTalk } = await import('./addon.jsx')
+ return createTalk(sdk)
+}
diff --git a/core/http/react-ui/src/pages/Talk.jsx b/addons/talk/ui/src/pages/Talk.jsx
similarity index 99%
rename from core/http/react-ui/src/pages/Talk.jsx
rename to addons/talk/ui/src/pages/Talk.jsx
index eacfcde13..cfed569f1 100644
--- a/core/http/react-ui/src/pages/Talk.jsx
+++ b/addons/talk/ui/src/pages/Talk.jsx
@@ -716,7 +716,7 @@ export default function Talk() {
return (
- {t('title')}
+ {/* The add-on shell already titles the page "Talk". */}
{t('subtitle')}
{isConnected && (
diff --git a/core/http/react-ui/src/pages/talk.css b/addons/talk/ui/src/pages/talk.css
similarity index 99%
rename from core/http/react-ui/src/pages/talk.css
rename to addons/talk/ui/src/pages/talk.css
index b3735b76e..03aec8fd5 100644
--- a/core/http/react-ui/src/pages/talk.css
+++ b/addons/talk/ui/src/pages/talk.css
@@ -10,7 +10,6 @@
.talk-page a:focus-visible,
.talk-page input:focus-visible { outline: 2px solid var(--dk-accent-text); outline-offset: 2px; }
.talk-hd { display: flex; align-items: center; gap: 12px; min-height: 56px; padding: 8px 0; }
-.talk-hd h1 { margin: 0; font-family: var(--font-sans); font-size: 20px; font-weight: 650; letter-spacing: -.01em; }
.talk-hd__sub { color: var(--dk-muted); font-family: var(--font-mono); font-size: 12px; }
.talk-hd__acts { display: flex; align-items: center; gap: 2px; margin-left: auto; }
.talk-icobtn {
diff --git a/addons/talk/ui/src/style.css b/addons/talk/ui/src/style.css
new file mode 100644
index 000000000..9d3339f14
--- /dev/null
+++ b/addons/talk/ui/src/style.css
@@ -0,0 +1 @@
+/* Styles of the Talk add-on. The page style sheet (pages/talk.css) is added by the build. */
diff --git a/addons/talk/ui/test/parity.test.mjs b/addons/talk/ui/test/parity.test.mjs
new file mode 100644
index 000000000..7a8d77059
--- /dev/null
+++ b/addons/talk/ui/test/parity.test.mjs
@@ -0,0 +1,39 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { readFileSync } from 'node:fs'
+import { dirname, join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { hasKey, iconLiterals, iconNames, literalKeys, sourceFiles, spriteNames, text, unresolvedImports } from '../../../internal/host-compat/test/parity.mjs'
+
+const here = dirname(fileURLToPath(import.meta.url))
+const src = join(here, '../src')
+const files = sourceFiles(src)
+
+test('every literal key the page reads is in the English talk locale', () => {
+ const en = JSON.parse(readFileSync(join(src, 'i18n/talk.en.json'), 'utf8'))
+ const keys = literalKeys(text(files))
+ assert.ok(keys.size > 40, `found only ${keys.size} literal keys`)
+ assert.deepEqual([...keys].filter((k) => !hasKey(en, k)), [])
+})
+
+test('every icon the page names is one the SDK allows', async () => {
+ const allowed = await iconNames()
+ const sprite = spriteNames()
+ const used = iconLiterals(text(files))
+ assert.ok(used.size > 5, `found only ${used.size} icons`)
+ assert.deepEqual([...used].filter((n) => !sprite.has(n) || !allowed.has(n)), [])
+})
+
+test('the source imports only its own files, the compat stand-ins and the modules they answer', () => {
+ assert.deepEqual(unresolvedImports(src), [])
+})
+
+test('the source holds no direct network call', () => {
+ const direct = []
+ for (const f of files) {
+ for (const line of readFileSync(f, 'utf8').split('\n')) {
+ if (/\bfetch\(/.test(line) && !/^\s*\/\//.test(line)) direct.push(`${f.slice(src.length)}: ${line.trim()}`)
+ }
+ }
+ assert.deepEqual(direct, [])
+})
diff --git a/addons/talk/ui/test/render.test.mjs b/addons/talk/ui/test/render.test.mjs
new file mode 100644
index 000000000..e434e14d0
--- /dev/null
+++ b/addons/talk/ui/test/render.test.mjs
@@ -0,0 +1,55 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { readFileSync } from 'node:fs'
+import { dirname, join } from 'node:path'
+import { fileURLToPath, pathToFileURL } from 'node:url'
+import { build } from '../build.mjs'
+import { React, renderToString, makeSdk } from '../../../internal/host-compat/test/host.mjs'
+
+const here = dirname(fileURLToPath(import.meta.url))
+
+async function load(overrides) {
+ const dir = await build()
+ const mod = await import(pathToFileURL(join(dir, 'ui/talk.js')).href + `?t=${Date.now()}`)
+ const host = await makeSdk({ addonId: 'talk', pages: ['talk'], overrides })
+ return { mod, ...host }
+}
+
+test('render: createAddon returns a page component and dispose', async () => {
+ const { mod, sdk, controller } = await load()
+ const addon = await mod.createAddon(sdk)
+ assert.equal(typeof addon.Component, 'function')
+ assert.equal(typeof addon.dispose, 'function')
+ addon.dispose()
+ controller.abort('done')
+})
+
+test('render: an unknown page draws nothing', async () => {
+ const { mod, sdk } = await load()
+ const addon = await mod.createAddon(sdk)
+ assert.equal(renderToString(React.createElement(addon.Component, { page: 'nope', path: '', search: '' })), '')
+})
+
+test('render: the page draws its title from the talk locale and no key', async () => {
+ const { mod, sdk } = await load()
+ const addon = await mod.createAddon(sdk)
+ const html = renderToString(React.createElement(addon.Component, { page: 'talk', path: '', search: '' }))
+ assert.ok(html.length > 200, 'the page drew something')
+ assert.doesNotMatch(html, />(stage|picker|view|controls|transcript|settings|toasts)\.[A-Za-z.]+, 'a key was shown instead of its words')
+})
+
+test('createAddon fails readably on a host without the pieces the page needs', async () => {
+ const { mod, sdk } = await load()
+ const old = Object.freeze({ ...sdk, ui: Object.freeze({}) })
+ await assert.rejects(mod.createAddon(old), /needs a newer LocalAI/)
+})
+
+test('render: the manifest declares one page, the main page and SDK 2', () => {
+ const m = JSON.parse(readFileSync(join(here, '../../manifest.json'), 'utf8'))
+ assert.equal(m.id, 'talk')
+ assert.deepEqual(m.capabilities.ui.pages, ['talk'])
+ assert.deepEqual(m.capabilities.ui.navigation, ['talk'])
+ assert.equal(m.capabilities.ui.mainPage, 'talk')
+ assert.deepEqual(m.capabilities.ui.sdk, { min: 2, max: 2 })
+ assert.deepEqual(m.capabilities.tools ?? [], [])
+})
diff --git a/addons/talk/ui/test/run.mjs b/addons/talk/ui/test/run.mjs
new file mode 100644
index 000000000..f92501cb4
--- /dev/null
+++ b/addons/talk/ui/test/run.mjs
@@ -0,0 +1,23 @@
+// Runs the node unit tests of the Talk UI package and prints one JSON line
+// per test, the same shape as the host's runner (core/http/react-ui/tests/unit):
+// {"case":"unit:
","status":"passed"|"failed"|"skipped"}
+import { run } from 'node:test'
+import { readdirSync } from 'node:fs'
+import { dirname, join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+const here = dirname(fileURLToPath(import.meta.url))
+const names = readdirSync(here).filter(f => f.endsWith('.test.mjs')).sort()
+let failed = false
+const stream = run({ files: names.map(f => join(here, f)), concurrency: 1 })
+stream.on('test:pass', ({ name, skip, todo, nesting }) => {
+ if (nesting > 0) return
+ console.log(JSON.stringify({ case: `unit: ${name}`, status: skip || todo ? 'skipped' : 'passed' }))
+})
+stream.on('test:fail', ({ name, nesting, details }) => {
+ if (nesting > 0) return
+ failed = true
+ console.log(JSON.stringify({ case: `unit: ${name}`, status: 'failed', error: String(details && details.error && details.error.message || 'failed').slice(0, 400) }))
+})
+stream.on('end', () => { process.exit(failed ? 1 : 0) })
+stream.resume()
diff --git a/core/application/addon_pin_sweep_test.go b/core/application/addon_pin_sweep_test.go
index 7934deb39..4e93d3f77 100644
--- a/core/application/addon_pin_sweep_test.go
+++ b/core/application/addon_pin_sweep_test.go
@@ -8,6 +8,7 @@ import (
"testing"
"time"
+ "github.com/mudler/LocalAI/core/services/addons/builtin"
"github.com/mudler/LocalAI/core/services/addons/pins"
)
@@ -32,15 +33,20 @@ func pinnedIDs(t *testing.T, s pins.Store, user string) []string {
}
func TestSweepNeverDeletesTheBuiltinPins(t *testing.T) {
+ // No page is built in today, so the test makes up two.
+ defer builtin.SetCatalogForTest([]builtin.Entry{
+ {ID: "guide", Kind: builtin.KindPage, TitleKey: "items.chat", Icon: "chat", MainPath: "/app/chat"},
+ {ID: "help", Kind: builtin.KindPage, TitleKey: "items.models", Icon: "boxes", MainPath: "/app/models"},
+ })()
ctx := context.Background()
s, _ := pins.NewFileStore("")
- _ = s.Replace(ctx, "u1", []string{"studio", "talk", "notes"})
+ _ = s.Replace(ctx, "u1", []string{"guide", "help", "notes"})
t0 := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
owner := fakeOwner{ids: []string{}}
sweepAddonPins(ctx, s, owner, t0)
sweepAddonPins(ctx, s, owner, t0.Add(pins.Retention+time.Hour))
// notes is not installed and expired; the built-ins are always present.
- if got := pinnedIDs(t, s, "u1"); !slices.Equal(got, []string{"studio", "talk"}) {
+ if got := pinnedIDs(t, s, "u1"); !slices.Equal(got, []string{"guide", "help"}) {
t.Fatalf("unexpected pins %v", got)
}
}
diff --git a/core/http/endpoints/addons/builtin.go b/core/http/endpoints/addons/builtin.go
index 4aab5f111..17b4bc3ad 100644
--- a/core/http/endpoints/addons/builtin.go
+++ b/core/http/endpoints/addons/builtin.go
@@ -27,7 +27,7 @@ func builtinViewer(c echo.Context, db *gorm.DB) (builtin.Viewer, bool) {
// ListBuiltin lists the built-in page add-ons the caller may open.
// @Summary List the built-in add-ons the caller may open
-// @Description Returns the page add-ons that ship with LocalAI (Studio and Talk). Each entry carries only its own gate: the route needs an authenticated caller and no `addons` feature, so a user who reaches Studio today still reaches it from the Add-ons drawer.
+// @Description Returns the page add-ons that ship inside the web bundle. The list is empty today: Studio and Talk are packaged add-ons that a person installs from the Add-ons drawer. Each entry carries only its own gate: the route needs an authenticated caller and no `addons` feature.
// @Tags AddonNavigation
// @Produce json
// @Success 200 {object} map[string][]builtin.Entry "Entries the caller may open"
diff --git a/core/http/endpoints/addons/builtin_test.go b/core/http/endpoints/addons/builtin_test.go
index 4a0a420a8..55bdda7d7 100644
--- a/core/http/endpoints/addons/builtin_test.go
+++ b/core/http/endpoints/addons/builtin_test.go
@@ -8,9 +8,11 @@ import (
"testing"
"github.com/labstack/echo/v4"
+ "github.com/mudler/LocalAI/core/services/addons/builtin"
)
-func TestListBuiltinWithoutAnAccountDatabaseListsEveryEntry(t *testing.T) {
+func listBuiltin(t *testing.T) (*httptest.ResponseRecorder, []string) {
+ t.Helper()
e := echo.New()
e.GET("/api/addons/builtin", ListBuiltin(nil))
rec := httptest.NewRecorder()
@@ -26,7 +28,30 @@ func TestListBuiltinWithoutAnAccountDatabaseListsEveryEntry(t *testing.T) {
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
- if len(body.Builtin) != 2 || body.Builtin[0].ID != "studio" || body.Builtin[1].ID != "talk" {
+ ids := []string{}
+ for _, e := range body.Builtin {
+ ids = append(ids, e.ID)
+ }
+ return rec, ids
+}
+
+func TestListBuiltinIsEmptyBecauseStudioAndTalkArePackagedAddOns(t *testing.T) {
+ rec, ids := listBuiltin(t)
+ if len(ids) != 0 {
+ t.Fatalf("no page is built in, got %v", ids)
+ }
+ if rec.Header().Get("Cache-Control") != "private, no-store" {
+ t.Fatalf("the list depends on the caller and must not be cached, got %q", rec.Header().Get("Cache-Control"))
+ }
+}
+
+func TestListBuiltinWithoutAnAccountDatabaseListsEveryEntry(t *testing.T) {
+ defer builtin.SetCatalogForTest([]builtin.Entry{
+ {ID: "guide", Kind: builtin.KindPage, TitleKey: "items.chat", Icon: "chat", MainPath: "/app/chat"},
+ {ID: "notes-page", Kind: builtin.KindPage, TitleKey: "items.models", Icon: "boxes", MainPath: "/app/models"},
+ })()
+ rec, ids := listBuiltin(t)
+ if len(ids) != 2 || ids[0] != "guide" || ids[1] != "notes-page" {
t.Fatalf("unexpected body %s", rec.Body.String())
}
if rec.Header().Get("Cache-Control") != "private, no-store" {
diff --git a/core/http/endpoints/addons/pins_test.go b/core/http/endpoints/addons/pins_test.go
index 2520e0562..7fcec17d8 100644
--- a/core/http/endpoints/addons/pins_test.go
+++ b/core/http/endpoints/addons/pins_test.go
@@ -11,19 +11,31 @@ import (
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/http/auth"
+ "github.com/mudler/LocalAI/core/services/addons/builtin"
"github.com/mudler/LocalAI/core/services/addons/pins"
)
+// No page is built in today, so these tests make up two built-in pages to check
+// how the routes filter and keep pins.
+func madeUpPages(t *testing.T) {
+ t.Helper()
+ t.Cleanup(builtin.SetCatalogForTest([]builtin.Entry{
+ {ID: "guide", Kind: builtin.KindPage, TitleKey: "items.chat", Icon: "chat", MainPath: "/app/chat"},
+ {ID: "help", Kind: builtin.KindPage, TitleKey: "items.models", Icon: "boxes", MainPath: "/app/models"},
+ }))
+}
+
// pinsApp builds the two routes over a store. With seeded set, the upgrade seed
// has run for the local owner, as it does at start in a deployment that
// existed before this release.
func pinsApp(t *testing.T, seeded bool) *echo.Echo {
+ madeUpPages(t)
store, err := pins.NewFileStore("")
if err != nil {
t.Fatal(err)
}
if seeded {
- if _, err := store.SeedExisting(context.Background(), []string{auth.AddonLocalOwnerID}, pins.SeedIDs); err != nil {
+ if _, err := store.SeedExisting(context.Background(), []string{auth.AddonLocalOwnerID}, []string{"guide", "help"}); err != nil {
t.Fatal(err)
}
}
@@ -55,23 +67,23 @@ func TestAnAccountCreatedAfterTheUpgradeGetsNoPins(t *testing.T) {
func TestPinsRoundTripForTheLocalOwner(t *testing.T) {
e := pinsApp(t, true)
code, got := call(e, http.MethodGet, "")
- if code != 200 || len(got) != 2 || got[0] != "studio" || got[1] != "talk" {
+ if code != 200 || len(got) != 2 || got[0] != "guide" || got[1] != "help" {
t.Fatalf("seed: %d %v", code, got)
}
- code, got = call(e, http.MethodPut, `["talk","studio"]`)
- if code != 200 || got[0] != "talk" {
+ code, got = call(e, http.MethodPut, `["help","guide"]`)
+ if code != 200 || got[0] != "help" {
t.Fatalf("put: %d %v", code, got)
}
_, got = call(e, http.MethodGet, "")
- if len(got) != 2 || got[0] != "talk" {
+ if len(got) != 2 || got[0] != "help" {
t.Fatalf("round trip: %v", got)
}
}
func TestPutDropsIDsTheCallerCannotSee(t *testing.T) {
e := pinsApp(t, false)
- _, got := call(e, http.MethodPut, `["studio","not-installed"]`)
- if len(got) != 1 || got[0] != "studio" {
+ _, got := call(e, http.MethodPut, `["guide","not-installed"]`)
+ if len(got) != 1 || got[0] != "guide" {
t.Fatalf("an unknown id must be dropped without a hint: %v", got)
}
}
@@ -89,9 +101,19 @@ func TestPutRejectsAListOverTheHardLimit(t *testing.T) {
e := pinsApp(t, false)
items := make([]string, pins.HardLimit+1)
for i := range items {
- items[i] = `"studio"`
+ items[i] = `"guide"`
}
if code, _ := call(e, http.MethodPut, "["+strings.Join(items, ",")+"]"); code != 400 {
t.Fatalf("want 400, got %d", code)
}
}
+
+func TestStudioAndTalkAreNotVisiblePinsWithoutTheAddOns(t *testing.T) {
+ // Studio and Talk are packaged add-ons: the routes list them only for a
+ // caller whose owner lists them as installed, never as built-in pages.
+ e := pinsApp(t, false)
+ _, got := call(e, http.MethodPut, `["studio","talk","guide"]`)
+ if len(got) != 1 || got[0] != "guide" {
+ t.Fatalf("studio and talk are not built in, so they are dropped: %v", got)
+ }
+}
diff --git a/core/http/react-ui/inline-style-baseline.txt b/core/http/react-ui/inline-style-baseline.txt
index aa34eab5f..960e7a870 100644
--- a/core/http/react-ui/inline-style-baseline.txt
+++ b/core/http/react-ui/inline-style-baseline.txt
@@ -1 +1 @@
-199
+182
diff --git a/core/http/react-ui/public/locales/de/nav.json b/core/http/react-ui/public/locales/de/nav.json
index c2ee5f143..5c19954c5 100644
--- a/core/http/react-ui/public/locales/de/nav.json
+++ b/core/http/react-ui/public/locales/de/nav.json
@@ -13,7 +13,6 @@
"account": "Konto",
"accountFor": "Konto: {{name}}",
"sections": {
- "create": "Erstellen",
"recognition": "Erkennung",
"build": "Entwickeln",
"operate": "Betrieb",
@@ -33,8 +32,6 @@
"home": "Start",
"models": "Models",
"chat": "Chat",
- "studio": "Studio",
- "talk": "Sprechen",
"fineTune": "Fine-Tuning (Experimentell)",
"quantize": "Quantisierung (Experimentell)",
"faces": "Gesichter",
diff --git a/core/http/react-ui/public/locales/en/nav.json b/core/http/react-ui/public/locales/en/nav.json
index b8e6c7976..e57397aa0 100644
--- a/core/http/react-ui/public/locales/en/nav.json
+++ b/core/http/react-ui/public/locales/en/nav.json
@@ -13,7 +13,6 @@
"account": "Account",
"accountFor": "Account: {{name}}",
"sections": {
- "create": "Create",
"recognition": "Recognition",
"build": "Build",
"operate": "Operate",
@@ -34,8 +33,6 @@
"home": "Home",
"models": "Models",
"chat": "Chat",
- "studio": "Studio",
- "talk": "Talk",
"fineTune": "Fine-Tune",
"quantize": "Quantize",
"faces": "Faces",
diff --git a/core/http/react-ui/public/locales/es/nav.json b/core/http/react-ui/public/locales/es/nav.json
index 249cd2463..546f2d943 100644
--- a/core/http/react-ui/public/locales/es/nav.json
+++ b/core/http/react-ui/public/locales/es/nav.json
@@ -13,7 +13,6 @@
"account": "Cuenta",
"accountFor": "Cuenta: {{name}}",
"sections": {
- "create": "Crear",
"recognition": "Reconocimiento",
"build": "Construir",
"operate": "Operar",
@@ -33,8 +32,6 @@
"home": "Inicio",
"models": "Models",
"chat": "Chat",
- "studio": "Studio",
- "talk": "Hablar",
"fineTune": "Ajuste fino (Experimental)",
"quantize": "Cuantización (Experimental)",
"faces": "Rostros",
diff --git a/core/http/react-ui/public/locales/id/nav.json b/core/http/react-ui/public/locales/id/nav.json
index b8fec31b7..aa3ac639e 100644
--- a/core/http/react-ui/public/locales/id/nav.json
+++ b/core/http/react-ui/public/locales/id/nav.json
@@ -13,7 +13,6 @@
"account": "Akun",
"accountFor": "Akun: {{name}}",
"sections": {
- "create": "Buat",
"recognition": "Pengenalan",
"build": "Bangun",
"operate": "Operasikan",
@@ -33,8 +32,6 @@
"home": "Beranda",
"models": "Models",
"chat": "Obrolan",
- "studio": "Studio",
- "talk": "Bicara",
"fineTune": "Fine-Tune (Eksperimental)",
"quantize": "Kuantisasi (Eksperimental)",
"faces": "Wajah",
diff --git a/core/http/react-ui/public/locales/it/nav.json b/core/http/react-ui/public/locales/it/nav.json
index cc6388bc4..9a3b22c8f 100644
--- a/core/http/react-ui/public/locales/it/nav.json
+++ b/core/http/react-ui/public/locales/it/nav.json
@@ -13,7 +13,6 @@
"account": "Account",
"accountFor": "Account: {{name}}",
"sections": {
- "create": "Crea",
"recognition": "Riconoscimento",
"build": "Costruisci",
"operate": "Gestione",
@@ -33,8 +32,6 @@
"home": "Home",
"models": "Models",
"chat": "Chat",
- "studio": "Studio",
- "talk": "Conversazione",
"fineTune": "Fine-Tuning (Sperimentale)",
"quantize": "Quantizzazione (Sperimentale)",
"faces": "Volti",
diff --git a/core/http/react-ui/public/locales/ko/nav.json b/core/http/react-ui/public/locales/ko/nav.json
index d351801e5..48420181e 100644
--- a/core/http/react-ui/public/locales/ko/nav.json
+++ b/core/http/react-ui/public/locales/ko/nav.json
@@ -13,7 +13,6 @@
"account": "계정",
"accountFor": "계정: {{name}}",
"sections": {
- "create": "만들기",
"recognition": "인식",
"build": "구축",
"operate": "운영",
@@ -33,8 +32,6 @@
"home": "홈",
"models": "Models",
"chat": "채팅",
- "studio": "스튜디오",
- "talk": "대화",
"fineTune": "파인튜닝 (실험적)",
"quantize": "양자화 (실험적)",
"faces": "얼굴",
diff --git a/core/http/react-ui/public/locales/pt-BR/nav.json b/core/http/react-ui/public/locales/pt-BR/nav.json
index 7f4326ec7..8533d8e2e 100644
--- a/core/http/react-ui/public/locales/pt-BR/nav.json
+++ b/core/http/react-ui/public/locales/pt-BR/nav.json
@@ -13,7 +13,6 @@
"account": "Conta",
"accountFor": "Conta: {{name}}",
"sections": {
- "create": "Criar",
"recognition": "Reconhecimento",
"build": "Construir",
"operate": "Operar",
@@ -33,8 +32,6 @@
"home": "Início",
"models": "Models",
"chat": "Chat",
- "studio": "Studio",
- "talk": "Falar",
"fineTune": "Ajuste Fino",
"quantize": "Quantizar",
"faces": "Rostos",
diff --git a/core/http/react-ui/public/locales/zh-CN/nav.json b/core/http/react-ui/public/locales/zh-CN/nav.json
index e210536ac..a26c1293d 100644
--- a/core/http/react-ui/public/locales/zh-CN/nav.json
+++ b/core/http/react-ui/public/locales/zh-CN/nav.json
@@ -13,7 +13,6 @@
"account": "账户",
"accountFor": "账户:{{name}}",
"sections": {
- "create": "创建",
"recognition": "识别",
"build": "构建",
"operate": "运维",
@@ -33,8 +32,6 @@
"home": "首页",
"models": "Models",
"chat": "聊天",
- "studio": "工作室",
- "talk": "通话",
"fineTune": "微调(实验性)",
"quantize": "量化(实验性)",
"faces": "人脸",
diff --git a/core/http/react-ui/src/addons/useAddonPage.js b/core/http/react-ui/src/addons/useAddonPage.js
new file mode 100644
index 000000000..e805ba80b
--- /dev/null
+++ b/core/http/react-ui/src/addons/useAddonPage.js
@@ -0,0 +1,16 @@
+import { useEffect } from 'react'
+import { useAddonRegistry } from './useAddonRegistry'
+import { appStatus } from './registryState'
+
+// The address of a page of an installed add-on, or null while the add-on is
+// absent, disabled, failed or unusable. A caller that links to an add-on page
+// (a command, a button) shows the link only when this answers an address, so a
+// person who has not installed the add-on never sees a dead end.
+export function useAddonPage(addonId, page) {
+ const { apps, acquire, allowed } = useAddonRegistry()
+ // The list is read only for a user the server lets see add-ons.
+ useEffect(() => (allowed ? acquire() : undefined), [acquire, allowed])
+ const app = apps.find(a => a.id === addonId)
+ const ready = appStatus(app) === 'ready' && Array.isArray(app.ui.pages) && app.ui.pages.includes(page)
+ return ready ? `/app/addons/${encodeURIComponent(addonId)}/${encodeURIComponent(page)}` : null
+}
diff --git a/core/http/react-ui/src/components/Sidebar.jsx b/core/http/react-ui/src/components/Sidebar.jsx
index a4aa9499b..6bebba8a5 100644
--- a/core/http/react-ui/src/components/Sidebar.jsx
+++ b/core/http/react-ui/src/components/Sidebar.jsx
@@ -8,7 +8,7 @@ import { useBranding } from '../contexts/BrandingContext'
import { apiUrl } from '../utils/basePath'
import { preloadRoute } from '../router'
import { hubs, hubEntryPath, hubOwnsPath } from './hub/hubConfig'
-import { topDestinations, createDestinations } from '../utils/destinations'
+import { topDestinations } from '../utils/destinations'
import { primaryDestinations, visiblePrimary } from '../utils/navigationModel'
import { useNavigation } from '../context/NavigationContext'
import PinnedNav from './PinnedNav'
@@ -21,18 +21,13 @@ const SECTIONS_KEY = 'localai_sidebar_sections'
// The sidebar has two layouts. The four-item layout shows Home, Chat, Models
// and Add-ons, the Pinned section, and Build and Operate under
// Workspace. With the legacy switch on (features.addon_navigation === false)
-// it shows Home and Models, the Create group (Chat, Studio, Talk) and
-// Workspace. Build and Operate are single entries; each opens a hub whose tab
-// bar lives in hub/hubConfig.js (shared with HubLayout). The legacy entries
-// are shared with the 404 page, which lists the same destinations.
+// it shows Home, Models and Chat, and Workspace. Build and Operate are single
+// entries; each opens a hub whose tab bar lives in hub/hubConfig.js (shared
+// with HubLayout). The legacy entries are shared with the 404 page, which
+// lists the same destinations.
const topItems = topDestinations
const sections = [
- {
- id: 'create',
- titleKey: 'sections.create',
- items: createDestinations,
- },
// Items come from the hubs (hubConfig.js) and carry their own gating.
{ id: 'workspace', titleKey: 'sections.workspace', hubs: true },
]
@@ -180,8 +175,8 @@ export default function Sidebar({ isOpen, onClose }) {
const failedOps = operations.filter((op) => op.error).length
const activeOps = operations.length
- // Create carries no gating beyond filterItem. Workspace lists one entry per
- // hub the viewer can use; its target is the hub's overview.
+ // Workspace lists one entry per hub the viewer can use; its target is the
+ // hub's overview.
const getVisibleSectionItems = (section) => {
if (!section.hubs) return section.items.filter(filterItem)
return hubs.flatMap(hub => {
diff --git a/core/http/react-ui/src/components/home/homeActions.js b/core/http/react-ui/src/components/home/homeActions.js
index a5b175bb8..03f8eb9ec 100644
--- a/core/http/react-ui/src/components/home/homeActions.js
+++ b/core/http/react-ui/src/components/home/homeActions.js
@@ -1,7 +1,8 @@
// The slash actions on the Home command bar. Every entry has a destination in
// the product today; the page decides what each one does (navigate, open the
// model list, start a chat). `needs` hides an action the person cannot use:
-// the admin pages, the assistant, or stopping a model when none is loaded.
+// the admin pages, the assistant, stopping a model when none is loaded, or
+// Studio while that add-on is not installed.
export const SLASH_GROUPS = ['chat', 'models', 'go']
@@ -14,18 +15,19 @@ export const SLASH_ACTIONS = [
{ id: 'installed', cmd: '/installed', icon: 'boxes', group: 'models', needs: 'admin' },
{ id: 'import', cmd: '/import', icon: 'upload', group: 'models', needs: 'admin' },
{ id: 'stop', cmd: '/stop', icon: 'stop', group: 'models', needs: 'loaded' },
- { id: 'studio', cmd: '/studio', icon: 'palette', group: 'go' },
+ { id: 'studio', cmd: '/studio', icon: 'palette', group: 'go', needs: 'studio' },
{ id: 'settings', cmd: '/settings', icon: 'settings', group: 'go', needs: 'admin' },
{ id: 'docs', cmd: '/docs', icon: 'book', group: 'go' },
]
-// `ctx`: { isAdmin, assistantAvailable, hasModels, loadedCount }
+// `ctx`: { isAdmin, assistantAvailable, hasModels, loadedCount, studioInstalled }
export function availableActions(ctx) {
return SLASH_ACTIONS.filter(a => {
if (a.needs === 'admin') return ctx.isAdmin
if (a.needs === 'assistant') return ctx.isAdmin && ctx.assistantAvailable
if (a.needs === 'models') return ctx.hasModels
if (a.needs === 'loaded') return ctx.isAdmin && ctx.loadedCount > 0
+ if (a.needs === 'studio') return ctx.studioInstalled === true
return true
})
}
diff --git a/core/http/react-ui/src/i18n/index.js b/core/http/react-ui/src/i18n/index.js
index 4dd66df7e..ce45237e1 100644
--- a/core/http/react-ui/src/i18n/index.js
+++ b/core/http/react-ui/src/i18n/index.js
@@ -22,8 +22,6 @@ export const NAMESPACES = [
'auth',
'home',
'chat',
- 'talk',
- 'studio',
'models',
'agents',
'skills',
diff --git a/core/http/react-ui/src/pages/Home.jsx b/core/http/react-ui/src/pages/Home.jsx
index 3d743b2cf..139413ce5 100644
--- a/core/http/react-ui/src/pages/Home.jsx
+++ b/core/http/react-ui/src/pages/Home.jsx
@@ -26,6 +26,7 @@ import HomeUndoToast from '../components/home/HomeUndoToast'
import { useResources } from '../hooks/useResources'
import { usePolling } from '../hooks/usePolling'
import { useOperations } from '../hooks/useOperations'
+import { useAddonPage } from '../addons/useAddonPage'
import { fileToBase64, backendControlApi, systemApi, modelsApi, mcpApi, nodesApi } from '../utils/api'
import { readAttachmentText, renderPdfPages, PdfNoTextError } from '../utils/pdf'
import { greetingKey } from '../utils/greeting'
@@ -448,9 +449,11 @@ export default function Home() {
const stripBusy = !!stagingOp || !!failedOp
useEffect(() => { if (stripBusy) setStripOpen(true) }, [stripBusy])
+ // Studio is an add-on: its command is offered only while it is installed.
+ const studioPage = useAddonPage('studio', 'studio')
const slashContext = useMemo(
- () => ({ isAdmin, assistantAvailable, hasModels: hasModels && !firstRun, loadedCount }),
- [isAdmin, assistantAvailable, hasModels, firstRun, loadedCount],
+ () => ({ isAdmin, assistantAvailable, hasModels: hasModels && !firstRun, loadedCount, studioInstalled: Boolean(studioPage) }),
+ [isAdmin, assistantAvailable, hasModels, firstRun, loadedCount, studioPage],
)
const runAction = useCallback((id) => {
@@ -466,12 +469,12 @@ export default function Home() {
setStripOpen(true)
stripRef.current?.scrollIntoView?.({ block: 'nearest', behavior: reducedMotion() ? 'auto' : 'smooth' })
break
- case 'studio': navigate('/app/studio'); break
+ case 'studio': if (studioPage) navigate(studioPage); break
case 'settings': navigate('/app/settings'); break
case 'docs': window.open(DOCS_URL, '_blank', 'noopener,noreferrer'); break
default: break
}
- }, [navigate, openNewChat, openAssistantChat])
+ }, [navigate, openNewChat, openAssistantChat, studioPage])
// "/" from anywhere that is not a text field starts a command.
useEffect(() => {
@@ -671,7 +674,7 @@ export default function Home() {
onDelete={requestDelete}
emptyHint={(
- {(isAdmin ? ['/gallery', '/import'] : []).concat(isAdmin && assistantAvailable ? ['/assistant'] : [], ['/studio']).map(c => (
+ {(isAdmin ? ['/gallery', '/import'] : []).concat(isAdmin && assistantAvailable ? ['/assistant'] : [], studioPage ? ['/studio'] : []).map(c => (
{c}
))}
diff --git a/core/http/react-ui/src/router.jsx b/core/http/react-ui/src/router.jsx
index 60ce5be12..48073d8b0 100644
--- a/core/http/react-ui/src/router.jsx
+++ b/core/http/react-ui/src/router.jsx
@@ -84,14 +84,6 @@ const Home = page('', () => import('./pages/Home'))
const Chat = page('chat', () => import('./pages/Chat'))
const Models = page('models', () => import('./pages/Models'))
const ManageRedirect = page('manage', () => import('./pages/ManageRedirect'))
-page('image', () => import('./pages/ImageGen'))
-page('video', () => import('./pages/VideoGen'))
-page('3d', () => import('./pages/ThreeDGen'))
-page('tts', () => import('./pages/TTS'))
-page('sound', () => import('./pages/Sound'))
-page('diarization', () => import('./pages/Diarization'))
-page('transform', () => import('./pages/AudioTransform'))
-const Talk = page('talk', () => import('./pages/Talk'))
// Referenced only from JSX below — same blind spot as Activity further down.
// eslint-disable-next-line no-unused-vars
const OperateOverview = page('operate', () => import('./pages/OperateOverview'))
@@ -128,7 +120,6 @@ const Explorer = page(null, () => import('./pages/Explorer'))
const Login = page(null, () => import('./pages/Login'))
const FineTune = page('fine-tune', () => import('./pages/FineTune'))
const Quantize = page('quantize', () => import('./pages/Quantize'))
-const Studio = page('studio', () => import('./pages/Studio'))
const FaceRecognition = page('face', () => import('./pages/FaceRecognition'))
const VoiceRecognition = page('voice', () => import('./pages/VoiceRecognition'))
const VoiceProfileCreate = page(null, () => import('./pages/VoiceProfileCreate'))
@@ -170,7 +161,7 @@ import HubLayout from './components/hub/HubLayout'
import { AddonRegistryProvider } from './addons/registry'
import { useAuth } from './context/AuthContext'
import { NavigationProvider, useNavigation } from './context/NavigationContext'
-import { studioRedirect } from './utils/legacyRoutes'
+import { studioAddress, studioRedirect, talkAddress } from './utils/legacyRoutes'
import { buildHub, operateHub } from './components/hub/hubConfig'
import { useHubAuth } from './components/hub/useHubAuth'
@@ -183,14 +174,27 @@ function AgentChatRedirect() {
return
}
-// An old generator link opens the same generator inside Studio. The model,
-// the query string and the hash are kept.
+// An old generator link opens the same generator inside the Studio add-on. The
+// model, the query string and the hash are kept.
function StudioTabRedirect({ legacy }) {
const { model } = useParams()
const { search, hash } = useLocation()
return
}
+// /app/studio[/tab[/model]] is a page of the Studio add-on now.
+function StudioRedirect() {
+ const { tab, model } = useParams()
+ const { search, hash } = useLocation()
+ return
+}
+
+// /app/talk is a page of the Talk add-on now.
+function TalkRedirect() {
+ const { search, hash } = useLocation()
+ return
+}
+
function BrowseRedirect() {
const { '*': splat } = useParams()
return
@@ -264,17 +268,16 @@ const appChildren = [
{ path: 'diarization/:model', element: },
{ path: 'transform', element: },
{ path: 'transform/:model', element: },
- { path: 'studio', element: },
- // Tabs are path segments, not a query parameter: switching generator is
- // navigation, and ?tab= reads like a filter. Legacy ?tab= links redirect.
- { path: 'studio/:tab', element: },
+ // Studio and Talk are add-ons; the old addresses open them. When the add-on
+ // is not installed the add-on page says so.
+ { path: 'studio', element: },
+ { path: 'studio/:tab', element: },
// A generator opened on one model: /app/studio/images/.
- { path: 'studio/:tab/:model', element: },
- { path: 'talk', element: },
+ { path: 'studio/:tab/:model', element: },
+ { path: 'talk', element: },
{ path: 'account', element: },
// The Add-ons drawer. A viewer without the addons feature still reaches it
- // when a built-in tile (Studio, Talk) is theirs to open, so no one loses a
- // page they can open today.
+ // when a built-in tile is theirs to open (the built-in list is empty today).
// With the legacy switch on, both routes sit in the Build hub again, under
// its Add-ons tab, and need the addons feature as they did before.
{
diff --git a/core/http/react-ui/src/utils/destinations.js b/core/http/react-ui/src/utils/destinations.js
index b6e81c5ba..f2a770bb6 100644
--- a/core/http/react-ui/src/utils/destinations.js
+++ b/core/http/react-ui/src/utils/destinations.js
@@ -4,15 +4,12 @@
import { hubs, hubEntryPath } from '../components/hub/hubConfig'
import { primaryDestinations, visiblePrimary } from './navigationModel.js'
+// Studio and Talk are add-ons: they are listed in the Add-ons drawer when they
+// are installed, not here.
export const topDestinations = [
{ path: '/app', icon: 'home', labelKey: 'items.home' },
{ path: '/app/models', icon: 'boxes', labelKey: 'items.models', adminOnly: true },
-]
-
-export const createDestinations = [
{ path: '/app/chat', icon: 'chat', labelKey: 'items.chat' },
- { path: '/app/studio', icon: 'palette', labelKey: 'items.studio' },
- { path: '/app/talk', icon: 'phone', labelKey: 'items.talk' },
]
function visible(item, { isAdmin, authEnabled, hasFeature, features }) {
@@ -23,7 +20,7 @@ function visible(item, { isAdmin, authEnabled, hasFeature, features }) {
return true
}
-// Home, Models, Chat, Studio, Talk and the hubs, in the sidebar's order.
+// Home, Models, Chat and the hubs, in the sidebar's order.
export function destinationsFor(auth) {
const hubItems = hubs.flatMap(hub => {
const path = hubEntryPath(hub, auth)
@@ -31,7 +28,6 @@ export function destinationsFor(auth) {
})
return [
...topDestinations.filter(i => visible(i, auth)),
- ...createDestinations.filter(i => visible(i, auth)),
...hubItems,
]
}
diff --git a/core/http/react-ui/src/utils/legacyRoutes.js b/core/http/react-ui/src/utils/legacyRoutes.js
index 1b1c8d3c7..cd2818dbb 100644
--- a/core/http/react-ui/src/utils/legacyRoutes.js
+++ b/core/http/react-ui/src/utils/legacyRoutes.js
@@ -1,7 +1,7 @@
// The old routes of the migration table and where they go now. Redirects stay
// in the app until a separate decision removes them.
-// Old generator route segment -> Studio tab key (pages/Studio.jsx MODALITIES).
+// Old generator route segment -> Studio tab key (the tabs of the Studio add-on).
export const STUDIO_TABS = Object.freeze({
image: 'images',
video: 'video',
@@ -12,29 +12,51 @@ export const STUDIO_TABS = Object.freeze({
transform: 'transform',
})
-// The target of an old generator route. The model segment, the query string
-// and the hash are kept as they came.
+// Studio and Talk are add-ons. Their pages live under /app/addons//;
+// the tab is one path segment and the model travels as ?model=, because
+// an add-on path segment cannot hold "/" or ":" (see resolveSubpath in the SDK).
+export const STUDIO_PAGE = '/app/addons/studio/studio'
+export const TALK_PAGE = '/app/addons/talk/talk'
+
+function addonAddress(base, { tab, model, search = '', hash = '' } = {}) {
+ const params = new URLSearchParams(search)
+ if (model) params.set('model', model)
+ const query = params.toString()
+ return `${base}${tab ? `/${encodeURIComponent(tab)}` : ''}${query ? `?${query}` : ''}${hash}`
+}
+
+// Where an old /app/studio[/tab[/model]] link opens now. The model, the query
+// string and the hash are kept.
+export function studioAddress(args) {
+ return addonAddress(STUDIO_PAGE, args)
+}
+
+// Where an old /app/talk link opens now.
+export function talkAddress(args) {
+ return addonAddress(TALK_PAGE, args)
+}
+
+// The target of an old generator route.
export function studioRedirect(legacy, model, search = '', hash = '') {
- const tab = STUDIO_TABS[legacy]
- const tail = model ? `/${encodeURIComponent(model)}` : ''
- return `/app/studio/${tab}${tail}${search}${hash}`
+ return studioAddress({ tab: STUDIO_TABS[legacy], model, search, hash })
}
// One row per old route of the spec's migration table that changes or keeps
-// its path. `kind` is 'same' (no redirect), 'studio-tab' or 'addon-page'.
+// its path. `kind` is 'same' (no redirect), 'studio-tab' (a Studio tab) or
+// 'addon-page' (a page of an add-on).
export const MIGRATION_ROUTES = Object.freeze([
{ from: '/app/chat', to: '/app/chat', kind: 'same' },
{ from: '/app/models', to: '/app/models', kind: 'same' },
{ from: '/app/settings', to: '/app/settings', kind: 'same' },
- { from: '/app/studio', to: '/app/studio', kind: 'same' },
- { from: '/app/talk', to: '/app/talk', kind: 'same' },
- { from: '/app/image', to: '/app/studio/images', kind: 'studio-tab' },
- { from: '/app/video', to: '/app/studio/video', kind: 'studio-tab' },
- { from: '/app/3d', to: '/app/studio/threed', kind: 'studio-tab' },
- { from: '/app/tts', to: '/app/studio/tts', kind: 'studio-tab' },
- { from: '/app/sound', to: '/app/studio/sound', kind: 'studio-tab' },
- { from: '/app/diarization', to: '/app/studio/diarization', kind: 'studio-tab' },
- { from: '/app/transform', to: '/app/studio/transform', kind: 'studio-tab' },
+ { from: '/app/studio', to: '/app/addons/studio/studio', kind: 'addon-page' },
+ { from: '/app/talk', to: '/app/addons/talk/talk', kind: 'addon-page' },
+ { from: '/app/image', to: '/app/addons/studio/studio/images', kind: 'studio-tab' },
+ { from: '/app/video', to: '/app/addons/studio/studio/video', kind: 'studio-tab' },
+ { from: '/app/3d', to: '/app/addons/studio/studio/threed', kind: 'studio-tab' },
+ { from: '/app/tts', to: '/app/addons/studio/studio/tts', kind: 'studio-tab' },
+ { from: '/app/sound', to: '/app/addons/studio/studio/sound', kind: 'studio-tab' },
+ { from: '/app/diarization', to: '/app/addons/studio/studio/diarization', kind: 'studio-tab' },
+ { from: '/app/transform', to: '/app/addons/studio/studio/transform', kind: 'studio-tab' },
{ from: '/app/face', to: '/app/addons/face/recognition', kind: 'addon-page' },
{ from: '/app/voice', to: '/app/addons/voice/recognition', kind: 'addon-page' },
{ from: '/app/voice-library', to: '/app/addons/voice/library', kind: 'addon-page' },
diff --git a/core/http/react-ui/tests/unit/legacyroutes.test.mjs b/core/http/react-ui/tests/unit/legacyroutes.test.mjs
index ccaf8c2e7..6d13b93fc 100644
--- a/core/http/react-ui/tests/unit/legacyroutes.test.mjs
+++ b/core/http/react-ui/tests/unit/legacyroutes.test.mjs
@@ -3,15 +3,24 @@ import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
-import { STUDIO_TABS, studioRedirect, MIGRATION_ROUTES } from '../../src/utils/legacyRoutes.js'
+import { STUDIO_TABS, studioAddress, studioRedirect, talkAddress, MIGRATION_ROUTES } from '../../src/utils/legacyRoutes.js'
const here = dirname(fileURLToPath(import.meta.url))
const router = readFileSync(join(here, '../../src/router.jsx'), 'utf8')
-test('studioRedirect keeps the model, the query and the hash', () => {
- assert.equal(studioRedirect('image', 'stable/diffusion', '?x=1', '#top'), '/app/studio/images/stable%2Fdiffusion?x=1#top')
- assert.equal(studioRedirect('3d', undefined, '', ''), '/app/studio/threed')
- assert.equal(studioRedirect('tts', 'piper', '?voice=a', ''), '/app/studio/tts/piper?voice=a')
+test('studioRedirect opens the Studio add-on and keeps the model, the query and the hash', () => {
+ // A model id may hold "/" or ":", which an add-on path segment cannot, so it travels as ?model=.
+ assert.equal(studioRedirect('image', 'stable/diffusion', '?x=1', '#top'), '/app/addons/studio/studio/images?x=1&model=stable%2Fdiffusion#top')
+ assert.equal(studioRedirect('3d', undefined, '', ''), '/app/addons/studio/studio/threed')
+ assert.equal(studioRedirect('tts', 'piper', '?voice=a', ''), '/app/addons/studio/studio/tts?voice=a&model=piper')
+})
+
+test('studioAddress and talkAddress build the add-on page addresses', () => {
+ assert.equal(studioAddress(), '/app/addons/studio/studio')
+ assert.equal(studioAddress({ tab: 'images', model: 'a b', search: '?work=1' }), '/app/addons/studio/studio/images?work=1&model=a+b')
+ assert.equal(studioAddress({ search: '?work=w%201' }), '/app/addons/studio/studio?work=w+1')
+ assert.equal(talkAddress(), '/app/addons/talk/talk')
+ assert.equal(talkAddress({ search: '?model=x', hash: '#h' }), '/app/addons/talk/talk?model=x#h')
})
test('every standalone generator route maps to a Studio tab', () => {
@@ -39,8 +48,17 @@ test('each moved route has its own redirect entry, plain and with a model, bound
}
})
-test('Studio has a route that carries a model', () => {
- assert.ok(router.includes("path: 'studio/:tab/:model'"))
+test('the Studio and Talk routes redirect to their add-ons, with or without a tab and a model', () => {
+ for (const path of ["path: 'studio'", "path: 'studio/:tab'", "path: 'studio/:tab/:model'"]) {
+ assert.ok(router.includes(`{ ${path}, element: }`), `no StudioRedirect for ${path}`)
+ }
+ assert.ok(router.includes("{ path: 'talk', element: }"))
+ assert.ok(MIGRATION_ROUTES.some(r => r.from === '/app/studio' && r.to === '/app/addons/studio/studio' && r.kind === 'addon-page'))
+ assert.ok(MIGRATION_ROUTES.some(r => r.from === '/app/talk' && r.to === '/app/addons/talk/talk' && r.kind === 'addon-page'))
+})
+
+test('the router imports no Studio or Talk page: both are add-ons', () => {
+ assert.ok(!/pages\/(Studio|Talk|ImageGen|VideoGen|ThreeDGen|TTS|Sound|Diarization|AudioTransform)'/.test(router))
})
test('the same-path routes need no redirect', () => {
@@ -48,7 +66,8 @@ test('the same-path routes need no redirect', () => {
})
test('moved biometric routes are wrapped in MovedPage in the router', () => {
- for (const r of MIGRATION_ROUTES.filter(x => x.kind === 'addon-page')) {
+ // Studio and Talk have no core page to fall back to; they redirect (see above).
+ for (const r of MIGRATION_ROUTES.filter(x => x.kind === 'addon-page' && !['/app/studio', '/app/talk'].includes(x.from))) {
const [, , , addon, page] = r.to.split('/')
assert.ok(router.includes(``) || router.includes(``), `router.jsx does not wrap ${r.from}`)
}
diff --git a/core/http/react-ui/tests/unit/navigation.test.mjs b/core/http/react-ui/tests/unit/navigation.test.mjs
index b832983de..b1cc9efce 100644
--- a/core/http/react-ui/tests/unit/navigation.test.mjs
+++ b/core/http/react-ui/tests/unit/navigation.test.mjs
@@ -44,11 +44,13 @@ test('tileFromApp: the title falls back to the id', () => {
})
test('buildTiles: built-in first, then apps by id, a built-in id wins', () => {
- const builtin = [{ id: 'studio', titleKey: 'items.studio', icon: 'palette', mainPath: '/app/studio' }]
- const tiles = buildTiles([app({ id: 'zed' }), app({ id: 'studio' }), app({ id: 'abc' })], builtin)
- assert.deepEqual(tiles.map(t => t.id), ['studio', 'abc', 'zed'])
+ // No add-on is built in today (Studio and Talk are packaged add-ons), so the
+ // rule is checked with an entry the test makes up.
+ const builtin = [{ id: 'guide', titleKey: 'items.guide', icon: 'book', mainPath: '/app/guide' }]
+ const tiles = buildTiles([app({ id: 'zed' }), app({ id: 'guide' }), app({ id: 'abc' })], builtin)
+ assert.deepEqual(tiles.map(t => t.id), ['guide', 'abc', 'zed'])
assert.equal(tiles[0].source, 'builtin')
- assert.equal(tiles[0].target, '/app/studio')
+ assert.equal(tiles[0].target, '/app/guide')
})
test('filterTiles matches title and description, case-insensitive', () => {
@@ -98,13 +100,13 @@ test('pins api: reads and writes arrays, sends JSON, never throws on a malformed
const calls = []
const fetchImpl = async (url, init) => {
calls.push([url, init?.method || 'GET', init?.body])
- if (url.endsWith('/api/addons/builtin')) return { ok: true, json: async () => ({ builtin: [{ id: 'studio' }] }) }
- return { ok: true, json: async () => (init?.method === 'PUT' ? JSON.parse(init.body) : ['studio', 7, null]) }
+ if (url.endsWith('/api/addons/builtin')) return { ok: true, json: async () => ({ builtin: [{ id: 'guide' }] }) }
+ return { ok: true, json: async () => (init?.method === 'PUT' ? JSON.parse(init.body) : ['guide', 7, null]) }
}
const api = createPinsApi({ base: (p) => `/base${p}`, fetchImpl })
- assert.deepEqual(await api.listPins(), ['studio'])
+ assert.deepEqual(await api.listPins(), ['guide'])
assert.deepEqual(await api.putPins(['a', 'b']), ['a', 'b'])
- assert.deepEqual(await api.listBuiltin(), [{ id: 'studio' }])
+ assert.deepEqual(await api.listBuiltin(), [{ id: 'guide' }])
assert.equal(calls[1][0], '/base/api/addons/pins')
assert.equal(calls[1][1], 'PUT')
assert.equal(calls[1][2], '["a","b"]')
diff --git a/core/services/addons/builtin/builtin.go b/core/services/addons/builtin/builtin.go
index d8f7c52cf..37e58c244 100644
--- a/core/services/addons/builtin/builtin.go
+++ b/core/services/addons/builtin/builtin.go
@@ -1,10 +1,11 @@
// SPDX-License-Identifier: MIT
// Package builtin lists the page add-ons that ship inside the LocalAI web
-// bundle: Studio and Talk. Voice, Face, fine-tuning and quantization are
-// packaged add-ons and are not listed here. Page add-ons have
-// no process and no lifecycle of their own. The catalog is the one place that
-// says who may see each of them, so the pin routes and the drawer agree.
+// bundle. None does today: Studio, Talk, Voice, Face, fine-tuning and
+// quantization are packaged add-ons that a person installs and removes, so the
+// catalog is empty. The package stays because it is the one place that says
+// who may see a built-in page, so the pin routes and the drawer agree on it the
+// day one is added. Page add-ons have no process and no lifecycle of their own.
package builtin
import "slices"
@@ -45,9 +46,16 @@ func (v Viewer) allows(e Entry) bool {
return true
}
-var catalog = []Entry{
- {ID: "studio", Kind: KindPage, TitleKey: "items.studio", Icon: "palette", MainPath: "/app/studio"},
- {ID: "talk", Kind: KindPage, TitleKey: "items.talk", Icon: "phone", MainPath: "/app/talk"},
+var catalog = []Entry{}
+
+// SetCatalogForTest replaces the catalog and returns a function that puts the
+// old one back. No page is built in today, so the tests of the packages that
+// read the catalog (the pin and list routes, the pin sweep) use it to check
+// the rules against entries they make up. It is not safe for concurrent use.
+func SetCatalogForTest(entries []Entry) (restore func()) {
+ old := catalog
+ catalog = slices.Clone(entries)
+ return func() { catalog = old }
}
// Catalog returns a copy of every entry, in display order.
diff --git a/core/services/addons/builtin/builtin_test.go b/core/services/addons/builtin/builtin_test.go
index c6fb3bd7d..e5242e97f 100644
--- a/core/services/addons/builtin/builtin_test.go
+++ b/core/services/addons/builtin/builtin_test.go
@@ -14,10 +14,25 @@ func ids(es []Entry) []string {
return out
}
-func TestStudioAndTalkAreVisibleWithoutAnyFeature(t *testing.T) {
- got := ids(Visible(Viewer{Admin: false, Has: func(string) bool { return false }}))
- if !slices.Equal(got, []string{"studio", "talk"}) {
- t.Fatalf("a viewer with no feature must still see studio and talk, got %v", got)
+func TestNoAddonIsBuiltIn(t *testing.T) {
+ // Studio and Talk are packaged add-ons, installed and removed like the
+ // others, so nothing is listed for a viewer, whatever the viewer holds.
+ if got := ids(Visible(Viewer{Admin: true})); len(got) != 0 {
+ t.Fatalf("no add-on is built in, got %v", got)
+ }
+ if got := ids(Visible(Viewer{Has: func(string) bool { return true }})); len(got) != 0 {
+ t.Fatalf("no add-on is built in, got %v", got)
+ }
+ if len(IDs()) != 0 || len(Catalog()) != 0 {
+ t.Fatalf("the catalog must be empty, got %v", IDs())
+ }
+}
+
+func TestStudioAndTalkAreNotBuiltIn(t *testing.T) {
+ for _, id := range []string{"studio", "talk"} {
+ if slices.Contains(IDs(), id) {
+ t.Fatalf("%s is a packaged add-on and must not be in the built-in catalog", id)
+ }
}
}
diff --git a/gallery/addons/README.md b/gallery/addons/README.md
index e2e017a54..5a3bb343c 100644
--- a/gallery/addons/README.md
+++ b/gallery/addons/README.md
@@ -3,7 +3,7 @@
This directory holds the template of the catalog entries of the first-party add-ons.
It holds no live URL, registry or namespace. The registry and repository are inputs of
the release workflow (`ADDON_OCI_REPOSITORY`). Each first-party add-on has its own
-template (`tables`, `voice`, `face`, `fine-tuning`, `quantization`) and its own release
+template (`tables`, `voice`, `face`, `fine-tuning`, `quantization`, `studio`, `talk`) and its own release
platform matrix, `addons//package/platforms.yaml`. The templates differ in `name:` only.
## Render entries
@@ -31,7 +31,7 @@ go run ./scripts/addons/cmd/addonpkg catalog \
--merged ... # the other flags as above
```
-Use `face`, `fine-tuning` or `quantization` in place of `voice` for the other packages.
+Use `face`, `fine-tuning`, `quantization`, `studio` or `talk` in place of `voice` for the other packages.
Each package has its own repository, so pass the repository of that package.
The add-on packages live in `quay.io/go-skynet/local-ai-addons`; the template itself holds
diff --git a/gallery/addons/studio.yaml.tmpl b/gallery/addons/studio.yaml.tmpl
new file mode 100644
index 000000000..ceb333a93
--- /dev/null
+++ b/gallery/addons/studio.yaml.tmpl
@@ -0,0 +1,18 @@
+addonId: "{{addon}}"
+name: Studio
+description: "{{description}}"
+version: "{{version}}"
+channel: "{{channel}}"
+artifact: "oci://{{repository}}:{{tag}}"
+digest: "{{digest}}"
+os: "{{os}}"
+arch: "{{arch}}"
+policy:
+ requireSignature: true
+ issuer: "{{issuer}}"
+ identity: "{{identity}}"
+ui:
+ title: "{{title}}"
+ description: "{{description}}"
+ mainPage: "{{main_page}}"
+ icon: "{{icon}}"
diff --git a/gallery/addons/talk.yaml.tmpl b/gallery/addons/talk.yaml.tmpl
new file mode 100644
index 000000000..282b5940a
--- /dev/null
+++ b/gallery/addons/talk.yaml.tmpl
@@ -0,0 +1,18 @@
+addonId: "{{addon}}"
+name: Talk
+description: "{{description}}"
+version: "{{version}}"
+channel: "{{channel}}"
+artifact: "oci://{{repository}}:{{tag}}"
+digest: "{{digest}}"
+os: "{{os}}"
+arch: "{{arch}}"
+policy:
+ requireSignature: true
+ issuer: "{{issuer}}"
+ identity: "{{identity}}"
+ui:
+ title: "{{title}}"
+ description: "{{description}}"
+ mainPage: "{{main_page}}"
+ icon: "{{icon}}"
diff --git a/pkg/addons/packaging/packaging_test.go b/pkg/addons/packaging/packaging_test.go
index ce47cedd0..416a59e01 100644
--- a/pkg/addons/packaging/packaging_test.go
+++ b/pkg/addons/packaging/packaging_test.go
@@ -869,7 +869,7 @@ var _ = Describe("catalog template", func() {
Expect(entries[0].Arch).NotTo(Equal(entries[1].Arch))
})
It("renders an entry for every first-party add-on from its own template", func() {
- for _, id := range []string{"tables", "voice", "face", "fine-tuning", "quantization"} {
+ for _, id := range []string{"tables", "voice", "face", "fine-tuning", "quantization", "studio", "talk"} {
p := newProjectFor(id, both)
m := p.merged("1.2.3", rev, nil)
repoName := newRegistry().repo("addons/" + id)
@@ -1038,7 +1038,7 @@ var _ = Describe("workflow policy", func() {
})
It("rejects a workflow that names one add-on by path or skips an add-on in the matrix", func() {
expect(mutate("node \"addons/$ADDON/ui/build.mjs\"\n - name: Fill", "node addons/tables/ui/build.mjs\n - name: Fill"), "P_ADDON_PATH")
- expect(mutate(" addon: [tables, voice, face, fine-tuning, quantization]\n", " platform: [x]\n"), "P_MATRIX")
+ expect(mutate(" addon: [tables, voice, face, fine-tuning, quantization, studio, talk]\n", " platform: [x]\n"), "P_MATRIX")
})
It("rejects a plan that does not refuse an add-on without an enabled platform", func() {
expect(mutate("has no enabled platform; refusing to publish", "has no platform"), "P_FAIL_CLOSED")
@@ -1089,7 +1089,7 @@ var _ = Describe("workflow policy", func() {
// firstPartyAddons are the add-ons the release workflow packages. The list is
// written out so a new add-on is a deliberate change here and in the workflow.
-var firstPartyAddons = []string{"tables", "voice", "face", "fine-tuning", "quantization"}
+var firstPartyAddons = []string{"tables", "voice", "face", "fine-tuning", "quantization", "studio", "talk"}
func readWorkflow(t *testing.T) string {
t.Helper()
diff --git a/pkg/addons/packaging/platforms_test.go b/pkg/addons/packaging/platforms_test.go
index 9bda7235e..d6c2e2797 100644
--- a/pkg/addons/packaging/platforms_test.go
+++ b/pkg/addons/packaging/platforms_test.go
@@ -24,7 +24,7 @@ var releasePlatforms = map[string]string{
// request. Enabling without this record, or recording without enabling, fails.
var nativeChecked = map[string]bool{"linux/amd64": true, "linux/arm64": true, "darwin/arm64": true, "darwin/amd64": true}
-var packagedAddons = []string{"voice", "face", "fine-tuning", "quantization"}
+var packagedAddons = []string{"voice", "face", "fine-tuning", "quantization", "studio", "talk"}
func TestEveryPackagedAddonListsEveryReleasePlatformWithItsRunner(t *testing.T) {
for _, id := range packagedAddons {
diff --git a/scripts/addons/package_test.sh b/scripts/addons/package_test.sh
index 32e36cbd8..348413604 100755
--- a/scripts/addons/package_test.sh
+++ b/scripts/addons/package_test.sh
@@ -88,7 +88,7 @@ test -f "$work/voice/receipt.json"
case_ok 'package.sh builds and verifies the voice add-on for the host'
# A platform other than the host is refused for every add-on, whatever the config.
if [ "$host" = linux/amd64 ]; then foreign=linux/arm64; else foreign=linux/amd64; fi
-for id in tables voice face fine-tuning quantization; do
+for id in tables voice face fine-tuning quantization studio talk; do
expect_fail "package.sh refuses a foreign platform without an executable: $id" 'is not this host' sh scripts/addons/package.sh --config "addons/$id/package/platforms.yaml" --layout "$work/foreign-$id" --version 1.2.3 --platform "$foreign" --local-check
done
case_ok 'package.sh refuses a foreign platform without an executable for every add-on'
@@ -114,11 +114,11 @@ exec "$realgo" "\$@"
STUB
chmod 755 "$work/stubbin/go"
: >"$work/gobuild.log"
-for id in tables voice face fine-tuning quantization; do
+for id in tables voice face fine-tuning quantization studio talk; do
PATH="$work/stubbin:$PATH" sh scripts/addons/package.sh --config "addons/$id/package/platforms.yaml" --layout "$work/stub-$id" --version 1.2.3 --platform "$host" --local-check >"$work/out" 2>&1 || { cat "$work/out" >&2; exit 1; }
done
grep -x './addons/tables/cmd/tables CGO_ENABLED=1' "$work/gobuild.log" >/dev/null
-for id in voice face fine-tuning quantization; do
+for id in voice face fine-tuning quantization studio talk; do
grep -x "./addons/$id/cmd/$id CGO_ENABLED=0" "$work/gobuild.log" >/dev/null || { echo "FAIL: $id is not built with CGO_ENABLED=0" >&2; cat "$work/gobuild.log" >&2; exit 1; }
done
case_ok 'package.sh builds Tables with cgo and the pure Go add-ons without'
diff --git a/scripts/addons/suites.json b/scripts/addons/suites.json
index 3968a04ce..f259668e2 100644
--- a/scripts/addons/suites.json
+++ b/scripts/addons/suites.json
@@ -3292,6 +3292,8 @@
"package.sh refuses a foreign platform without an executable: face",
"package.sh refuses a foreign platform without an executable: fine-tuning",
"package.sh refuses a foreign platform without an executable: quantization",
+ "package.sh refuses a foreign platform without an executable: studio",
+ "package.sh refuses a foreign platform without an executable: talk",
"package.sh refuses a foreign platform without an executable for every add-on",
"package.sh builds Tables with cgo and the pure Go add-ons without",
"matrix fails closed while no platform is enabled",
diff --git a/swagger/docs.go b/swagger/docs.go
index ab46f9d33..2dc4ac1a6 100644
--- a/swagger/docs.go
+++ b/swagger/docs.go
@@ -476,7 +476,7 @@ const docTemplate = `{
},
"/api/addons/builtin": {
"get": {
- "description": "Returns the page add-ons that ship with LocalAI (Studio and Talk). Each entry carries only its own gate: the route needs an authenticated caller and no ` + "`" + `addons` + "`" + ` feature, so a user who reaches Studio today still reaches it from the Add-ons drawer.",
+ "description": "Returns the page add-ons that ship inside the web bundle. The list is empty today: Studio and Talk are packaged add-ons that a person installs from the Add-ons drawer. Each entry carries only its own gate: the route needs an authenticated caller and no ` + "`" + `addons` + "`" + ` feature.",
"produces": [
"application/json"
],
diff --git a/swagger/swagger.json b/swagger/swagger.json
index 96044d790..4415ed23f 100644
--- a/swagger/swagger.json
+++ b/swagger/swagger.json
@@ -473,7 +473,7 @@
},
"/api/addons/builtin": {
"get": {
- "description": "Returns the page add-ons that ship with LocalAI (Studio and Talk). Each entry carries only its own gate: the route needs an authenticated caller and no `addons` feature, so a user who reaches Studio today still reaches it from the Add-ons drawer.",
+ "description": "Returns the page add-ons that ship inside the web bundle. The list is empty today: Studio and Talk are packaged add-ons that a person installs from the Add-ons drawer. Each entry carries only its own gate: the route needs an authenticated caller and no `addons` feature.",
"produces": [
"application/json"
],
diff --git a/swagger/swagger.yaml b/swagger/swagger.yaml
index 0239d2773..2fd17daa9 100644
--- a/swagger/swagger.yaml
+++ b/swagger/swagger.yaml
@@ -6224,10 +6224,10 @@ paths:
- Addons
/api/addons/builtin:
get:
- description: 'Returns the page add-ons that ship with LocalAI (Studio and Talk).
- Each entry carries only its own gate: the route needs an authenticated caller
- and no `addons` feature, so a user who reaches Studio today still reaches
- it from the Add-ons drawer.'
+ description: 'Returns the page add-ons that ship inside the web bundle. The
+ list is empty today: Studio and Talk are packaged add-ons that a person installs
+ from the Add-ons drawer. Each entry carries only its own gate: the route needs
+ an authenticated caller and no `addons` feature.'
produces:
- application/json
responses: