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.]+ { + 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: