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 <mudler@localai.io>
This commit is contained in:
Ettore Di Giacinto committed 2026-10-20 20:54:44 +00:00
1 parent 222d8e64b3
commit c6bb76939b
69 files changed
+2288 -15

No files matched your search

+8
View File
@@ -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" }
}
@@ -0,0 +1,4 @@
import { fromSdk } from '../ui.js'
// The host's ConfirmDialog, through the SDK.
export default fromSdk('ConfirmDialog')
@@ -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 <Host {...props} />
}
@@ -0,0 +1,4 @@
import { fromSdk } from '../ui.js'
// The host's Lightbox, through the SDK.
export default fromSdk('Lightbox')
@@ -0,0 +1,4 @@
import { fromSdk } from '../ui.js'
// The host's LoadingSpinner, through the SDK.
export default fromSdk('LoadingSpinner')
@@ -0,0 +1,4 @@
import { fromSdk } from '../ui.js'
// The host's Modal, through the SDK.
export default fromSdk('Modal')
@@ -0,0 +1,4 @@
import { fromSdk } from '../ui.js'
// The host's ModelSelector, through the SDK.
export default fromSdk('ModelSelector')
@@ -0,0 +1,4 @@
import { fromSdk } from '../ui.js'
// The host's PageHeader, through the SDK.
export default fromSdk('PageHeader')
@@ -0,0 +1,4 @@
import { fromSdk } from '../../ui.js'
// The host's WaveformPlayer, through the SDK.
export default fromSdk('WaveformPlayer')
@@ -0,0 +1,4 @@
import { fromSdk } from '../../ui.js'
// The host's MediaInput, through the SDK.
export default fromSdk('MediaInput')
@@ -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')
@@ -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` }
}
@@ -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) }
}
@@ -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 }
}
@@ -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)
}
@@ -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)
}
@@ -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)
}
@@ -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)
}
@@ -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)
}
@@ -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)
}
@@ -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)
}
+92
View File
@@ -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)
}
}
@@ -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
+7
View File
@@ -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 }
@@ -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 }
}
@@ -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)
}
+10
View File
@@ -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
+58
View File
@@ -0,0 +1,58 @@
// Turns the addresses the Studio and Talk pages were written with
// (/app/studio/images/<model>?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/<id>/<page>, and its
// segments cannot hold "/" or ":" , so the model id travels as ?model=<id> 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 }
}
@@ -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(', ')}`)
}
+14
View File
@@ -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
}
@@ -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 }))
},
}
@@ -0,0 +1,20 @@
// The path prefix LocalAI is served under, read the way the host reads it
// (core utils/basePath.js): from the <base href> 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
}
@@ -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'
@@ -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
}
}
@@ -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)}`,
},
}
@@ -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 <label>".
//
// `location` is the source page's useLocation() value, so `from` captures the
// full path including any sub-route or query string — returning lands you
// where you actually were, not just on the section root.
export function fromState(location, label) {
return { from: location.pathname + location.search, fromLabel: label }
}
@@ -0,0 +1,74 @@
export function formatBytes(bytes) {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
}
export function percentColor(pct) {
if (pct > 90) return 'var(--color-error)'
if (pct > 70) return 'var(--color-warning)'
return 'var(--color-success)'
}
// normalizeTimestampMs converts a timestamp emitted by the backend into JS
// milliseconds, regardless of its encoding. The agent SSE bridge emits the
// json_message timestamp in three different shapes depending on deploy mode:
// an RFC3339 string (standalone agent pool), Unix milliseconds (local
// dispatcher), or Unix nanoseconds (older NATS path). A numeric value is
// classified by magnitude (s / ms / us / ns) so any of them yields a sane
// epoch. Falls back to Date.now() for null/empty/unparseable input.
export function normalizeTimestampMs(ts) {
if (ts === null || ts === undefined || ts === '') return Date.now()
if (typeof ts === 'string') {
const parsed = Date.parse(ts)
return Number.isNaN(parsed) ? Date.now() : parsed
}
if (typeof ts !== 'number' || !Number.isFinite(ts)) return Date.now()
if (ts > 1e17) return Math.floor(ts / 1e6) // nanoseconds
if (ts > 1e14) return Math.floor(ts / 1e3) // microseconds
if (ts > 1e11) return ts // milliseconds
return ts * 1000 // seconds
}
export function formatTimestamp(ts) {
if (!ts) return '-'
const d = new Date(ts)
return d.toLocaleTimeString() + '.' + String(d.getMilliseconds()).padStart(3, '0')
}
// Like formatTimestamp but prefixed with the localized date, for views where
// traces/entries can span more than one day and the time alone is ambiguous.
export function formatDateTime(ts) {
if (!ts) return '-'
return new Date(ts).toLocaleDateString() + ' ' + formatTimestamp(ts)
}
export function relativeTime(ts) {
if (!ts) return ''
const diff = Date.now() - ts
const seconds = Math.floor(diff / 1000)
if (seconds < 60) return 'Just now'
const minutes = Math.floor(seconds / 60)
if (minutes < 60) return `${minutes}m ago`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours}h ago`
const days = Math.floor(hours / 24)
if (days < 7) return `${days}d ago`
return new Date(ts).toLocaleDateString()
}
export function generateId() {
return Date.now().toString(36) + Math.random().toString(36).slice(2)
}
export function vendorColor(vendor) {
if (!vendor) return 'var(--color-accent)'
const v = vendor.toLowerCase()
if (v.includes('nvidia')) return '#76b900'
if (v.includes('amd')) return '#ed1c24'
if (v.includes('intel')) return '#0071c5'
if (v.includes('apple')) return '#a2aaad'
return 'var(--color-accent)'
}
@@ -0,0 +1,15 @@
// Remember the last model the user picked, keyed by capability, so returning to
// a page (Home chat box, Image, TTS, Talk...) defaults to that model instead of
// whatever happens to sort first. Only persisted when a capability key exists.
// localStorage access is wrapped because private-browsing modes throw.
const LAST_MODEL_PREFIX = 'localai_last_model:'
export function readLastModel(capability) {
if (!capability) return null
try { return localStorage.getItem(LAST_MODEL_PREFIX + capability) } catch { return null }
}
export function writeLastModel(capability, model) {
if (!capability || !model) return
try { localStorage.setItem(LAST_MODEL_PREFIX + capability, model) } catch { /* ignore */ }
}
@@ -0,0 +1,92 @@
import { generateId } from './format'
const STORAGE_KEY = 'localai_client_mcp_servers'
// localStorage is shared across same-origin pages; an XSS that lands once can
// poison persisted MCP server entries to attempt header injection or to feed
// a non-http URL into the fetch path. Validate every entry on load and drop
// anything that doesn't match the expected shape.
function sanitiseServer(s) {
if (!s || typeof s !== 'object') return null
const id = typeof s.id === 'string' ? s.id : ''
const name = typeof s.name === 'string' ? s.name : ''
const url = typeof s.url === 'string' ? s.url : ''
if (!url) return null
// fetch() refuses non-http schemes anyway, but reject early so they
// can't get persisted back out. URL parsing also catches malformed values.
let parsed
try {
parsed = new URL(url)
} catch {
return null
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null
const headers = {}
if (s.headers && typeof s.headers === 'object' && !Array.isArray(s.headers)) {
for (const [k, v] of Object.entries(s.headers)) {
// Drop CRLF / control chars to block header injection through poisoned storage.
if (typeof k !== 'string' || typeof v !== 'string') continue
if (/[\x00-\x1f\x7f]/.test(k) || /[\x00-\x1f\x7f]/.test(v)) continue
headers[k] = v
}
}
return {
id,
name,
url,
headers,
useProxy: s.useProxy !== false,
}
}
export function loadClientMCPServers() {
try {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored) {
const data = JSON.parse(stored)
if (Array.isArray(data)) {
return data.map(sanitiseServer).filter(Boolean)
}
}
} catch (_e) {
// ignore
}
return []
}
export function saveClientMCPServers(servers) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(servers))
} catch (_e) {
// ignore
}
}
export function addClientMCPServer({ name, url, headers, useProxy }) {
const servers = loadClientMCPServers()
const server = {
id: generateId(),
name: name || new URL(url).hostname,
url,
headers: headers || {},
useProxy: useProxy !== false,
}
servers.push(server)
saveClientMCPServers(servers)
return server
}
export function removeClientMCPServer(id) {
const servers = loadClientMCPServers().filter(s => s.id !== id)
saveClientMCPServers(servers)
return servers
}
export function updateClientMCPServer(id, updates) {
const servers = loadClientMCPServers().map(s =>
s.id === id ? { ...s, ...updates } : s
)
saveClientMCPServers(servers)
return servers
}
@@ -0,0 +1,41 @@
// modelBudget answers the one question every "will this model run here" verdict
// on the models page is built from: how much memory a model may occupy, and
// whose memory it is.
//
// In distributed mode that is NOT the host serving this page. The controller is
// usually a GPU-less pod while every model runs on a worker, so sizing against
// its own aggregate told admins that a cluster of A100s could only run the
// smallest CPU build. The server reports the cluster's best single node in an
// additional `cluster` block; the local aggregate stays untouched for the
// resource monitor, which is genuinely about this host.
//
// The best single node, not the fleet total: a model loads into one node, so a
// summed fleet of four 16GB cards would promise a 40GB model a home it does not
// have.
//
// Every missing or unusable field falls back to the local reading, so a
// controller that cannot reach its registry keeps behaving exactly as a
// single-node install does.
export function modelBudget(resources) {
const cluster = resources?.cluster
if (cluster?.enabled && cluster.total_memory > 0) {
return {
totalMemory: cluster.total_memory,
hasGpu: !!cluster.is_gpu,
nodeName: cluster.node_name || '',
nodeCount: cluster.node_count || 0,
scope: 'cluster',
}
}
return {
totalMemory: resources?.aggregate?.total_memory || 0,
// gpu_count is 0 and gpus is null on a CPU-only host, where total_memory is
// system RAM. The fits check has always used it either way; only the copy
// has to stop calling it VRAM.
hasGpu: (resources?.aggregate?.gpu_count || 0) > 0 || (resources?.gpus?.length || 0) > 0,
nodeName: '',
nodeCount: 0,
scope: 'local',
}
}
@@ -0,0 +1,113 @@
// Numbers behind the Models ledger: how a model fits this machine, and how
// much disk the models volume has left. Pure functions, so the rules sit in one
// place and can be read without rendering anything.
const GB = 1024 * 1024 * 1024
// A model "fits" when it needs no more than this share of the memory the page
// measures against. The same limit the VRAM chart draws, so a row and the chart
// never disagree.
export const FIT_LIMIT = 0.95
// The models disk turns amber when less than a tenth of it is free, or when
// less than 20 GB is free. Either one alone is enough: 10 percent of a 4 TB
// disk is still 400 GB, which is not a warning, and 20 GB free on a 100 GB
// disk is the same warning at a different size.
export const LOW_DISK_FRACTION = 0.1
export const LOW_DISK_BYTES = 20 * GB
function positive(value) {
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0
}
// How one model sits in the memory this page measures against.
//
// budget what modelBudget() returns: the memory a model may occupy.
// ramAvailable system RAM that could take the layers that do not fit on the
// GPU. Null when unknown (a cluster reading has no RAM figure),
// which turns a spill into a plain "over".
//
// Returns null while the estimate or the budget is missing: a row with no
// reading must not claim to fit or not to fit.
//
// fits needs no more than the limit. amount = room left.
// spill too big for the GPU, but the rest would run from RAM. amount = the
// part that runs on the CPU.
// over too big for the GPU and RAM together, or for RAM on a host with no
// GPU. amount = how far over.
export function fitFor(vramBytes, budget, ramAvailable = null) {
const need = positive(vramBytes)
const total = positive(budget?.totalMemory)
if (!need || !total) return null
const limit = total * FIT_LIMIT
const fraction = Math.min(1, need / total)
if (need <= limit) {
return { state: 'fits', amount: limit - need, need, limit, total, fraction }
}
const overflow = need - limit
const ram = budget.hasGpu && budget.scope !== 'cluster' ? positive(ramAvailable) : 0
if (ram > 0 && overflow <= ram) {
return { state: 'spill', amount: overflow, need, limit, total, fraction }
}
return { state: 'over', amount: overflow - ram, need, limit, total, fraction }
}
// The state of the models volume, or null when the page cannot say honestly.
//
// Hidden, not zeroed, when:
// - the resources reading carries no disk block (an older server, or a failed
// read; the server omits the block rather than report 0 bytes);
// - the server is a cluster controller. Models live on the workers, and the
// controller's own volume says nothing about where an install lands.
export function diskState(resources) {
const disk = resources?.disk
if (!disk || resources?.cluster?.enabled) return null
const total = positive(disk.total)
if (!total) return null
const free = Math.max(0, Math.min(total, positive(disk.available)))
const models = positive(resources.storage_size)
const used = Math.max(0, total - free)
return {
total,
free,
used,
// The models directory can sit on a bigger volume than its own contents;
// never claim more than the disk holds.
models: Math.min(models, used),
other: Math.max(0, used - Math.min(models, used)),
low: free < total * LOW_DISK_FRACTION || free < LOW_DISK_BYTES,
}
}
// What the disk has left after an install of `sizeBytes`. Null when either
// number is unknown. Negative means the download does not fit.
export function leavesFree(disk, sizeBytes) {
const size = positive(sizeBytes)
if (!disk || !size) return null
return disk.free - size
}
// "16.7" for 16.7 GB. Smaller than a tenth of a GB would read as "0.0", which
// says nothing, so it is "<0.1".
export function gbNumber(bytes) {
const value = positive(bytes) / GB
if (value > 0 && value < 0.1) return '<0.1'
if (value >= 100) return String(Math.round(value))
return value.toFixed(1)
}
export function gbLabel(bytes) {
return `${gbNumber(bytes)} GB`
}
// CSS custom property for a bar's fill, so the width lives in the stylesheet.
export function fitStyle(fraction) {
return { '--ledger-fit': `${Math.round(Math.max(0, Math.min(1, fraction)) * 100)}%` }
}
// CSS custom properties for an element, so a width can live in the stylesheet.
// An identity function with a name: the inline-style ratchet counts literal
// style objects, and a property that only carries a number is not one.
export function cssVars(vars) {
return vars
}
@@ -0,0 +1,127 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { makeSdk } from './host.mjs'
import { setSdk } from '../src/runtime.js'
// The calls the pages make go out through sdk.core.fetch with the paths and
// bodies of the core API client (core utils/api.js).
async function setup(answer) {
const host = await makeSdk({
addonId: 'studio', pages: ['studio'],
overrides: {
fetch: async (url, init) => {
host.calls.push([url, init])
const r = typeof answer === 'function' ? answer(url, init) : answer
return {
ok: r.ok !== false, status: r.status || 200,
headers: new Map(Object.entries(r.headers || { 'content-type': 'application/json' })),
json: async () => r.body ?? {}, blob: async () => new Blob(['x']),
}
},
},
})
setSdk(host.sdk)
return { ...host, api: await import('../src/utils/api.js') }
}
test('JSON calls: the endpoint, the method, the JSON body and the content type', async () => {
const { api, calls } = await setup({ body: { ok: 1 } })
assert.deepEqual(await api.imageApi.generate({ prompt: 'x' }), { ok: 1 })
await api.videoApi.generate({ prompt: 'v' })
await api.threeDApi.generate({ a: 1 })
await api.threeDApi.animate({ b: 2 })
await api.realtimeApi.call({ sdp: 's' })
await api.realtimeApi.pipelineModels()
await api.voiceApi.register({ name: 'n' })
assert.deepEqual(calls.map(([url, init]) => [url, init.method || 'GET']), [
['/v1/images/generations', 'POST'], ['/video', 'POST'], ['/3d/generations', 'POST'], ['/3d/animate', 'POST'],
['/v1/realtime/calls', 'POST'], ['/api/pipeline-models', 'GET'], ['/v1/voice/register', 'POST'],
])
assert.equal(calls[0][1].body, '{"prompt":"x"}')
assert.equal(calls[0][1].headers.get('content-type'), 'application/json')
})
test('models: the list keeps only the parameters that are set, install carries the variant, estimate the contexts', async () => {
const { api, calls } = await setup({ body: {} })
await api.modelsApi.list({ term: 'a b', page: 2, empty: '', none: null })
await api.modelsApi.install('org/name')
await api.modelsApi.install('org/name', 'q4 k')
await api.modelsApi.estimate('m', [4096, 8192])
await api.modelsApi.estimate('m')
assert.deepEqual(calls.map(([url]) => url), [
'/api/models?term=a+b&page=2', '/api/models/install/org/name', '/api/models/install/org/name?variant=q4%20k',
'/api/models/estimate/m?contexts=4096%2C8192', '/api/models/estimate/m',
])
})
test('an error answer becomes an Error with the server message, the status and the body', async () => {
const { api } = await setup({ ok: false, status: 400, body: { error: { message: 'no such model' } } })
await assert.rejects(api.imageApi.generate({}), (e) => e.message === 'no such model' && e.status === 400 && e.body.error.message === 'no such model')
})
test('audio calls return the blob and the saved file names from the headers', async () => {
const { api, calls } = await setup({
headers: { 'content-type': 'audio/wav', 'content-disposition': 'attachment; filename="out.wav"', 'x-audio-input-url': '/generated-audio/in.wav' },
})
const out = await api.ttsApi.generate({ text: 'hi' })
assert.equal(out.serverUrl, '/generated-audio/out.wav')
assert.equal(out.inputUrl, '/generated-audio/in.wav')
assert.equal(out.referenceUrl, null)
assert.equal(calls[0][0], '/tts')
await api.soundApi.generate({})
await api.ttsApi.generateV1({})
assert.deepEqual(calls.slice(1).map(([url]) => url), ['/v1/sound-generation', '/v1/audio/speech'])
})
test('multipart calls send FormData and set no content type', async () => {
const { api, calls } = await setup({ headers: { 'content-type': 'audio/wav' } })
await api.audioTransformApi.process({ model: 'm', audioFile: new Blob(['a']), referenceFile: new Blob(['r']), format: 'wav', sampleRate: 16000, params: { gain: 2, skip: '', none: null } })
const form = calls[0][1].body
assert.ok(form instanceof FormData)
assert.deepEqual([...form.keys()].sort(), ['audio', 'model', 'params[gain]', 'reference', 'response_format', 'sample_rate'])
assert.equal(calls[0][0], '/audio/transformations')
assert.equal(calls[0][1].headers.has('content-type'), false)
await api.diarizationApi.run({ file: new Blob(['a']), model: 'd', profiles: true })
assert.equal(calls[1][0], '/v1/audio/diarization')
assert.deepEqual([...calls[1][1].body.keys()].sort(), ['file', 'include_speaker_profiles', 'include_text', 'model', 'response_format'])
await api.imageApi.upscale({ model: 'u', image: new Blob(['i']), scale: 4 })
assert.equal(calls[2][0], '/v1/images/upscale')
})
test('remesh posts the mesh and returns the blob', async () => {
const { api, calls } = await setup({ headers: { 'content-type': 'model/gltf-binary' } })
const blob = await api.threeDApi.remesh(new Blob(['m']), 'trellis', 12)
assert.equal(calls[0][0], '/3d/remesh')
assert.equal(await blob.text(), 'x')
})
test('fetchUrl: a core path goes through the SDK, a blob address is fetched directly', async () => {
const { api, calls } = await setup({ body: {} })
await api.fetchUrl('/generated-images/a.png')
assert.deepEqual(calls.map(([url]) => url), ['/generated-images/a.png'])
const seen = []
const real = globalThis.fetch
globalThis.fetch = async (url) => { seen.push(url); return { ok: true } }
try {
await api.fetchUrl('blob:http://x/abc')
await api.fetchUrl('data:text/plain,hi')
} finally { globalThis.fetch = real }
assert.deepEqual(seen, ['blob:http://x/abc', 'data:text/plain,hi'])
assert.equal(calls.length, 1)
})
test('the stream address of the audio transform is a WebSocket address', async () => {
const { api } = await setup({})
assert.equal(api.audioTransformApi.streamUrl(), '/audio/transformations/stream')
})
test('every endpoint the stand-in uses is one the core config lists', async () => {
const { readFileSync } = await import('node:fs')
const { fileURLToPath } = await import('node:url')
const { dirname, join } = await import('node:path')
const src = readFileSync(join(dirname(fileURLToPath(import.meta.url)), '../src/utils/api.js'), 'utf8')
const config = readFileSync(join(dirname(fileURLToPath(import.meta.url)), '../src/utils/config.js'), 'utf8')
const names = [...src.matchAll(/API_CONFIG\.endpoints\.([A-Za-z0-9]+)/g)].map(m => m[1])
assert.ok(names.length > 10)
for (const name of new Set(names)) assert.match(config, new RegExp(`\\b${name}\\b`), name)
})
@@ -0,0 +1,48 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
// The add-on builder refuses a bundle that imports a core UI file, so these
// files are copies. They must stay byte-equal to the core originals.
const here = dirname(fileURLToPath(import.meta.url))
const compat = join(here, '../src')
const core = resolve(here, '../../../../core/http/react-ui/src')
const read = (p) => readFileSync(p, 'utf8')
const COPIES = [
'utils/format.js', 'utils/capabilities.js', 'utils/modelLedger.js', 'utils/modelBudget.js', 'utils/mcpClientStorage.js',
'utils/clipboard.js', 'utils/editorNav.js', 'utils/config.js', 'utils/lastModel.js', 'components/home/memory.js',
'hooks/useAudioPeaks.js',
]
for (const file of COPIES) {
test(`${file} is a byte copy of the core file`, () => {
assert.equal(read(join(compat, file)), read(join(core, file)))
})
}
test('apiUrl and basePath keep the host rules, and the module loads without a document', async () => {
const { apiUrl, basePath, routerBasename } = await import('../src/utils/basePath.js')
assert.equal(basePath, '')
assert.equal(routerBasename, '/')
assert.equal(apiUrl('/v1/x'), '/v1/x')
const host = read(join(core, 'utils/basePath.js'))
// The host file's three rules, which the stand-in repeats.
assert.match(host, /replace\(\/\\\/\+\$\/, ''\)/)
assert.match(host, /startsWith\('http:\/\/'\) \|\| path\.startsWith\('https:\/\/'\)/)
assert.match(host, /return basePath \+ path/)
})
test('the stand-ins sit at the paths the page code imports', () => {
// A relative import that is not in the add-on's src/ is answered from here,
// by the same relative path. These are the ones the pages use.
for (const file of [
'components/Icon.jsx', 'components/ModelSelector.jsx', 'components/PageHeader.jsx', 'components/ConfirmDialog.jsx',
'components/LoadingSpinner.jsx', 'components/Modal.jsx', 'components/Lightbox.jsx', 'components/audio/WaveformPlayer.jsx',
'components/biometrics/MediaInput.jsx', 'components/home/HomeModelPicker.jsx', 'context/AuthContext.jsx',
'hooks/useModels.js', 'hooks/useResources.js', 'hooks/useObjectUrl.js', 'hooks/useVoiceProfiles.js',
'hooks/useOperations.js', 'hooks/useMediaCapture.js', 'hooks/useMCPClient.js', 'utils/api.js',
]) assert.ok(read(join(compat, file)).length > 0, file)
})
+70
View File
@@ -0,0 +1,70 @@
// A fake host for the tests of the add-ons that use the compat stand-ins: the
// real createHostSDK over stub components and hooks, and a minimal browser
// (storage, document) so page code that touches them at render time runs in node.
import { createRequire } from 'node:module'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { dirname, join, resolve } from 'node:path'
const here = dirname(fileURLToPath(import.meta.url))
export const hostUi = resolve(here, '../../../../core/http/react-ui')
const require = createRequire(join(hostUi, 'package.json'))
export const React = require('react')
export const { renderToString, renderToStaticMarkup } = require('react-dom/server')
export function installBrowser() {
const store = new Map()
globalThis.localStorage = {
getItem: (k) => (store.has(k) ? store.get(k) : null),
setItem: (k, v) => { store.set(k, String(v)) },
removeItem: (k) => { store.delete(k) },
clear: () => store.clear(),
}
globalThis.document ??= { querySelector: () => null, documentElement: { getAttribute: () => 'light' }, addEventListener() {}, removeEventListener() {} }
return store
}
const tag = (name) => (props) => React.createElement('div', { 'data-host': name }, props.title || props.label || props.children || null)
export async function makeSdk({ addonId, pages, overrides = {} }) {
installBrowser()
const { createHostSDK } = await import(pathToFileURL(join(hostUi, 'src/addons/sdk.js')).href)
const calls = []
const notices = []
const navigations = []
const controller = new AbortController()
let location = { page: pages[0], path: '', search: '' }
const components = Object.fromEntries([
'Button', 'Spinner', 'EmptyState', 'Badge', 'Stack', 'Text', 'PageHeader', 'ConfirmDialog', 'ErrorWithTraceLink', 'ModelSelector',
'MediaInput', 'WaveformPlayer', 'DistributionBars', 'EmbeddingInspector', 'TabSwitch', 'LoadingSpinner', 'Modal', 'Lightbox', 'HomeModelPicker',
].map((n) => [n, tag(n)]))
const hooks = {
useMediaCapture: () => ({ recording: false, start() {}, stop() {}, error: null }),
useObjectUrl: () => null,
useDelayedAction: () => ({ pending: null, schedule() {}, undo() {} }),
audioBufferToWavBlob: () => new Blob([]),
useModels: () => ({ models: [], loading: false, error: null, refetch() {} }),
useResources: () => ({ resources: null, loading: false, error: null, refetch() {} }),
useVoiceProfiles: () => ({ profiles: [], loading: false, error: null, refetch() {} }),
useOperations: () => ({ operations: [], refetch() {} }),
useMCPClient: () => ({ servers: [], connect() {}, disconnect() {}, tools: [] }),
}
const sdk = createHostSDK({
addonId, generation: 1, signal: controller.signal, pages,
host: {
React, components, hooks, Icon: ({ name }) => React.createElement('i', { 'data-icon': name }),
viewer: { isAdmin: () => true, hasFeature: () => true },
basePath: '',
fetch: async (url, init) => { calls.push([url, init]); return { ok: true, headers: new Map(), json: async () => ({}), blob: async () => new Blob([]) } },
createPortal: (children) => children,
theme: { mode: () => 'light', subscribe: () => () => {} }, language: () => 'en', subscribeLanguage: () => () => {}, api: {},
...overrides,
},
bindings: {
current: () => location,
openLayer: () => ({ close() {} }),
notify: (kind, message) => notices.push([kind, message]),
navigate: (path, options) => navigations.push([path, options]),
},
})
return { sdk, controller, calls, notices, navigations, at: (next) => { location = { ...location, ...next } } }
}
@@ -0,0 +1,66 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { clearLocales, registerLocales, translator } from '../src/i18n.js'
registerLocales({
media: {
en: {
studio: { tabs: { images: 'Images' }, work: { results_one: '{{count}} result', results_other: '{{count}} results', starters: ['a', 'b'] } },
'flat.key': 'Flat',
hello: 'Hello {{name}}, {{ name }}!',
},
it: { studio: { tabs: { images: 'Immagini' } } },
},
talk: { en: { title: 'Talk', picker: { none: 'None' } } },
})
test('a nested key resolves, and a flat key with dots resolves', () => {
const t = translator('en', ['media'])
assert.equal(t('studio.tabs.images'), 'Images')
assert.equal(t('flat.key'), 'Flat')
})
test('the language wins, then its base language, then English', () => {
assert.equal(translator('it', ['media'])('studio.tabs.images'), 'Immagini')
assert.equal(translator('it-CH', ['media'])('studio.tabs.images'), 'Immagini')
assert.equal(translator('de', ['media'])('studio.tabs.images'), 'Images')
assert.equal(translator('it', ['media'])('studio.work.results_other', { count: 2 }), '2 results', 'a missing Italian key falls back to English')
})
test('a missing key gives the default value from either argument, else the key', () => {
const t = translator('en', ['media'])
assert.equal(t('nope.key'), 'nope.key')
assert.equal(t('nope.key', 'Fallback {{x}}', { x: 1 }), 'Fallback 1')
assert.equal(t('nope.key', { defaultValue: 'Opt' }), 'Opt')
})
test('plural keys follow the count', () => {
const t = translator('en', ['media'])
assert.equal(t('studio.work.results', { count: 1 }), '1 result')
assert.equal(t('studio.work.results', { count: 5 }), '5 results')
assert.equal(t('studio.work.results', { count: 0 }), '0 results')
})
test('returnObjects gives the list, and a list is never shown as text', () => {
const t = translator('en', ['media'])
assert.deepEqual(t('studio.work.starters', { returnObjects: true }), ['a', 'b'])
assert.equal(t('studio.work', 'fallback'), 'fallback')
})
test('placeholders are filled, whitespace and repeats included, and unknown ones stay', () => {
const t = translator('en', ['media'])
assert.equal(t('hello', { name: 'Ada' }), 'Hello Ada, Ada!')
assert.equal(t('hello'), 'Hello {{name}}, {{ name }}!')
})
test('namespaces: the first that has the key wins, and ns:key names one', () => {
const t = translator('en', ['media', 'talk'])
assert.equal(t('title'), 'Talk')
assert.equal(t('talk:picker.none'), 'None')
assert.equal(translator('en', 'talk')('picker.none'), 'None')
})
test('clearLocales empties the registry', () => {
clearLocales()
assert.equal(translator('en', ['media'])('studio.tabs.images'), 'studio.tabs.images')
})
@@ -0,0 +1,28 @@
// A node resolve hook for tests that import add-on source files directly. The
// bundler answers a relative import whose target is not in the add-on's src/
// from the compat directory (see addons/internal/uibuild/uibuild.mjs); this
// hook does the same for node, so a source file runs without a build.
import { existsSync, statSync } from 'node:fs'
import { dirname, join, relative, resolve, sep } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
const compatSrc = resolve(dirname(fileURLToPath(import.meta.url)), '../src')
const tails = ['', '.js', '.jsx', '.mjs']
const find = (base) => tails.map((t) => base + t).find((f) => existsSync(f) && statSync(f).isFile())
export async function resolve_(specifier, context, nextResolve) {
if (specifier.startsWith('.') && context.parentURL?.startsWith('file:')) {
const parent = fileURLToPath(context.parentURL)
const m = /^(.*[\\/]addons[\\/][^\\/]+[\\/]ui[\\/]src)[\\/]/.exec(parent)
if (m) {
const target = resolve(dirname(parent), specifier)
const found = find(target)
if (found) return nextResolve(pathToFileURL(found).href, context)
const rel = relative(m[1], target)
const hit = !rel.startsWith('..') && find(join(compatSrc, rel))
if (hit) return nextResolve(pathToFileURL(hit).href, context)
}
}
return nextResolve(specifier, context)
}
export { resolve_ as resolve }
@@ -0,0 +1,85 @@
// Checks shared by the add-ons that keep the host page code: the words, the
// icons and the imports of the source stay in step with the host.
import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
const here = dirname(fileURLToPath(import.meta.url))
export const hostUi = resolve(here, '../../../../core/http/react-ui')
export const compatSrc = resolve(here, '../src')
export function sourceFiles(dir) {
const out = []
for (const name of readdirSync(dir)) {
const p = join(dir, name)
if (statSync(p).isDirectory()) out.push(...sourceFiles(p))
else if (/\.(jsx?|mjs)$/.test(name)) out.push(p)
}
return out
}
export const text = (files) => files.map((f) => readFileSync(f, 'utf8')).join('\n')
// The literal keys a source passes to t(): t('a.b'), t("a.b", ...), t(`a.${x}`) is skipped.
export function literalKeys(source) {
return new Set([...source.matchAll(/\bt\(\s*(['"])([A-Za-z0-9_.:]+)\1/g)].map((m) => m[2]))
}
// A key exists in a locale object when the path resolves, as a value or as a
// plural pair (key_one / key_other).
export function hasKey(data, key) {
const walk = (name) => name.split('.').reduce((node, part) => (node && typeof node === 'object' ? node[part] : undefined), data)
return walk(key) !== undefined || walk(`${key}_other`) !== undefined || walk(`${key}_one`) !== undefined
}
export async function iconNames() {
const { ICON_NAMES } = await import(pathToFileURL(join(hostUi, 'src/addons/sdk.js')).href)
return new Set(ICON_NAMES)
}
// The icon ids of the host sprite.
export function spriteNames() {
const sprite = readFileSync(join(hostUi, 'src/vendor/ui-kit/icons/sprite.svg'), 'utf8')
return new Set([...sprite.matchAll(/id="dk-icon-([a-z0-9-]+)"/g)].map((m) => m[1]))
}
// The icon names a source draws: the literals inside <Icon .../>, the `icon`
// props and fields, and the TYPE_ICON table.
export function iconLiterals(source) {
const out = new Set()
const add = (chunk) => { for (const m of chunk.matchAll(/(['"])([a-z][a-z0-9]*(?:-[a-z0-9]+)*)\1/g)) out.add(m[2]) }
for (const m of source.matchAll(/<Icon\b[^>]*?\/?>/g)) add(m[0].replace(/\b(?:className|style|title|aria-[a-z]+)=("[^"]*"|\{[^}]*\})/g, '').replace(/[=!]==?\s*(['"])[^'"]*\1/g, ''))
for (const m of source.matchAll(/\bicon\s*[:=]\s*\{?\s*(['"][a-z0-9-]+['"])/g)) add(m[1])
const table = /TYPE_ICON\s*=\s*\{([^}]*)\}/.exec(source)
if (table) add(table[1].replace(/\b[a-z]+\s*:/g, ''))
return out
}
// Every import of a source file: a relative one must be a file of the add-on or
// of the compat directory; a bare one must be a module the compat directory
// answers. Returns the imports that are neither.
export function unresolvedImports(srcRoot) {
const bare = new Set(['react', 'react/jsx-runtime', 'react-dom', 'react-router-dom', 'react-i18next'])
const tails = ['', '.js', '.jsx', '.mjs', '.json', '.css', '/index.js', '/index.jsx']
const exists = (base) => tails.some((t) => existsSync(base + t) && statSync(base + t).isFile())
const bad = []
for (const file of sourceFiles(srcRoot)) {
const source = readFileSync(file, 'utf8')
const specs = [
...source.matchAll(/^\s*(?:import|export)\b[^'"\n;]*?\bfrom\s*['"]([^'"]+)['"]/gm),
...source.matchAll(/^\s*import\s*['"]([^'"]+)['"]/gm),
...source.matchAll(/\bimport\(\s*['"]([^'"]+)['"]\s*\)/g),
]
for (const m of specs) {
const spec = m[1]
if (spec.startsWith('.')) {
const target = resolve(dirname(file), spec)
const rel = target.slice(srcRoot.length)
if (!exists(target) && !exists(join(compatSrc, rel))) bad.push(`${file.slice(srcRoot.length)}: ${spec}`)
} else if (!bare.has(spec)) {
bad.push(`${file.slice(srcRoot.length)}: ${spec}`)
}
}
}
return bad
}
@@ -0,0 +1,5 @@
// Import this first, then import the source under test with `await import()`:
// static imports are linked before any hook is installed.
import { register } from 'node:module'
register('./loader.mjs', import.meta.url)
@@ -0,0 +1,40 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { paramsOf, parseAddress, resolveTarget } from '../src/route.js'
test('parseAddress splits path, query and hash', () => {
assert.deepEqual(parseAddress('/app/studio/images?a=1&b=2#top'), { pathname: '/app/studio/images', search: 'a=1&b=2', hash: '#top' })
assert.deepEqual(parseAddress('/app'), { pathname: '/app', search: '', hash: '' })
})
test('an address of the add-on itself becomes a subpath: the tab in the path, the model in the query', () => {
assert.deepEqual(resolveTarget('/app/studio', 'studio'), { own: true, subpath: 'studio' })
assert.deepEqual(resolveTarget('/app/studio/', 'studio'), { own: true, subpath: 'studio' })
assert.deepEqual(resolveTarget('/app/studio/images', 'studio'), { own: true, subpath: 'studio/images' })
assert.deepEqual(resolveTarget('/app/studio/images/stable%2Fdiffusion?work=w1', 'studio'), { own: true, subpath: 'studio/images?work=w1&model=stable%2Fdiffusion' })
assert.deepEqual(resolveTarget('/app/studio?work=a b', 'studio'), { own: true, subpath: 'studio?work=a+b' })
assert.deepEqual(resolveTarget('/app/talk', 'talk'), { own: true, subpath: 'talk' })
})
test('the other moved page is reached by its add-on address', () => {
assert.deepEqual(resolveTarget('/app/talk', 'studio'), { own: false, address: '/app/addons/talk/talk' })
assert.deepEqual(resolveTarget('/app/studio/tts/piper#x', 'talk'), { own: false, address: '/app/addons/studio/studio/tts?model=piper#x' })
})
test('any other address under /app is opened as it is, and an address outside it is refused', () => {
assert.deepEqual(resolveTarget('/app/models?view=installed', 'studio'), { own: false, address: '/app/models?view=installed' })
assert.deepEqual(resolveTarget('/app/model-editor/x%2Fy', 'talk'), { own: false, address: '/app/model-editor/x%2Fy' })
assert.deepEqual(resolveTarget('/app', 'talk'), { own: false, address: '/app' })
assert.equal(resolveTarget('/api/models', 'studio'), null)
assert.equal(resolveTarget('https://example.com/app', 'studio'), null)
assert.equal(resolveTarget('/appendix', 'studio'), null)
assert.equal(resolveTarget('/app/studiox', 'studio').own, false)
})
test('params: the tab is the first segment, the model is ?model= or the second segment', () => {
assert.deepEqual(paramsOf({ path: '', search: '' }), { tab: undefined, model: undefined })
assert.deepEqual(paramsOf({ path: 'images', search: '' }), { tab: 'images', model: undefined })
assert.deepEqual(paramsOf({ path: 'images', search: 'model=a%2Fb%3Ac' }), { tab: 'images', model: 'a/b:c' })
assert.deepEqual(paramsOf({ path: 'images/by-hand', search: '' }), { tab: 'images', model: 'by-hand' })
assert.deepEqual(paramsOf({ path: 'images/by-hand', search: 'model=wins' }), { tab: 'images', model: 'wins' })
})
@@ -0,0 +1,98 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { React, renderToString, makeSdk } from './host.mjs'
import { setSdk } from '../src/runtime.js'
// The router stand-in, driven the way a page uses it: hooks inside a
// RouteProvider, a Link, and the calls that reach the shell.
async function setup(addonId, pages) {
const host = await makeSdk({ addonId, pages })
setSdk(host.sdk)
const router = await import('../src/modules/react-router-dom.js')
return { ...host, router }
}
function capture(router, route, element) {
const seen = {}
function Probe() {
seen.navigate = router.useNavigate()
seen.params = router.useParams()
seen.search = router.useSearchParams()
seen.location = router.useLocation()
seen.outlet = router.useOutletContext()
return element ? element(router) : null
}
const html = renderToString(React.createElement(router.RouteProvider, { value: route }, React.createElement(Probe)))
return { seen, html }
}
test('useParams and useSearchParams read the route the shell passed', async () => {
const { router } = await setup('studio', ['studio'])
const { seen } = capture(router, { page: 'studio', path: 'images', search: 'model=a%2Fb&work=w1' })
assert.deepEqual(seen.params, { tab: 'images', model: 'a/b' })
assert.equal(seen.search[0].get('work'), 'w1')
assert.equal(seen.location.search, '?model=a%2Fb&work=w1')
assert.equal(seen.location.pathname, '/app/addons/studio/studio/images')
})
test('navigate to an address of the add-on stays inside it, with the model in the query', async () => {
const { router, navigations } = await setup('studio', ['studio'])
const { seen } = capture(router, { page: 'studio', path: '', search: '' })
seen.navigate('/app/studio/images/some%2Fmodel')
seen.navigate('/app/studio', { replace: true })
assert.deepEqual(navigations, [
['/app/addons/studio/studio/images?model=some%2Fmodel', { replace: false }],
['/app/addons/studio/studio', { replace: true }],
])
})
test('navigate to a core page, or to the other add-on, opens it, and a bad address throws', async () => {
const { router, navigations } = await setup('talk', ['talk'])
const { seen } = capture(router, { page: 'talk', path: '', search: '' })
seen.navigate('/app/models')
seen.navigate('/app/model-editor/x', { state: { from: '/app/addons/talk/talk', fromLabel: 'Talk' } })
seen.navigate('/app/studio/diarization')
assert.deepEqual(navigations, [
['/app/models', { replace: false }],
['/app/model-editor/x', { replace: false, state: { from: '/app/addons/talk/talk', fromLabel: 'Talk' } }],
['/app/addons/studio/studio/diarization', { replace: false }],
])
assert.throws(() => seen.navigate('/api/models'), /Cannot open/)
})
test('setSearchParams keeps the page and the tab and replaces the query', async () => {
const { router, navigations } = await setup('studio', ['studio'])
const { seen } = capture(router, { page: 'studio', path: 'tts', search: 'a=1' })
seen.search[1]({ work: 'w2' })
seen.search[1]({})
seen.search[1]((prev) => ({ a: prev.get('a'), b: '2' }), { replace: true })
assert.deepEqual(navigations, [
['/app/addons/studio/studio/tts?work=w2', { replace: false }],
['/app/addons/studio/studio/tts', { replace: false }],
['/app/addons/studio/studio/tts?a=1&b=2', { replace: true }],
])
})
test('addToast of the outlet context reaches the SDK notices, and an unknown kind is an info', async () => {
const { router, notices } = await setup('studio', ['studio'])
const { seen } = capture(router, { page: 'studio', path: '', search: '' })
seen.outlet.addToast('saved', 'success')
seen.outlet.addToast('careful', 'warning')
seen.outlet.addToast('plain')
seen.outlet.addToast('odd', 'weird')
assert.deepEqual(notices, [['success', 'saved'], ['warning', 'careful'], ['info', 'plain'], ['info', 'odd']])
})
test('a Link draws a real address and navigates on a plain click only', async () => {
const { router, navigations } = await setup('studio', ['studio'])
const { html, seen } = capture(router, { page: 'studio', path: '', search: '' }, (r) => React.createElement(r.Link, { to: '/app/studio/images/m', className: 'ws-link' }, 'Open'))
assert.match(html, /<a [^>]*href="\/app\/addons\/studio\/studio\/images\?model=m"[^>]*class="ws-link"[^>]*>Open<\/a>|<a [^>]*class="ws-link"[^>]*href="\/app\/addons\/studio\/studio\/images\?model=m"[^>]*>Open<\/a>/)
assert.ok(seen)
// The click handler, taken from the element the Link makes.
const link = router.Link({ to: '/app/models', children: 'x' })
const plain = { defaultPrevented: false, button: 0, preventDefault() { this.defaultPrevented = true } }
link.props.onClick(plain)
assert.equal(plain.defaultPrevented, true)
link.props.onClick({ ...plain, defaultPrevented: false, ctrlKey: true, preventDefault() { throw new Error('a modified click must open in a tab') } })
assert.deepEqual(navigations, [['/app/models', { replace: false }]])
})
+23
View File
@@ -0,0 +1,23 @@
// Runs the node unit tests of the host-compat package and prints one JSON line
// per test, the same shape as the host's runner (core/http/react-ui/tests/unit):
// {"case":"unit: <name>","status":"passed"|"failed"|"skipped"}
import { run } from 'node:test'
import { readdirSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const here = dirname(fileURLToPath(import.meta.url))
const names = readdirSync(here).filter(f => f.endsWith('.test.mjs')).sort()
let failed = false
const stream = run({ files: names.map(f => join(here, f)), concurrency: 1 })
stream.on('test:pass', ({ name, skip, todo, nesting }) => {
if (nesting > 0) return
console.log(JSON.stringify({ case: `unit: ${name}`, status: skip || todo ? 'skipped' : 'passed' }))
})
stream.on('test:fail', ({ name, nesting, details }) => {
if (nesting > 0) return
failed = true
console.log(JSON.stringify({ case: `unit: ${name}`, status: 'failed', error: String(details && details.error && details.error.message || 'failed').slice(0, 400) }))
})
stream.on('end', () => { process.exit(failed ? 1 : 0) })
stream.resume()
@@ -0,0 +1 @@
export default function Badge() { return <b>badge</b> }
@@ -0,0 +1,4 @@
export const Fragment = Symbol.for('fragment')
export function jsx(type, props, key) { return globalThis.__fixtureSdk.React.createElement(type, key === undefined ? props : { ...props, key }) }
export const jsxs = jsx
export const jsxDEV = jsx
@@ -0,0 +1 @@
export default {}
@@ -0,0 +1 @@
export default {}
@@ -0,0 +1 @@
export default {}
@@ -0,0 +1 @@
export const useState = (v) => globalThis.__fixtureSdk.React.useState(v)
@@ -0,0 +1 @@
export const setSdk = (sdk) => { globalThis.__fixtureSdk = sdk }
@@ -0,0 +1,10 @@
import { useState } from 'react'
import Badge from './components/Badge'
import './page.css'
// Uses the automatic JSX runtime, a host module name and a component that is
// not in src/: the build answers all three from compat/.
export function Page() {
const [n] = useState(3)
return <p className="fx">compat {n}<Badge /></p>
}
@@ -0,0 +1,11 @@
import { useState } from 'react'
import { setSdk } from '../host-compat-fixture-runtime.js'
import './page.css'
export async function createAddon(sdk) {
setSdk(sdk)
const { Page } = await import('./Page.jsx')
return { Component: Page, dispose() {} }
}
export const unused = useState
@@ -0,0 +1 @@
.fx { color: red; }
@@ -0,0 +1 @@
/* own */
@@ -45,3 +45,34 @@ test('a bare React import makes the build fail', async () => {
test('a relative import of a host UI file makes the build fail', async () => {
await assert.rejects(buildAddonUI({ root: join(here, 'fixture-core'), name: 'x', rgb: [1, 2, 3] }), /host UI file .*useObjectUrl\.js/)
})
test('compat mode answers host module names from the compat directory, collects imported style sheets and writes one file', async () => {
const root = join(here, 'fixture-compat')
const dist = await buildAddonUI({ root, name: 'fx', rgb: [1, 2, 3], compat: join(root, 'compat') })
const js = readFileSync(join(dist, 'ui/fx.js'), 'utf8')
assert.doesNotMatch(js, /from\s*['"]react/)
assert.doesNotMatch(js, /^\s*import\s/m, 'the bundle is one file with no import')
assert.match(js, /export\s*\{[^}]*createAddon/)
assert.match(js, /badge/)
const css = readFileSync(join(dist, 'ui/fx.css'), 'utf8')
assert.match(css, /\/\* own \*\//)
assert.match(css, /\.fx \{ color: red; \}/)
assert.equal(css.split('.fx {').length - 1, 1, 'a sheet imported twice is added once')
})
test('compat mode still refuses a host UI file', async () => {
await assert.rejects(buildAddonUI({ root: join(here, 'fixture-core'), name: 'x', rgb: [1, 2, 3], compat: join(here, 'fixture-compat/compat') }), /host UI file .*useObjectUrl\.js/)
})
test('compat mode runs the page: the page code reaches React through the stand-ins', async () => {
const { createRequire } = await import('node:module')
const { pathToFileURL } = await import('node:url')
const require = createRequire(join(here, '../../../../core/http/react-ui/package.json'))
const React = require('react')
const { renderToString } = require('react-dom/server')
const root = join(here, 'fixture-compat')
const dist = await buildAddonUI({ root, name: 'fx', rgb: [1, 2, 3], compat: join(root, 'compat') })
const mod = await import(pathToFileURL(join(dist, 'ui/fx.js')).href + `?t=${Date.now()}`)
const addon = await mod.createAddon({ React })
assert.match(renderToString(React.createElement(addon.Component)), /<p class="fx">compat <!-- -->3<b>badge<\/b><\/p>/)
})
+68 -8
View File
@@ -6,8 +6,8 @@
// addons/tables/ui/build.mjs, for add-ons that share this builder.
import { createRequire } from 'node:module'
import { fileURLToPath } from 'node:url'
import { dirname, join, resolve, sep } from 'node:path'
import { copyFileSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { dirname, join, relative, resolve, sep } from 'node:path'
import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
import { deflateSync } from 'node:zlib'
const here = dirname(fileURLToPath(import.meta.url))
@@ -58,24 +58,84 @@ function hostGuard() {
}
}
// Compat mode, for an add-on whose source was written against the host's own
// modules (React hooks, the router, i18next, the shared components). The
// source keeps those imports; this plugin answers them from a directory of
// stand-ins that talk to the SDK, so the bundle never holds a host file:
//
// - a bare import of react, react-dom, react-router-dom or react-i18next, and
// the JSX runtime, resolves to compat/modules/<name>.js;
// - a relative import whose target is not in the add-on's src/ resolves to
// the file at the same path under compat/ (components/Icon, utils/api ...);
// - a style sheet the code imports is collected, in order, into the
// add-on's one style sheet.
const COMPAT_BARE = {
react: 'modules/react.js',
'react/jsx-runtime': 'modules/jsx-runtime.js',
'react/jsx-dev-runtime': 'modules/jsx-runtime.js',
'react-dom': 'modules/react-dom.js',
'react-router-dom': 'modules/react-router-dom.js',
'react-i18next': 'modules/react-i18next.js',
}
function compatPlugin({ srcRoot, compatSrc, sheets }) {
const tails = ['', '.js', '.jsx', '.mjs', `${sep}index.js`, `${sep}index.jsx`]
const file = (base) => tails.map((t) => base + t).find((f) => existsSync(f) && statSync(f).isFile())
return {
name: 'addon-compat',
resolveId(source, importer) {
if (COMPAT_BARE[source]) return join(compatSrc, COMPAT_BARE[source])
if (importer && source.startsWith('.') && importer.startsWith(srcRoot + sep)) {
const target = resolve(dirname(importer), source)
if (file(target)) return null
const rel = relative(srcRoot, target)
const hit = !rel.startsWith('..') && file(join(compatSrc, rel))
if (hit) return hit
}
return null
},
load(id) {
if (!id.endsWith('.css')) return null
if (!sheets.includes(id)) sheets.push(id)
return { code: '', moduleType: 'js' }
},
}
}
const reactImport = /(?:\bfrom\s*|\bimport\s*\(?\s*|\brequire\s*\(\s*)(['"])(react|react-dom)(?:\/[^'"]*)?\1/
export async function buildAddonUI({ root, name, rgb }) {
export async function buildAddonUI({ root, name, rgb, compat }) {
const out = join(root, 'dist')
const { build: bundle } = await import(require.resolve('rolldown'))
rmSync(out, { recursive: true, force: true })
mkdirSync(join(out, 'ui'), { recursive: true })
const sheets = []
const srcRoot = join(root, 'src')
const plugins = [hostGuard()]
if (compat) plugins.unshift(compatPlugin({ srcRoot, compatSrc: resolve(compat), sheets }))
await bundle({
input: join(root, 'src/index.jsx'),
// React stays a visible import so the check below can see it. The host
// gives the add-on React through the SDK, so any such import is refused.
external: [/^react(-dom)?(\/|$)/],
plugins: [hostGuard()],
output: { file: join(out, `ui/${name}.js`), format: 'esm', minify: false, sourcemap: false },
transform: { jsx: { runtime: 'classic', pragma: 'h', pragmaFrag: 'Fragment' } },
// In compat mode the plugin answers those imports with stand-ins first.
external: compat ? [] : [/^react(-dom)?(\/|$)/],
plugins,
output: {
file: join(out, `ui/${name}.js`), format: 'esm', minify: false, sourcemap: false,
// One file: a page loaded on demand is part of it.
...(compat ? { codeSplitting: false } : {}),
},
transform: { jsx: compat ? { runtime: 'automatic', importSource: 'react' } : { runtime: 'classic', pragma: 'h', pragmaFrag: 'Fragment' } },
logLevel: 'warn',
})
copyFileSync(join(root, 'src/style.css'), join(out, `ui/${name}.css`))
if (compat) {
// The add-on's own sheet first, then the sheets its code imported.
const own = join(srcRoot, 'style.css')
const parts = [existsSync(own) ? readFileSync(own, 'utf8') : '', ...sheets.filter((f) => f !== own).map((f) => readFileSync(f, 'utf8'))]
writeFileSync(join(out, `ui/${name}.css`), parts.join('\n'))
} else {
copyFileSync(join(root, 'src/style.css'), join(out, `ui/${name}.css`))
}
writeFileSync(join(out, 'ui/icon.png'), solidIcon(rgb))
const js = readFileSync(join(out, `ui/${name}.js`), 'utf8')
const bad = reactImport.exec(js)
+3 -3
View File
@@ -200,14 +200,14 @@ export default function AddonRoute() {
{reloadButton}
</div>
)}
<AddonSurface id={addonId} instance={instance} resolved={resolved || lastResolved.current} frozen={isStale} t={t} />
<AddonSurface id={addonId} instance={instance} resolved={resolved || lastResolved.current} search={location.search.replace(/^\?/, '')} frozen={isStale} t={t} />
{leaveDialog}
{reloadDialog}
</div>
)
}
function AddonSurface({ id, instance, resolved, frozen = false, t }) {
function AddonSurface({ id, instance, resolved, search = '', frozen = false, t }) {
const Component = instance.Component
const labels = useMemo(() => ({ title: t('shell.error.title'), text: t('shell.error.text'), retryLabel: t('shell.error.retry') }), [t])
if (!Component) {
@@ -221,7 +221,7 @@ function AddonSurface({ id, instance, resolved, frozen = false, t }) {
data-testid="addon-surface"
>
<AddonErrorBoundary key={`${instance.version}:${resolved ? resolved.path : ''}`} labels={labels}>
<Component page={resolved ? resolved.page : ''} path={resolved ? resolved.rest : ''} />
<Component page={resolved ? resolved.page : ''} path={resolved ? resolved.rest : ''} search={search} />
</AddonErrorBoundary>
</div>
)
+16 -1
View File
@@ -1,4 +1,5 @@
import * as React from 'react'
import { createPortal } from 'react-dom'
import Icon from '../components/Icon'
import i18n from '../i18n'
import { addonsApi } from '../utils/addonsClient'
@@ -8,7 +9,16 @@ import {
PageHeader, ConfirmDialog, ErrorWithTraceLink, ModelSelector, MediaInput, WaveformPlayer,
DistributionBars, EmbeddingInspector, TabSwitch,
} from './hostComponents'
import LoadingSpinner from '../components/LoadingSpinner'
import Modal from '../components/Modal'
import Lightbox from '../components/Lightbox'
import HomeModelPicker from '../components/home/HomeModelPicker'
import { useMediaCapture, audioBufferToWavBlob } from '../hooks/useMediaCapture'
import { useModels } from '../hooks/useModels'
import { useResources } from '../hooks/useResources'
import { useVoiceProfiles } from '../hooks/useVoiceProfiles'
import { useOperations } from '../hooks/useOperations'
import { useMCPClient } from '../hooks/useMCPClient'
import useObjectUrl from '../hooks/useObjectUrl'
import { useDelayedAction } from '../hooks/useDelayedAction'
import { basePath } from '../utils/basePath'
@@ -59,8 +69,13 @@ export const host = Object.freeze({
Button, Spinner, EmptyState, Badge, Stack, Text, ArtifactCard,
PageHeader, ConfirmDialog, ErrorWithTraceLink, ModelSelector, MediaInput, WaveformPlayer,
DistributionBars, EmbeddingInspector, TabSwitch,
LoadingSpinner, Modal, Lightbox, HomeModelPicker,
},
hooks: { useMediaCapture, useObjectUrl, useDelayedAction, audioBufferToWavBlob },
hooks: {
useMediaCapture, useObjectUrl, useDelayedAction, audioBufferToWavBlob,
useModels, useResources, useVoiceProfiles, useOperations, useMCPClient,
},
createPortal,
basePath,
Icon,
theme: { mode: themeMode, subscribe: subscribeTheme },
+54 -1
View File
@@ -22,6 +22,10 @@ export const ICON_NAMES = Object.freeze([
'arrow-left', 'arrow-right', 'chart-bar', 'circle-dot', 'clipboard', 'code', 'cube', 'filter-off', 'gender',
'globe', 'headphones', 'help-circle', 'mic', 'play', 'plug-off', 'save', 'shield', 'smile', 'waveform',
'camera', 'stop', 'circle', 'image', 'spinner', 'user-shield', 'mask',
// Drawn by the Studio and Talk add-ons.
'boxes', 'compass', 'gauge', 'ghost', 'git-branch', 'images', 'key', 'layers', 'maximize', 'memory', 'music',
'pencil', 'repeat', 'server', 'sliders', 'square-dashed', 'store', 'undo', 'video', 'volume', 'palette', 'phone',
'columns', 'chevron-left',
])
// Theme tokens as stable semantic names. Values are CSS custom properties of
@@ -125,6 +129,31 @@ export function coreRequestUrl(path, basePath = '') {
return `${basePath}${pathname}${search}`
}
const APP_PATH = /^\/app(?:\/[A-Za-z0-9._~%-]+)*\/?$/
const APP_QUERY = /^[A-Za-z0-9._~%&=+,:@/-]{0,1024}$/
// appAddress returns an address of the web app (/app/..., with an optional
// query and hash) or null. Dot segments, other schemes and API routes never pass.
export function appAddress(input) {
if (typeof input !== 'string' || input.length === 0 || input.length > 2048) return null
let rest = input
let hash = ''
const h = rest.indexOf('#')
if (h >= 0) { hash = rest.slice(h); rest = rest.slice(0, h) }
if (hash && !/^#[A-Za-z0-9._~%-]{0,128}$/.test(hash)) return null
let query = ''
const q = rest.indexOf('?')
if (q >= 0) { query = rest.slice(q); rest = rest.slice(0, q) }
if (query && !APP_QUERY.test(query.slice(1))) return null
if (!APP_PATH.test(rest)) return null
for (const segment of rest.split('/')) {
let decoded
try { decoded = decodeURIComponent(segment) } catch { return null }
if (decoded === '.' || decoded === '..' || decoded.includes('/') || decoded.includes('\\')) return null
}
return `${rest}${query}${hash}`
}
// An operation identifier is a namespace name to the owner: it starts with a
// letter and holds lower-case letters, digits and hyphens. A bare UUID that
// starts with a digit is refused, so the prefix is part of the identifier.
@@ -189,6 +218,9 @@ export function createHostSDK({ addonId, generation, signal, pages = [], binding
}
// Wrappers are created once per SDK so React sees a stable component type.
// React 19 passes `ref` to a function component as a plain prop, so a ref
// reaches the wrapped component and a host widget that exposes a handle
// (the model picker) stays usable.
const wrap = (Component, fixed) => {
if (!Component) return undefined
const Wrapped = (props) => React.createElement(Component, { ...props, ...fixed })
@@ -216,7 +248,8 @@ export function createHostSDK({ addonId, generation, signal, pages = [], binding
const known = new Set(ICON_NAMES)
const Icon = (props) => {
const name = known.has(props?.name) ? props.name : 'puzzle'
return React.createElement(host.Icon, { name, size: props?.size, title: props?.title, spin: props?.spin === true, className: props?.className })
const style = props?.style && typeof props.style === 'object' ? props.style : undefined
return React.createElement(host.Icon, { name, size: props?.size, title: props?.title, spin: props?.spin === true, className: props?.className, style })
}
Icon.displayName = 'AddonIcon'
@@ -370,6 +403,23 @@ export function createHostSDK({ addonId, generation, signal, pages = [], binding
if (!r) throw new AddonSdkError('invalid_path', 'An add-on may link only to its own declared pages')
return `${addonPrefix(addonId)}/${r.path}${r.search ? `?${r.search}` : ''}`
},
// Opens a page of the app itself (a core page or a page of another
// add-on), for a link that leaves this add-on. Only /app/... addresses
// pass: no other origin, scheme or API route.
openApp(address, options) {
alive()
const target = appAddress(address)
if (!target) throw new AddonSdkError('invalid_path', 'An add-on may open only addresses under /app')
// The only state a page may hand over is the way back: where it was and
// what to call it, as short strings (the Model Editor reads these).
const from = options?.state?.from
const fromLabel = options?.state?.fromLabel
const state = typeof from === 'string' && from.startsWith('/app') && from.length <= 2048 && typeof fromLabel === 'string' && fromLabel.length <= 80
? { from, fromLabel }
: undefined
bindings.navigate?.(target, { replace: options?.replace === true, ...(state ? { state } : {}) })
return true
},
current: () => {
const c = bindings.current?.() || {}
return { page: String(c.page || ''), path: String(c.path || ''), search: String(c.search || '') }
@@ -382,6 +432,9 @@ export function createHostSDK({ addonId, generation, signal, pages = [], binding
// A write is only ever stored as a proposal; the host confirms it.
propose: (request, options) => callApi('propose', request || {}, options),
}),
// Draws children into a node outside the add-on's own tree (a full-screen
// sheet). It is the host's portal; the node must be in the page.
portal: (children, node) => (host.createPortal && node ? host.createPortal(children, node) : children),
resources,
// Reads that answer with private files. The result is the host's own
// description of each file (identifier, name, media type, size): the
+1 -1
View File
@@ -82,7 +82,7 @@ test('sdk: the object is a frozen allowlist that carries the host React and no h
assert.equal(Object.isFrozen(sdk), true)
assert.deepEqual(Object.keys(sdk).sort(), [
'Icon', 'React', 'addonId', 'api', 'approvals', 'core', 'files', 'generation', 'hooks', 'iconNames', 'isDirty', 'layers', 'locale', 'markDirty', 'navigation',
'notify', 'onDispose', 'resources', 'signal', 'theme', 'ui', 'version', 'viewer',
'notify', 'onDispose', 'portal', 'resources', 'signal', 'theme', 'ui', 'version', 'viewer',
])
assert.deepEqual(Object.keys(sdk.api).sort(), ['propose', 'read'])
assert.deepEqual(Object.keys(sdk.ui).sort(), ['Button', 'Text'])
+58 -1
View File
@@ -2,7 +2,7 @@ import test from 'node:test'
import assert from 'node:assert/strict'
import * as React from 'react'
import { renderToString } from 'react-dom/server'
import { createHostSDK, coreRequestUrl, ICON_NAMES, SDK_VERSION } from '../../src/addons/sdk.js'
import { createHostSDK, appAddress, coreRequestUrl, ICON_NAMES, SDK_VERSION } from '../../src/addons/sdk.js'
const C = (tag) => (p) => React.createElement(tag, { 'data-props': JSON.stringify(Object.keys(p).sort()) }, p.children)
const NAMES = ['PageHeader', 'ConfirmDialog', 'ErrorWithTraceLink', 'ModelSelector', 'MediaInput', 'WaveformPlayer', 'DistributionBars', 'EmbeddingInspector', 'TabSwitch']
@@ -125,3 +125,60 @@ test('sdk.core.fetch aborts an in-flight call when the add-on is withdrawn', asy
await assert.rejects(p, /aborted/)
assert.equal(seen.aborted, true)
})
test('appAddress accepts addresses under /app and nothing else', () => {
for (const ok of ['/app', '/app/chat', '/app/models/edit/a%20b', '/app/addons/talk/talk?model=x%2Fy&work=1', '/app/studio#top']) {
assert.equal(appAddress(ok), ok, ok)
}
for (const bad of ['', 'app/chat', '//evil.example/app', 'https://evil.example/app', '/api/models', '/app/../api/models', '/app/%2e%2e/x', '/app/a%2Fb/../c', '/app\\evil', '/apps', '/app/x?<script>', '/app/x#a b', null, 7]) {
assert.equal(appAddress(bad), null, String(bad))
}
})
test('sdk.navigation.openApp hands only an /app address to the shell, with the way back as the only state', () => {
const seen = []
const controller = new AbortController()
const sdk = createHostSDK({
addonId: 'talk', generation: 1, signal: controller.signal, pages: ['talk'],
host: { React, Icon: () => null, language: () => 'en', theme: {}, api: {} },
bindings: { navigate: (path, options) => seen.push([path, options]) },
})
assert.equal(sdk.navigation.openApp('/app/models'), true)
sdk.navigation.openApp('/app/model-editor/x', { state: { from: '/app/addons/talk/talk?a=1', fromLabel: 'Talk', secret: 'no' } })
sdk.navigation.openApp('/app/chat', { replace: true, state: { from: 'https://evil.example', fromLabel: 'x' } })
assert.deepEqual(seen, [
['/app/models', { replace: false }],
['/app/model-editor/x', { replace: false, state: { from: '/app/addons/talk/talk?a=1', fromLabel: 'Talk' } }],
['/app/chat', { replace: true }],
])
assert.throws(() => sdk.navigation.openApp('/api/models'), (e) => e.code === 'invalid_path')
assert.throws(() => sdk.navigation.openApp('https://evil.example/app'), (e) => e.code === 'invalid_path')
controller.abort('disabled')
assert.throws(() => sdk.navigation.openApp('/app/models'), (e) => e.code === 'withdrawn')
})
test('sdk.portal draws into the host portal when a node is given and returns the children otherwise', () => {
const portals = []
const { sdk } = make({ createPortal: (children, node) => { portals.push([children, node]); return 'portal' } })
assert.equal(sdk.portal('kid', { id: 'node' }), 'portal')
assert.deepEqual(portals, [['kid', { id: 'node' }]])
assert.equal(sdk.portal('kid', null), 'kid')
})
test('sdk.Icon passes a style object and the Studio and Talk icon names are allowed', () => {
const seen = []
const { sdk } = make({ Icon: (p) => { seen.push(p); return null } })
renderToString(React.createElement(sdk.Icon, { name: 'palette', style: { color: 'red' } }))
renderToString(React.createElement(sdk.Icon, { name: 'phone', style: 'not-an-object' }))
assert.deepEqual(seen.map(p => [p.name, p.style]), [['palette', { color: 'red' }], ['phone', undefined]])
for (const n of ['palette', 'phone', 'boxes', 'compass', 'music', 'video', 'undo', 'square-dashed', 'layers']) assert.ok(ICON_NAMES.includes(n), n)
})
test('a ref reaches a host widget through its sdk.ui wrapper (React 19 passes ref as a prop)', () => {
const seen = []
const Widget = React.forwardRef((props, ref) => { seen.push(typeof ref); return null })
const { sdk } = make({ components: { HomeModelPicker: Widget } })
const ref = React.createRef()
renderToString(React.createElement(sdk.ui.HomeModelPicker, { ref }))
assert.deepEqual(seen, ['object'])
})