mirror of
https://github.com/mudler/LocalAI.git
synced 2026-10-28 17:18:49 -04:00
A visual pass over the add-on pages found the same few problems on several of them. Fix them with one rule per problem instead of page by page. Titles: every add-on page takes the host page-title size. Talk and Tables no longer shrink it, Studio draws one "Studio" title above its tabs (the card of a generator is a section), and Voice keeps one title on all its tabs. The Text to speech tab gets a styled form. The "not available" page shows the add-on name, not the lowercase id, and sits at the same inset as an add-on page. One primary action: the empty state of a list carries the add button, so the header button waits until there is something in the list (Voice library, enrolling a speaker or a person, Add rows). The Add-ons page shows one way into Browse. A table with no columns says so once. Tables: Share is hidden when there are no named accounts. The active table tab is highlighted and an empty tab list leaves no gap before "New table". Studio: the tab strip stays on one row at 1280 wide, /studio/3d opens the 3D tab, and an input chip has a divider between its label and its placeholder. Fine-tune and Quantize: the Experimental badge sits beside the lede, not inside the h1; an open step shows its number instead of an empty circle; the sticky action bar keeps a gap above it. The footer shows nothing for the empty version of a source build, and the Voice Text to speech tab says where to get a model when none is installed. Assisted-by: Claude:claude-sonnet-5-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
97 lines
5.5 KiB
JavaScript
97 lines
5.5 KiB
JavaScript
import test from 'node:test'
|
|
import assert from 'node:assert/strict'
|
|
import { createRequire } from 'node:module'
|
|
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
import { dirname, join, resolve } from 'node:path'
|
|
import { build } from '../build.mjs'
|
|
|
|
const here = dirname(fileURLToPath(import.meta.url))
|
|
const hostUi = resolve(here, '../../../../core/http/react-ui')
|
|
const require = createRequire(join(hostUi, 'package.json'))
|
|
const React = require('react')
|
|
const { renderToString } = require('react-dom/server')
|
|
|
|
export async function load(extra = {}) {
|
|
const dir = await build()
|
|
const mod = await import(pathToFileURL(join(dir, 'ui/voice.js')).href + `?t=${Date.now()}`)
|
|
const { createHostSDK } = await import(pathToFileURL(join(hostUi, 'src/addons/sdk.js')).href)
|
|
const stub = (tag) => (p) => React.createElement(tag, { 'data-stub': tag }, p.title || p.label || p.children || null)
|
|
const calls = []
|
|
const controller = new AbortController()
|
|
const sdk = createHostSDK({
|
|
addonId: 'voice', generation: 1, signal: controller.signal, pages: ['voice', 'recognition', 'library', 'create', 'speech'],
|
|
host: {
|
|
React,
|
|
components: Object.fromEntries(['Button', 'Spinner', 'EmptyState', 'Badge', 'Stack', 'Text', 'PageHeader', 'ConfirmDialog', 'ErrorWithTraceLink', 'ModelSelector', 'MediaInput', 'WaveformPlayer', 'DistributionBars', 'EmbeddingInspector', 'TabSwitch'].map(n => [n, n === 'PageHeader' ? (p => React.createElement('header', { 'data-stub': 'header' }, p.title ? React.createElement('h1', null, p.title) : null, p.supporting)) : stub('div')])),
|
|
hooks: { useMediaCapture: () => ({}), useObjectUrl: () => null, useDelayedAction: () => ({ pending: null, schedule() {}, undo() {} }) },
|
|
viewer: { isAdmin: () => true, hasFeature: () => true },
|
|
basePath: '',
|
|
fetch: async (url, init) => { calls.push([url, init]); return { ok: true, json: async () => ({ data: [] }), blob: async () => new Blob([]) } },
|
|
Icon: () => null, theme: { mode: () => 'light', subscribe: () => () => {} }, language: () => 'en', subscribeLanguage: () => () => {}, api: {},
|
|
...extra,
|
|
},
|
|
bindings: { current: () => ({ page: 'recognition', path: '', search: '' }), openLayer: () => ({ close() {} }), notify() {} },
|
|
})
|
|
return { mod, sdk, controller, calls }
|
|
}
|
|
|
|
test('render: createAddon returns a page component and dispose', async () => {
|
|
const { mod, sdk, controller } = await load()
|
|
const addon = await mod.createAddon(sdk)
|
|
assert.equal(typeof addon.Component, 'function')
|
|
assert.equal(typeof addon.dispose, 'function')
|
|
addon.dispose()
|
|
controller.abort('done')
|
|
})
|
|
|
|
test('render: an unknown page draws nothing', async () => {
|
|
const { mod, sdk } = await load()
|
|
const addon = await mod.createAddon(sdk)
|
|
assert.equal(renderToString(React.createElement(addon.Component, { page: 'nope', path: '' })), '')
|
|
})
|
|
|
|
test('render: the manifest declares one page in the navigation, the older pages, the main page and SDK 2', async () => {
|
|
const { readFileSync } = await import('node:fs')
|
|
const m = JSON.parse(readFileSync(join(here, '../../manifest.json'), 'utf8'))
|
|
assert.equal(m.id, 'voice')
|
|
assert.deepEqual(m.capabilities.ui.pages, ['voice', 'recognition', 'library', 'create', 'speech'])
|
|
assert.deepEqual(m.capabilities.ui.navigation, ['voice'])
|
|
assert.equal(m.capabilities.ui.mainPage, 'voice')
|
|
assert.deepEqual(m.capabilities.ui.sdk, { min: 2, max: 2 })
|
|
assert.deepEqual(m.capabilities.tools ?? [], [])
|
|
})
|
|
|
|
test('render: a tab is drawn with the rest of the path and the tab strip', async () => {
|
|
const { mod, sdk } = await load()
|
|
const addon = await mod.createAddon(sdk, { pages: { speech: ({ path, tabs }) => React.createElement('div', null, tabs, React.createElement('p', null, `page:${path}`)) } })
|
|
const html = renderToString(React.createElement(addon.Component, { page: 'voice', path: 'speech/abc' }))
|
|
assert.match(html, /<p>page:abc<\/p>/)
|
|
assert.match(html, /data-testid="voice-tabs"/)
|
|
assert.match(html, /data-tab="speech"[^>]*aria-selected="true"/)
|
|
})
|
|
|
|
test('render: no tab in the path opens the recognition tab', async () => {
|
|
const { mod, sdk } = await load()
|
|
const addon = await mod.createAddon(sdk)
|
|
assert.match(renderToString(React.createElement(addon.Component, { page: 'voice', path: '' })), /data-testid="voices-page"/)
|
|
})
|
|
|
|
test('render: an older page name draws nothing itself; the shell sends it to its tab', async () => {
|
|
const { mod, sdk } = await load()
|
|
const addon = await mod.createAddon(sdk)
|
|
for (const page of ['recognition', 'library', 'create', 'speech']) {
|
|
assert.equal(renderToString(React.createElement(addon.Component, { page, path: '' })), '', page)
|
|
}
|
|
})
|
|
|
|
test('render: each tab draws exactly one h1, the one Voice title (the add form keeps its own)', async () => {
|
|
const { mod, sdk } = await load()
|
|
const addon = await mod.createAddon(sdk)
|
|
const h1s = (html) => [...html.matchAll(/<h1[^>]*>(.*?)<\/h1>/g)].map(m => m[1].replace(/<[^>]+>/g, '').trim())
|
|
assert.deepEqual(h1s(renderToString(React.createElement(addon.Component, { page: 'voice', path: '' }))), ['Voices'])
|
|
assert.deepEqual(h1s(renderToString(React.createElement(addon.Component, { page: 'voice', path: 'recording' }))), ['Voices'])
|
|
assert.deepEqual(h1s(renderToString(React.createElement(addon.Component, { page: 'voice', path: 'speech' }))), ['Voices'])
|
|
assert.deepEqual(h1s(renderToString(React.createElement(addon.Component, { page: 'voice', path: 'library' }))), ['Voices'])
|
|
assert.deepEqual(h1s(renderToString(React.createElement(addon.Component, { page: 'voice', path: 'library/new' }))), ['Add a speech voice'])
|
|
})
|