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