From c6bb76939b8765db33bddf5d3c37d013a56840ac Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Tue, 20 Oct 2026 14:57:27 +0000 Subject: [PATCH] feat(addons): let add-on pages keep the host modules they were written against A page written for the web UI imports React, the router, i18next and a few shared components. The add-on builder refuses a bundle that holds a host file, so moving such a page into an add-on meant rewriting it. Add a compat mode to the shared builder. It answers those imports from a directory of stand-ins that read the SDK, bundles the dynamic import of the page code into the one output file, and collects the style sheets the page imports. A relative import that is not in the add-on's own source resolves to the file at the same path under the compat directory. Widen the SDK for these pages, without a version change: more host components (spinner, modal, lightbox, model picker) and hooks (models, resources, voice profiles, operations, MCP client), a style on icons, the icon names the pages draw, sdk.navigation.openApp to open a page of the app (only addresses under /app, and the way back as the only state) and sdk.portal. The shell passes the query string to the page. The stand-ins cover the router (the tab in the path, the model in the query), the translator over the host locale files, the core API calls through sdk.core.fetch, and byte copies of the pure helpers, with tests that keep the copies equal to the originals. Assisted-by: Claude Code:claude-sonnet-5-5 node Signed-off-by: Ettore Di Giacinto --- addons/internal/host-compat/package.json | 8 + .../src/components/ConfirmDialog.jsx | 4 + .../host-compat/src/components/Icon.jsx | 8 + .../host-compat/src/components/Lightbox.jsx | 4 + .../src/components/LoadingSpinner.jsx | 4 + .../host-compat/src/components/Modal.jsx | 4 + .../src/components/ModelSelector.jsx | 4 + .../host-compat/src/components/PageHeader.jsx | 4 + .../src/components/audio/WaveformPlayer.jsx | 4 + .../src/components/biometrics/MediaInput.jsx | 4 + .../src/components/home/HomeModelPicker.jsx | 4 + .../host-compat/src/components/home/memory.js | 54 +++++ .../host-compat/src/context/AuthContext.jsx | 8 + .../host-compat/src/hooks/useAudioPeaks.js | 71 ++++++ .../host-compat/src/hooks/useMCPClient.js | 6 + .../host-compat/src/hooks/useMediaCapture.js | 10 + .../host-compat/src/hooks/useModels.js | 6 + .../host-compat/src/hooks/useObjectUrl.js | 6 + .../host-compat/src/hooks/useOperations.js | 6 + .../host-compat/src/hooks/useResources.js | 6 + .../host-compat/src/hooks/useVoiceProfiles.js | 6 + addons/internal/host-compat/src/i18n.js | 92 ++++++++ .../host-compat/src/modules/jsx-runtime.js | 10 + .../host-compat/src/modules/react-dom.js | 7 + .../host-compat/src/modules/react-i18next.js | 18 ++ .../src/modules/react-router-dom.js | 90 +++++++ .../internal/host-compat/src/modules/react.js | 10 + addons/internal/host-compat/src/route.js | 58 +++++ addons/internal/host-compat/src/runtime.js | 23 ++ addons/internal/host-compat/src/ui.js | 14 ++ addons/internal/host-compat/src/utils/api.js | 221 ++++++++++++++++++ .../host-compat/src/utils/basePath.js | 20 ++ .../host-compat/src/utils/capabilities.js | 37 +++ .../host-compat/src/utils/clipboard.js | 81 +++++++ .../internal/host-compat/src/utils/config.js | 172 ++++++++++++++ .../host-compat/src/utils/editorNav.js | 15 ++ .../internal/host-compat/src/utils/format.js | 74 ++++++ .../host-compat/src/utils/lastModel.js | 15 ++ .../host-compat/src/utils/mcpClientStorage.js | 92 ++++++++ .../host-compat/src/utils/modelBudget.js | 41 ++++ .../host-compat/src/utils/modelLedger.js | 113 +++++++++ addons/internal/host-compat/test/api.test.mjs | 127 ++++++++++ .../internal/host-compat/test/copies.test.mjs | 48 ++++ addons/internal/host-compat/test/host.mjs | 70 ++++++ .../internal/host-compat/test/i18n.test.mjs | 66 ++++++ addons/internal/host-compat/test/loader.mjs | 28 +++ addons/internal/host-compat/test/parity.mjs | 85 +++++++ addons/internal/host-compat/test/register.mjs | 5 + .../internal/host-compat/test/route.test.mjs | 40 ++++ .../internal/host-compat/test/router.test.mjs | 98 ++++++++ addons/internal/host-compat/test/run.mjs | 23 ++ .../compat/components/Badge.jsx | 1 + .../compat/modules/jsx-runtime.js | 4 + .../compat/modules/react-dom.js | 1 + .../compat/modules/react-i18next.js | 1 + .../compat/modules/react-router-dom.js | 1 + .../fixture-compat/compat/modules/react.js | 1 + .../host-compat-fixture-runtime.js | 1 + .../uibuild/test/fixture-compat/src/Page.jsx | 10 + .../uibuild/test/fixture-compat/src/index.jsx | 11 + .../uibuild/test/fixture-compat/src/page.css | 1 + .../uibuild/test/fixture-compat/src/style.css | 1 + addons/internal/uibuild/test/uibuild.test.mjs | 31 +++ addons/internal/uibuild/uibuild.mjs | 76 +++++- core/http/react-ui/src/addons/AddonRoute.jsx | 6 +- core/http/react-ui/src/addons/hostServices.js | 17 +- core/http/react-ui/src/addons/sdk.js | 55 ++++- core/http/react-ui/tests/unit/esm.test.mjs | 2 +- core/http/react-ui/tests/unit/sdkv2.test.mjs | 59 ++++- 69 files changed, 2288 insertions(+), 15 deletions(-) create mode 100644 addons/internal/host-compat/package.json create mode 100644 addons/internal/host-compat/src/components/ConfirmDialog.jsx create mode 100644 addons/internal/host-compat/src/components/Icon.jsx create mode 100644 addons/internal/host-compat/src/components/Lightbox.jsx create mode 100644 addons/internal/host-compat/src/components/LoadingSpinner.jsx create mode 100644 addons/internal/host-compat/src/components/Modal.jsx create mode 100644 addons/internal/host-compat/src/components/ModelSelector.jsx create mode 100644 addons/internal/host-compat/src/components/PageHeader.jsx create mode 100644 addons/internal/host-compat/src/components/audio/WaveformPlayer.jsx create mode 100644 addons/internal/host-compat/src/components/biometrics/MediaInput.jsx create mode 100644 addons/internal/host-compat/src/components/home/HomeModelPicker.jsx create mode 100644 addons/internal/host-compat/src/components/home/memory.js create mode 100644 addons/internal/host-compat/src/context/AuthContext.jsx create mode 100644 addons/internal/host-compat/src/hooks/useAudioPeaks.js create mode 100644 addons/internal/host-compat/src/hooks/useMCPClient.js create mode 100644 addons/internal/host-compat/src/hooks/useMediaCapture.js create mode 100644 addons/internal/host-compat/src/hooks/useModels.js create mode 100644 addons/internal/host-compat/src/hooks/useObjectUrl.js create mode 100644 addons/internal/host-compat/src/hooks/useOperations.js create mode 100644 addons/internal/host-compat/src/hooks/useResources.js create mode 100644 addons/internal/host-compat/src/hooks/useVoiceProfiles.js create mode 100644 addons/internal/host-compat/src/i18n.js create mode 100644 addons/internal/host-compat/src/modules/jsx-runtime.js create mode 100644 addons/internal/host-compat/src/modules/react-dom.js create mode 100644 addons/internal/host-compat/src/modules/react-i18next.js create mode 100644 addons/internal/host-compat/src/modules/react-router-dom.js create mode 100644 addons/internal/host-compat/src/modules/react.js create mode 100644 addons/internal/host-compat/src/route.js create mode 100644 addons/internal/host-compat/src/runtime.js create mode 100644 addons/internal/host-compat/src/ui.js create mode 100644 addons/internal/host-compat/src/utils/api.js create mode 100644 addons/internal/host-compat/src/utils/basePath.js create mode 100644 addons/internal/host-compat/src/utils/capabilities.js create mode 100644 addons/internal/host-compat/src/utils/clipboard.js create mode 100644 addons/internal/host-compat/src/utils/config.js create mode 100644 addons/internal/host-compat/src/utils/editorNav.js create mode 100644 addons/internal/host-compat/src/utils/format.js create mode 100644 addons/internal/host-compat/src/utils/lastModel.js create mode 100644 addons/internal/host-compat/src/utils/mcpClientStorage.js create mode 100644 addons/internal/host-compat/src/utils/modelBudget.js create mode 100644 addons/internal/host-compat/src/utils/modelLedger.js create mode 100644 addons/internal/host-compat/test/api.test.mjs create mode 100644 addons/internal/host-compat/test/copies.test.mjs create mode 100644 addons/internal/host-compat/test/host.mjs create mode 100644 addons/internal/host-compat/test/i18n.test.mjs create mode 100644 addons/internal/host-compat/test/loader.mjs create mode 100644 addons/internal/host-compat/test/parity.mjs create mode 100644 addons/internal/host-compat/test/register.mjs create mode 100644 addons/internal/host-compat/test/route.test.mjs create mode 100644 addons/internal/host-compat/test/router.test.mjs create mode 100644 addons/internal/host-compat/test/run.mjs create mode 100644 addons/internal/uibuild/test/fixture-compat/compat/components/Badge.jsx create mode 100644 addons/internal/uibuild/test/fixture-compat/compat/modules/jsx-runtime.js create mode 100644 addons/internal/uibuild/test/fixture-compat/compat/modules/react-dom.js create mode 100644 addons/internal/uibuild/test/fixture-compat/compat/modules/react-i18next.js create mode 100644 addons/internal/uibuild/test/fixture-compat/compat/modules/react-router-dom.js create mode 100644 addons/internal/uibuild/test/fixture-compat/compat/modules/react.js create mode 100644 addons/internal/uibuild/test/fixture-compat/host-compat-fixture-runtime.js create mode 100644 addons/internal/uibuild/test/fixture-compat/src/Page.jsx create mode 100644 addons/internal/uibuild/test/fixture-compat/src/index.jsx create mode 100644 addons/internal/uibuild/test/fixture-compat/src/page.css create mode 100644 addons/internal/uibuild/test/fixture-compat/src/style.css diff --git a/addons/internal/host-compat/package.json b/addons/internal/host-compat/package.json new file mode 100644 index 000000000..64c2bb393 --- /dev/null +++ b/addons/internal/host-compat/package.json @@ -0,0 +1,8 @@ +{ + "name": "localai-addon-host-compat", + "private": true, + "version": "0.1.0", + "description": "Stand-ins for the host modules that the Studio and Talk pages were written against; each answers through the SDK.", + "type": "module", + "scripts": { "test": "node test/run.mjs" } +} diff --git a/addons/internal/host-compat/src/components/ConfirmDialog.jsx b/addons/internal/host-compat/src/components/ConfirmDialog.jsx new file mode 100644 index 000000000..08fb46a54 --- /dev/null +++ b/addons/internal/host-compat/src/components/ConfirmDialog.jsx @@ -0,0 +1,4 @@ +import { fromSdk } from '../ui.js' + +// The host's ConfirmDialog, through the SDK. +export default fromSdk('ConfirmDialog') diff --git a/addons/internal/host-compat/src/components/Icon.jsx b/addons/internal/host-compat/src/components/Icon.jsx new file mode 100644 index 000000000..d737e49ab --- /dev/null +++ b/addons/internal/host-compat/src/components/Icon.jsx @@ -0,0 +1,8 @@ +import { getSdk } from '../runtime.js' + +// The host's icon, through the SDK. A name the SDK does not list draws the +// neutral glyph (see ICON_NAMES in the host SDK). +export default function Icon(props) { + const Host = getSdk().Icon + return +} diff --git a/addons/internal/host-compat/src/components/Lightbox.jsx b/addons/internal/host-compat/src/components/Lightbox.jsx new file mode 100644 index 000000000..7b75847ed --- /dev/null +++ b/addons/internal/host-compat/src/components/Lightbox.jsx @@ -0,0 +1,4 @@ +import { fromSdk } from '../ui.js' + +// The host's Lightbox, through the SDK. +export default fromSdk('Lightbox') diff --git a/addons/internal/host-compat/src/components/LoadingSpinner.jsx b/addons/internal/host-compat/src/components/LoadingSpinner.jsx new file mode 100644 index 000000000..fbad9c541 --- /dev/null +++ b/addons/internal/host-compat/src/components/LoadingSpinner.jsx @@ -0,0 +1,4 @@ +import { fromSdk } from '../ui.js' + +// The host's LoadingSpinner, through the SDK. +export default fromSdk('LoadingSpinner') diff --git a/addons/internal/host-compat/src/components/Modal.jsx b/addons/internal/host-compat/src/components/Modal.jsx new file mode 100644 index 000000000..17fa71bd4 --- /dev/null +++ b/addons/internal/host-compat/src/components/Modal.jsx @@ -0,0 +1,4 @@ +import { fromSdk } from '../ui.js' + +// The host's Modal, through the SDK. +export default fromSdk('Modal') diff --git a/addons/internal/host-compat/src/components/ModelSelector.jsx b/addons/internal/host-compat/src/components/ModelSelector.jsx new file mode 100644 index 000000000..22e4b38a1 --- /dev/null +++ b/addons/internal/host-compat/src/components/ModelSelector.jsx @@ -0,0 +1,4 @@ +import { fromSdk } from '../ui.js' + +// The host's ModelSelector, through the SDK. +export default fromSdk('ModelSelector') diff --git a/addons/internal/host-compat/src/components/PageHeader.jsx b/addons/internal/host-compat/src/components/PageHeader.jsx new file mode 100644 index 000000000..00dd95945 --- /dev/null +++ b/addons/internal/host-compat/src/components/PageHeader.jsx @@ -0,0 +1,4 @@ +import { fromSdk } from '../ui.js' + +// The host's PageHeader, through the SDK. +export default fromSdk('PageHeader') diff --git a/addons/internal/host-compat/src/components/audio/WaveformPlayer.jsx b/addons/internal/host-compat/src/components/audio/WaveformPlayer.jsx new file mode 100644 index 000000000..f2cf9d53b --- /dev/null +++ b/addons/internal/host-compat/src/components/audio/WaveformPlayer.jsx @@ -0,0 +1,4 @@ +import { fromSdk } from '../../ui.js' + +// The host's WaveformPlayer, through the SDK. +export default fromSdk('WaveformPlayer') diff --git a/addons/internal/host-compat/src/components/biometrics/MediaInput.jsx b/addons/internal/host-compat/src/components/biometrics/MediaInput.jsx new file mode 100644 index 000000000..f93aa25b5 --- /dev/null +++ b/addons/internal/host-compat/src/components/biometrics/MediaInput.jsx @@ -0,0 +1,4 @@ +import { fromSdk } from '../../ui.js' + +// The host's MediaInput, through the SDK. +export default fromSdk('MediaInput') diff --git a/addons/internal/host-compat/src/components/home/HomeModelPicker.jsx b/addons/internal/host-compat/src/components/home/HomeModelPicker.jsx new file mode 100644 index 000000000..000347eb6 --- /dev/null +++ b/addons/internal/host-compat/src/components/home/HomeModelPicker.jsx @@ -0,0 +1,4 @@ +import { fromSdk } from '../../ui.js' + +// The host's HomeModelPicker, through the SDK. It keeps its ref handle. +export default fromSdk('HomeModelPicker') diff --git a/addons/internal/host-compat/src/components/home/memory.js b/addons/internal/host-compat/src/components/home/memory.js new file mode 100644 index 000000000..314d9d73d --- /dev/null +++ b/addons/internal/host-compat/src/components/home/memory.js @@ -0,0 +1,54 @@ +// Memory figures for the Home status strip. + +const K = 1024 +const UNITS = ['B', 'KB', 'MB', 'GB', 'TB'] + +function trim(value) { + return String(parseFloat(value.toFixed(1))) +} + +// "8.8 / 24 GB": both numbers in the unit of the total, so the pair reads at a +// glance and the unit is written once. +export function memoryFigure(used, total) { + if (!(total > 0)) return null + const i = Math.min(UNITS.length - 1, Math.max(0, Math.floor(Math.log(total) / Math.log(K)))) + const scale = Math.pow(K, i) + return { used: trim((used || 0) / scale), total: trim(total / scale), unit: UNITS[i] } +} + +export function percent(used, total) { + if (!(total > 0)) return 0 + return Math.max(0, Math.min(100, (used / total) * 100)) +} + +// CSS custom property for a bar's fill, so the width lives in the stylesheet. +export function fillStyle(pct) { + return { '--home-fill': `${pct}%` } +} + +// What the strip reads for this host: the unified resources reading, which is +// GPU memory when a GPU is present and system RAM otherwise. +export function hostMemory(resources) { + if (!resources) return null + const aggregate = resources.aggregate || {} + const gpus = Array.isArray(resources.gpus) ? resources.gpus : [] + const isGpu = resources.type === 'gpu' && gpus.length > 0 + const total = aggregate.total_memory || (isGpu ? 0 : resources.ram?.total) || 0 + const used = aggregate.used_memory ?? (isGpu ? 0 : resources.ram?.used) ?? 0 + if (!(total > 0)) return null + const ram = resources.ram && resources.ram.total > 0 ? { used: resources.ram.used || 0, total: resources.ram.total } : null + return { + isGpu, + used, + total, + pct: aggregate.usage_percent != null ? aggregate.usage_percent : percent(used, total), + gpuCount: gpus.length, + device: isGpu ? (gpus[0]?.name || '') : '', + ram, + } +} + +// CSS custom property for the undo toast's countdown bar. +export function undoStyle(ms) { + return { '--dk-undo': `${ms / 1000}s` } +} diff --git a/addons/internal/host-compat/src/context/AuthContext.jsx b/addons/internal/host-compat/src/context/AuthContext.jsx new file mode 100644 index 000000000..cd772b7b8 --- /dev/null +++ b/addons/internal/host-compat/src/context/AuthContext.jsx @@ -0,0 +1,8 @@ +import { getSdk } from '../runtime.js' + +// What the pages read of the signed-in person: the role and the feature +// flags, from the SDK's viewer. The server still decides every call. +export function useAuth() { + const { viewer } = getSdk() + return { isAdmin: viewer.isAdmin(), hasFeature: (name) => viewer.hasFeature(name) } +} diff --git a/addons/internal/host-compat/src/hooks/useAudioPeaks.js b/addons/internal/host-compat/src/hooks/useAudioPeaks.js new file mode 100644 index 000000000..88ebea2dd --- /dev/null +++ b/addons/internal/host-compat/src/hooks/useAudioPeaks.js @@ -0,0 +1,71 @@ +import { useEffect, useState } from 'react' + +// Module-scoped lazy AudioContext: WaveformPlayer / WaveformStrip / Strip can +// all coexist on a single page (the AudioTransform page mounts three at once) +// and most browsers cap concurrent AudioContexts at ~6. Keep one alive for +// the lifetime of the tab and reuse it across decodes. +let sharedCtx = null +export function getSharedAudioContext() { + if (sharedCtx) return sharedCtx + const Ctx = window.AudioContext || window.webkitAudioContext + if (!Ctx) return null + sharedCtx = new Ctx() + return sharedCtx +} + +// useAudioPeaks — decode an audio source (data URL, blob URL, or http URL) +// into a mono peak array suitable for canvas waveform rendering. Returns +// `{ peaks, duration, error, loading }`. Safe under rapid src changes — +// in-flight decodes are cancelled. +export default function useAudioPeaks(src, buckets = 480) { + const [peaks, setPeaks] = useState(null) + const [duration, setDuration] = useState(0) + const [error, setError] = useState(null) + const [loading, setLoading] = useState(false) + + useEffect(() => { + setPeaks(null) + setDuration(0) + setError(null) + setLoading(false) + if (!src) return + let cancelled = false + setLoading(true) + + async function decode() { + try { + const response = await fetch(src) + const buf = await response.arrayBuffer() + const ctx = getSharedAudioContext() + if (!ctx) throw new Error('Web Audio API not available') + const audioBuf = await ctx.decodeAudioData(buf.slice(0)) + if (cancelled) return + const data = audioBuf.getChannelData(0) + const step = Math.max(1, Math.floor(data.length / buckets)) + const result = new Float32Array(buckets) + for (let i = 0; i < buckets; i++) { + let peak = 0 + const start = i * step + const end = Math.min(start + step, data.length) + for (let j = start; j < end; j++) { + const v = Math.abs(data[j]) + if (v > peak) peak = v + } + result[i] = peak + } + setPeaks(result) + setDuration(audioBuf.duration) + setLoading(false) + } catch (e) { + if (!cancelled) { + setError(e?.message || 'Could not decode audio') + setLoading(false) + } + } + } + decode() + return () => { cancelled = true } + }, [src, buckets]) + + return { peaks, duration, error, loading } +} diff --git a/addons/internal/host-compat/src/hooks/useMCPClient.js b/addons/internal/host-compat/src/hooks/useMCPClient.js new file mode 100644 index 000000000..7f520b004 --- /dev/null +++ b/addons/internal/host-compat/src/hooks/useMCPClient.js @@ -0,0 +1,6 @@ +import { getSdk } from '../runtime.js' + +// The host's useMCPClient, through the SDK. +export function useMCPClient(...args) { + return getSdk().hooks.useMCPClient(...args) +} diff --git a/addons/internal/host-compat/src/hooks/useMediaCapture.js b/addons/internal/host-compat/src/hooks/useMediaCapture.js new file mode 100644 index 000000000..9fe15cf0b --- /dev/null +++ b/addons/internal/host-compat/src/hooks/useMediaCapture.js @@ -0,0 +1,10 @@ +import { getSdk } from '../runtime.js' + +// The host's useMediaCapture, through the SDK. +export function useMediaCapture(...args) { + return getSdk().hooks.useMediaCapture(...args) +} + +export function audioBufferToWavBlob(...args) { + return getSdk().hooks.audioBufferToWavBlob(...args) +} diff --git a/addons/internal/host-compat/src/hooks/useModels.js b/addons/internal/host-compat/src/hooks/useModels.js new file mode 100644 index 000000000..1cb892bab --- /dev/null +++ b/addons/internal/host-compat/src/hooks/useModels.js @@ -0,0 +1,6 @@ +import { getSdk } from '../runtime.js' + +// The host's useModels, through the SDK. +export function useModels(...args) { + return getSdk().hooks.useModels(...args) +} diff --git a/addons/internal/host-compat/src/hooks/useObjectUrl.js b/addons/internal/host-compat/src/hooks/useObjectUrl.js new file mode 100644 index 000000000..2430d897c --- /dev/null +++ b/addons/internal/host-compat/src/hooks/useObjectUrl.js @@ -0,0 +1,6 @@ +import { getSdk } from '../runtime.js' + +// The host's useObjectUrl, through the SDK. +export default function useObjectUrl(source) { + return getSdk().hooks.useObjectUrl(source) +} diff --git a/addons/internal/host-compat/src/hooks/useOperations.js b/addons/internal/host-compat/src/hooks/useOperations.js new file mode 100644 index 000000000..999de6d67 --- /dev/null +++ b/addons/internal/host-compat/src/hooks/useOperations.js @@ -0,0 +1,6 @@ +import { getSdk } from '../runtime.js' + +// The host's useOperations, through the SDK. +export function useOperations(...args) { + return getSdk().hooks.useOperations(...args) +} diff --git a/addons/internal/host-compat/src/hooks/useResources.js b/addons/internal/host-compat/src/hooks/useResources.js new file mode 100644 index 000000000..05547f2e8 --- /dev/null +++ b/addons/internal/host-compat/src/hooks/useResources.js @@ -0,0 +1,6 @@ +import { getSdk } from '../runtime.js' + +// The host's useResources, through the SDK. +export function useResources(...args) { + return getSdk().hooks.useResources(...args) +} diff --git a/addons/internal/host-compat/src/hooks/useVoiceProfiles.js b/addons/internal/host-compat/src/hooks/useVoiceProfiles.js new file mode 100644 index 000000000..c225583b0 --- /dev/null +++ b/addons/internal/host-compat/src/hooks/useVoiceProfiles.js @@ -0,0 +1,6 @@ +import { getSdk } from '../runtime.js' + +// The host's useVoiceProfiles, through the SDK. +export function useVoiceProfiles(...args) { + return getSdk().hooks.useVoiceProfiles(...args) +} diff --git a/addons/internal/host-compat/src/i18n.js b/addons/internal/host-compat/src/i18n.js new file mode 100644 index 000000000..8228f7496 --- /dev/null +++ b/addons/internal/host-compat/src/i18n.js @@ -0,0 +1,92 @@ +// A small i18next-compatible translator, for add-ons that carry the host's own +// locale files. It reads the same JSON shape (nested keys, `_one`/`_other` +// plural keys, `{{name}}` placeholders) and the same call forms, so page code +// written for react-i18next runs unchanged. No React in this file. + +const resources = new Map() // language -> namespace -> object + +// registerLocales({ media: { en: {...}, it: {...} }, talk: { en: {...} } }) +export function registerLocales(byNamespace) { + for (const [ns, languages] of Object.entries(byNamespace)) { + for (const [lng, data] of Object.entries(languages)) { + if (!resources.has(lng)) resources.set(lng, new Map()) + resources.get(lng).set(ns, data) + } + } +} + +export function clearLocales() { + resources.clear() +} + +function candidates(language) { + const lang = String(language || 'en') + const out = [lang] + const base = lang.split('-')[0] + if (base !== lang) out.push(base) + if (!out.includes('en')) out.push('en') + return out +} + +function walk(data, key) { + if (!data || typeof data !== 'object') return undefined + if (Object.prototype.hasOwnProperty.call(data, key)) return data[key] + let node = data + for (const part of key.split('.')) { + if (node === null || typeof node !== 'object' || !Object.prototype.hasOwnProperty.call(node, part)) return undefined + node = node[part] + } + return node +} + +function pluralSuffix(language, count) { + try { + return new Intl.PluralRules(language).select(count) + } catch { + return count === 1 ? 'one' : 'other' + } +} + +function lookup(language, namespaces, key, count) { + for (const lng of candidates(language)) { + const byNs = resources.get(lng) + if (!byNs) continue + for (const ns of namespaces) { + const data = byNs.get(ns) + if (!data) continue + if (typeof count === 'number') { + for (const suffix of [pluralSuffix(lng, count), 'other']) { + const hit = walk(data, `${key}_${suffix}`) + if (hit !== undefined) return hit + } + } + const hit = walk(data, key) + if (hit !== undefined) return hit + } + } + return undefined +} + +function interpolate(text, vars) { + return text.replace(/\{\{\s*-?\s*([A-Za-z0-9_.]+)\s*\}\}/g, (match, name) => ( + vars && Object.prototype.hasOwnProperty.call(vars, name) && vars[name] !== undefined ? String(vars[name]) : match + )) +} + +// translator(language, namespaces) returns t(key, defaultOrOptions, options). +export function translator(language, namespaces) { + const list = (Array.isArray(namespaces) ? namespaces : [namespaces || 'common']).filter(Boolean) + return function t(key, second, third) { + const options = typeof second === 'object' && second !== null ? second : (third || {}) + const fallback = typeof second === 'string' ? second : options.defaultValue + let name = String(key) + let spaces = list + const colon = name.indexOf(':') + if (colon > 0) { spaces = [name.slice(0, colon)]; name = name.slice(colon + 1) } + const count = typeof options.count === 'number' ? options.count : undefined + const found = lookup(language, spaces, name, count) + if (found !== undefined && typeof found === 'object') return options.returnObjects ? found : (fallback ?? name) + const text = typeof found === 'string' ? found : (typeof fallback === 'string' ? fallback : name) + return interpolate(text, options) + } +} diff --git a/addons/internal/host-compat/src/modules/jsx-runtime.js b/addons/internal/host-compat/src/modules/jsx-runtime.js new file mode 100644 index 000000000..37d28246c --- /dev/null +++ b/addons/internal/host-compat/src/modules/jsx-runtime.js @@ -0,0 +1,10 @@ +// Stand-in for `react/jsx-runtime`: JSX becomes createElement of the host React. +import { getSdk } from '../runtime.js' + +const React = getSdk().React +export const Fragment = React.Fragment +export function jsx(type, props, key) { + return key === undefined ? React.createElement(type, props) : React.createElement(type, { ...props, key }) +} +export const jsxs = jsx +export const jsxDEV = jsx diff --git a/addons/internal/host-compat/src/modules/react-dom.js b/addons/internal/host-compat/src/modules/react-dom.js new file mode 100644 index 000000000..35d922f32 --- /dev/null +++ b/addons/internal/host-compat/src/modules/react-dom.js @@ -0,0 +1,7 @@ +// Stand-in for `react-dom`: only the portal, through the SDK. +import { getSdk } from '../runtime.js' + +export function createPortal(children, node) { + return getSdk().portal(children, node) +} +export default { createPortal } diff --git a/addons/internal/host-compat/src/modules/react-i18next.js b/addons/internal/host-compat/src/modules/react-i18next.js new file mode 100644 index 000000000..ddba6bc64 --- /dev/null +++ b/addons/internal/host-compat/src/modules/react-i18next.js @@ -0,0 +1,18 @@ +// Stand-in for `react-i18next`: useTranslation over the SDK's language and the +// locale files the add-on registered (see ../i18n.js). +import { getSdk } from '../runtime.js' +import { translator } from '../i18n.js' + +const { useEffect, useMemo, useState } = getSdk().React + +export function useTranslation(namespaces) { + const sdk = getSdk() + const [language, setLanguage] = useState(() => sdk.locale.language()) + useEffect(() => { + setLanguage(sdk.locale.language()) + return sdk.locale.subscribe((lng) => setLanguage(String(lng || 'en'))) + }, [sdk]) + const key = Array.isArray(namespaces) ? namespaces.join('|') : String(namespaces || '') + const t = useMemo(() => translator(language, Array.isArray(namespaces) ? namespaces : [namespaces]), [language, key]) // eslint-disable-line react-hooks/exhaustive-deps + return { t, i18n: { language, changeLanguage: async () => {} }, ready: true } +} diff --git a/addons/internal/host-compat/src/modules/react-router-dom.js b/addons/internal/host-compat/src/modules/react-router-dom.js new file mode 100644 index 000000000..7657ec2a9 --- /dev/null +++ b/addons/internal/host-compat/src/modules/react-router-dom.js @@ -0,0 +1,90 @@ +// Stand-in for `react-router-dom`: the hooks and the link the Studio and Talk +// pages use, over the SDK's navigation. An address under /app that is not a page +// of this add-on is opened with sdk.navigation.openApp; the pages of this +// add-on are reached with sdk.navigation.navigate. See ../route.js. +import { getSdk } from '../runtime.js' +import { paramsOf, resolveTarget } from '../route.js' +import { basePath } from '../utils/basePath.js' + +const React = getSdk().React +const { createContext, createElement, useCallback, useContext, useMemo } = React + +const RouteContext = createContext({ page: '', path: '', search: '' }) + +// The add-on wraps its page in this, with what the shell passed it. +export const RouteProvider = RouteContext.Provider + +function go(to, options = {}) { + const sdk = getSdk() + const target = resolveTarget(to, sdk.addonId) + if (!target) throw new Error(`Cannot open ${String(to)}`) + if (target.own) { + sdk.navigation.navigate(target.subpath, { replace: options.replace === true }) + } else { + sdk.navigation.openApp(target.address, { replace: options.replace === true, state: options.state }) + } +} + +function hrefOf(to) { + const sdk = getSdk() + const target = resolveTarget(to, sdk.addonId) + if (!target) return String(to) + return basePath + (target.own ? sdk.navigation.href(target.subpath) : target.address) +} + +export function useNavigate() { + return useCallback((to, options) => { + if (typeof to === 'number') { window.history.go(to); return } + go(to, options) + }, []) +} + +export function useParams() { + const route = useContext(RouteContext) + return useMemo(() => paramsOf(route), [route]) +} + +export function useSearchParams() { + const route = useContext(RouteContext) + const params = useMemo(() => new URLSearchParams(route.search), [route.search]) + const setSearchParams = useCallback((next, options) => { + const sdk = getSdk() + const value = typeof next === 'function' ? next(new URLSearchParams(route.search)) : next + const query = new URLSearchParams(value).toString() + const base = [route.page, ...String(route.path || '').split('/').filter(Boolean)].join('/') + sdk.navigation.navigate(`${base}${query ? `?${query}` : ''}`, { replace: options?.replace === true }) + }, [route.page, route.path, route.search]) + return [params, setSearchParams] +} + +export function useLocation() { + const route = useContext(RouteContext) + return useMemo(() => { + const sdk = getSdk() + const base = [route.page, ...String(route.path || '').split('/').filter(Boolean)].join('/') + return { pathname: base ? sdk.navigation.href(base) : '', search: route.search ? `?${route.search}` : '', hash: '', state: null } + }, [route]) +} + +// Pages call addToast(message, kind) on the host layout's outlet context. +export function useOutletContext() { + return useMemo(() => ({ + addToast: (message, kind = 'info') => { + const notify = getSdk().notify + ;(notify[kind] || notify.info)(String(message)) + }, + }), []) +} + +export function Link({ to, replace, state, onClick, children, ...rest }) { + return createElement('a', { + ...rest, + href: hrefOf(to), + onClick: (event) => { + onClick?.(event) + if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button !== 0) return + event.preventDefault() + go(to, { replace, state }) + }, + }, children) +} diff --git a/addons/internal/host-compat/src/modules/react.js b/addons/internal/host-compat/src/modules/react.js new file mode 100644 index 000000000..c202679f3 --- /dev/null +++ b/addons/internal/host-compat/src/modules/react.js @@ -0,0 +1,10 @@ +// Stand-in for `react`: the React of the host, handed over by the SDK. +import { getSdk } from '../runtime.js' + +const React = getSdk().React +export default React +export const { + Children, Fragment, Suspense, cloneElement, createContext, createElement, forwardRef, isValidElement, lazy, memo, + startTransition, useCallback, useContext, useDeferredValue, useEffect, useId, useImperativeHandle, useLayoutEffect, + useMemo, useReducer, useRef, useState, useSyncExternalStore, useTransition, +} = React diff --git a/addons/internal/host-compat/src/route.js b/addons/internal/host-compat/src/route.js new file mode 100644 index 000000000..2228353c6 --- /dev/null +++ b/addons/internal/host-compat/src/route.js @@ -0,0 +1,58 @@ +// Turns the addresses the Studio and Talk pages were written with +// (/app/studio/images/?work=..) into the addresses an add-on may use. +// Pure: no React, no SDK, so a node test drives it directly. +// +// An add-on page has one flat path under /app/addons//, and its +// segments cannot hold "/" or ":" , so the model id travels as ?model= and +// the one path segment that remains is the tab. + +// The pages that used to be core routes and are add-on pages now. +export const MOVED = Object.freeze([ + { prefix: '/app/studio', addon: 'studio', page: 'studio' }, + { prefix: '/app/talk', addon: 'talk', page: 'talk' }, +]) + +export function parseAddress(to) { + let rest = String(to ?? '') + let hash = '' + const h = rest.indexOf('#') + if (h >= 0) { hash = rest.slice(h); rest = rest.slice(0, h) } + let search = '' + const q = rest.indexOf('?') + if (q >= 0) { search = rest.slice(q + 1); rest = rest.slice(0, q) } + return { pathname: rest, search, hash } +} + +function decode(segment) { + try { return decodeURIComponent(segment) } catch { return segment } +} + +// resolveTarget says where an address leads from the add-on `addonId`: +// { own: true, subpath } a page of this add-on ("studio/images?model=x") +// { own: false, address } anything else under /app, as an address +// null not an address of the app +export function resolveTarget(to, addonId) { + const { pathname, search, hash } = parseAddress(to) + const trimmed = pathname.replace(/\/+$/, '') + const moved = MOVED.find((m) => trimmed === m.prefix || trimmed.startsWith(`${m.prefix}/`)) + if (moved) { + const segments = trimmed.slice(moved.prefix.length).split('/').filter(Boolean).map(decode) + const params = new URLSearchParams(search) + if (segments[1]) params.set('model', segments[1]) + const query = params.toString() + const tab = segments[0] ? `/${encodeURIComponent(segments[0])}` : '' + if (moved.addon === addonId) return { own: true, subpath: `${moved.page}${tab}${query ? `?${query}` : ''}` } + return { own: false, address: `/app/addons/${moved.addon}/${moved.page}${tab}${query ? `?${query}` : ''}${hash}` } + } + if (trimmed === '/app' || trimmed.startsWith('/app/')) return { own: false, address: `${trimmed}${search ? `?${search}` : ''}${hash}` } + return null +} + +// What a page reads from its address: the tab is the first path segment and +// the model comes from ?model= (or, for an address typed by hand, the second +// path segment). +export function paramsOf({ path = '', search = '' }) { + const segments = String(path).split('/').filter(Boolean).map(decode) + const model = new URLSearchParams(search).get('model') || segments[1] || undefined + return { tab: segments[0] || undefined, model } +} diff --git a/addons/internal/host-compat/src/runtime.js b/addons/internal/host-compat/src/runtime.js new file mode 100644 index 000000000..adb400372 --- /dev/null +++ b/addons/internal/host-compat/src/runtime.js @@ -0,0 +1,23 @@ +// The one place the stand-ins of host modules find the SDK of the add-on that +// is running. An add-on calls setSdk(sdk) in createAddon, before it loads its +// page code (a dynamic import), so no stand-in reads the SDK while the bundle +// is still being evaluated. +let current = null + +export function setSdk(sdk) { + current = sdk +} + +export function getSdk() { + if (!current) throw new Error('The host SDK is not set yet') + return current +} + +// Fails early, and readably, when the host is older than the add-on needs. +export function requireHost(sdk, names) { + const missing = names.filter((n) => { + const [group, key] = n.split('.') + return !sdk[group] || typeof sdk[group][key] === 'undefined' + }) + if (missing.length > 0) throw new Error(`This add-on needs a newer LocalAI: the host has no ${missing.join(', ')}`) +} diff --git a/addons/internal/host-compat/src/ui.js b/addons/internal/host-compat/src/ui.js new file mode 100644 index 000000000..4b777f146 --- /dev/null +++ b/addons/internal/host-compat/src/ui.js @@ -0,0 +1,14 @@ +// A host component reached through the SDK, as a component the page code can +// import by the name it knew. It is looked up when it is drawn, so the SDK that +// is current at that moment answers; a ref reaches the host component. +import { getSdk } from './runtime.js' + +export function fromSdk(name) { + const { React } = getSdk() + const Component = React.forwardRef((props, ref) => { + const Host = getSdk().ui[name] + return React.createElement(Host, ref ? { ...props, ref } : props) + }) + Component.displayName = `Host(${name})` + return Component +} diff --git a/addons/internal/host-compat/src/utils/api.js b/addons/internal/host-compat/src/utils/api.js new file mode 100644 index 000000000..e273f7248 --- /dev/null +++ b/addons/internal/host-compat/src/utils/api.js @@ -0,0 +1,221 @@ +// The core API calls the Studio and Talk pages make, reached with the +// signed-in person's own session (sdk.core.fetch). The shapes are the ones of +// core/http/react-ui/src/utils/api.js (the bodies, the endpoints from +// utils/config.js and the error handling); only the transport changed. +import { API_CONFIG } from './config.js' +import { apiUrl } from './basePath.js' +import { getSdk } from '../runtime.js' + +const enc = encodeURIComponent + +// A call to a core route: the SDK fetch, or a plain fetch for an address the +// SDK does not take (a blob: or data: URL, a full address). +async function coreFetch(path, init) { + return getSdk().core.fetch(path, init) +} + +// Reads a file the core serves (a generated image, audio or mesh) or a local +// blob. The result is the fetch response. +export async function fetchUrl(url, init) { + const text = String(url) + if (text.startsWith('/')) { + const base = apiUrl('') + return coreFetch(base && text.startsWith(`${base}/`) ? text.slice(base.length) : text, init) + } + return fetch(text, init) +} + +async function handleResponse(response) { + if (!response.ok) { + let errorMessage = `HTTP ${response.status}` + let errorBody = null + try { + const data = await response.json() + errorBody = data + if (data?.error?.message) errorMessage = data.error.message + else if (data?.error) errorMessage = data.error + } catch (_e) { + // response wasn't JSON + } + const err = new Error(errorMessage) + err.status = response.status + err.body = errorBody + throw err + } + const contentType = response.headers.get('content-type') + if (contentType && contentType.includes('application/json')) { + return response.json() + } + return response +} + +function withParams(endpoint, params) { + if (!params) return endpoint + const q = new URLSearchParams() + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined && value !== null && value !== '') q.set(key, value) + }) + const text = q.toString() + return text ? `${endpoint}?${text}` : endpoint +} + +async function fetchJSON(endpoint, options = {}) { + const response = await coreFetch(endpoint, { + headers: { 'Content-Type': 'application/json', ...options.headers }, + ...options, + }) + return handleResponse(response) +} + +async function postJSON(endpoint, body, options = {}) { + return fetchJSON(endpoint, { + method: 'POST', + body: JSON.stringify(body), + ...options, + }) +} + +export const modelsApi = { + list: (params) => fetchJSON(withParams(API_CONFIG.endpoints.models, params)), + // variant is optional. Omitting it lets the server auto-select the best + // build for this host, which is what the listing's auto_variant predicted. + install: (id, variant) => postJSON( + variant + ? `${API_CONFIG.endpoints.installModel(id)}?variant=${enc(variant)}` + : API_CONFIG.endpoints.installModel(id), + {}, + ), + estimate: (id, contexts) => fetchJSON( + withParams(API_CONFIG.endpoints.modelEstimate(id), contexts?.length ? { contexts: contexts.join(',') } : {}), + ), +} + +export const imageApi = { + upscale: async ({ model, image, scale }) => { + const form = new FormData() + form.append('model', model) + form.append('image', image) + form.append('scale', String(scale)) + return handleResponse(await coreFetch('/v1/images/upscale', { method: 'POST', body: form })) + }, + generate: (body) => postJSON(API_CONFIG.endpoints.imageGenerations, body), +} + +export const videoApi = { + generate: (body) => postJSON(API_CONFIG.endpoints.video, body), +} + +export const threeDApi = { + animate: (body) => postJSON(API_CONFIG.endpoints.threeDAnimate, body), + generate: (body) => postJSON(API_CONFIG.endpoints.threeDGenerations, body), + remesh: async (mesh, model, detail) => { + const form = new FormData() + form.append('model', model) + form.append('detail', String(detail)) + form.append('mesh', mesh, 'source.glb') + const response = await coreFetch(API_CONFIG.endpoints.threeDRemesh, { method: 'POST', body: form }) + await handleResponse(response) + return response.blob() + }, +} + +// Shared response handling for audio-blob endpoints: throws on non-2xx (with +// the API error message when present); returns the blob, the server path of +// the saved file (from Content-Disposition) and the saved input paths the +// audio transform endpoint reports in X-Audio-Input-Url / X-Audio-Reference-Url. +async function parseAudioBlobResponse(response) { + if (!response.ok) { + const data = await response.json().catch(() => ({})) + throw new Error(data?.error?.message || `HTTP ${response.status}`) + } + let serverUrl = null + const disposition = response.headers.get('content-disposition') + if (disposition) { + const match = disposition.match(/filename[^;=\n]*=["']?([^"';\n]*)["']?/) + if (match && match[1]) serverUrl = '/generated-audio/' + match[1] + } + const inputUrl = response.headers.get('x-audio-input-url') || null + const referenceUrl = response.headers.get('x-audio-reference-url') || null + const blob = await response.blob() + return { blob, serverUrl, inputUrl, referenceUrl } +} + +async function postAudioBlob(endpoint, body) { + const response = await coreFetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + return parseAudioBlobResponse(response) +} + +async function postMultipartAudioBlob(endpoint, formData) { + const response = await coreFetch(endpoint, { method: 'POST', body: formData }) + return parseAudioBlobResponse(response) +} + +export const ttsApi = { + generate: (body) => postAudioBlob(API_CONFIG.endpoints.tts, body), + generateV1: (body) => postAudioBlob(API_CONFIG.endpoints.audioSpeech, body), +} + +export const soundApi = { + generate: (body) => postAudioBlob(API_CONFIG.endpoints.soundGeneration, body), +} + +export const audioTransformApi = { + process: ({ model, audioFile, referenceFile, format, sampleRate, params }) => { + const fd = new FormData() + fd.append('model', model) + fd.append('audio', audioFile, audioFile?.name || 'audio.wav') + if (referenceFile) fd.append('reference', referenceFile, referenceFile.name || 'reference.wav') + if (format) fd.append('response_format', format) + if (sampleRate) fd.append('sample_rate', String(sampleRate)) + if (params) { + for (const [k, v] of Object.entries(params)) { + if (v == null || v === '') continue + fd.append(`params[${k}]`, String(v)) + } + } + return postMultipartAudioBlob(API_CONFIG.endpoints.audioTransformations, fd) + }, + // A WebSocket address cannot go through fetch: the browser opens it with the + // page's own session. + streamUrl: () => apiUrl(API_CONFIG.endpoints.audioTransformStream).replace(/^http/, 'ws'), +} + +export const voiceApi = { + register: (body) => postJSON(API_CONFIG.endpoints.voiceRegister, body), +} + +export const realtimeApi = { + call: (body) => postJSON(API_CONFIG.endpoints.realtimeCalls, body), + pipelineModels: () => fetchJSON(API_CONFIG.endpoints.pipelineModels), +} + +export function fileToBase64(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => { + const base64 = reader.result.split(',')[1] || reader.result + resolve(base64) + } + reader.onerror = reject + reader.readAsDataURL(file) + }) +} + +// Multipart requests must let the browser set the boundary. +export const diarizationApi = { + run: async ({ file, model, profiles = false }) => { + const body = new FormData() + body.append('file', file) + body.append('model', model) + if (profiles) { + body.append('include_speaker_profiles', 'true') + body.append('include_text', 'true') + body.append('response_format', 'verbose_json') + } + return handleResponse(await coreFetch('/v1/audio/diarization', { method: 'POST', body })) + }, +} diff --git a/addons/internal/host-compat/src/utils/basePath.js b/addons/internal/host-compat/src/utils/basePath.js new file mode 100644 index 000000000..eaee3d8bf --- /dev/null +++ b/addons/internal/host-compat/src/utils/basePath.js @@ -0,0 +1,20 @@ +// The path prefix LocalAI is served under, read the way the host reads it +// (core utils/basePath.js): from the element. Unlike the host file +// it is safe to import where there is no document. +function getBasePath() { + if (typeof document === 'undefined') return '' + const el = document.querySelector('base[href]') + if (!el) return '' + try { + return new URL(el.getAttribute('href')).pathname.replace(/\/+$/, '') + } catch { return '' } +} + +export const basePath = getBasePath() +export const routerBasename = basePath || '/' + +export function apiUrl(path) { + if (!basePath) return path + if (path.startsWith('http://') || path.startsWith('https://')) return path + return basePath + path +} diff --git a/addons/internal/host-compat/src/utils/capabilities.js b/addons/internal/host-compat/src/utils/capabilities.js new file mode 100644 index 000000000..472e0e6e1 --- /dev/null +++ b/addons/internal/host-compat/src/utils/capabilities.js @@ -0,0 +1,37 @@ +// Capability flags — must match the FLAG_* strings from core/config/model_config.go +// Server feature keys (returned by GET /api/features), kept as constants so +// callers don't sprinkle string literals across the UI. +export const FEATURE_ADDONS = 'addons' +export const FEATURE_LOCALAI_ASSISTANT = 'localai_assistant' + +export const CAP_CHAT = 'FLAG_CHAT' +export const CAP_COMPLETION = 'FLAG_COMPLETION' +export const CAP_EDIT = 'FLAG_EDIT' +export const CAP_EMBEDDINGS = 'FLAG_EMBEDDINGS' +export const CAP_RERANK = 'FLAG_RERANK' +export const CAP_IMAGE = 'FLAG_IMAGE' +export const CAP_UPSCALE = 'FLAG_UPSCALE' +export const CAP_TRANSCRIPT = 'FLAG_TRANSCRIPT' +export const CAP_TTS = 'FLAG_TTS' +export const CAP_SOUND_GENERATION = 'FLAG_SOUND_GENERATION' +export const CAP_TOKENIZE = 'FLAG_TOKENIZE' +export const CAP_VAD = 'FLAG_VAD' +export const CAP_DIARIZATION = 'FLAG_DIARIZATION' +export const CAP_SOUND_CLASSIFICATION = 'FLAG_SOUND_CLASSIFICATION' +export const CAP_VIDEO = 'FLAG_VIDEO' +// Wire format note: /api/models/capabilities serves KnownUsecaseStrings, which +// syncKnownUsecasesFromString rewrites to the UPPERCASE keys of +// GetAllModelConfigUsecases() — so this must be FLAG_3D, not the lowercase +// "3d" served by the OpenAI-style /v1/models/capabilities endpoint. +export const CAP_3D = 'FLAG_3D' +export const CAP_3D_ANIMATION = 'FLAG_3D_ANIMATION' +export const CAP_DETECTION = 'FLAG_DETECTION' +export const CAP_FACE_RECOGNITION = 'FLAG_FACE_RECOGNITION' +export const CAP_SPEAKER_RECOGNITION = 'FLAG_SPEAKER_RECOGNITION' +export const CAP_AUDIO_TRANSFORM = 'FLAG_AUDIO_TRANSFORM' +export const CAP_REALTIME_AUDIO = 'FLAG_REALTIME_AUDIO' +export const CAP_SCORE = 'FLAG_SCORE' +export const CAP_DECISIONS = 'FLAG_DECISIONS' +export const CAP_TOKEN_CLASSIFY = 'FLAG_TOKEN_CLASSIFY' + +export const CAP_MOTION = 'FLAG_MOTION' diff --git a/addons/internal/host-compat/src/utils/clipboard.js b/addons/internal/host-compat/src/utils/clipboard.js new file mode 100644 index 000000000..21c2fe362 --- /dev/null +++ b/addons/internal/host-compat/src/utils/clipboard.js @@ -0,0 +1,81 @@ +// Clipboard helper that works in non-secure contexts. +// +// navigator.clipboard is only defined on https:// origins and on +// http://localhost. When LocalAI is served over plain http from a remote +// host (LXC + Docker is a common deployment), every page that called +// `navigator.clipboard.writeText` silently failed (#9904). This helper +// transparently falls back to a hidden-textarea + execCommand('copy') +// trick that browsers still honour when the page is not a secure context. +// +// Returns true on success, false on failure. Callers should use the return +// value to drive the success/failure toast — the old code always claimed +// success regardless of what actually happened. +export async function copyToClipboard(text) { + if (text == null) return false + const value = typeof text === 'string' ? text : String(text) + + if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText && window.isSecureContext) { + try { + await navigator.clipboard.writeText(value) + return true + } catch { + // Permissions denied, browser refused, etc. — try the fallback. + } + } + + return legacyCopy(value) +} + +function legacyCopy(value) { + if (typeof document === 'undefined') return false + const ta = document.createElement('textarea') + ta.value = value + // Keep the textarea out of the viewport and out of layout reads. Using + // `position: fixed` + a negative offset avoids scrolling the page when + // we call .select() below. + ta.setAttribute('readonly', '') + ta.style.position = 'fixed' + ta.style.top = '0' + ta.style.left = '-9999px' + ta.style.opacity = '0' + document.body.appendChild(ta) + // Preserve the current selection so triggering execCommand doesn't blow + // away whatever the user had highlighted on the page. + const previousSelection = saveSelection() + let ok = false + try { + ta.select() + ta.setSelectionRange(0, value.length) + ok = document.execCommand('copy') + } catch { + ok = false + } finally { + document.body.removeChild(ta) + restoreSelection(previousSelection) + } + return ok +} + +function saveSelection() { + try { + const sel = window.getSelection() + if (!sel || sel.rangeCount === 0) return null + const ranges = [] + for (let i = 0; i < sel.rangeCount; i++) ranges.push(sel.getRangeAt(i).cloneRange()) + return ranges + } catch { + return null + } +} + +function restoreSelection(ranges) { + if (!ranges) return + try { + const sel = window.getSelection() + if (!sel) return + sel.removeAllRanges() + for (const r of ranges) sel.addRange(r) + } catch { + // best-effort + } +} diff --git a/addons/internal/host-compat/src/utils/config.js b/addons/internal/host-compat/src/utils/config.js new file mode 100644 index 000000000..c2702ed30 --- /dev/null +++ b/addons/internal/host-compat/src/utils/config.js @@ -0,0 +1,172 @@ +export const API_CONFIG = { + endpoints: { + // Operations + operations: '/api/operations', + operationsHistory: '/api/operations/history', + cancelOperation: (jobID) => `/api/operations/${jobID}/cancel`, + pauseOperation: (jobID) => `/api/operations/${jobID}/pause`, + dismissOperation: (jobID) => `/api/operations/${jobID}/dismiss`, + + // Models gallery + models: '/api/models', + installModel: (id) => `/api/models/install/${id}`, + deleteModel: (id) => `/api/models/delete/${id}`, + modelEstimate: (id) => `/api/models/estimate/${id}`, + modelVariants: (id) => `/api/models/variants/${id}`, + modelConfig: (id) => `/api/models/config/${id}`, + modelConfigJson: (name) => `/api/models/config-json/${name}`, + configMetadata: '/api/models/config-metadata', + configAutocomplete: (provider) => `/api/models/config-metadata/autocomplete/${encodeURIComponent(provider)}`, + configPatch: (name) => `/api/models/config-json/${encodeURIComponent(name)}`, + modelJob: (uid) => `/api/models/job/${uid}`, + + // Backends gallery + backends: '/api/backends', + installBackend: (id) => `/api/backends/install/${id}`, + deleteBackend: (id) => `/api/backends/delete/${id}`, + installExternalBackend: '/api/backends/install-external', + backendJob: (uid) => `/api/backends/job/${uid}`, + deleteInstalledBackend: (name) => `/api/backends/system/delete/${name}`, + backendsUpgrades: '/api/backends/upgrades', + backendsUpgradesCheck: '/api/backends/upgrades/check', + upgradeBackend: (name) => `/api/backends/upgrade/${name}`, + + // Resources + resources: '/api/resources', + + // Settings + settings: '/api/settings', + + // Traces + traces: '/api/traces', + tracesSummary: '/api/traces/summary', + trace: (id) => `/api/traces/${encodeURIComponent(id)}`, + clearTraces: '/api/traces/clear', + backendTraces: '/api/backend-traces', + backendTrace: (id) => `/api/backend-traces/${encodeURIComponent(id)}`, + clearBackendTraces: '/api/backend-traces/clear', + + // Backend Logs + backendLogs: '/api/backend-logs', + backendLogsModel: (modelId) => `/api/backend-logs/${encodeURIComponent(modelId)}`, + clearBackendLogs: (modelId) => `/api/backend-logs/${encodeURIComponent(modelId)}/clear`, + + // P2P + p2pWorkers: '/api/p2p/workers', + p2pFederation: '/api/p2p/federation', + p2pStats: '/api/p2p/stats', + p2pToken: '/api/p2p/token', + + // Agent jobs + agentTasks: '/api/agent/tasks', + agentTask: (id) => `/api/agent/tasks/${id}`, + executeAgentTask: (name) => `/api/agent/tasks/${name}/execute`, + agentJobs: '/api/agent/jobs', + agentJob: (id) => `/api/agent/jobs/${id}`, + cancelAgentJob: (id) => `/api/agent/jobs/${id}/cancel`, + executeAgentJob: '/api/agent/jobs/execute', + + // Progress of a cold load still staging a model onto a worker + modelLoadStatus: (id) => `/api/models/${encodeURIComponent(id)}/load-status`, + + // OpenAI-compatible endpoints + chatCompletions: '/v1/chat/completions', + mcpChatCompletions: '/v1/mcp/chat/completions', + mcpServers: (model) => `/v1/mcp/servers/${model}`, + mcpPrompts: (model) => `/v1/mcp/prompts/${model}`, + mcpGetPrompt: (model, prompt) => `/v1/mcp/prompts/${model}/${encodeURIComponent(prompt)}`, + mcpResources: (model) => `/v1/mcp/resources/${model}`, + mcpReadResource: (model) => `/v1/mcp/resources/${model}/read`, + completions: '/v1/completions', + imageGenerations: '/v1/images/generations', + audioSpeech: '/v1/audio/speech', + audioTranscriptions: '/v1/audio/transcriptions', + audioTransformations: '/audio/transformations', + audioTransformStream: '/audio/transformations/stream', + soundGeneration: '/v1/sound-generation', + embeddings: '/v1/embeddings', + + // Face biometrics + faceVerify: '/v1/face/verify', + faceAnalyze: '/v1/face/analyze', + faceEmbed: '/v1/face/embed', + faceRegister: '/v1/face/register', + faceIdentify: '/v1/face/identify', + faceForget: '/v1/face/forget', + + // Voice biometrics + voiceVerify: '/v1/voice/verify', + voiceAnalyze: '/v1/voice/analyze', + voiceEmbed: '/v1/voice/embed', + voiceRegister: '/v1/voice/register', + voiceIdentify: '/v1/voice/identify', + voiceForget: '/v1/voice/forget', + + modelsList: '/v1/models', + modelsCapabilities: '/api/models/capabilities', + modelsAliases: '/api/aliases', + + // Realtime / WebRTC + realtimeCalls: '/v1/realtime/calls', + pipelineModels: '/api/pipeline-models', + + // LocalAI-specific + tts: '/tts', + video: '/video', + threeDGenerations: '/3d/generations', + threeDAnimate: '/3d/animate', + threeDRemesh: '/3d/remesh', + backendMonitor: '/backend/monitor', + backendShutdown: '/backend/shutdown', + backendLoad: '/backend/load', + modelsApply: '/models/apply', + modelsDelete: (name) => `/models/delete/${name}`, + modelsAvailable: '/models/available', + modelsGalleries: '/models/galleries', + modelsReload: '/models/reload', + modelsImportUri: '/models/import-uri', + modelsImport: '/models/import', + vramEstimate: '/api/models/vram-estimate', + modelsStorage: '/api/models/storage', + modelsJobStatus: (uid) => `/models/jobs/${uid}`, + modelEditGet: (name) => `/api/models/edit/${encodeURIComponent(name)}`, + modelEdit: (name) => `/models/edit/${name}`, + modelToggleState: (name, action) => `/models/toggle-state/${name}/${action}`, + modelTogglePinned: (name, action) => `/models/toggle-pinned/${name}/${action}`, + backendsAvailable: '/backends/available', + backendsKnown: '/backends/known', + backendsInstalled: '/backends', + version: '/version', + system: '/system', + corsProxy: '/api/cors-proxy', + + // Failover chains + failoverChains: '/api/failover', + failoverChain: (name) => `/api/failover/${encodeURIComponent(name)}`, + failoverEvents: '/api/failover/events', + failoverPin: (name) => `/api/failover/${encodeURIComponent(name)}/pin`, + + // Nodes (distributed) + nodes: '/api/nodes', + node: (id) => `/api/nodes/${id}`, + nodeModels: (id) => `/api/nodes/${id}/models`, + nodeDrain: (id) => `/api/nodes/${id}/drain`, + nodeResume: (id) => `/api/nodes/${id}/resume`, + nodeApprove: (id) => `/api/nodes/${id}/approve`, + nodeHeartbeat: (id) => `/api/nodes/${id}/heartbeat`, + nodeBackends: (id) => `/api/nodes/${id}/backends`, + nodeBackendsInstall: (id) => `/api/nodes/${id}/backends/install`, + nodeBackendsUpgrade: (id) => `/api/nodes/${id}/backends/upgrade`, + nodeBackendsDelete: (id) => `/api/nodes/${id}/backends/delete`, + nodeBackendLogs: (id) => `/api/nodes/${id}/backend-logs`, + nodeBackendLogsModel: (id, modelId) => `/api/nodes/${id}/backend-logs/${encodeURIComponent(modelId)}`, + nodeModelsUnload: (id) => `/api/nodes/${id}/models/unload`, + nodeLabels: (id) => `/api/nodes/${id}/labels`, + nodeLabelKey: (id, key) => `/api/nodes/${id}/labels/${key}`, + nodeMaxReplicasPerModel: (id) => `/api/nodes/${id}/max-replicas-per-model`, + nodeVramBudget: (id) => `/api/nodes/${id}/vram-budget`, + nodesScheduling: '/api/nodes/scheduling', + nodesModels: '/api/nodes/models', + nodesSchedulingModel: (model) => `/api/nodes/scheduling/${encodeURIComponent(model)}`, + }, +} diff --git a/addons/internal/host-compat/src/utils/editorNav.js b/addons/internal/host-compat/src/utils/editorNav.js new file mode 100644 index 000000000..6afb9df48 --- /dev/null +++ b/addons/internal/host-compat/src/utils/editorNav.js @@ -0,0 +1,15 @@ +// Navigation context for the Model Editor. +// +// Many pages link into the Model Editor (Models, Manage, Chat, Talk, Agent +// Jobs, Middleware…). Its in-page Back button used to navigate to a hardcoded +// route, so it always dumped you on the same page regardless of where you came +// from. To fix that, every linker passes this object as react-router location +// state; the editor reads it and returns you to the exact page that linked +// here, labelled "Back to