feat(addons): installable add-ons foundation and the Tables add-on (#12702)

* feat(addons): add the add-on runtime, navigation and first-party add-ons

Add the add-on runtime to LocalAI: manifests, the host operation broker,
admin gating by account role, the add-on UI bundle builder and the SDK
for add-on pages. Move the navigation to Chat, Models, Add-ons and
Settings, with pinned add-ons in the sidebar and an Add-ons drawer.

Ship the first-party add-ons (voice, face, fine-tuning, quantization) as
packages for linux/amd64, linux/arm64 and darwin/arm64. Intel macOS
(darwin/amd64) is enabled in the platform files.

Squashed from the review branch. The tree is unchanged; the pre-squash
history is kept on a local backup branch.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(gosec): tighten file modes and annotate false positives

Write job state, configs and imported models with mode 0600, clean
the paths that are read from disk, and mark the route tables, the
header name and the retry jitter as reviewed false positives.

Assisted-by: Claude Code:claude-sonnet-5-5 gosec
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(addons): keep API keys off recovery mutations and same-site cookies

POST /api/addons/dispatch forwarded the recovery and update
mutations to the owner for any administrator key, which skipped the
browser-session rule of their own routes. Refuse those actions
there. Also apply the same-origin rule to the legacy token cookie,
not only the session cookie, because the global CSRF check lets
same-site requests through.

Do not fail the start of LocalAI when an add-on library cannot be
refreshed; log it and keep the installed add-ons up.

Assisted-by: Claude Code:claude-sonnet-5-5 go
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(addons): refuse a reinstall that would migrate retained data unbacked

An upgrade takes a verified backup before the new package migrates
the data. A reinstall over retained data has no running generation
to stop, so the new package would migrate it without one. Refuse a
reinstall whose data format differs from the retained one.

Assisted-by: Claude Code:claude-sonnet-5-5 go
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(addons): release the manager lock while a release is fetched

Submit held the state lock across the catalog lookup and the package
download, so the apps list and status reads stalled for the whole
install. Serialize Submit and Reconcile with their own lock and drop
the state lock around the fetch only.

Assisted-by: Claude Code:claude-sonnet-5-5 go
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(addons): kill an add-on only after three missed health probes

One slow 100 ms probe killed a busy add-on and dropped the work it
was serving. Require three misses in a row.

Assisted-by: Claude Code:claude-sonnet-5-5 go
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(ui): move settings under the operate hub

The Operate hub already has Settings as a tab, so the sidebar no longer shows a separate Settings entry. The /app/settings route stays, and the hub tab still reaches it. The navigation test now expects Chat, Models and Add-ons as the primary items.

Assisted-by: Claude Code:claude-sonnet-5-5 node
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(addons): accept same-host browser requests that carry no fetch metadata

Browsers do not send Sec-Fetch-* headers over plain HTTP on an address
that is not localhost, so every add-on request from a LAN address was
refused as cross-site. Keep refusing a Sec-Fetch-Site value other than
same-origin. When the header is absent, accept the request only if its
Origin, or failing that its Referer, names the same host and port as
the request.

Assisted-by: Claude Code:claude-sonnet-5-5 go
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(ui): show the server's reason when an add-on request is refused

A 403 or 400 answer showed a generic message and hid the reason in the
response body, so a refusal by the browser guard could not be told from
a missing permission. Show error.message from the body as the server
sent it, and keep the generic text when the body has none.

Assisted-by: Claude Code:claude-sonnet-5-5 node
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(addons): run the add-on owner in the normal binary

The owner was only enabled when an embedding host set the add-on config,
so a normal run answered every add-on request as unavailable.

Add --addons-enabled (default true) and --addons-ownership-mode
(default named-user, or local-owner; anything else stops startup).
The node is its own owner: state lives under the data path, and the
carrier uses loopback keys made at each start and kept in memory.
--addons-enabled=false turns the owner off completely.

Named-user mode needs the account database. With authentication off
the owner stays off and a warning says how to use local-owner.

Assisted-by: Claude Code:claude-sonnet-5-5 go
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(addons): keep the loopback owner address stable across restarts

The single-node loopback transport took a new ephemeral port at each
start. The owner stores its address in its identity file and refuses to
start when the address differs, so every start after the first failed
with "invalid addon owner configuration".

Choose the port once, keep it in the add-on state root (loopback-port,
mode 0600) and reuse it. If the port is taken at a later start, stop
with an error that names the port and --addons-enabled=false, because
another port would break the identity check. The keys and certificate
stay in memory.

Assisted-by: Claude Code:claude-sonnet-5-5 go
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* 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>

* feat(addons): ship Studio and Talk as add-ons instead of built-in pages

Studio and Talk were compiled into the web bundle and always listed in
the Add-ons drawer. They are add-ons now, like Voice, Face, fine-tuning
and quantization: a person installs and removes them, the drawer lists
them only while they are installed, and they are packaged the same way.

Move the pages, their components, hooks and helpers into addons/studio
and addons/talk. Each has a manifest, a UI bundle, a release platform
matrix and a catalog template, and is built by the compat mode of the
shared builder. The core API stays where it is: the pages call it with
the signed-in person's session. The history keeps its browser storage
keys. The unit tests of the moved helpers move with them.

In the core, drop the two pages, the Create group of the sidebar (Chat
joins Home and Models in the old layout), the built-in catalog entries
(the catalog is empty) and the talk locale namespace. The old addresses
(/app/studio, /app/talk and the generator links) redirect to the add-on
pages, with the model as ?model=. The Home /studio command is offered
only while the Studio add-on is installed and opens it.

The release workflow and its tests now list seven add-ons.

Assisted-by: Claude Code:claude-sonnet-5-5 go node
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(ui): run the Studio and Talk specs against the add-on bundles

The pages are add-ons now, so a spec that draws one installs the real
built bundle first: the apps list names it and its module and style
sheet are served from the build output, the way the add-on owner serves
them. studio-addon.js holds that helper, and the Studio and Talk
fixtures call it.

Update the specs for the new addresses (/app/addons/studio/studio, the
model as ?model=), the session permission the add-on route needs, the
legacy redirects, the Home /studio command that appears only while the
add-on is installed, and the sidebar without a Create group.

Add specs for the add-ons themselves: every tab draws with no script
error, the shell titles the page once, the old addresses say so when the
add-on is not installed, a button of the page reaches a core page with
the way back, and the drawer lists both only after they are installed.

Assisted-by: Claude Code:claude-sonnet-5-5 node
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(ui): centre the icon of a built-in tile in its icon box

The drawer drew the icon of a built-in tile (Studio's palette was one)
in a bare 40px box. Only the placeholder box was laid out as a centred
grid with the 20px size, so the real icon sat in the top-left corner at
the size of the page text.

Give the built-in tile the same classes as the placeholder box, and
check in a browser that the icon is centred.

Assisted-by: Claude Code:claude-sonnet-5-5 node
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* docs(addons): describe Studio and Talk as add-ons

Replace the built-in list (now empty) with the Studio and Talk add-on
sections, the new targets of the old links, the Home command, and the
seven packaged add-ons. Describe the SDK additions the pages use:
components, hooks, icons, openApp and portal. Say on the Studio page
that it is installed from the drawer.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(ui): click the shell title in the Studio slash-key spec

The add-on shell titles the page, and the Studio overview no longer draws
a second title, but the generator tabs keep theirs, so a bare h1 locator
matched two elements. Click the first, which is the shell's.

Assisted-by: Claude Code:claude-sonnet-5-5 node
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(addons): read the loopback port file through os.Root

The persisted port file was read with a path built from the add-on
root, which gosec reports as a file inclusion through a variable. Open
it through os.Root so the name cannot leave the root, even by a
symbolic link, and require a regular file with no group or other
access. A looser file now fails closed instead of being trusted.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(ui): expect Settings under the Operate hub, not the sidebar

Settings moved out of the sidebar into the Operate hub. The navigation
spec still expected a sidebar link. Assert that the sidebar has no
Settings entry, and that the Operate hub opens Settings as a tab.

Assisted-by: Claude Code:claude-sonnet-5-5 playwright
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(ui): drop Settings from the 404 places

Settings is a tab of the Operate hub and not a destination of its own,
so the 404 page does not list it. Assert that it is absent.

Assisted-by: Claude Code:claude-sonnet-5-5 playwright
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(ui): drive the media history and TTS specs through the Studio add-on

The image, video, sound and speech generators are pages of the Studio
add-on now, and the old addresses redirect to it. Install the add-on
with the Studio fixtures, open its pages and use its compose card, so
the specs run against the page that exists.

Assisted-by: Claude Code:claude-sonnet-5-5 playwright
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(ui): count the first-run steps, not every list item

The recommended models are a list inside the second step, so counting
all list items read 3 only while the models were still loading. Count
the step elements, which does not depend on the load order.

Assisted-by: Claude Code:claude-sonnet-5-5 playwright
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(ui): fail the node refresh by state, not by call count

The spec served the list to the first request and failed the rest. The
first request is not always the one from the page, so the page could
get the failure first and show no list. Serve the list until the page
shows it, then fail the refreshes.

Assisted-by: Claude Code:claude-sonnet-5-5 playwright
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(addons): default to local-owner when auth is off

A server without authentication has no accounts, so named-user left
the add-on owner off. Resolve the unset mode in one place: local-owner
when authentication is off, named-user when it is on. A flag or
environment value still wins, and an unknown value is still refused.

Assisted-by: Claude Code:claude-sonnet-5-5 go
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(addons): register an official add-on library by default

Add gallery/addons/index.yaml, a YAML index of the four first-party
add-ons (fine-tuning, quantization, voice, face) in the library format.
The entries come from addonpkg catalog over locally built linux/amd64
packages, so the digests pin those images.

On the first start, when the owner has no sources.json, register the
raw GitHub URL of that index on master as the "official" library and
write the file. The file is then the record: a library the admin
removes stays removed, and an existing file is never changed.
--addons-official-library=false skips the default.

Assisted-by: Claude Code:claude-sonnet-5-5 go node
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* docs(addons): document the ownership default and the official library

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(addons): list Studio and Talk in the first-party index

Add the Studio and Talk entries to gallery/addons/index.yaml next to
the four existing ones. They come from addonpkg catalog over locally
built linux/amd64 packages, so the digests pin those images.

Assisted-by: Claude Code:claude-sonnet-5-5 go node
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(addons): expect Studio and Talk in the official index

The official index now lists six add-ons. Update the test that
decodes it so it accepts Studio and Talk.

Assisted-by: Claude Code:claude-sonnet-5-5 go
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(addons): add a real icon to each first-party add-on

Each icon is a 256 by 256 PNG: a rounded square in one colour of the
UI kit palette with a white outline glyph from the kit sprite. Fine-tuning
uses sliders, quantization layers, voice waveform, face user, studio
palette, talk phone and tables database. They were rendered with headless
Chrome from the sprite paths.

Assisted-by: Claude Code:claude-sonnet-5-5 chrome
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(addons): ship a committed icon.png instead of the generated square

The shared UI builder and the Tables builder copy ui/icon.png from the
add-on source tree to the package unchanged when the file exists. They
write the generated placeholder only when it does not. Add a fixture with
an icon and a test for both cases.

Assisted-by: Claude Code:claude-sonnet-5-5 node
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(addons): list the tables add-on in the first-party index

Add the Tables entry to gallery/addons/index.yaml next to the six
existing ones. It comes from addonpkg catalog over a locally built
linux/amd64 package, so the digest pins that image. The official index
test now expects seven add-ons.

Assisted-by: Claude Code:claude-sonnet-5-5 go node
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(addons): make the Voice add-on one page with tabs

The Voice add-on had four pages: recognition, library, create and
speech, and the drawer listed two of them. It now has one page, "voice",
and one drawer entry. Recognition, Library, Text to speech and From a
recording are tabs of that page, drawn with the same tab strip the
recognition page already used. The tab is the first path segment
(voice/library), as in the Studio add-on, and the form that adds a voice
is a view of the Library tab (voice/library/new).

The old page names stay declared in the manifest. Each one redirects to
its tab and keeps the query string, so saved links still open.

Assisted-by: Claude Code:claude-sonnet-5-5 node
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(ui): send the old voice routes to the Voice tabs

/app/voice, /app/voice-library and /app/voice-library/new now open the
matching tab of the Voice add-on page while the add-on is installed and
running, and the core pages otherwise. The moved-page helper compares
the first segment of the target with the declared pages, so a target
can name a tab. The docs and the navigation spec list the new addresses.

Assisted-by: Claude Code:claude-sonnet-5-5 node
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(addons): cover the Voice tabs and the old page redirects

The add-on unit tests render the tabs instead of the four pages. The
Playwright spec checks the tab strip, the address of each tab, the
missing Library tab for a member, and that the old page addresses of the
add-on redirect to their tab with the query string kept. The migration
table test follows the new targets.

Assisted-by: Claude Code:claude-sonnet-5-5 node
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(studio): keep the model menu inside a narrow screen

The Home model picker sizes its menu from the viewport, and assumes the
picker sits at the page gutter. In the Studio composer card the field is
further in, so on a 390px screen the menu of the Sound expansion picker
ended 7px past the right edge. Size the menu from the field it opens
under instead.

Assisted-by: Claude Code:claude-sonnet-5-5 node
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(ui): build the add-on bundles once for the whole run

Each worker built the Studio and Talk bundles into the same dist
directory, and the build empties that directory first. A worker that
started, or restarted after a failure, removed the files another worker
was serving, so a Studio spec saw "the style sheet of the add-on could
not be loaded". Build once in a global setup before the workers start.
A run without the setup still builds per worker.

Assisted-by: Claude Code:claude-sonnet-5-5 node
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(ui): register the worker when the add-node spec says so

The mocked roster added the worker on the third read of /api/nodes. The
shell and the page both read it, and which one asks first depends on
load, so the page could take the new worker as part of the roster it
opened with and never show it as found. The worker now joins after the
page shows it is listening.

Assisted-by: Claude Code:claude-sonnet-5-5 node
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(ui): hold the gallery answer until the skeleton is seen

The spec delayed the answer by a fixed 800 ms and then looked for the
skeleton. The assertion polls at growing intervals, so on a slow machine
it could step over that window. The answer now waits until the skeleton
has been seen, and the spec then checks that the skeleton goes away.

Assisted-by: Claude Code:claude-sonnet-5-5 node
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(ui): give the PDF tag the time the other PDF specs have

pdf.js and its worker load on first use. The other specs in this file
wait up to ten seconds for the result of that load. The first one waited
five, and missed the tag on a busy machine.

Assisted-by: Claude Code:claude-sonnet-5-5 node
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(ui): build the Face and Fine-tuning bundles in the global setup

The navigation spec serves the built Fine-tuning bundle, but nothing
built it, so two specs failed on a fresh checkout when the dist folder
was missing. The spec also built the Face bundle in a hook that every
worker runs, and a build rewrites the folder the other workers serve.

The global setup now builds Studio, Talk, Face and Fine-tuning once,
before the workers start. The spec builds the two bundles itself only
when it runs without the global setup.

Assisted-by: Claude Code:claude-sonnet-5-5 node
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(ui): fix the Home answers in the stale pin spec

The spec asserts that the page shows no alert. The Home page raises its
own alert when the model list fails or a staging operation has failed,
and both answers come from the shared test server. A slow reply or a
failed install left there counted as an alert about the pin.

The spec now fixes both answers, so the alert count tells only about the
pin. The assertions are unchanged.

Assisted-by: Claude Code:claude-sonnet-5-5 node
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(ui): read the server address in the add-node spec from the page

The spec expected the register address to carry port 8089, so it failed
on any other port. It now expects the origin the page was opened from.

Assisted-by: Claude Code:claude-sonnet-5-5 node
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(addons): give the navigation routes spec its own catalog

No page is built in today, so the catalog is empty and the three specs
that expect the studio and talk entries failed on the list and on the
pin seed. They passed only while Studio and Talk were built in.

The spec now sets a catalog of two ungated entries for each example and
restores the old one afterwards. The assertions are unchanged.

Assisted-by: Claude Code:claude-sonnet-5-5 go
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(cli): report a failed write of the MLX hostfile

The tunnel callback ignored the error of os.WriteFile and set the
hostfile variable even when the file was not written. It now logs the
error and leaves the variable alone.

Assisted-by: Claude Code:claude-sonnet-5-5 go
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(addons): discard the close error of the held listener

errcheck flags the deferred Close of the listener that keeps the port
busy. The test has no use for that error, so the defer drops it
explicitly.

Assisted-by: Claude Code:claude-sonnet-5-5 go
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(addons): move the add-on tool tests to Ginkgo

The fine-tuning, quantization and add-on host library tests used
t.Errorf, t.Fatal and t.Run, which the forbidigo rule rejects. They now
use Describe, It and Gomega, with a suite bootstrap in each package. The
cases and the assertions are the same.

The tables in the host client and the tool schema tests keep one entry
per case.

Assisted-by: Claude Code:claude-sonnet-5-5 go
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(addons): move the host operation tests to Ginkgo

The hostops tests used the testing package calls that forbidigo rejects.
They now run as Ginkgo specs under one suite bootstrap. Every check and
every per-operation case is kept.

Assisted-by: Claude Code:claude-sonnet-5-5 go
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(addons): move the pin store tests to Ginkgo

The pin service, file store, sweep and shared store contract tests used
the testing package calls that forbidigo rejects. The store contract is
now a function that registers its specs for each store, so the file
store, the memory-only store and the database store run the same cases.

Assisted-by: Claude Code:claude-sonnet-5-5 go
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(addons): move the remaining add-on tests to Ginkgo

The built-in catalog, tool arguments, loopback carrier, host operation
contract, packaging, application, config, CLI and endpoint tests used
the testing package calls that forbidigo rejects. They now run as Ginkgo
specs. The endpoint tests also drop the unchecked Close errors that
errcheck reports when the auth tag is on.

Assisted-by: Claude Code:claude-sonnet-5-5 go
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(ui): wait for the targeted Reinstall button before the install click

The "generic deletion blocks targeted install at confirmation" spec
dispatches a popstate to set the target node, then clicks Reinstall with
a script click. The click can run before the targeted page has rendered
or while the button is still disabled, so no install request is sent and
the poll on the install count times out. It failed 1 run in 10 with two
workers and on every retry in CI.

The spec now waits for the button to be enabled first. The assertions
are unchanged.

Assisted-by: Claude Code:claude-sonnet-5-5 playwright
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* chore(addons): refresh the voice package digest in the first-party index

The Voice add-on became one page with tabs and got a new icon, so the
digest in gallery/addons/index.yaml no longer matched the build. Render
the entry again with addonpkg catalog over a locally built linux/amd64
package; the main page is now voice. The other entries are unchanged.

Assisted-by: Claude Code:claude-sonnet-5-5 go node
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(ui): wait for the target node's Reinstall button after the popstate

The "generic deletion blocks targeted install at confirmation" spec
dispatches a popstate to set the target node, then waits for a button
named Reinstall and clicks it. Until React renders the targeted page,
the page still draws the generic Reinstall button. It is enabled and has
the same name, so the wait passed on it, the click started a global
install, and no node install request was sent. The poll on the install
count timed out with the confirm dialog still open.

With the CPU throttled to 8x, the spec failed on 20 of 20 runs, and the
button it clicked had the title "Reinstall". The spec now waits for the
button titled "Reinstall on <node>". With the throttle it passes 30 of
30 runs. The assertions are unchanged.

Assisted-by: Claude Code:claude-sonnet-5-5 playwright
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(ui): give the Studio hand-off spec a budget for the cold bundle load

The "a result made through a hand-off records which result it came from"
spec opens the Studio add-on straight from an address, twice. Each is a
cold load: the page shows "Loading" until the add-on module has run, and
the next assertion had the default five seconds. The load takes about
two seconds on an idle run and seven to ten under CPU load, so the spec
failed on a loaded machine with the hand-off note not yet drawn.

With the CPU throttled to 6x and eight busy loops running, it failed on
21 of 30 runs. The first assertion after each load now waits up to 20
seconds, and the spec then passed 30 of 30 under the same load. The
assertions are unchanged.

Assisted-by: Claude Code:claude-sonnet-5-5 playwright
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(addons): carry the identity page styles in the Face add-on

The Face page is drawn with the shared identity views, but its style sheet
held only the clip and error rules. The page, card, model list, known-faces
table, enrol sheet and distance scale classes had no rules. They looked
right only when the host had already loaded the style sheet of its own
Faces page, which a visit through the add-on drawer does not do. Opened
that way, the cards had no gap, the model list showed bullets, and the
install buttons ran into the names.

The Face sheet now holds the same identity rules as the core sheet, so the
page looks the same whichever way it is opened.

Assisted-by: Claude Code:claude-sonnet-5-5 playwright
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(addons): scope the narrow-screen identity rules of the Voice add-on

The rules for narrow screens (single-column pairs, smaller tool padding,
compact rank rows) were copied into the Voice style sheet without their
media query, so they applied at every width. They now sit in a
max-width 720px query, and the reduced-motion rule in its own query, as in
the core sheet.

Assisted-by: Claude Code:claude-sonnet-5-5 playwright
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(addons): drop the repeated title from the Voice and Face pages

The add-on shell already titles the page, so the recognition pages showed the name twice: "Voice" above "Voices", "Face" above "Faces". Keep the supporting line and the model picker, as the Studio page does, and remove the unused title strings.

Assisted-by: Claude Code:claude-sonnet-5-5 node playwright
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(ui): replace the Pinned sidebar section with an Apps entry

The sidebar no longer lists pinned add-ons. It shows one Apps entry that
opens the add-ons drawer, with a Show all link below it that opens the
same drawer. Pins are used from the drawer, where a pinned tile keeps
its pin and move buttons. The server pins API is unchanged.

Remove the PinnedNav component and its locale title, rename the
items.addons label to Apps, and update the e2e specs and the add-ons
documentation to match.

Assisted-by: Claude Code:claude-sonnet-5-5 playwright
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(addons): assert the single shell title on the Voice and Face pages

The add-on shell now shows the one title, "Voice" or "Face", and the page no longer repeats it. The tests still looked for the h1 "Voices" and "Faces". Assert that the add-on page has exactly one h1 and that it is the shell title. The core pages keep their own "Voices" and "Faces" titles.

Assisted-by: Claude Code:claude-sonnet-5-5 playwright
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(addons): drop the repeated title from the Voice Library and Text to speech pages

The add-on shell already titles the page "Voice", so these two tabs showed a second heading below it ("Speech voices", "Speak text"). Keep the supporting line, as the Studio and recognition pages do, and remove the unused title strings. The e2e spec now checks that each tab has exactly one h1, the shell title.

Assisted-by: Claude Code:claude-sonnet-5-5 node playwright
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(ui): list pinned apps under Apps in the sidebar

A pinned app now shows as an indented child of the Apps entry, with its icon and title, in pin order. Show all stays the last child. The pins come from GET /api/addons/pins through the navigation context, and the apps from the add-on registry, which the drawer feeds on every list read, so the links follow the drawer. A pin without an installed app is not drawn, nothing extra shows when no app is pinned, and the children are hidden when the sidebar is collapsed to the rail.

Assisted-by: Claude Code:claude-sonnet-5-5 node playwright
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* docs(addons): say that pinned apps are listed under Apps in the sidebar

The sidebar lists the pinned apps between Apps and Show all, in pin order. The pins section and the navigation section said that pins do not appear in the sidebar.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(ui): remove the Show all link under Apps in the sidebar

The link repeated the Apps entry, which opens the same drawer, and looked redundant when no app was pinned. Apps is now the only add-on entry; pinned apps still list under it in pin order. The link, its locale key and the style comment are gone, and the e2e spec and the add-ons docs now say that Apps is the only entry.

Assisted-by: Claude Code:claude-sonnet-5-5 node playwright
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(ui): show a first-run card on Apps when nothing is installed

An administrator who opens Apps with no add-on installed now sees a
short card with three steps: choose a library, browse and install,
start the add-on. Its button opens the Browse tab. The card is hidden
once an add-on is installed and for anyone who cannot install.

Assisted-by: Claude Code:claude-sonnet-5-5 node playwright
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(ui): hide the pinned apps in the icon rail

When the window is narrower than 1024 px the sidebar shrinks to the icon rail by CSS alone, without the collapsed class. The pinned app links were still drawn there, with the indent meant for the full sidebar, so their icons sat to the right of the rail and were cut off. Hide the pinned app links in both rail states. The Apps entry keeps its centred icon. The e2e spec checks that the rail shows no pinned app and that every icon is centred at 1280 px collapsed and at 1000 px.

Assisted-by: Claude Code:claude-sonnet-5-5 node playwright
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(ui): stop the backend redirect test racing the installed list

The test reads the redirected URL right after the path changes. The
Backends page then loads the installed list and drops a selected backend
that is not installed, so the backend parameter was sometimes already gone
when the test read it. The mock server has no llama-cpp, and about 15 of
40 repeated runs failed on the backend assertion. The redirect itself was
correct, including show_all.

Serve the selected backend in the installed list, so the page keeps the
selection and the assertions stay as strict as before.

Assisted-by: Claude Code:claude-sonnet-5-5 node playwright
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(addons): accept a same-host Referer or Origin for human confirmation

Browsers send no Sec-Fetch-* headers over plain HTTP on an address that
is not localhost, so creating a resource from a LAN address failed with
"Creating a resource requires your own browser session".

When Sec-Fetch-Site is present it must still be same-origin with an
empty destination, as before. When it is absent, Origin or, failing
that, Referer must name the request host and port. A destination or
mode that is present must still be the expected one. This matches the
rule in BrowserProtection. The credential, token cookie, API key and
tool-context checks are unchanged.

Assisted-by: Claude Code:claude-sonnet-5-5 go
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(ui): keep hub entries off the Apps route in the sidebar

The Build hub still owned /app/addons through its legacy Add-ons tab,
so the sidebar lit both Apps and Build on the Apps page. A tab whose
gate is closed now owns no route when the sidebar asks, so only the
Apps entry is active there. The hubs keep their own routes, and the
legacy navigation still gives /app/addons to the Build hub.

Assisted-by: Claude Code:claude-sonnet-5-5 node playwright
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(ui): label the pin control on app tiles and in the detail view

The pin was a small icon-only button and hard to find. Each app tile now
shows a button reading Pin, or Pinned with a filled pin, and the detail
view has the same control in its header. Both toggle the same stored pin.

The Apps grid lists pinned apps first, in pin order, then the others.
The sidebar list and the server pins API are unchanged.

Assisted-by: Claude Code:claude-sonnet-5-5 node playwright
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(addons): check the Voice From a recording tab has one title

The Library and Text to speech tabs already assert that the add-on shell
title is the only h1. Do the same for the From a recording tab, so a
heading added there later fails the navigation spec.

Assisted-by: Claude Code:claude-sonnet-5-5 node playwright
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(ui): stop the add-on shell drawing a title over add-on pages

The app decides what its page shows. The shell drew the add-on name as a
header above every add-on page, so an add-on could not choose its own
title. The shell now draws no header on a live, loading or frozen
add-on page; each add-on titles its own pages. The unavailable and
missing-page states keep the name, because no add-on page is drawn there.

The runtime and shell specs now check that the shell adds no page title.

Assisted-by: Claude Code:claude-sonnet-5-5 node playwright
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(addons): let each add-on draw its own page title

The shell no longer titles add-on pages, so each add-on draws one h1 per
page. Voice titles its tabs "Voices" (Recognition and From a recording),
"Speech voices" (Library) and "Speak text" (Text to speech); "Add a speech
voice" already had its own. Face draws "Faces", Studio "Studio" and Talk
"Talk". Tables promotes the database list and database name from h2 to h1.
Fine-tuning and Quantization already drew a title.

The add-on unit tests and the navigation and Studio specs now assert
exactly one h1 per page, with the page's own title.

Assisted-by: Claude Code:claude-sonnet-5-5 node playwright
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(addons): add a kind to library entries and read unknown kinds tolerantly

A library entry now has an optional kind: addon (the default when it is
absent), backend or model. Every index written before kinds existed stays
valid and its entries are add-ons.

A reader keeps the entries of the kinds it knows and skips the others
without reading their fields, so a newer kind never makes an older host
refuse the whole index. The refresh result and the library state report
how many entries were skipped and of which kinds, and the refresh logs a
warning. A kind that is not a plain kind name still makes the index
malformed. Search lists add-ons unless the caller asks for a kind, and
Resolve returns add-ons only, so an add-on install cannot be pointed at
a backend image.

A library policy may name the signer with an anchored identityRegex
instead of an exact identity, so builds from master and from tags both
verify. An add-on install refuses a policy that carries one.

Assisted-by: Claude Code:claude-sonnet-5-5 go
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(backends): install a backend from a library entry

A library can now list backends. An entry of kind backend names the
image of the backend gallery by digest, and POST /backends/library/install
installs it on the local node. The gallery stays the source of truth and
its install path is the one used: the library resolves the entry, takes
the signature policy from the library, pins the image by digest and calls
the same install as the gallery. Installing a name that came from a
gallery, another library or by hand is refused and changes nothing.

The signature policy of a backend library names the signer with an
anchored identityRegex, so images signed by the merge workflow on master
and on tags both verify. A pattern that is not anchored is refused.

An install is bounded by a size limit per kind: add-ons keep 512 MiB and
a backend image defaults to 8 GiB, set with --addons-backend-max-bytes.
The limit reads the manifest after the signature check and before any
layer is fetched. The metadata of an installed backend records the
library it came from.

scripts/backends/cmd/backendcatalog renders entries from
backend/index.yaml and pins them against the registry. The pilot is the
CPU build of local-store, in gallery/backends/index.yaml. The library is
registered on the first start only with --addons-backend-library, which
is off by default.

Assisted-by: Claude Code:claude-sonnet-5-5 go
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(ui): show the kind of library entries and install backends from them

The Browse tab lists the entries of every kind this host reads and marks
each one as an add-on or a backend. A backend installs through the
backend route and not through the add-on lifecycle: the confirmation
says that the backend gallery is not changed and that a backend runs
native code, and a refusal of the host, such as a name installed from
another origin, is shown with its own reason. The details give the kind,
the alias and the signer pattern.

The library editor can name the signer with a pattern. Without this a
save would drop the pattern of a backend library. The Libraries tab
reports the entries a refresh skipped because their kind is not read.

Assisted-by: Claude Code:claude-sonnet-5-5 node playwright
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* docs(addons): describe backends in the library

Document the entry kinds, how a reader skips a kind it does not know,
the backend entry format, the signer identity pattern, the install
route, the size limit of each kind, the pilot library and the command
that renders it. The backend page links to it.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(mcp): install a backend from a library entry

The backend library route installs a backend from an add-on library
entry, and the admin REST surface and the MCP catalog must stay in step.
Add the install_library_backend tool with an in-process and an HTTP
client, the route map entry, the safety-rule listing and a skill recipe.

Listing library entries stays on the add-on routes, which the assistant
does not expose, so no list tool is added.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(ui): carry the add-on library keys in every locale

The add-on library and backend library strings existed only in English,
so the other locales had no value for them. Add the missing keys of the
addons namespace to de, es, id, it, ko, pt-BR and zh-CN. The values are
the English text until a translation is contributed.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* Revert "feat(mcp): install a backend from a library entry"

This reverts commit 7493cff727.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* Revert "docs(addons): describe backends in the library"

This reverts commit 57816ed7e6.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* Revert "feat(ui): show the kind of library entries and install backends from them"

This reverts commit 49b2b151b7.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* Revert "feat(backends): install a backend from a library entry"

This reverts commit 1d8d3e3421.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* Revert "feat(addons): add a kind to library entries and read unknown kinds tolerantly"

This reverts commit 484813f856.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(ui): drop the backend library keys from the locales

Revert the part of 39f77ae29 that added strings for backend libraries:
the kind labels, the backend install texts, the signer identity pattern
and the skipped entries notice. The add-on library keys stay.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(ui): add an add-ons control to the chat header

Add-on tools were on or off only through the /addons command, with a toast
and no state a person could see. The chat header now has an Add-ons button
in the style of the MCP dropdown. It lists each running add-on that ships
tools with a switch, and a badge shows how many are on.

The chat keeps the ids that are on (addonToolIds) next to the older
boolean, and both are saved with the chat. A chat that has only the boolean
means every add-on. The request sends addon_tools alone when every add-on is
on, and adds addon_tool_ids when only some are. /addons turns all on or all
off and uses the same state.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(addons): let a chat request name the add-ons whose tools it uses

addon_tools=true still offers the tools of every add-on the caller may use.
A new addon_tool_ids metadata key, a comma-separated list of add-on ids,
narrows that to some add-ons. The list counts only with the opt-in, and a
list that names nothing offers no tool rather than all of them.

The executor drops the tools of add-ons that are not listed and refuses a
call to one as an unknown tool, so a model cannot reach a tool the person
turned off. Both keys are consumed so they do not reach the backend.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(addons): cover the per-add-on tool list and the chat header control

Go tests cover the list form (the list filters the tools offered and the
calls allowed) and the unchanged boolean form. Unit tests cover the chat
add-on rules. Playwright specs cover the header control: listing only
add-ons that ship tools, toggling each one, the count, /addons in step with
the switches, and the request metadata.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(ui): check each add-on page has one title when not enabled

The shell names an add-on only where the add-on page cannot mount (not
enabled, failed, no UI, bundle not loaded, no such page). Once the page
mounts it draws its own title, including when it says it is not enabled
on this server, and the shell draws none. The shell code already works
this way; this change states the rule beside the unavailable state and
pins it in the specs.

The shell spec now expects exactly one title in the unavailable states.
The navigation spec checks the Face page without a face model (the
add-on title only) and the Face page of a stopped add-on (the shell
title only).

Assisted-by: Claude Code:claude-sonnet-5-5 node playwright
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(addons): upgrade add-ons on their own when the setting is on

A new auto_upgrade_addons setting (flag LOCALAI_AUTO_UPGRADE_ADDONS,
default off) lets the owner upgrade an installed add-on when its library
lists a newer version. It follows the backend setting: a startup flag, a
runtime setting, and the same schedule as the backend update check (a
first pass shortly after start, then every six hours).

Each pass fetches the library of every installed add-on, and for each
available update builds the same pinned upgrade command an administrator
sends and runs it through the same lifecycle path. The channel rules, the
signer identity, the digest checks, the SDK compatibility check and the
backup before the upgrade all apply unchanged. The only difference is the
gate: the owner acts on its own setting, so no administrator is asked.
A refused or failed upgrade changes nothing and is logged; a candidate
that failed is not retried until the library lists another digest.

With the setting off, nothing runs and upgrades stay manual.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(ui): add the Upgrade add-ons automatically toggle to Settings

The toggle sits beside Upgrade backends automatically and is off by
default. It saves the auto_upgrade_addons runtime setting. The English
locale gets the label key, and the Settings test fixture lists the new
field.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(addons): cover the automatic upgrade setting and document it

The setting defaults to off in the config, the runtime settings and the
flag, and is read back from the saved settings file. With it on, a real
owner upgrades an installed add-on through the manager; with it off,
nothing changes, and a change of the setting at run time takes effect on
the next pass. The manager specs show the upgrade goes through the same
pinned path as the manual one, and that a corrupted package, a failed
signature policy, an incompatible release, a missing update checker and
a caller who is not an administrator each leave the installed version in
place. The new specs are added to the lifecycle case inventory.

The add-ons page describes the setting, its schedule and its default.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* refactor(addons): name the library entry identifier id

The library entry of an add-on used addonId while the shared envelope
and the model entries use id. Release now has the field ID, written as
id, and every writer emits it: the index encoder, the package workflow
templates, the catalog generator and gallery/addons/index.yaml. The
digests in the index did not change.

DecodeIndex still reads addonId as a legacy alias for one release, so
an existing library and a library served by an older tool keep
working. An entry that holds both names is refused. The Apps UI reads
id and falls back to addonId.

Assisted-by: Claude Code:claude-sonnet-5-5 go node playwright
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(addons): use the id field in the auto-upgrade test release

The auto-upgrade test arrived after the identifier rename and still
named the release field AddonID, so the application tests did not
build.

Assisted-by: Claude Code:claude-sonnet-5-5 go
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(addons): annotate the auto-upgrade actor label for gosec

The actor label looks like a credential to gosec G101. It is a fixed name for upgrades the owner starts itself.

Assisted-by: Claude Code:claude-sonnet-5-5 go
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(schema): persist the per-chat add-on tool fields

The chat header persists addonTools and addonToolIds, but Conversation had no fields for them, so the server dropped them on save. Add both and update the generated swagger files by hand because swag is not installed here.

Assisted-by: Claude Code:claude-sonnet-5-5 go
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(ui): count the add-ons auto-upgrade setting in the settings groups

The setting sits in the Backends and galleries group, once. The page now lists 54 settings plus the three branding images, so the spec expects 57.

Assisted-by: Claude Code:claude-sonnet-5-5 node playwright
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(ui): pause the fake clock ahead of now in the clear-all spec

The fake clock runs from install, so pausing at the current time failed with 'Cannot fast-forward to the past' on a slow runner. The save itself is synchronous and was not lost.

Assisted-by: Claude Code:claude-sonnet-5-5 node playwright
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(ui): hold the Studio capabilities reply longer in the skeleton spec

800 ms was shorter than a slow runner needs to render the page, so the skeleton was gone before the check. The product shows it.

Assisted-by: Claude Code:claude-sonnet-5-5 node playwright
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(i18n): add the French add-ons locale

Master added the fr locale after this branch forked. The add-ons unit test requires every locale to carry the add-ons namespace, so fr needs its own addons.json.

Assisted-by: Claude Code:claude-sonnet-5-5 node
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
mudler-agentandEttore Di Giacinto authored and GitHub committed 2026-10-22 17:57:19 +02:00
1 parent 099d6c3dd0
commit e45dc5e9f7
964 files changed
+159560 -515

No files matched your search

+7
View File
@@ -150,3 +150,10 @@ compromised and we've shipped malicious signed images:
page if/when added.
- **Tarball/HTTP backend signing.** Cosign can sign arbitrary blobs, but
for now non-OCI backends keep using the `sha256:` field in YAML.
Embedded consumers with host-provisioned private Sigstore trust can use
`cosignverify.NewVerifierWithTrustedMaterial`. It bypasses public TUF fetching,
not signature, identity, SCT, or Rekor checks. Never construct this input from a
registry attachment or catalog response. `NewVerifier` retains its public TUF
default. Hermetic cryptographic regressions live in `offline_internal_test.go`;
the optional live registry tests remain separate and are not acceptance evidence.
+26
View File
@@ -92,6 +92,32 @@ The in-process MCP server runs inside the same LocalAI binary that serves chat.
The user chose KISS. Every mutating tool has a safety rule (`prompts/10_safety.md` rule 1) that requires the LLM to summarise the action and wait for explicit user confirmation before calling it. There is no `plan_*`/`apply_*` two-step in code. If you add a mutating tool, do **not** add per-tool confirmation logic in Go — instead, list the new tool name in `prompts/10_safety.md` so the LLM knows it falls under the confirmation rule.
## Scoped exception: add-on tools are not assistant tools
The prompt-only confirmation above applies to the admin catalog in
`pkg/mcp/localaitools/` and to nothing else. Tools of installed add-ons (for
example Tables) follow a different, code-enforced rule, documented in
[docs/content/features/tables.md](../docs/content/features/tables.md):
- They are never registered in this catalog and no `/api/addons` route appears
in `toolToHTTPRoute`. `TestToolHTTPRouteMappingComplete` stays unchanged, and
the consumer matrix test (`core/services/addons/tools`) fails if a tool name
or route of the add-on surface appears here.
- A change proposed by an add-on tool is a stored proposal. Only the proposer's
own browser session confirms it, with the digest of what the host card showed.
A model's "yes" or an argument named `approved` is never authority.
- REST-to-MCP parity is intentionally partial for add-ons: the lifecycle
mutations (install, enable, disable, upgrade, uninstall, library changes) are
deliberately **not** exposed as assistant tools, because the assistant is one
process-wide server with no per-call principal and would inherit prompt-only
authorization. Do not add them without a per-call actor and a host approval
card.
- New code that discovers or executes tools must be classified in
`tools.Consumers` (`core/services/addons/tools/consumers.go`) as route or
reject. The matrix test scans the sources and fails for an unclassified file.
Do not rewrite the unrelated assistant tools to match this rule.
## Distributed mode
The in-memory MCP server runs only on the head node (where the chat handler runs). `inproc.Client` wraps services that are already distributed-aware (`GalleryService` coordinates with workers; `ListNodes` reads the NATS-populated registry). No NATS routing of MCP tools — the admin surface lives on the head, period.
+426
View File
@@ -0,0 +1,426 @@
---
name: 'add-on packages'
# Builds, checks, signs and publishes the first-party add-on OCI packages
# (Tables, Voice, Face, Fine-tuning and Quantization). This workflow is
# independent of the backend image workflows: it has its own registry input,
# its own signer identity and its own jobs.
#
# Event policy (the rules live in scripts/addons/channel.sh):
# pull_request validate and build legs, publish nothing, no secrets
# push to master publish the development channel
# push to other branch publish nothing
# push of a v<semver> tag prepare the immutable version, move no alias
# release published promote latest, for a stable non-draft release only
#
# Every add-on has its own platform file (addons/<id>/package/platforms.yaml),
# its own package legs and its own repository. Tables publishes to
# ADDON_OCI_REPOSITORY, which is the repository <namespace>/tables. Each other
# add-on publishes to <namespace>/<id>, where the namespace is
# ADDON_OCI_REPOSITORY without its last path element. A publishing event fails
# for an add-on until one of its platforms is enabled.
#
# Aliases move only in the promote job, after every platform leg passed, the
# index was pushed by digest, signed and verified.
on:
push:
branches: ['**']
tags: ['v*']
paths:
- 'addons/**'
- 'pkg/addons/**'
- 'scripts/addons/**'
- 'gallery/addons/**'
- '.github/workflows/addons-package.yml'
pull_request:
paths:
- 'addons/**'
- 'pkg/addons/**'
- 'scripts/addons/**'
- 'gallery/addons/**'
- '.github/workflows/addons-package.yml'
release:
types: [published]
permissions:
contents: read
jobs:
validate:
name: validate package rules
runs-on: ubuntu-24.04
timeout-minutes: 20
permissions:
contents: read
strategy:
fail-fast: true
matrix:
addon: [tables, voice, face, fine-tuning, quantization, studio, talk]
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
- uses: actions/setup-node@v6
with:
node-version: '22'
- name: Install the host UI build tools
# The package checks build the add-on UI with the bundler the host UI
# already installs.
run: npm ci --prefix core/http/react-ui
- name: Generate the gRPC stubs the packaging checks compile
# The generated stubs are not in the tree and the Go packages of the
# host import them. Same pinned tools as the test workflow.
if: matrix.addon == 'tables'
run: |
curl -L -s https://github.com/protocolbuffers/protobuf/releases/download/v26.1/protoc-26.1-linux-x86_64.zip -o protoc.zip
sudo unzip -j -d /usr/local/bin protoc.zip bin/protoc
rm protoc.zip
go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.34.2
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@1958fcbe2ca8bd93af633f11e97d44e567e945af
PATH="$PATH:$HOME/go/bin" make protogen-go
- name: Build the UI of this add-on
env:
ADDON: ${{ matrix.addon }}
run: node "addons/$ADDON/ui/build.mjs"
- name: Fill the module cache the packaging checks read
# package_test.sh runs offline (GOPROXY=off) against its own cache.
# The suite covers every add-on, so one leg runs it.
if: matrix.addon == 'tables'
run: |
GOPATH="$PWD/.cache/addons/go" GOMODCACHE="$PWD/.cache/addons/go/pkg/mod" \
GOCACHE="$PWD/.cache/addons/go-build" go mod download
- name: Run the packaging checks
if: matrix.addon == 'tables'
run: sh scripts/addons/package_test.sh
plan:
name: plan the event
runs-on: ubuntu-24.04
timeout-minutes: 10
permissions:
contents: read
env:
ADDON_OCI_REPOSITORY: ${{ vars.ADDON_OCI_REPOSITORY }}
outputs:
publish: ${{ steps.plan.outputs.publish }}
immutable: ${{ steps.plan.outputs.immutable }}
aliases: ${{ steps.plan.outputs.aliases }}
channel: ${{ steps.plan.outputs.channel }}
version: ${{ steps.plan.outputs.version }}
matrix: ${{ steps.plan.outputs.matrix }}
addons: ${{ steps.plan.outputs.addons }}
has_platforms: ${{ steps.plan.outputs.has_platforms }}
registry: ${{ steps.plan.outputs.registry }}
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
- name: Decide what this event may publish
id: plan
env:
EVENT: ${{ github.event_name }}
REF: ${{ github.ref }}
SHA: ${{ github.sha }}
DRAFT: ${{ github.event.release.draft || false }}
PRERELEASE: ${{ github.event.release.prerelease || false }}
run: |
set -eu
flags=""
[ "$DRAFT" = "true" ] && flags="$flags --draft"
[ "$PRERELEASE" = "true" ] && flags="$flags --prerelease"
# shellcheck disable=SC2086
sh scripts/addons/channel.sh --event "$EVENT" --ref "$REF" --sha "$SHA" $flags > decision.txt
cat decision.txt
version=$(sed -n 's/^version=//p' decision.txt)
publish=$(sed -n 's/^publish=//p' decision.txt)
[ -n "$version" ] || version="0.0.0-dev.${SHA%"${SHA#????????????}"}"
if [ "$publish" = "true" ]; then
# Fail closed: a publishing event needs the namespace input and a
# non-empty release platform matrix. There is no default registry.
if [ -z "$ADDON_OCI_REPOSITORY" ]; then
echo "ADDON_OCI_REPOSITORY is not configured; refusing to publish" >&2
exit 1
fi
fi
# One set of legs for each add-on, from its own platform file. Only
# the "no enabled platform" answer is tolerated, and only on an event
# that publishes nothing: that add-on gets no legs. On a publishing
# event it fails the plan. A missing or malformed platform file, or
# any other error, fails the plan on every event.
include='[]'
for id in tables voice face fine-tuning quantization studio talk; do
cfg="addons/$id/package/platforms.yaml"
err="matrix-$id.err"
if rows=$(go run ./scripts/addons/cmd/addonpkg matrix --config "$cfg" 2>"$err"); then
:
elif grep -q 'no enabled platform' "$err"; then
if [ "$publish" = "true" ]; then
echo "add-on $id has no enabled platform; refusing to publish" >&2
exit 1
fi
continue
else
echo "cannot read the platform matrix of add-on $id" >&2
cat "$err" >&2
exit 1
fi
include=$(printf '%s' "$rows" | jq -c --argjson all "$include" --arg addon "$id" '$all + [.include[] | . + {addon: $addon}]')
done
matrix=$(printf '%s' "$include" | jq -c '{include: .}')
addons=$(printf '%s' "$include" | jq -c 'map(.addon) | unique')
has=$(printf '%s' "$include" | jq 'length > 0')
{
cat decision.txt
echo "version=$version"
echo "matrix=$matrix"
echo "addons=$addons"
echo "has_platforms=$has"
echo "registry=${ADDON_OCI_REPOSITORY%%/*}"
} >> "$GITHUB_OUTPUT"
package:
name: package ${{ matrix.addon }} ${{ matrix.platform }}
needs: [plan, validate]
if: needs.plan.outputs.has_platforms == 'true'
runs-on: ${{ matrix.runner }}
timeout-minutes: 60
permissions:
contents: read
strategy:
fail-fast: true
matrix: ${{ fromJSON(needs.plan.outputs.matrix) }}
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
- uses: actions/setup-node@v6
with:
node-version: '22'
- name: Build the UI
env:
ADDON: ${{ matrix.addon }}
run: |
npm ci --prefix core/http/react-ui
node "addons/$ADDON/ui/build.mjs"
- name: Package this platform
env:
ADDON: ${{ matrix.addon }}
VERSION: ${{ needs.plan.outputs.version }}
PLATFORM: ${{ matrix.platform }}
SHA: ${{ github.sha }}
run: |
sh scripts/addons/package.sh --config "addons/$ADDON/package/platforms.yaml" --layout "layout" --version "$VERSION" --platform "$PLATFORM" --revision "$SHA"
- name: Native runtime check
env:
ADDON: ${{ matrix.addon }}
VERSION: ${{ needs.plan.outputs.version }}
PLATFORM: ${{ matrix.platform }}
run: |
go run ./scripts/addons/cmd/addonpkg native-check --config "addons/$ADDON/package/platforms.yaml" --layout layout --platform "$PLATFORM" --version "$VERSION"
- uses: actions/upload-artifact@v7
with:
name: layout-${{ matrix.addon }}-${{ matrix.slug }}
path: layout
if-no-files-found: error
retention-days: 7
publish:
name: publish and sign ${{ matrix.addon }}
needs: [plan, validate, package]
if: needs.plan.outputs.publish == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 60
environment: addons-release
concurrency:
group: addons-publish-${{ matrix.addon }}-${{ needs.plan.outputs.immutable }}
cancel-in-progress: false
permissions:
contents: read
id-token: write
strategy:
# A leg that is pushing must finish, so one failure never cancels another.
fail-fast: false
matrix:
addon: ${{ fromJSON(needs.plan.outputs.addons) }}
env:
ADDON_OCI_BASE: ${{ vars.ADDON_OCI_REPOSITORY }}
ADDON: ${{ matrix.addon }}
# cosign gates --registry-referrers-mode=oci-1-1 behind this flag.
COSIGN_EXPERIMENTAL: '1'
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
- name: Choose the repository of this add-on
# Tables publishes to the configured repository. Every other add-on
# publishes to <namespace>/<id>. The later steps read
# ADDON_OCI_REPOSITORY.
run: |
set -eu
case "$ADDON_OCI_BASE" in
*/*) ;;
*) echo "ADDON_OCI_REPOSITORY needs a namespace and a name" >&2; exit 1 ;;
esac
if [ "$ADDON" = tables ]; then
repo="$ADDON_OCI_BASE"
else
repo="${ADDON_OCI_BASE%/*}/$ADDON"
fi
echo "ADDON_OCI_REPOSITORY=$repo" >> "$GITHUB_ENV"
- uses: actions/download-artifact@v8
with:
pattern: layout-${{ matrix.addon }}-*
path: legs
- name: Join the platform legs into one index
id: merge
env:
VERSION: ${{ needs.plan.outputs.version }}
SHA: ${{ github.sha }}
run: |
set -eu
go run ./scripts/addons/cmd/addonpkg merge --config "addons/$ADDON/package/platforms.yaml" \
--out merged --version "$VERSION" --revision "$SHA" legs/*
- name: Registry login
uses: docker/login-action@v3
with:
registry: ${{ needs.plan.outputs.registry }}
username: ${{ secrets.ADDON_OCI_USERNAME }}
password: ${{ secrets.ADDON_OCI_PASSWORD }}
- name: Push the platform digests and the index
id: push
env:
IMMUTABLE: ${{ needs.plan.outputs.immutable }}
run: |
set -eu
go run ./scripts/addons/cmd/addonpkg publish --config "addons/$ADDON/package/platforms.yaml" \
--layout merged --repository "$ADDON_OCI_REPOSITORY" --tag "$IMMUTABLE" --adopt-existing | tee push.txt
echo "digest=$(sed -n 's/^digest=//p' push.txt)" >> "$GITHUB_OUTPUT"
echo "children=$(sed -n 's/^children=//p' push.txt)" >> "$GITHUB_OUTPUT"
- uses: sigstore/cosign-installer@v3
with:
cosign-release: 'v2.6.5'
- name: Sign by digest
id: sign
env:
DIGEST: ${{ steps.push.outputs.digest }}
run: |
set -eu
cosign sign --yes --recursive \
--new-bundle-format \
--registry-referrers-mode=oci-1-1 \
"${ADDON_OCI_REPOSITORY}@${DIGEST}"
- name: Verify the signature with the exact add-on workflow identity
id: verify
env:
DIGEST: ${{ steps.push.outputs.digest }}
CHILDREN: ${{ steps.push.outputs.children }}
IDENTITY: https://github.com/${{ github.repository }}/.github/workflows/addons-package.yml@${{ github.ref }}
ISSUER: https://token.actions.githubusercontent.com
run: |
set -eu
for d in "$DIGEST" $(echo "$CHILDREN" | tr ',' ' '); do
cosign verify --new-bundle-format \
--certificate-identity "$IDENTITY" \
--certificate-oidc-issuer "$ISSUER" \
"${ADDON_OCI_REPOSITORY}@${d}" > /dev/null
done
- name: Keep the digest for the promote job
# A matrix job cannot hand one output to the next job, so the digest of
# each add-on travels in an artifact of its own.
env:
DIGEST: ${{ steps.push.outputs.digest }}
run: printf '%s\n' "$DIGEST" > digest.txt
- uses: actions/upload-artifact@v7
with:
name: digest-${{ matrix.addon }}
path: digest.txt
if-no-files-found: error
retention-days: 7
promote:
name: promote channel aliases ${{ matrix.addon }}
needs: [plan, validate, publish]
if: needs.plan.outputs.publish == 'true' && needs.plan.outputs.aliases != ''
runs-on: ubuntu-24.04
timeout-minutes: 20
environment: addons-release
# One promotion at a time per channel, and a queued run never cancels a
# running one. The freshness check below makes a slow, older run a no-op.
concurrency:
group: addons-promote-${{ matrix.addon }}-${{ needs.plan.outputs.channel }}
cancel-in-progress: false
permissions:
contents: read
strategy:
fail-fast: false
matrix:
addon: ${{ fromJSON(needs.plan.outputs.addons) }}
env:
ADDON_OCI_BASE: ${{ vars.ADDON_OCI_REPOSITORY }}
ADDON: ${{ matrix.addon }}
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
- name: Choose the repository of this add-on
# The same rule as in publish: Tables keeps the configured repository,
# every other add-on uses <namespace>/<id>.
run: |
set -eu
case "$ADDON_OCI_BASE" in
*/*) ;;
*) echo "ADDON_OCI_REPOSITORY needs a namespace and a name" >&2; exit 1 ;;
esac
if [ "$ADDON" = tables ]; then
repo="$ADDON_OCI_BASE"
else
repo="${ADDON_OCI_BASE%/*}/$ADDON"
fi
echo "ADDON_OCI_REPOSITORY=$repo" >> "$GITHUB_ENV"
- uses: actions/download-artifact@v8
with:
name: digest-${{ matrix.addon }}
path: digest
- name: Registry login
uses: docker/login-action@v3
with:
registry: ${{ needs.plan.outputs.registry }}
username: ${{ secrets.ADDON_OCI_USERNAME }}
password: ${{ secrets.ADDON_OCI_PASSWORD }}
- name: Move the aliases
env:
ALIASES: ${{ needs.plan.outputs.aliases }}
VERSION: ${{ needs.plan.outputs.version }}
SHA: ${{ github.sha }}
REF: ${{ github.ref }}
EVENT: ${{ github.event_name }}
run: |
set -eu
DIGEST=$(tr -d '[:space:]' < digest/digest.txt)
case "$DIGEST" in
sha256:????????????????????????????????????????????????????????????????) ;;
*) echo "the digest of $ADDON is missing or malformed" >&2; exit 1 ;;
esac
for alias in $(echo "$ALIASES" | tr ',' ' '); do
go run ./scripts/addons/cmd/addonpkg promote \
--repository "$ADDON_OCI_REPOSITORY" --alias "$alias" \
--digest "$DIGEST" --version "$VERSION" --revision "$SHA" \
--ref "$REF" --event "$EVENT" --git-dir .git
done
+1
View File
@@ -140,3 +140,4 @@ formal-verification/out/
/backend/go/gemxcpp/build-*/
/backend/go/gemxcpp/package/
/backend/go/gemxcpp/gemxcpp
addons/internal/uibuild/test/*/dist
+15
View File
@@ -0,0 +1,15 @@
// SPDX-License-Identifier: MIT
// Command face is the executable of the Face add-on. It serves no tools:
// the add-on works in the browser, through the SDK and the core API.
package main
import (
"os"
"github.com/mudler/LocalAI/addons/internal/idle"
)
func main() {
os.Exit(idle.Run(os.Args))
}
+25
View File
@@ -0,0 +1,25 @@
{
"schemaVersion": 1,
"id": "face",
"version": "0.1.0",
"executable": "face",
"os": "linux",
"arch": "amd64",
"host": { "min": 1, "max": 1 },
"api": { "min": 1, "max": 1 },
"protocol": { "min": 1, "max": 1 },
"dataFormat": { "min": 1, "max": 1 },
"capabilities": {
"ui": {
"entry": "ui/face.js",
"css": ["ui/face.css"],
"sdk": { "min": 2, "max": 2 },
"pages": ["recognition"],
"navigation": [],
"mainPage": "recognition",
"icon": "ui/icon.png",
"title": "Face",
"description": "Find out whose face is in a photo, or whether two photos show the same person."
}
}
}
+38
View File
@@ -0,0 +1,38 @@
# Release platform matrix of the face add-on package.
#
# A platform ships only while "enabled" is true. Enable a platform in the same
# change that shows its native check passing on a runner of that platform;
# until then it stays listed and disabled. The release workflow fails closed
# when no platform is enabled, and it never builds a platform that is not
# enabled here.
#
# runner: the GitHub runner label that builds and checks the platform
# natively. Required for an enabled platform.
# native_check: "executable-start": the packaged executable is extracted on
# the native runner and started. Required for an enabled platform.
# runtime: files besides the executable that the platform needs at run
# time, relative to the package root. They are taken from the
# runtime directory of the build and must exist.
schema: 1
addon: face
executable: face
manifest: addons/face/manifest.json
# The directory whose content is laid at the package root (ui/face.js ...).
ui: addons/face/ui/dist
platforms:
- platform: linux/amd64
enabled: true
runner: ubuntu-24.04
native_check: executable-start
- platform: linux/arm64
enabled: true
runner: ubuntu-24.04-arm
native_check: executable-start
- platform: darwin/arm64
enabled: true
runner: macos-14
native_check: executable-start
- platform: darwin/amd64
enabled: true
runner: macos-15-intel
native_check: executable-start
+2
View File
@@ -0,0 +1,2 @@
# Written by build.mjs; the package build makes it.
/dist/
+13
View File
@@ -0,0 +1,13 @@
import { fileURLToPath } from 'node:url'
import { dirname } from 'node:path'
import { buildAddonUI } from '../../internal/uibuild/uibuild.mjs'
const here = dirname(fileURLToPath(import.meta.url))
export async function build() {
return buildAddonUI({ root: here, name: 'face', rgb: [20, 100, 90] })
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
build().then((dir) => console.log(`built ${dir}`), (e) => { console.error(e); process.exit(1) })
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

+8
View File
@@ -0,0 +1,8 @@
{
"name": "localai-face-ui",
"private": true,
"version": "0.1.0",
"description": "Native UI of the Face add-on: a standalone ES module that the host loads after its own build.",
"type": "module",
"scripts": { "build": "node build.mjs", "test": "node test/run.mjs" }
}
+116
View File
@@ -0,0 +1,116 @@
// The Faces page: who is this, same person, the people this browser knows and
// what is stored. Ported from pages/FaceRecognition.jsx. The known-faces list
// and the enrol sheet are the shared views inside the shared Workbench.
// Detect and analyze, and the raw faceprint, sit behind a disclosure.
import { createClipInput } from '../../../internal/identity-ui/src/ClipInput.jsx'
import { createWorkbench } from '../../../internal/identity-ui/src/Workbench.jsx'
import { createKnownList } from '../../../internal/identity-ui/src/KnownList.jsx'
import { createEnrolSheet } from '../../../internal/identity-ui/src/EnrolSheet.jsx'
import { createVerdict } from '../../../internal/identity-ui/src/Verdict.jsx'
import { createDistanceScale } from '../../../internal/identity-ui/src/DistanceScale.jsx'
import { createErrorNote } from '../../../internal/identity-ui/src/ErrorNote.jsx'
import { createModelNeeded } from '../../../internal/identity-ui/src/ModelNeeded.jsx'
import { createBoundingBoxCanvas } from '../../../internal/identity-ui/src/BoundingBoxCanvas.jsx'
import { createIdentityRegistry } from '../../../internal/identity-ui/src/registry.js'
import { createMoreFaceTools } from './MoreFaceTools.jsx'
const CAP_FACE_RECOGNITION = 'FLAG_FACE_RECOGNITION'
// `identity` is the shared helper module (identity.js), passed as in the Voice
// add-on. The Faces page has no tab counts, so it reads nothing from it.
// eslint-disable-next-line no-unused-vars
export function createFaceRecognition(ctx, api, identity) {
const { h, t, Icon, ui, viewer } = ctx
const { useCallback, useEffect, useMemo, useState } = ctx.React
const { ClipInput } = createClipInput(ctx)
const { KnownList } = createKnownList(ctx)
const { Verdict } = createVerdict(ctx)
const { DistanceScale } = createDistanceScale(ctx)
const { ErrorNote } = createErrorNote(ctx)
const { useRegistry } = createIdentityRegistry(ctx)
const { BoundingBoxCanvas } = createBoundingBoxCanvas(ctx)
const { EnrolSheet } = createEnrolSheet(ctx, { ClipInput, ErrorNote, api })
const { ModelNeeded } = createModelNeeded(ctx, api)
const { MoreFaceTools } = createMoreFaceTools(ctx, api)
const { Workbench } = createWorkbench(ctx, { ClipInput, KnownList, EnrolSheet, Verdict, DistanceScale, ErrorNote, useRegistry, BoundingBoxCanvas, api })
// Shown in place of a page whose feature the signed-in user may not use. It
// replaces a silent redirect: the person learns what is off and what turns it on.
function FeatureOff() {
return (
<main className="page idn-page idn-off" data-testid="face-off">
<section className="idn-needed dk-card">
<span className="dk-badge dk-badge--warn"><Icon name="lock" /> {t('off.badge')}</span>
<h1 className="idn-h2">{t('off.face.title')}</h1>
<p className="idn-needed__text">{t('off.face.text')}</p>
<details className="idn-disclosure" open>
<summary><Icon name="chevron-right" /> {t('needed.whatTitle')}</summary>
<p className="idn-disclosure__text">{t('off.face.what')}</p>
</details>
</section>
</main>
)
}
// The face models that are installed: the capability list of the core,
// narrowed to the face-recognition flag, as the core hook useModels does.
function useFaceModels() {
const [state, setState] = useState({ models: [], loading: true })
const load = useCallback(async (silent) => {
if (!silent) setState(s => ({ ...s, loading: true }))
try {
const data = await api.models.listCapabilities()
const models = (data?.data || []).filter(m => !m.disabled && m.capabilities?.includes(CAP_FACE_RECOGNITION))
setState({ models, loading: false })
} catch {
setState({ models: [], loading: false })
}
}, [])
useEffect(() => { load(false) }, [load])
return { ...state, refetch: useCallback(() => load(true), [load]) }
}
function FacesPage({ path }) {
const isAdmin = viewer.isAdmin()
const [model, setModel] = useState(() => ctx.modelFrom(path) || '')
const { models, loading, refetch } = useFaceModels()
const names = useMemo(() => models.map(m => m.id), [models])
const needModel = !loading && names.length === 0
return (
<main className="page idn-page" data-testid="faces-page">
<ui.PageHeader
title={t('faces.title')}
supporting={t('faces.lede')}
actions={!needModel ? (
<div className="idn-modelpick">
<ui.ModelSelector value={model} onChange={setModel} capability={CAP_FACE_RECOGNITION} options={names} loading={loading} triggerClassName="idn-model" />
</div>
) : null}
/>
<Workbench
kind="face" model={model}
needed={needModel ? <ModelNeeded kind="face" isAdmin={isAdmin} onInstalled={refetch} /> : null}
more={needModel ? null : (
<details className="idn-disclosure idn-disclosure--page idn-more">
<summary><Icon name="chevron-right" /> {t('face.moreTitle')} <span className="dk-hint">{t('face.moreHint')}</span></summary>
<MoreFaceTools model={model} />
</details>
)}
/>
</main>
)
}
// The page is for a viewer who holds face_recognition (spec, "Classification":
// the gate of the page is the gate of the core route, and the library inside
// it has no stricter gate). The server checks every /v1/face/* call again. A
// viewer without the feature gets the notice and no call.
function FaceRecognition(props) {
if (!viewer.hasFeature('face_recognition')) return <FeatureOff />
return <FacesPage {...props} />
}
return { FaceRecognition }
}
+246
View File
@@ -0,0 +1,246 @@
// The two developer tools behind the face workbench: detect faces and guess
// attributes (guesses, often wrong), and the raw faceprint. Ported from
// components/identity/MoreFaceTools.jsx; the shared widgets come from the SDK.
import { createBoundingBoxCanvas } from '../../../internal/identity-ui/src/BoundingBoxCanvas.jsx'
const TABS = [
{ id: 'analyze', icon: 'chart-bar', label: 'Detect and analyze' },
{ id: 'embed', icon: 'code', label: 'Embedding' },
]
export function createMoreFaceTools(ctx, api) {
const { h, Fragment, Icon, ui } = ctx
const { useMemo, useState } = ctx.React
const { BoundingBoxCanvas } = createBoundingBoxCanvas(ctx)
function MoreFaceTools({ model }) {
const [tab, setTab] = useState('analyze')
return (
<div className="idn-more__body">
<ui.TabSwitch tabs={TABS} value={tab} onChange={setTab} />
<div className="biometrics-page__body">
{tab === 'analyze' && <AnalyzeTab model={model} />}
{tab === 'embed' && <EmbedTab model={model} />}
</div>
</div>
)
}
function AnalyzeTab({ model }) {
const [img, setImg] = useState(null)
const [actions, setActions] = useState({ age: false, gender: false, emotion: false, race: false })
const [antiSpoofing, setAntiSpoofing] = useState(false)
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
const [result, setResult] = useState(null)
const [focusIdx, setFocusIdx] = useState(0)
const submit = async (e) => {
e.preventDefault()
if (!model) { ctx.toast('Select a face model first', 'warning'); return }
if (!img) { ctx.toast('Add an image to analyze', 'warning'); return }
setLoading(true); setError(null); setResult(null); setFocusIdx(0)
try {
const data = await api.face.analyze({
model,
img: img.dataUrl,
actions: Object.entries(actions).filter(([, v]) => v).map(([k]) => k),
anti_spoofing: antiSpoofing,
})
setResult(data)
if (!data?.faces?.length) ctx.toast('No face detected in the image', 'warning')
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}
const boxes = useMemo(() => (result?.faces || []).map((f, i) => ({
x: f.region.x, y: f.region.y, w: f.region.w, h: f.region.h,
label: f.dominant_emotion || f.dominant_gender || `Face ${i + 1}`,
sublabel: f.age ? `~${Math.round(f.age)}y` : null,
tone: i === focusIdx ? 'accent' : 'default',
})), [result, focusIdx])
const faces = result?.faces || []
const focus = faces[focusIdx]
return (
<form className="biometrics-twocol" onSubmit={submit}>
<aside className="biometrics-panel">
<h2 className="biometrics-panel__title">Analyze a face</h2>
<ui.MediaInput mode="image" label="Source image" value={img} onChange={setImg} idPrefix="face-analyze" />
<fieldset className="biometrics-fieldset">
<legend>Guess attributes <small>(off by default, often wrong)</small></legend>
<div className="biometrics-chipset" role="group">
{['age', 'gender', 'emotion', 'race'].map(k => (
<label key={k} className={`biometrics-chip ${actions[k] ? 'active' : ''}`}>
<input type="checkbox" checked={actions[k]} onChange={(e) => setActions(a => ({ ...a, [k]: e.target.checked }))} />
<span>{k}</span>
</label>
))}
</div>
</fieldset>
<div className="form-row">
<div className="form-row__label">
<span className="form-row__label-text">Anti-spoofing</span>
<span className="form-row__hint">Reject photos-of-photos (requires model support).</span>
</div>
<label className="biometrics-switch">
<input type="checkbox" checked={antiSpoofing} onChange={(e) => setAntiSpoofing(e.target.checked)} />
<span aria-hidden="true" />
</label>
</div>
<button type="submit" className="btn btn-primary btn-full" disabled={loading || !img}>
{loading ? <><ui.Spinner size="sm" /> Analyzing…</> : <><Icon name="sparkles" /> Analyze</>}
</button>
</aside>
<section className="biometrics-results">
{loading && <div className="biometrics-empty"><ui.Spinner size="lg" /></div>}
{error && <ui.ErrorWithTraceLink message={error} />}
{!loading && !error && !result && (
<EmptyState icon="smile"
title="Drop a portrait to analyze"
body="The backend will detect each face and return age, gender, emotion, and race distributions — with an optional liveness check." />
)}
{result && img && (
<>
<div className="biometrics-split">
<div className="biometrics-split__media">
<BoundingBoxCanvas src={img.dataUrl} boxes={boxes} alt="Analyzed source" />
{faces.length > 1 && (
<div className="biometrics-facepicker" role="tablist" aria-label="Select face">
{faces.map((_, i) => (
<button key={i} type="button"
className={`biometrics-facepicker__chip ${i === focusIdx ? 'active' : ''}`}
onClick={() => setFocusIdx(i)}
aria-pressed={i === focusIdx}>
Face {i + 1}
</button>
))}
</div>
)}
</div>
<div className="biometrics-split__aside">
{focus && (
<>
<div className="biometrics-summary card">
<div className="biometrics-summary__head">
<h3><Icon name="user" /> Face {focusIdx + 1}</h3>
{antiSpoofing && <LivenessPill isReal={focus.is_real} score={focus.antispoof_score} />}
</div>
<dl className="biometrics-summary__grid">
{focus.age != null && <><dt>Age</dt><dd>~{Math.round(focus.age)}</dd></>}
{focus.dominant_gender && <><dt>Gender</dt><dd>{focus.dominant_gender}</dd></>}
{focus.dominant_emotion && <><dt>Emotion</dt><dd>{focus.dominant_emotion}</dd></>}
{focus.dominant_race && <><dt>Race</dt><dd>{focus.dominant_race}</dd></>}
{focus.face_confidence != null && <><dt>Detection</dt><dd>{(focus.face_confidence * 100).toFixed(1)}%</dd></>}
</dl>
</div>
<ui.DistributionBars title="Gender" icon="gender" distribution={focus.gender} dominant={focus.dominant_gender} />
<ui.DistributionBars title="Emotion" icon="smile" distribution={focus.emotion} dominant={focus.dominant_emotion} />
<ui.DistributionBars title="Race" icon="globe" distribution={focus.race} dominant={focus.dominant_race} />
</>
)}
</div>
</div>
<ResponseDetails data={result} />
</>
)}
</section>
</form>
)
}
function EmbedTab({ model }) {
const [img, setImg] = useState(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
const [result, setResult] = useState(null)
const [elapsedMs, setElapsedMs] = useState(null)
const submit = async (e) => {
e.preventDefault()
if (!model) { ctx.toast('Select a face model first', 'warning'); return }
if (!img) { ctx.toast('Add an image', 'warning'); return }
setLoading(true); setError(null); setResult(null)
const started = performance.now()
try {
const data = await api.face.embed({ model, img: img.dataUrl })
setElapsedMs(performance.now() - started)
setResult(data)
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}
return (
<form className="biometrics-twocol" onSubmit={submit}>
<aside className="biometrics-panel">
<h2 className="biometrics-panel__title">Get a raw embedding</h2>
<p className="biometrics-panel__note">
Returns a single face embedding vector. This is the same representation the backend uses internally for verify, identify, and compare.
</p>
<ui.MediaInput mode="image" label="Image" value={img} onChange={setImg} idPrefix="face-embed" />
<button type="submit" className="btn btn-primary btn-full" disabled={loading || !img}>
{loading ? <><ui.Spinner size="sm" /> Embedding…</> : <><Icon name="code" /> Extract vector</>}
</button>
</aside>
<section className="biometrics-results">
{loading && <div className="biometrics-empty"><ui.Spinner size="lg" /></div>}
{error && <ui.ErrorWithTraceLink message={error} />}
{!loading && !error && !result && (
<EmptyState icon="code"
title="Get a face embedding"
body="For developers — retrieve the raw vector for a face to store, search, or cluster outside of LocalAI." />
)}
{result && (
<ui.EmbeddingInspector embedding={result.embedding} dim={result.dim} model={result.model} elapsedMs={elapsedMs} />
)}
</section>
</form>
)
}
function LivenessPill({ isReal, score }) {
if (isReal == null) {
return <span className="biometrics-pill muted"><Icon name="help-circle" /> Not checked</span>
}
return (
<span className={`biometrics-pill ${isReal ? 'good' : 'bad'}`}>
<Icon name={isReal ? 'user-shield' : 'mask'} />
{isReal ? 'Real' : 'Spoof'}
{score != null && <small>{(score * 100).toFixed(0)}%</small>}
</span>
)
}
// Not ui.EmptyState: this is the biometrics empty card of the core page.
function EmptyState({ icon, title, body }) {
return (
<div className="biometrics-empty">
<Icon name={icon} aria-hidden="true" />
<h3>{title}</h3>
<p>{body}</p>
</div>
)
}
function ResponseDetails({ data }) {
return (
<details className="biometrics-response">
<summary><Icon name="chevron-right" /> Raw response</summary>
<pre>{JSON.stringify(data, null, 2)}</pre>
</details>
)
}
return { MoreFaceTools }
}
+35
View File
@@ -0,0 +1,35 @@
// The core routes the Face add-on uses, reached with the signed-in user's own
// session (sdk.core.fetch). Paths and bodies are the ones of faceApi in
// core/http/react-ui/src/utils/api.js; only the transport changed. register
// takes { model, name, img, labels }, identify { model, img, ... }.
import { createIdentityApi } from '../../../internal/identity-ui/src/registry.js'
export function createApi(ctx) {
const { json } = ctx
const query = (params = {}) => {
const q = new URLSearchParams()
for (const [k, v] of Object.entries(params)) if (v !== undefined && v !== null && v !== '') q.set(k, String(v))
const s = q.toString()
return s ? `?${s}` : ''
}
// /v1/voice/* and /v1/face/*: the shared views read both kinds; the page only
// uses the face one. A failed call keeps its status (a 404 on forget reads as
// already gone).
const identity = createIdentityApi(ctx)
return {
identity,
face: identity.face,
models: {
listCapabilities: () => json('/api/models/capabilities'),
// The gallery list, as modelsApi.list builds it: empty parameters are left out.
list: (params) => json(`/api/models${query(params)}`),
// Starts a gallery install (modelsApi.install).
install: (id) => json(`/api/models/install/${encodeURIComponent(id)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }),
},
// The running installs, polled while one is in progress.
operations: {
list: () => json('/api/operations'),
},
raw: ctx.core,
}
}
+67
View File
@@ -0,0 +1,67 @@
import en from './i18n/en.js'
import { modelFrom as pickModel } from './lib/model.js'
// The one object every page factory of the Face add-on receives. It binds the
// SDK once: React, the shared components, the session-bound core fetch, the
// translation and the small adapters that replace the host router and toasts.
export function createContext(sdk) {
const { React } = sdk
const h = React.createElement
const Fragment = React.Fragment
sdk.locale.define('en', Object.fromEntries(Object.entries(en).filter(([, v]) => typeof v === 'string')))
const lists = Object.fromEntries(Object.entries(en).filter(([, v]) => Array.isArray(v)))
const t = (key, vars) => sdk.locale.t(key, key, vars)
// A list of strings or objects (the "why" and "stored" notes).
const list = (key) => lists[key] || []
// A link to a page of this add-on, as an anchor the shell navigates.
function Link({ page, query, className, children, ...rest }) {
const href = sdk.navigation.href(query ? `${page}?${query}` : page)
return h('a', {
href, className,
onClick: (e) => { if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return; e.preventDefault(); sdk.navigation.navigate(query ? `${page}?${query}` : page) },
...rest,
}, children)
}
const useSearch = () => new URLSearchParams(sdk.navigation.current().search)
// The model a page works on: ?model=<id> first (a moved link carries it
// there, because a model id may hold "/" or ":" that a path segment cannot),
// then the first path segment of a link typed by hand.
const modelFrom = (path) => pickModel(sdk.navigation.current().search, path)
const toast = (message, kind = 'info') => (sdk.notify[kind] || sdk.notify.info)(message)
// The path prefix LocalAI is served under, read the way the host reads it
// (utils/basePath.js: the <base href> element). document.baseURI would be the
// address of the current page when there is no such element.
const basePath = (() => {
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 '' }
})()
const json = async (path, init) => {
const response = await sdk.core.fetch(path, init)
if (!response.ok) {
const body = await response.json().catch(() => ({}))
throw new Error(body?.error?.message || `HTTP ${response.status}`)
}
return response.json()
}
// The undo bar of a delayed action: text and an Undo button. Replaces the
// host's toast with an undo, which the SDK does not expose.
function UndoBar({ text, undoLabel, onUndo }) {
return h('div', { className: 'vc-undo', role: 'status' }, h('span', null, text), h(sdk.ui.Button, { variant: 'ghost', size: 'sm', onClick: onUndo }, undoLabel))
}
return {
sdk, React, h, Fragment, t, list, Icon: sdk.Icon, ui: sdk.ui, hooks: sdk.hooks, viewer: sdk.viewer, core: sdk.core,
Link, useSearch, modelFrom, toast, json, UndoBar, basePath,
openPanel: (title, render) => sdk.layers.openPanel({ title, render }),
markDirty: (flag) => sdk.markDirty(flag),
}
}
+198
View File
@@ -0,0 +1,198 @@
// Generated once from the host locale files; edit by hand from here on.
export default {
"faces.title": "Faces",
"faces.lede": "Find out whose face is in a photo, or whether two photos show the same person. Photos go only to the model running on this server.",
"tool.aria": "Recognition tool",
"tool.sameTab": "Same person?",
"tool.clip": "Clip",
"tool.clipA": "Clip A",
"tool.clipB": "Clip B",
"tool.photo": "Photo",
"tool.photoA": "Photo A",
"tool.photoB": "Photo B",
"tool.compare": "Compare",
"tool.comparing": "Comparing",
"tool.workingAria": "Working on it",
"face.person": "person",
"face.print": "faceprint",
"face.whoTab": "Who is this",
"face.whoLede": "Matches a photo against the people you enrolled.",
"face.sameLede": "Two photos, one question. Nobody has to be enrolled.",
"face.whoGo": "Find who this is",
"face.working": "Searching",
"face.whoNote": "Searches the people enrolled on this server. The photo you test with is not stored.",
"face.sameNote": "The answer comes from one faceprint per photo. Nothing is stored.",
"face.workingText": "Comparing the photo with the faceprints the server holds.",
"face.probably": "Probably {{name}}",
"face.matchText": "{{name}}'s faceprint is the closest of {{count}} compared, {{distance}} away. Anything under {{cutoff}} counts as the same person.",
"face.nobody": "Nobody enrolled looks like this",
"face.missText": "The closest was {{name}} at {{distance}}, past the cut-off of {{cutoff}}. That usually means a new person, or a photo that is blurred, turned away or in poor light.",
"face.noneEnrolled": "Nobody is enrolled to compare with",
"face.noneEnrolledText": "Enrol a person first, then look up a photo against them.",
"face.noneReturned": "The server returned no people",
"face.noneReturnedText": "It holds no faceprints made with this model. If the server restarted, enrolled faces are gone: enrol them again.",
"face.enrolThis": "Enrol this face",
"face.same": "Probably the same person",
"face.different": "Probably different people",
"face.knownTitle": "Known people",
"face.enrol": "Enrol a person",
"face.enrolTitle": "Enrol a person",
"face.enrolIntro": "The server keeps a faceprint and a name. It does not keep the photo.",
"face.emptyTitle": "Nobody enrolled in this browser yet",
"face.emptyText": "Enrol a person with one clear photo, then test a photo against them.",
"face.agreedHint": "I have their permission, or another lawful basis, to store a faceprint of them.",
"face.keepHint": "Lets you see the photo and enrol again after a server restart. Off by default. It is stored in this browser only.",
"face.antiSpoof": "Check for photos of photos",
"face.antiSpoofHint": "Needs a model that supports it.",
"face.real": "Real",
"face.spoof": "Spoof",
"face.found": "Face in photo {{n}}",
"face.neededTitle": "Faces need a face model",
"face.neededText": "Face recognition turns a photo into a faceprint and compares faceprints. It runs on a face-recognition model, and none is installed on this server.",
"face.neededWhat": "Install a model that supports face recognition. Once one is installed, this page turns on by itself. Users also need the Face recognition permission.",
"face.moreTitle": "More tools",
"face.moreHint": "detect faces, guess traits, get the raw faceprint",
"face.why": [
"The server matches one face per photo. A group photo is matched on a single face.",
"Light, angle, glasses and blur change a face a lot.",
"This is a similarity score, not a security check. Do not use it to unlock anything."
],
"face.stored": [
{
"where": "On the server",
"what": "A {{print}} (numbers that describe the face, not the picture), a name and your labels. They are kept in memory and are lost when the server restarts. The photo is not kept."
},
{
"where": "In this browser",
"what": "The name, labels and time of each person you enrolled. A copy of the photo only if you tick Keep a copy when enrolling."
},
{
"where": "Photos you test with",
"what": "They go to the model on this server to be compared and are not kept."
}
],
"same.text": "The two {{print}}s are {{distance}} apart. Anything under {{cutoff}} counts as the same person.",
"same.distance": "distance",
"word.strong": "Strong match",
"word.likely": "Likely match",
"word.close": "Close call",
"word.near": "Just past the cut-off",
"scale.aria": "Distance scale with the cut-off at {{cutoff}}. Lower means more alike. {{dots}}",
"scale.cutoff": "cut-off {{value}}",
"scale.axis": "distance between {{print}}s, lower means more alike",
"scale.oneDot": "one dot per enrolled {{person}}",
"scale.onePair": "one dot for the pair",
"ranks.aria": "Closest people, nearest first",
"ranks.within": "within cut-off",
"ranks.far": "too far",
"cutoff.label": "Cut-off",
"cutoff.default": "model default",
"cutoff.reset": "Reset to {{value}}",
"cutoff.stricter": "Stricter",
"cutoff.looser": "Looser",
"cutoff.explain": "Stricter means fewer wrong matches and more misses. The answer above updates as you move it. The server is not asked again.",
"how.title": "How to read this.",
"how.body": "Distance is how far apart two {{print}}s are, 0 meaning identical. The cut-off is the line between same and different. The word Strong or Likely says how far inside the line a result sits. It is not a probability.",
"why.title": "Why this might be wrong",
"why.short": "The clip is short: {{s}} s. Three seconds or more of speech works better.",
"clip.mic": "microphone",
"clip.camera": "camera",
"clip.record": "Record",
"clip.takePhoto": "Take a photo",
"clip.chooseAudio": "Choose a file",
"clip.choosePhoto": "Choose a photo",
"clip.readAloud": "Read this aloud, normal pace",
"clip.remove": "Remove",
"clip.blocked": "The {{thing}} is blocked. Allow it in the browser's site settings and try again, or choose a file instead.",
"clip.insecure": "A browser only opens the {{thing}} on https or on localhost. You are on {{origin}}. Open this page through localhost or add TLS, or choose a file.",
"clip.unsupported": "This browser cannot open the {{thing}}. Choose a file instead.",
"clip.missing": "No {{thing}} was found on this device. Choose a file instead.",
"clip.failed": "The {{thing}} could not be opened. Choose a file instead.",
"clip.allowed": "Microphone allowed.",
"clip.capture": "Capture",
"clip.cancel": "Cancel",
"clip.recording": "Recording, {{s}} s",
"clip.micReady": "Microphone ready",
"clip.stop": "Stop",
"clip.start": "Start recording",
"clip.recorded": "Recording",
"clip.photographed": "Photo",
"clip.uploaded": "Uploaded file",
"clip.goodLength": "good length",
"clip.short": "short",
"clip.checkLevel": "check level",
"clip.preview": "Preview",
"clip.photoAlt": "The photo you chose",
"known.caption": "People saved in this browser, with their {{noun}} state",
"known.name": "Name",
"known.added": "Added",
"known.actions": "Actions",
"known.best": "best match",
"known.notOnServer": "not on the server",
"known.notInBrowser": "not in this browser's list",
"known.removing": "removing",
"known.goneText": "The last search did not return this person. The server may have restarted, so the {{print}} is gone, or it was enrolled with another model.",
"known.extraText": "The server returned this person but this browser has no record of them.",
"known.noLabels": "no labels",
"known.undo": "Undo",
"known.reenrolSaved": "Re-enrol from saved copy",
"known.enrolAgain": "Enrol again",
"known.play": "Play the saved sample of {{name}}",
"known.forget": "Remove {{name}}",
"known.note": "This list is kept in this browser. The server has no list call, so it cannot be checked until you run a search.",
"known.noteSearched": "This list is kept in this browser. The last search checked it against the server; a person it did not return is marked.",
"stored.title": "What is stored, and where",
"enrol.close": "Close",
"enrol.cancel": "Cancel",
"enrol.submit": "Enrol",
"enrol.working": "Enrolling",
"enrol.ready": "Ready to enrol.",
"enrol.stillNeeded": "Still needed: {{list}}.",
"enrol.needModel": "a model",
"enrol.needRecording": "a recording",
"enrol.needPhoto": "a photo",
"enrol.needName": "a name",
"enrol.needTranscript": "a transcript",
"enrol.needPermission": "permission",
"enrol.recordingStep": "Recording",
"enrol.photoStep": "Photo",
"enrol.recordingLabel": "Recording",
"enrol.photoLabel": "Photo",
"enrol.whoStep": "Who is it",
"enrol.name": "Name",
"enrol.namePlaceholder": "e.g. Marta Rossi",
"enrol.labels": "Labels",
"enrol.labelsHint": "optional, one per line as key: value",
"enrol.asSpeech": "Also keep it as a speech voice",
"enrol.asSpeechHint": "Text-to-speech can then speak in this voice. Needs the exact transcript.",
"enrol.transcript": "Exact transcript",
"enrol.permissionStep": "Permission",
"enrol.agreed": "This person agreed",
"enrol.keep": "Keep a copy in this browser",
"toast.enrolled": "Enrolled {{name}}",
"toast.enrolledBoth": "Enrolled {{name}} and saved a speech voice",
"toast.tooBig": "The copy was too large for browser storage, so it was not kept.",
"toast.speechFailed": "Enrolled, but the speech voice could not be saved: {{message}}",
"toast.removing": "{{name}} will be removed.",
"toast.removed": "Removed {{name}}",
"toast.alreadyGone": "{{name}} was already gone from the server.",
"error.retry": "Try again",
"error.traces": "Open traces",
"needed.badge": "Not enabled on this server",
"needed.backend": "backend {{backend}}",
"needed.install": "Install",
"needed.installing": "Installing",
"needed.started": "Installing {{name}}. Progress is in Operations.",
"needed.loadFailed": "Models could not be listed.",
"needed.noneListed": "No matching model is listed in the galleries.",
"needed.browse": "Browse all models",
"needed.askAdmin": "Ask an administrator to install one.",
"needed.whatTitle": "What turns this on",
"off.badge": "Not enabled for your account",
"off.voice.title": "Voice recognition is off for your account",
"off.voice.text": "An administrator has not allowed you to use voice recognition.",
"off.voice.what": "An administrator can turn on the Voice recognition permission for your user in Users and keys.",
"off.face.title": "Face recognition is off for your account",
"off.face.text": "An administrator has not allowed you to use face recognition.",
"off.face.what": "An administrator can turn on the Face recognition permission for your user in Users and keys."
}
+27
View File
@@ -0,0 +1,27 @@
import { createContext } from './ctx.jsx'
import { createApi } from './api.js'
import { createFaceRecognition } from './FaceRecognition.jsx'
import * as identity from '../../../internal/identity-ui/src/identity.js'
// The second argument is for tests: the host passes the SDK only.
export function createAddon(sdk, options = {}) {
const ctx = createContext(sdk)
// The JSX of every factory compiles to calls of `h`; ctx binds it once and
// each factory reads it from ctx. This `h` is for the JSX in this file.
// eslint-disable-next-line no-unused-vars
const { h } = ctx
const api = createApi(ctx)
const pages = {
recognition: createFaceRecognition(ctx, api, identity).FaceRecognition,
...options.pages,
}
function Component({ page, path }) {
const Page = pages[page]
return Page ? <Page path={path || ''} /> : null
}
return { Component, components: {}, chatActions: [], dispose() {} }
}
// For tests: the context of an SDK, built the way createAddon builds it.
export const __ctxFor = (sdk) => createContext(sdk)
+8
View File
@@ -0,0 +1,8 @@
// The model a page works on. A moved link carries it as ?model=<id>, because
// a model id may hold "/" or ":" that an add-on path segment refuses; a link
// typed by hand may still carry it as the first path segment.
export function modelFrom(search, path) {
const fromQuery = new URLSearchParams(search || '').get('model')
if (fromQuery) return fromQuery
return String(path || '').split('/')[0]
}
+242
View File
@@ -0,0 +1,242 @@
.vc-undo { display: flex; gap: var(--dk-space-2, 8px); align-items: center; }
.vc-field { display: flex; flex-direction: column; gap: var(--dk-space-1, 4px); margin-bottom: var(--dk-space-3, 12px); }
.vc-note { color: var(--dk-text-muted, var(--dk-text)); }
/* ==========================================================================
Voices and Faces. Built on the kit's table, sheet, hub tab, range, field,
chip, badge and toast classes. What is here is what the kit does not say:
the distance scale, the verdict, the clip card, the registry's person cell
and the speech voice rows. Colours, radii and spacing are kit roles (--dk-*).
Surfaces are flat and nothing sits on a coloured rail at the edge. Motion
is switched off for reduced motion at the end of this file.
========================================================================== */
.idn-page { display: flex; flex-direction: column; gap: var(--dk-space-5); min-width: 0; }
.idn-page :is(a, button, summary, select, input, textarea, [tabindex]):focus-visible { outline: 2px solid var(--dk-accent-text); outline-offset: 2px; }
.idn-h2 { margin: 0; font: var(--dk-weight-semibold) var(--dk-text-17) / 1.3 var(--dk-font-display); color: var(--dk-text); }
.idn-page > .page-header { margin-bottom: 0; }
.idn-modelpick { min-width: 240px; max-width: 100%; }
.idn-model { display: inline-flex; align-items: center; gap: var(--dk-space-2); width: 100%; height: 36px; padding: 0 var(--dk-space-3); border: 1px solid var(--dk-control-edge); border-radius: var(--dk-radius-2); background: var(--dk-card); color: var(--dk-text); font: var(--dk-weight-semibold) var(--dk-text-13) / 1 var(--dk-font-mono); cursor: pointer; }
.idn-model:hover:not(:disabled) { background: var(--dk-hover); }
.idn-model:disabled { opacity: .5; cursor: not-allowed; }
.idn-model .searchable-select__label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: left; }
.idn-model .searchable-select__chevron { color: var(--dk-muted); font-size: 10px; }
.idn-tabs { align-self: stretch; max-width: 100%; }
.idn-tabs .dk-tab { text-decoration: none; display: inline-flex; align-items: center; gap: var(--dk-space-2); }
.idn-tabs .dk-tab[aria-selected="true"]::after { transform: scaleX(1); }
/* ---- The tool card -------------------------------------------------------- */
.idn-bench { display: flex; flex-direction: column; gap: var(--dk-space-5); min-width: 0; }
.idn-tool { padding: var(--dk-space-4) var(--dk-space-5) var(--dk-space-5); display: flex; flex-direction: column; gap: var(--dk-space-4); min-width: 0; }
.idn-tool__bar { display: flex; align-items: center; gap: var(--dk-space-3); flex-wrap: wrap; }
.idn-tool__lede { margin: 0; }
.idn-tool__body { display: flex; flex-direction: column; gap: var(--dk-space-4); min-width: 0; }
.idn-tool__go { display: flex; align-items: center; gap: var(--dk-space-3); flex-wrap: wrap; }
.idn-tool__option { align-items: flex-start; }
.idn-pair { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--dk-space-3); }
.idn-working { display: flex; flex-direction: column; gap: var(--dk-space-2); }
.idn-working .dk-hint { margin: 0; }
/* ---- Clip input ----------------------------------------------------------- */
.idn-clip { display: flex; flex-direction: column; gap: var(--dk-space-3); padding: var(--dk-space-3) var(--dk-space-4); border-radius: var(--dk-radius-3); background: var(--dk-inset); border: 1px solid var(--dk-edge); min-width: 0; }
.idn-clip__head { display: flex; align-items: center; justify-content: space-between; gap: var(--dk-space-2); min-height: 28px; }
.idn-clip__choices { display: flex; flex-direction: column; gap: var(--dk-space-3); align-items: flex-start; }
.idn-clip__buttons { display: flex; align-items: center; gap: var(--dk-space-2); flex-wrap: wrap; }
.idn-clip__prompt { margin: 0; padding: var(--dk-space-3); border-radius: var(--dk-radius-2); background: var(--dk-card); border: 1px solid var(--dk-edge); font-size: var(--dk-text-15); line-height: 1.5; }
.idn-clip__prompt .dk-eyebrow { display: block; margin-bottom: 4px; }
.idn-clip__hint, .idn-clip__notice { display: flex; align-items: flex-start; gap: var(--dk-space-2); margin: 0; font-size: var(--dk-text-13); color: var(--dk-muted); }
.idn-clip__hint .lai-icon, .idn-clip__notice .lai-icon { flex: none; margin-top: 2px; }
.idn-clip__notice { padding: var(--dk-space-2) var(--dk-space-3); border-radius: var(--dk-radius-2); background: var(--dk-card); border: 1px solid var(--dk-edge); color: var(--dk-text); }
.idn-clip__notice[role="alert"] { background: var(--dk-warn-wash); border-color: transparent; color: var(--dk-warn-text); }
.idn-clip__live { display: flex; flex-direction: column; gap: var(--dk-space-3); }
.idn-clip__video { width: 100%; max-width: 420px; border-radius: var(--dk-radius-2); background: var(--dk-canvas); }
.idn-clip__rec { margin: 0; display: flex; align-items: center; gap: var(--dk-space-2); font-size: var(--dk-text-15); }
.idn-clip__card { display: flex; flex-direction: column; gap: var(--dk-space-3); min-width: 0; }
.idn-clip__meta { display: flex; align-items: center; gap: var(--dk-space-2); flex-wrap: wrap; min-width: 0; }
.idn-clip__name { min-width: 0; overflow-wrap: anywhere; font-size: var(--dk-text-15); }
.idn-clip__img { max-width: 100%; max-height: 260px; width: auto; border-radius: var(--dk-radius-2); object-fit: contain; align-self: flex-start; background: var(--dk-canvas); }
.idn-clip__notes { margin: 0; padding-left: 18px; font-size: var(--dk-text-13); color: var(--dk-warn-text); }
.idn-clip .voice-profile-player, .idn-clip .waveform-player { min-width: 0; }
/* ---- Result: verdict, scale, ranks, cut-off ------------------------------- */
.idn-result { display: flex; flex-direction: column; gap: var(--dk-space-4); padding-top: var(--dk-space-4); border-top: 1px solid var(--dk-edge); min-width: 0; }
.idn-verdict { display: grid; grid-template-columns: 44px minmax(0, 1fr); gap: var(--dk-space-3); align-items: start; }
.idn-verdict__icon { display: grid; place-items: center; width: 44px; height: 44px; border-radius: 50%; background: var(--dk-inset); color: var(--dk-muted); font-size: 20px; }
.idn-verdict[data-tone="match"] .idn-verdict__icon { background: var(--dk-ok-wash); color: var(--dk-ok-text); }
.idn-verdict__title { margin: 0; display: flex; align-items: center; flex-wrap: wrap; gap: var(--dk-space-2); font: var(--dk-weight-bold) var(--dk-text-21) / 1.25 var(--dk-font-display); overflow-wrap: anywhere; }
.idn-verdict__title .dk-badge { font-family: var(--dk-font-body); }
.idn-verdict__text { margin: 4px 0 0; max-width: 70ch; font-size: var(--dk-text-15); line-height: 1.5; }
.idn-scale { margin: 0; padding: 0 36px; }
.idn-scale__plot { position: relative; height: calc(108px + var(--rows, 0) * 18px); }
.idn-scale__track { position: absolute; left: 0; right: 0; top: 38px; height: 8px; border-radius: var(--dk-radius-pill); background: var(--dk-inset); overflow: hidden; border: 1px solid var(--dk-edge); box-sizing: border-box; }
.idn-scale__inside { position: absolute; left: 0; top: 0; bottom: 0; width: var(--at); background: var(--dk-accent-wash); }
.idn-scale__cut { position: absolute; left: var(--at); top: 22px; height: 32px; width: 2px; margin-left: -1px; background: var(--dk-text); z-index: 1; }
.idn-scale__cut-label { position: absolute; bottom: 100%; left: 50%; transform: translateX(-50%); white-space: nowrap; padding-bottom: 2px; font-size: 11px; font-weight: 600; }
.idn-scale__dot { position: absolute; left: var(--at); top: 0; width: 0; height: 0; z-index: 2; }
.idn-scale__mark { position: absolute; left: -8px; top: 34px; width: 16px; height: 16px; box-sizing: border-box; border-radius: 50%; background: var(--dk-card); border: 2px solid var(--dk-control-edge); }
.idn-scale__dot[data-within="true"] .idn-scale__mark { border-color: var(--dk-accent-text); }
.idn-scale__dot[data-best="true"] .idn-scale__mark { background: var(--dk-accent); border-color: var(--dk-accent); box-shadow: 0 0 0 3px var(--dk-card); }
.idn-scale__name { position: absolute; left: 0; transform: translateX(-50%); top: 58px; max-width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; }
.idn-scale__dot[data-row="1"] .idn-scale__name { top: 76px; }
.idn-scale__dot[data-row="2"] .idn-scale__name { top: 94px; }
.idn-scale__ticks { position: absolute; left: 0; right: 0; top: calc(86px + var(--rows, 0) * 18px); height: 16px; }
.idn-scale__tick { position: absolute; left: var(--at); transform: translateX(-50%); font-size: 11px; color: var(--dk-muted); }
.idn-scale__tick::before { content: ""; position: absolute; left: 50%; bottom: 100%; height: 5px; width: 1px; background: var(--dk-control-edge); margin-bottom: 1px; }
.idn-scale__caption { display: flex; justify-content: space-between; gap: var(--dk-space-3); margin: var(--dk-space-2) -36px 0; padding: 0 4px; font-size: 12px; color: var(--dk-muted); }
.idn-ranks { margin: 0; padding: 0; list-style: none; }
.idn-ranks li { display: grid; grid-template-columns: 32px minmax(0, 1fr) auto auto; align-items: center; gap: var(--dk-space-3); min-height: 40px; border-bottom: 1px solid var(--dk-edge); font-size: var(--dk-text-15); }
.idn-ranks li:last-child { border-bottom: 0; }
.idn-ranks li[data-within="true"] .idn-ranks__name { font-weight: var(--dk-weight-semibold); }
.idn-ranks__n { color: var(--dk-muted); font-size: 12px; }
.idn-ranks__name { min-width: 0; overflow-wrap: anywhere; }
.idn-result__tune { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr); gap: var(--dk-space-5); align-items: start; }
.idn-cutoff { display: flex; flex-direction: column; gap: var(--dk-space-2); min-width: 0; }
.idn-cutoff__head { display: flex; align-items: center; justify-content: space-between; gap: var(--dk-space-2); flex-wrap: wrap; }
.idn-cutoff__row { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: var(--dk-space-3); }
.idn-cutoff .dk-hint, .idn-how { margin: 0; font-size: var(--dk-text-13); color: var(--dk-muted); line-height: 1.5; }
.idn-how strong { color: var(--dk-text); }
.idn-result__enrol { align-self: flex-start; }
.idn-face { margin: 0; display: flex; flex-direction: column; gap: var(--dk-space-2); }
/* ---- Disclosures ---------------------------------------------------------- */
.idn-disclosure > summary { display: flex; align-items: center; gap: var(--dk-space-2); min-height: 40px; cursor: pointer; font-weight: var(--dk-weight-semibold); font-size: var(--dk-text-15); list-style: none; }
.idn-disclosure > summary::-webkit-details-marker { display: none; }
.idn-disclosure > summary .lai-icon { color: var(--dk-muted); transition: transform var(--dk-dur-1) var(--dk-ease-out); }
.idn-disclosure[open] > summary .lai-icon:first-child { transform: rotate(90deg); }
.idn-disclosure > summary .dk-hint { font-weight: 400; }
.idn-disclosure__list { margin: 0 0 var(--dk-space-3); padding-left: 38px; font-size: var(--dk-text-13); color: var(--dk-muted); line-height: 1.6; }
.idn-disclosure__text { margin: 0 0 var(--dk-space-3); padding-left: 24px; font-size: var(--dk-text-13); color: var(--dk-muted); max-width: 70ch; }
.idn-disclosure--page { border-top: 1px solid var(--dk-edge); }
.idn-stored { margin: 0 0 var(--dk-space-3); padding-left: 24px; display: grid; gap: var(--dk-space-3); }
.idn-stored div { display: grid; grid-template-columns: 160px minmax(0, 1fr); gap: var(--dk-space-3); }
.idn-stored dt { font-weight: var(--dk-weight-semibold); font-size: var(--dk-text-13); }
.idn-stored dd { margin: 0; font-size: var(--dk-text-13); color: var(--dk-muted); line-height: 1.55; max-width: 70ch; }
.idn-more__body { padding: 0 0 var(--dk-space-4) 24px; }
/* ---- Known people --------------------------------------------------------- */
.idn-known { display: flex; flex-direction: column; gap: var(--dk-space-2); min-width: 0; }
.idn-known__head { display: flex; align-items: center; justify-content: space-between; gap: var(--dk-space-3); flex-wrap: wrap; }
.idn-known__note { margin: 0; }
.idn-known .dk-table-wrap { border: 1px solid var(--dk-edge); border-radius: var(--dk-radius-3); background: var(--dk-card); }
.idn-known__empty { padding: var(--dk-space-6) var(--dk-space-4); border: 1px solid var(--dk-edge); border-radius: var(--dk-radius-3); background: var(--dk-card); }
.idn-who { display: flex; align-items: center; gap: var(--dk-space-3); min-width: 0; }
.idn-who__text { display: flex; flex-direction: column; min-width: 0; }
.idn-who .dk-table-name { display: flex; align-items: center; flex-wrap: wrap; gap: var(--dk-space-2); }
.idn-avatar { display: inline-grid; place-items: center; flex: none; width: 36px; height: 36px; border-radius: 50%; overflow: hidden; background: var(--dk-inset); border: 1px solid var(--dk-edge); font: var(--dk-weight-bold) 12px var(--dk-font-mono); color: var(--dk-text); }
.idn-avatar img { width: 100%; height: 100%; object-fit: cover; }
.idn-avatar[data-tone="warn"] { background: var(--dk-warn-wash); color: var(--dk-warn-text); border-color: transparent; }
.idn-when { white-space: nowrap; font-size: 12px; color: var(--dk-muted); }
.idn-actions { text-align: right; white-space: nowrap; }
.idn-actions > * { vertical-align: middle; }
.idn-known tr[data-pending="true"] .idn-who { opacity: .55; }
/* ---- Enrol sheet ---------------------------------------------------------- */
.idn-sheet { display: flex; flex-direction: column; gap: var(--dk-space-5); }
.idn-sheet__step { display: flex; flex-direction: column; gap: var(--dk-space-3); }
.idn-sheet__h { margin: 0; display: flex; align-items: center; gap: var(--dk-space-2); font: var(--dk-weight-semibold) var(--dk-text-15) / 1.3 var(--dk-font-display); }
.idn-sheet__n { display: inline-grid; place-items: center; width: 24px; height: 24px; border-radius: 50%; border: 1.5px solid var(--dk-accent-text); color: var(--dk-accent-text); font: 600 12px var(--dk-font-mono); }
.idn-sheet__choice { align-items: flex-start; gap: var(--dk-space-3); }
.idn-sheet__choice > span { display: flex; flex-direction: column; gap: 2px; }
.idn-sheet__choice .dk-hint { margin: 0; }
.idn-sheet__need { margin-right: auto; font-size: var(--dk-text-13); color: var(--dk-muted); }
/* ---- Errors, disabled state, recording tab -------------------------------- */
.idn-error { display: grid; grid-template-columns: 32px minmax(0, 1fr); gap: var(--dk-space-3); padding: var(--dk-space-3) var(--dk-space-4); border-radius: var(--dk-radius-2); background: var(--dk-error-wash); color: var(--dk-error-text); }
.idn-error__icon { font-size: 22px; padding-top: 2px; }
.idn-error__title { margin: 0; font: var(--dk-weight-semibold) var(--dk-text-15) / 1.3 var(--dk-font-display); }
.idn-error__text, .idn-error__raw { margin: 4px 0 0; font-size: var(--dk-text-13); }
.idn-error__raw { opacity: .85; overflow-wrap: anywhere; }
.idn-error__acts { display: flex; gap: var(--dk-space-2); flex-wrap: wrap; margin-top: var(--dk-space-3); }
.idn-error .dk-btn { color: inherit; }
.idn-needed { padding: var(--dk-space-5); display: flex; flex-direction: column; align-items: flex-start; gap: var(--dk-space-3); }
.idn-needed__text { margin: 0; max-width: 62ch; font-size: var(--dk-text-15); line-height: 1.5; }
.idn-needed__list { margin: 0; padding: 0; list-style: none; width: min(100%, 560px); border: 1px solid var(--dk-edge); border-radius: var(--dk-radius-3); }
.idn-needed__list li { display: flex; align-items: center; justify-content: space-between; gap: var(--dk-space-3); padding: var(--dk-space-3) var(--dk-space-4); border-bottom: 1px solid var(--dk-edge); }
.idn-needed__list li:last-child { border-bottom: 0; }
.idn-needed__list li > span { display: flex; flex-direction: column; min-width: 0; }
.idn-needed__list .dk-hint { margin: 0; }
.idn-off { min-height: 40vh; }
.idn-recording { padding: var(--dk-space-5); display: flex; flex-direction: column; align-items: flex-start; gap: var(--dk-space-3); }
.idn-recording__text { margin: 0; max-width: 62ch; line-height: 1.5; }
.idn-recording__steps { margin: 0; padding-left: 20px; display: grid; gap: 4px; color: var(--dk-muted); font-size: var(--dk-text-15); }
.idn-recording .dk-hint { margin: 0; max-width: 62ch; }
/* ---- Speech voices -------------------------------------------------------- */
.voice-library-page { display: flex; flex-direction: column; gap: var(--dk-space-4); min-width: 0; }
.idn-speech__head { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--dk-space-4); flex-wrap: wrap; }
.idn-speech__lede { margin: 4px 0 0; max-width: 56ch; }
.idn-speech__acts { display: flex; align-items: center; gap: var(--dk-space-3); flex-wrap: wrap; }
.voice-library-toolbar { display: flex; gap: var(--dk-space-3); flex-wrap: wrap; }
.voice-library-search { flex: 1 1 260px; min-width: 0; }
.voice-library-language { min-width: 160px; }
.voice-library-loading { display: flex; align-items: center; gap: var(--dk-space-3); padding: var(--dk-space-5); color: var(--dk-muted); }
.idn-speech__list { border: 1px solid var(--dk-edge); border-radius: var(--dk-radius-3); background: var(--dk-card); overflow: hidden; }
.idn-speech__list .dk-row { text-align: left; }
.voice-row__end { display: flex; align-items: center; gap: var(--dk-space-3); }
.voice-row__meta { font-size: 12px; color: var(--dk-muted); white-space: nowrap; }
.voice-row__waveform { display: inline-flex; align-items: center; gap: 2px; width: 120px; height: 22px; }
.voice-row__waveform > span { flex: 1; min-width: 2px; border-radius: 2px; background: var(--dk-control-edge); }
.voice-row__chevron { color: var(--dk-muted); }
.voice-library-empty { min-height: 0; }
.voice-library-detail { display: flex; flex-direction: column; gap: var(--dk-space-4); min-width: 0; }
.voice-detail__player { min-width: 0; }
.voice-detail__section { display: flex; flex-direction: column; gap: var(--dk-space-2); }
.voice-detail__section h3 { margin: 0; }
.voice-detail__section blockquote { margin: 0; padding: var(--dk-space-3) var(--dk-space-4); border-radius: var(--dk-radius-2); background: var(--dk-inset); border: 1px solid var(--dk-edge); font-size: var(--dk-text-15); line-height: 1.55; overflow-wrap: anywhere; }
.voice-detail__metadata { margin: 0; }
.voice-detail__model-setup { display: flex; flex-direction: column; gap: var(--dk-space-3); padding: var(--dk-space-4); border-radius: var(--dk-radius-3); background: var(--dk-inset); border: 1px solid var(--dk-edge); }
.voice-detail__model-setup-heading { display: flex; gap: var(--dk-space-3); align-items: flex-start; }
.voice-detail__model-setup-heading h3 { margin: 0; font: var(--dk-weight-semibold) var(--dk-text-15) / 1.3 var(--dk-font-display); }
.voice-detail__model-setup-heading p, .voice-detail__model-fallback, .voice-detail__capability-note { margin: 4px 0 0; font-size: var(--dk-text-13); color: var(--dk-muted); }
.voice-detail__model-list { margin: 0; padding: 0; list-style: none; display: grid; gap: var(--dk-space-2); }
.voice-detail__model-list li { display: flex; align-items: center; justify-content: space-between; gap: var(--dk-space-3); }
.voice-detail__model-list li > span { display: flex; flex-direction: column; min-width: 0; }
.voice-detail__model-list small { color: var(--dk-muted); }
.voice-detail__model-loading { display: flex; align-items: center; gap: var(--dk-space-2); font-size: var(--dk-text-13); color: var(--dk-muted); }
.voice-detail__model-fallback a { color: var(--dk-accent-text); }
.voice-detail__api { border-top: 1px solid var(--dk-edge); }
.voice-detail__api-body { display: flex; flex-direction: column; gap: var(--dk-space-3); padding: 0 0 var(--dk-space-3) 24px; font-size: var(--dk-text-13); color: var(--dk-muted); }
.voice-detail__api-body > p { margin: 0; }
.voice-detail__compatible-models { display: flex; flex-direction: column; gap: var(--dk-space-2); }
.voice-detail__compatible-models ul { display: flex; flex-wrap: wrap; gap: var(--dk-space-2); margin: 0; padding: 0; list-style: none; }
.voice-detail__compatible-models li { display: inline-flex; align-items: center; gap: var(--dk-space-2); padding: 2px 10px; border-radius: var(--dk-radius-pill); background: var(--dk-ok-wash); color: var(--dk-ok-text); }
.voice-detail__code-heading { display: flex; align-items: center; justify-content: space-between; gap: var(--dk-space-2); }
.voice-detail__api pre { margin: 0; padding: var(--dk-space-3); overflow-x: auto; border-radius: var(--dk-radius-2); background: var(--dk-inset); border: 1px solid var(--dk-edge); color: var(--dk-text); font-size: 12px; line-height: 1.55; }
.voice-delete { margin-right: auto; }
/* ---- Phone ---------------------------------------------------------------- */
@media (max-width: 720px) {
.idn-tool { padding: var(--dk-space-3); }
.idn-pair { grid-template-columns: minmax(0, 1fr); }
.idn-result__tune { grid-template-columns: minmax(0, 1fr); gap: var(--dk-space-4); }
.idn-scale { padding: 0 26px; }
.idn-scale__caption { margin: var(--dk-space-2) -26px 0; flex-direction: column; gap: 2px; }
.idn-stored div { grid-template-columns: minmax(0, 1fr); gap: 2px; }
.idn-verdict { grid-template-columns: 36px minmax(0, 1fr); }
.idn-verdict__icon { width: 36px; height: 36px; font-size: 17px; }
.idn-verdict__title { font-size: var(--dk-text-17); }
.idn-modelpick { min-width: 0; width: 100%; }
.voice-row__waveform { display: none; }
.idn-ranks li { grid-template-columns: 20px minmax(0, 1fr) auto auto; gap: var(--dk-space-2); font-size: var(--dk-text-13); }
.idn-known .dk-table td { padding-inline: var(--dk-space-2); }
.idn-actions { white-space: normal; }
.idn-actions .dk-btn--secondary { margin-bottom: 4px; }
.idn-more__body { padding-left: 0; }
.idn-more > summary .dk-hint { display: none; }
.idn-needed__list li { flex-direction: column; align-items: flex-start; }
}
@media (prefers-reduced-motion: no-preference) {
.idn-clip__rec .dk-dot[data-live="true"] { animation: idn-pulse 1.1s ease-in-out infinite; }
}
@keyframes idn-pulse { 50% { opacity: .35; } }
@media (prefers-reduced-motion: reduce) {
.idn-disclosure > summary .lai-icon { transition: none; }
/* The kit's indeterminate bar keeps sliding; here it sits still at its width. */
.idn-working .dk-progress[data-indeterminate] .dk-progress-bar { animation: none; }
}
.idn-who .dk-table-sub { white-space: normal; overflow: visible; text-overflow: clip; max-width: 62ch; }
+17
View File
@@ -0,0 +1,17 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { modelFrom } from '../src/lib/model.js'
test('the model comes from ?model first and keeps slashes, colons and percent signs', () => {
assert.equal(modelFrom('model=org%2Fname%3Av1', 'ignored'), 'org/name:v1')
})
test('without ?model the model is the first path segment', () => {
assert.equal(modelFrom('', 'speechbrain/extra'), 'speechbrain')
assert.equal(modelFrom('x=1', 'speechbrain'), 'speechbrain')
})
test('no model gives an empty string', () => {
assert.equal(modelFrom('', ''), '')
assert.equal(modelFrom(undefined, undefined), '')
})
+86
View File
@@ -0,0 +1,86 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { createRequire } from 'node:module'
import { fileURLToPath } from 'node:url'
import { dirname, join, resolve } from 'node:path'
import { load } from './render.test.mjs'
const here = dirname(fileURLToPath(import.meta.url))
const require = createRequire(join(resolve(here, '../../../../core/http/react-ui'), 'package.json'))
const React = require('react')
const { renderToString } = require('react-dom/server')
test('recognition: the first paint has the faces page test id', async () => {
const { mod, sdk } = await load()
const addon = await mod.createAddon(sdk)
const html = renderToString(React.createElement(addon.Component, { page: 'recognition', path: '' }))
assert.match(html, /data-testid="faces-page"/)
})
test('recognition: a viewer without face_recognition gets the off notice', async () => {
const { mod, sdk } = await load({ viewer: { isAdmin: () => false, hasFeature: (f) => f !== 'face_recognition' } })
const addon = await mod.createAddon(sdk)
const html = renderToString(React.createElement(addon.Component, { page: 'recognition', path: '' }))
assert.match(html, /data-testid="face-off"/)
assert.doesNotMatch(html, /idn-bench/)
})
test('recognition: register, identify and forget use the core face routes', async () => {
const { createApi } = await import('../src/api.js')
const seen = []
const ctx = { core: { fetch: async (p, i) => { seen.push([p, i?.method]); return { ok: true, json: async () => ({}) } } }, json: async (p, i) => { seen.push([p, i?.method]); return {} } }
const api = createApi(ctx)
await api.face.register({ model: 'm', img: 'x', name: 'a' })
await api.face.identify({ model: 'm', img: 'x' })
await api.face.forget({ model: 'm', id: 'a' })
assert.deepEqual(seen.map(s => s[0]), ['/v1/face/register', '/v1/face/identify', '/v1/face/forget'])
assert.deepEqual(seen.map(s => s[1]), ['POST', 'POST', 'POST'])
})
test('recognition: any viewer who holds face_recognition gets the library, administrator or not', async () => {
const { mod, sdk } = await load({ viewer: { isAdmin: () => false, hasFeature: () => true } })
const addon = await mod.createAddon(sdk)
const html = renderToString(React.createElement(addon.Component, { page: 'recognition', path: '' }))
assert.match(html, /idn-bench/)
assert.doesNotMatch(html, /data-testid="face-off"/)
})
test('recognition: the model of the path reaches the model selection', async () => {
const seen = []
const names = ['Button', 'Spinner', 'EmptyState', 'Badge', 'Stack', 'Text', 'PageHeader', 'ConfirmDialog', 'ErrorWithTraceLink', 'MediaInput', 'WaveformPlayer', 'DistributionBars', 'EmbeddingInspector', 'TabSwitch']
const components = Object.fromEntries(names.map(n => [n, (p) => React.createElement('div', null, p.title || p.label || p.children || null)]))
components.PageHeader = (p) => React.createElement('header', null, p.title, p.actions)
components.ModelSelector = (p) => { seen.push(p.value); return React.createElement('div') }
const { mod, sdk } = await load({ components })
const addon = await mod.createAddon(sdk)
renderToString(React.createElement(addon.Component, { page: 'recognition', path: 'insightface' }))
assert.deepEqual(seen, ['insightface'])
})
test('recognition: the bundle has the face canvas, the face strings and no host import', async () => {
const { readFileSync } = await import('node:fs')
const { build } = await import('../build.mjs')
const dir = await build()
const js = readFileSync(join(dir, 'ui/face.js'), 'utf8')
assert.match(js, /biometrics-bbox/)
assert.doesNotMatch(js, /from ['"]react(-dom)?/)
assert.match(js, /faces\.title/)
})
test('recognition: every translation key the sources read is in the Face bundle', async () => {
const { readFileSync, readdirSync, statSync } = await import('node:fs')
const { default: en } = await import('../src/i18n/en.js')
const roots = [join(here, '../src'), join(here, '../../../internal/identity-ui/src')]
const files = []
const walk = (d) => { for (const f of readdirSync(d)) { const p = join(d, f); if (statSync(p).isDirectory()) walk(p); else if (/\.jsx?$/.test(p) && !p.includes('i18n')) files.push(p) } }
roots.forEach(walk)
const missing = []
for (const f of files) {
for (const m of readFileSync(f, 'utf8').matchAll(/\bt\(\s*'([a-zA-Z]+\.[a-zA-Z0-9_.]+)'/g)) {
// The voice branch of a shared view is never reached on this page.
if (m[1].startsWith('voice.')) continue
if (!(m[1] in en)) missing.push(`${f.split('/').pop()}: ${m[1]}`)
}
}
assert.deepEqual(missing, [])
})
+75
View File
@@ -0,0 +1,75 @@
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/face.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: 'face', generation: 1, signal: controller.signal, pages: ['recognition'],
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 the recognition page, 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, 'face')
assert.deepEqual(m.capabilities.ui.pages, ['recognition'])
assert.deepEqual(m.capabilities.ui.navigation, [])
assert.equal(m.capabilities.ui.mainPage, 'recognition')
assert.deepEqual(m.capabilities.ui.sdk, { min: 2, max: 2 })
assert.deepEqual(m.capabilities.tools ?? [], [])
})
test('render: a registered page is drawn with its path', async () => {
const { mod, sdk } = await load()
const addon = await mod.createAddon(sdk, { pages: { recognition: ({ path }) => React.createElement('p', null, `page:${path}`) } })
assert.equal(renderToString(React.createElement(addon.Component, { page: 'recognition', path: 'abc' })), '<p>page:abc</p>')
})
test('render: the recognition page draws exactly one h1, its own title', async () => {
const { mod, sdk } = await load()
const addon = await mod.createAddon(sdk)
const html = renderToString(React.createElement(addon.Component, { page: 'recognition', path: '' }))
assert.deepEqual([...html.matchAll(/<h1[^>]*>(.*?)<\/h1>/g)].map(m => m[1]), ['Faces'])
})
+23
View File
@@ -0,0 +1,23 @@
// Runs the node unit tests of the Voice UI package and prints one JSON line
// per test, the same shape as the host's runner (core/http/react-ui/tests/unit):
// {"case":"unit: <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,21 @@
// SPDX-License-Identifier: MIT
// Command fine-tuning is the executable of the fine-tuning add-on. It serves
// the typed tools of the add-on and reaches the job engine through the host
// operations; the add-on UI works in the browser through the SDK.
package main
import (
"os"
"github.com/mudler/LocalAI/addons/fine-tuning/internal/tools"
"github.com/mudler/LocalAI/addons/internal/hostclient"
"github.com/mudler/LocalAI/addons/internal/protocol"
"github.com/mudler/LocalAI/pkg/addons/contract"
)
func main() {
os.Exit(protocol.Run(os.Args, func(s protocol.Startup) contract.Runtime {
return tools.New(hostclient.New(s), s.DataDir)
}))
}
@@ -0,0 +1,142 @@
// SPDX-License-Identifier: MIT
package tools
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"sync"
"github.com/mudler/LocalAI/pkg/addons/contract"
)
// maxReceipts bounds the receipts the journal keeps. A repeat of an older
// operation is not answered from memory any more; the host asks for a receipt
// only for a recent, unsettled operation.
const maxReceipts = 1024
// job is what the add-on knows of one resource a tool created: a job it
// started or a dataset it sent. The engine job ID
// never leaves the add-on: a model names the job by its resource.
type job struct {
JobID string `json:"jobId,omitempty"`
Name string `json:"name"`
// Dataset marks a resource that stands for an inline dataset and not for a
// job; Ref is the reference the host issued for it.
Dataset bool `json:"dataset,omitempty"`
Ref string `json:"ref,omitempty"`
// Revision counts the changes a tool made to the job. It is the data
// revision of the resource, so a stale approval is noticed.
Revision uint64 `json:"revision"`
}
type journalFile struct {
Jobs map[string]job `json:"jobs"`
Receipts map[string]contract.Receipt `json:"receipts"`
Order []string `json:"order"`
}
// journal maps resource IDs to engine job IDs and keeps the receipts of the
// operations that changed something. It is one JSON file in the data
// directory, written through a temporary file and renamed over the old one.
type journal struct {
mu sync.Mutex
path string
data journalFile
}
func openJournal(dir string) *journal {
j := &journal{path: filepath.Join(dir, "journal.json")}
j.data = journalFile{Jobs: map[string]job{}, Receipts: map[string]contract.Receipt{}}
if b, err := os.ReadFile(j.path); err == nil {
var f journalFile
if json.Unmarshal(b, &f) == nil {
if f.Jobs != nil {
j.data.Jobs = f.Jobs
}
if f.Receipts != nil {
j.data.Receipts = f.Receipts
}
j.data.Order = f.Order
}
}
return j
}
func (j *journal) job(resource string) (job, bool) {
j.mu.Lock()
defer j.mu.Unlock()
v, ok := j.data.Jobs[resource]
return v, ok
}
func (j *journal) receipt(op string) (contract.Receipt, bool) {
j.mu.Lock()
defer j.mu.Unlock()
r, ok := j.data.Receipts[op]
return r, ok
}
// commit stores the receipt of an operation and, when upd is not nil, the new
// state of a job in one write. A failed write leaves the memory as it was, so
// the caller reports the failure and nothing claims a state that is not on disk.
func (j *journal) commit(resource string, upd *job, r contract.Receipt) error {
j.mu.Lock()
defer j.mu.Unlock()
next := journalFile{Jobs: map[string]job{}, Receipts: map[string]contract.Receipt{}, Order: append([]string(nil), j.data.Order...)}
for k, v := range j.data.Jobs {
next.Jobs[k] = v
}
for k, v := range j.data.Receipts {
next.Receipts[k] = v
}
if upd != nil {
next.Jobs[resource] = *upd
}
if _, seen := next.Receipts[r.OperationID]; !seen {
next.Order = append(next.Order, r.OperationID)
}
next.Receipts[r.OperationID] = r
for len(next.Order) > maxReceipts {
delete(next.Receipts, next.Order[0])
next.Order = next.Order[1:]
}
if err := writeAtomic(j.path, next); err != nil {
return err
}
j.data = next
return nil
}
func writeAtomic(path string, v any) error {
b, err := json.Marshal(v)
if err != nil {
return err
}
tmp, err := os.CreateTemp(filepath.Dir(path), "journal-*.tmp")
if err != nil {
return err
}
name := tmp.Name()
fail := func(e error) error { _ = tmp.Close(); _ = os.Remove(name); return e }
if _, err := tmp.Write(b); err != nil {
return fail(err)
}
if err := tmp.Sync(); err != nil {
return fail(err)
}
if err := tmp.Close(); err != nil {
_ = os.Remove(name)
return err
}
if err := os.Chmod(name, 0o600); err != nil {
_ = os.Remove(name)
return errors.Join(err)
}
if err := os.Rename(name, path); err != nil {
_ = os.Remove(name)
return err
}
return nil
}
@@ -0,0 +1,14 @@
// SPDX-License-Identifier: MIT
package tools
import (
"testing"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestTools(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Fine-tuning tools suite")
}
+557
View File
@@ -0,0 +1,557 @@
// SPDX-License-Identifier: MIT
// Package tools is the runtime of the fine-tuning add-on executable. A job that
// a tool starts is a resource of the host: the host reserves the resource ID,
// and this package remembers which engine job it stands for. A tool can only
// control a job that a tool started, because only those have a resource.
package tools
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"regexp"
"strings"
"time"
"unicode/utf8"
"github.com/mudler/LocalAI/addons/internal/hostclient"
"github.com/mudler/LocalAI/addons/internal/protocol"
"github.com/mudler/LocalAI/pkg/addons/contract"
)
// Tool names. They are the entries of capabilities.tools in the manifest and
// the exact values of arguments.tool.
const (
ToolResourceExists = "resource-exists"
ToolList = "fine-tune-list"
ToolStart = "fine-tune-start"
ToolDataset = "fine-tune-dataset"
ToolStatus = "fine-tune-status"
ToolCheckpoints = "fine-tune-checkpoints"
ToolStop = "fine-tune-stop"
ToolExport = "fine-tune-export"
)
// Tools is the declared tool set of this add-on, in manifest order.
//
// fine-tune-list is a read tool that names no resource: it lists the jobs of the
// calling user, the ones started in the UI included. The host runs it as that
// user and the host operation scopes the answer to the same user. Every other
// tool works on a resource that a tool created.
var Tools = []string{ToolResourceExists, ToolList, ToolStart, ToolDataset, ToolStatus, ToolCheckpoints, ToolStop, ToolExport}
// Engine is the host operation channel. *hostclient.Client implements it, and
// a nil client answers hostclient.ErrNoHostOps.
type Engine interface {
Do(ctx context.Context, operationID, op string, args, out any) error
}
var _ Engine = (*hostclient.Client)(nil)
// schema is the schema revision of every job. The data revision counts the
// changes a tool made.
const schema uint64 = 1
var (
modelName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`)
exportFormats = map[string]bool{"lora": true, "merged_16bit": true, "merged_4bit": true, "gguf": true}
)
const maxNameRunes = 80
// maxDatasetBytes is the bound of an inline dataset, the one of the host
// operation.
const maxDatasetBytes = 900 << 10
var datasetExt = []string{".jsonl", ".json", ".csv", ".txt"}
type runtime struct {
eng Engine
j *journal
now func() time.Time
}
// New returns the runtime of the executable. dataDir holds the journal.
func New(eng Engine, dataDir string) contract.Runtime {
return &runtime{eng: eng, j: openJournal(dataDir), now: time.Now}
}
// startArgs are the arguments of fine-tune-start. The host adds "tool"; the
// name is the label the host shows in the approval card and the resource list.
type startArgs struct {
Tool string `json:"tool"`
Name string `json:"name"`
Model string `json:"model"`
Backend string `json:"backend"`
TrainingType string `json:"trainingType"`
TrainingMethod string `json:"trainingMethod"`
DatasetSource string `json:"datasetSource"`
DatasetSplit string `json:"datasetSplit"`
LearningRate float64 `json:"learningRate"`
NumEpochs int64 `json:"numEpochs"`
BatchSize int64 `json:"batchSize"`
MaxSteps int64 `json:"maxSteps"`
AdapterRank int64 `json:"adapterRank"`
AdapterAlpha int64 `json:"adapterAlpha"`
}
// engineStart is the argument object of finetune.start. It has exactly the
// fields the host operation accepts and none of the add-on's own.
type engineStart struct {
Model string `json:"model"`
Backend string `json:"backend,omitempty"`
TrainingType string `json:"trainingType,omitempty"`
TrainingMethod string `json:"trainingMethod,omitempty"`
DatasetSource string `json:"datasetSource"`
DatasetSplit string `json:"datasetSplit,omitempty"`
LearningRate float64 `json:"learningRate,omitempty"`
NumEpochs int64 `json:"numEpochs,omitempty"`
BatchSize int64 `json:"batchSize,omitempty"`
MaxSteps int64 `json:"maxSteps,omitempty"`
AdapterRank int64 `json:"adapterRank,omitempty"`
AdapterAlpha int64 `json:"adapterAlpha,omitempty"`
}
// datasetArgs are the arguments of fine-tune-dataset. The name is the file
// name of the dataset and the label of the resource.
type datasetArgs struct {
Tool string `json:"tool"`
Name string `json:"name"`
Content string `json:"content"`
}
type stopArgs struct {
Tool string `json:"tool"`
SaveCheckpoint bool `json:"saveCheckpoint"`
}
type exportArgs struct {
Tool string `json:"tool"`
ExportFormat string `json:"exportFormat"`
Name string `json:"name"`
}
// decode reads the arguments into a closed struct: an unknown field or
// trailing data is a bad request.
func decode(raw json.RawMessage, v any) error {
if len(raw) == 0 {
raw = json.RawMessage(`{}`)
}
dec := json.NewDecoder(bytes.NewReader(raw))
dec.DisallowUnknownFields()
if err := dec.Decode(v); err != nil {
return protocol.ErrBadRequest
}
if _, err := dec.Token(); err != io.EOF {
return protocol.ErrBadRequest
}
return nil
}
func toolOf(env contract.Envelope) (string, error) {
var a struct {
Tool string `json:"tool"`
}
if json.Unmarshal(env.Request.Arguments, &a) != nil || a.Tool == "" {
return "", protocol.ErrBadRequest
}
return a.Tool, nil
}
func payload(v any) json.RawMessage { b, _ := json.Marshal(v); return b }
// mapErr turns the refusal of a host operation into the protocol error that
// tells the owner what happened. A failure that does not say the request was
// wrong stays as it is, and the owner reads it as uncertain.
func mapErr(err error) error {
var he *contract.HostError
if !errors.As(err, &he) {
return err
}
switch he.Code {
case contract.HostBadRequest, contract.HostTooLarge:
return fmt.Errorf("%w: %s", protocol.ErrBadRequest, he.Message)
case contract.HostNotFound:
return fmt.Errorf("%w: %s", protocol.ErrNotFound, he.Message)
case contract.HostConflict:
return fmt.Errorf("%w: %s", protocol.ErrConflict, he.Message)
case contract.HostFeature, contract.HostScope:
return fmt.Errorf("%w: %s", protocol.ErrForbidden, he.Message)
}
return err
}
// jobOf is the job of the resource. A dataset resource is not a job.
func (r *runtime) jobOf(env contract.Envelope) (job, error) {
j, ok := r.entryOf(env)
if !ok || j.Dataset {
return job{}, protocol.ErrNotFound
}
return j, nil
}
func (r *runtime) entryOf(env contract.Envelope) (job, bool) {
if env.Request.ResourceID == "" {
return job{}, false
}
return r.j.job(env.Request.ResourceID)
}
func (r *runtime) Read(ctx context.Context, env contract.Envelope) (contract.Result, error) {
tool, err := toolOf(env)
if err != nil {
return contract.Result{}, err
}
op := env.Request.OperationID
switch tool {
case ToolResourceExists:
j, ok := r.j.job(env.Request.ResourceID)
if !ok {
return contract.Result{Payload: payload(map[string]bool{"exists": false})}, nil
}
if j.Dataset {
// The reference lives in the host and the journal; there is no
// engine job to ask.
return contract.Result{Payload: payload(map[string]bool{"exists": true})}, nil
}
err := r.eng.Do(ctx, op, "finetune.get", map[string]string{"jobId": j.JobID}, nil)
var he *contract.HostError
switch {
case err == nil:
return contract.Result{Payload: payload(map[string]bool{"exists": true})}, nil
case errors.As(err, &he) && he.Code == contract.HostNotFound:
return contract.Result{Payload: payload(map[string]bool{"exists": false})}, nil
}
// The engine could not answer. The host settles a creation only on an
// explicit answer, so this is an error and never "does not exist".
return contract.Result{}, err
case ToolList:
// No resource: the host runs this read as the calling user and the
// host operation lists only that user's jobs.
var a struct {
Tool string `json:"tool"`
}
if err := decode(env.Request.Arguments, &a); err != nil {
return contract.Result{}, err
}
var got json.RawMessage
if err := r.eng.Do(ctx, op, "finetune.list", nil, &got); err != nil {
return contract.Result{}, mapErr(err)
}
return contract.Result{Payload: got}, nil
case ToolStatus:
j, ok := r.entryOf(env)
if !ok {
return contract.Result{}, protocol.ErrNotFound
}
if j.Dataset {
return contract.Result{Payload: payload(map[string]any{
"name": j.Name, "revision": contract.Revision{Schema: schema, Data: j.Revision}, "kind": "dataset", "datasetRef": j.Ref,
})}, nil
}
var got map[string]any
if err := r.eng.Do(ctx, op, "finetune.get", map[string]string{"jobId": j.JobID}, &got); err != nil {
return contract.Result{}, mapErr(err)
}
// The engine job ID stays in the add-on. The name and the revision
// are what the host reads to label and track the resource.
delete(got, "id")
got["name"] = j.Name
got["revision"] = contract.Revision{Schema: schema, Data: j.Revision}
return contract.Result{Payload: payload(got)}, nil
case ToolCheckpoints:
j, err := r.jobOf(env)
if err != nil {
return contract.Result{}, err
}
var got json.RawMessage
if err := r.eng.Do(ctx, op, "finetune.checkpoints", map[string]string{"jobId": j.JobID}, &got); err != nil {
return contract.Result{}, mapErr(err)
}
return contract.Result{Payload: got}, nil
}
return contract.Result{}, protocol.ErrBadRequest
}
func (r *runtime) checkStart(a startArgs) error {
if a.Model == "" || a.DatasetSource == "" || len([]rune(a.Name)) > maxNameRunes ||
a.LearningRate < 0 || a.NumEpochs < 0 || a.BatchSize < 0 || a.MaxSteps < 0 || a.AdapterRank < 0 || a.AdapterAlpha < 0 {
return protocol.ErrBadRequest
}
return nil
}
func checkExport(a exportArgs) error {
if a.ExportFormat != "" && !exportFormats[a.ExportFormat] {
return protocol.ErrBadRequest
}
if a.Name != "" && !modelName.MatchString(a.Name) {
return protocol.ErrBadRequest
}
return nil
}
func (r *runtime) Preview(_ context.Context, env contract.Envelope) (contract.Preview, error) {
tool, err := toolOf(env)
if err != nil {
return contract.Preview{}, err
}
out := contract.Preview{OperationID: env.Request.OperationID, ExpiresAt: r.now().Add(time.Minute)}
switch tool {
case ToolStart:
var a startArgs
if err := decode(env.Request.Arguments, &a); err != nil {
return out, err
}
if err := r.checkStart(a); err != nil {
return out, err
}
// The resource does not exist yet; an operation that already
// committed is replayed by Apply, so a repeat is not a clash.
if _, taken := r.j.job(env.Request.ResourceID); taken {
if _, done := r.j.receipt(env.Request.OperationID); !done {
return out, protocol.ErrConflict
}
}
out.Revision = contract.Revision{Schema: schema, Data: 0}
out.Summary = payload(map[string]any{"count": 1, "sample": map[string]string{"action": "start fine-tuning job", "name": nameOf(a), "model": a.Model}})
return out, nil
case ToolDataset:
var a datasetArgs
if err := decode(env.Request.Arguments, &a); err != nil {
return out, err
}
if err := checkDataset(a); err != nil {
return out, err
}
if _, taken := r.j.job(env.Request.ResourceID); taken {
if _, done := r.j.receipt(env.Request.OperationID); !done {
return out, protocol.ErrConflict
}
}
out.Revision = contract.Revision{Schema: schema, Data: 0}
// The card shows the name and the size, never the content.
out.Summary = payload(map[string]any{"count": 1, "sample": map[string]any{"action": "send dataset", "name": a.Name, "bytes": len(a.Content)}})
return out, nil
case ToolStop, ToolExport:
j, err := r.jobOf(env)
if err != nil {
return out, err
}
sample := map[string]any{"name": j.Name}
if tool == ToolStop {
var a stopArgs
if err := decode(env.Request.Arguments, &a); err != nil {
return out, err
}
sample["action"], sample["saveCheckpoint"] = "stop fine-tuning job", a.SaveCheckpoint
} else {
var a exportArgs
if err := decode(env.Request.Arguments, &a); err != nil {
return out, err
}
if err := checkExport(a); err != nil {
return out, err
}
sample["action"], sample["exportFormat"] = "export fine-tuned model", a.ExportFormat
}
out.Revision = contract.Revision{Schema: schema, Data: j.Revision}
out.Summary = payload(map[string]any{"count": 1, "sample": sample})
return out, nil
}
return out, protocol.ErrBadRequest
}
// checkDataset applies the rules of the host operation early, so a card is not
// shown for a dataset the host would refuse.
func checkDataset(a datasetArgs) error {
if a.Content == "" || len(a.Content) > maxDatasetBytes || !utf8.ValidString(a.Content) || len(a.Name) == 0 || len(a.Name) > 255 || strings.ContainsAny(a.Name, `/\`) || a.Name == "." || a.Name == ".." {
return protocol.ErrBadRequest
}
for _, ext := range datasetExt {
if strings.HasSuffix(a.Name, ext) && len(a.Name) > len(ext) {
return nil
}
}
return protocol.ErrBadRequest
}
func nameOf(a startArgs) string {
if a.Name != "" {
return a.Name
}
return a.Model
}
func (r *runtime) Apply(ctx context.Context, env contract.Envelope, rev contract.Revision) (contract.Receipt, error) {
tool, err := toolOf(env)
if err != nil {
return contract.Receipt{}, err
}
// An operation that already committed is answered from its receipt: the
// engine ran once and runs no more.
if rec, ok := r.j.receipt(env.Request.OperationID); ok {
return rec, nil
}
switch tool {
case ToolStart:
return r.applyStart(ctx, env)
case ToolDataset:
return r.applyDataset(ctx, env)
case ToolStop:
var a stopArgs
if err := decode(env.Request.Arguments, &a); err != nil {
return contract.Receipt{}, err
}
return r.mutate(ctx, env, rev, "finetune.stop", func(id string) any {
return map[string]any{"jobId": id, "saveCheckpoint": a.SaveCheckpoint}
})
case ToolExport:
var a exportArgs
if err := decode(env.Request.Arguments, &a); err != nil {
return contract.Receipt{}, err
}
if err := checkExport(a); err != nil {
return contract.Receipt{}, err
}
return r.mutate(ctx, env, rev, "finetune.export", func(id string) any {
args := map[string]any{"jobId": id}
if a.ExportFormat != "" {
args["exportFormat"] = a.ExportFormat
}
if a.Name != "" {
args["name"] = a.Name
}
return args
})
}
return contract.Receipt{}, protocol.ErrBadRequest
}
func (r *runtime) applyStart(ctx context.Context, env contract.Envelope) (contract.Receipt, error) {
var a startArgs
if err := decode(env.Request.Arguments, &a); err != nil {
return contract.Receipt{}, err
}
if err := r.checkStart(a); err != nil {
return contract.Receipt{}, err
}
res, op := env.Request.ResourceID, env.Request.OperationID
if res == "" {
return contract.Receipt{}, protocol.ErrBadRequest
}
if _, taken := r.j.job(res); taken {
return contract.Receipt{}, protocol.ErrConflict
}
var started struct {
ID string `json:"id"`
Status string `json:"status"`
Message string `json:"message"`
}
err := r.eng.Do(ctx, op, "finetune.start", engineStart{
Model: a.Model, Backend: a.Backend, TrainingType: a.TrainingType, TrainingMethod: a.TrainingMethod,
DatasetSource: a.DatasetSource, DatasetSplit: a.DatasetSplit, LearningRate: a.LearningRate,
NumEpochs: a.NumEpochs, BatchSize: a.BatchSize, MaxSteps: a.MaxSteps, AdapterRank: a.AdapterRank, AdapterAlpha: a.AdapterAlpha,
}, &started)
if err != nil {
return contract.Receipt{}, mapErr(err)
}
if started.ID == "" {
return contract.Receipt{}, errors.New("the engine started a job and returned no ID")
}
rec := contract.Receipt{
OperationID: op, State: contract.ReceiptCommitted, Revision: contract.Revision{Schema: schema, Data: 1},
Result: contract.Result{Payload: payload(map[string]string{"status": started.Status, "message": started.Message})},
}
// If this write fails the job runs without a mapping. The error is
// uncertain, so the host asks again with the same operation ID and the
// engine answers it with the same job.
if err := r.j.commit(res, &job{JobID: started.ID, Name: nameOf(a), Revision: 1}, rec); err != nil {
return contract.Receipt{}, err
}
return rec, nil
}
// applyDataset sends the inline dataset through the host operation, which
// returns a reference only the calling user can use, and remembers it for the
// resource. The reference is in the receipt the person sees and in the status
// of the resource, which only the owner of the resource can read.
func (r *runtime) applyDataset(ctx context.Context, env contract.Envelope) (contract.Receipt, error) {
var a datasetArgs
if err := decode(env.Request.Arguments, &a); err != nil {
return contract.Receipt{}, err
}
if err := checkDataset(a); err != nil {
return contract.Receipt{}, err
}
res, op := env.Request.ResourceID, env.Request.OperationID
if res == "" {
return contract.Receipt{}, protocol.ErrBadRequest
}
if _, taken := r.j.job(res); taken {
return contract.Receipt{}, protocol.ErrConflict
}
var stored struct {
DatasetRef string `json:"datasetRef"`
}
if err := r.eng.Do(ctx, op, "finetune.dataset", map[string]string{"name": a.Name, "content": a.Content}, &stored); err != nil {
return contract.Receipt{}, mapErr(err)
}
if stored.DatasetRef == "" {
return contract.Receipt{}, errors.New("the engine stored a dataset and returned no reference")
}
rec := contract.Receipt{
OperationID: op, State: contract.ReceiptCommitted, Revision: contract.Revision{Schema: schema, Data: 1},
Result: contract.Result{Payload: payload(map[string]string{"datasetRef": stored.DatasetRef})},
}
if err := r.j.commit(res, &job{Name: a.Name, Dataset: true, Ref: stored.DatasetRef, Revision: 1}, rec); err != nil {
return contract.Receipt{}, err
}
return rec, nil
}
// mutate runs a host operation on the job of the resource, once, if the
// resource is still at the revision the person approved.
func (r *runtime) mutate(ctx context.Context, env contract.Envelope, rev contract.Revision, op string, args func(jobID string) any) (contract.Receipt, error) {
j, err := r.jobOf(env)
if err != nil {
return contract.Receipt{}, err
}
id := env.Request.OperationID
if rev.Schema != schema || rev.Data != j.Revision {
return contract.Receipt{OperationID: id, State: contract.ReceiptRejected,
Result: contract.Result{Payload: payload(map[string]any{"error": "stale", "revision": contract.Revision{Schema: schema, Data: j.Revision}})}}, nil
}
var out json.RawMessage
if err := r.eng.Do(ctx, id, op, args(j.JobID), &out); err != nil {
return contract.Receipt{}, mapErr(err)
}
next := j
next.Revision++
rec := contract.Receipt{OperationID: id, State: contract.ReceiptCommitted, Revision: contract.Revision{Schema: schema, Data: next.Revision}}
if len(out) > 0 && json.Valid(out) {
rec.Result.Payload = out
} else {
rec.Result.Payload = payload(map[string]any{})
}
if err := r.j.commit(env.Request.ResourceID, &next, rec); err != nil {
return contract.Receipt{}, err
}
return rec, nil
}
func (r *runtime) Receipt(_ context.Context, op string) (contract.Receipt, error) {
if rec, ok := r.j.receipt(op); ok {
return rec, nil
}
return contract.Receipt{}, protocol.ErrNotFound
}
// OpenOutput refuses: no tool of this add-on produces a file.
func (r *runtime) OpenOutput(context.Context, contract.Envelope, string) (io.ReadCloser, error) {
return nil, protocol.ErrNotFound
}
@@ -0,0 +1,527 @@
// SPDX-License-Identifier: MIT
package tools
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/mudler/LocalAI/addons/internal/hostclient"
"github.com/mudler/LocalAI/addons/internal/protocol"
"github.com/mudler/LocalAI/pkg/addons/contract"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var ctx = context.Background()
type call struct {
opID, op string
args map[string]any
}
// fakeEngine records host operations and answers from canned results. A
// result of type error is returned as the failure of that operation.
type fakeEngine struct {
mu sync.Mutex
result map[string]any
calls []call
}
func (f *fakeEngine) Do(_ context.Context, opID, op string, args, out any) error {
f.mu.Lock()
defer f.mu.Unlock()
var a map[string]any
if args != nil {
b, _ := json.Marshal(args)
_ = json.Unmarshal(b, &a)
}
f.calls = append(f.calls, call{opID, op, a})
r, ok := f.result[op]
if !ok {
r = map[string]any{}
}
if err, isErr := r.(error); isErr {
return err
}
if out != nil {
b, _ := json.Marshal(r)
return json.Unmarshal(b, out)
}
return nil
}
func (f *fakeEngine) ops() []string {
f.mu.Lock()
defer f.mu.Unlock()
var out []string
for _, c := range f.calls {
out = append(out, c.op)
}
return out
}
func (f *fakeEngine) count(op string) int {
n := 0
for _, o := range f.ops() {
if o == op {
n++
}
}
return n
}
func (f *fakeEngine) last(op string) call {
f.mu.Lock()
defer f.mu.Unlock()
for i := len(f.calls) - 1; i >= 0; i-- {
if f.calls[i].op == op {
return f.calls[i]
}
}
return call{}
}
func envelope(tool, opID, resource, args string) contract.Envelope {
m := map[string]any{}
if args != "" {
_ = json.Unmarshal([]byte(args), &m)
}
m["tool"] = tool
raw, _ := json.Marshal(m)
return contract.Envelope{
Request: contract.Request{AddonID: "fine-tuning", ResourceID: resource, OperationID: opID, Generation: 1, Action: "apply", Arguments: raw},
Authority: contract.Authority{Actor: contract.Actor{UserID: "u1", CredentialID: "c1", Mode: "named-user"}, Scope: "apply", ExpiresAt: time.Now().Add(time.Minute)},
}
}
const startJSON = `{"name":"my run","model":"org/base","datasetSource":"org/data"}`
// started returns a runtime that already holds the job job-1 for resource res1.
func started() (contract.Runtime, *fakeEngine) {
eng := &fakeEngine{result: map[string]any{"finetune.start": map[string]any{"id": "job-1", "status": "queued"}}}
rt := New(eng, GinkgoT().TempDir())
rec, err := rt.Apply(ctx, envelope("fine-tune-start", "create-res1", "res1", startJSON), contract.Revision{Schema: 1})
Expect(err).NotTo(HaveOccurred(), "setup")
Expect(rec.State).To(Equal(contract.ReceiptCommitted), "setup: %+v", rec)
return rt, eng
}
func exists(rt contract.Runtime, res string) (bool, error) {
r, err := rt.Read(ctx, envelope("resource-exists", "x-"+res, res, ""))
if err != nil {
return false, err
}
var v struct {
Exists bool `json:"exists"`
}
_ = json.Unmarshal(r.Payload, &v)
return v.Exists, nil
}
func loadManifest() contract.Manifest {
b, err := os.ReadFile("../../manifest.json")
Expect(err).NotTo(HaveOccurred())
m, err := contract.ParseManifest(b)
Expect(err).NotTo(HaveOccurred(), "the manifest must satisfy the contract")
return m
}
const datasetJSON = `{"name":"data.jsonl","content":"{\"text\":\"a\"}\n"}`
var _ = Describe("fine-tuning tools", func() {
It("start calls the engine once and remembers the job", func() {
eng := &fakeEngine{result: map[string]any{"finetune.start": map[string]any{"id": "job-1", "status": "queued"}}}
rt := New(eng, GinkgoT().TempDir())
rec, err := rt.Apply(ctx, envelope("fine-tune-start", "op1", "res1", startJSON), contract.Revision{Schema: 1, Data: 0})
Expect(err).NotTo(HaveOccurred())
Expect(rec.State).To(Equal(contract.ReceiptCommitted))
Expect(rec.Revision.Schema).NotTo(BeZero())
Expect(eng.ops()).To(Equal([]string{"finetune.start"}))
// The same operation again does not start a second job.
again, err := rt.Apply(ctx, envelope("fine-tune-start", "op1", "res1", startJSON), contract.Revision{Schema: 1, Data: 1})
Expect(err).NotTo(HaveOccurred())
Expect(again.State).To(Equal(contract.ReceiptCommitted))
Expect(eng.count("finetune.start")).To(Equal(1))
})
It("start sends only the fields of the engine operation", func() {
eng := &fakeEngine{result: map[string]any{"finetune.start": map[string]any{"id": "job-1", "status": "queued"}}}
rt := New(eng, GinkgoT().TempDir())
args := `{"name":"my run","model":"org/base","datasetSource":"org/data","numEpochs":2,"learningRate":0.0002,"adapterRank":8}`
_, err := rt.Apply(ctx, envelope("fine-tune-start", "op1", "res1", args), contract.Revision{Schema: 1})
Expect(err).NotTo(HaveOccurred())
c := eng.last("finetune.start")
Expect(c.opID).To(Equal("op1"), "the call must carry the operation of the request")
for _, banned := range []string{"tool", "name", "resourceId"} {
Expect(c.args).NotTo(HaveKey(banned), "the engine must not receive %q", banned)
}
Expect(c.args["model"]).To(Equal("org/base"))
Expect(c.args["numEpochs"]).To(Equal(float64(2)))
Expect(c.args["adapterRank"]).To(Equal(float64(8)))
})
It("previews a start with a nonzero revision and creates nothing", func() {
eng := &fakeEngine{}
rt := New(eng, GinkgoT().TempDir())
p, err := rt.Preview(ctx, envelope("fine-tune-start", "op1", "res1", startJSON))
Expect(err).NotTo(HaveOccurred())
Expect(p.Revision.Schema).NotTo(BeZero())
Expect(eng.ops()).To(BeEmpty())
Expect(json.Valid(p.Summary)).To(BeTrue(), "summary %s", p.Summary)
_, err = rt.Preview(ctx, envelope("fine-tune-start", "op1", "res1", `{"name":"x","model":"","datasetSource":"d"}`))
Expect(err).To(MatchError(protocol.ErrBadRequest), "an empty model must be refused")
})
It("status reads the job of the resource", func() {
rt, eng := started()
eng.result["finetune.get"] = map[string]any{"id": "job-1", "status": "training", "model": "org/base"}
res, err := rt.Read(ctx, envelope("fine-tune-status", "r1", "res1", ""))
Expect(err).NotTo(HaveOccurred())
Expect(eng.last("finetune.get").args["jobId"]).To(Equal("job-1"))
var v map[string]any
Expect(json.Unmarshal(res.Payload, &v)).To(Succeed())
Expect(v["name"]).To(Equal("my run"))
Expect(v["status"]).To(Equal("training"))
rev, _ := v["revision"].(map[string]any)
Expect(rev["schema"]).NotTo(BeNil(), "inspect needs a revision: %v", v)
Expect(rev["schema"]).NotTo(Equal(float64(0)), "inspect needs a revision: %v", v)
Expect(v).NotTo(HaveKey("id"), "the engine job ID must not reach the model")
Expect(v).NotTo(HaveKey("progressPercent"), "a snapshot percent is unreliable and must not be reported")
})
It("checkpoints read the job of the resource", func() {
rt, eng := started()
eng.result["finetune.checkpoints"] = map[string]any{"checkpoints": []any{map[string]any{"step": 10}}}
res, err := rt.Read(ctx, envelope("fine-tune-checkpoints", "r1", "res1", ""))
Expect(err).NotTo(HaveOccurred())
Expect(string(res.Payload)).To(ContainSubstring(`"step":10`))
Expect(eng.last("finetune.checkpoints").args["jobId"]).To(Equal("job-1"), "wrong job")
})
It("does not let a tool control a job it did not start", func() {
eng := &fakeEngine{}
rt := New(eng, GinkgoT().TempDir())
_, err := rt.Apply(ctx, envelope("fine-tune-stop", "op2", "nope", `{}`), contract.Revision{Schema: 1})
Expect(err).To(MatchError(protocol.ErrNotFound), "stop")
_, err = rt.Apply(ctx, envelope("fine-tune-export", "op3", "nope", `{}`), contract.Revision{Schema: 1})
Expect(err).To(MatchError(protocol.ErrNotFound), "export")
_, err = rt.Read(ctx, envelope("fine-tune-status", "r1", "nope", ""))
Expect(err).To(MatchError(protocol.ErrNotFound), "status")
_, err = rt.Preview(ctx, envelope("fine-tune-stop", "op2", "nope", `{}`))
Expect(err).To(MatchError(protocol.ErrNotFound), "preview")
Expect(eng.ops()).To(BeEmpty(), "the engine must not be called")
})
It("stop and export use the stored job and advance the revision", func() {
rt, eng := started()
eng.result["finetune.stop"] = map[string]any{}
eng.result["finetune.export"] = map[string]any{"model": "my-model"}
p, err := rt.Preview(ctx, envelope("fine-tune-stop", "op2", "res1", `{"saveCheckpoint":true}`))
Expect(err).NotTo(HaveOccurred())
Expect(p.Revision.Schema).NotTo(BeZero())
rec, err := rt.Apply(ctx, envelope("fine-tune-stop", "op2", "res1", `{"saveCheckpoint":true}`), p.Revision)
Expect(err).NotTo(HaveOccurred())
Expect(rec.State).To(Equal(contract.ReceiptCommitted))
c := eng.last("finetune.stop")
Expect(c.args["jobId"]).To(Equal("job-1"))
Expect(c.args["saveCheckpoint"]).To(Equal(true))
Expect(rec.Revision.Data).To(BeNumerically(">", p.Revision.Data), "the revision must advance")
// A repeat of the same operation is answered from the receipt.
_, err = rt.Apply(ctx, envelope("fine-tune-stop", "op2", "res1", `{"saveCheckpoint":true}`), p.Revision)
Expect(err).NotTo(HaveOccurred())
Expect(eng.count("finetune.stop")).To(Equal(1))
// Export names the format and the model, and never a path.
ex, err := rt.Apply(ctx, envelope("fine-tune-export", "op3", "res1", `{"exportFormat":"gguf","name":"my-model"}`), rec.Revision)
Expect(err).NotTo(HaveOccurred())
Expect(ex.State).To(Equal(contract.ReceiptCommitted))
Expect(string(ex.Result.Payload)).To(ContainSubstring("my-model"))
a := eng.last("finetune.export").args
Expect(a["jobId"]).To(Equal("job-1"))
Expect(a["exportFormat"]).To(Equal("gguf"))
Expect(a["name"]).To(Equal("my-model"))
})
It("rejects a stale revision and does not call the engine", func() {
rt, eng := started()
rec, err := rt.Apply(ctx, envelope("fine-tune-stop", "op2", "res1", `{}`), contract.Revision{Schema: 1, Data: 99})
Expect(err).NotTo(HaveOccurred())
Expect(rec.State).To(Equal(contract.ReceiptRejected))
Expect(eng.count("finetune.stop")).To(BeZero(), "the engine was called")
})
Describe("host refusals", func() {
for code, want := range map[string]error{
contract.HostFeature: protocol.ErrForbidden, contract.HostBadRequest: protocol.ErrBadRequest,
contract.HostNotFound: protocol.ErrNotFound, contract.HostConflict: protocol.ErrConflict,
} {
It("maps "+code+" to a protocol error", func() {
rt, eng := started()
eng.result["finetune.export"] = &contract.HostError{Code: code, Message: "m"}
_, err := rt.Apply(ctx, envelope("fine-tune-export", "op-"+code, "res1", `{}`), contract.Revision{Schema: 1, Data: 1})
Expect(err).To(MatchError(want))
})
}
It("keeps an unavailable engine uncertain", func() {
rt, eng := started()
eng.result["finetune.export"] = &contract.HostError{Code: contract.HostUnavailable, Message: "m"}
_, err := rt.Apply(ctx, envelope("fine-tune-export", "op-u", "res1", `{}`), contract.Revision{Schema: 1, Data: 1})
Expect(err).To(HaveOccurred())
for _, e := range []error{protocol.ErrBadRequest, protocol.ErrForbidden, protocol.ErrNotFound, protocol.ErrConflict} {
Expect(err).NotTo(MatchError(e), "an unavailable engine must stay uncertain")
}
})
})
It("answers resource-exists from the journal and the engine", func() {
rt, eng := started()
eng.result["finetune.get"] = map[string]any{"id": "job-1", "status": "training"}
ok, err := exists(rt, "res1")
Expect(err).NotTo(HaveOccurred(), "mapped")
Expect(ok).To(BeTrue(), "mapped")
ok, err = exists(rt, "other")
Expect(err).NotTo(HaveOccurred(), "unmapped")
Expect(ok).To(BeFalse(), "unmapped")
// The job was deleted in the UI: the engine says not found.
eng.result["finetune.get"] = &contract.HostError{Code: contract.HostNotFound, Message: "gone"}
ok, err = exists(rt, "res1")
Expect(err).NotTo(HaveOccurred(), "deleted job")
Expect(ok).To(BeFalse(), "deleted job")
// The engine cannot answer: unknown, never false.
eng.result["finetune.get"] = &contract.HostError{Code: contract.HostUnavailable, Message: "down"}
_, err = exists(rt, "res1")
Expect(err).To(HaveOccurred(), "an unavailable engine must not read as absent")
eng.result["finetune.get"] = hostclient.ErrNoHostOps
_, err = exists(rt, "res1")
Expect(err).To(HaveOccurred(), "no host endpoint must not read as absent")
})
It("keeps the journal across a restart", func() {
dir := GinkgoT().TempDir()
eng := &fakeEngine{result: map[string]any{"finetune.start": map[string]any{"id": "job-1", "status": "queued"}, "finetune.get": map[string]any{"status": "training"}}}
_, err := New(eng, dir).Apply(ctx, envelope("fine-tune-start", "op1", "res1", startJSON), contract.Revision{Schema: 1})
Expect(err).NotTo(HaveOccurred())
again := New(eng, dir)
_, err = again.Read(ctx, envelope("fine-tune-status", "r1", "res1", ""))
Expect(err).NotTo(HaveOccurred())
Expect(eng.last("finetune.get").args["jobId"]).To(Equal("job-1"), "the restarted runtime lost the job")
// The receipt of the first run answers a repeat after the restart.
_, err = again.Apply(ctx, envelope("fine-tune-start", "op1", "res1", startJSON), contract.Revision{Schema: 1})
Expect(err).NotTo(HaveOccurred())
Expect(eng.count("finetune.start")).To(Equal(1))
r, err := again.Receipt(ctx, "op1")
Expect(err).NotTo(HaveOccurred())
Expect(r.State).To(Equal(contract.ReceiptCommitted))
_, err = again.Receipt(ctx, "never")
Expect(err).To(MatchError(protocol.ErrNotFound), "an unknown operation must be not found")
// The journal is a plain file written through a temporary one.
entries, _ := os.ReadDir(dir)
for _, e := range entries {
Expect(e.Name()).NotTo(ContainSubstring(".tmp"), "a temporary file was left")
}
_, err = os.Stat(filepath.Join(dir, "journal.json"))
Expect(err).NotTo(HaveOccurred())
})
It("refuses every tool without a host endpoint", func() {
var nilClient *hostclient.Client
dir := GinkgoT().TempDir()
rt := New(nilClient, dir)
_, err := rt.Apply(ctx, envelope("fine-tune-start", "op1", "res1", startJSON), contract.Revision{Schema: 1})
Expect(err).To(MatchError(hostclient.ErrNoHostOps), "start")
// Seed a job as an earlier run with an endpoint would have.
seed := New(&fakeEngine{result: map[string]any{"finetune.start": map[string]any{"id": "job-1"}}}, dir)
_, err = seed.Apply(ctx, envelope("fine-tune-start", "op0", "res1", startJSON), contract.Revision{Schema: 1})
Expect(err).NotTo(HaveOccurred())
rt = New(nilClient, dir)
for _, tool := range []string{"fine-tune-status", "fine-tune-checkpoints", "resource-exists"} {
_, err := rt.Read(ctx, envelope(tool, "r-"+tool, "res1", ""))
Expect(err).To(MatchError(hostclient.ErrNoHostOps), tool)
}
for _, tool := range []string{"fine-tune-stop", "fine-tune-export"} {
_, err := rt.Apply(ctx, envelope(tool, "a-"+tool, "res1", `{}`), contract.Revision{Schema: 1, Data: 1})
Expect(err).To(MatchError(hostclient.ErrNoHostOps), tool)
}
})
It("treats an unknown tool as a bad request", func() {
rt := New(&fakeEngine{}, GinkgoT().TempDir())
_, err := rt.Read(ctx, envelope("fine-tune-nope", "r1", "res1", ""))
Expect(err).To(MatchError(protocol.ErrBadRequest), "read")
_, err = rt.Apply(ctx, envelope("fine-tune-nope", "a1", "res1", ""), contract.Revision{Schema: 1})
Expect(err).To(MatchError(protocol.ErrBadRequest), "apply")
_, err = rt.OpenOutput(ctx, envelope("fine-tune-status", "o1", "res1", ""), "h")
Expect(err).To(MatchError(protocol.ErrNotFound), "output")
})
Describe("manifest", func() {
It("declares the tools and the resource mapping", func() {
m := loadManifest()
Expect(m.ID).To(Equal("fine-tuning"))
Expect(m.Capabilities.UI.MainPage).To(Equal("jobs"))
Expect(m.Capabilities.UI.Pages).To(Equal([]string{"jobs"}))
Expect(m.Capabilities.UI.SDK.Min).To(BeEquivalentTo(2))
Expect(m.Capabilities.UI.SDK.Max).To(BeEquivalentTo(2))
Expect(m.Capabilities.Tools).To(Equal(Tools))
r := m.Capabilities.Resources
Expect(r).NotTo(BeNil())
Expect(r.Noun).To(Equal("fine-tuning job"))
Expect(r.Create).To(Equal("fine-tune-start"))
Expect(r.Inspect).To(Equal("fine-tune-status"))
kinds := map[string]string{}
for _, d := range m.Capabilities.ToolDefinitions {
kinds[d.Name] = d.Kind
}
want := map[string]string{"fine-tune-list": "read", "fine-tune-status": "read", "fine-tune-checkpoints": "read", "fine-tune-start": "create", "fine-tune-dataset": "create", "fine-tune-stop": "write", "fine-tune-export": "write"}
Expect(kinds).To(Equal(want))
for _, d := range m.Capabilities.ToolDefinitions {
if d.Name == "fine-tune-list" {
Expect(contract.IsResourceless(d)).To(BeTrue(), "the list tool must declare no resource")
} else {
Expect(contract.IsResourceless(d)).To(BeFalse(), "%s must not be resource-less", d.Name)
}
Expect(len(d.Description)).To(BeNumerically("<", 400), d.Name)
Expect(d.Description).To(ContainSubstring("started"), "%s: the description must say that only jobs a tool started can be controlled", d.Name)
}
})
It("names no user or path in any tool argument", func() {
m := loadManifest()
for _, d := range m.Capabilities.ToolDefinitions {
var s struct {
Properties map[string]json.RawMessage `json:"properties"`
}
Expect(json.Unmarshal(d.InputSchema, &s)).To(Succeed())
for name := range s.Properties {
lower := strings.ToLower(name)
Expect(contract.ForbiddenToolProperty(name) || strings.Contains(lower, "path") || strings.Contains(lower, "dir")).To(BeFalse(),
"%s: property %q names an identity or a path", d.Name, name)
}
}
})
It("gives the start schema the task 20 fields and the name the host needs", func() {
m := loadManifest()
found := false
for _, d := range m.Capabilities.ToolDefinitions {
if d.Name != "fine-tune-start" {
continue
}
found = true
var s struct {
Properties map[string]json.RawMessage `json:"properties"`
Required []string `json:"required"`
}
_ = json.Unmarshal(d.InputSchema, &s)
want := []string{"name", "model", "backend", "trainingType", "trainingMethod", "datasetSource", "datasetSplit", "learningRate", "numEpochs", "batchSize", "maxSteps", "adapterRank", "adapterAlpha"}
Expect(s.Properties).To(HaveLen(len(want)))
for _, w := range want {
Expect(s.Properties).To(HaveKey(w))
}
break
}
Expect(found).To(BeTrue(), "no start definition")
})
})
It("lists the caller's jobs without a resource", func() {
eng := &fakeEngine{result: map[string]any{"finetune.list": map[string]any{"jobs": []any{map[string]any{"id": "ui-job", "status": "completed"}}}}}
rt := New(eng, GinkgoT().TempDir())
res, err := rt.Read(ctx, envelope("fine-tune-list", "r1", "", ""))
Expect(err).NotTo(HaveOccurred())
Expect(string(res.Payload)).To(ContainSubstring("ui-job"))
Expect(eng.ops()).To(Equal([]string{"finetune.list"}))
Expect(eng.last("finetune.list").args).To(BeNil(), "the list takes no argument")
_, err = rt.Read(ctx, envelope("fine-tune-list", "r2", "", `{"userId":"other"}`))
Expect(err).To(MatchError(protocol.ErrBadRequest), "an argument must be refused")
var nilClient *hostclient.Client
_, err = New(nilClient, GinkgoT().TempDir()).Read(ctx, envelope("fine-tune-list", "r3", "", ""))
Expect(err).To(MatchError(hostclient.ErrNoHostOps), "no endpoint")
})
It("sends a dataset once and returns its ref only through the resource", func() {
eng := &fakeEngine{result: map[string]any{"finetune.dataset": map[string]any{"datasetRef": "0000000a-data.jsonl"}}}
rt := New(eng, GinkgoT().TempDir())
p, err := rt.Preview(ctx, envelope("fine-tune-dataset", "create-d1", "d1", datasetJSON))
Expect(err).NotTo(HaveOccurred())
Expect(p.Revision.Schema).NotTo(BeZero())
Expect(eng.ops()).To(BeEmpty())
Expect(string(p.Summary)).NotTo(ContainSubstring("text"), "the approval card must not carry the content")
rec, err := rt.Apply(ctx, envelope("fine-tune-dataset", "create-d1", "d1", datasetJSON), p.Revision)
Expect(err).NotTo(HaveOccurred())
Expect(rec.State).To(Equal(contract.ReceiptCommitted))
Expect(string(rec.Result.Payload)).To(ContainSubstring("0000000a-data.jsonl"))
_, err = rt.Apply(ctx, envelope("fine-tune-dataset", "create-d1", "d1", datasetJSON), p.Revision)
Expect(err).NotTo(HaveOccurred())
Expect(eng.count("finetune.dataset")).To(Equal(1))
a := eng.last("finetune.dataset").args
Expect(a["name"]).To(Equal("data.jsonl"))
Expect(a["content"]).NotTo(BeNil())
Expect(a).To(HaveLen(2), "host operation args")
// The resource is the dataset: its status gives the reference to the owner of the resource.
st, err := rt.Read(ctx, envelope("fine-tune-status", "r1", "d1", ""))
Expect(err).NotTo(HaveOccurred())
Expect(string(st.Payload)).To(ContainSubstring(`"datasetRef":"0000000a-data.jsonl"`))
Expect(string(st.Payload)).To(ContainSubstring(`"kind":"dataset"`))
Expect(eng.ops()).To(HaveLen(1), "the status of a dataset must not call the engine")
ok, err := exists(rt, "d1")
Expect(err).NotTo(HaveOccurred())
Expect(ok).To(BeTrue())
// A dataset is not a job.
for _, tool := range []string{"fine-tune-checkpoints"} {
_, err := rt.Read(ctx, envelope(tool, "r2", "d1", ""))
Expect(err).To(MatchError(protocol.ErrNotFound), tool)
}
_, err = rt.Apply(ctx, envelope("fine-tune-stop", "s1", "d1", `{}`), contract.Revision{Schema: 1, Data: 1})
Expect(err).To(MatchError(protocol.ErrNotFound), "stop")
})
Describe("dataset validation", func() {
big := strings.Repeat("a", 900<<10+1)
for name, args := range map[string]string{
"empty": `{"name":"d.jsonl","content":""}`,
"too large": `{"name":"d.jsonl","content":"` + big + `"}`,
"a folder": `{"name":"a/d.jsonl","content":"x"}`,
"parent": `{"name":"../d.jsonl","content":"x"}`,
"wrong type": `{"name":"d.exe","content":"x"}`,
"only the type": `{"name":".jsonl","content":"x"}`,
"extra field": `{"name":"d.jsonl","content":"x","path":"/etc"}`,
} {
It("refuses "+name, func() {
rt := New(&fakeEngine{}, GinkgoT().TempDir())
_, err := rt.Preview(ctx, envelope("fine-tune-dataset", "o-"+name, "d9", args))
Expect(err).To(MatchError(protocol.ErrBadRequest))
})
}
It("accepts the largest allowed dataset", func() {
rt := New(&fakeEngine{}, GinkgoT().TempDir())
ok := `{"name":"d.jsonl","content":"` + strings.Repeat("a", 900<<10) + `"}`
_, err := rt.Preview(ctx, envelope("fine-tune-dataset", "o-ok", "d9", ok))
Expect(err).NotTo(HaveOccurred(), "900 KiB")
})
})
It("passes a dataset ref to start and keeps no job when the host refuses it", func() {
eng := &fakeEngine{result: map[string]any{"finetune.start": map[string]any{"id": "job-1", "status": "queued"}}}
rt := New(eng, GinkgoT().TempDir())
args := `{"name":"run","model":"org/base","datasetSource":"0000000a-data.jsonl"}`
_, err := rt.Apply(ctx, envelope("fine-tune-start", "create-r1", "r1", args), contract.Revision{Schema: 1})
Expect(err).NotTo(HaveOccurred())
Expect(eng.last("finetune.start").args["datasetSource"]).To(Equal("0000000a-data.jsonl"))
// The host refuses the reference (the per-user check is proven in the host tests); the add-on only maps the refusal and keeps nothing.
eng.result["finetune.start"] = &contract.HostError{Code: contract.HostBadRequest, Message: "datasetSource is not a dataset reference of this user"}
_, err = rt.Apply(ctx, envelope("fine-tune-start", "create-r2", "r2", `{"name":"run","model":"org/base","datasetSource":"0000000b-theirs.jsonl"}`), contract.Revision{Schema: 1})
Expect(err).To(MatchError(protocol.ErrBadRequest))
_, err = rt.Read(ctx, envelope("fine-tune-status", "r3", "r2", ""))
Expect(err).To(MatchError(protocol.ErrNotFound), "a refused start must not leave a job")
_, err = rt.Receipt(ctx, "create-r2")
Expect(err).To(MatchError(protocol.ErrNotFound), "a refused start must not leave a receipt")
})
})
+285
View File
@@ -0,0 +1,285 @@
{
"schemaVersion": 1,
"id": "fine-tuning",
"version": "0.1.0",
"executable": "fine-tuning",
"os": "linux",
"arch": "amd64",
"host": {
"min": 1,
"max": 1
},
"api": {
"min": 1,
"max": 1
},
"protocol": {
"min": 1,
"max": 1
},
"dataFormat": {
"min": 1,
"max": 1
},
"capabilities": {
"tools": [
"resource-exists",
"fine-tune-list",
"fine-tune-start",
"fine-tune-dataset",
"fine-tune-status",
"fine-tune-checkpoints",
"fine-tune-stop",
"fine-tune-export"
],
"toolDefinitions": [
{
"name": "fine-tune-list",
"kind": "read",
"description": "List the fine-tuning jobs of the person you are talking to, newest first, including jobs started in the web page. A model can only control jobs it started.",
"inputSchema": {
"type": "object",
"properties": {},
"additionalProperties": false
}
},
{
"name": "fine-tune-start",
"kind": "create",
"description": "Propose a new fine-tuning job. A person confirms it in the approval card; nothing starts before that. datasetSource is a Hugging Face ID or a reference from fine-tune-dataset. A model can only control jobs it started.",
"inputSchema": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "A label for the job, 1 to 80 characters.",
"maxLength": 80
},
"model": {
"type": "string",
"description": "Base model as a gallery or Hugging Face ID. A local path is refused.",
"maxLength": 256
},
"backend": {
"type": "string",
"description": "Fine-tuning backend. The default is trl.",
"maxLength": 64
},
"trainingType": {
"type": "string",
"description": "Kind of training. The default is lora.",
"enum": [
"lora",
"loha",
"lokr",
"full"
]
},
"trainingMethod": {
"type": "string",
"description": "Training method. The default is sft.",
"enum": [
"sft",
"dpo",
"grpo",
"rloo",
"reward",
"kto",
"orpo"
]
},
"datasetSource": {
"type": "string",
"description": "Dataset as a Hugging Face ID or a reference from fine-tune-dataset. A local path is refused.",
"maxLength": 256
},
"datasetSplit": {
"type": "string",
"description": "Dataset split, such as train.",
"maxLength": 64
},
"learningRate": {
"type": "number",
"description": "Learning rate. Zero keeps the default.",
"minimum": 0
},
"numEpochs": {
"type": "integer",
"description": "Number of epochs. Zero keeps the default.",
"minimum": 0,
"maximum": 1000
},
"batchSize": {
"type": "integer",
"description": "Batch size. Zero keeps the default.",
"minimum": 0,
"maximum": 4096
},
"maxSteps": {
"type": "integer",
"description": "Stop after this many steps. Zero means no limit.",
"minimum": 0
},
"adapterRank": {
"type": "integer",
"description": "Rank of the adapter. Zero keeps the default.",
"minimum": 0,
"maximum": 1024
},
"adapterAlpha": {
"type": "integer",
"description": "Alpha of the adapter. Zero keeps the default.",
"minimum": 0,
"maximum": 4096
}
},
"required": [
"name",
"model",
"datasetSource"
],
"additionalProperties": false
}
},
{
"name": "fine-tune-dataset",
"kind": "create",
"description": "Propose sending a small text dataset (up to 900 KiB, a .jsonl, .json, .csv or .txt file) to the server. A person confirms it. The reference it returns is the datasetSource of fine-tune-start, works for that person only and is lost when the server restarts. A model can only control jobs it started.",
"inputSchema": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "File name of the dataset, ending in .jsonl, .json, .csv or .txt. No folder.",
"maxLength": 255
},
"content": {
"type": "string",
"description": "The dataset as UTF-8 text, 1 byte to 900 KiB.",
"maxLength": 921600
}
},
"required": [
"name",
"content"
],
"additionalProperties": false
}
},
{
"name": "fine-tune-status",
"kind": "read",
"description": "Show the status of a fine-tuning job that a tool started, or the reference of a dataset that fine-tune-dataset sent. A model can only control jobs it started.",
"inputSchema": {
"type": "object",
"properties": {
"resourceId": {
"type": "string",
"description": "ID of the fine-tuning job, as returned by a job start. Only a job that a tool started has one."
}
},
"required": [
"resourceId"
],
"additionalProperties": false
}
},
{
"name": "fine-tune-checkpoints",
"kind": "read",
"description": "List the saved checkpoints of a fine-tuning job that a tool started, with step and time. A model can only control jobs it started.",
"inputSchema": {
"type": "object",
"properties": {
"resourceId": {
"type": "string",
"description": "ID of the fine-tuning job, as returned by a job start. Only a job that a tool started has one."
}
},
"required": [
"resourceId"
],
"additionalProperties": false
}
},
{
"name": "fine-tune-stop",
"kind": "write",
"description": "Propose stopping a fine-tuning job that a tool started. A person confirms it. A model can only control jobs it started.",
"inputSchema": {
"type": "object",
"properties": {
"resourceId": {
"type": "string",
"description": "ID of the fine-tuning job, as returned by a job start. Only a job that a tool started has one."
},
"saveCheckpoint": {
"type": "boolean",
"description": "Keep a checkpoint of the work so far. The default is false."
}
},
"required": [
"resourceId"
],
"additionalProperties": false
}
},
{
"name": "fine-tune-export",
"kind": "write",
"description": "Propose registering the result of a fine-tuning job that a tool started as a model. Administrators only. A model can only control jobs it started.",
"inputSchema": {
"type": "object",
"properties": {
"resourceId": {
"type": "string",
"description": "ID of the fine-tuning job, as returned by a job start. Only a job that a tool started has one."
},
"exportFormat": {
"type": "string",
"description": "Export format. The default is the engine default.",
"enum": [
"lora",
"merged_16bit",
"merged_4bit",
"gguf"
]
},
"name": {
"type": "string",
"description": "Name of the model, 1 to 64 letters, digits, dots, dashes or underscores. Leave out to let the engine choose.",
"maxLength": 64
}
},
"required": [
"resourceId"
],
"additionalProperties": false
}
}
],
"resources": {
"noun": "fine-tuning job",
"create": "fine-tune-start",
"inspect": "fine-tune-status"
},
"ui": {
"entry": "ui/fine-tuning.js",
"css": [
"ui/fine-tuning.css"
],
"sdk": {
"min": 2,
"max": 2
},
"pages": [
"jobs"
],
"navigation": [],
"mainPage": "jobs",
"icon": "ui/icon.png",
"title": "Fine-tuning",
"description": "Train an adapter on your own data, then export it as a model you can chat with."
}
}
}
+38
View File
@@ -0,0 +1,38 @@
# Release platform matrix of the fine-tuning add-on package.
#
# A platform ships only while "enabled" is true. Enable a platform in the same
# change that shows its native check passing on a runner of that platform;
# until then it stays listed and disabled. The release workflow fails closed
# when no platform is enabled, and it never builds a platform that is not
# enabled here.
#
# runner: the GitHub runner label that builds and checks the platform
# natively. Required for an enabled platform.
# native_check: "executable-start": the packaged executable is extracted on
# the native runner and started. Required for an enabled platform.
# runtime: files besides the executable that the platform needs at run
# time, relative to the package root. They are taken from the
# runtime directory of the build and must exist.
schema: 1
addon: fine-tuning
executable: fine-tuning
manifest: addons/fine-tuning/manifest.json
# The directory whose content is laid at the package root (ui/fine-tuning.js ...).
ui: addons/fine-tuning/ui/dist
platforms:
- platform: linux/amd64
enabled: true
runner: ubuntu-24.04
native_check: executable-start
- platform: linux/arm64
enabled: true
runner: ubuntu-24.04-arm
native_check: executable-start
- platform: darwin/arm64
enabled: true
runner: macos-14
native_check: executable-start
- platform: darwin/amd64
enabled: true
runner: macos-15-intel
native_check: executable-start
+2
View File
@@ -0,0 +1,2 @@
# Written by build.mjs; the package build makes it.
/dist/
+13
View File
@@ -0,0 +1,13 @@
import { fileURLToPath } from 'node:url'
import { dirname } from 'node:path'
import { buildAddonUI } from '../../internal/uibuild/uibuild.mjs'
const here = dirname(fileURLToPath(import.meta.url))
export async function build() {
return buildAddonUI({ root: here, name: 'fine-tuning', rgb: [92, 74, 150] })
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
build().then((dir) => console.log(`built ${dir}`), (e) => { console.error(e); process.exit(1) })
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

+8
View File
@@ -0,0 +1,8 @@
{
"name": "localai-fine-tuning-ui",
"private": true,
"version": "0.1.0",
"description": "Native UI of the fine-tuning add-on: a standalone ES module that the host loads after its own build.",
"type": "module",
"scripts": { "build": "node build.mjs", "test": "node test/run.mjs" }
}
File diff suppressed because it is too large. Load diff
+169
View File
@@ -0,0 +1,169 @@
// The drawing is made in the size it is shown at, so the text stays readable: a
// wide one on a desktop, a narrow one on a phone.
const SIZES = {
wide: { W: 720, H: 260, PAD: { top: 16, right: 64, bottom: 36, left: 56 } },
compact: { W: 340, H: 240, PAD: { top: 16, right: 48, bottom: 36, left: 44 } },
}
// The factory binds the context once. The drawing is the one of
// components/tools/LossChart.jsx; only the translator and React come from ctx.
export function createLossChart(ctx) {
const { h, Fragment, t } = ctx
const { useEffect, useMemo, useRef, useState } = ctx.React
function useCompact() {
const query = '(max-width: 640px)'
const [compact, setCompact] = useState(() => typeof window !== 'undefined' && window.matchMedia(query).matches)
useEffect(() => {
const mq = window.matchMedia(query)
const on = () => setCompact(mq.matches)
mq.addEventListener('change', on)
return () => mq.removeEventListener('change', on)
}, [])
return compact
}
function tickText(v, exp) {
if (!Number.isFinite(v)) return ''
if (exp) return v.toExponential(1)
if (Math.abs(v) >= 100) return v.toFixed(0)
if (Math.abs(v) >= 1) return v.toFixed(2)
return v.toFixed(3)
}
// The metrics of a fine-tuning run as one thin line, with a tab for each metric.
// Loss also marks the evaluation loss as hollow dots when the run reports it.
// The end of the line carries its value, the axes are labelled, and a data table
// sits behind a disclosure for anyone who wants the numbers.
function LossChart({ events, totalSteps }) {
const [metric, setMetric] = useState('loss')
const [hover, setHover] = useState(null)
const svgRef = useRef(null)
const compact = useCompact()
const { W, H, PAD } = SIZES[compact ? 'compact' : 'wide']
const series = useMemo(() => ({
loss: events.filter(e => e.loss > 0),
learning_rate: events.filter(e => e.learning_rate > 0),
grad_norm: events.filter(e => e.grad_norm > 0),
}), [events])
const evalPoints = useMemo(() => events.filter(e => e.eval_loss > 0), [events])
const data = series[metric]
const exp = metric === 'learning_rate'
const key = metric
const tabs = ['loss', 'learning_rate', 'grad_norm']
const geometry = useMemo(() => {
if (data.length < 2) return null
const values = data.map(e => e[key]).concat(metric === 'loss' ? evalPoints.map(e => e.eval_loss) : [])
const lo = Math.min(...values)
const hi = Math.max(...values)
const span = hi - lo || Math.abs(hi) || 1
const yMin = metric === 'loss' ? Math.max(0, lo - span * 0.08) : lo - span * 0.08
const yMax = hi + span * 0.08
const steps = data.map(e => e.current_step)
const xMin = Math.min(...steps)
const xMax = Math.max(totalSteps || 0, ...steps)
const cw = W - PAD.left - PAD.right
const ch = H - PAD.top - PAD.bottom
const x = (s) => PAD.left + ((s - xMin) / (xMax - xMin || 1)) * cw
const y = (v) => PAD.top + (1 - (v - yMin) / (yMax - yMin || 1)) * ch
const yTicks = Array.from({ length: 4 }, (_, i) => yMin + ((yMax - yMin) * i) / 3)
const xTicks = Array.from({ length: compact ? 3 : 5 }, (_, i) => Math.round(xMin + ((xMax - xMin) * i) / (compact ? 2 : 4)))
return { x, y, yTicks, xTicks, xMin, xMax, cw, ch }
}, [data, evalPoints, key, metric, totalSteps, W, H, PAD, compact])
const move = (e) => {
if (!geometry || !svgRef.current) return
const rect = svgRef.current.getBoundingClientRect()
const px = ((e.clientX - rect.left) / rect.width) * W
const step = geometry.xMin + ((px - PAD.left) / geometry.cw) * (geometry.xMax - geometry.xMin)
let best = data[0]
for (const d of data) if (Math.abs(d.current_step - step) < Math.abs(best.current_step - step)) best = d
setHover(best)
}
const last = data[data.length - 1]
const first = data[0]
const readout = hover || last
return (
<section className="bt-chart dk-card" aria-labelledby="bt-chart-title" data-testid="job-chart">
<header className="bt-chart__head">
<div>
<h2 className="bt-h2" id="bt-chart-title">{t(`chart.${metric}`)}</h2>
<p className="dk-hint">
{geometry
? t(metric === 'loss' && evalPoints.length ? 'chart.windowEval' : 'chart.window', { from: first.current_step, to: last.current_step, total: totalSteps || last.current_step })
: t('chart.waiting')}
</p>
</div>
<div className="dk-segmented" role="tablist" aria-label={t('chart.metric')}>
{tabs.map(id => (
<button key={id} type="button" role="tab" className="dk-seg" aria-selected={metric === id} onClick={() => { setMetric(id); setHover(null) }}>
{t(`chart.${id}`)}
</button>
))}
</div>
</header>
{geometry ? (
<>
<svg
ref={svgRef}
className="bt-chart__svg"
viewBox={`0 0 ${W} ${H}`}
role="img"
aria-label={t('chart.aria', { metric: t(`chart.${metric}`), last: tickText(last[key], exp), step: last.current_step })}
onMouseMove={move}
onMouseLeave={() => setHover(null)}
>
{geometry.yTicks.map((v, i) => (
<g key={i}>
<line className="bt-chart__grid" x1={PAD.left} x2={W - PAD.right} y1={geometry.y(v)} y2={geometry.y(v)} />
<text className="bt-chart__tick" x={PAD.left - 8} y={geometry.y(v) + 4} textAnchor="end">{tickText(v, exp)}</text>
</g>
))}
{geometry.xTicks.map((s, i) => (
<text key={i} className="bt-chart__tick" x={geometry.x(s)} y={H - PAD.bottom + 16} textAnchor="middle">{s}</text>
))}
<text className="bt-chart__axis" x={PAD.left + geometry.cw / 2} y={H - 4} textAnchor="middle">{t('chart.step')}</text>
<polyline className="bt-chart__line" points={data.map(e => `${geometry.x(e.current_step)},${geometry.y(e[key])}`).join(' ')} />
{metric === 'loss' && evalPoints.map((e, i) => (
<circle key={i} className="bt-chart__eval" cx={geometry.x(e.current_step)} cy={geometry.y(e.eval_loss)} r="4" />
))}
<circle className="bt-chart__end" cx={geometry.x(last.current_step)} cy={geometry.y(last[key])} r="4" />
<text className="bt-chart__label" x={geometry.x(last.current_step) + 10} y={geometry.y(last[key]) + 4}>{tickText(last[key], exp)}</text>
{hover && <line className="bt-chart__cursor" x1={geometry.x(hover.current_step)} x2={geometry.x(hover.current_step)} y1={PAD.top} y2={H - PAD.bottom} />}
</svg>
<p className="bt-chart__readout dk-mono" aria-live="off">
{t('chart.readout', { step: readout.current_step, value: tickText(readout[key], exp) })}
</p>
<details className="bt-chart__data">
<summary>{t('chart.dataTitle')}</summary>
<div className="dk-table-wrap bt-chart__table">
<table className="dk-table dk-table--compact">
<caption className="dk-sr-only">{t('chart.dataTitle')}</caption>
<thead><tr><th>{t('chart.step')}</th><th className="dk-num">{t(`chart.${metric}`)}</th>{metric === 'loss' && <th className="dk-num">{t('chart.eval')}</th>}</tr></thead>
<tbody>
{data.slice(-12).map(e => (
<tr key={e.current_step} data-row>
<td>{e.current_step}</td>
<td className="dk-num">{tickText(e[key], exp)}</td>
{metric === 'loss' && <td className="dk-num">{e.eval_loss > 0 ? tickText(e.eval_loss, false) : ''}</td>}
</tr>
))}
</tbody>
</table>
</div>
</details>
</>
) : (
<p className="bt-chart__empty">{t('chart.emptyText')}</p>
)}
</section>
)
}
return { LossChart }
}
+37
View File
@@ -0,0 +1,37 @@
// The core routes the fine-tuning add-on uses, reached with the signed-in
// user's own session (sdk.core.fetch). Paths and bodies are the ones of
// fineTuneApi and resourcesApi in core/http/react-ui/src/utils/api.js; only the
// transport changed.
export function createApi(ctx) {
const { json } = ctx
const enc = encodeURIComponent
const post = (path, data) => json(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) })
return {
fineTune: {
listBackends: () => json('/api/fine-tuning/backends'),
startJob: (data) => post('/api/fine-tuning/jobs', data),
listJobs: () => json('/api/fine-tuning/jobs'),
getJob: (id) => json(`/api/fine-tuning/jobs/${enc(id)}`),
stopJob: (id, saveCheckpoint) => json(`/api/fine-tuning/jobs/${enc(id)}/stop?save_checkpoint=${saveCheckpoint ? 'true' : 'false'}`, { method: 'POST' }),
deleteJob: (id) => json(`/api/fine-tuning/jobs/${enc(id)}`, { method: 'DELETE' }),
listCheckpoints: (id) => json(`/api/fine-tuning/jobs/${enc(id)}/checkpoints`),
exportModel: (id, data) => post(`/api/fine-tuning/jobs/${enc(id)}/export`, data),
// The browser sets the multipart boundary, so no Content-Type is given.
uploadDataset: (file) => {
const formData = new FormData()
formData.append('file', file)
return json('/api/fine-tuning/datasets', { method: 'POST', body: formData })
},
// The response is a stream of events; the caller reads it with readSse
// and aborts the signal to close it.
progress: (id, signal) => ctx.core.fetch(`/api/fine-tuning/jobs/${enc(id)}/progress`, { signal }),
},
// The one request useResources makes: the machine facts for the checks.
resources: {
get: () => json('/api/resources'),
},
// The link the browser follows to download the archive of a job.
downloadHref: (id) => `${ctx.basePath}/api/fine-tuning/jobs/${enc(id)}/download`,
}
}
+68
View File
@@ -0,0 +1,68 @@
import en from './i18n/en.js'
import { modelFrom as pickModel } from './lib/model.js'
// The one object every factory of the fine-tuning add-on receives. It binds the
// SDK once: React and the JSX helper, the shared components, the
// session-bound core fetch, the translation and the small adapters that replace
// the host router and toasts. The shared job views (addons/internal/tools-ui)
// read h, Fragment, t, Icon, React, viewer and basePath from it.
export function createContext(sdk) {
const { React } = sdk
const h = React.createElement
const Fragment = React.Fragment
sdk.locale.define('en', Object.fromEntries(Object.entries(en).filter(([, v]) => typeof v === 'string')))
const t = (key, vars) => sdk.locale.t(key, key, vars)
// The text of a key, or the fallback when the bundle has none.
const tOr = (key, fallback) => {
const text = t(key)
return text === key ? fallback : text
}
// The SDK translator has no plural rule: the host's `_one` and `_other`
// variants are picked here.
const plural = (key, count, vars) => t(`${key}_${count === 1 ? 'one' : 'other'}`, { count, ...vars })
// A link to a page of this add-on, as an anchor the shell navigates.
function Link({ page, query, className, children, ...rest }) {
const href = sdk.navigation.href(query ? `${page}?${query}` : page)
return h('a', {
href, className,
onClick: (e) => { if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return; e.preventDefault(); sdk.navigation.navigate(query ? `${page}?${query}` : page) },
...rest,
}, children)
}
const useSearch = () => new URLSearchParams(sdk.navigation.current().search)
// The model a page works on: ?model=<id> first, then the first path segment.
const modelFrom = (path) => pickModel(sdk.navigation.current().search, path)
const toast = (message, kind = 'info') => (sdk.notify[kind] || sdk.notify.info)(message)
// The path prefix LocalAI is served under, read the way the host reads it
// (utils/basePath.js: the <base href> element).
const basePath = (() => {
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 '' }
})()
// A JSON call of a core route. A failure keeps the server's message.
const json = async (path, init) => {
const response = await sdk.core.fetch(path, init)
if (!response.ok) {
const body = await response.json().catch(() => ({}))
throw new Error(body?.error?.message || body?.error || `HTTP ${response.status}`)
}
return response.json()
}
return {
sdk, React, h, Fragment, t, tOr, plural, Icon: sdk.Icon, ui: sdk.ui, hooks: sdk.hooks, viewer: sdk.viewer, core: sdk.core,
Link, useSearch, modelFrom, toast, json, basePath,
openPanel: (title, render) => sdk.layers.openPanel({ title, render }),
// A dialog the host draws: focus, Escape and the veil are the host's.
openDialog: (spec) => sdk.layers.openDialog(spec),
markDirty: (flag) => sdk.markDirty(flag),
}
}
+300
View File
@@ -0,0 +1,300 @@
// Generated once from the host locale file public/locales/en/tools.json; edit by hand from here on.
// The last three keys belong to this add-on.
export default {
"steps.label": "Progress",
"steps.setup": "Set up",
"steps.check": "Check",
"steps.run": "Run",
"steps.result": "Result",
"run.stop": "Stop",
"run.connecting": "Connecting to the job. Waiting for the first progress event.",
"run.progress": "Progress",
"run.stages": "Stages",
"run.step": "step",
"run.of": "of",
"run.epoch": "epoch",
"run.tokens": "tokens/s",
"run.eta": "about {{time}} left",
"run.etaSeconds": "about {{seconds}} s left",
"checks.heading": "Check before you start",
"checks.live": "Updates as you change the setup.",
"checks.meterLabel": "{{used}} percent in use, {{free}} free of {{total}}",
"checks.openBackends": "Open Backends",
"checks.barOk": "Nothing blocks the start.",
"checks.barWarn_one": "{{count}} warning. You can still start.",
"checks.barWarn_other": "{{count}} warnings. You can still start.",
"checks.title.model": "Model",
"checks.title.data": "Dataset",
"checks.title.backend": "Backend",
"checks.title.memory": "Memory",
"checks.title.disk": "Disk",
"checks.title.reward": "Reward functions",
"checks.title.resume": "Resuming",
"checks.title.access": "Model access",
"checks.title.type": "Quantization type",
"checks.title.source": "Source",
"checks.title.name": "Name",
"checks.fineTune.modelOk": "Training {{model}}.",
"checks.fineTune.modelMissing": "Enter the model to train.",
"checks.fineTune.dataOk": "Data comes from {{source}}.",
"checks.fineTune.dataMissing": "Add a dataset: a Hugging Face id or an uploaded file.",
"checks.fineTune.backendOk": "{{name}} is installed.",
"checks.fineTune.backendMissing": "No fine-tuning backend is installed, so the job may fail to start.",
"checks.fineTune.backendOther": "{{name}} is not among the installed fine-tuning backends ({{list}}).",
"checks.fineTune.backendUnknown": "The installed backends could not be read.",
"checks.fineTune.memoryGpu": "{{name}}: {{free}} of {{total}} free. Batch size {{batch}}, sequence length {{seq}}, gradient accumulation {{accum}}. How much the job needs depends on the model and these settings, and LocalAI does not estimate it before the job starts.",
"checks.fineTune.memoryCluster": "{{total}} on the best node ({{node}}). Batch size {{batch}}, sequence length {{seq}}. How much the job needs depends on the model and these settings.",
"checks.fineTune.memoryCpu": "No GPU found. Training uses the CPU and {{free}} of {{total}} RAM is free. Expect it to be slow. Batch size {{batch}}, sequence length {{seq}}.",
"checks.fineTune.memoryUnknown": "This account cannot read the machine's memory. Batch size {{batch}}, sequence length {{seq}}. How much the job needs depends on the model and these settings.",
"checks.fineTune.disk": "{{free}} free on the models disk. Downloads and checkpoints need room.",
"checks.fineTune.diskLow": "Only {{free}} free on the models disk. Downloads and checkpoints need room.",
"checks.fineTune.rewardMissing": "GRPO needs at least one reward function. Add one under More options.",
"checks.fineTune.resume": "Training continues from {{path}}.",
"checks.fineTune.accessNone": "No token set. A gated model needs a Hugging Face token under More options.",
"checks.fineTune.accessSet": "A Hugging Face token is set.",
"checks.quantize.modelOk": "Quantizing {{model}}.",
"checks.quantize.modelMissing": "Enter the model to quantize.",
"checks.quantize.typeMissing": "Enter the custom quantization type.",
"checks.quantize.backendOk": "{{name}} is installed.",
"checks.quantize.backendMissing": "No quantization backend is installed, so the job may fail to start.",
"checks.quantize.backendOther": "{{name}} is not among the installed quantization backends ({{list}}).",
"checks.quantize.backendUnknown": "The installed backends could not be read.",
"checks.quantize.memory": "{{free}} of {{total}} RAM is free. Conversion runs on the CPU.",
"checks.quantize.disk": "{{free}} free on the models disk. The download, the converted file and the result are written to disk.",
"checks.quantize.diskLow": "Only {{free}} free on the models disk. The download, the converted file and the result are written to disk.",
"checks.quantize.accessNone": "No token set. A gated model needs a Hugging Face token under More options.",
"checks.quantize.accessSet": "A Hugging Face token is set.",
"checks.import.sourceOk_huggingface": "Reads as a Hugging Face repository. LocalAI looks at its files when you import.",
"checks.import.sourceOk_url": "URL format recognised. Its contents have not been checked.",
"checks.import.sourceOk_config": "Reads as a model configuration file. It is read and used as it is.",
"checks.import.sourceOk_oci": "Reads as an OCI image reference.",
"checks.import.sourceOk_ollama": "Reads as an Ollama model reference.",
"checks.import.sourceOk_local": "Reads as a path on the machine that runs LocalAI.",
"checks.import.sourceOk": "The source is recognised.",
"checks.import.sourceUnknown": "This does not look like any form LocalAI knows. It will still try, and may fail.",
"checks.import.backendDownload": "{{name}} is not installed yet. LocalAI downloads it first.",
"checks.import.backendChosen": "{{name}} is chosen and installed.",
"checks.import.backendAuto": "LocalAI picks the backend from the source's files when the import starts.",
"checks.import.nameTaken": "A model named {{name}} is already installed.",
"checks.import.nameFree": "{{name}} is not in use.",
"checks.import.nameAuto": "The model is named after the source.",
"checks.import.disk": "{{free}} free on the models disk. The download size is known once the import starts.",
"checks.import.memory_gpu": "{{free}} of GPU memory is free now. What this model needs is known once the import starts.",
"checks.import.memory_ram": "{{free}} of RAM is free now. What this model needs is known once the import starts.",
"checks.import.memory_cluster": "{{free}} is available on the best node. What this model needs is known once the import starts.",
"checks.import.memory": "{{free}} of memory is free now. What this model needs is known once the import starts.",
"form.key": "Key",
"form.value": "Value",
"form.addOption": "Add option",
"form.copy": "Copy to clipboard",
"form.more": "More options",
"form.moreHint": "method, backend, adapter, optimizer, evaluation, reward functions",
"form.baseModel": "Base model",
"form.model": "Model",
"form.modelHint": "A Hugging Face id, or a path on this host.",
"form.data": "Your data",
"form.dataset": "Dataset source",
"form.datasetHint": "A Hugging Face dataset id, or upload a file.",
"form.split": "Split",
"form.upload": "Upload a file instead",
"form.uploadHint": ".json, .jsonl or .csv",
"form.removeFile": "Remove the file",
"form.train": "What to train",
"form.kind": "Kind of training",
"form.kinds.adapter.title": "Adapter",
"form.kinds.adapter.text": "Trains a small adapter and leaves the base weights alone. The usual choice, and it needs little memory.",
"form.kinds.full.title": "Full model",
"form.kinds.full.text": "Changes every weight. It needs several times the memory of an adapter.",
"form.epochs": "Epochs",
"form.batch": "Batch size",
"form.lr": "Learning rate",
"form.backend": "Backend",
"form.method": "Training method",
"form.adapterKind": "Adapter kind",
"form.rank": "Rank",
"form.alpha": "Alpha",
"form.dropout": "Dropout",
"form.gradAccum": "Gradient accumulation steps",
"form.warmup": "Warmup steps",
"form.seqLen": "Max sequence length",
"form.optimizer": "Optimizer",
"form.maxSteps": "Max steps",
"form.maxStepsHint": "0 for automatic.",
"form.saveSteps": "Save steps",
"form.saveLimit": "Save total limit",
"form.saveLimitHint": "0 keeps every checkpoint.",
"form.weightDecay": "Weight decay",
"form.seed": "Seed",
"form.seedHint": "0 picks a random seed.",
"form.precision": "Mixed precision",
"form.auto": "Auto",
"form.targetModules": "Target modules",
"form.targetModulesHint": "Comma-separated. Leave blank for the backend default.",
"form.gradCheckpointing": "Gradient checkpointing (slower, uses less memory)",
"form.token": "Hugging Face token",
"form.tokenHint": "Only needed for gated models.",
"form.evalEnable": "Evaluate while training",
"form.evalStrategy": "Evaluation strategy",
"form.evalSteps_": "Steps",
"form.evalEpoch": "Epoch",
"form.evalSteps": "Evaluation steps",
"form.evalStepsHint": "0 matches save steps.",
"form.evalSplit": "Evaluation split",
"form.evalDataset": "Evaluation dataset",
"form.evalDatasetPlaceholder": "A separate dataset",
"form.evalRatio": "Auto-split ratio",
"form.rewards": "Reward functions (GRPO)",
"form.rewardsHint": "GRPO needs at least one reward function. Pick built-in ones or add your own.",
"form.resume": "Resume from checkpoint",
"form.clearCheckpoint": "Clear the checkpoint",
"form.liquidHint": "The dataset must be preprocessed by LFM2AudioChatMapper: a directory of arrow files the LFM2 data loader can read.",
"form.liquidVoice": "Speech voice",
"form.liquidInherit": "Inherit from the system prompt",
"form.liquidVal": "Validation dataset",
"form.extra": "Extra options",
"form.extraHint": "Backend-specific key and value pairs.",
"form.summaryFull": "full model",
"form.summary_lora": "LoRA adapter",
"form.summary_loha": "LoHa adapter",
"form.summary_lokr": "LoKr adapter",
"form.recipe": "Train a {{kind}} of {{model}} with {{method}}, {{epochs}} epochs over {{data}}.",
"form.recipeEmpty": "Choose a model and a dataset to see what will be trained.",
"rewards.format_reward": "Checks the <think>...</think> then answer format.",
"rewards.reasoning_accuracy_reward": "Compares the <answer> content to the dataset's answer column.",
"rewards.length_reward": "Scores how close the answer is to a target length.",
"rewards.xml_tag_reward": "Scores properly opened and closed XML tags.",
"rewards.no_repetition_reward": "Penalizes repeated n-grams.",
"rewards.code_execution_reward": "Checks that Python code blocks are valid syntax.",
"rewards.param_target_length": "Target length",
"rewards.fnName": "Function name",
"rewards.fnBody": "Function body",
"rewards.fnHint": "Receives completions and kwargs and must return a list of floats. re, math, json and string are available.",
"rewards.add": "Add",
"rewards.cancel": "Cancel",
"rewards.custom": "Add a custom reward function",
"fineTune.title": "Fine-tune",
"fineTune.lede": "Train an adapter on your own data, then export it as a model you can chat with.",
"fineTune.importConfig": "Import config",
"fineTune.exportConfig": "Export config",
"fineTune.newJob": "New job",
"fineTune.badConfig": "Could not read that file. Use a JSON config exported from this page.",
"fineTune.start": "Start fine-tuning",
"fineTune.starting": "Starting...",
"fineTune.resume": "Resume training",
"fineTune.resuming": "Resuming from checkpoint:",
"fineTune.clear": "Clear",
"fineTune.barNote": "Nothing is changed until you start.",
"fineTune.jobMeta": "{{kind}} {{method}} on {{backend}}, started {{started}}",
"fineTune.failed": "Training failed",
"fineTune.failedAt": "Training stopped at step {{step}} of {{total}}",
"fineTune.noMessage": "The backend gave no message.",
"fineTune.finished": "Training finished.",
"fineTune.finishedLoss": "Training finished. The last loss was {{loss}}.",
"fineTune.stopped": "The job was stopped. A checkpoint is kept when you chose to keep it.",
"fineTune.tryTitle": "What to do next",
"fineTune.tryText": "The server reported that memory ran out. Each change below is applied to a copy of the setup, and nothing starts until you press Start.",
"fineTune.tryPlain": "Open the setup again, change what the message points at, and start a new job.",
"fineTune.fixBatch": "Lower the batch size to 1",
"fineTune.fixCheckpointing": "Turn on gradient checkpointing (slower, uses less memory)",
"fineTune.retryChanges": "Open the setup with these changes",
"fineTune.reuse": "Open the setup again",
"fineTune.resumeFrom": "Resume from step {{step}}",
"fineTune.status.queued": "Queued",
"fineTune.status.loading_model": "Loading model",
"fineTune.status.loading_dataset": "Loading dataset",
"fineTune.status.training": "Training",
"fineTune.status.saving": "Saving",
"fineTune.status.completed": "Completed",
"fineTune.status.failed": "Failed",
"fineTune.status.stopped": "Stopped",
"stop.title": "Stop training?",
"stop.text": "A checkpoint saved now lets you resume or export what has been learned so far.",
"stop.keep": "Stop and keep a checkpoint",
"stop.discard": "Stop without a checkpoint",
"stop.cancel": "Keep training",
"stop.cancelDelete": "Cancel",
"chart.loss": "Loss",
"chart.learning_rate": "Learning rate",
"chart.grad_norm": "Gradient norm",
"chart.metric": "Metric",
"chart.step": "step",
"chart.eval": "Eval loss",
"chart.waiting": "Waiting for more steps.",
"chart.window": "Steps {{from}} to {{to}} of {{total}}",
"chart.windowEval": "Steps {{from}} to {{to}} of {{total}}. Hollow dots are the evaluation loss.",
"chart.aria": "{{metric}} over training steps. The last value is {{last}} at step {{step}}.",
"chart.readout": "step {{step}}: {{value}}",
"chart.dataTitle": "Show the numbers",
"chart.emptyText": "The chart draws once the job has reported two steps.",
"log.title": "Log",
"log.note": "What the job reported since this page opened.",
"log.only": "Warnings and errors only",
"log.copy": "Copy",
"log.copied": "Copied",
"log.download": "Download",
"log.empty": "Nothing reported yet.",
"log.noneFiltered": "No warnings or errors.",
"checkpoints.title": "Checkpoints",
"checkpoints.step": "Step",
"checkpoints.epoch": "Epoch",
"checkpoints.loss": "Loss",
"checkpoints.path": "Path",
"checkpoints.actions": "Actions",
"checkpoints.resume": "Resume",
"checkpoints.resumeTitle": "Resume training from this checkpoint",
"checkpoints.export": "Export",
"checkpoints.exportTitle": "Export this checkpoint",
"export.title": "Export as a model",
"export.lede": "Exporting turns the result into a model LocalAI can load.",
"export.checkpoint": "Checkpoint",
"export.final": "Final model (output directory)",
"export.checkpointOption": "Step {{step}} (loss {{loss}})",
"export.format": "Export format",
"export.lora": "LoRA adapter",
"export.merged16": "Merged (16-bit)",
"export.merged4": "Merged (4-bit)",
"export.quant": "Quantization",
"export.name": "Model name",
"export.nameHint": "Leave blank to generate one from the job.",
"export.go": "Export",
"export.exporting": "Exporting...",
"export.working": "Export in progress...",
"export.done": "Model exported and registered as \"{{name}}\"",
"export.failed": "Export failed: {{message}}",
"export.unknown": "unknown error",
"export.nextTitle": "{{name}} is a model now",
"export.nextText": "It is registered with LocalAI. Open a chat with it, or find it in Models.",
"export.chat": "Chat with {{name}}",
"export.models": "Open in Models",
"export.archive": "Download archive",
"jobs.title": "Earlier jobs",
"jobs.count_one": "{{count}} kept on this server",
"jobs.count_other": "{{count}} kept on this server",
"jobs.loading": "Loading jobs",
"jobs.emptyTitle": "No fine-tuning jobs yet",
"jobs.emptyText": "Set one up above. It appears here with its status, and you can reuse its setup later.",
"jobs.model": "Model",
"jobs.kind": "Kind",
"jobs.status": "Status",
"jobs.started": "Started",
"jobs.actions": "Actions",
"jobs.open": "Open this job",
"jobs.reuse": "Reuse",
"jobs.reuseTitle": "Use this job's setup for a new job",
"jobs.delete": "Delete",
"jobs.deleteTitle": "Delete this job and its data",
"jobs.deleteDialogTitle": "Delete this job?",
"jobs.deleteDialogText": "This deletes the job with its checkpoints and any exported model. It cannot be undone.",
"off.badge": "Not available to you",
"off.fineTune.title": "Your account cannot fine-tune",
"off.fineTune.text": "Fine-tuning is a separate permission, and this account does not have it.",
"off.quantize.title": "Your account cannot quantize",
"off.quantize.text": "Quantization is a separate permission, and this account does not have it.",
"off.admin": "An administrator can turn it on under Users and keys.",
"off.askAdmin": "Ask an administrator to turn it on for your account.",
"off.back": "Go to Home",
"landing.experimental": "Experimental",
"run.unknownPercent": "Progress not reported yet",
"export.adminOnly.title": "Exporting a model is for administrators",
"export.adminOnly.text": "Exporting adds a model to this server for everyone who can use it. Ask an administrator to export this job."
}
+46
View File
@@ -0,0 +1,46 @@
import { createContext } from './ctx.jsx'
import { createApi } from './api.js'
import { createFineTune } from './FineTune.jsx'
import * as tools from '../../../internal/tools-ui/src/tools.js'
import { readSse } from '../../../internal/tools-ui/src/sse.js'
import { createToolSteps } from '../../../internal/tools-ui/src/ToolSteps.jsx'
import { createChecks } from '../../../internal/tools-ui/src/Checks.jsx'
import { createStageLine } from '../../../internal/tools-ui/src/StageLine.jsx'
import { createJobLog } from '../../../internal/tools-ui/src/JobLog.jsx'
import { createToolOff } from '../../../internal/tools-ui/src/ToolOff.jsx'
// What the page and the shared job views are built from, for one SDK.
function build(sdk) {
const ctx = createContext(sdk)
const api = createApi(ctx)
const shared = {
tools, readSse,
ToolSteps: createToolSteps(ctx), Checks: createChecks(ctx), StageLine: createStageLine(ctx),
JobLog: createJobLog(ctx), ToolOff: createToolOff(ctx),
}
return { ctx, api, parts: createFineTune(ctx, api, shared) }
}
// The second argument is for tests: the host passes the SDK only.
export function createAddon(sdk, options = {}) {
const { ctx, parts } = build(sdk)
// The JSX of every factory compiles to calls of `h`; ctx binds it once and
// each factory reads it from ctx. This `h` is for the JSX in this file.
// eslint-disable-next-line no-unused-vars
const { h } = ctx
const pages = {
jobs: parts.FineTune,
...options.pages,
}
function Component({ page, path }) {
const Page = pages[page]
return Page ? <Page path={path || ''} /> : null
}
return { Component, components: {}, chatActions: [], dispose() {} }
}
// For tests: the context, the API and the parts of the page, built the way
// createAddon builds them.
export const __partsFor = (sdk) => build(sdk)
export const __ctxFor = (sdk) => createContext(sdk)
+8
View File
@@ -0,0 +1,8 @@
// The model a page works on. A moved link carries it as ?model=<id>, because
// a model id may hold "/" or ":" that an add-on path segment refuses; a link
// typed by hand may still carry it as the first path segment.
export function modelFrom(search, path) {
const fromQuery = new URLSearchParams(search || '').get('model')
if (fromQuery) return fromQuery
return String(path || '').split('/')[0]
}
+22
View File
@@ -0,0 +1,22 @@
// The statuses in which a job is working. They are the stages of the stage line.
export const ACTIVE = ['queued', 'loading_model', 'loading_dataset', 'training', 'saving']
// How far a job is, in percent, or null when that is not known. The engine
// reads 0 for a job that is running but has reported nothing yet (a snapshot
// has no percent of its own), so a 0 while the job works means "unknown", and
// the page must not draw it as 0%. Steps, when they are known, give a better
// answer than that 0.
export function percentOf(event) {
if (!event) return null
const status = event.status
if (status === 'completed') return 100
const percent = Number(event.progress_percent) || 0
if (percent > 0) return Math.min(percent, 100)
if (ACTIVE.includes(status)) {
const done = Number(event.current_step) || 0
const total = Number(event.total_steps) || 0
if (done > 0 && total > 0) return Math.min((done / total) * 100, 100)
return null
}
return percent
}
+271
View File
@@ -0,0 +1,271 @@
/* ==========================================================================
Build tools: the landing, Fine-tune, Quantize and the shared pieces Import
uses. Built on the kit's card, field, table, badge, chip, switch, progress,
meter, segmented, dialog and sheet classes. What is here is what the kit does
not say: the step line, the check list, the stage line, the job card, the
chart and the log. Colours, radii and spacing are kit roles (--dk-*).
Surfaces are flat and nothing sits on a coloured rail at the edge. Motion is
switched off for reduced motion at the end of this file.
========================================================================== */
.bt-page { display: flex; flex-direction: column; gap: var(--dk-space-5); min-width: 0; }
.bt-page :is(a, button, summary, select, input, textarea, [tabindex]):focus-visible { outline: 2px solid var(--dk-accent-text); outline-offset: 2px; }
.bt-page > .page-header { margin-bottom: 0; }
.bt-h1 { margin: 0; font: var(--dk-weight-bold) var(--dk-text-32) / 1.15 var(--dk-font-display); letter-spacing: -.02em; text-wrap: balance; }
.bt-h2 { margin: 0; font: var(--dk-weight-semibold) var(--dk-text-17) / 1.3 var(--dk-font-display); color: var(--dk-text); }
.bt-h3 { margin: 0; font: var(--dk-weight-semibold) var(--dk-text-15) / 1.3 var(--dk-font-display); color: var(--dk-text); }
.bt-experimental { vertical-align: middle; margin-left: var(--dk-space-2); }
.bt-head-acts { display: flex; align-items: center; gap: var(--dk-space-2); flex-wrap: wrap; justify-content: flex-end; }
.bt-stack { display: flex; flex-direction: column; gap: var(--dk-space-4); min-width: 0; }
.bt-empty { color: var(--dk-muted); }
.bt-block { display: flex; flex-direction: column; gap: var(--dk-space-3); min-width: 0; }
.bt-block.dk-card { padding: var(--dk-space-4) var(--dk-space-5); }
.bt-block__head { display: flex; align-items: baseline; gap: var(--dk-space-3); flex-wrap: wrap; }
.bt-block__head .dk-hint { margin: 0; }
/* ---- Step line -------------------------------------------------------------- */
.bt-steps { list-style: none; margin: 0; padding: 0; display: flex; align-items: center; gap: var(--dk-space-3); flex-wrap: wrap; }
.bt-step { display: inline-flex; align-items: center; gap: var(--dk-space-2); color: var(--dk-muted); font-size: var(--dk-text-13); font-weight: var(--dk-weight-semibold); }
.bt-step:not(:last-child)::after { content: ''; width: 48px; height: 2px; margin-left: var(--dk-space-2); border-radius: 2px; background: var(--dk-edge); }
.bt-step[data-state="done"]:not(:last-child)::after { background: var(--dk-accent); }
.bt-step__mark { display: inline-grid; place-items: center; width: 24px; height: 24px; border-radius: 50%; border: 2px solid var(--dk-edge); font: var(--dk-weight-bold) var(--dk-text-12) / 1 var(--dk-font-mono); color: var(--dk-muted); box-sizing: border-box; }
.bt-step__mark .lai-icon { font-size: 13px; }
.bt-step[data-state="done"] .bt-step__mark { background: var(--dk-accent); border-color: var(--dk-accent); color: var(--dk-on-accent); }
.bt-step[data-state="current"] { color: var(--dk-text); }
.bt-step[data-state="current"] .bt-step__mark { border-color: var(--dk-accent-text); color: var(--dk-accent-text); }
.bt-step[data-state="failed"] { color: var(--dk-text); }
.bt-step[data-state="failed"] .bt-step__mark { background: var(--dk-error-wash); border-color: var(--dk-error); color: var(--dk-error-text); }
.bt-step[data-state="done"] { color: var(--dk-text); }
/* ---- Form sections ----------------------------------------------------------- */
.bt-form-page { display: flex; flex-direction: column; gap: var(--dk-space-5); min-width: 0; }
.bt-section { display: flex; flex-direction: column; gap: var(--dk-space-3); min-width: 0; }
.bt-section__title { margin: 0; display: flex; align-items: center; gap: var(--dk-space-3); font: var(--dk-weight-semibold) var(--dk-text-17) / 1.3 var(--dk-font-display); }
.bt-section__mark { display: inline-grid; place-items: center; width: 24px; height: 24px; flex: none; border-radius: 50%; border: 2px solid var(--dk-edge); box-sizing: border-box; color: var(--dk-on-accent); }
.bt-section__mark .lai-icon { font-size: 13px; }
.bt-section[data-done="true"] .bt-section__mark { background: var(--dk-accent); border-color: var(--dk-accent); }
.bt-section__body { display: flex; flex-direction: column; gap: var(--dk-space-3); padding-left: calc(24px + var(--dk-space-3)); min-width: 0; }
.bt-pair { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--dk-space-3); }
.bt-pair--wide { grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); }
.bt-triple { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--dk-space-3); }
.bt-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: var(--dk-space-3); }
.bt-form { display: flex; flex-direction: column; gap: var(--dk-space-3); }
.bt-actions { display: flex; align-items: center; gap: var(--dk-space-2); flex-wrap: wrap; }
.bt-choices { border: 0; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0; min-width: 0; border-radius: var(--dk-radius-2); overflow: hidden; border: 1px solid var(--dk-edge); }
.bt-choices > .dk-label { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
.bt-choice { display: flex; align-items: flex-start; gap: var(--dk-space-3); padding: var(--dk-space-3) var(--dk-space-4); background: var(--dk-card); cursor: pointer; }
.bt-choice + .bt-choice { border-top: 1px solid var(--dk-edge); }
.bt-choice:hover { background: var(--dk-hover); }
.bt-choice[data-checked="true"] { background: var(--dk-accent-wash); }
.bt-choice b { display: block; font-size: var(--dk-text-15); }
.bt-choice__text { display: block; color: var(--dk-muted); font-size: var(--dk-text-13); line-height: 1.45; }
.bt-choice input { margin-top: 3px; flex: none; }
.bt-upload { position: relative; display: flex; align-items: center; gap: var(--dk-space-3); flex-wrap: wrap; }
.bt-upload__input { position: absolute; width: 1px; height: 1px; opacity: 0; overflow: hidden; }
.bt-upload__input:focus-visible + label { outline: 2px solid var(--dk-accent-text); outline-offset: 2px; }
.bt-more { border-top: 1px solid var(--dk-edge); padding-top: var(--dk-space-2); }
.bt-more__toggle { display: flex; align-items: center; gap: var(--dk-space-2); width: 100%; min-height: 40px; padding: 0; background: none; border: 0; color: var(--dk-text); font: inherit; font-size: var(--dk-text-15); text-align: left; cursor: pointer; }
.bt-more__toggle { flex-wrap: wrap; }
.bt-more__toggle .dk-hint { margin: 0; min-width: 0; }
.bt-more__body { display: flex; flex-direction: column; gap: var(--dk-space-4); padding: var(--dk-space-3) 0 var(--dk-space-2); min-width: 0; }
.bt-switchrow { display: flex; align-items: center; gap: var(--dk-space-3); }
.bt-eval { display: flex; flex-direction: column; gap: var(--dk-space-3); }
.bt-kv { display: flex; flex-direction: column; gap: var(--dk-space-2); align-items: flex-start; }
.bt-kv__row { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto; gap: var(--dk-space-2); width: 100%; align-items: center; }
.bt-banner { display: flex; align-items: center; gap: var(--dk-space-3); padding: var(--dk-space-3) var(--dk-space-4); border-radius: var(--dk-radius-2); background: var(--dk-accent-wash); color: var(--dk-text); font-size: var(--dk-text-13); flex-wrap: wrap; }
.bt-banner code { overflow-wrap: anywhere; }
.bt-rewards, .bt-liquid { display: flex; flex-direction: column; gap: var(--dk-space-3); padding-top: var(--dk-space-3); border-top: 1px solid var(--dk-edge); }
.bt-rewards .dk-hint, .bt-liquid .dk-hint { margin: 0; }
.bt-reward { display: flex; flex-direction: column; gap: var(--dk-space-2); padding: var(--dk-space-3); border: 1px solid var(--dk-edge); border-radius: var(--dk-radius-2); background: var(--dk-card); min-width: 0; }
.bt-reward[data-on="true"] { background: var(--dk-accent-wash); border-color: transparent; }
.bt-reward__name { display: block; font-size: var(--dk-text-13); font-weight: var(--dk-weight-semibold); }
.bt-reward__head { display: flex; align-items: center; justify-content: space-between; gap: var(--dk-space-2); }
.bt-reward-draft { display: flex; flex-direction: column; gap: var(--dk-space-3); }
.bt-code { margin: 0; padding: var(--dk-space-3); border-radius: var(--dk-radius-1); background: var(--dk-inset); font: var(--dk-text-12) / 1.5 var(--dk-font-mono); overflow-x: auto; white-space: pre-wrap; overflow-wrap: anywhere; }
.bt-recipe { margin: 0; padding: var(--dk-space-3) var(--dk-space-4); border-radius: var(--dk-radius-2); background: var(--dk-inset); font-size: var(--dk-text-15); line-height: 1.5; overflow-wrap: anywhere; }
/* ---- Check list --------------------------------------------------------------- */
.bt-checks-card { padding: var(--dk-space-2) var(--dk-space-5); }
.bt-checks { list-style: none; margin: 0; padding: 0; }
.bt-check { display: grid; grid-template-columns: 24px minmax(0, 1fr); gap: var(--dk-space-3); padding: var(--dk-space-3) 0; }
.bt-check + .bt-check { border-top: 1px solid var(--dk-edge); }
.bt-check__mark { font-size: 20px; line-height: 1; color: var(--dk-muted); padding-top: 1px; }
.bt-check[data-tone="ok"] .bt-check__mark { color: var(--dk-ok-text); }
.bt-check[data-tone="warn"] .bt-check__mark { color: var(--dk-warn-text); }
.bt-check[data-tone="fail"] .bt-check__mark { color: var(--dk-error-text); }
.bt-check__head { display: flex; align-items: baseline; justify-content: space-between; gap: var(--dk-space-3); }
.bt-check__title { font-weight: var(--dk-weight-semibold); font-size: var(--dk-text-15); }
.bt-check__value { font-size: var(--dk-text-13); color: var(--dk-text); text-align: right; overflow-wrap: anywhere; }
.bt-check__text { margin: 2px 0 0; font-size: var(--dk-text-13); line-height: 1.5; color: var(--dk-muted); overflow-wrap: anywhere; }
.bt-check[data-tone="fail"] .bt-check__text { color: var(--dk-error-text); }
.bt-check__meter { margin-top: var(--dk-space-2); }
/* ---- Action bar --------------------------------------------------------------- */
.bt-bar { position: sticky; bottom: 0; z-index: var(--dk-z-sticky); display: flex; align-items: center; justify-content: space-between; gap: var(--dk-space-4); flex-wrap: wrap; padding: var(--dk-space-3) 0; background: var(--dk-canvas); border-top: 1px solid var(--dk-edge); }
.bt-bar__text { margin: 0; flex: 1 1 280px; min-width: 0; font-size: var(--dk-text-13); line-height: 1.5; color: var(--dk-muted); overflow-wrap: anywhere; }
.bt-bar__acts { display: flex; align-items: center; gap: var(--dk-space-2); flex-wrap: wrap; margin-left: auto; }
/* ---- Alerts and statuses ------------------------------------------------------- */
.bt-alert { display: grid; grid-template-columns: 24px minmax(0, 1fr); gap: var(--dk-space-3); padding: var(--dk-space-4); border-radius: var(--dk-radius-3); background: var(--dk-error-wash); color: var(--dk-error-text); }
.bt-alert .lai-icon { font-size: 20px; margin-top: 1px; }
.bt-alert__title { margin: 0; font-weight: var(--dk-weight-bold); font-size: var(--dk-text-15); }
.bt-alert__text { margin: 2px 0 0; font-size: var(--dk-text-13); line-height: 1.5; overflow-wrap: anywhere; white-space: pre-wrap; }
.bt-status { margin: 0; display: flex; align-items: center; gap: var(--dk-space-2); font-size: var(--dk-text-13); overflow-wrap: anywhere; }
.bt-status--error { color: var(--dk-error-text); }
.bt-path { display: inline-block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; vertical-align: bottom; font-size: var(--dk-text-12); }
.bt-pathrow { margin: 0; display: flex; align-items: center; gap: var(--dk-space-2); min-width: 0; }
/* ---- Job card ----------------------------------------------------------------- */
.bt-job { display: flex; flex-direction: column; gap: var(--dk-space-3); padding: var(--dk-space-4) var(--dk-space-5); min-width: 0; }
.bt-job__top { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--dk-space-3); flex-wrap: wrap; }
.bt-job__who { min-width: 0; }
.bt-job__title { margin: 0; font: var(--dk-weight-bold) var(--dk-text-17) / 1.3 var(--dk-font-display); overflow-wrap: anywhere; }
.bt-job__meta { margin: 2px 0 0; font-size: var(--dk-text-13); color: var(--dk-muted); overflow-wrap: anywhere; }
.bt-job__acts { display: flex; align-items: center; gap: var(--dk-space-2); }
.bt-job__figures { display: flex; align-items: baseline; gap: var(--dk-space-5); flex-wrap: wrap; font-size: var(--dk-text-13); color: var(--dk-muted); }
.bt-job__figures b { color: var(--dk-text); }
.bt-job__percent { font: var(--dk-weight-bold) var(--dk-text-44) / 1 var(--dk-font-display); letter-spacing: -.02em; color: var(--dk-text); font-variant-numeric: tabular-nums; }
.bt-job__percent small { font-size: var(--dk-text-21); margin-left: 2px; color: var(--dk-muted); }
.bt-job__wait { margin: 0; display: flex; align-items: center; gap: var(--dk-space-2); color: var(--dk-muted); font-size: var(--dk-text-13); }
.bt-job__message { margin: 0; overflow-wrap: anywhere; }
.bt-job__done { margin: 0; display: flex; align-items: center; gap: var(--dk-space-2); color: var(--dk-ok-text); font-size: var(--dk-text-15); font-weight: var(--dk-weight-semibold); }
.bt-stages { list-style: none; margin: 0; padding: 0; display: grid; grid-auto-flow: column; grid-auto-columns: minmax(0, 1fr); gap: var(--dk-space-2); }
.bt-stage { display: flex; flex-direction: column; gap: 6px; min-width: 0; font-size: var(--dk-text-12); color: var(--dk-muted); }
.bt-stage__bar { height: 3px; border-radius: 3px; background: var(--dk-edge); }
.bt-stage[data-state="done"] .bt-stage__bar { background: var(--dk-muted); }
.bt-stage[data-state="current"] { color: var(--dk-text); font-weight: var(--dk-weight-bold); }
.bt-stage[data-state="current"] .bt-stage__bar { background: var(--dk-accent); }
.bt-stage[data-state="failed"] { color: var(--dk-error-text); font-weight: var(--dk-weight-bold); }
.bt-stage[data-state="failed"] .bt-stage__bar { background: var(--dk-error); }
.bt-stage__label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.bt-try { display: flex; flex-direction: column; gap: var(--dk-space-3); padding: var(--dk-space-4) var(--dk-space-5); }
.bt-try .dk-hint { margin: 0; }
.bt-try__acts { display: flex; gap: var(--dk-space-2); flex-wrap: wrap; }
.bt-next { display: grid; grid-template-columns: 40px minmax(0, 1fr); gap: var(--dk-space-3); padding: var(--dk-space-4) var(--dk-space-5); }
.bt-next .dk-hint { margin: 2px 0 var(--dk-space-3); }
.bt-next__mark { display: grid; place-items: center; width: 40px; height: 40px; border-radius: 50%; background: var(--dk-ok-wash); color: var(--dk-ok-text); font-size: 20px; }
.bt-next__acts { display: flex; gap: var(--dk-space-2); flex-wrap: wrap; }
.bt-out { display: flex; flex-direction: column; gap: var(--dk-space-3); }
.bt-out__name { max-width: 420px; }
/* ---- Chart -------------------------------------------------------------------- */
.bt-chart { padding: var(--dk-space-4) var(--dk-space-5); display: flex; flex-direction: column; gap: var(--dk-space-3); min-width: 0; }
.bt-chart__head { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--dk-space-3); flex-wrap: wrap; }
.bt-chart__head .dk-hint { margin: 2px 0 0; }
.bt-chart__svg { width: 100%; height: auto; display: block; overflow: visible; }
.bt-chart__grid { stroke: var(--dk-edge); stroke-width: 1; }
.bt-chart__tick { fill: var(--dk-muted); font: 11px var(--dk-font-mono); }
.bt-chart__axis { fill: var(--dk-muted); font: 11px var(--dk-font-body); }
.bt-chart__line { fill: none; stroke: var(--dk-series-1); stroke-width: 2; stroke-linejoin: round; stroke-linecap: round; }
.bt-chart__eval { fill: var(--dk-card); stroke: var(--dk-series-2); stroke-width: 2; }
.bt-chart__end { fill: var(--dk-series-1); }
.bt-chart__label { fill: var(--dk-text); font: 700 12px var(--dk-font-mono); }
.bt-chart__cursor { stroke: var(--dk-muted); stroke-dasharray: 3 3; stroke-width: 1; }
.bt-chart__readout { margin: 0; font-size: var(--dk-text-12); color: var(--dk-muted); }
.bt-chart__empty { margin: 0; color: var(--dk-muted); font-size: var(--dk-text-13); }
.bt-chart__data summary { cursor: pointer; font-size: var(--dk-text-13); color: var(--dk-muted); min-height: 32px; display: flex; align-items: center; }
.bt-chart__table { --dk-table-max: 240px; margin-top: var(--dk-space-2); }
/* ---- Log ---------------------------------------------------------------------- */
.bt-log { padding: var(--dk-space-4) var(--dk-space-5); display: flex; flex-direction: column; gap: var(--dk-space-3); min-width: 0; }
.bt-log__head { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--dk-space-3); flex-wrap: wrap; }
.bt-log__head .dk-hint { margin: 2px 0 0; }
.bt-log__tools { display: flex; align-items: center; gap: var(--dk-space-2); flex-wrap: wrap; }
.bt-log__body { max-height: 280px; overflow: auto; padding: var(--dk-space-3); border-radius: var(--dk-radius-2); background: var(--dk-inset); font: var(--dk-text-12) / 1.6 var(--dk-font-mono); }
.bt-log__line { display: grid; grid-template-columns: 64px 44px minmax(0, 1fr); gap: var(--dk-space-3); }
.bt-log__time, .bt-log__tag { color: var(--dk-muted); }
.bt-log__text { overflow-wrap: anywhere; white-space: pre-wrap; }
.bt-log__line[data-level="warn"] .bt-log__text, .bt-log__line[data-level="warn"] .bt-log__tag { color: var(--dk-warn-text); }
.bt-log__line[data-level="err"] .bt-log__text, .bt-log__line[data-level="err"] .bt-log__tag { color: var(--dk-error-text); }
.bt-log__empty { margin: 0; color: var(--dk-muted); }
/* ---- Jobs table --------------------------------------------------------------- */
.bt-jobs { display: flex; flex-direction: column; gap: var(--dk-space-3); min-width: 0; }
.bt-empty-card { padding: var(--dk-space-5); border-radius: var(--dk-radius-3); border: 1px solid var(--dk-edge); background: var(--dk-card); }
.bt-linkcell { display: block; padding: 0; background: none; border: 0; font: inherit; color: inherit; text-align: left; cursor: pointer; max-width: 100%; }
.bt-linkcell:hover .dk-table-name { text-decoration: underline; }
.bt-jobs__msg { color: var(--dk-error-text); overflow-wrap: anywhere; }
.bt-rowacts { display: flex; align-items: center; justify-content: flex-end; gap: var(--dk-space-1); }
/* ---- Landing ------------------------------------------------------------------ */
.bt-machine { display: flex; flex-direction: column; gap: var(--dk-space-3); background: var(--dk-inset); }
.bt-machine__facts { margin: 0; display: flex; flex-wrap: wrap; gap: var(--dk-space-2) var(--dk-space-5); }
.bt-machine__facts > div { min-width: 0; }
.bt-machine__facts dt { display: flex; align-items: center; gap: 6px; font-size: var(--dk-text-12); color: var(--dk-muted); }
.bt-machine__facts dd { margin: 2px 0 0; font-size: var(--dk-text-13); }
.bt-machine__backends { margin: 0; display: flex; align-items: center; gap: var(--dk-space-2); flex-wrap: wrap; font-size: var(--dk-text-13); color: var(--dk-muted); }
.bt-now { display: grid; grid-template-columns: 24px minmax(0, 1fr) auto; align-items: center; gap: var(--dk-space-3); padding: var(--dk-space-3) var(--dk-space-4); border-radius: var(--dk-radius-3); background: var(--dk-accent-wash); }
.bt-now[data-kind="failed"] { background: var(--dk-error-wash); color: var(--dk-error-text); }
.bt-now__mark { font-size: 20px; }
.bt-now__title { margin: 0; font-weight: var(--dk-weight-bold); font-size: var(--dk-text-15); overflow-wrap: anywhere; }
.bt-now__text { margin: 2px 0 0; font-size: var(--dk-text-13); overflow-wrap: anywhere; }
.bt-groups { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--dk-space-6); }
.bt-group { display: flex; flex-direction: column; gap: var(--dk-space-3); min-width: 0; }
.bt-group__head { display: flex; align-items: baseline; gap: var(--dk-space-3); flex-wrap: wrap; }
.bt-group__head .dk-hint { margin: 0; }
.bt-flow { display: flex; align-items: center; gap: var(--dk-space-2); flex-wrap: wrap; }
.bt-flow__list { list-style: none; margin: 0; padding: 0; display: flex; align-items: center; gap: var(--dk-space-2); flex-wrap: wrap; }
.bt-flow__item { display: inline-flex; align-items: center; gap: var(--dk-space-2); color: var(--dk-muted); }
.bt-flow .dk-chip { cursor: default; }
.bt-flow .dk-hint { margin: 0; }
.bt-tools { list-style: none; margin: 0; padding: 0; border: 1px solid var(--dk-edge); border-radius: var(--dk-radius-3); background: var(--dk-card); overflow: hidden; }
.bt-tool { display: grid; grid-template-columns: 40px minmax(0, 1fr) auto; gap: var(--dk-space-4); align-items: start; padding: var(--dk-space-4) var(--dk-space-5); }
.bt-tool + .bt-tool { border-top: 1px solid var(--dk-edge); }
.bt-tool__icon { display: grid; place-items: center; width: 40px; height: 40px; border-radius: var(--dk-radius-2); background: var(--dk-inset); color: var(--dk-muted); font-size: 18px; }
.bt-tool__title { margin: 0; display: flex; align-items: center; gap: var(--dk-space-2); flex-wrap: wrap; font: var(--dk-weight-bold) var(--dk-text-15) / 1.3 var(--dk-font-display); }
.bt-tool__text { margin: 4px 0 0; max-width: 62ch; font-size: var(--dk-text-13); line-height: 1.5; color: var(--dk-muted); }
.bt-tool__why { margin: var(--dk-space-2) 0 0; font-size: var(--dk-text-13); color: var(--dk-warn-text); }
.bt-tool__end { display: flex; flex-direction: column; align-items: flex-end; gap: var(--dk-space-2); }
.bt-needs { list-style: none; margin: var(--dk-space-2) 0 0; padding: 0; display: flex; flex-wrap: wrap; gap: var(--dk-space-1) var(--dk-space-4); }
.bt-needs li { display: inline-flex; align-items: center; gap: 6px; font-size: var(--dk-text-12); color: var(--dk-muted); }
.bt-needs li[data-tone="ok"] .lai-icon { color: var(--dk-ok-text); }
.bt-needs li[data-tone="warn"] { color: var(--dk-warn-text); }
/* ---- Disabled (account) --------------------------------------------------------- */
.bt-off { padding-top: var(--dk-space-5); }
.bt-off__card { display: flex; flex-direction: column; align-items: flex-start; gap: var(--dk-space-3); padding: var(--dk-space-5); background: var(--dk-inset); }
.bt-off__text { margin: 0; max-width: 56ch; font-size: var(--dk-text-15); line-height: 1.5; }
.bt-off__card .dk-hint { margin: 0; }
.bt-off__acts { display: flex; gap: var(--dk-space-2); flex-wrap: wrap; }
/* ---- Phone ---------------------------------------------------------------------- */
@media (max-width: 640px) {
.bt-section__body { padding-left: 0; }
.bt-pair, .bt-pair--wide, .bt-triple { grid-template-columns: minmax(0, 1fr); }
.bt-steps { gap: 6px; flex-wrap: nowrap; }
.bt-step { gap: 4px; }
.bt-step__label { font-size: var(--dk-text-12); }
.bt-step__mark { width: 22px; height: 22px; }
.bt-step:not(:last-child)::after { width: 8px; margin-left: 0; }
.bt-tool { grid-template-columns: 40px minmax(0, 1fr); }
.bt-tool__end { grid-column: 1 / -1; flex-direction: row; justify-content: flex-start; flex-wrap: wrap; }
.bt-now { grid-template-columns: 24px minmax(0, 1fr); }
.bt-now .dk-btn { grid-column: 1 / -1; justify-self: start; }
.bt-kv__row { grid-template-columns: minmax(0, 1fr) auto; }
.bt-kv__row .dk-input:nth-child(2) { grid-column: 1 / -1; grid-row: 2; }
.bt-log__line { grid-template-columns: 56px minmax(0, 1fr); }
.bt-log__tag { display: none; }
.bt-more__toggle .dk-hint { flex-basis: 100%; padding-left: 24px; }
.bt-rowacts { flex-direction: column; align-items: flex-end; gap: 0; }
.bt-jobs .dk-table { table-layout: fixed; }
.bt-jobs .dk-table th:nth-child(3) { width: 112px; }
.bt-jobs .dk-table th:last-child { width: 84px; }
.bt-jobs .dk-table td:first-child { overflow-wrap: anywhere; min-width: 0; }
.bt-jobs .dk-table .dk-table-name, .bt-jobs .dk-table .dk-table-sub { white-space: normal; overflow: visible; text-overflow: clip; overflow-wrap: anywhere; }
.bt-bar__acts { width: 100%; }
.bt-bar__acts .dk-btn--primary { flex: 1 1 auto; }
.bt-job__percent { font-size: var(--dk-text-32); }
.bt-stage__label { font-size: 11px; }
.bt-next { grid-template-columns: minmax(0, 1fr); }
.bt-block.dk-card, .bt-job, .bt-chart, .bt-log, .bt-try, .bt-next { padding-inline: var(--dk-space-4); }
.bt-checks-card { padding-inline: var(--dk-space-4); }
}
@media (prefers-reduced-motion: reduce) {
.bt-page * { animation-duration: .001ms !important; animation-iteration-count: 1 !important; transition-duration: .001ms !important; }
}
/* Added by the add-on: the buttons of the stop and delete layers. The host draws the layer; the foot is ours. */
.bt-dialogacts { display: flex; gap: var(--dk-space-2, 8px); justify-content: flex-end; flex-wrap: wrap; }
+49
View File
@@ -0,0 +1,49 @@
import { createRequire } from 'node:module'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { dirname, join, resolve } from 'node:path'
import { build } from '../build.mjs'
export 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 } = require('react-dom/server')
const COMPONENTS = ['Button', 'Spinner', 'EmptyState', 'Badge', 'Stack', 'Text', 'PageHeader', 'ConfirmDialog', 'ErrorWithTraceLink', 'ModelSelector', 'MediaInput', 'WaveformPlayer', 'DistributionBars', 'EmbeddingInspector', 'TabSwitch']
// Builds the bundle and draws it through the real registration path: the host
// SDK, the module's createAddon and its Component. Options: viewer, fetch,
// components, layers (the openLayer binding), language.
export async function load(extra = {}) {
const dir = await build()
const mod = await import(pathToFileURL(join(dir, 'ui/fine-tuning.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 layers = []
const controller = new AbortController()
const { viewer, components, layerSpec, fetch, ...rest } = extra
const stubs = Object.fromEntries(COMPONENTS.map(n => [n, stub('div')]))
stubs.PageHeader = (p) => React.createElement('header', { 'data-stub': 'header' }, p.title ? React.createElement('h1', null, p.title) : null, p.supporting, p.actions)
const sdk = createHostSDK({
addonId: 'fine-tuning', generation: 1, signal: controller.signal, pages: ['jobs'],
host: {
React,
components: { ...stubs, ...components },
hooks: {},
viewer: viewer || { isAdmin: () => true, hasFeature: () => true },
basePath: '/base',
fetch: fetch || (async (url, init) => { calls.push([url, init]); return { ok: true, json: async () => [], blob: async () => new Blob([]) } }),
Icon: ({ name }) => React.createElement('i', { 'data-icon': name }), theme: { mode: () => 'light', subscribe: () => () => {} }, language: () => 'en', subscribeLanguage: () => () => {}, api: {},
...rest,
},
bindings: {
current: () => ({ page: 'jobs', path: '', search: '' }),
openLayer: (kind, spec) => { layers.push({ kind, spec }); return { close() {} } },
notify() {},
},
})
return { mod, sdk, controller, calls, layers }
}
export const draw = (addon, props) => renderToString(React.createElement(addon.Component, props))
+17
View File
@@ -0,0 +1,17 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { modelFrom } from '../src/lib/model.js'
test('the model comes from ?model first and keeps slashes, colons and percent signs', () => {
assert.equal(modelFrom('model=org%2Fname%3Av1', 'ignored'), 'org/name:v1')
})
test('without ?model the model is the first path segment', () => {
assert.equal(modelFrom('', 'speechbrain/extra'), 'speechbrain')
assert.equal(modelFrom('x=1', 'speechbrain'), 'speechbrain')
})
test('no model gives an empty string', () => {
assert.equal(modelFrom('', ''), '')
assert.equal(modelFrom(undefined, undefined), '')
})
+248
View File
@@ -0,0 +1,248 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync, readdirSync } from 'node:fs'
import { join } from 'node:path'
import { load, draw, here, hostUi, React, renderToString } from './load.mjs'
import { createApi } from '../src/api.js'
import en from '../src/i18n/en.js'
const read = (p) => readFileSync(p, 'utf8')
const src = join(here, '../src')
const toolsUi = join(here, '../../../internal/tools-ui/src')
const files = (dir) => readdirSync(dir, { recursive: true }).filter(f => /\.(jsx?|mjs)$/.test(f)).map(f => join(dir, f))
const ownText = files(src).map(read).join('\n')
const sharedText = files(toolsUi).map(read).join('\n')
const flatten = (obj, prefix = '') => Object.entries(obj).flatMap(([k, v]) =>
typeof v === 'string' ? [[`${prefix}${k}`, v]] : v && typeof v === 'object' && !Array.isArray(v) ? flatten(v, `${prefix}${k}.`) : [])
const job = (extra = {}) => ({ id: 'job-1234567890', model: 'org/base', backend: 'trl', training_type: 'lora', training_method: 'sft', created_at: '2026-10-18T10:00:00Z', ...extra })
test('page: the first paint has the core test ids of the form and the job list', async () => {
const { mod, sdk } = await load()
const html = draw(await mod.createAddon(sdk), { page: 'jobs', path: '' })
for (const id of ['fine-tune-page', 'fine-tune-form', 'tool-steps', 'tool-checks', 'ft-bar', 'ft-start', 'ft-recipe', 'ft-jobs', 'ft-more-toggle']) {
if (id === 'ft-more-toggle') continue // inside the closed disclosure
assert.match(html, new RegExp(`data-testid="${id}"`), id)
}
})
test('page: a viewer without fine_tuning gets the off notice, no form and no core call', async () => {
const { mod, sdk, calls } = await load({ viewer: { isAdmin: () => false, hasFeature: (f) => f !== 'fine_tuning' } })
const html = draw(await mod.createAddon(sdk), { page: 'jobs', path: '' })
assert.match(html, /data-testid="tool-off"/)
assert.match(html, /Your account cannot fine-tune/)
assert.doesNotMatch(html, /fine-tune-form/)
assert.deepEqual(calls, [])
})
test('page: the API helper uses the core fine-tuning paths and bodies', async () => {
const seen = []
const reply = { ok: true, json: async () => ({}) }
const ctx = {
core: { fetch: async (p, i) => { seen.push([p, i]); return reply } },
json: async (p, i) => { seen.push([p, i]); return {} },
}
const api = createApi(ctx)
await api.fineTune.listBackends()
await api.fineTune.listJobs()
await api.fineTune.getJob('a b')
await api.fineTune.startJob({ model: 'm' })
await api.fineTune.stopJob('j1', true)
await api.fineTune.stopJob('j1', false)
await api.fineTune.deleteJob('j1')
await api.fineTune.listCheckpoints('j1')
await api.fineTune.exportModel('j1', { name: 'n' })
await api.resources.get()
const paths = seen.map(s => `${s[1]?.method || 'GET'} ${s[0]}`)
assert.deepEqual(paths, [
'GET /api/fine-tuning/backends', 'GET /api/fine-tuning/jobs', 'GET /api/fine-tuning/jobs/a%20b',
'POST /api/fine-tuning/jobs', 'POST /api/fine-tuning/jobs/j1/stop?save_checkpoint=true',
'POST /api/fine-tuning/jobs/j1/stop?save_checkpoint=false', 'DELETE /api/fine-tuning/jobs/j1',
'GET /api/fine-tuning/jobs/j1/checkpoints', 'POST /api/fine-tuning/jobs/j1/export', 'GET /api/resources',
])
assert.equal(seen[3][1].body, JSON.stringify({ model: 'm' }))
assert.equal(seen[3][1].headers['Content-Type'], 'application/json')
})
test('page: the dataset upload is a FormData body with no Content-Type header', async () => {
const seen = []
const ctx = { json: async (p, i) => { seen.push([p, i]); return { path: '/x/y.jsonl' } } }
const out = await createApi(ctx).fineTune.uploadDataset(new Blob(['{}'], { type: 'application/json' }))
assert.equal(out.path, '/x/y.jsonl')
const [path, init] = seen[0]
assert.equal(path, '/api/fine-tuning/datasets')
assert.equal(init.method, 'POST')
assert.ok(init.body instanceof FormData)
assert.equal(init.headers, undefined)
})
test('page: a failed upload or call throws the server message', async () => {
const { mod, sdk } = await load({ fetch: async () => ({ ok: false, status: 413, json: async () => ({ error: { message: 'too big' } }) }) })
const { api } = await mod.__partsFor(sdk)
await assert.rejects(api.fineTune.uploadDataset(new Blob(['x'])), /too big/)
await assert.rejects(api.fineTune.listJobs(), /too big/)
})
test('page: the progress stream is read through core.fetch with the abort signal', async () => {
const seen = []
const ctx = { core: { fetch: async (p, i) => { seen.push([p, i]); return { ok: true } } } }
const controller = new AbortController()
await createApi(ctx).fineTune.progress('j 1', controller.signal)
assert.equal(seen[0][0], '/api/fine-tuning/jobs/j%201/progress')
assert.equal(seen[0][1].signal, controller.signal)
})
test('page: the download link carries the base path', async () => {
const { mod, sdk } = await load()
// The base path is read from the <base href> element, as the host reads it.
globalThis.document = { querySelector: () => ({ getAttribute: () => 'http://localhost/base/' }) }
let ExportPanel
try { ExportPanel = (await mod.__partsFor(sdk)).parts.ExportPanel } finally { delete globalThis.document }
const html = renderToString(React.createElement(ExportPanel, { job: job({ status: 'completed', export_status: 'completed', export_model_name: 'my-model' }) }))
assert.match(html, /href="\/base\/api\/fine-tuning\/jobs\/job-1234567890\/download"/)
assert.match(html, /data-testid="export-next"/)
assert.match(html, /href="\/base\/app\/chat\/my-model"/)
})
test('progress: a running job whose percent reads 0 shows an unknown progress, not 0%', async () => {
const { mod, sdk } = await load()
const { JobView } = (await mod.__partsFor(sdk)).parts
const html = renderToString(React.createElement(JobView, {
job: job({ status: 'training' }),
seed: { latest: { status: 'training', progress_percent: 0, current_step: 0, total_steps: 100 } },
onStop() {}, onReuse() {}, onResume() {}, onTerminal() {}, exportCheckpoint() {},
}))
assert.match(html, /data-testid="job-view"/)
assert.match(html, /data-indeterminate/)
assert.doesNotMatch(html, /bt-job__percent">0/)
assert.match(html, /Progress not reported yet/)
})
test('progress: a running job with a percent shows it', async () => {
const { mod, sdk } = await load()
const { JobView } = (await mod.__partsFor(sdk)).parts
const html = renderToString(React.createElement(JobView, {
job: job({ status: 'training' }),
seed: { latest: { status: 'training', progress_percent: 40, current_step: 40, total_steps: 100 } },
onStop() {}, onReuse() {}, onResume() {}, onTerminal() {}, exportCheckpoint() {},
}))
assert.match(html, /bt-job__percent">40<small>/)
assert.match(html, /aria-valuenow="40"/)
assert.doesNotMatch(html, /data-indeterminate/)
assert.match(html, /data-testid="job-stop"/)
})
test('progress: a failed job shows its message and the retry control', async () => {
const { mod, sdk } = await load()
const { JobView } = (await mod.__partsFor(sdk)).parts
const html = renderToString(React.createElement(JobView, {
job: job({ status: 'failed', message: 'CUDA out of memory' }),
seed: { latest: null }, onStop() {}, onReuse() {}, onResume() {}, onTerminal() {}, exportCheckpoint() {},
}))
assert.match(html, /data-testid="job-failed-message"[^>]*>CUDA out of memory/)
assert.match(html, /data-testid="job-retry"/)
assert.match(html, /data-testid="job-try"/)
})
test('export: the export panel and the checkpoint export buttons are for an administrator', async () => {
const done = job({ status: 'completed' })
const seed = { checkpoints: [{ path: '/srv/c1', step: 10, loss: 0.5, epoch: 1 }] }
const admin = await load({ viewer: { isAdmin: () => true, hasFeature: () => true } })
const a = (await admin.mod.__partsFor(admin.sdk)).parts
const adminPanel = renderToString(React.createElement(a.ExportPanel, { job: done, seed }))
assert.match(adminPanel, /id="ft-export-format"/)
assert.doesNotMatch(adminPanel, /data-testid="export-admin-only"/)
const adminJob = renderToString(React.createElement(a.JobView, { job: done, seed, onStop() {}, onReuse() {}, onResume() {}, onTerminal() {}, exportCheckpoint() {} }))
assert.match(adminJob, /data-testid="job-checkpoints"/)
assert.match(adminJob, /data-icon="save"/)
const member = await load({ viewer: { isAdmin: () => false, hasFeature: () => true } })
const m = (await member.mod.__partsFor(member.sdk)).parts
const memberPanel = renderToString(React.createElement(m.ExportPanel, { job: done, seed }))
assert.match(memberPanel, /data-testid="export-admin-only"/)
assert.match(memberPanel, /Exporting a model is for administrators/)
assert.doesNotMatch(memberPanel, /ft-export-format/)
const memberJob = renderToString(React.createElement(m.JobView, { job: done, seed, onStop() {}, onReuse() {}, onResume() {}, onTerminal() {}, exportCheckpoint() {} }))
assert.match(memberJob, /data-testid="job-checkpoints"/)
assert.doesNotMatch(memberJob, /data-icon="save"/)
})
test('dialogs: the stop and delete dialogs are host layers with the core test ids', async () => {
const { mod, sdk } = await load()
const { StopDialogBody, DeleteDialogBody } = (await mod.__partsFor(sdk)).parts
const stop = renderToString(React.createElement(StopDialogBody, { onChoose() {}, close() {} }))
for (const id of ['stop-dialog', 'stop-discard', 'stop-keep']) assert.match(stop, new RegExp(`data-testid="${id}"`), id)
const del = renderToString(React.createElement(DeleteDialogBody, { onConfirm() {}, close() {} }))
for (const id of ['delete-dialog', 'delete-confirm']) assert.match(del, new RegExp(`data-testid="${id}"`), id)
})
test('dialogs: choosing in the stop dialog stops with or without a checkpoint and closes the layer', async () => {
const { mod, sdk } = await load()
const { StopDialogBody } = (await mod.__partsFor(sdk)).parts
const out = []
const tree = StopDialogBody({ onChoose: (keep) => out.push(['choose', keep]), close: () => out.push(['close']) })
const buttons = []
const walk = (n) => { if (!n || typeof n !== 'object') return; if (n.props?.['data-testid']?.startsWith('stop-') && n.props.onClick) buttons.push(n); [].concat(n.props?.children || []).forEach(walk) }
walk(tree)
buttons.find(b => b.props['data-testid'] === 'stop-keep').props.onClick()
buttons.find(b => b.props['data-testid'] === 'stop-discard').props.onClick()
assert.deepEqual(out, [['close'], ['choose', true], ['close'], ['choose', false]])
})
test('chart: the loss chart draws a line from two steps and a waiting note before', async () => {
const { mod, sdk } = await load()
const { LossChart } = (await mod.__partsFor(sdk)).parts
const some = renderToString(React.createElement(LossChart, { events: [{ current_step: 1, loss: 2 }, { current_step: 2, loss: 1.5 }], totalSteps: 10 }))
assert.match(some, /data-testid="job-chart"/)
assert.match(some, /<polyline class="bt-chart__line"/)
const none = renderToString(React.createElement(LossChart, { events: [], totalSteps: 10 }))
assert.match(none, /The chart draws once the job has reported two steps/)
})
test('plural: the job count picks the singular and the plural key', async () => {
const { mod, sdk } = await load()
const { ctx } = await mod.__partsFor(sdk)
assert.equal(ctx.plural('jobs.count', 1), '1 kept on this server')
assert.equal(ctx.plural('jobs.count', 3), '3 kept on this server')
})
test('parity: every data-testid of the core page and chart is in the add-on or the shared views', () => {
const core = ['pages/FineTune.jsx', 'components/tools/LossChart.jsx'].map(f => read(join(hostUi, 'src', f))).join('\n')
const ids = new Set([...core.matchAll(/(?:data-testid|testId)="([^"]+)"/g)].map(m => m[1]))
assert.ok(ids.size >= 20, `found only ${ids.size} test ids in the core sources`)
const mine = ownText + sharedText
for (const id of ids) assert.ok(mine.includes(`"${id}"`) || mine.includes(`'${id}'`), `missing data-testid ${id}`)
})
test('i18n: every literal key the add-on and the shared views read is in en.js', () => {
const keys = new Set()
for (const text of [ownText, sharedText]) for (const m of text.matchAll(/\bt\(\s*'([A-Za-z0-9_.]+)'/g)) keys.add(m[1])
assert.ok(keys.size > 100, `found only ${keys.size} literal keys`)
const missing = [...keys].filter(k => typeof en[k] !== 'string')
assert.deepEqual(missing, [])
})
test('i18n: en.js repeats the host strings and adds only the keys the add-on owns', () => {
const tools = Object.fromEntries(flatten(JSON.parse(read(join(hostUi, 'public/locales/en/tools.json')))))
const own = []
for (const [k, v] of Object.entries(en)) {
if (k in tools) assert.equal(v, tools[k], k)
else own.push(k)
}
assert.deepEqual(own.sort(), ['export.adminOnly.text', 'export.adminOnly.title', 'run.unknownPercent'])
assert.equal(Object.keys(en).some(k => k.startsWith('quantize.')), false)
})
test('style: the stylesheet starts with the shared tools rules unchanged', () => {
assert.ok(read(join(src, 'style.css')).startsWith(read(join(toolsUi, 'tools.css'))))
})
test('source: no file imports a core UI file or the router', () => {
for (const f of files(src)) {
const text = read(f)
assert.doesNotMatch(text, /from\s+['"][^'"]*core\/http\/react-ui/, f)
assert.doesNotMatch(text, /from\s+['"](react|react-dom|react-router-dom|react-i18next)['"]/, f)
assert.doesNotMatch(text, /\bEventSource\b/, f)
}
})
@@ -0,0 +1,32 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { percentOf, ACTIVE } from '../src/lib/progress.js'
test('a final status reads as a known percent: completed is 100', () => {
assert.equal(percentOf({ status: 'completed', progress_percent: 0 }), 100)
})
test('a running job with a percent of 0 is unknown, not 0', () => {
for (const status of ACTIVE) assert.equal(percentOf({ status, progress_percent: 0 }), null, status)
assert.equal(percentOf({ status: 'training' }), null)
assert.equal(percentOf({ status: 'training', progress_percent: 0, current_step: 0, total_steps: 100 }), null)
})
test('a running job with a percent above 0 reports it, capped at 100', () => {
assert.equal(percentOf({ status: 'training', progress_percent: 42.4 }), 42.4)
assert.equal(percentOf({ status: 'training', progress_percent: 140 }), 100)
})
test('a running job with steps but no percent is computed from the steps', () => {
assert.equal(percentOf({ status: 'training', progress_percent: 0, current_step: 25, total_steps: 100 }), 25)
})
test('no event is unknown', () => {
assert.equal(percentOf(null), null)
assert.equal(percentOf(undefined), null)
})
test('a failed or stopped job keeps the percent it reported, and 0 there is a real 0', () => {
assert.equal(percentOf({ status: 'failed', progress_percent: 0 }), 0)
assert.equal(percentOf({ status: 'stopped', progress_percent: 30 }), 30)
})
@@ -0,0 +1,51 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { load, draw, here, React } from './load.mjs'
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(draw(addon, { page: 'nope', path: '' }), '')
})
test('render: the manifest declares one page, the main page, SDK 2 and no navigation or hub entry', () => {
const m = JSON.parse(readFileSync(join(here, '../../manifest.json'), 'utf8'))
assert.equal(m.id, 'fine-tuning')
assert.deepEqual(m.capabilities.ui.pages, ['jobs'])
assert.equal(m.capabilities.ui.mainPage, 'jobs')
assert.deepEqual(m.capabilities.ui.sdk, { min: 2, max: 2 })
assert.equal(m.capabilities.ui.title, 'Fine-tuning')
assert.equal(m.capabilities.ui.hub, undefined)
})
test('render: a registered page is drawn with its path', async () => {
const { mod, sdk } = await load()
const addon = await mod.createAddon(sdk, { pages: { jobs: ({ path }) => React.createElement('p', null, `page:${path}`) } })
assert.equal(draw(addon, { page: 'jobs', path: 'abc' }), '<p>page:abc</p>')
})
test('render: the jobs page is drawn by the real registration path, which binds the JSX helper', async () => {
// A helper that lost its binding throws here: the page draws JSX in every
// component the factories build.
const { mod, sdk } = await load()
const addon = await mod.createAddon(sdk)
const html = draw(addon, { page: 'jobs', path: '' })
assert.match(html, /data-testid="fine-tune-page"/)
assert.match(html, /data-testid="fine-tune-form"/)
assert.match(html, /data-testid="tool-steps"/)
assert.match(html, /data-testid="ft-bar"/)
assert.match(html, /data-testid="ft-jobs"/)
assert.match(html, /Fine-tune/)
assert.equal((html.match(/<h1[ >]/g) || []).length, 1, 'one title')
})
+23
View File
@@ -0,0 +1,23 @@
// Runs the node unit tests of the fine-tuning UI package and prints one JSON line
// per test, the same shape as the host's runner (core/http/react-ui/tests/unit):
// {"case":"unit: <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()
+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()
Loaded 100 of 964 files, more files were not shown because too many files have changed in this diff. Show more