Commit Graph
89 Commits
Author SHA1 Message Date
mudler-agentandEttore Di Giacinto 4899d28e8a feat(chats): server-side chats with steering, sub-agents and compaction (#12918)
* Find the Sandbox guest image in the executable, not its build info

The Sandbox smoke checked that the packaged executable carries the
guest image by looking for -X main.guestImage in go version -m. The
package is built with -trimpath, and with -trimpath Go leaves -ldflags
out of the build info, so the check failed on every gvisor run,
although the image was linked in.

Look for the reference in the executable instead: the value of
SANDBOX_GUEST_IMAGE when it is set, any digest reference otherwise.
package.sh had the same check for an executable built elsewhere; it
now looks for its own reference the same way.

Assisted-by: Claude:claude-opus-5-5

* Answer a network card the limit refuses with one precise reason

A card that waited while the administrator's limit went down was
refused on the first click with 400, and every later click was a bare
409, so a stale card looked like a conflict loop. A refused card now
answers 400 egress_above_ceiling every time, the same answer sent twice
is one answer (204), and another answer to an answered card is 409
already_answered. The chat view marks a card the limit cannot admit
with above_ceiling.

When the limit is off, or below the chat's preset, the note of the turn
and the block line of the proxy now say that the server's setting
blocks the network and no approval changes it, so the model reports it
instead of waiting or retrying.

Assisted-by: Claude:claude-sonnet-5-5

* Explain a blocked Sandbox network instead of offering dead buttons

A card the limit cannot admit shows why network access is off or
limited, and who can change it, with a link to Settings > Tools >
Sandbox network for administrators. The buttons of a card disable while
an answer is sent, and a failed answer reads the card again. The
Network row says why its choices are capped. Settings opens a group
from ?group=. Strings are translated in all nine locales and the chat
and add-on docs describe the new behaviour.

Assisted-by: Claude:claude-sonnet-5-5

* Add an always-allowed hosts list to the Sandbox network

The extra registries setting only widens the package registries, so a
one-off host needs the approval card in every chat. The new admin
setting chats_egress_allowed_hosts lists exact hosts (optionally
host:port to pin a port) that a chat reaches whenever its effective
preset is registries or all.

The list sits in the same decision as the presets: the ceiling still
caps it, the ports setting still applies, and a private, loopback,
link-local or metadata address still needs the private switch and an
administrator's rule or answer. Each use is audited with the reason
allowed_host. Hosts are validated like the extra registries (lower
case, punycode, no scheme, path or pattern, at most 64) and an IP
literal is refused unless the private switch is on.

get_chat_egress and the chat egress view return the list; no MCP tool
writes it, as for the extra registries.

Assisted-by: Claude:claude-sonnet-5-5

* Let users delete from the Trash for good

A delete only set a tombstone, so the Trash grew without end and
nothing could free the space of a deleted file.

The workspace stores gain a Purger: it erases the deleted nodes of
some change sets, or of a whole folder, with their journal rows, the
change sets that nothing is left in, and the blobs that no row refers
to. Both stores implement it and pass the same contract specs. A blob
is removed after a grace period, and only if it is still unused then,
so an upload of the same bytes in flight keeps them.

DELETE /api/chats/files/trash empties the user's Trash and
DELETE /api/chats/files/trash/{rev} deletes one entry. Both are scoped
to the caller's home. The Trash listing now gives files, folders and
bytes per entry and for the whole Trash. The file tools of a chat
cannot reach any of this.

Assisted-by: Claude:claude-sonnet-5-5

* Add Empty Trash and Delete permanently to the Files page

The Trash tab shows what it holds, with an Empty Trash button that is
off while the Trash is empty and a Delete permanently button on each
item. Both ask in the page first and say how many files and folders and
how much space they remove. Texts are translated in all nine locales.

Assisted-by: Claude:claude-sonnet-5-5

* Fall back to Brave Search and Bing when DuckDuckGo asks for a captcha

DuckDuckGo answers some servers with HTTP 202 and a bot check (the
"anomaly" page) instead of results. The parser read that page as "no
results it could read", so web_search failed for every query.

The keyless search is now a chain: DuckDuckGo, then the result page of
Brave Search, then the result page of Bing. Each page has its own
tolerant parser (title, URL, snippet; Bing's /ck/a links and DDG's uddg
links are unwrapped) with saved fixtures.

A bot check is a typed error (ErrChallenged), and a 403 or 429 is
ErrBlocked. Both wrap ErrProvider. A Fallback with a cool-down skips a
challenged or blocked provider for a few minutes, so each search does
not wait for it first; when every provider is cooling down, all are
asked again. Fallback.SearchAttempts and TestEach report what each
provider did (ok, no_results, challenged, blocked, timeout, error,
skipped).

The error a model reads now lists what was tried and what each provider
did, then where an administrator fixes it.

safehttp keeps the status code of an answer (Response.StatusCode) and
of a refusal (StatusError, still ErrStatus for errors.Is).

Assisted-by: Claude:claude-opus-5-5

* Use the keyless search chain in the chats and test each provider

The built-in web_search now searches with DuckDuckGo, Brave Search and
Bing in turn when no SearXNG instance is set. A new runtime setting,
chats_web_search_providers, sets the order (searxng, duckduckgo,
brave_html, bing_html) and leaves a provider out. Empty keeps the old
choice: SearXNG alone when an instance is set, so its queries do not
reach the public engines unless the administrator lists them.

POST /api/chats/admin/web/test asks every provider of the order and
returns what each did in "providers" (ok, no_results, challenged,
blocked, timeout or error, with the reason), next to the first answer.
Settings > Tools has a Test search on the web_search row that shows it,
and the provider list is checked on the page and by the server.

The cool-down of a challenged provider is kept per frontend, like the
cache and the limits; web.md says why.

Docs: web.md (providers, chain, bot checks, privacy, how to fix it with
SearXNG or the Web add-on) and runtime-settings.md. Strings in the
nine locales. Swagger regenerated.

Assisted-by: Claude:claude-opus-5-5

* Try the mirror library alone in the reinstall recovery spec

The spec adds a second library with the same URL and expects an
install from it to keep the retained data apart. Since two enabled
libraries that list one add-on are a conflict, the reinstall from the
original library then failed with library_conflict, and the install
from the mirror failed for that reason instead of the trusted identity.

Switch the original library off while the mirror is tried, check that
the mirror is refused for its identity and not for a conflict, then
switch the mirror off and the original on for the reinstall.

Assisted-by: Claude:claude-opus-5-5

* Pause a chat turn at the step limit and offer Continue

A chat turn ran at most 10 rounds of tool calls, the default of the
harness, because nothing set a limit. A multi-step task (read some
files, run commands, write a result) stopped half way, and the person
saw an answer that looked final.

The limit is now the runtime setting chats_max_iterations (1 to 500,
default 40, Settings > Tools); a model's agent.max_iterations still
wins. When the tool loop reaches it, the harness sends a paused event
with the limit, and the turn ends with last_turn "paused". A paused
turn counts as finished: waiting messages start the next turn and a
sub-agent reports back. The status line says "Paused, after 40 steps"
and has a Continue button, which sends a message that asks the model
to go on.

A claim that found no waiting message released the chat as "done",
which hid a pause. It now keeps the status the chat had.

Assisted-by: Claude:claude-opus-5-5

* Edit the always-allowed Sandbox hosts in Settings > Tools

Add a list editor for chats_egress_allowed_hosts next to the extra
package registries: the hosts are tags with a remove button, an input
adds one on Enter, comma or Add (a pasted list adds them all), and the
reason an entry is refused shows under the input, with the count out
of 64. The same checks stop Apply on a bad entry, and the server still
has the last word.

The chat's Network panel lists the hosts read only and the row says
how many an administrator always allows while the chat's network is on.
The help text of both settings now says how they differ. Strings are
translated in all nine locales.

Assisted-by: Claude:claude-sonnet-5-5

* Document the always-allowed Sandbox hosts

Describe chats_egress_allowed_hosts in the runtime settings page, the
network section of the add-ons page (including the allowed_host audit
reason and how the list differs from extra registries) and the chats
page.

Assisted-by: Claude:claude-sonnet-5-5

* Let a chat edit, pause and delete the person's triggers

A chat could propose a trigger and list them, but not change one: the
Triggers page and the API could. list_triggers now gives each trigger a
stable ref (not its id) and shows the prompt of one trigger only when
asked. update_trigger and delete_trigger store a change record in the
trigger table: an edit or a delete waits for Apply or Delete on a card,
a pause or a resume applies at once with a receipt. An applied change
can be undone for a day; a delete restores the trigger with its id.

Change records are rows in the database, so every frontend shows and
answers the same card. The new routes under /api/chats/triggers/changes
serve the card and are gated by the triggers feature.

Assisted-by: Claude:claude-opus-5-5

* Show the trigger changes a chat asks for as cards

An edit lists each changed field before and after with Apply and
Dismiss, a delete shows the trigger and its last run with Delete and
Keep, and an applied change is a receipt with Undo. The cards sit below
the message text like the proposal card, and read their state from the
server so a reload shows the same answer.

Assisted-by: Claude:claude-opus-5-5

* Add a Test search button to the web tools settings

POST /api/chats/admin/web/test existed, but no page called it and the
docs showed only curl. The web_search row of Settings > Tools now has
a Test search button. It runs one search with the applied settings and
shows the provider with its first results, or the error a chat would
read.

Assisted-by: Claude:claude-opus-5-5

* Describe undo per tool call in the chats docs

The docs said the checkpoint line of a turn carries the change set of
the turn. A turn checkpoint is taken only by a chat with a VM sandbox,
which the service never sets up, so that rev is always 0. What ships
is one change set per file tool call, with a files_change card on its
tool line. The docs now say so and how to undo a whole turn.

Grouping a turn's changes into one change set in the file store is
left for later: it touches the file store's revisions and the undo of
shared folders.

Assisted-by: Claude:claude-opus-5-5

* Mount the skill folders read-only for the built-in bash

The built-in pdf skill ships scripts, but the commands saw only the
home and the shared folders, so the model could read the scripts and
not run them. The built-in bash now mounts the three skill layers
read-only, at the names the file tools already use: shared-skills,
shared-builtin-skills and shared-addon-skills. A shared folder named
skills moves to "shared-skills 2", so the two never collide.

A read-only mount with subfolders made every command report its
folders as changes the mount refused, because the host backend lists
every folder of its work tree. A folder alone is no change: both
stores now refuse only files, whiteouts and opaque folders there.

The Sandbox add-on mounts only the home; the docs say to copy a
script there first.

Assisted-by: Claude:claude-opus-5-5

* Find the add-on that runs commands by what it shadows

The chats knew the add-on that runs commands from a per-process cache,
filled with the Sandbox's own tool name the first time a chat listed
its tools. Another add-on that replaces bash did not count, so skills
that need commands showed as unavailable. The admin network view read
the same cache: after a restart it said no Sandbox serves until
someone chatted, each frontend of a cluster could answer differently,
and a removed add-on stayed listed.

The tool that runs commands is now the one that shadows bash, as the
spec asks. sandboxRunTool and the cache are gone. The stand-in of an
add-on that cannot work carries the built-in tools it replaces, and
the admin view asks the add-on host which add-on serves bash now: the
pinned one or the oldest running install, and none when the pinned one
is stopped.

Assisted-by: Claude:claude-opus-5-5

* Show international host names in punycode in the network checks

The card-spoofing defence of the sandbox network asks for host names
in their ASCII form, but CheckHost passed Unicode names through. A
look-alike name reached the consent card as text that reads like the
real site.

CheckHost now converts an international name with the IDNA lookup
profile and refuses one with no valid ASCII form. The conversion runs
before the address checks, so full-width digits that spell a loopback
address are refused as loopback.

Assisted-by: Claude:claude-opus-5-5

* Keep the seeded Studio and Talk pins until the add-ons are installed

The upgrade pins Studio and Talk for existing accounts, but both are
add-ons that an administrator installs later. The sweep marked a pin
whose add-on is not installed as missing and deleted it after 30
days, so an administrator who installed Studio late found the seeded
pins gone.

The sweep now records the first time a seeded add-on is present, in
the table of seed runs or the pins file. A seeded pin keeps no clock
until then, and a mark that an older sweep set is cleared. After the
first install the pin follows the retention rule as before.

Assisted-by: Claude:claude-opus-5-5

* Fix two small labels of the Skills and Backends pages

On the Skills page, the skill of an add-on that is not chosen said
"Replaced by <add-on>", the label of a built-in skill that an add-on
serves. It now says "Not active: <add-on> provides it", as the add-on
skills spec asks.

On the Backends page, Clear filters on an empty Installed search left
the library filter set, so the list stayed empty. It now clears the
library filter too.

Assisted-by: Claude:claude-opus-5-5

* Tell the model when no network card can appear

Each turn told the model that a blocked host asks the user. No card
appears when the ceiling is off or no wider than the chat's preset, in
an unattended run or in a sub-agent, so the model waited for an answer
that could not come.

The line now says that a blocked host is refused and nothing asks the
user in those cases, so the model names the host it needs in its
answer.

Assisted-by: Claude:claude-opus-5-5

* Translate six Backends and preview labels in every locale

check, reinstallBackend, deleteBackend and filterLabel of the Backends
page and the label and failed text of the file preview showed in
English in the eight other locales. They now have translations, and
the i18n baseline goes down to match.

Also fix a comment of the home composer that still named the removed
Canvas chip.

Assisted-by: Claude:claude-opus-5-5

* List the admin routes that have no assistant tool, with the reason

The admin oversight routes of saved agents, triggers and connectors
had no MCP tool and no recorded reason, and the other deliberate skips
lived only in code comments. coverage_test.go now keeps them in
adminRoutesWithoutTool with a reason each, and a spec checks that no
route there is also wrapped by a tool. The contributor checklist names
the map.

The tool catalog of the assistant docs said it matched the real one
but missed most tools added since. It now lists every tool, read-only
and mutating, and the actions that stay in the web interface.

Assisted-by: Claude:claude-opus-5-5

* Close the doc and registry gaps of the chats and add-ons

- /api/agent/* answers 404 with the chats_off reason when the chats
  are off, as /api/chats/* and /api/workspace/* do.
- The add-ons docs get an upgrade section: Studio and Talk are
  add-ons, nothing is preinstalled, and users need the addons feature,
  which the upgrade does not grant. It says how an administrator
  installs them (also from a private library) and grants the feature,
  and carries a paragraph for the release notes.
- The migration page no longer lists fork, edit, the chat settings and
  the token counters as removed.
- Runtime settings document chats_vision_model,
  chats_transcription_model and the skill keys of
  chats_tool_providers.
- The docs show POST /api/chats/files/folders,
  GET /api/chats/files/history and the allow route of a connector
  sender.
- The in-tree specs get amendments where the shipped design differs.
- A route test of the tagged suite still set the removed Runs field.

Assisted-by: Claude:claude-opus-5-5

* Look for the kit empty state in the backends regression spec

The Backends page now draws the shared EmptyState of the kit
(ui-empty), not the old dk-empty block, so the regression spec of the
gallery suite found no empty state after a search with no match.
Check the kit's no-results form and its title.

Assisted-by: Claude:claude-opus-5-5

* Ignore a features request that outlives its page in the add-on stubs

stubAddonRoutes answers /api/features by fetching the real flags and
turning add-ons on. A spec that ends while the page polls, like the
one that checks that a viewer without the feature never reads the
list, left that fetch in flight, and Playwright failed the spec with
"route.fetch: Target page, context or browser has been closed" after
its checks had passed. Abort the request when the fetch fails because
the page is gone.

Assisted-by: Claude:claude-opus-5-5

* Bring the gallery-ui case inventory up to date

The suite lists the node unit cases of addons.test.mjs and the
Playwright specs of addons-gallery.spec.js. The inventory missed the
unit cases added since it was written, and one spec was renamed when
the legacy switch came in ("hub: the Add-ons tab appears only for an
account that holds the feature" is now "hub: under the legacy switch
the Add-ons tab appears ..."). The list comes from a local run of
both parts against the UI test server.

Assisted-by: Claude:claude-opus-5-5

* Read spreadsheets, documents and slides in read_document

read_document said "file.xlsx has no text layer; it may be a scan" for
workbooks that some Python libraries write. Their workbook gives the
sheet as an absolute part name (/xl/worksheets/sheet1.xml) and the
cells as inline strings. The text extractor of the harness joined
"xl/" with that name, found no sheet, and returned no text; the tool
then took any empty result for a scanned PDF.

The new package core/services/studio/extract has one entry point,
Extract(ctx, name, data), that returns the text and the structure it
read, for read_document now and for a file indexer later. Office files
go through the files preview reader, which resolves both kinds of part
names and reads inline strings:

- a workbook comes sheet by sheet, rows as tab-separated lines, up to
  2000 rows and 50 columns a sheet, with a note for a cut sheet;
- a Word document gives headings, list items, paragraphs and tables;
- a presentation gives each slide with its title, text and notes.

PDF and EPUB keep the harness extractor. Only a PDF with no text is
reported as a probable scan; an empty Office file is reported as empty.
The preview reader now keeps a Word table as one block with its rows,
and the preview pane draws it as a table.

Assisted-by: Claude:claude-opus-5-5

* Add the missing explorer, library and tools translations

Eight locales had no explorer.json, library.json or tools.json. The
browser asks the server for every namespace of the language it shows,
so an Italian page that used the library answered 404 for
/locales/it/library.json on every load and fell back to English.

The 24 files now exist, translated, with every key of English. The
locale gate counted a missing file as missing keys, which the baseline
allowed. It now fails on a missing namespace file whatever the
baseline says, and the baseline is 24 entries shorter.

Assisted-by: Claude:claude-opus-5-5

* Back off while the add-on service is unavailable

A page that followed an add-on upgrade read the operation every two
seconds, up to 300 times. While the add-on service restarted, each read
waited for its timeout and answered 503, so the log of one session had
48 failed dispatches in a row, and the page only showed the upgrade as
running. The Sandbox state of a chat's Tools menu read once and, on a
503, said nothing more.

The reads of an operation now back off while the service does not
answer: 2, 4, 8, then up to 30 seconds. The upgrade entry says that it
waits for the add-on service and when it checks again, in Activity and
in the details, which also get a Check now button. The Tools menu says
that the Sandbox is starting and reads its state again with the same
back-off, kept for the page, so opening another chat does not ask
again before the wait is over.

Assisted-by: Claude:claude-opus-5-5

* Start a chat with its switches and list them before it exists

The composer of a new chat could not show the Tools and Skills menus,
because the lists and the switches belong to a chat. A chat started
from Home therefore always began with every tool and skill on.

- POST /api/chats takes switches: the add-ons, MCP servers and
  connectors, built-in groups and skills that are off, and the opt-in
  groups that are on. They are checked as the PUT endpoints check them
  and stored with the new record, so the chat never exists without
  them; a bad entry refuses the request and creates nothing. The SQL
  store now writes these lists on create too.
- GET /api/chats/draft/tools and /api/chats/draft/addons list what a
  chat on a model, with a saved agent, could use, every switch in its
  default state. They refuse what create refuses: an unknown or
  forbidden model, an agent without the agents feature, another
  user's agent.

Document both in the chats page and regenerate the swagger.

Assisted-by: Claude:claude-opus-5-5

* Give Home the composer of the chat page

Home drew a smaller message box than the chat page: no files, no
agent, no Tools or Skills menus, no paste to attach, no rich model
list, and its text was lost on a reload. A chat started there could
not be set up before its first message.

Move the composer wiring out of the chat page into one place that
both pages use:

- useChatComposer holds the draft (text and files), the model and the
  saved agent of a new chat, the chat models, the switches and what
  attaching and pasting do.
- ChatComposer draws the model list (fit, vision, memory bar), the
  agent picker, the Tools and Skills menus, the attach buttons,
  dictation, the add-on slot and the vision notice over HomeComposer.
- useChatSwitches has a draft mode: before the chat exists it lists
  from the draft endpoints, a switch changes only the screen, and the
  choices go with the create request. They outlive a change of model
  or agent.

Home now creates the chat with the model, the agent and the drafted
switches, sends the first message with its files and opens the chat.
When the message cannot go, the new chat is removed and the text and
files stay on Home. Its text is kept in the browser across a reload.
Home also gets the shortcuts row, the notices of the chat page and the
line that says whether the model is loaded. Its own slash actions,
widgets, examples and first-run steps stay.

The empty chat page gets the same draft menus. On a phone the chips
of both wrap to a second row, and the Tools and agent menus open as a
sheet from the bottom, as the skills picker does.

Assisted-by: Claude:claude-opus-5-5

* Move add-on settings to the add-on's own page

The settings.sections slot drew every add-on's settings at the end of the
global Settings page, and the Sandbox and Web sections were unstyled: the
add-on UIs used class names the host does not define (btn, btn-primary)
and bare inputs, the SDK offered no form primitives, its muted token
named a property that does not exist, and the slot root was an
inline-flex span that laid a form out in a row.

An add-on's settings sections now render on the Settings tab of its
details on the Add-ons page (/app/addons?detail=<id>&view=settings),
narrowed to that add-on, for administrators only. An add-on page gets a
host Settings link to that tab. The Settings page no longer draws the
slot: an Add-ons group, shown when an add-on has settings, lists those
add-ons with a link each. The old #ad-section-<addon>-<id> anchor
redirects to the tab once the apps list is read. Server settings of an
add-on's feature stay on the Settings page; the tab shows them under
"Server settings for this add-on", with a read-only summary of the
Sandbox network and links to ?group=tools#settings-egress and
#settings-builtin-tools.

The SDK gets a settings kit in sdk.ui (Section, Field, Select,
TextInput, Switch, Choice, StatusChip, Callout, List/ListRow, Code,
Actions, Table, ExternalLink), drawn with host classes and tokens, and
more theme tokens. The Sandbox and Web sections are rebuilt with it:
backend rows with a status chip and the raw reason collapsed in a code
block, a backend choice with one line per option, and the AppArmor and
user-namespace hint (with a docs link) only for the gVisor
"exit status 128 ... cannot read client sync file" failure.

The Settings page showed "Changed, default 30, Reset" next to a 0 for the
audit retention, and the same for the Sandbox network limit, preset and
ports: the server stores 0 or an empty value for a setting never set.
Those fields now read that value as their default, and the retention
input has its unit and bounds.

Assisted-by: Claude:claude-opus-5-5

* Adapt the add-on runtime specs to the new navigation

Three specs of the esm-ui suite still assumed the old shell:

- the Add-ons tab that lists the pages add-ons declare is part of the
  legacy layout, so the navigation spec found no hub subnav; it now
  sets the legacy switch on the server's flags for that page;
- the sidebar has no "Chat" link any more, so the unsaved-edits spec
  could not leave the page; it leaves through Home in the primary
  navigation;
- the shell keeps a notification stream open, so the network never
  goes idle and the spec for a member without the feature waited out
  its timeout; it waits for the server's switches, the account and
  the shell instead.

Assisted-by: Claude:claude-opus-5-5

* Name the legacy Add-ons tab unit case as the esm unit tests do

The esm-ui inventory listed "unit: registry: the Add-ons tab lists
page links only when the registry has usable add-ons", but the unit
test is named "the legacy Add-ons tab lists ...", so the suite could
never match its inventory. Use the name the test prints.

Assisted-by: Claude:claude-opus-5-5

* Say on the Collections page when the embedding model is missing

At startup the server opened a collection and its first embedding call
got 404 from POST /embeddings. The collections embed through this
server's own API with collections_embedding_model, the default
granite-embedding-107m-multilingual, and that model was not installed.
Nothing told the user: uploads and searches just failed.

GET /api/collections now also returns embedding_model and
embedding_model_installed (a loaded configuration of that name, or a
model file of that name). When it is missing, the Collections page
says "No embedding model installed", names the model, and gives an
administrator a button that opens the Models page with a search for
it.

Assisted-by: Claude:claude-opus-5-5

* Report modified times and the upload limit in folder listings

The Files page could not sort by date or show when a file changed: a
folder listing had no time. It also learned the upload limit only after
it had sent the whole file. The default server body limit is 15 MB,
lower than the 256 MiB a workspace file may have, so a large upload
failed late and without a clear reason.

Each listed entry now has "modified", the time of the change set of
its revision. Both workspace stores fill it in List: the JSON store
from its in-memory change sets, the database store with one query per
batch of revisions. The listing also has "max_file_size", the smaller
of the workspace limit and the server body limit, so a client can
refuse a larger file before it sends any byte.

The store contract suite checks the new time against the history, and
the route tests check both fields.

Assisted-by: Claude:claude-opus-5-5

* Add a sortable file list with thumbnails for the Files page

The building blocks of the new file browser, used by the page in a
later change:

- fileBrowser.js: the kind of an entry from its extension, the order
  (folders first, natural and case-insensitive names, then size,
  modified time or type), the filter of a folder, dates in words with
  the full date for a tooltip, the Shift range, the arrow moves of a
  list and of a grid, type-to-find, names that do not clash, why a
  name cannot be used, the name of a pasted screenshot and the view
  settings read back from storage.
- FileList: a grid with sortable column headers or a grid of tiles
  with image thumbnails, a check box per row, a row menu, inline
  rename with the reason a name is refused, rows that can be dragged
  onto folders, and the keyboard: arrows, Shift, Space, Enter, F2,
  Delete, Ctrl+A, Escape, Backspace and typing a name.
- MoveDialog: a folder picker that walks the tree and refuses a move
  into the moving folder itself.
- ActionMenu can open at a point with no trigger (a right click), and
  its trigger can take another icon and class (a split button).
- fileSize also writes gigabytes.

Assisted-by: Claude:claude-opus-5-5

* Add an upload queue with progress, cancel and retry

An upload of the Files page was one fetch per file in a loop: nothing
showed while it ran, one failure stopped the rest, and leaving the
page lost it.

- uploadQueue.js keeps every upload outside React: queued, sending
  with the bytes sent and a smoothed rate, done, failed with a reason
  and whether Try again can help, or canceled. Two files go at once.
  It lives for the whole app, so opening another folder or page does
  not stop an upload.
- filesApi.uploadWithProgress sends the same PUT with XMLHttpRequest,
  which reports upload progress and can be aborted. The server already
  streams the body into the store; nothing changes there.
- useUploads reads the queue and asks before the tab closes while a
  file is on its way.
- UploadTray shows the queue at the bottom of the page: the total and
  the time left, each file with its progress, speed and time left,
  Cancel, Try again with the reason, and a done state that fades out.
  A live region tells a screen reader when uploads start, fail and
  end.
- ConflictDialog asks what to do when an upload has the name of a file
  in the folder: replace it, keep both, or skip it.
- dropFiles.js reads dropped folders with their paths.

Assisted-by: Claude:claude-opus-5-5

* Rebuild the Files page as a file browser

The Files page was a list of links: the store's byte order, the same
icon for every file, three icon buttons on every row, no progress on
an upload, no selection, and on a phone a preview that opened below
the whole list.

Browsing: a breadcrumb that folds a deep path, sortable columns (name,
modified, type, size) with folders first, a list or a grid of
thumbnails, both kept per user in the browser, a filter of the open
folder with a search of every file on Enter, type icons, human sizes,
dates in words with the full date on hover, and Back and Forward
between folders.

Uploads go through the upload queue and its tray: the Upload button
or its folder menu, drops of files and folders anywhere on the page or
onto a folder row or crumb, and paste. A clash asks to replace, keep
both or skip; a file over the server limit is refused before it is
sent.

Selection and actions: click, Ctrl and Shift, check boxes, a selection
bar (Download, Move to, Move to Trash; at the bottom of the screen on
a phone), a row menu and a right click, inline rename and new
folder or file, drag to move, the keyboard, and a toast with Undo for
a delete, a move and a rename.

States: a skeleton on the first load, an error with Try again, empty
states that say how files get here, and a no-match state. The preview
uses the side pane: docked on a wide screen, a sheet on a phone, with
the same actions as the row menu. Trash, History and the Managed by
LocalAI group stay as they were.

Every new string is in the nine languages. The page uses design
tokens only (seven raw font sizes fewer). The specs cover sorting,
the filter, the views, selection and bulk actions, the keyboard, the
menu, rename checks, drag to move, drops, paste, clashes, the size
limit, upload progress, cancel and retry against the real server, and
the error states.

Assisted-by: Claude:claude-opus-5-5

* Fix two specs that fail on the integration branch

The Describe spec of the egress package checked that the no-card line
does not contain "asks the user", but that line says "nothing asks the
user". It now checks for the sentence that promises a card.

The context meter spec gave the chat a 4k context window. The tool
schemas of the chat now take 3k of it, so the turn stopped before the
model request. The spec uses a 16k window.

Assisted-by: Claude:claude-opus-5-5

* Translate the always-allowed hosts note of the chat network row

The three egress keys of the always-allowed hosts were only in English,
so the locale check failed for every chats namespace. The workspace
namespaces now miss two keys fewer, so their baseline goes down too.

Assisted-by: Claude:claude-opus-5-5

* Bring the contracts and host-slots case inventories up to date

The contracts suite has eight new specs for the check of a tool that
shadows a built-in. The host-slots suite runs the unit case of the file
preview slot, which the inventory did not list.

Assisted-by: Claude:claude-opus-5-5

* Add the design of the file text index

The search of the chats' files reads only small UTF-8 text files, so a
PDF or an Office file is found by name only. The design extracts the
text when a file is written, keeps it in LocalAI's own store keyed by
node and hash, checks access at query time and runs one indexer per
cluster under an advisory lock.

Assisted-by: Claude:claude-opus-5-5

* Give extracted documents their pages, sheets and slides

Extract now says where each page, sheet and slide starts in the text,
so a search hit can name its page or sheet. A PDF page starts with a
"## Page N" line, as sheets and slides already start with a heading.
A PDF also stops at a page limit (200 by default) and at the text
limit, which it ignored before, and a panic of the reader is an error.

The test fixtures move to extracttest, with a real text PDF and a
workbook with shared strings, so other packages can build the same
documents.

Assisted-by: Claude:claude-opus-5-5

* Add a store for the text of workspace documents

textindex keeps one entry per workspace node: the text read from a
PDF or Office file, the hash it was read from, its pages or sheets and
its status (pending, ready, failed, skipped), plus the cursor of the
indexer in the workspace journal.

The GORM store keeps a lower-cased copy of the text, so SQL and the
Go matcher agree on every script, and searches it with FTS5 and the
trigram tokenizer on SQLite or a pg_trgm index on PostgreSQL. Without
them it scans the column. The JSON store keeps the metadata in memory
and each text in its own file, and scans the texts. One contract
suite runs on JSON, SQLite and PostgreSQL.

Assisted-by: Claude:claude-opus-5-5

* Index the text of new and changed workspace documents

The indexer tails the workspace journal from a cursor kept in the text
store. A write of a PDF, Word, Excel or PowerPoint file marks it
pending (the same hash only follows the path), a delete removes the
entry and a move rewrites the paths below a folder. Then it reads the
pending files two at a time, with a timeout, a size cap, a page cap and
a text cap, and stores ready, failed or skipped. A panic of the reader
fails the file, not the pass. The same content is read once.

The first pass starts at revision 0, which indexes the files that were
there before, in bounded steps that resume from the cursor. Enqueue
marks an upload pending at once and Forget drops the entries of nodes a
purge erased. In a cluster each pass runs under a new advisory lock,
so one frontend reads.

Assisted-by: Claude:claude-opus-5-5

* Search and read the text of documents in the chats' files

search_files and GET /api/chats/files/search now also search the text
index: after the name and text-file hits come lines of PDF, Word, Excel
and PowerPoint files, as path (page 3, line 40): text, with the sheet or
slide where a document has them. Each entry is shown only when the
caller may read its file now and the file still has the content the
text came from. Documents keep up to a third of the hits. When files
below the path are still pending, the answer says so instead of
"Nothing found.", and the REST result has indexing.

read_file returns the text of a PDF or Office file with the usual offset
and limit, from the index when it matches the file and read on the
spot otherwise. An upload and a write of a chat tool queue the file at
once, and emptying the Trash drops the entries of the purged files.

Assisted-by: Claude:claude-opus-5-5

* Start the file text index with the files and add its settings

With files on, the server opens the text store next to the workspace
index (the auth database, or a folder under the data path) and starts
the indexer. A chat worker searches the index but runs no indexer.

chats_index_files (default on) switches the reading off and
chats_index_max_text_bytes (2 MiB by default, 64 KiB to 16 MiB) caps
the text kept for one document. Both apply at the next pass, and both
are rows of Settings > Tools.

Assisted-by: Claude:claude-opus-5-5

* Show the page, sheet or slide of a document hit on the Files page

Search all files now lists hits inside PDF and Office files with their
page, sheet or slide instead of a line number of the extracted text,
and says how many documents are still being indexed. The strings are
in all nine locales.

Assisted-by: Claude:claude-opus-5-5

* Test finding an uploaded PDF by its words through the routes

The PDF has a generated name and is uploaded with the REST upload. The
search route first reports it as being indexed, then finds it on its
page after a pass of the indexer, and so does the search_files tool of
a chat. Another user finds nothing.

Assisted-by: Claude:claude-opus-5-5

* Document the search inside documents

The chats page says what the index reads, where the text is kept, who
finds it, which search engine each database uses, the two settings,
the backfill and the single-runner mode in a cluster, and how
search_files and read_file answer for documents. The API instructions
and the PDF skill say the same in short.

Assisted-by: Claude:claude-opus-5-5

* Test the indexer on the JSON workspace store

A server without a database keeps the workspace and the text in JSON
files. The indexer follows the uploads, a folder move and a delete in
that journal as it does in the database one.

Assisted-by: Claude:claude-opus-5-5

* Read all of a long document with read_file

The index keeps the first chats_index_max_text_bytes of a document, so
read_file used to stop where the index stopped. It now uses the index
only when it holds the whole text, and reads the document again (up to
16 MiB of text) when the index cut it.

Assisted-by: Claude:claude-opus-5-5

* Start bash in the user's home

The Sandbox's bash and the built-in bash started in /workspace, the
folder that holds the mounts, so a model that ran a script by a
relative path failed and a file it saved there was lost. A call now
starts in /workspace/home, the folder of the user's files: a relative
path is the same file the file tools see. The tool descriptions and
the commands block of every turn say where a call starts and that
only what is saved there is kept. The built-in bash says that the
/workspace paths do not exist on the host and that a shared folder is
next to the home.

Assisted-by: Claude:claude-opus-5-5

* Name files in tool results by the path the model uses

write_file, edit_file and search_files named a file of the home by its
path in the home ("/scripts/build.py") while the model had written
/workspace/home/scripts/build.py, and a model took the two spellings
for two files. The results now give the path the model uses, and
move_file says where it moved the file to. The cards and the change
notes keep the path in the home.

Assisted-by: Claude:claude-opus-5-5

* Name the paths a command's commit did not save

The result of a bash call ended with "N file(s) changed in your files
meanwhile; the sandbox's version was kept as a copy" for every path the
commit refused, also for a file written outside the user's files,
where no copy is kept. It now has one line a path, with the path as
the guest sees it, the reason, and the path of the copy when the store
kept one, so the model and the user can act on it. The host returns
the paths and the copy to the Sandbox add-on relative to the user's
files, as its mounts are.

Assisted-by: Claude:claude-opus-5-5

* Show the files a command saved under its step

A document that a script wrote with bash reached the user's files, but
the chat showed nothing: only the file tools put a file on their step,
and the Sandbox committed for no chat, so the chat's file list, the
chips under the answer and the preview pane never saw the file.

The host now commits a Sandbox call for the chat that ran it (the
action resource of the request names the chat). After each bash call,
of the Sandbox or of this machine, the chat reads the change sets of
the chat that the call made from the shared store and puts the files
on the step's card, next to the card that says who served the call
(session.SetToolFiles; files with path, change set and op on the
card's new files field). The step lists them as chips that open the
preview pane, the chat's clients are told that its files changed, and
the files reach the file list, so the answer gets its chips and the
pane opens on the newest one, as for write_file.

Assisted-by: Claude:claude-opus-5-5

* Keep JSON out of the answer of a trigger run

The workspace skill told the model to put the widget data shape and a
source marker in a trigger prompt. Some proposals then asked the run to
print that JSON, and every run ended its chat with a stray line of it.

The skill now says source and note live inside the data file and are
never the answer, and that a prompt must not ask for JSON. The runner
appends one closing instruction to the first message of every trigger
run: finish with one short plain sentence. The propose_trigger and
update_trigger descriptions say the prompt must stand alone and must
not ask for JSON. Connector chats are talks with people and do not get
the line.

Assisted-by: Claude:claude-sonnet-5-5

* Read the images of a page as base64 for its frame

The bridge of a page reads UTF-8 text only, so the UI could not put a
png or a jpg of the page's folder into the page. A read of the page
file routes with encoding=base64 now returns an image as base64 with
its media type. Only the extensions of an image are read this way
(png, jpg, gif, webp, avif, bmp, ico, svg); any other file is 415, so
this is no way to read an arbitrary binary file. The path checks, the
1 MiB limit, the viewer's rights and, for a shared page, the consent
check are the same as for a text read.

Assisted-by: Claude:claude-opus-5-5

* Let a page use the files next to it

A page runs from a srcdoc with no network, so fetch('news.json'),
<script src="app.js">, <link rel="stylesheet" href="style.css">,
<img src="logo.png"> and import './x.js' all failed without a word.
Models that split a page into files, or load its data with fetch,
made pages that showed nothing.

Before the frame loads, the UI now reads the files that the page names
with relative URLs, with the same calls as the bridge, and puts them in:
classic scripts in place (deferred ones at the end of the body), style
sheets as <style> with their @import and url(), images as data: URLs,
and ES modules as blob: URLs behind an import map, so cycles and
import() work. A page with modules gets blob: in script-src; no other
part of the policy changes. Inside the frame, fetch and XMLHttpRequest
read relative URLs with files.read and answer with a real Response (200,
404 for a missing file, 413, 415); any other URL rejects with "network
is off in pages; put the data in a file next to the page".

Every path is resolved inside the page's folder: .., %2e%2e, absolute
paths, backslashes and protocol-relative URLs are refused, and the
query and the fragment are dropped. The files go in only after a shared
page is allowed, with the viewer's rights. The files that go in are at
most 4 MiB, images 2 MiB; a script or style sheet that does not load
shows a notice in the page. A change of one of these files reloads the
page, as a change of index.html does.

The skills, the docs and the shared pages fixture say and test the same.

Assisted-by: Claude:claude-opus-5-5

* Return tool descriptions from an MCP connector test

A connection test listed only the tool names. A person who adds a
server cannot tell from a name such as "query" what the tool does or
whether to keep the connector on.

The test response now also has tool_details: each tool with the
description its server gives, in the order of tools. tools is
unchanged, so existing clients keep working.

Assisted-by: Claude:claude-opus-5-5

* Add jq, ripgrep, sqlite3 and archive tools to the Sandbox guest

The guest user cannot install packages and apt is blocked by the proxy,
so any tool the model needs has to be in the image. Add jq, ripgrep,
sqlite3, zip, unzip, file and tree to the final stage.

A new check-tools.sh runs in the existing build step and fails the build
when one of these programs, Poppler, git or curl is missing or does not
start.

The list is one constant, GuestTools, in the shared sandbox package. The
Commands block of the prompt of an isolated chat names it. The tool
description (capped at 400 characters, already full) and the turn note
(cut at 400 characters) have no room for it. Tests keep the Dockerfile,
the check script and the Sandbox documentation in line with the constant.

Assisted-by: Claude:claude-sonnet-5-5

* Keep the helper files of a chat in its work folder

A chat asked for a Word document left a 19 KB build script at the top of
the user's home, next to the user's own files. Chats like it leave
scripts, intermediate data, logs and drafts at the top level or in
folders such as scripts/, and nothing tells them apart from what the
user asked for.

Each chat now has a work folder, Chats/<chat folder>/work/ in the
user's home, next to its attachments. The chat takes the name of its
folder at its first turn (checked against the folders and the other
recent chats of the user, so two chats with one title get two names)
and makes the folder only when it is used: before a bash command runs,
or when a file is saved there. The prompt of every turn names the
folder and says what goes in it: scripts, helper code, intermediate
data, logs and drafts go there; the deliverable goes where the user
said or at the top of the home; no new top-level folders for scripts;
a run-once command needs no file. A sub-agent is told its parent's
folder. The descriptions of bash (the Sandbox's, within its 400
characters, and the built-in one) and of write_file end with one short
hint, and the built-in office, apps and workspace skills say the same
in one line.

The steering is soft. When write_file, move_file or a command leaves a
helper file outside the work folder, the result gets one line that
suggests moving it, at most once a turn of a chat.

One function (workspace/workfiles) decides the kind of a file:
working for anything in a chat's work folder, or for a script, a log
or a temporary file by its extension or a folder named scripts, tmp or
scratch; deliverable for everything else, and always for pages, the
LocalAI folder, the shared folders and the files the user attached.
The chat's file list (kind), the files of a command's card
(files[].kind) and the card of a file tool (file_kind) carry it, so the
interface only renders it.

The runtime setting chats_work_folder (on by default) turns the
steering off.

Assisted-by: Claude:claude-opus-5-5

* Stop telling the model that the Sandbox has npm

The guest image has no Node, so the network hints named a tool that
does not exist.

Assisted-by: Claude:claude-sonnet-5-5

* Update the Sandbox description test after dropping npm

Assisted-by: Claude:claude-sonnet-5-5

* Open the links of a page after the person confirms them

A page runs in a frame without allow-popups or top navigation, so a
click on <a href="https://..."> did nothing, and a link to #id loaded
LocalAI's own address in the frame, since a srcdoc resolves it against
the parent. A news page had only dead links.

The bridge of the frame now takes the clicks on links (also with a
modifier, the middle button or Enter) and window.open(), and sends
link.open(url) to the host. The host lets through only an http or https
address without a user name or password, at most 2048 characters;
javascript:, data:, file:, blob:, //host and links to other files of
the page are refused with a message in the console. A bar above the
page shows the site in large type, the whole address, and Open and
Cancel. A host in punycode also says how it reads, since it may imitate
another site. Open is a click in the app, so the new tab is not blocked
and opens with noopener and noreferrer. A page asks at most 5 links at
once, then one every 3 seconds. A link to #id scrolls the page.

The sandbox, the policy of the frame and the consent of shared pages do
not change; a shared page asks the same way.

Assisted-by: Claude:claude-opus-5-5

* Let the items of a Home widget link

A list widget showed each item as plain text, so a digest of articles
or a list of pages could not take the person anywhere. An item now has
an optional url. An http or https address without a user name opens in
a new tab with noopener and noreferrer, and its tooltip names the site;
a path of the app (/app/pages, /app/chats, /app/files and the other
screens a person reads) opens inside LocalAI. The label is the link,
with a focus ring, an underline on hover and focus, and an icon for an
address outside the app.

The widget-data schema checks the url, and the server adds an error in
words that names the item and the rule, so a trigger run that writes a
bad link learns why. The skill and the docs show the field.

Assisted-by: Claude:claude-opus-5-5

* Attach any file in a chat with one button

The chat had three attach buttons (image, audio, file) that all add to
the same list, and the file button's dialog filtered on */*, which
some file dialogs read as a type filter that greys out archives such as
.zip. A chat now shows one Attach file button whose dialog has no type
filter. Composers without anyFile keep the three buttons.

Assisted-by: Claude:claude-opus-5-5

* Accept both Node behaviours for a broken punycode label in the link test

Node 22 refuses the address, Node 24 parses it. Both mean the page never
opens it unwarned.

Assisted-by: Claude:claude-sonnet-5-5

* Show a chat's working files apart from what it made

The Files pane, the steps and the preview treated a helper script like
the document the user asked for: a 19 KB build script was listed next
to the document, got a chip under the answer, and the preview opened on
whichever file was newest.

The interface now renders the kind the server puts on each file. The
pane lists what the chat made first and folds the working files into
one quiet heading under the rest, "Working files · 2", with a line that
says what they are; opening it shows their rows, which preview like any
file. A working file opened from a step unfolds the group. The preview
that opens by itself after a turn picks the newest file that is not a
working file, and the chips under the answer name only those files. A
step that saved working files lists them as one line, "Also saved 2
working files", that opens to their chips, and the file of a write_file
step that is a working file is a quieter chip. The files the user
attached are unchanged.

Assisted-by: Claude:claude-opus-5-5

* Add a search field and a segmented filter to the UI kit

The Agents views each drew their own toolbar: a search box capped at
320 px on the agents list, round chip filters on the triggers history,
and a bare checkbox on the tasks page. Add-ons already had the look the
product wants (a search that fills the row and a segmented tray), but
only as add-on classes.

SearchField and Segmented are that toolbar as two small primitives with
ui-* classes and token values only. Segmented buttons carry their id in
data-filter, so specs can press them. The README shows how to use them.

Assisted-by: Claude:claude-opus-5-5

* Draw an agent task run as a history row with its output

The jobs of an agent task were table rows that opened to bare <pre>
blocks: no copy, parameters printed as k=v, the traces in a raw
<details>, and "by manual" in the server's words.

RunItem is one run of a history list: a toggle, the state chip, what it
was and what happened, when and for how long, small actions, and the
details that open under it. OutputBlock draws an answer or an error in
mono with a Copy button. JobRow and JobDetail use them for a job: the
values it ran with as labelled chips, the answer or error, the steps,
the webhook outcome and a link to the chat that holds the run. Times
show as words with the full date as a tooltip.

The agent task helpers gain the pure rules the page needs: the jobs of
a task, the run filters (waiting and running count as in progress), the
on/off filter and search of the task list, the field errors of a save,
how a job started, and the API call that runs a task by name.

Assisted-by: Claude:claude-opus-5-5

* Create, edit and run agent tasks in a side sheet

The task form opened as bare fields above the list, with a checkbox for
On and a Save button that was only disabled when a field was missing.
Run with parameters was a second inline form in the same place.

TaskSheet is a side sheet with three labelled groups (about, who runs
it, what it does), help under each field, the parameters the prompt
asks for as chips while you type, and an On switch. Save checks the
required fields, says next to each one what is missing, and moves the
focus to the first. RunSheet asks for each value the prompt names and
shows the prompt as the task will receive it.

Assisted-by: Claude:claude-opus-5-5

* Show the details of an agent task in a pane with tabs

A task had no details view: its prompt, its runs and the way to run it
over the API were only in the lede of the page.

TaskPane is the kit's side pane with three tabs. Overview shows who
runs the task, the values it asks for, the prompt and a curl call that
runs it by name, with Copy. Runs lists its jobs. Settings holds the On
switch, the webhook count and Delete. Run and Edit stay in the footer.
The tabs move with the arrow keys, Home and End.

Assisted-by: Claude:claude-opus-5-5

* Rebuild the agent tasks page from the shared list pieces

The tasks page looked unstyled next to Add-ons and the agents list: two
raw tables under uppercase eyebrows, a bare checkbox for All users,
forms that opened above the list, three text buttons on every row, and
on a phone a table that broke names one syllable per line.

The page now uses the same pieces as the polished pages. The header has
one line and New task. A toolbar holds the search, an All/On/Off filter
and, for an administrator, an All users switch. Each task is an agent
row: a tile, the name, two lines of description, who runs it and what
it asks for, the last run's state and time with the run strip, an On
switch, Run and a menu. A task with no parameters runs at once. The
name opens the details pane; New task, Edit and Run open the sheets.
The run history is grouped by day, with a state filter, a task filter
and Show more. A job is deleted after an in-page question. With no
tasks the page explains what one is, with an example; a failed load
shows the error with Try again. On a phone the controls grow to 44 px
and the filters scroll sideways.

New strings, and the task strings that existed only in English, are in
all nine locales; the strings the page no longer uses are gone. A new
stubbed spec covers the list, filters, sheets, pane and history, and the
real-server spec follows the new controls. The docs describe the page.

Assisted-by: Claude:claude-opus-5-5

* Rebuild the MCP connectors page on the Add-ons patterns

The page read as a form: every connector was a row of raw controls (a
checkbox, a Test button, red Delete text), the add form pushed the list
down, a test result vanished on reload, and the frame was narrower than
the pages next to it in the sidebar.

The page now uses the same blocks as Add-ons and Files:

- Full page width and the shared PageHeader with a one-sentence purpose
  and "Add connector".
- The Add-ons toolbar (search and a state filter with counts) from four
  connectors, and the Add-ons rows: a monogram tile, a state chip
  (Connected, Needs sign-in, Can't connect, Not tested, Off), one line
  with the tools or the reason, a meta line, the kit Switch and a menu.
- Details in the shared SidePane: the switch, the last test, the tools
  with their descriptions and a search, the connection with the query of
  the address hidden, and what is saved.
- Add and edit in the shared SideSheet: kind first, grouped fields with
  inline checks, masked secrets with show and hide, then "Save and
  test", the result with the tools, and Turn on. Escape and Cancel ask
  before they drop typed changes.
- An empty state that says what a connector is for.

The last test of each connector is kept in this browser with a hash of
what it tested, never the address or a secret, so the page shows the
last known state after a reload. Strings are in all nine locales.

Assisted-by: Claude:claude-opus-5-5

* Document the MCP connectors page and its states

The MCP servers section now describes the add sheet (kind, checks,
masked secrets, Save and test, Turn on), the states a connector shows,
where the last test is kept, the search and the filter, and the details
pane. The test example shows tool_details.

Assisted-by: Claude:claude-opus-5-5

* Render a preloaded page without its loading placeholder

A page that was preloaded still showed the route's loading placeholder
when it opened: React.lazy suspends once on its first render even when
the chunk is already in memory, and React keeps a fallback on screen
for at least 300 ms once it shows one. Opening a chat from Home showed
a blank page for that time.

The page loader now remembers the module once it has loaded and hands
it to React.lazy as a thenable that resolves at once, so a preloaded
page renders in the same commit. A preload of a segment now warms
every page registered under it: /app/chats is the chat page, its
connectors and its conversion page, and before only the last one
registered was preloaded.

Assisted-by: Claude:claude-opus-5-5

* Move the composer from Home into the new chat in one gesture

Sending the first message on Home was a hard cut. The page stood still
while the chat was created, then showed a blank placeholder, the empty
"new chat" screen with its composer in the middle, an empty thread
with the composer at the bottom, and only then the message, while the
sidebar folded and moved the whole column. Focus was lost.

Now Send fades the rest of Home and keeps the composer in place while
the message goes. When the send has answered, a view transition moves
the composer to the dock of the chat, lifts the text out of it into
the message, moves the attached files with it and folds the sidebar in
place; the model's indicator comes in after the message has landed and
the empty composer has the focus. The chat page draws the message from
what Home already knows (a small hand-off store: the chat record, the
text and the files, with the local copy of an image for its thumbnail)
and keeps the same row when the server's transcript replaces it, so
nothing is drawn twice or animates again. What is sent to the server
does not change.

A browser without view transitions gets a FLIP slide of the composer
and the message (Web Animations, transform and opacity only). Less
motion gets a short cross-fade, or an instant switch. A send that fails
brings Home back, shakes the composer and keeps the draft; a second
Enter while one send is in flight sends nothing. The draft is cleared
when Home closes, so the box never shows its placeholder on the way.

Focus mode folds the sidebar in a layout effect, so the fold happens in
the frame the conversation appears. One motion token is added,
--dur-glide (360 ms), for a part that travels to its place on the next
page.

Assisted-by: Claude:claude-opus-5-5

* Draw the triggers as rows and their history like the task runs

The triggers page used a table with a switch column next to the agents
card list, folded its list behind Hide, filtered the history with round
chips, and on a phone cut each history row at the right edge.

Each trigger is now an agent row: a clock or folder tile, the name, the
schedule in the server's words, the agent or model and when it runs
next, the last run's state and time with the run strip, an On switch,
Run now and a menu. The week sentence sits under the Run history title.
The history uses the segmented filter and the same run rows and day
headings as the agent tasks, with Show more after 30 runs; an open run
shows its reason with Copy. On a trigger page the prompt comes before
the runs, in the body face, the On switch is the kit switch, and the
runs stop at 10 with Show more.

Assisted-by: Claude:claude-opus-5-5

* Use the shared search and the full width on the agents list

The agents list stopped its search at 320 px and used the content width,
while the Triggers and Tasks views next to it use the full width, so the
title and the view tabs moved when you switched views.

The list now uses SearchField, which fills the toolbar row as on
Add-ons, and the full page width like its sibling views.

Assisted-by: Claude:claude-opus-5-5

* Polish the agent task sheets and pane after a visual review

Screenshots of every state showed a few rough edges:

- The name placeholder looked like a filled-in value next to the error
  "Give the task a name". The name field has no placeholder now, and
  the prompt placeholder starts with "For example:".
- A task run by an agent showed the agent id until the agents were
  read. It says "Agent" until then.
- A run opened in the details pane also opened in the history, and the
  two copies shared an element id. The pane keeps its own open runs and
  ids.
- A failed run in the pane drew its meta line in red; the title carries
  the error colour there now.
- A group title that is not a legend had a double gap under it, and the
  kebab menus were under 44 px on a phone.

Assisted-by: Claude:claude-opus-5-5

* Cover the code files of a shared page in its consent

The consent to a shared page was kept against the hash of index.html
and page.json. The UI also puts the scripts, style sheets and modules
next to the page into it before it runs, so the author could change
app.js after a viewer allowed the page and run new code without asking
again.

The server now finds the same files as the UI's inliner: every script
src, stylesheet link, relative CSS @import and module import of the
folder, followed through the module graph. Their content hashes go into
the hash of the page, and a file the page names that is missing counts
too, so adding it later also asks again. Data files and images stay
out, so saving data does not ask again. The files are listed as
code_files. What a file names is kept per content hash in a small
per-frontend cache, which only saves reads.

Two more checks close the gaps around it. A frame of an older version
that reads a changed file of code gets 409, so a script edited while
the page is open never runs under the old consent. The UI puts no text
file into a shared page that is not in code_files, so if the server and
the UI ever disagree, the file fails to load instead of running outside
the consent. The trust sheet names the files of code when only one of
them changed.

Assisted-by: Claude:claude-opus-5-5

* Let a page be a tile on Home in its compact mode

Home widgets could show a list, a number or the last chats, but not one
of the pages a person or the assistant built. A page can now be a tile.

A page opts in with a widget block in page.json: a size (small, medium
or large, the height of the tile) and an optional short title. A
widget of type page in LocalAI/home.json names the page, and a folder
id for a page of a shared folder. The server takes at most three page
widgets and says why in words, and refuses a name that is not a page
name.

The tile runs the page in the same sandbox and bridge as the full
view. The page reads localai.page.mode ("widget" in a tile, "page"
otherwise) and localai.page.size, and <html> carries data-mode and
data-size, so the page decides how its tile looks instead of being
shrunk. The tile reserves the height of its size, builds its frame only
once it scrolls into view, tells the page through a new visibility
event when it goes out of sight or the tab is hidden, takes the frame
down far off screen, and keeps at most three frames alive. It has a
title bar with Open page and a refresh button, a skeleton while it
loads, Retry on an error, and the link question of the full view. A
page that is gone or has no widget block shows a calm line with a link
to Customise Home; a shared page that the viewer has not allowed shows
Allow this page, which opens the same trust sheet.

Customise Home lists the pages with a widget block, with the size of
their tile. The task board starter has a compact view, the skills tell
the assistant how to put a page on Home, and the docs describe it.

Assisted-by: Claude:claude-opus-5-5

* Give the Files upload its own size limit

The Files page upload was capped by the general --upload-limit (15 MB
by default), although a workspace file can be 256 MiB.

Make 0 the default of --upload-limit, meaning not set. When it is not
set, the general routes keep the 15 MB limit and the Files upload route
skips it, so the workspace file size is its limit. When the operator
sets a value, it applies to the Files upload too, lower or higher (a
file never exceeds the workspace size). The max_file_size of a folder
listing reports the limit in force.

Assisted-by: Claude:claude-sonnet-5-5

* Refuse writes of code files from a page

A page loads its own scripts, modules and styles, so a page that could
overwrite app.js or style.css would run new code on its next load. The
page bridge now writes data files only.

The server refuses a write whose final segment has a code or document
extension (js, mjs, cjs, css, html, htm, svg, xml, xhtml, wasm), is named
index, or is page.json, with 403 and a clear message. The check applies
to the page routes only; the Files page and the assistant's file tools
keep full write access. The judgement lives in pages.IsCodeFile and is
mirrored by isCodeFile in the web host, which refuses early with the
error code forbidden. Both run one table of cases, covering case, double
extensions, trailing dots and spaces, percent-encoding, backslashes and
lookalike dots.

Assisted-by: Claude:claude-sonnet-5-5

* Make the trigger tools a built-in group a chat can switch off

propose_trigger, list_triggers, update_trigger and delete_trigger were
added to every chat a person starts, with no switch. They are now four
entries of the built-in tool registry in one group, triggers, so the
Tools menu, the tools panel, the draft list and the builtins switch
endpoint handle them like the web group. The group is on by default.

A trigger or connector run and a sub-agent still never get them. The
panel lists the group as not usable, with a reason, for those chats,
for a user without the triggers feature, and not at all when the server
runs no triggers. The group has no runtime setting, so the registry
gets an Unlisted flag and Settings > Tools skips it. The panel now
builds every grouped tool to learn whether it is usable, not only the
opt-in ones.

Assisted-by: Claude:claude-sonnet-5-5

* Show the Triggers group in the chat Tools menu

The menu rows come from the groups the server lists, so the row needs
only its label in the nine locales. A Playwright spec covers the row,
its switch request, Select all and Disable all, and the disabled row
with its reason.

Assisted-by: Claude:claude-sonnet-5-5

* Talk about settings, not the files behind them

The settings cards, the shortcuts row, Home and the change notes in a
chat named the files that hold a person's settings: "Defined in
LocalAI/shortcuts.json", "Layout is in LocalAI/home.json", "Saved
LocalAI/persona.json". A person changes their shortcuts or their Home,
not a file, and the Files page still shows the files to whoever looks.

The "Stored in", "Defined in" and "Layout is in" lines are gone; the
history line keeps Versions, Revert and the Invalid badge. The notes
say what changed ("Saved your shortcuts.", "Created the page Task
board."), the errors name the setting or the widget instead of a path,
and the Source view says "The settings as text" instead of JSON and
schema. A failed save now says what happened in one plain sentence and
keeps the server's own words under a collapsed Details. The drafts that
"Edit with the assistant" puts in the composer no longer carry the
path; the built-in workspace skill already maps shortcuts and Home to
their files.

All nine locales are translated; only the changed keys moved, and four
keys that nothing reads any more are removed.

Assisted-by: Claude:claude-opus-5-5

* Drop internal names from the skills, add-ons and tasks copy

Other pages used words that only make sense to someone who knows how
LocalAI is built:

- Skills said "a folder with a SKILL.md" and "the file workspace". It
  now says what a skill is for, and that skills need Files.
- The add-on notes blamed "the owner", the internal name of the server
  that runs add-ons. They now say what happened to the action, or that
  the add-on service is not available; a private library names a
  credential "an administrator configured on this server".
- The Tasks header listed an API route, and the first-run wizard spoke
  of a "host bash fallback". The memory line of Settings no longer
  mentions "the resources endpoint".

All locales that have these keys are translated. The skill delete
dialog, the unused skills intro and the Tasks header exist only in
English and change only there.

Assisted-by: Claude:claude-opus-5-5

* Fail the unit tests when UI copy names implementation details

A new test reads every string of every locale and fails when one names
a settings or data file (.json, .yaml, LocalAI/), an API route or HTTP
method, a setting key (chats_*), an environment variable, a format
word (JSON, YAML, schema) or a path shown as code. Plain words such as
"schema" and "endpoint" are checked in English only, since other
languages use them for other things.

Operator pages may still name the model configuration, the metrics
route or a startup setting that the docs name. Those keys are listed in
ui-copy-allowlist.json, each with the audience and the reason. The list
only shrinks: an entry whose string no longer needs it, or whose key is
gone, fails too.

Assisted-by: Claude:claude-opus-5-5

* Name the triggers group in the chat tools routes

The routes that list and switch the built-in groups of a chat said only
"a name such as web". They now name all three: web, triggers and
saved_agents, with the tools of each.

Assisted-by: Claude:claude-opus-5-5

* Make the built-in skills answer in plain words

The skills told the model to report file names, folders and formats
after a change. Add one rule near the top of localai-workspace and
localai-apps: do the work with files, but say what changed, and name a
file only when the user did or asks. Replace the example replies that
carried paths, keep every path that says where to write, and pin the
rule with a test.

Assisted-by: Claude:claude-sonnet-5-5

* Apply the Go 1.26 modernizers to the latest Studio code

go fix suggested slices.Contains, strings.SplitSeq and range-over-int
on lines added since the first pass. ParseSearchProviders had the same
hand-written membership loop, so it uses slices.Contains too. Only
lines that the branch added since then are changed.

Assisted-by: Claude:claude-opus-5-5

* Share the blob wait and the counting of the two workspace purges

The JSON and GORM stores each had the 2-minute default for the blob
grace, the same switch that reads the BlobGrace option, and the same
loop that counts files, folders and bytes per revision. They now call
blob.Grace and PurgeResult.Add, both with specs. The SQL and the
in-memory walk that pick the nodes stay in each store.

Assisted-by: Claude:claude-opus-5-5

* Read the upper layer of a sandbox in one place for both stores

The JSON and GORM workspace stores had the same readUpper, place,
joinRel, depth and sortedKeys, with the same upperSet types. They move
to workspace/internal/upperset, which has its own specs. The commit
algorithm that applies the set to the nodes stays in each store; it
needs a storage interface first.

Assisted-by: Claude:claude-opus-5-5

* Let chat attachments use the 1 GiB message limit

The docs promise 1 GiB for a message with attachments, but the general
body limit (15 MB by default) ran first, because only the Files upload
skipped it. Skip it also for a multipart POST to the messages route
when the operator set no --upload-limit. A JSON message keeps the
general limit, and an explicit --upload-limit still wins. A message
that goes over the 1 GiB limit now gets 413, not 400.

Assisted-by: Claude:claude-opus-5-5

* Name the read_file tool in the skills index header

A weaker model read a built-in skill with the wrong path and then tried
the Sandbox, which cannot see skill folders. The header now says which
tool reads a skill and that the shown path is the one to use, before it
mentions where the user's own skills go.

Assisted-by: Claude:claude-sonnet-5-5

* Tell the model that skill folders are not in the Sandbox

The Commands block of an isolated chat now says that skills are read
with read_file. The Sandbox tool description and the per-turn note are
at their size caps, so the sentence goes in the system prompt.

Assisted-by: Claude:claude-sonnet-5-5

* Point read_document at read_file for text files

read_document reads PDF, Word, Excel, PowerPoint and EPUB files. For a
Markdown or text file it said only that the file is not a document it
can read. The error now names read_file.

Assisted-by: Claude:claude-sonnet-5-5

* Tidy the text index stores and the indexer pool

The two stores wrote the default limits 20 and 100 as numbers in five
places; they are now named constants, so the stores cannot drift. The
JSON store checks a missing file with errors.Is. The indexer starts its
readers with wg.Go and keeps the first error inline, which drops the
cmpErr helper.

Assisted-by: Claude:claude-opus-5-5

* Refuse the server's public address in the Sandbox proxy

web_fetch refuses the addresses of the server: its interfaces,
loopback and the host of the external base URL. The Sandbox proxy runs
in the add-on process and listed only the interfaces of its own
machine. Under "All public sites", an always-allowed host or an Allow
card, a command could reach the LocalAI API through the public name or
address of the server, a NAT address or a proxy in front of it.

The host now keeps one list for both. The application builds it once,
gives it to web_fetch, and sends it as CIDR strings in the new self
field of every egress.check allow. The proxy keeps the last list it
got and refuses it on each dial, besides its own interfaces, also for
an allow it cached. safehttp gets the codec and a join of deny lists,
so both sides read the list the same way.

Assisted-by: Claude:claude-opus-5-5

* Check the TLS server name on every port of the Sandbox proxy

The proxy checked the ClientHello only on CONNECT to port 443. An
always-allowed host pinned to another port, or any port added to
chats_egress_ports, was a raw tunnel: a command could open TLS to
another tenant of the same front through it. The docs said the server
name must still match.

On other ports the proxy still waits for nothing, since the protocol
may be anything and the server may speak first. But when the first
bytes from the guest are a TLS handshake record, it reads the whole
ClientHello and passes it on only when the server name is the CONNECT
host; otherwise it closes the tunnel and audits the reason sni.

Assisted-by: Claude:claude-opus-5-5

* Say what sent each notification

The inbox only knew whether a notification was about a run or needed
attention, so a trigger that was switched off and a connector that was
switched off looked the same. Each notification now carries a source:
trigger, connector or system (the conversion of old data). The list
endpoint returns it, and items stored before have none.

Assisted-by: Claude:claude-opus-5-5

* Say where a skill is when a read of it fails

When read_file, read_document or list_files fail with not found for a
path that names a skill of the chat (a wrong skills folder, or just
<name>/SKILL.md), the error now gives the skill's real path and says to
read it with read_file. The skills come from the chat's own resolver, so
every layer, add-on and per-user switch is respected, and nothing is
looked up unless a read fails.

Assisted-by: Claude:claude-sonnet-5-5

* Keep the last valid Sandbox network lists when a value is bad

The settings endpoint refuses a bad chats_egress_allowed_hosts with the
reason, and the runtime settings file keeps the value in force. A bad
value that reached the configuration another way was dropped with no
log line: the always-allowed hosts and the extra registries became
empty, and a bad port list fell back to 80 and 443, which can widen a
narrower list.

Each of the three lists now keeps the last value that parsed, logs a
bad value once, and uses the default only when the process never saw
a valid one. The parse runs again only when the value changes, not on
every egress.check.

Assisted-by: Claude:claude-opus-5-5

* Use the keyless chain of the built-in search in the Web add-on

The add-on replaces the built-in web_search, but its keyless provider
was DuckDuckGo alone. DuckDuckGo often answers data-center addresses
with a bot check, so installing the add-on without a key made search
worse than the built-in, which falls back to the Brave Search and Bing
result pages.

The keyless entry of the add-on's order (saved name duckduckgo) is now
the built-in chain from pkg/web/search: DuckDuckGo, then the Brave
Search page, then the Bing page, with the same cool-down. The status
tool reports the providers in their cool-down, and the Settings tab
shows them with the reason. Test the providers gives one row for each
page of the chain.

Assisted-by: Claude:claude-opus-5-5

* Fix the GitHub hosts in the Q1 row of the egress design

The Q1 row still listed raw.githubusercontent.com on the registries
list. The owner decision below it and registries.json exclude it,
since it serves any user content, and add
release-assets.githubusercontent.com. The row now says the same.

Assisted-by: Claude:claude-opus-5-5

* Keep one private-address rule for the network cards

A card asked whether its host is a private IP literal by calling
egress.Admits with a made-up ceiling, which re-derived the rule that
egress already has. egress.PrivateLiteral is now exported (with a spec)
and the card uses it. The two String methods of an allowed host write
host:port with net.JoinHostPort, which brackets an IPv6 literal the
same way the hand-written code did.

Assisted-by: Claude:claude-opus-5-5

* Read a unique constraint violation in one place

The jobs and triggers stores had the same isUniqueViolation, and the
saved agents store had the same test inline before it looked for the
name index. They now call dberr.IsUniqueViolation, which has specs for
the SQLite and PostgreSQL error texts. The workspace store keeps its
own stricter match.

Assisted-by: Claude:claude-opus-5-5

* Ask before a Home widget link opens another site

A list widget turned an http or https item into a plain link to a new
tab. Its data file is written by trigger runs and the assistant, which
read web text, so a planted link opened with one click, and only a
tooltip showed the host. A link of a page asks first.

An external item link now opens the confirmation bar of page links in
the widget: the site, the full address, the punycode warning, and Open
or Cancel. A link inside the app still navigates directly. The bar
takes a label, so the widget says that it wants to open the link.

Assisted-by: Claude:claude-opus-5-5

* Prune the audit of the commands run on this machine

The audit of the built-in bash had no retention: the database table
grew without bound in a cluster, with commands of up to 4 KiB each.
The Sandbox network audit had chats_egress_audit_days and a daily
prune under an advisory lock.

Both audits record what the commands of the chats did, so the host
command audit now uses the same retention and is pruned in the same
daily pass: one frontend of a cluster, under the same lock; a
standalone process alone. The GORM and file stores get Prune. No new
setting.

Assisted-by: Claude:claude-opus-5-5

* Save the Studio JSON stores with atomicfile.WriteJSON

Seven stores (agents, connectors, jobs, MCP connections, notifications,
push subscriptions and triggers) encoded their state and then called
atomicfile.Write with the same four lines. atomicfile.WriteJSON does
both and has specs. The load side stays in each store, since the error
messages differ.

Assisted-by: Claude:claude-opus-5-5

* Build the result page walkers of the keyless search on one walk

hasChallenge and firstByClass each had their own recursive walk that
stopped at the first match. They are now predicates passed to
firstElement, which already did that walk. The order of the walk and
the element tested first are the same.

Assisted-by: Claude:claude-opus-5-5

* Expire network cards that a dead frontend left pending

Pending network cards expired only on the turn-end path of the process
that ran the turn. When that frontend died, its cards stayed pending
in the chat record, counted toward the cap of three, and gave the next
turn's connections cards-full blocks. The UI showed them as waiting.

A card now names the turn that asked it. One rule, shared with the
command card in a later change, says whether a card still has a
waiter: its turn holds the chat and its wait has not run out. A card
that fails it reads as expired, takes no place under the cap, and is
expired in the record when the next card is asked, when the next turn
starts, or when the reaper ends the dead turn.

Assisted-by: Claude:claude-opus-5-5

* Reuse ChooseProvider for the expected add-on of a tool

shadowers.expected repeated ChooseProvider line for line: the pinned
add-on when it is installed, else the oldest install. It now calls it.
commandFiles kept an order field and sorted the nodes of a map by it;
it now appends each node to a list when it first sees it, which gives
the same order without the sort.

Assisted-by: Claude:claude-opus-5-5

* Do not print a nil error when a key or entry is missing

Three checks joined "the read failed" and "the read found nothing" in
one branch, so the second case wrote "<nil>" into the message: the
VAPID keys of push notifications, the sealing key of connectors and a
rendered catalog entry. Each case now has its own message. The message
of a failed read does not change.

Assisted-by: Claude:claude-opus-5-5

* Check the close errors and rename the tool variables

golangci-lint failed on the new studio code. Five deferred Close calls
and one Fprintf into a hash dropped their error, and two locals named t
made forbidigo read `t.Run` as a testing call. Discard the errors
explicitly and name the locals tool.

Assisted-by: Claude:claude-opus-5-5

* Make the file text index forget purged files and say its limits

Three gaps of the text index of documents:

- A purge dropped the index entries of what it erased in a goroutine
  that only logged its errors, and the purge had already erased the
  journal rows. A crash or a store error left the text in the database
  for good. Each pass of the indexer now also checks a batch of entries
  (200 every 30 s, round all of them) and deletes those whose node is
  gone, so what a failed Forget leaves is removed.

- A document search stopped at 20000 files or 64 MiB of text without a
  word, and the "still being indexed" count stopped at 50. The search
  now sets Limited (search_files says "results limited", the REST
  answer has "limited", the Files page shows a note), and the count is
  the number of pending entries below the path.

- read_file extracted a long document again for every page, up to 50
  MiB and 60 s each time. The first read keeps the whole text in the
  index store by content hash (a new documents table, or files in the
  JSON store), and the next pages come from it. The text goes away with
  the last entry of that content, on delete and on purge.

Search reads the text without its folded copy, which only the database
needs.

Assisted-by: Claude:claude-opus-5-5

* Stop the PDF reader when the extraction gives up

The PDF and EPUB reader runs in a goroutine, and the call returns after
60 seconds when it is slow. The goroutine went on reading every page,
since its context ended only with the caller's. It now gets a context
that ends when the call returns, so it stops at the next page. What
the call returns does not change.

Assisted-by: Claude:claude-opus-5-5

* Compare the code files of a shared page as the store does

The guard that refuses new code to a frame of an older version compared
the name it reads with the names of the page's code exactly. The
workspace folds case, so an old frame that read Index.html or JS/App.js
got the new code with no 409. Compare names in the store's form
(workspace.FoldName), as the write check already does, and test the
case variants.

Assisted-by: Claude:claude-opus-5-5

* Expire the command card of a turn that is over

The card of the built-in bash was cleared only by a defer in the call
that asked. When that frontend died, the card stayed in the chat
record. The chat hid it only while no turn ran, so the next turn
showed it again, and an answer to it reached no one but still got 204.

The card now names its turn and when it was asked, and follows the
same rule as the network cards: it waits only while that turn holds
the chat and its five minutes have not run out. GET /api/chats/{id}
shows only such a card. The next turn and the reaper clear a card left
behind, and the waiting call clears only its own card, so it never
removes a newer one. An answer to a card with nobody waiting is
410 consent_expired, and the chat shows the card as not answered, with
no buttons. The store gets UpdateHostConsent, an atomic change of the
card.

Assisted-by: Claude:claude-opus-5-5

* Answer a trigger change once across frontends

ApplyChange, UndoChange and DismissChange checked the outcome of the
change record under a mutex of one process, then wrote the store. Two
frontends could both see a pending change and both apply it, or both
undo it.

Add Store.SetOutcome, a conditional update of the outcome (only from
the outcome it names), to the GORM and JSON stores and to the store
suite. The three answers now claim the record with it before they
write, give the outcome back when the change itself fails, and answer
ErrProposalAnswered (409) when another request won. A test runs the
two answers on two frontends at the moment between the read and the
write, for an edit, its undo, and a delete.

Assisted-by: Claude:claude-opus-5-5

* Add the shadowers spec to the tables-tools inventory

The tool adapter gained a spec that lists the add-ons that replace a
built-in in any state. The tables-tools suite runs every spec of that
package, so verify.sh found one case more than the inventory holds.

Assisted-by: Claude:claude-opus-5-5

* Offer Clear filters when the backend Catalog shows nothing

The Installed view had a Clear filters button in its no-results state;
the Catalog view did not, although the spec asks for it in both. Move
the button to one place and show it in the Catalog when a search, a
type filter or a library filter leaves no row. Add Playwright specs for
both views, which the earlier Installed fix lacked.

Assisted-by: Claude:claude-opus-5-5

* Give the chat settings without an assistant tool their reason

chats_max_iterations, chats_work_folder, chats_index_files and
chats_index_max_text_bytes had no MCP tool and no entry in
adminRoutesWithoutTool, and the older chats_saved_agents,
chats_suggestions, chats_vision_model and chats_transcription_model
were unlisted too. Add each with the reason it stays a person's choice
in Settings: they apply to every user's chats, and the step limit is
one a model must not raise for itself.

Assisted-by: Claude:claude-opus-5-5

* Remove the stray allowed-hosts keys from the chats inbox locales

allowedHosts, allowedHostsOff and allowedHostsNote sat twice in eight
locales: under egress, where English has them and the UI reads them,
and again under inbox, where nothing reads them. In de and ko the two
copies had drifted apart. Delete the inbox copies and keep the egress
ones.

Assisted-by: Claude:claude-opus-5-5

* Bring three design specs in line with what shipped

- web tools and shadowing: the status still said phase 2 waits; both
  phases shipped. Note that the Web add-on's keyless entry is now the
  built-in keyless chain.
- Home front door: Home now creates the chat and sends the first
  message itself, with a short-lived hand-off to the chat page, and
  its composer takes attachments. Both were described the old way or
  as out of scope.
- chat footer: the meter bar is 96 px with the tokens left, and a phone
  keeps the bar and the tokens left, not the percentage only.

Assisted-by: Claude:claude-opus-5-5

* Store the command card's answer and read it back every two seconds

The answer to the command card of the built-in bash went only as a
bus message. When the message was lost, the call waited the full five
minutes and ended as not answered, although the person had clicked.
The network card stores its answer first and re-reads the card every
two seconds.

The command card now does the same, through one shared pair of
helpers: listenConsent subscribes before the card is stored, and
waitConsent wakes on the bus message or every two seconds and reads
the record, which holds the truth. The network card uses the same
helpers. HostExecDecide writes the decision to the card atomically
before it sends the message: the same answer twice is 204, the other
answer 409 already_answered, and an answered card is no longer shown
as waiting.

Assisted-by: Claude:claude-opus-5-5

* Load skills by name with a load_skill tool

A weaker model failed to read a skill at the path the index showed. The
chat now has a built-in load_skill tool that takes a skill's name and
returns the instructions of its SKILL.md, without the frontmatter.

The tool reads the same resolved skill set as the index: the nearest
skill of each name, the chat's switches, the add-ons the chat can use
now and the grants of its agent. It reads through the user's rights.
When the skill's folder holds other files, the answer starts with the
base directory and names up to 20 of them for read_file; a chat without
read_file gets the instructions only. A skill that needs commands, in a
chat that cannot run them, is refused with the reason. An unknown name
is an error that gives near names first and then every name the chat
has.

Every chat with at least one skill gets the tool, and a sub-agent gets
it with its parent's tools. It has no switch and Settings > Tools does
not list it.

The index of skills now lists names and descriptions only and tells the
model to call load_skill. The Sandbox sentence and the error for a
wrong skill path now point at load_skill too.

Assisted-by: Claude:claude-opus-5-5

* Tell the built-in skills to load other skills by name

localai-apps told the model to read the localai-workspace skill, and
writing-skills said a description is what the agent sees before it
opens the file. Both now speak of loading a skill with load_skill.
localai-workspace keeps the path of its schemas folder and says to read
it with read_file, since those files are not a skill.

A test checks that no built-in skill sends the model to another skill by
"read" or by the path of its SKILL.md.

Assisted-by: Claude:claude-opus-5-5

* Document load_skill and the skills index

The chats page now says that the prompt lists skills by name and
description, that load_skill returns a skill's instructions and names
the files of its folder, what it refuses and who gets it. It no longer
says the model reads a skill by its path. The add-ons page says how a
chat loads an add-on's skill, and the saved agents page says that the
skills grant limits load_skill too.

Assisted-by: Claude:claude-opus-5-5

* Add the proposal action spec to the tables-acceptance inventory

The spec that an editor cannot confirm a schema change as a write ran in
the suite but was never in the inventory, so the evidence manifest check
failed. CI did not reach that check before, because the burst spec
failed first.

Assisted-by: Claude:claude-opus-5-5

* Give a chat a goal that it works toward by itself

A chat can now hold one goal. While it is active, every turn's prompt
names it, the model gets goal_done and goal_blocked, and when a turn
ends by itself the frontend that ran it starts the next one with
"Continue toward the goal". The person's waiting messages still go
first, and a turn they start does not count as an automatic one.

The goal pauses when the person presses Stop, when a turn fails or is
cut short, and when the automatic turns or the model steps reach their
limits. Resume gives it fresh budgets. A sub-agent, and a chat that a
trigger, a connector, a job or a page started, has no goal and no goal
tools.

The goal lives with the chat record in the store (UpdateGoal on the
JSON and GORM stores, with contract tests and a column migration), so
every frontend reads the same one. The next turn goes through the same
turn claim as any other, so only one frontend continues it; a reaped
turn, or a JSON store that reloads after a crash, leaves the goal
paused instead of continued twice. The transcript keeps goal lines
(set, replaced, continue, paused, resumed, done, blocked, cleared) as
their own role, so an automatic message never reads as typed by the
person.

Assisted-by: Claude:claude-opus-5-5

* Add a store for the loops of the chats

A loop repeats a prompt in one chat, at an interval or when the model
asks for the next run. The new loops package keeps the loops, the
daily budget of runs of each owner, and which chats confirmed a loop
once. JSON and GORM stores share one contract suite. Add checks the
limits per chat and per owner in the same step as the insert, and
TakeRun never hands out the last run of a day twice, also across
frontends.

A user message that a loop sent now carries a loop mark (loop ID, run
number, self-paced or not). Both chat stores keep it. The model reads
a short line before the prompt that says the message repeats by
itself, and, for a self-paced loop, how to ask for the next run.

Assisted-by: Claude:claude-opus-5-5

* Add the file preview slot specs to the host-slots inventory

The file preview slot came with two Go specs in the host-slots package.
verify.sh found them in the run but not in the inventory.

Assisted-by: Claude:claude-opus-5-5

* Check the Settings page and the add-ons with the UI copy guard

The guard read the locale files only. The Settings page keeps its
English labels and descriptions in settingsSchema.js, and several
add-ons carry their own English bundle, so neither was checked.

The guard now also reads the label, description and note of every
setting and the label and hint of every group, and the English
bundles of the add-ons (an en.js module or an English JSON file).
The allow-list takes settings:<key>.<what> and addon:<id>:<key>
entries, each with its reason; the operator ones (the SearXNG JSON
format, the fine-tuning file types, the API route of a voice, the
schema of the Databases add-on) are listed.

What it found in the voice library is fixed in plain words: "model
YAML is not modified" and "Existing model YAML is not changed" now say
that the models' settings are not changed, in the voice add-on, the
Studio add-on and the host locales that have the strings (en, fr,
pt-BR). The two host allow-list entries for them go.

Assisted-by: Claude:claude-opus-5-5

* Add the update digest specs to the updates inventory

Two checker specs pin that an update names its candidate digest and
that a stale cached notice stays readable. The updates suite runs the
whole checker package, so the inventory needs both.

Assisted-by: Claude:claude-opus-5-5

* Draw the Add-ons search and state filter with the kit pieces

The Add-ons page had its own copy of the search box and the segmented
filter (ad-search, ad-seg), the same shapes and styles as the kit's
SearchField and Segmented that the Agents pages use. Use the kit
pieces and drop the copy; the narrow-screen rules now target the kit
classes inside the toolbar. The search keeps its test id.

Assisted-by: Claude:claude-opus-5-5

* Add the limits of a chat goal to the runtime settings

chats_goal_max_turns (1 to 200, default 20) is the most turns a chat
with a goal starts by itself, and chats_goal_max_steps (1 to 10000,
default 400) the most model steps of all the turns of one goal, before
the goal pauses. Both are read when a turn ends, so they apply without
a restart. They sit in Settings > Tools next to the step limit of one
turn, and the settings page documents them.

Assisted-by: Claude:claude-opus-5-5

* Add the goal routes of a chat to the API

GET, PUT and DELETE /api/chats/{id}/goal read, set and clear a chat's
goal, and POST /api/chats/{id}/goal/resume resumes a paused or blocked
one. They belong to the chat's owner like the other chat routes and
take the chats feature. A chat view carries the goal with the limits
that apply now, the transcript gives goal lines with goal_event and
goal_turn, and the event stream has a goal event so that other tabs
read the chat again.

The routes are in the route feature registry, the swagger file and the
chats entry of /api/instructions. They manage the person's own chat,
not the server, so they have no MCP tool, as the other chat routes. The
chats page documents goals, their states and limits, and how several
frontends share them.

Assisted-by: Claude:claude-opus-5-5

* Redesign the notification inbox

The inbox panel was a plain list: every row had the same weight, a
failed run looked like a finished one, the panel ran off the screen on
a phone, and a failed load showed nothing.

The panel now has a header with the unread count, a menu for Clear read
and Clear all, Mark all read, and an All / Unread / Failed filter. Items
are grouped under Today, Yesterday and Earlier. Each item shows what
sent it (trigger, connector or LocalAI) with a tinted icon, the title,
two lines of text or the reason of a failure, and when it happened, with
the exact time on hover. A click marks it read and opens its chat; a
connector that was switched off opens the connectors page. Mark read and
Delete show on hover or focus, and stay in view on a touch screen.

The panel has a loading skeleton, an error with Try again, an empty
state that says what shows up here, and a caught-up state. A new
notification slides in, is highlighted for a few seconds and is
announced once. The badge stops at 99+. Focus moves into the panel when
it opens; the arrow keys, Home, End and Delete work on the items, and
Esc returns focus to the button. On a phone the panel is a sheet over
the bottom of the screen with 44 px targets.

Assisted-by: Claude:claude-opus-5-5

* Explain the reads of the text index and the provider requests to gosec

gosec flagged three new lines. The text index reads files from the
directory the host gave it, with names it listed there. A search
provider sends its request to the endpoint fixed at construction, never
to a URL from a tool call. Mark the lines with the reason.

Assisted-by: Claude:claude-opus-5-5

* Keep the add-on details open when Escape closes a dialog

Escape in the restore dialog of the recovery panel also closed the
details pane around it. The pane handles Escape in React, and the
dialog listens on the document, so the pane ran first and saw no
preventDefault. The pane now registers as a modal layer and closes only
when it is the top layer, as the side panes do.

Assisted-by: Claude:claude-opus-5-5

* Add skill and agent messages, compact and clear to the chats API

A message can now carry a skill of the chat or one of the user's saved
agents. The server checks the name against the chat's resolved skill
set (switches, the agent's grants, shadowing, add-ons) or the saved
agents the chat may call, and refuses an unknown one with a 400 that
lists what the chat has. A skill message gives the model the text of
the skill's SKILL.md before the user's words, for that turn only; an
agent message asks the model to start the agent with spawn_agent. The
transcript keeps the words and the command (Entry.Invoked), not the
instructions. A regenerate or an edit runs the command again. Such a
message does not wait for a running turn: it answers 409.

POST /api/chats/{id}/compact runs the summary the loop makes when the
context fills up, through a new optional session.Compactor that the
loop harness implements with its own history compaction. The compacted
state is saved for the next turn and a "compacted" line with the sizes
goes in the transcript.

POST /api/chats/{id}/clear writes a "cleared" line and drops the saved
state. The model then sees nothing above the line: the rebuilt state
and the transcript summary start after the last such line, so a fork
from above it keeps what the model saw there. Export, import and the
Markdown export carry both lines.

Both calls take the turn claim, so no frontend starts a turn on the
chat meanwhile; messages sent during them start a turn after. No MCP
tool: they act on one chat of the caller and are not admin calls.

Assisted-by: Claude:claude-opus-5-5

* Give each network card spec its own settings

A check that a card spec starts can still wait on its card when the
next spec begins. It read the settings through a closure over a
variable that the next BeforeEach replaced, which the race detector
reported. Each service now reads the settings holder it was built with,
under a lock, and the specs change the settings through it.

Assisted-by: Claude:claude-opus-5-5

* Show the effective audit retention in the settings snapshot

The settings snapshot returned 0 for chats_egress_audit_days while the
setting was unset. Any page that saves the whole snapshot back, such as
the Traces settings strip, then posted 0 and the server rejected it with
"Invalid chats_egress_audit_days", so saving tracing settings failed on a
fresh install.

Report the retention that applies (30 days by default), the same way
chats_max_iterations does.

Assisted-by: Claude:claude-sonnet-5-5

* Run the Sandbox smoke test in a short scratch folder

The smoke test step was reached for the first time and both isolated
backends refused to start: their state folder under the checkout's
cache made the socket paths 126 and 132 bytes, over the 107 a unix
socket allows. Point SANDBOX_SMOKE_WORK at /tmp/sandbox-smoke, which
leaves room for the sockets.

Assisted-by: Claude:claude-opus-5-5

* Use paths from the home folder in the Sandbox command specs

bash now starts in the user's home folder, so the end-to-end specs that
read and wrote home/<name> found nothing. Use names relative to the
home folder, as the Sandbox specs already do.

Assisted-by: Claude:claude-opus-5-5

* Show the goal of a chat on the chat page

A bar above the composer shows the goal with its state: Pause (which
stops the running turn and so pauses the goal) while it works, Resume
when it paused or needs the person, and Clear. A second line says why
it paused, what the model needs, or what it did. The status line shows
"Goal · turn 3 of 20" while the goal is active, and the Continue button
of a paused turn stays away then, since the next turn starts by itself.

The transcript shows the goal lines as quiet rows: set, replaced,
continuing (with its turn), paused with the reason in plain words,
resumed, reached with the summary, blocked and cleared. A goal event,
also from another tab, reads the chat again, and a hidden live region
says each change to a screen reader once.

The strings are in all nine locales. Unit tests cover the helpers and
the thread rows; a Playwright spec covers the bar, the progress, the
lines, Pause, Resume, Clear and the event.

Assisted-by: Claude:claude-opus-5-5

* Run the loops of the chats

The loop host fires the loops that are due. A run sends the prompt to
the chat as a new message only when the chat is idle: a busy chat
skips the run, and an interval loop moves to its next slot instead of
piling missed runs up. A self-paced loop gets two tools while it is
active: schedule_next (clamped to the minimum interval and one day)
and loop_done. A run in which the model calls neither ends the loop
with a note. A loop ends after its run limit, pauses after three
failed runs in a row, and waits for the next UTC day when the owner's
daily budget of runs is used up.

One frontend fires the loops, the holder of a new advisory lock. A run
is claimed in the store first, and the turn claim keeps one turn per
chat, so a short overlap of two leaders starts no run twice. A new
leader finishes the runs the old one watched. Deleting a chat removes
its loops; a due loop whose chat is gone removes itself.

Five runtime settings bound the loops: chats_loop_min_interval,
chats_loop_max_runs, chats_loop_max_per_chat, chats_loop_max_per_user
and chats_loop_daily_runs.

Assisted-by: Claude:claude-opus-5-5

* Add the loops API of the chats

GET, POST and DELETE /api/chats/{id}/loops list, start and stop the
loops of a chat; PUT and DELETE /api/chats/{id}/loops/{loopId} pause,
resume, confirm or stop one. GET /api/chats/{id} carries the loops,
the transcript marks the user lines a loop sent with loop {id, run},
and a loops event tells every open tab that the list changed.

The routes are gated by the chats feature and documented in the
swagger spec and the API instructions. They act on the caller's own
chats only, so they get no assistant tool. The chats page gets a loop
section, with when to use a loop and when a trigger, and the cluster
behaviour; the runtime settings page lists the five loop settings.

Assisted-by: Claude:claude-opus-5-5

* Show the loops of a chat above the message box

Each loop of the open chat shows as one line over the message box,
such as "Loop · every 5 min · run 4 · next at 09:20", with Pause or
Resume and Stop. A loop that paused itself or ended says why; an
ended one has Dismiss. The first loop of a chat shows a card instead:
"Run this every 5 minutes until you stop it?" with Start and Cancel.
The server keeps the pending loop, so a reload shows the card again.

A message that a loop sent carries a small "Loop run 4" label. The
page reads the loops again on the loops event, so every open tab
follows. The answer to /loop list is ready for the slash command. The
strings are in all nine locales.

Assisted-by: Claude:claude-opus-5-5

* Build the chat slash menu on one command registry

The slash commands of Home and of a chat now come from one registry,
src/utils/slashCommands.js. A command has a kind (navigate, insert or
server), the surfaces that offer it, a needs check and, optionally, an
argument provider: args(query, ctx) returns the items the menu lists
when the box holds "/cmd <query>". Every existing command and test id
stays; the registry adds:

- one command per skill of the chat or the draft (/review), marked
  built-in, add-on, shared or yours, and /skill <name>, which also
  reaches a skill named like a command;
- /agent <name> <task>, which lists the saved agents, or offers to turn
  them on for the chat when they are off;
- /model and /models with a model name, which switch at once;
- /compact and /clear, which show their progress in the status line
  and leave a marker in the thread; the context meter drops;
- /attach and /attach clear, /help (a card that lists the commands)
  and /about (a card with the model, its state, the tools, skills,
  where commands run and the context size).

Tab completes a command or an argument, Enter runs it, and the first
row whose name starts with what was typed is selected first. A message
typed as a skill or agent command is sent with its marker, not as text;
the transcript shows the words and a chip. Home offers the same set
without the commands that need a thread.

The words are in all nine locales. The registry has unit tests, and a
Playwright spec covers the menu, the completion, the marker, the chip,
compact, clear and the cards with the routes stubbed. The docs page
gains a slash commands section.

Assisted-by: Claude:claude-opus-5-5

* Add the /goal command to the chat

/goal shows a card with the goal, its state, the automatic turns and
steps it used out of the limits, and when it was set. /goal <text>
sets a new goal at once, replacing the one the chat had without a
question, and the goal bar says what it replaced. /goal clear and
/goal resume act on the goal; typing "/goal " lists resume and clear
for the goal the chat has. None of these is sent as a message. In a
new chat, /goal <text> creates the chat first.

The command sits in the Context group of the shared command registry,
on the chat surface only, and not in a chat that a trigger or another
run started. A skill named goal is reached through /skill. Unit tests
cover the parser and the menu items; the Playwright spec covers the
card, setting, replacing, resuming and clearing from the box.

Assisted-by: Claude:claude-opus-5-5

* Add /loop to the chat

/loop [interval] <prompt> starts a loop in the chat; /loop list shows
the loops of the chat with the code that /loop stop takes, and
/loop stop [id] stops one or all. The command is in the slash
registry: after "/loop " the menu offers list and stop, and after
"stop " the loops of the chat.

A loop prompt that invokes a skill (/review the diff, or
/skill review the diff) runs as the person's skill command would: the
model gets the skill's text, and the transcript shows the words, the
skill's chip and the loop label. The server checks the skill when the
loop starts and at each run; any other command is refused with the
reason.

Assisted-by: Claude:claude-opus-5-5

* List the loop settings on the Settings page

The five limits of the loops of the chats show under Tools with the
other chat settings: the shortest interval, the runs per loop, the
loops per chat and per user, and the loop runs per day. All apply at
once.

Assisted-by: Claude:claude-opus-5-5

* Clear a sent Home draft when the page goes away too

Home clears the draft of a sent message when it closes, so the text
stays visible while the composer moves into the new chat. While the
chat page still loads, a reload or a closed tab could come first, and
the sent text came back on Home. Leaving the page now clears it too.

Assisted-by: Claude:claude-opus-5-5

* Check the close error of a previewed file

golangci-lint (errcheck) flagged the deferred Close of the file the
preview endpoint reads. Discard its error explicitly, as the other
studio file handlers do.

Assisted-by: Claude:claude-opus-5-5

* Explain the document read of the text index to gosec

gosec reported the read of a stored document text as a file inclusion
through a variable. The path is the configured index directory joined
with a hash that documentPath refuses when it holds a path separator
or a dot, so it cannot leave the documents folder.

Assisted-by: Claude:claude-opus-5-5

* Build the darwin/arm64 add-on packages on macos-15

GitHub retires the macos-14 runner image on 2026-11-02 and runs
scheduled brownouts until then: every darwin/arm64 package job failed
at once with the brownout notice. macos-15 is the Apple Silicon image
that replaces it.

Assisted-by: Claude:claude-opus-5-5

* Expect the chat's own load_skill tool in the agent prompt spec

The spec that keeps the agent engine's text out of the prompt also
required that no load_skill tool or mention reach the model. Chats now
offer their own load_skill tool and the skills index tells the model to
call it, so the spec failed. Keep the checks on the engine's own skill
and command text, and check that the chat's tool is offered.

Assisted-by: Claude:claude-opus-5-5

* Read the user's scripted tool call in the mock model

The chat prompt now names the chat's work folder, and the folder name
comes from the chat title, which holds a cut copy of the user message.
The mock model read the first ADDON_TOOL_CALL marker, the cut one in the
system prompt, and called the tool with broken arguments over and over.
Read the last marker, the one of the user message.

Assisted-by: Claude:claude-opus-5-5

* Keep the phone targets of the inbox at 44 pixels in every build

On a phone the inbox close button and the item actions must be 44
pixels wide. Their rule had the same weight as the kit's .dk-btn
min-width, so the order of the stylesheets decided. The single-chunk
coverage build puts the kit later and the close button shrank to 28
pixels. Scope both rules to their container so they win in any order.

Assisted-by: Claude:claude-opus-5-5

* Let the about-you spec accept the sidebar's own seen date

When the user has pages, the sidebar writes the date of its New label
under seen.new in user.json. In the whole suite other specs make pages
first, so the saved record held that date as well and the exact
comparison failed. Check that the uploaded seen record is kept.

Assisted-by: Claude:claude-opus-5-5

* Give the typed-while-working chat spec time for two turns

The spec waits up to 30 seconds for each step, but two turns of the
mock model with its tool steps take about 28 seconds on their own, so
the default 30 second test limit cut it under load. Set its limit to
90 seconds.

Assisted-by: Claude:claude-opus-5-5

* Scope the foundation spec's state query to the sheet header

The details pane now repeats the observed state under Health, so a
role query for "State: Off" inside the add-on sheet matched two
elements and failed in strict mode. The administrator-manages spec,
and the two specs after it, did not run.

Query the chip in the header (.ad-pane__meta), which is the state the
administrator sees next to the add-on's name, through one helper used
for all three assertions.

Assisted-by: Claude:claude-sonnet-5-5

* Wait for the page to read each apps answer in the registry polling spec

The spec moved the page clock as soon as the stand-in saw a request.
The registry sets its next timer only after it reads the answer, so on
a slow runner the clock could pass the due time of a timer that did not
exist yet, and the next read never came. With the CPU throttled ten
times, the old spec failed in 3 of 3 runs.

The spec now counts the answers the page has read and lets queued work
finish before it moves the clock. It pauses the clock before it leaves
the page, and it tries the pause again when a slow machine passed the
target time. The assertions do not change: one read at once, one read
per interval and no faster, and no read after the disable.

Assisted-by: Claude:claude-opus-5-5

* Close the egress test listener only after it accepts

The dial helper of the egress proxy specs closed its listener when it
returned, while the goroutine that accepts the connection could still
be waiting to run. On a loaded runner the listener sometimes closed
first. The kernel then reset the connection that was still in the
accept queue, the spec's write consumed the reset error, which the
spec ignores, and the next read saw EOF. The spec failed with
"unexpected EOF" before the proxy got the request.

Wait for Accept to return before the listener closes, then start the
proxy handler on the accepted connection.

Assisted-by: Claude:claude-opus-5-5

* Run host commands in a shell that reads no startup file

The host backend promises a fixed environment, but bash -lc reads
/etc/profile and the user's dotfiles. On macOS path_helper rewrote PATH
there, so the command did not get the fixed PATH. Turn the login shell
into 'bash --noprofile --norc -c' in the host backend only, and make the
test assert the exact fixed PATH.

Assisted-by: Claude:claude-sonnet-5-5

* Answer the command card in the platform turns and 400 a bad answer

The tool-list spec of the platform suite timed out: without accounts
the one person is an administrator, so the chat has the built-in bash
in ask mode. The tool list is sorted and bash is first, and the mock
model calls the first tool, so every turn waited five minutes for a
yes on the command card. The turn helper now refuses each card it
sees, as a person who clicks deny, and the turn goes on to its step
limit. bash stays in the checked list.

An administrator who posts a consent with no card id or an unknown
decision got 403 "only an administrator can allow commands". That is
a bad request, not a refusal: it now answers 400, as the swagger
already says, and the route check for administrators passes.

Assisted-by: Claude:claude-opus-5-5

* Let a chat switch off its shell, file, media, collection and sub-agent tools

The Tools menu of a chat switched only three groups of built-in tools:
web, saved_agents and triggers. A person could not keep a chat away
from the shell, the files, the helper models, the collections or the
sub-agents.

Add five groups to the registry of built-in tools, all on until the
chat switches them off:

- shell: bash, whoever serves it. The Sandbox add-on's bash and the
  built-in bash on this machine both go, and the add-on list reports
  commands switched_off. The row stays listed when nothing can run
  commands, not usable, with the reason.
- files: the file tools and read_document.
- media: describe_image and transcribe_audio.
- collections: list_collections and search_collection, usable only
  for a user with the collections feature.
- subagents: spawn_agent. list_agents and /agent follow it.

The registry gets two flags. External marks a tool that its own code
builds for the turn (with the wrappers it needs); the registry lists it
in its group and Hidden tells the builder what the chat switched off.
Covers makes a group's switch also hide the add-on tool that replaces
the built-in one. PUT .../builtins, the panel, the draft list and the
fallbacks use the existing group code.

A sub-agent follows the switches of its parent chat. The turn prompt
is built from the final tool list: no file tool names, no skill
writing hint, no work folder and no rules pointer when the chat cannot
use files, no describe_image when the helper is off, and a commands
block that says the person switched commands off.

Assisted-by: Claude:claude-opus-5-5

* Queue the edited chat message after the first step starts

The spec queued its message as soon as the chat showed a busy state.
The turn is busy before its first model request starts, and that
request takes every message that waits. With the chat's tools and
skills, the first request now starts a few hundred milliseconds after
the turn, so the message went with it, and the edit got 409 "already
delivered" (5 of 6 local runs).

Wait until the answer streams, as the Stop spec does: the request is
running then, and the queued message waits for the next step.

Assisted-by: Claude:claude-opus-5-5

* Give the reload chat spec a turn that outlasts the reload

The spec reloaded the page during a 3 second turn and expected the
page to show the turn still running. A reload of the coverage build
on a busy runner takes 4 to 5 seconds (measured with a 6x CPU
throttle), so the turn had ended before the page could show it. With
the 30 second turn the page always showed it running, so the resumed
state works and only the window was short.

Add MOCK_LONG_STREAM_MEDIUM to the mock model, a turn of about 10
seconds, and use it in the spec with a 60 second limit.

Assisted-by: Claude:claude-opus-5-5

* Wait for the chat page after the Home hand-off in the specs

Send on Home changes the address inside a view transition, and the
chat page mounts about 35 ms later. Specs that waited only for the
address acted on Home in between:

- The title spec pressed Ctrl+K at once, because the server names a
  chat after its first message as it arrives, so the title poll ends
  at once too. The key reached the page before the chat page listened,
  and the menu did not open (3 of 3 presses at 0 ms failed, all at
  300 ms and later passed).
- The Back spec went back before the chat page was drawn. Home was
  never closed, so its draft of the sent message stayed in the box.

startChat now waits for the chat page, the title spec for the title
in the chat header, and the Back spec for the chat page before Back.

Assisted-by: Claude:claude-opus-5-5

* Accept a resolved user.json conflict in the quiet page specs

The sidebar notes in the seen record of user.json the first day it
labels Pages as new, and saves it from the version it read. All specs
share the test server's one user, so tabs of other workers save the
same record at the same time: one gets 409 and useSeen reads the file
again and saves once more. Three tabs that open /app/files at once
give 200, 409, 200. The quiet check of the shell pages counted the
409 as a problem.

Ignore a 409 on PUT /api/chats/wellknown/user in that check.

Assisted-by: Claude:claude-opus-5-5

* Check only the spec's own items in the Trash after the reload

After Empty Trash and a reload, the spec expected the whole Trash to
be empty. The Trash belongs to the test server's one user, and specs
of other workers move files to it meanwhile, so CI saw "In the Trash:
3 files, 356 B." after the reload.

The checks right after Empty Trash stay. After the reload, check that
the server's Trash, the Trash tab and Files do not hold the spec's
items, once each list has loaded.

Assisted-by: Claude:claude-opus-5-5

* Show the new tool groups in the chat Tools menu

The Tools menu lists the groups of built-in tools that the server
sends. The five new ones need words and an order, and the shell row
needs to say who runs the commands.

Name the groups in all nine locales (Run commands, Your files, Images
and audio, Collections, Sub-agents) and order the rows by what they
reach: the shell and the files first, the triggers last. The shell row
carries a chip with the provider (Sandbox or This machine) and a line
under it that says the commands run isolated, or a warning when they
run on this machine. When nothing can run commands the row is disabled
with the words of the commands notice. A switched-off sub-agents group
also turns /agent off in the composer.

The specs cover the rows, the off and on requests, the bulk actions,
the provider chip, the disabled reasons, the draft on Home and a real
server round trip.

Assisted-by: Claude:claude-opus-5-5

* Document the groups of built-in tools a chat can switch off

The Tools menu section described only the web, triggers and saved
agents groups. List every group with its tools, say which groups the
server may leave out and why a row can be not usable, how Run commands
covers both providers, what the model is told when a group is off, and
that a sub-agent follows its parent's switches.

Assisted-by: Claude:claude-opus-5-5

* Fix the host command consent spec for the 400 contract

The consent route answers 400 for a body without a card id or with a
decision other than once or deny, whoever asks. The spec still expected
403 for always from an administrator. Cover the malformed answers for
an administrator and a user, each in their own chat, keep another
user's chat at 404, and say it in the chats guide.

Assisted-by: Claude:claude-sonnet-5-5

* Find the web tool group by name in the route spec

The tools panel lists the shell group before the web group, so the
spec that read the first entry compared the wrong group. Look the
group up by name, which does not depend on the registry order.

Assisted-by: Claude:claude-sonnet-5-5

* Give Skills its own sidebar item

Skills work in every chat, but the sidebar filed the page under Agents
and only agent skill chips linked to it. Add a Skills item right after
Memory, show it in the legacy list too, and stop lighting up Agents on
the skills pages.

Assisted-by: Claude:claude-sonnet-5-5

* Add Manage skills to the skills picker and a /skills command

The picker now ends with a Manage skills link, and /skills opens the
Skills page from the composer on Home and in a chat. The picker keeps
the height of its status line, so a refused change does not move the
rows. Words are added in all nine locales.

Assisted-by: Claude:claude-sonnet-5-5

* Say on the Skills page that every chat can use skills

The page subtitle named only agents as users of skills. Say that all
chats use them, translate it, and document where to manage skills: the
sidebar item, the picker link and /skills.

Assisted-by: Claude:claude-sonnet-5-5

* Keep Home on screen while its widgets load their strings

A Home widget reads the pages strings, and i18next loads a namespace
the first time a component asks for it, by suspending. Home had no
Suspense boundary of its own, so the route's boundary hid all of Home
(display: none) and showed the route fallback until the strings came.

A widget shows only after home.json is read, so this happened after
the composer was already drawn. Focus given to the composer in that
window went nowhere, and a key typed then was lost. The slash menu
spec failed in CI this way when another spec had written widgets to
the shared home.json: the box was empty and the body had the focus.
With widgets in home.json the composer was hidden right after it
appeared on 5 of 5 loads, and the spec failed 1 or 2 times in 5.

The examples and the widgets now have their own boundary with no
fallback, so only they wait. A new spec holds the pages strings while
it types in the composer; it fails without the change (the composer
never takes the focus) and passes with it.

Assisted-by: Claude:claude-opus-5-5

* Send the stubbed file change after the Files page read the folder

The change feed spec answered the first stream request with the change
at once. The app shell (the appearance sync) opens the shared stream
when the app starts, before the Files page chunk has loaded, so on a
slow runner the change reached a tab where nobody watched the folder
yet and was dropped. The page then never read the folder again, and
the spec failed on all three CI attempts. A CPU throttle of 8 makes it
fail 3 of 3 times locally.

On a real server the page's own first read would already show such a
change. The stub now holds the change until the page has read the
folder; the page subscribes in the same commit as that read, so the
change arrives with a subscriber in place. The resume check is kept.

Assisted-by: Claude:claude-opus-5-5

* Give the oversized 3D input as a sparse file, not a buffer

The spec built a 32 MiB buffer and passed it to setInputFiles.
Playwright sends a buffer to the browser over the protocol: that step
alone took 5.5 s on an idle machine, and on the loaded CI runner the
test hit the 30 s timeout on all three attempts.

It now writes a sparse file of the same size to the test output folder
and passes its path, which the local browser opens as is. The page
still checks the size before it reads the file, so the toast and the
no-request assertion are unchanged. The step takes milliseconds.

Assisted-by: Claude:claude-opus-5-5

* Check the tool line, not the tool name, in the tool call journey

The spec looked for /list_files/ in the transcript. The user's own
message holds that name, so the check could pass before any tool ran.
Once the model's call streamed, a second element matched, and the
strict locator failed (2 matches on the first attempt, 5 on the
retry).

It now waits for the activity line that the tool run adds ("Listed
/"), which only shows after the tool ran.

Assisted-by: Claude:claude-opus-5-5

* Measure the proposal card and its neighbours in one frame

The layout spec read the boxes of the message text, the card and the
message actions with three boundingBox calls. They are viewport
positions taken in three frames, and the transcript scrolls to its end
while the card fills in. On the phone the actions were read after a
scroll and came out 156 px above the bottom of the card.

The three boxes now come from one page.evaluate, so they are read in
the same frame. The order assertions are unchanged.

Assisted-by: Claude:claude-opus-5-5

* Read the page fixture time when the page is served

The task board fixture of the discovery and Pages specs set its
change time once, when the file loaded. A worker loads it once and
keeps running specs, so a spec that ran two minutes later read
"Changed 2 minutes ago by the assistant" where it expects "just now".

The change time is now a getter, read when the fixture is serialized
into a response.

Assisted-by: Claude:claude-opus-5-5

* Check a background process without /proc in the host test

The alive helper read /proc/<pid>/stat, which macOS does not have, so it
reported every process as dead and the spec failed there with "did not
survive the suspend". Use signal 0 for existence and ps (or /proc where
present) to tell a zombie from a live process.

Assisted-by: Claude:claude-sonnet-5-5

* Keep the composer switch menus at their own height

The base rule of the dropdown menu caps it at 320px and lets it scroll.
The switch menus set their own height with a rule of the same weight in a
lazy sheet, so which one won depended on the order the sheets loaded in.
When the base rule won, the picker showed a few rows, and a click on a row
at the edge of the list scrolled the list by a few pixels.

Double the class on the switch menu rules so they win in any order, and
add a test that loads the base rule last.

Assisted-by: Claude:claude-sonnet-5-5

* Show what applies in the chat Network switch

The Network row highlighted the choice stored with the chat, so a
chat that asked for All public sites under an administrator limit of
Off showed All highlighted although it was disabled and not in effect.

Highlight the effective choice, the lower of the chat's choice and the
limit, which the server already computes. When the chat asks for more
than the limit, say so in one line instead of highlighting it. Add a
line that the limit is set by the administrator, and tell the host
network line that limits need the Sandbox add-on.

Assisted-by: Claude:claude-sonnet-5-5

* Design the host components that add-ons reference

The host UI names the Sandbox and the Web add-ons to place their server
settings, and the chat's Network row links to the global Settings page.
Describe a closed registry of host components that an add-on references
by ID in its manifest, entitled by the existing host operation grants
and tool shadows, so the controls live on the add-on's own page and a
second add-on can reuse them with no host change.

Assisted-by: Claude:claude-opus-5-5

* Let an add-on ask for host controls in its manifest

An add-on had no way to say that a host-owned control belongs on its
page, so the host UI placed those controls by add-on name. Add
ui.controls, a list of IDs from a closed registry of host controls:
network-policy (the network of the commands an add-on runs) and
tool-providers (the rows of the built-in tools it replaces).

The host decides which controls it draws: EntitledControls keeps a
control only when the add-on holds its host operation grant and
replaces its built-in tool. The field is optional, so every existing
manifest stays valid.

Assisted-by: Claude:claude-opus-5-5

* List the host controls an add-on is entitled to

The apps list now carries ui.controls with the host controls the
manifest declares and the host setting entitles the add-on to: the
manager passes each add-on's host operation grants to the list.

GET /api/chats/{id}/egress gains addon, the add-on that runs the chat's
commands when it has the network-policy control, so the chat can draw
its Network row from that add-on and link to its settings. applies and
applies_reason keep their meaning: the policy holds with or without the
control. The model and the 400 error no longer send an administrator to
Settings > Tools for the limit.

Assisted-by: Claude:claude-opus-5-5

* Draw the host controls on the add-on's own page

The host UI placed the Sandbox network and the tool rows by add-on name
(SERVER_SETTINGS), showed a read-only summary on the add-on's Settings
tab, and linked the chat's Network row to the global Settings page.

Add a registry of host controls keyed by the IDs of the contract. The
Settings tab of an add-on draws, below its own sections, the controls
the apps list gives it, in one form with the pending bar of the
Settings page: network-policy reuses the network section and its
setting rows, tool-providers the rows of the built-in tools the add-on
replaces. The chat draws its Network row only when the server names an
add-on with network-policy, and the administrator link opens that
add-on's tab. The row itself is unchanged.

The Settings page keeps the settings of features that exist without an
add-on, points to the add-on where one serves them, and no longer
lists the network settings. Its old address opens the add-on's tab.

Assisted-by: Claude:claude-opus-5-5

* Ask for the host controls in the Sandbox and Web manifests

The Sandbox asks for network-policy and tool-providers, so the network
of its commands and the bash row are drawn on its own Settings tab and
its chats get the Network row. The Web add-on asks for tool-providers
only: it holds no egress grant. The manifest tests check that the
default grants entitle each add-on to what it asks for.

The proxy's refusal now sends the model to the Sandbox Settings tab
instead of Settings > Tools.

Assisted-by: Claude:claude-opus-5-5

* Keep the button of the network pointer row at its size

The row used the wide layout of the tool rows, so its Open settings
button stretched across the page. Use the plain setting row, which
puts the button on the right like the other controls.

Assisted-by: Claude:claude-opus-5-5

* Fix the add-on contract inventory and the apps Swagger fixture

The contracts case inventory missed the six host control specs, so
verify.sh rejected the Ginkgo report. List them.

The Swagger spec test never saw `controls` in the emitted UIView JSON
because its fixture left the omitempty field empty. Set it there.

Assisted-by: Claude:claude-sonnet-5-5

* Add the host controls spec to the apps inventory

The apps suite ran one more spec than scripts/addons/suites.json
listed, so verify.sh rejected the apps report as an inventory
mismatch. List the new spec in the report inventory and in the
suite's aggregate case list. Every other Ginkgo suite was re-run
and matches its inventory.

Assisted-by: Claude:claude-sonnet-5-5

---------

Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-11-01 12:08:23 +01:00
mudler-agentandEttore Di Giacinto 2cadf319a0 feat(realtime): LFM3-Audio speech-to-speech on llama.cpp, remove liquid-audio (#12958)
* chore(backends): remove the liquid-audio Python backend

The backend targets liquid-audio, which loads LFM2 checkpoints only.
Remove its directory, the gallery file and the LFM2.5-Audio model
entries, the library and generated backend gallery entries, the
backend matrix and CI job, the Makefile targets and the golden test
fixtures that listed it.

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

* refactor(config): drop liquid-audio from capabilities, importers and UI

With the liquid-audio backend gone, remove its capability entry, its
importer, its artifact-gate entry and the realtime_audio heuristic
that only matched that backend. realtime_audio is now set only
through known_usecases. Update the Talk and fine-tuning pages, the
docs and the swagger text that named the backend.

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

* chore(llama-cpp): bump llama.cpp to e486a4b5

Move the llama.cpp pin to e486a4b559617dc88bf79978996f6c670b6bbc9d,
the master head of 2026-10-28. It contains the LFM3-Audio support
(ggml-org/llama.cpp#31257) and the realtime server split
(ggml-org/llama.cpp#31269).

The score task patch is regenerated against the new pin. It applies
with git apply and with patch -p1, with no offset and no fuzz. Its
hunks in server-task.h and server-context.cpp were rebased by hand
next to SERVER_TASK_TYPE_REALTIME and the control dispatch.

The C++ backend was built on CPU for this change: the llama, mtmd and
server-context libraries and the grpc-server binary link.

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

* feat(llama-cpp): compile server-realtime.cpp into grpc-server

server-context.cpp includes server-realtime.h, and its realtime slot
type is defined in server-realtime.cpp. Upstream compiles that file
into the server library. The gRPC server is one translation unit, so
it must include the file too, or the link fails.

The include is guarded by __has_include. Forks that predate the
realtime split do not ship the file, so they keep building as before.

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

* chore(llama-cpp): probe realtime and TTS voice support in prepare.sh

prepare.sh writes llama_compat.h from probes on the checkout. Add two:
LOCALAI_HAS_REALTIME (SERVER_TASK_TYPE_REALTIME in server-task.h) and
LOCALAI_HAS_TTS_VOICE (set_voice in mtmd-helper.h). The probes let the
gRPC server choose the realtime and voice code paths per checkout, so
forks without them keep compiling.

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

* feat(llama-cpp): advertise speech recognition and speech-to-speech

llama-cpp now registers the AudioTranscription and AudioToAudioStream
RPCs, with the transcript and realtime_audio usecases. The default
usecase stays chat, so a plain GGUF is still a chat model. The
backend implements the RPCs in a follow-up change; until then the
calls return UNIMPLEMENTED.

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

* feat(config): add pipeline.any_to_any for speech-to-speech models

A realtime_audio model that serves the whole speech loop itself sets
pipeline.any_to_any. The flag is off by default, so chained pipelines
and existing realtime_audio configs are unchanged. The realtime_audio
heuristic stays explicit: a llama-cpp model is never a Talk-page model
unless known_usecases says so.

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

* feat(config): read the audio encoder and speech-to-speech projector flags

Add HasAudioEncoderProjector for clip.has_audio_encoder and
HasSpeechToSpeechProjector, which requires the LFM3 audio encoder
family together with the gen-audio decoder. Qwen3-TTS sets both encoder
flags too, so the flags alone cannot tell it apart from LFM3.

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

* feat(importer): import LFM3-Audio projectors as speech-to-speech

An mmproj that carries the LFM3 audio encoder and the gen-audio decoder
now imports with known_usecases realtime_audio, chat, transcript and
tts, pipeline.any_to_any: true, the parallel:2 option and the tokenizer
template. Other gen-audio projectors keep the TTS-only result, Qwen3-TTS
included. The probe runs against a local HTTP server in the tests.

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

* feat(llama-cpp): named-voice TTS and the LFM3 ASR prompt

Models without a speaker reference (LFM3-Audio) take a named voice
through their "tts" chat template instead of a reference file. The
request is validated with needs_reference=false, the prompt is rendered
with common_chat_get_tts_prompt(), and the voice is set on the gen-audio
input. Qwen3-TTS Base keeps the reference-file rule. The branch is
guarded by LOCALAI_HAS_TTS_VOICE, so forks without named voices keep the
old behaviour.

Transcription takes its prompt from common_chat_get_asr_prompt(). LFM3
gets a system turn that names the language and an empty user turn.
Qwen3-ASR's preset is unchanged: fixed user text, no system turn, and a
caller prompt is still appended after a newline. The preset call is
guarded by a new LOCALAI_HAS_ASR_PRESET probe.

The pure parts sit in tts_request_options.h and transcription_prompt.h,
with standalone tests run by backend/cpp/run-unit-tests.sh.

Add the test-extra-backend-llama-cpp-lfm3 target. It runs the tts,
ttsstream and transcription specs against LFM3-Audio GGUF weights. It is
a manual run and is not wired into CI.

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

* test(e2e): mock AudioToAudioStream and pending realtime S2S specs

The mock backend serves AudioToAudioStream deterministically: session
created, commit with RMS, a tool call on the first turn with tools, then
transcript pieces and paced 24 kHz tone frames, with response.cancel
ending a reply as cancelled.

The realtime S2S specs cover the mock and real-weights variants. They
skip until the /v1/realtime S2S path lands, so the mock suite stays
green in CI.

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

* feat(llama-cpp): speech-to-speech AudioToAudioStream on the realtime slot

Implement AudioToAudioStream on upstream's realtime slot, so a model
that answers after the audio (LFM3-Audio) can hold a conversation over
the existing duplex RPC. The proto schema does not change: tools travel
in the config, tool calls leave as function_call_arguments.done and tool
results arrive as a function_call_output item.

realtime_session.h keeps what needs no model: the turn text derived
from the chat template (with a tool turn), the conversation state, a
filter that keeps <tool_call> blocks out of the transcript and drops
audio produced inside them, and the translation of slot results into
events. grpc-server.cpp wires it to the slot with control tasks and
parses calls with the parser the chat template selects.

The unit test uses a fake renderer. A ctest-only check renders the real
LFM3 template and compares the slot text with the full render.

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

* feat(gallery): add the LFM3-Audio-2B entries

Two llama-cpp entries for LiquidAI/LFM3-Audio-2B-GGUF, the Q4_K_M
default (lfm3-audio-2b) and the Q8_0 build (lfm3-audio-2b-q8), both
with the Q8_0 audio projector. Each declares pipeline.any_to_any and the
realtime_audio, chat, transcript and tts usecases the importer writes
for an LFM3 projector, with parallel:2 and the tokenizer template. The
Q4 entry lists the Q8 one as a variant.

File sizes and sha256 come from the Hugging Face API for the pinned
repo revision. Speaker reference files and the VAD model are not part
of the entries: VAD stays optional.

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

* docs: describe LFM3-Audio speech-to-speech and its gallery entries

Adds a speech-to-speech section to the Realtime API page (any_to_any
configuration, VAD versus manual commit, tools, transcripts, barge-in,
limits), a pipeline.any_to_any row, a llama.cpp speech-to-speech row in
the compatibility table, and LFM3-Audio mentions on the TTS and ASR
pages. The feature is marked experimental: the behaviour follows the
backend session contract and has not been run with real weights.

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

* feat(realtime): run any-to-any models on AudioToAudioStream

A realtime_audio model with pipeline.any_to_any now opens one duplex
stream to the backend and keeps it for the session, replacing the
"any-to-any models not implemented" branch. The chained pipeline is
unchanged.

The endpoint forwards input audio (with a pre-roll from speech start
when a VAD model is set, otherwise every chunk and a client commit),
sends response.create, and turns the backend's events into the usual
realtime events: audio and transcript deltas, tool calls through the
existing function_call items, and a single response.done. A function
call output is forwarded before the next response. Barge-in sends
response.cancel ahead of the new speech and stops forwarding audio at
once. The user's speech is transcribed on the side and reported on the
user item; a failure there does not touch the response.

Server VAD is on only when pipeline.vad is set. Classifier mode,
compaction, streaming.llm, voice gating, sound detection and
semantic_vad are rejected for these models.

The mock backend now matches the llama.cpp contract (session.updated,
the cancel error when idle) and its VAD reports the loud stretches of
the audio, so the S2S e2e specs run a VAD turn. The specs are enabled.

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

* test(e2e): add the real-weights realtime S2S variant and the gRPC s2s spec

The realtime S2S specs now check reply audio on real models: duration,
loudness, clipping, and keywords found in the model transcript or in a
separate ASR of the output audio. run-realtime-lfm3.sh stages the llama-cpp
and sherpa-onnx backends, the LFM3-Audio files and the sherpa VAD, ASR
and TTS models, then runs the real variant. It is manual and never part of
CI: `make test-realtime` still filters out real-models.

The backend suite gains an s2s capability (spoken turn, tool call, tool
result, spoken answer over AudioToAudioStream) and optional TTS duration
and RMS bounds plus a transcript keyword check, all enabled for
test-extra-backend-llama-cpp-lfm3.

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

* test(e2e): expect the idle-cancel error from the mock S2S

The mock now answers a response.cancel with no reply running with
response_cancel_not_active, as the llama.cpp backend does. The cancel
spec still treated that cancel as a no-op and read the error as extra
data after the stream ended.

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

* feat(talk): show any-to-any models as one speech-to-speech model

/api/pipeline-models lists a pipeline.any_to_any model with any_to_any
set and its optional VAD. A realtime_audio model that is not any-to-any
is listed only when it has the four pipeline slots, since the session
takes the chained path for it.

The Talk page shows a speech-to-speech model as one model: no four-slot
view, the VAD slot only when a VAD model is set, and the female and male
voices offered. Without a VAD model the server does not detect turns, so
the page offers Done speaking, which sends input_audio_buffer.commit and
then response.create. Text-only replies stream through
response.output_text.delta and .done.

self_contained stays in the response for clients built before any_to_any.

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

* fix(realtime): refuse a llama-cpp realtime model without pipeline.any_to_any

llama.cpp has no VAD, transcription or TTS RPCs, so filling the empty
pipeline slots with the model name failed later with UNIMPLEMENTED.
Fail at session start and name the missing setting instead.

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

* fix(realtime): hold manual-commit S2S audio until the commit

In manual-commit mode the endpoint streamed every appended chunk to the
backend, so input_audio_buffer.clear only emptied the Go buffer while the
model kept the audio, and idle audio filled its context between turns.
Keep the audio in the input buffer and send it, behind pending text and
tool-result items, when the client commits or asks for a response.

Frames and controls now go through one ordered write queue, so no gRPC
send happens under the audio buffer lock. A tool result created while a
VAD turn is open is refused with a clear error. When the stream ends
outside a response the session reports it and closes, and a bare EOF
from Send reports the status that Recv holds.

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

* test(e2e): drop the S2S readiness gate and stale comments

The gate was always true. Some comments referred to work items that
readers of the repository cannot see.

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

* docs(realtime): add the liquid-audio migration note and the clear semantics

Say that the Python liquid-audio backend is gone, which gallery entries
replace it and that LFM2.5-Audio has no replacement. Describe what
input_audio_buffer.clear drops for speech-to-speech sessions now that
manual-commit audio is held back until the commit.

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

* fix(talk): do not promise turn detection when no VAD model is set

The idle note said the server notices when you stop talking. A
speech-to-speech model without a VAD model needs Done speaking, so the
note now says that.

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

* test(e2e): make the mock S2S backend keep the llama-cpp session limits

The mock accepted what the real backend refuses and hid two endpoint bugs.
It now treats output_modalities from the config as a fixed session mode and
picks the default per response from the tools set at that time. A fixed text
plus audio mode with tools reads the tool call out loud. After the first
commit or message, session.update of instructions, voice or tools is an
error that does not belong to a response, committing an empty buffer is an
error, and response.create while a reply runs is refused. Errors of a
refused response.create carry response_scoped in their meta.

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

* fix(realtime): leave the S2S output mode to the backend unless the client sets one

The stream config sent text plus audio when the session had no tools yet.
The llama-cpp backend keeps a mode from the config as a fixed session mode,
and tools added later by session.update never cleared it. The model then
read tool calls out loud. Send output_modalities only when the client chose
them. The backend picks the default of each response from the tools it has
when the response starts.

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

* feat(llama-cpp): mark the realtime errors that answer a response.create

The error event now carries response_scoped: true when it ends the request
for a response (a refused response.create, a failed commit made by it, or
a stream failure). Other errors, such as a refused session.update or a
commit of an empty buffer, are not about a response and one that runs
keeps generating. The client needs the difference to know whether a
response.done is still coming.

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

* fix(realtime): end an S2S response only on the errors that belong to it

Every backend error ended the response in flight, although the backend
also reports errors about other things (a refused session.update, a commit
of an empty buffer) and goes on generating. The rest of the reply was lost
and the next response.create met either a refusal or the leftover events of
the old reply. End the response only on an error marked response_scoped,
forward the others to the client and keep waiting for response.done. Drop
the events buffered by an earlier response when a new one starts, and skip
the commit of a turn that forwarded no audio.

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

* fix(realtime): run the S2S input transcription detached from the commit slot

The commit waited for the side transcription of the user audio while it
held the commit slot, and response.create waits for that slot. The ASR
latency was added before every reply, and on a backend with few slots a
busy ASR task could stall the reply. The model already has the audio, so
run the transcription detached under the session context and release the
slot at once. The transcript is still reported on the user item.

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

* test(realtime): do not pin the place of the input transcript in the S2S order

The transcript of the user audio is reported on the side and may land
before or after the first response events. Check the order of the response
events without it.

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

* fix(realtime): stop an S2S reader leak and a cancel that waits on the write queue

The reader waited on a channel it closes itself when the events buffer was
full, so it outlived the session after a turn ended on an error or timed
out. It now also stops when the session closes.

Stopping a response sent the cancel through the write queue and waited for
it before the grace timer started. With backpressure on the stream the wait
never ended and nothing read the events meanwhile. The timer now starts
first and the cancel is only queued.

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

* feat(realtime): let a chained pipeline run without a VAD model

A pipeline that names no pipeline.vad failed to build, because the
VAD slot was always loaded, and a new session still started with
server_vad. The slot is now optional for chained pipelines as it
already is for any-to-any models: the session starts with turn
detection off and the client commits each turn. Turning server_vad on
for such a session is refused, and a switch to a pipeline without a
VAD drops turn detection.

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

* feat(gallery): add LFM3-Audio VAD and chained-pipeline entries

Add lfm3-audio-2b-vad and -q8-vad (any-to-any with silero-vad-ggml as
the turn detector), and lfm3-audio-2b-pipeline, -pipeline-vad and the
-q8 forms (the model chained through its own transcription, chat and
speech slots, with and without the VAD). The gallery has no
dependencies between models, so the VAD entries state that
silero-vad-ggml must be installed first.

Drop the variants list from lfm3-audio-2b: it let the installer pick
the larger Q8 build on any host with enough memory, so a plain install
of the Q4 entry did not get Q4. All entries are now independent. Tests
cover the entry shapes, the single-build install and their acceptance
by the realtime endpoint.

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

* test(e2e): run the realtime specs for any-to-any and chained shapes

The realtime specs covered one any-to-any model with a VAD. They now run
for any-to-any and for a chained pipeline, each with and without a VAD,
on the mock backend in CI. VAD-less shapes also check that turn
detection starts off and that server_vad is refused. The manual
real-weights script writes one config per shape that mirrors the gallery
entries and runs the four shapes in turn (LFM3_MATRIX picks a subset).

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

* docs(realtime): document the LFM3-Audio VAD and chained-pipeline entries

List the eight LFM3-Audio gallery entries, explain that silero-vad-ggml
must be installed before the -vad entries, describe the chained pipeline
on one model and the behaviour of a pipeline without a VAD, and
describe the shape matrix of the e2e specs.

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

* test(realtime): wait for the detached input transcription in the S2S specs

The input transcription runs in its own goroutine, so the transcription
completed and failed events can arrive after the response ends. Poll for
them instead of reading the event list right after the response.

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

* fix(realtime): release the S2S stream before running server-side tool calls

A response held the stream slot until it returned, and it ran the tool
calls from inside that scope. A server-side tool starts a follow-up
response on the same stream, which then waited for the slot its parent
still held, so the client never got response.done. Run the tool calls
once the slot is free; the event order is unchanged.

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

* fix(realtime): end an S2S session whose stream dies, and stop queuing after close

A stream that died during a response left the session open: later
requests failed one by one and VAD frames kept being forwarded. The
response now ends the session through the transport, and enqueue drops
requests once the session is closed so the write queue cannot grow.

The writer is now the only caller of Send and of CloseSend. close()
used to call CloseSend while the writer could be inside Send, which
gRPC does not allow.

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

* fix(realtime): close the open S2S turn when the VAD stops

Switching turn detection to manual in the middle of a VAD turn left the
turn forwarding, and the next client commit sent the whole buffer a
second time. Stopping the VAD now commits the forwarded audio on the
backend and drops it from the input buffer.

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

* fix(realtime): keep a tool result pending while an S2S user turn is open

The check that refuses a tool result during a user turn ran when the
item arrived, but the item was forwarded later, and the result was
marked as sent before the backend took it. If the user spoke in
between, the backend refused it and it was never retried. The result is
now claimed and queued under the audio lock only when no turn is open,
and otherwise stays pending for the next commit or response.

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

* fix(talk): empty the input buffer for models without a VAD

Without voice detection the microphone fills the server's input buffer
for the whole session, so Done speaking committed idle silence and the
echo of the previous reply. Clear the buffer when the session starts and
when a reply ends.

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

* fix(importer): keep an existing parallel option on LFM3-Audio imports

The speech-to-speech import added parallel:2 even when the options
already carried another parallel value. Use the same check as the
hardware defaults, which also covers n_parallel.

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

* fix(talk): list chained pipelines that have no VAD

/api/pipeline-models required a VAD for a chained pipeline, so the
VAD-less LFM3-Audio pipeline entries never showed up on the Talk page.
Any pipeline with transcription, LLM and speech slots now qualifies.
The Talk page treats every model without a VAD as manual-turn, and the
session sheet says so for chained pipelines too.

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

* chore(llama-cpp): bump llama.cpp to 0ac1a7fd

Move the llama.cpp pin to 0ac1a7fd35c127a18c0cbc6c57d12e256a6a385b,
the master head of 2026-10-29. It contains tool calls in the realtime
server (ggml-org/llama.cpp#31287).

The score task patch is regenerated so that it applies with no offset
and no fuzz.

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

* fix(functions): keep a null tool argument null

A tagged parameter whose text was "null" came out as an empty string:
unmarshalling JSON null into a string succeeds and leaves it empty, so
the value was taken for a string. Check the first byte of the JSON
value instead. A parameter with the text "null" now becomes JSON null,
as in llama.cpp.

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

* feat(functions): add an lfm3 XML format preset

The LFM3 chat template writes a call as <tool_call>, <function=NAME>,
one <parameter=KEY> block per argument, then the closing tags. The
qwen3-coder format has the same shape and auto-detection already found
it, but xml_format_preset: lfm3 was not a known name.

Add the preset, with tests for one call, a call without arguments, two
calls, string and non-string values (numbers, booleans, null, arrays,
objects), a call between text, and unfinished input.

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

* feat(realtime): parse tool calls with the chat template, as upstream

Since ggml-org/llama.cpp#31287 the realtime server handles tools with
the parser and grammar of the chat template. Do the same in the
speech-to-speech stream instead of a hard-coded tag filter and a parse
of the whole text at the end.

- Each response gets a common_chat_session with the tools of the
  session and parallel calls. Its grammar and preserved tokens go into
  the sampling of the response, so the slot keeps the call tokens in
  the text and constrains the call.
- The text of the response is fed to the session piece by piece
  (task_result_state::update_chat_msg). Content deltas become the
  transcript; tool-call deltas are collected into one
  function_call_arguments.done per call at the end. Audio after the
  start of a call is dropped.
- A tool_choice of auto, none or required is accepted in the params of
  the config, in session.update and in response.create, with the
  response overriding the session.
- The tool turn is derived from a conversation in which the assistant
  called a tool, like upstream, not from a plain reply.
- response.done carries raw_text, everything the model wrote with the
  call markup, for a caller that parses it another way. If the parser
  throws, the rest of the response is content and the raw text is the
  way out.

ToolCallFilter is no longer used and goes in the next commit.

The unit tests cover the splitter contract (content, call deltas, the
final piece, whole characters only). realtime_chat_check feeds
LFM3-format text through the session in pieces of 1, 7 and all bytes
and checks the call, the transcript and the grammar.

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

* refactor(realtime): drop the tool-call text filter

The parser of the chat template now splits content from tool calls, so
ToolCallFilter, which looked for the literal <tool_call> tags, has no
caller. Remove it with its tests. Every case it covered (a call
between text, split tags, several calls, an unfinished call, text after
a call) is handled by the parser, and realtime_chat_check runs the
LFM3 format through it in pieces of different sizes.

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

* feat(realtime): fall back to the raw text for S2S tool calls

For an any-to-any model, the calls the backend parsed still win. When
there are none and the request had tools, parse the raw text that
response.done now carries the way the chained pipeline parses the text
of its LLM (CleanupLLMResult, ParseFunctionCall, ParseTextContent), so
a call format the backend parser does not know can still be served
with function.xml_format_preset or the other function settings of the
model. The content around the calls replaces the streamed transcript
only if the config captures it, as in the chained path.

Also pass tool_choice to the backend: the session mode with the stream
config and with session.update, and the mode of a response.create
override with that event. A named function cannot be forced, so it is
not sent.

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

* test(e2e): carry raw_text in the mock S2S response.done

Match the llama-cpp session: with tools set, response.done has raw_text,
the call markup of the LFM3 template for a tool turn and the transcript
for any other reply.

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

* fix(functions): parse tool blocks that have text between them

The iterative XML parser stopped at the first scope block that was
preceded by text, so "<tool_call>A</tool_call> then <tool_call>B" gave
one call. Skip text between blocks once a call was found; text before
the first block still ends the parse as before. Blocks that follow each
other, or only whitespace, already worked.

Tests cover three calls back to back and with text between them, with
the lfm3 preset and with auto-detection. They also pin the current
reading of argument values: the parser has no tool schema, so a value
such as 42 or true is read as JSON even when the schema types it as a
string.

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

* feat(gallery): read LFM3-Audio tool calls with the lfm3 preset

Set function.xml_format_preset: lfm3 on the eight LFM3-Audio-2B entries
and in the config the importer writes for a speech-to-speech projector,
so the server parses the call format of the LFM3 chat template when the
backend returns no call (and in the chained pipeline entries, which use
the regular chat path). gallery/index.yaml is regenerated from the
library.

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

* docs(realtime): describe how S2S tool calls are read

Replace the description of the hard-coded tag filter with the parser
and grammar of the chat template, tool_choice, and the raw-text
fallback in the server. Document function.xml_format_preset, and set it
in the real-weights e2e model configs like the gallery entries do.

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

* fix(functions): keep earlier calls when a later block does not parse

After a valid call, text that holds the opening tag again (a prose
mention, a markdown example, a malformed second block) made the parser
treat it as a new block, fail, and return no calls at all. Remember the
state after each complete block and fall back to it when a later one
fails: the calls found so far are kept, parsing stops there and the
remaining text stays in the content.

Text skipped between two valid blocks now goes to the content instead of
being dropped, so StripToolCallMarkup keeps it.

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

* fix(realtime): do not drop audio after a tool call started

In mixed mode the speech for text written before a call can still be
pending when the call starts, so dropping every audio frame after the
first call delta cut the speech while its transcript was sent. The
llama.cpp server never drops audio when it reads a result; do the same.
If a model speaks the call markup aloud, that is model behaviour to
observe on real weights, not something to filter here.

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

* fix(realtime): flush held-back text when a response is cancelled

The parser got is_partial=true on a cancelled response, so text it held
back (a possible start of a tag) never reached the transcript. The llama.cpp
server parses a cancelled response with is_partial=false; do the same. A
cancelled response still makes no tool call. The path where the parser
throws already sends the text as content and needs no change.

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

* test(realtime): stop the fake S2S stream from sending after Close

The barge-in spec failed now and then with a data race and "send on
closed channel": its script pushes audio from a goroutine, and the
session closes the stream while that goroutine still runs. A push now
does nothing once the stream is closed, and Close waits for a push in
flight. The production close order is right: the writer closes the
stream only after it stops sending.

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

* fix(realtime): align the S2S tool-call fallback with the backend

Four gaps in how the Go side reads the tool calls of an any-to-any reply:

- With tool_choice none (the response value wins over the session value)
  the backend builds no tool parser, yet the raw-text fallback still
  turned a call the model wrote into a function_call item. Skip it.
- Without a capture regex the fallback never cleaned the transcript, so the
  stored item and transcript.done kept the raw call markup. When the call
  is XML, use the text left after the markup is cut out; a reply that is
  only a call now has an empty transcript.
- A named function as tool_choice mapped to nothing, so the backend kept
  the previous mode. Refuse it with an error in session.update and
  response.create.
- A response-level tools list changed what the fallback looked for, but
  the backend only has the tools of the session. Use those in both.

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

* docs(realtime): say how S2S tool_choice and response tools behave

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

* fix(e2e): use the real llama-cpp backend in the real realtime run

The suite always registered the mock backend under the name llama-cpp, so
a run with REALTIME_S2S_MODEL set never reached the llama-cpp backend
found in REALTIME_BACKENDS_PATH. Register the mock only when that run is
off.

The real run converts the input audio with ffmpeg. Check for it in the
suite and in run-realtime-lfm3.sh before any download or backend start,
with a message that says what to install, and list it as a prerequisite
in the script header and the Makefile target.

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

* test(realtime): cover the tools path of a client session.update to the S2S backend

A tool call that never came back from an any-to-any model raised the
question whether the tools of the session reach the backend at all. Add a
spec that sends the session.update of the real-weights e2e (flat
Responses API tool, no session type) and checks that the backend gets
the tool in the chat-completions form.

Log the number of tools sent when the stream opens and when a
session.update carries them, so a run log shows it without reading the
wire. The "No tools received" line of the llama-cpp backend does not
show it: the server prints it for the input transcription request, which
has no tools by design.

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

* test(e2e): interrupt the chained pipeline reply while it is in flight

With unary stages, the default of the lfm3 pipeline gallery entries, the
chained pipeline synthesizes the whole reply and sends it as one audio
delta right before response.done. The barge-in spec waited for that
delta and then rendered the interruption with the TTS, so the first
response was already done when the speech started and ended completed
instead of cancelled. Real runs on a GPU showed it with both
quantizations.

For a pipeline, start the interruption at response.created, which comes
before the LLM and TTS stages. Render the interruption audio before the
first utterance is sent, so nothing sits between the reply and the
interruption. The any-to-any shape still waits for the first audio
delta, which that backend streams while it generates.

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

* test(e2e): accept a number word and its homophone in the spoken-answer check

The check that the reply audio says what the transcript says needs a
shared word of 4+ letters. A short answer failed it: the model wrote
"Four" and the ASR heard "for". Compare numbers in one spoken form, so
digits, number words and their homophones match each other. A homophone
only counts when the other text has the number itself, so "for" on both
sides still shares nothing and the check does not pass on a stop word.

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

* fix(llama-cpp): ask LFM3-Audio for function calls when a realtime session has tools

The default system prompt of a realtime session was "Respond with
interleaved text and audio." whatever the tools. Under that prompt
LFM3-Audio answers in words ("I'll check the weather ...") and never
writes the call. The model card, and the llama.cpp realtime example
client, use "Respond with function calls, and interleaved text and
audio." when the session has tools, and the model then calls the tool.

Pick the default by the tools of the session, and treat the plain default
that the LocalAI server sends back as no choice: it follows the tools too,
also when they arrive with a session.update before the first audio. A
prompt the client wrote is kept as it is.

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

* fix(realtime): transcribe the input of an any-to-any turn between responses

The input transcript of a speech-to-speech turn ran at the same time as
the reply, on the same model. llama.cpp serves both from one context: the
realtime slot wants embeddings on, the chat slot of the transcript wants
them off, and each flip makes it reserve its compute buffers again. In a
GB10 run that was two reserves of about 175 ms per step, the reply fell to
2.6 tokens per second and the transcript to 380 ms per token, until the
transcript was done.

When pipeline.transcription is the model of the stream, run the transcript
after the response, or before one that is not started yet, and keep
responses out while it runs. A separate transcription model still runs in
parallel.

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

* fix(realtime): send the input transcript of an any-to-any turn before its reply

The previous change moved the same-model transcript after the response.
A client that reads until response.done then never saw
conversation.item.input_audio_transcription.completed: the transcript
was made (the log shows it) but sent after the end of the window.

Run it in line with the commit instead. A VAD turn transcribes and then
responds. A client response.create already waits for the commit slot, so
it starts once the transcript is out. The transcript event now comes
before any output of the response of its turn, for a manual commit and for
a VAD turn, and never overlaps the response on the llama.cpp context. A
transcription model of its own still runs in parallel.

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

* feat(realtime): transcribe the input of an any-to-any model only when asked

The stream answers from the audio, so the transcript of the input is for
the client only. On the model of the stream it is costly: llama.cpp
serves the transcript request and the realtime stream from one context
and rebuilds its compute buffers on every step while both are active.
On a GB10 that was 26 to 29 reserves of about 175 ms per turn, against 0
to 1 on the llama.cpp server, and the reply ran at a few tokens per
second.

An any-to-any model no longer fills pipeline.transcription with its own
name. Without it the session does not transcribe the input; with another
model it does, in parallel with the reply; with the model itself named it
does, before the reply. The reply transcript always comes from the stream.
The gallery entries set none, so they get the fast path.

The mock configs and run-realtime-lfm3.sh name a transcription model, so
the real-weights spec still covers the input transcript.

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

* fix(realtime): end a response as cancelled when a barge-in lands during the LLM call

The chained pipeline cancels the response context on a barge-in. When that
happens while the model is generating, the backend call fails with the
context error, and the response ended as failed: an error event and no
response.done. A client that waits for the terminal of the interrupted
response then waits forever. On a GB10 the q4 model answered before the
barge-in arrived, so only the slower q8 model showed it: the spec read
events for 30 s and timed out.

When the context is cancelled, a failed Predict call or a failed result is
a cancel, as the TTS step and the streamed LLM path already treat it. The
response ends with response.done{cancelled} and no error.

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

* test(e2e): wait for the input transcript that comes from a model of its own

An any-to-any model now gets its input transcript from a separate ASR
that runs next to the reply. The reply is often over first, so a spec that
stops reading at response.done saw no transcript in the VAD shape. Keep
reading, up to 30 s, until the transcript arrives. The contract is that it
comes, not when.

Add a guard spec for what the input transcription is handed: the whole
turn, in a WAV that says the rate it was recorded at (the session rate for
a client commit, 16 kHz for a VAD turn).

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

* test(e2e): check the errors of Close in the realtime specs

golangci-lint (errcheck) flagged the unchecked Close of the websocket
connections, the response body and the fake stream. Use the
`defer func() { _ = x.Close() }()` form the other e2e specs use.

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

* test(e2e): write the mock S2S specs with Ginkgo and drop the env read

forbidigo only allows Ginkgo/Gomega specs and no os.Getenv outside the
sanctioned places. Convert the mock S2S tests to Describe/It with a suite
bootstrap (the package had none), with the same 11 cases. The mock no
longer reads MOCK_S2S_FRAMES: only these specs set it, so the reply length
is a package variable they change per spec and restore afterwards.

Assisted-by: Claude:claude-sonnet-5-5
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>
2026-10-29 22:06:41 +01:00
Stefan Walcz 6d7e8171be feat(agents): per-model output cap for agent job completions
Agent jobs (/api/agent/jobs, local and on distributed agent workers) run
cogito against a LocalAI client that sends no max_tokens of its own, so
every completion gets the client's fixed default of 16384. max_iterations
bounds the number of turns and loop_detection repeated tool calls, but
neither stops one completion that keeps generating, and 16384 is the same
for a small tool-picking model and a reasoning model that thinks at length.

Add agent.max_generation_tokens to the model config. When set, both job
paths apply it to the client with SetMaxTokens; requests that carry their
own cap still win, so cogito's length retry can raise it. Unset keeps
today's behaviour.

The specs run each job path against an MCP fixture and a recording LLM
endpoint and check the max_tokens the completions carry; they fail when
either call site drops the cap.

Assisted-by: Claude Code:claude-opus-5-5
Signed-off-by: Stefan Walcz <stefan.walcz@walcz.de>
(cherry picked from commit 5600a1cabe)
2026-10-25 09:21:58 +00:00
pos-ei-don 073adc50c9 feat(sglang): map reasoning_effort to per-level thinking budgets
The Go side already forwards the request's effort as
Metadata["reasoning_effort"], but the sglang backend only read
enable_thinking: every level got the same model-level thinking_budget,
so low/medium/high produced identical reasoning.

- New model option `effort_budgets:low=…;medium=…;high=…` picks the
  budget of the request's level; unlisted levels and requests without an
  effort keep `thinking_budget`. Parsing reuses _parse_thinking_budget.
- A level listed in effort_budgets turns reasoning on for that request,
  so levels the Go side does not know cannot silently disable it.
- ApplyReasoningEffort now treats `xhigh` and `max` as thinking levels.
  Before, they left DisableReasoning unset, so with a model default of
  "off" asking for more reasoning produced none.

Signed-off-by: pos-ei-don <1822533+pos-ei-don@users.noreply.github.com>
(cherry picked from commit 879c686ab1)
2026-10-25 08:50:08 +00:00
pos-ei-don a1e0faa9f0 docs(vllm): explain sampling validation
Describe stop strings, greedy decoding, and seed handling after sampling
parameters are validated during construction.

Signed-off-by: pos-ei-don <1822533+pos-ei-don@users.noreply.github.com>
(cherry picked from commit 05af7b072c)
2026-10-25 08:43:05 +00:00
e7cc993b0f feat(llama-cpp): add lazy_mode model option (#12743)
* feat(llama-cpp): add lazy_mode model option

Expose upstream --lazy-mode (on, auto, off) as a model option, for
example `lazy_mode:on`. It controls on-demand reading of large tensors
such as per-layer embeddings; `on` requires mmap.

The option is also accepted as `lazy-mode` or `lzm`. Invalid values log
a warning and keep the upstream default (auto). The pinned llama.cpp
already provides the lazy_mode field in common_params.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* chore(llama-cpp): bump llama.cpp to c0fd78626405

Update the default llama.cpp pin to the current upstream master. The
changes since the old pin that the backend touches are additive: a new
common_decision_type value, new mtmd audio-stream and download helper
declarations. The two server patches still apply in order, and the
common_params fields and server headers the backend uses are unchanged.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
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>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 22:38:23 +02:00
mudler-agentandEttore Di Giacinto 849c177834 feat(llama-cpp): bump llama.cpp and add moe_cache_mib option (#12729)
Bump LLAMA_VERSION to b76b13379d3b (upstream 0.7.0 plus later
commits). This is the same one-line change as the bot PR #12725.

Add the moe_cache_mib model option. It sets upstream's
moe_cache_size, a GPU cache for MoE experts kept in host memory,
from a size in MiB. It is off by default. Invalid, negative or
non-numeric values are ignored with a log line. Forks that predate
the field ignore the option, using the same prepare.sh probe as the
other fork-skew switches.

Document the option, and cover the MiB to bytes conversion with a
standalone unit test.

Assisted-by: Claude:claude-sonnet-5-5 g++

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-10-16 00:55:55 +02:00
mudler-agentandEttore Di Giacinto c7ce0c602c fix: stop listing the models directory on request paths and cache GGUF header reads (#12683)
* fix(http): stop listing the models directory on embeddings and bearer requests

Every embeddings request read the whole models directory before the body
was parsed, even when the body named its model. On a network-backed
models directory this cost seconds per request.

Embeddings now binds the body first and looks up a default model only
when the body has none, like chat. The pre-bind default lookup and the
gpt-4o constant default are gone from that route.

The first-available default lookup skips the directory when the filter
cannot accept an unconfigured file. Every usecase filter rejects files
without a config, so the scan never added results for them. Match-all,
nil and name filters keep the scan.

The bearer token check is now an exact test: a configured name, or one
stat of that name in the models directory. The value is a literal, not a
regular expression, and a name that is not a local relative path never
matches a file.

Assisted-by: Claude Code:claude-haiku-5-5

* perf(config): cache GGUF header reads for model defaults

Model defaults re-parsed the GGUF header on every SetDefaults call, which
runs per request and at startup reload. Put a bounded LRU in front of the
parser, keyed by absolute path and validated by size and modification
time. The cached value is a snapshot of the fields the defaults use, not
the parser output.

A changed size or mtime is a miss. Missing files and parse errors are not
cached, so the existing fallback is unchanged. LOCALAI_DISABLE_GUESSING,
the empty-path guard, the alias early return, the panic recovery and the
context-size default are all kept in front of the cache.

Assisted-by: Claude:claude-haiku-5-5

* test(http): cover listing-free default lookup on every usecase route

Assisted-by: Claude Code:claude-haiku-5-5

* feat(config): persist the GGUF header cache per instance

The header cache kept parsed GGUF results only in memory, so every restart
parsed each model file again before its defaults were known. Write the cache
to a per-instance file in the data path and load it at startup. A restarted
frontend with a fixed instance ID starts warm.

The file is versioned and records the estimator inputs (gguf-parser version,
candidate contexts, auto context size, offload count). A mismatch on any of
them makes the file ignored. Each entry keeps path, size and mtime, so a
changed model file is a miss on load. Writes run on one background saver,
debounced, through a temp file and rename. The entry bound applies to the
file as well. Without a data path the cache stays in memory only.

Documented as a per-instance exception in the model configuration docs.

Assisted-by: Claude:claude-haiku-5-5

* docs(embeddings): document model selection and the no-model error

Assisted-by: Claude Code:claude-haiku-5-5

* refactor(config): split the header cache into a generic cache

The GGUF header cache mixed two things: the mechanics (bounded LRU keyed
by path and identity, per-instance file, debounced atomic save, tolerant
load) and the GGUF fields. Move the mechanics into headerCache[S], driven
by a headerCacheSpec[S] that names the file prefix, schema, fingerprint,
parser and the snapshot encode/decode pair. The GGUF cache becomes one
instance of it.

File names, the identity check, LRU size, debounce, atomic write and the
corruption handling are unchanged. The on-disk JSON keeps the "estimator"
key so existing files still load.

Assisted-by: Claude Code:claude-haiku-5-5

* feat(config): share one GGUF header cache file between frontends

Every frontend used to write its own header cache file, named after its
instance ID, so a header read by one frontend was never reused by another.
The cache now uses one file, header-cache.json, in the data path, for all
frontends and for standalone mode. The instance ID no longer changes the
file name.

Frontends share the file in three ways. A save first merges the file's
entries in, so one frontend's write does not drop another's entries. A
miss reloads the file when its modification time has changed, and a hit
never reads it. Writes go to a temp file that is renamed into place, so a
reader never sees a partial file.

Two frontends saving at the same moment can still drop each other's last
entries. That costs one re-parse, never a wrong value, because every
entry is checked against its model file before use.

The shared file is valid only when every frontend mounts the same data
volume. The distributed-mode and model-configuration docs state this mode
and its volume limit.

Assisted-by: Claude:claude-haiku-5-5

* feat(config): give an instance ID its own header cache file

Assisted-by: Claude Code:claude-haiku-5-5

* style(config): gofmt the distributed config struct

Assisted-by: Claude Code:claude-haiku-5-5

---------

Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-10-14 01:06:14 +02:00
Stefan Walcz e3341d21a0 feat(pii): extend a detection over the previous word (extend_to_previous_word)
The mirror of extend_to_next_word. A name model often tags the surname with
high confidence and scores the first name in front of it below min_score,
so "Gast Greta Waldmeister" became "Gast Greta [REDACTED:ner:LASTNAME]".

extend_to_previous_word lists entity groups whose accepted detections also
cover the word directly before them on the same line. The preceding word
must consist of letters (plus '-' and the apostrophe): a word with digits -
an amount, a date, a room number - is never taken, nor is a protected term,
and the search never crosses a line break.

Docs: operations/middleware.md and advanced/model-configuration.md.

Assisted-by: Claude Code:claude-opus-5-5
Signed-off-by: Stefan Walcz <stefan.walcz@walcz.de>
(cherry picked from commit b1845b8bed)
2026-10-13 08:10:55 +00:00
Stefan Walcz e16b9d04cc feat(pii): protected terms that pass the NER filter unchanged
A name model reads many business names as surnames. Measured with
privacy-filter-multilingual on 24 synthetic German receipts and
invoices: the supplier name ("Hotel Seeblick", "Café Lindenhof", ...)
was masked as LASTNAME (0.78-0.98) or FIRSTNAME in 9 of 12 receipts
that carried one. The supplier is exactly what the upstream model needs
to categorise a receipt, and no score floor helps at those confidences.

pii_detection.protected_terms (inline) and
pii_detection.protected_terms_files (files inside the models path, one
term per line, '#' comments, re-read when size or mtime change) list
values that must pass unchanged. Each whole-word, case-insensitive
occurrence is replaced by a neutral placeholder before the detector
runs; every detection is mapped back onto the original text with the
placeholder parts cut out, so a protected term is never masked or
blocked, the detector never sees it as context, and event offsets keep
addressing the original text. A word extension (extend_to_next_word)
is dropped rather than run into a protected term.

Terms shorter than three characters are ignored. A missing or
unreadable file is logged and skipped: fewer protected terms means more
masking, never less. Paths are verified to stay inside the models path.

Opt-in, empty by default. Covered by Ginkgo specs in
core/services/routing/pii/protected_test.go (detector-visible text,
exact restoration, straddling detections, whole-word/case/line-wrap
matching, the segment path, file loading and reload), a YAML round-trip
spec, registry entries and the middleware/model-configuration docs.

Assisted-by: Claude Code:claude-opus-5-5
Signed-off-by: Stefan Walcz <stefan.walcz@walcz.de>
(cherry picked from commit 01f71772a5)
2026-10-13 08:10:55 +00:00
Stefan Walcz 9b13408c94 feat(pii): extend a detection over the next word (extend_to_next_word)
Address models tend to tag a postal code with high confidence but miss
or under-score the town right after it. Measured with
privacy-filter-multilingual on 24 synthetic German invoices: the postal
code was masked every time, while the town after it stayed in the clear
in 6 of 18 cases (4 not detected at all, 2 tagged CITY at 0.43/0.47,
below the configured floor). Masking the code alone leaves the pair half
redacted: "[REDACTED:ner:ZIPCODE] Ludwigshafen".

pii_detection.extend_to_next_word lists entity groups whose accepted
detections also cover the word that directly follows them on the same
line, with the same action and group. Horizontal whitespace is skipped,
a line break ends the search, and nothing is extended when no word
follows, so a hit never grows over bare whitespace or onto the next
line (or across the segment separator on the multi-message path).

The option is opt-in and empty by default; existing detector configs
behave exactly as before. Covered by Ginkgo specs in
core/services/routing/pii/extend_test.go (fail without the change), a
YAML round-trip spec, a registry entry for the model editor, and the
middleware and model-configuration docs.

Assisted-by: Claude Code:claude-opus-5-5
Signed-off-by: Stefan Walcz <stefan.walcz@walcz.de>
(cherry picked from commit c67c0782f8)
2026-10-13 08:06:49 +00:00
4d7bdc6ff2 feat(ui): redesign the web UI around a shared kit and a calm palette (#12526)
* build(ui): vendor the shared UI kit snapshot at 0.2.0

The restyle needs the kit's tokens, motion layer and component classes.
Take a pinned snapshot instead of depending on the kit at build time,
and keep a lock file with the version and per-file checksums so a later
update shows exactly what changed. The product theme stays outside the
vendored directory.

Assisted-by: Claude Code:claude-sonnet-5-5

* feat(ui): add the ink and teal theme and bridge the old variables

Define the product colours as the shared UI kit's roles, for light and
dark, in theme-localai.css. The kit's contrast check passes on every
pair. theme.css keeps the existing --color-* and --shadow-* names but
now points each at a role, so App.css and the pages get the new palette
without edits. Radii move to the kit scale.

index.html now sets data-theme before first paint with the same rule as
ThemeContext (stored choice, otherwise dark), because the contract
layout of the theme file no longer defaults to dark by itself.

Assisted-by: Claude Code:claude-sonnet-5-5

* feat(ui): restyle the shared chrome with the UI kit grammar

Adjust the shared classes so every page picks up the same interaction
language without per-page edits:

- Sidebar sits on the canvas and the current row lifts onto a card.
  Section labels are tracked uppercase, the badge is a soft pill, and
  the phone drawer leaves the tab order when closed.
- Buttons are flat: hover swaps the surface, press scales to .97, focus
  is a 2px ring with a 2px offset, danger is a tinted wash.
- Inputs use the card surface and the control edge; switches, tabs,
  filter chips, badges and cards follow the same rules. Cards no longer
  lift on hover; only linked or button cards react.
- Menus and popovers scale in from the trigger corner with 40px items.
  Dialogs get a veil fade and a spring settle. Toasts become pills at
  the bottom centre.
- The page transition is a 250 ms fade with a 6px rise. It fills
  backwards so a finished animation no longer leaves a transform that
  confined dialog veils to the main column.

The focus-ring test now checks the outline instead of a box shadow, and
new specs cover the theme roles, the first-paint theme and the sidebar
lift.

Assisted-by: Claude Code:claude-sonnet-5-5

* fix(ui): move leftover hard-coded colours onto the theme roles

The YAML editor restated the old blue palette in JavaScript, and a few
pages kept literal blues, indigo and violet tints, or fallbacks that
only applied because a variable was never defined. Point them at the
theme variables so they follow light and dark and the new palette.

The status badges in the account pages built their tint by appending
"22" to a variable, which is not valid once the variable is defined, so
they had no background. Use the wash roles instead.

Assisted-by: Claude Code:claude-sonnet-5-5

* feat(ui): raise the type scale and control size toward the kit

Body and list text moves to 15px and the rest of the scale follows the
kit's 12/13/15/17/21/32/44 steps. Page titles, section headings and
stat values are bold with tighter tracking; titles are 32px.

Buttons, inputs, selects, tabs and nav rows are 40px high with the 12px
radius, compact controls 32px. Tabs become a segmented control. The
sidebar widens to 240px (64px collapsed) and nav rows get more room.
Identifiers and counts in the split views use the mono face, and the
stat grid becomes separate inset tiles.

The Geist stack stays: it is bundled, and the thin look came from the
size, weight and negative tracking, not the face.

Assisted-by: Claude Code:claude-sonnet-5-5

* feat(ui): separate cards, panes and floating surfaces from the canvas

Cards, the Models and Installed split panes, the composers and the
confirm dialog use a stronger card edge, the rest shadow and the 20px
radius, so they read as layers in dark as well as light. Menus and
popovers move to a float surface (the hover tone in dark) with the
float shadow.

The selected rail row gets an accent wash and a 3px accent edge. The
send buttons are a clear accent when there is something to send and a
quiet inset when not; the Home button carries data-empty for that, since
submitting an empty box does nothing. The assistant card becomes an
accent wash with a square icon.

New surfaces spec checks the pane edge, the selected row, both send
buttons and the popover in both themes. The voice library empty-state
spec now waits for the layout to settle before comparing two boxes.

Assisted-by: Claude Code:claude-sonnet-5-5

* feat(ui): tidy the sidebar header and mark the current row with a dot

The header gives the configured horizontal logo a fixed width and
centres it in a 72px band, lined up with the nav icons. The collapsed
rail shows the configured icon logo centred, and its nav rows become
40px tiles centred in the 64px rail. The current row gets the kit's
accent dot, hidden in the rail.

The theme, language and account controls stay in the sidebar footer:
the app has no global search or command palette to put in a top bar, so
a bar would only hold controls that already have a place.

Assisted-by: Claude Code:claude-sonnet-5-5

* fix(ui): centre the avatar in the collapsed sidebar rail

The collapsed avatar link was set to "flex: 0", which gives it a zero
flex basis; with min-width: 0 the link shrank to its padding and the
icon overflowed from the link's left edge, about 14px right of the
icon column. Use "flex: 0 0 auto" in the collapsed and tablet rail.

The footer controls now share the nav icon column in the expanded
sidebar too (6px footer padding, 40px control boxes), and the tablet
rail gets the same footer padding and hidden language code as the
collapsed one.

New spec measures the centre x of the nav icons, mark, avatar,
language, theme and collapse icons in the collapsed, expanded and
tablet states, in both themes, and asserts they agree within 1px.

Assisted-by: Claude Code:claude-sonnet-5-5

* fix(ui): size the console and settings rails and stack settings on phones

The Operate console rail, the Settings section rail and the account tab
bar still used 13px text and the old underline tabs. They now use the
15px nav size, 40px rows and the segmented tab control. Form row labels
are 15px with 13px hints.

On a phone the Settings section rail sat beside the form and squeezed
every row into a few characters. Below 720px the rail stacks above the
content as a scrolling row and form rows wrap their control below the
label. The save button no longer carries the icon font class, which
drew a missing glyph before its label. The language menu is wide enough
to keep Bahasa Indonesia on one line.

Assisted-by: Claude Code:claude-sonnet-5-5

* build(ui): update the vendored UI kit snapshot to 0.3.0

Take the 0.3.0 snapshot: the sprite now carries the full outline icon set,
and the new icons/fa-map.json maps Font Awesome names to icon ids. The map
lets the app move off Font Awesome in the following commits. The lock file
is regenerated with the new checksums.

Assisted-by: Claude Code:claude-sonnet-5-5

* feat(ui): add an Icon component backed by the kit sprite

Icon draws an inline svg that points into the kit's outline sprite. The
sprite is inlined into the page once, so the references resolve under any
base path and in the embedded build without a request. Icons size with the
font (1em), take currentColor, hide from assistive tech unless given a
title, and spin on request. An unknown id draws a neutral circle.

FaIcon and iconFromFa resolve Font Awesome names through the kit's map,
for names that arrive at run time. iconHtml does the same for markup built
as a string. The GitHub and Apple marks are small local glyphs, as the kit
ships no brand marks.

Assisted-by: Claude Code:claude-sonnet-5-5

* refactor(ui): draw shared components and helpers with Icon

Replace the Font Awesome elements in the shared components and in the
utility modules with the Icon component. Lookup tables now hold kit icon
ids instead of class strings. Code-block copy buttons and artifact cards,
which build HTML strings, use iconHtml and a sanitizer-safe slot.

Assisted-by: Claude Code:claude-sonnet-5-5

* refactor(ui): draw model, backend and account pages with Icon

Replace the Font Awesome elements on the home, models, backends, import,
settings, login, account and users pages with the Icon component.

Assisted-by: Claude Code:claude-sonnet-5-5

* refactor(ui): draw chat, studio and recognition pages with Icon

Replace the Font Awesome elements on the chat, media generation, talk and
face and voice pages with the Icon component. The talk status table keeps
its spin and pulse states as Icon props. The connected and error states
now use a dotted circle and an alert circle, so they differ from the idle
ring by shape as well as by colour.

Assisted-by: Claude Code:claude-sonnet-5-5

* refactor(ui): draw agent, node and operate pages with Icon

Replace the Font Awesome elements on the agents, skills, collections,
jobs, fine-tune, quantize, nodes, swarm, usage, traces and activity
pages with the Icon component. Two class strings on layout elements held
leftover button and icon classes from an earlier merge; they are cleaned
up so the elements keep only their own classes.

Assisted-by: Claude Code:claude-sonnet-5-5

* style(ui): size and align icons for the svg component

Icon rules that targeted the font element now target the svg: the
descendant "i" selectors in App.css and auth.css become ".lai-icon". The
svg is 1.2em with a 2 unit line so it matches the visual size of the old
glyphs at the 12 to 16px sizes the app uses, sits on the text baseline,
and follows the context font size. Large empty-state marks get a lighter
line. Menu icons get a 16px box and the readiness badge icons keep their
20px circle with padding. Add the pulse used by the talk status.

Assisted-by: Claude Code:claude-sonnet-5-5

* build(ui): remove Font Awesome

No source file references the icon font any more. Drop the package and its
stylesheet import. The build no longer ships the solid, regular and brand
font files.

Assisted-by: Claude Code:claude-sonnet-5-5

* fix(ui): keep focus traps off the svg use references

The dialog and drawer focus traps collect focusable elements with a
"[href]" selector. An icon's use element carries an href, so it became the
first "focusable" element and Tab at the end of the dialog stopped there
instead of wrapping to the first button. Match "a[href]" instead.

Assisted-by: Claude Code:claude-sonnet-5-5

* style(ui): keep icon sizes overridable and set the line width per svg

Give the icon base rule zero specificity so a rule that sizes one icon
(nav column, menu box, avatar, language switcher) wins whatever its order
in the file. The sprite symbols fix their own line width; the inlined copy
drops it so the width set on each svg applies, as the --lai-stroke custom
property, and large marks can use a lighter line. Pin the avatar and the
language globe to the boxes the sidebar alignment spec expects. Import the
map as JSON with an import attribute so Node can load it in the spec.

Assisted-by: Claude Code:claude-sonnet-5-5

* test(ui): select icons by the svg markup and cover the sprite

Specs that found icons by their Font Awesome class now select the svg by
its data-icon. The dead-icon audit checks that every svg resolves to a
sprite symbol and has a size. The class hygiene spec fails on any
remaining Font Awesome class. A new spec checks every mapped icon id has
a symbol, that the sprite is inlined once, that an icon paints at the root
and under a forwarded path prefix, and that Font Awesome names map as
documented.

Assisted-by: Claude Code:claude-sonnet-5-5

* build(ui): update the vendored UI kit snapshot to 0.4.0

Take the 0.4.0 snapshot: hub tabs with count and attention badges, the six
chart series tokens and the grid colour in the theme contract, and sample
themes on a calmer palette. The kit headers are renamed and the lock file
is regenerated with the new checksums, as for the earlier snapshots.

Assisted-by: Claude Code:claude-sonnet-5-5

* style(ui): switch the theme to the calm palette

Rewrite the LocalAI theme on the calm palette: a muted teal accent on a
near-neutral green-grey canvas, desaturated status colours, no glow and no
coloured shadows. The theme fills every role of the shared UI kit's 0.4.0
theme contract for light and dark, including the six chart series and the
grid line. The bridge in theme.css keeps the old --color-* names working,
adds the dark surface ladder (card, raised, float) and a strong edge, and
points the fixed data hues at the chart series.

Two values differ from the first sketch. The dark text on the accent fill is
#021512 instead of #04201d: it reads 5.58:1 on the fill at rest and 6.4:1 on
the hover fill, against 5.08:1 at rest for the lighter value. The light
control edge is #6b7d7a. The kit's contrast script passes for all text pairs
(4.5:1), control and focus pairs (3:1) and series colours (3:1).

Leftovers that no longer fit the palette are fixed: the usage chart takes
the six series colours in order, the audio and animation canvases fall back
to the new accent, the face box loses its glow, and two gradient fills are
now flat. The theme tests expect the new canvas colours.

Assisted-by: Claude Code:claude-sonnet-5-5

* feat(ui): replace the console rail with hub tab bars

Build and Operate no longer open a second navigation rail beside the page.
Each is a hub: one row of the kit's hub tabs above the page, with count and
attention badges that scroll sideways on a phone. Every URL and route stays
as it was, plus a new /app/build landing page that lists the Build tools
with a line each.

Build tabs: Overview, Agents, Skills, Memory, Jobs, Fine-Tune, Quantize,
Import, Voices (recognition and library) and Faces. Operate tabs: Status,
This machine, Swarm (distributed mode only), Runtime (backends, activity,
failover), Traffic (usage, traces, middleware) and Settings (settings,
users), plus the API link. A tab that holds several pages shows a second row
of links, and a sub-page such as a node detail keeps its tab highlighted.
The feature and admin gates decide which tabs are drawn, and badges show only
values the Operate summary already has.

The sidebar lists Build and Operate under a Workspace label next to the
Create group. The voice library moves under Build and the model import page
gains the Build tab bar. The old rail styles, the rail signals and the
console config are removed, and the Operate overview docs describe the tab
bar. The specs that drove the rail now drive the tabs, and a new spec covers
the tab for each route, gating, badges and the phone layout.

Assisted-by: Claude Code:claude-sonnet-5-5

* feat(ui): rebuild Home as a calm console

Home now opens on one command bar: the model chip shows which models are
warm, the MCP chip and attach buttons sit beside it, and Send is a solid
button with an Enter glyph. Typing "/" opens a grouped, keyboard-driven
action list built on the kit command list; every action has a destination
in the product.

Memory use folds into a one-line strip that opens into the loaded models,
with Stop per model and Stop all. It opens by itself while a model is being
staged and after a failure, and shows nodes and aggregate memory in a
cluster. The list of resident models carries no per-model size because the
API reports none.

"Jump back in" lists the conversations stored in the browser, one card per
day, with j and k to move, Enter to resume and delete with an undo toast.
First run keeps the install steps and the recommended models. The assistant
prompt is a dismissible line, the library links are one quiet row and the
API section is collapsed. Chat accepts an empty new-chat hand-off for /new.

Assisted-by: Claude Code:claude-sonnet-5-5

* test(ui): cover the Home console

Update the Home specs for the new structure and add specs for the slash
menu, the model chip, the memory strip (expand, stop, staging, failure,
cluster), the resume list (grouping, j/k, Enter, delete with undo), first
run, the send hand-off, a non-admin user and the phone layout.

Assisted-by: Claude Code:claude-sonnet-5-5

* feat(ui): add the fit, disk and cleanup helpers for the models page

Pure functions and hooks that the rebuilt Models page reads, with node
tests for the rules.

modelLedger turns an estimate and the memory budget into one of three
verdicts (fits, spills to CPU, over) with the headroom in bytes, and
reads the models disk from the resources reading. The disk counts as low
under 10 percent or under 20 GB free, and is absent when the server
reports none or runs as a cluster controller.

cleanupPlan ranks installed models from what the API reports: loaded,
pinned, or named by an agent, a task, a failover chain or an alias keeps
a model protected; another installed build of the same gallery model is a
duplicate; disabled models rank above idle ones. The API records no last
use or use count, so none is used. When a lookup fails, nothing is called
safe.

useModelRemoval holds a removal in the browser for an undo window and
sends the existing delete call only when the window ends. Leaving the page
drops the batch without deleting anything. The undo toast takes optional
labels so other pages can reuse it.

Assisted-by: Claude Code:claude-sonnet-5-5

* feat(ui): rebuild Models as a ledger with a disk strip and cleanup review

Explore is one dense table. Each row carries the size, a solid memory bar
and the headroom in words ("3.7 free", "+1.5 on CPU", "0.9 over"), worked
out from the estimate at the chosen context length. Capability chips show
the server's count for each facet, search keeps its meaning and "/" jumps
to it, and a density switch (also "d") picks comfortable or compact rows.
Selection is a surface step and a check, never a rail. Arrow keys move,
Enter installs and Esc closes the inspector, which keeps the fit summary,
VRAM by context chart, variants, files, links, tags and licence. A failed
install shows its error in the row with a Retry that dismisses the old
failure first. A failed or empty listing says which it is, and a host with
no GPU is measured against memory and says so.

Installed uses the same table with state filters that carry counts, a
state per row, Load or Stop on the row, the row menu and the sort by size.
Sizes come from the files the gallery lists, so a model it does not know
shows a dash.

A strip in the header shows the free space on the models disk. It turns
amber under 10 percent or under 20 GB free, hides when the server reports
no disk or runs as a cluster controller, and opens the cleanup review.
Explore says how much an install leaves free.

The review ranks installed models as Safe to remove, Probably safe and
Your call from real facts only, lists protected models with the reason,
and says plainly that usage history is not recorded. A sticky bar shows
what a choice frees. Confirming runs a dry run that checks again and lists
what will go. Removal waits 30 seconds with an undo; nothing is deleted
before that, and leaving the page deletes nothing.

The old rail, filter band and popover styles are removed.

Assisted-by: Claude Code:claude-sonnet-5-5

* test(ui): cover the Models ledger, Installed table and cleanup review

Update the Models, lifecycle, cluster fit, height, search focus and
surfaces specs for the table and inspector, keeping what each one checks.

New specs, on a shared 41-model gallery stub with three machine profiles:
the fit bar and headroom words for a 24 GB card, an 8 GB laptop and a host
with no GPU; facet counts, search, "/" and Escape; selection, arrow keys,
Enter to install, density; the disk strip when normal, low and hidden; and
the states (loading, empty, offline, install failed, phone). Installed
covers filters with counts, row actions, the row menu, sizes and sort.
The cleanup specs cover grouping, protected models, the honest-data note,
the effect bar, the dry run, the undo window, a failed delete, leaving the
page, and the phone sheet.

Assisted-by: Claude Code:claude-sonnet-5-5

* feat(ui): add the placement helpers and the estimate hooks

The Placement section and the model page need the same few rules, so they
sit in plain functions that can be read and tested alone.

placement.js holds what gpu_layers, tensor_split and main_gpu mean (unset
asks for every layer and the llama.cpp engine trims it, zero is CPU only,
99999999 is the value LocalAI itself writes for all layers), the device
list taken from the resources reading, the split by free memory, the part
of an estimate that grows with context (read from two lengths, since that
term is linear), the fit states with their limit (95 percent of free
memory, and the leftover has to fit in system memory too), and a bisection
for the largest layer count whose estimate fits. The estimate returns one
total and no layer count, so the search runs over 1 to 256 and stops at
the first count that no longer changes it.

modelWalk.js keeps the order of the list a model page was opened from, in
memory and in session storage, for the previous and next buttons.

usePlacementEstimate reads /api/models/vram-estimate for a choice, again at
twice the context, and with every layer, and keeps readings for the
session. useModelPage reads a gallery entry by name, an estimate by
context size (from the model's own files when the gallery does not list
it), the builds and the loaded models. usePlacementConfig edits the four
placement keys of an installed model and saves only what changed.
useModelActions is the Load, Stop, disable, pin and remove logic of the
Installed table, shared with the model page. MemoryBar is one solid bar
with a tick at the capacity of its pool; over capacity it grows past the
tick and the tick turns red.

Assisted-by: Claude Code:claude-sonnet-5-5

* feat(ui): add the Placement section to the model editor

Run this model on: CPU only (gpu_layers: 0), Auto (the key stays unset) or
Custom. Custom takes a number, has an All layers button that writes
99999999, and shows a slider only when the estimate reports the model's
layer count, which it does not today. Context size has presets and a
number field because the KV cache follows it. With two or more GPUs there
is a split (written as percentages, with a button that takes them from the
free memory of each card) and a main GPU.

A bar per GPU and one for system memory show what other programs use, the
model's weights and working memory, and the part that grows with context,
with the room left or how far over it is. Under them a verdict in plain
words: Fits in GPU, Spills to CPU, Too many layers for the GPU, Runs on CPU
only, No GPU found, Not enough memory. It says "slower" and never a
multiplier, because the estimate has none. Fit it for me asks the estimate
for the largest layer count that fits the free GPU memory and says what it
set, with Undo; it is hidden when the estimate is unavailable or the host
has no GPU. Loading shows skeletons, an unavailable estimate shows a note
with Retry, and a server that schedules onto other machines shows no bars,
because its device list is the controller's.

The editor shows the section for an installed model, with a link in its
section rail. Auto sends null for the key, since a patch only merges, and
a null read back opens as Auto. The docs describe the section and what each
mode writes.

Assisted-by: Claude Code:claude-sonnet-5-5

* feat(ui): open a model on its own page

A model has an address, /app/models/<name>, for an installed model and a
gallery entry alike. Open it from the arrow at the end of a row, a double
click, "o" on the selected row, the inspector's Open details button, or a
tap on a phone. The title block holds the main action: Install with a
chevron that chooses the build, or Load and Stop with a menu (disable, pin,
edit configuration, logs, delete with a confirm). A strip answers whether
it fits, what it does and what installing leaves free.

Tabs: Overview (about, a memory bar, state, the pages it opens in, and the
agents, tasks, chains and aliases that name it); Fit and memory (verdict,
context sizes, the bar split into weights and context, and memory by
context against the limit, with a data table); Variants and files (builds
with size and fit, install any, the files of the chosen build). For an
installed model also Usage and history, which says what the API does not
record instead of drawing an empty chart, Configuration, which is the
Placement section with the file it writes and a link to the full editor,
and Logs, the backend log viewer without its page. Keys 1 to 6 switch
tabs, [ ] and j k walk the list the page was opened from, Esc or Backspace
go back.

The list stays mounted behind the page, so Back finds its view, search,
filters, selection and scroll as they were, and focus returns to the row's
arrow. The page covers loading, an unknown name with the closest matches,
the gallery being out of reach, an install in progress with Cancel, and a
failed install with Retry.

The docs describe the page and its keys.

Assisted-by: Claude Code:claude-sonnet-5-5

* test(ui): cover the model page and the Placement section

New specs for the model page: reaching it from Explore, Installed, a
double click, "o", a pasted link and a phone tap; the walker and Back with
the search, a filter, the selection, the Installed view and the scroll
kept, and no second read of the gallery; the title block, the answer strip,
tabs by click, keys and arrows; Fit and memory, builds and files with the
install call each one makes; an installed model's actions, used-by,
the honest usage tab, configuration and logs; loading, an unknown name,
offline, an install in flight and a failed one; and the phone.

New specs for Placement: every mode and the keys it writes, the slider
only when a layer count exists, the context presets, the bars and every
verdict, two GPUs, no GPU, a cluster, an unread machine, a loading and an
unavailable estimate, Fit it for me and Undo, and the section in the model
editor with its save.

The phone tap on a row now opens the page, so the two phone specs that
expected the inspector as the page check the page and keep the inspector
check for a window between a phone and a desk.

Assisted-by: Claude Code:claude-sonnet-5-5

* feat(ui): link Studio results and open workspaces from a prompt

Each workspace now records the result it was made from (parentId and an
edge kind such as take, animate or to-3d) and reads a prompt, model, size,
count and source from the query string, so one page can hand work to
another. A source result is fetched from the server's own output file and
becomes the start image, the picture for 3D, or the audio file. A note on
the page says when the source loaded or could not be loaded.

Diarization had no history; it now keeps the file name, the model and a
speaker count, never the recording. Prompts are cut at 2000 characters
when stored. The pure helpers (type suggestion, grouping, lineage layout,
favourites, clearing) have node tests.

Assisted-by: Claude Code:claude-sonnet-5-5

* feat(ui): rebuild the Studio front page as a composer with your work

The front page is a prompt box with a chip per type, a type suggestion
from the words, starters, and the options each workspace accepts. Generate
opens the workspace with those filled in. A type with no model is a dashed
chip that shows a gallery model, its size, memory need and an Install
button only when picked; the typed words stay while it installs.

Under it, Your work lists results from every workspace as a masonry with
filters, counts, favourites and a Clear history action. Results made from
each other stack into a project tile and open as a lineage board with a
dock for running a new take or branching to the next step; steps the
destination cannot start from yet are disabled with the reason.

The docs describe the page, what is stored in the browser, and the query
parameters a workspace accepts.

Assisted-by: Claude Code:claude-sonnet-5-5

* test(ui): cover the Studio composer, your work and the lineage view

Specs for the type suggestion, the keys, hand-off to each workspace, the
install path for a missing model, the masonry filters, favourites and
clearing, stacking, the lineage board, new take and branch, steps that
are disabled with a reason, and the phone layout. Existing Studio specs
move from lanes to chips with the same intent.

Assisted-by: Claude Code:claude-sonnet-5-5

* feat(ui): add the shared Studio workspace frame and move Images onto it

The seven Studio workspaces get one layout: a row of type tabs, a compose
card (optional sources as chips, a prompt with starters, a model chip,
the essential options as chips, an Advanced fold that names what is
inside, the memory the model needs, and one action with the reason when
it cannot run), a run area, and a strip of recent results of the type.

The run area shows a job card with the time that has passed and an
indeterminate bar, because these endpoints report no phase or percentage;
a failure with what the server said and one action; or the result with a
toolbar: Favourite (the list the front page keeps), Download, Use in (the
hand-off targets, disabled with the reason when a destination cannot
start from the result), Re-run with edits (the take's values go back in
the form, changed fields are outlined and listed) and Lineage. A type
with no model shows the install note from the front page.

Images is the first workspace on the frame. It keeps its size, count,
steps, seed, negative prompt, source image and reference images, and its
history writes, including the parent link and edge of a hand-off run.
useMediaHistory.addEntry now returns the id of the entry it stored. The
docs describe the workspace page.

Assisted-by: Claude Code:claude-sonnet-5-5

* feat(ui): move Video onto the workspace frame

Video keeps its size list, duration, frame rate, steps, seed, CFG scale,
frame count, negative prompt, start and end image and avatar audio.
The start and end image are source chips, the avatar audio opens the
recording and paste input from a chip, and the rest sit in the Advanced
fold. A start image from a hand-off shows as a chip with its picture.
Results play in the video player with the shared toolbar.

Assisted-by: Claude Code:claude-sonnet-5-5

* feat(ui): move TTS onto the workspace frame

TTS keeps the saved-voice picker for cloning models, the typed voice for
the others, the voice library deep link, and the delivery instructions,
which now sit in the Advanced fold. The result is the waveform player
with the words under it. The stored entry also keeps the voice id so
Re-run with edits can select the same saved voice.

Assisted-by: Claude Code:claude-sonnet-5-5

* feat(ui): move Sound onto the workspace frame

Sound keeps its Simple and Advanced modes and every field of both: the
description, instrumental, vocal language, caption, lyrics, BPM,
duration, key, language, time signature and think mode. The mode switch,
instrumental and duration are in the compose card, the rest in a More
options fold. The stored entry keeps all of the fields, so Re-run with
edits restores the form as it was.

Assisted-by: Claude Code:claude-sonnet-5-5

* feat(ui): move Transform onto the workspace frame

Transform keeps its audio and reference inputs with upload and record,
the echo test, the key=value parameters (now in the Advanced fold), the
input and output spectra and the three waveform players. The audio that
was chosen shows before the run, waiting to be transformed. Re-run with
edits puts back the model and parameters and fetches the audio and
reference the server kept for that run.

Assisted-by: Claude Code:claude-sonnet-5-5

* feat(ui): move 3D onto the workspace frame

3D keeps the picture input with paste and webcam, the animation
operations a model declares, quality and background, the shape and
material steps, guidance and seed, the GLB and animation viewers, the
remesh control and the download. A 3D result now has a title from the
motion prompt when it has no label, so the strip and the front page name
animation results by what was asked. Re-run with edits is shown disabled
with the reason, because only a small thumbnail of the picture is kept.

Assisted-by: Claude Code:claude-sonnet-5-5

* feat(ui): move Diarization onto the workspace frame

Diarization keeps its model and recording inputs, the option to prepare
speakers to remember, the clean-speech previews, naming and remembering a
speaker, and the history entry with only the file name, model and
counts. The result now shows a timeline with one lane per speaker, the
talk time of each speaker, and the segments with their start time and
text. RTTM, SRT (only when the run has text) and JSON are built in the
browser from the result. The helpers for talk time, axis ticks and the
two text formats have node tests.

Assisted-by: Claude Code:claude-sonnet-5-5

* refactor(ui): remove the styles and lists the old workspace layout used

Nothing renders the two-column workbench, the control column, the old
history lists, the generation progress tiles, the TTS voice picker or the
result echo any more. The inline-style baseline drops with them.

Assisted-by: Claude Code:claude-sonnet-5-5

* test(ui): cover the workspace frame and one run per type

Specs for the type tabs, the compose card and its reason when Generate
cannot run, starters, the Advanced fold, the job card with no invented
progress, a failed run and its one action, the install note, the strip
with its favourites filter, Use in with its disabled steps, Lineage, the
parent link, Re-run with edits and its list of changes, deleting and
clearing, and the hand-off note. One run through each of Video, TTS,
Sound, Transform, 3D and Diarization, the phone layout of all seven, and
reduced motion. Existing Studio specs move from the old control column to
the compose card with the same intent.

Assisted-by: Claude Code:claude-sonnet-5-5

* feat(ui): rebuild the chat thread with raised user turns, prose replies and one-line activity

Your messages are raised blocks on the right at a 760 px measure and the
model's replies are plain prose under its name and a warm or not loaded
dot. Reasoning, tool calls and their results fold into one quiet line
that opens inline into steps. Code blocks carry a Copy button and a
Canvas button that opens that block in the canvas, image attachments are
thumbnails that open in the lightbox, and files are chips. Per-message
actions show on hover, on focus and on the last turn, and a turn takes
focus so the arrow keys and C, E, R and B work. A failed reply keeps the
text written so far and shows the reason with one Retry action.

The Agent chat page keeps the older rules: the new styles are scoped to
the chat page and use their own class names.

Assisted-by: Claude Code:claude-sonnet-5-5

* feat(ui): use the Home command bar as the Chat composer

Chat now ends in the same object as Home: the model chip, the MCP chip,
a Canvas chip, the message box with attach buttons, a solid Send and the
hint line, with the slash menu on the kit command list. The slash menu
lists what Chat can do today (switch model, new chat, conversations,
manage mode, canvas, find, settings, export, clear). While a reply is
streaming Send becomes Stop, which Esc also presses, and Up in an empty
box edits your last message. Attached images show as thumbnails and a
line under the bar carries the speed and the token count.

HomeComposer takes optional props for this (extra chips, its own slash
list, Stop, paste, a stricter Enter); Home passes none of them.

Assisted-by: Claude Code:claude-sonnet-5-5

* feat(ui): open conversations from a Ctrl K menu with day groups, undo and a slim header

The conversations list opens as a centred menu on Ctrl or Cmd K. It
groups chats by day like the Home resume list, shows the model that
answered and the time, searches names and message text, and moves with
the arrow keys. Enter opens a chat, F2 renames it and Delete removes it.
Removing a chat hides the row and shows the kit undo toast; the chat is
deleted for good only when the undo time ends. Rename, duplicate, copy
and export are on each row, as before.

The header is one slim bar: the Chats button, the chat name (click to
rename), a context meter when the context size is known, settings and a
More menu with rename, duplicate, copy, export, model info, keyboard
shortcuts and clear. A dialog lists the shortcuts the page answers to.

Assisted-by: Claude Code:claude-sonnet-5-5

* feat(ui): show loaded state, capabilities and fit in the Chat model switcher

The model chip in Chat opens the same list as Home, grouped as Loaded
now and Installed. Each row says warm or not loaded and marks models that
understand images. When the list opens, the page reads the host memory
once and asks the server to estimate each listed model at the chat's
context size (up to twelve, three at a time), then shows what the model
needs and whether it fits: free memory, how much would run on the CPU, or
how far over the machine it is. A model with no estimate shows no fit
text, and no load time is shown because the API does not report one. A
memory bar closes the list.

The picker takes the model list from the page when it has one, and
useModels can skip its own request.

Assisted-by: Claude Code:claude-sonnet-5-5

* feat(ui): move chat settings into a sheet and add find in chat, jump to latest and a wider canvas

Settings open as a kit sheet: the system prompt, temperature, top P and
top K (each says "model default" until it is changed and has a Reset),
the context size with quick sizes and a note that it only drives the
meter, Manage mode and Focus mode, the model info for admins with its
Edit config button, and Clear conversation behind a confirmation. The old
slide-out drawer and the model info panel are gone.

Ctrl or Cmd Shift F (or the search button, or /find) opens a search bar
over the thread. It marks matches in the messages already on the page,
shows "n of m" and steps with Enter and Shift+Enter. Nothing is sent to
the server. Jump to latest is a pill above the composer. Esc stops a
reply, then closes the search, then closes the canvas.

The canvas panel gets the kit look: tabs, a Code and Preview switch, Copy
and Download, a full-page layout on narrow windows, and translated
labels. The Agent chat page shares it and gets the same look.

Assisted-by: Claude Code:claude-sonnet-5-5

* feat(ui): add the empty, no-model, loading and phone states to Chat

An empty chat opens with the composer under one line, starters to try,
whether the model is loaded, and the Jump back in list: the same rows as
Home, read from the chats the page holds. With no chat model installed,
an install card offers the starter models for this hardware, the gallery
and import, and the composer stays so the text is not lost.

While a reply waits for a model, a load card shows what the page knows:
the phase the server names, the node, the bytes and the time left when the
server reports them, and a progress bar. A model that is just not loaded
yet gets a plain note, with no invented phases or estimates. The foot
warns when the context is nearly full.

On a phone the header drops its labels, the model list and the settings
open as sheets from the bottom, per-message actions stay in view and the
canvas takes the whole page.

Assisted-by: Claude Code:claude-sonnet-5-5

* feat(ui): rebuild Talk as a calm voice page over what the connection really does

Talk is one stage and one transcript. The stage has the pipeline chip,
the voice and language chips, an outline orb that follows the real
microphone and playback levels, a heading and a sentence for the current
state, and the controls. The transcript lists You, Reply, Tool and Result
lines and can be copied. Session settings (instructions, voice, language,
tools, Manage mode and the pipeline's parts) open in a sheet.

The states are the ones the code reaches: no pipeline model, idle,
connecting, listening, thinking (also while a tool runs), speaking, an
interrupted reply (the server cancelled it; a note marks the cut), a
blocked microphone, a link that failed during a session, and any other
error with its reason and a link to the traces. Push to talk and
hands-free are not on the page, so they are not shown. Diagnostics keep
their waveform, spectrum and stats, drawn in theme colours.

The page text moves into the talk namespace, and the old Talk and
visualizer styles and the inline-style count go down with the rebuild.

Assisted-by: Claude Code:claude-sonnet-5-5

* refactor(ui): remove the chat styles and strings the rebuilt page replaced

The settings drawer, the model info panel, the bubble avatars, the
conversation menu popover, the context bar, the recent strip, the
staging bar, the file badges and the focus-mode rules have no user now.
Their rules, the Chat page's focus class and seven unused empty-state
strings are removed. The Agent chat page keeps the shared message,
sidebar and input rules it still renders with.

Assisted-by: Claude Code:claude-sonnet-5-5

* docs: describe the rebuilt Chat and Talk pages

Add a Chat page under features (thread, message actions and keys, the
message box and its slash actions, the model list with loaded state and
fit, conversations on Ctrl K, settings, find, canvas and the empty,
no-model and loading states) and a Talk section to the realtime API page
with the states the page shows. Manage mode now turns on from the chat
settings or /assistant, and the client MCP steps point at the MCP chip.

Assisted-by: Claude Code:claude-sonnet-5-5

* fix(ui): settle the rough edges of the new Chat and Talk pages

The undo toast sat under the conversations menu, so the Undo button could
not be pressed while the menu was open; the menu, the sheets and the
fullscreen canvas now stay below the toast layer. Esc in a rename box
saved the text through the blur that follows it; it now cancels. The
image viewer closed on Esc only when the page did not re-render on the
same key, so its key listener is registered once and reads the latest
handlers. Keys on a focused message no longer type their letter into the
editor they open, "/" from outside a text field starts a command as it
does on Home, and Esc leaves the page's own dialogs alone.

Code in the canvas is highlighted for languages that have no preview.
The conversations menu drops its key hints on a phone so Clear all
stays in view. Talk hides Test tone while connecting and calls a server
error "Something went wrong", since the call can still be open.

Assisted-by: Claude Code:claude-sonnet-5-5

* test(ui): cover the rebuilt Chat and Talk pages

Specs for the thread layout and the activity fold, code blocks, image
thumbnails and the viewer, per-message actions and their keys, a failed
reply with its one Retry, Stop and Esc while streaming, the composer and
every slash action, the conversations menu (groups, search, resume,
rename, delete with undo that ends by itself, one chat left), the model
switcher with loaded state, vision and fit text from stubbed estimates,
the settings sheet, the canvas panel, find in chat, Jump to latest, the
empty, no-model and loading states, the phone layout and reduced motion.
Talk is driven over a fake WebRTC link through idle, connecting,
listening, thinking, speaking, interrupted, blocked, lost, error and no
pipeline, its settings sheet and its phone layout. Node tests cover the
message text helpers and the conversation grouping.

The existing chat specs move to the new structure with the same intent:
the transcript spec now describes the raised turn and the prose reply, and
the render smoke accepts Talk's own header.

Assisted-by: Claude Code:claude-sonnet-5-5

* feat(ui): keep a bounded run log for agents in the browser

The server keeps no run history for an agent, so a run is one task and
the events until the agent answers, written to browser storage while the
page watches the stream: up to 50 runs per agent, task, step and answer
text only. Stored chats from the earlier agent chat page read as runs
with stable ids. A run still marked running five minutes after its last
event reads as stopped. Helpers read an agent's config into chips, build
the list of changed fields against the saved config, hide secret values
and offer starting points.

Assisted-by: Claude Code:claude-sonnet-5-5

* feat(ui): rebuild the Agents area around runs

The Agents page shows what needs a look (work in flight, a run that
failed in the last day), then each agent with its model, attached memory
and skills, and a strip of its last 14 runs. An agent has its own page: model,
tools, memory, skills, instructions, a task box and its runs. A run has
an address, shows the thread while it works (steps folded into one line,
the tool in use, the answer as it arrives) and settles into a report
about a second and a half after the agent answers: task, outcome,
follow-ups, evidence and steps, with wide tables opening wider on demand.
A failure says in plain words what happened and offers Run again.

Create and edit fold into sections with a ready mark and a one-line
summary, start from a template or an optional model-written draft, and
open a preview sheet with the config as saved and the changes against
the saved agent. Status becomes a quiet panel in the same language, and
the old chat link opens the agent page.

There is no Stop, approval, steer, version or dry-run control, because
the agent API has no call behind them.

Assisted-by: Claude Code:claude-sonnet-5-5

* test(ui): cover the Agents launcher, agent page, runs and editor

Specs for the Now strip and run strip, search and the empty state, the
agent page, starting a run, the live thread, settling into the report,
the run address across a reload and for a run from another browser,
follow-ups with their history, failures, the folding editor with ready
marks, templates, the preview sheet with hidden secrets and changes, the
status page, and the phone, 1440 and 2560 layouts.

Assisted-by: Claude Code:claude-sonnet-5-5

* docs: describe runs and the new agent create flow

Assisted-by: Claude Code:claude-sonnet-5-5

* feat(ui): add helpers for tasks, schedules and job outcomes

Reads a cron expression the way the server does (five fields or an @
shortcut), checks it, and puts the common shapes in words. The next run
is left out on purpose, because the schedule follows the server clock,
which the browser cannot read. Also groups jobs by day, sums the last
seven days, and gives each job one outcome line from its result or
error. A rerun call starts a new job with the same parameters and media.

Assisted-by: Claude Code:claude-sonnet-5-5

* feat(ui): rebuild the Jobs area around runs

The Jobs page opens with one sentence about the last seven days, then
the tasks (model, schedule in words, last 14 jobs, enabled switch, Run
now) and a run history grouped by day. Each row has an outcome sentence
and opens to the error or the start of the result with one next action.
Deleting a task waits 30 seconds with an undo button.

A task opens as a page with its recent runs, its prompt with the gaps
marked and its schedule. The task form folds into sections, takes a
schedule as a preset or a checked cron expression, warns about prompt
gaps the schedule does not fill, and has a preview sheet. A job opens
as a document: task, outcome, delivery and the recorded steps; a failed
job says what happened and offers Run again.

Run now now sends attached media through the job call, which is the
only one that takes it. "Clear History" only ever cancelled running
jobs, so it is now called Stop running jobs. Webhook headers of a saved
task show as JSON instead of [object Object].

Assisted-by: Claude Code:claude-sonnet-5-5

* test(ui): cover the Jobs page, task pages and job pages

Assisted-by: Claude Code:claude-sonnet-5-5

* docs: describe the Jobs page and the task form

Assisted-by: Claude Code:claude-sonnet-5-5

* feat(ui): add helpers that say who uses a skill or a collection

An agent loads a skill when skills are on and the skill is in its selection (an empty selection means every skill). It reads the one collection that carries its own name, when its knowledge base is on. The helpers derive that from the saved agent configs, build the config that adds or removes a skill or a collection, and estimate tokens as characters divided by four. Removing the last selected skill switches skills off, because an empty selection would mean every skill.

Assisted-by: Claude Code:claude-sonnet-5-5

* feat(ui): rebuild Skills and Memory as one library

Skills and collections sit in a list with an open item beside it. Each row says who uses it, read from the saved agent configs, or says it is not used yet. Chat reads neither, so it is never named. An item opens in a pane with a Used by strip (names link to the agent, a small x removes it, with undo) and an Add to menu that shows what the addition costs. A collection can be added only to the agent that carries its name.

The Memory pane searches the collection alone and shows ranked passages with scores, lists web sources with their refresh interval and the files, shows the server message when an upload fails, and names the endpoints and where files stay. The Simulate a message sheet runs a collection search and shows an agent's skills with a token estimate. It runs no model. The collection details route now opens the same page.

Assisted-by: Claude Code:claude-sonnet-5-5

* feat(ui): show use and cost in the agent form pickers

Each skill in the agent form says which other agents use it and what it adds to every message, with a total for the selection. The memory section names the collection the agent reads.

Assisted-by: Claude Code:claude-sonnet-5-5

* test(ui): cover the Skills and Memory libraries

Specs for the used-by lines (including an agent that uses every skill), the filters, search, add to agent, remove with undo, the last-skill case, an unreadable agent list, the empty states, git repositories, the Memory question box, sources, uploads that fail, the Simulate sheet with the parts the API can run, the agent form hints and the phone layout.

Assisted-by: Claude Code:claude-sonnet-5-5

* docs: describe the Skills and Memory libraries

Assisted-by: Claude Code:claude-sonnet-5-5

* feat(ui): add helpers for the Operate status page and backend rows

Pure functions for the parts that need rules. They work out the memory
pool the page measures (GPU memory, system memory, or the workers of a
cluster that are answering), which pools are too full, the headline and
the four ledger rows, the geometry of the capacity chart, and what
removing a backend would leave without a runtime (models name their
backend, and a meta backend names the concrete one it points at). A
second set says what a backend row states: installing, queued, removing,
failed, update available, current or absent.

LocalAI keeps no memory history, so the chart reads a bounded buffer of
readings the page took itself and says so. A reading with no total is
dropped rather than drawn as zero.

Two hooks are shared by the pages that need them. One retries a failed
operation after moving the failure into the record. The other holds a
cancel for an undo window, because the server cannot take a cancel back.

Assisted-by: Claude Code:claude-sonnet-5-5

* feat(ui): rebuild Operate Status, This machine, Backends, Activity and Logs

Status opens with one sentence ("2 things need you", or "Everything is
running") and four rows: Needs you, Capacity, Running now and Recent
failures. A row with a problem opens by itself and holds the button that
deals with it: Update a backend, Retry or Dismiss a failed operation,
Unload a model. A quiet row stays one line. A new installation gets a
first-run screen, a cluster sums the memory of the workers that are
answering, and a page still waiting for an answer says so. The chart
under the rows is drawn from readings the page took while it was open
and is labelled that way, because LocalAI keeps no memory history.

This machine leads with GPU memory as one bar, then host memory split by
running model, then VRAM, RAM, CPU and disk with a bar each. The running
models become a kit table with the same menu and stop dialog.

Backends is one list with Installed and Catalog views. A row says what
the backend is doing (a progress bar with Cancel, Queued, Failed with
Retry, Update 1.2.0, Current), carries the one button that matters, and
opens in place. Removing a backend names the models and the meta
backends that would stop working. Check for updates, Update all, From
URL and a first-run recommendation for llama-cpp are in the header.

Activity keeps its three sections as quiet rows. Cancel waits eight
seconds with an undo toast, because the server cannot take a cancel
back; a cancelled install can be started again from the record. Logs
gets a process list, a picker, stream and text filters, Follow and
Times switches, and a Clear with an undo window.

Not shown, because the API has no data for them: GPU temperature and
power, a size per backend, an earlier version to roll back to, a
dependency lookup beyond the models and meta backends that name a
backend, and models that failed to load.

Assisted-by: Claude Code:claude-sonnet-5-5

* test(ui): cover Operate Status, This machine, Backends, Activity and Logs

New specs for the Status headline and ledger (healthy, needs attention,
one thing, a full memory pool alone, loading, first run, cluster), its
actions (Update, Retry, Dismiss, Unload with its dialog), the capacity
chart built from readings taken while the page is open and bounded, the
phone layout, no coloured edge on a row, and reduced motion.

The Backends specs cover the two views, install progress with Cancel and
its undo window, Retry on a failed install, Update, Update all, Check for
updates, removal with the models and meta backends it would break,
Install from URL, the first-run recommendation, a cluster, and a phone.
Activity gains cancel with undo, Cancel now, a second cancel, leaving the
page, progress, and starting a cancelled install again. Logs covers the
stream and text filters, Follow, Times, Export, Clear with undo, the
process picker and list. This machine covers the GPU strip, several GPUs,
no GPU and Add a machine.

Existing specs keep their intent and follow the new structure: rows open
in place instead of in a pane, Update replaces Upgrade, the notice spec
now pins that an update is a row state and not a banner or a rail, and a
cancel waits for its undo window.

Assisted-by: Claude Code:claude-sonnet-5-5

* docs: describe the rebuilt Operate Status, Backends and Activity pages

Assisted-by: Claude Code:claude-sonnet-5-5

* feat(ui): add helpers for the Swarm pages

Pure functions for what the pages work out from the cluster API: a node's
state in words, which nodes a placement rule may use, what a rule would
ask for, what a drain or a lost node would leave without service, the
nodes a bulk backend update reaches, and the join commands for a worker,
a peer instance and a memory shard. Hooks read the roster, the loaded
replicas and the rules.

Everything runs in the browser from data the page already holds, and
says when it cannot see free memory or disk.

Assisted-by: Claude Code:claude-sonnet-5-5

* feat(ui): rebuild the Swarm hub: nodes, node page, placement rules, failover

Nodes is a sortable table with comfortable and compact rows, a Needs
attention filter by reason, a map of the cluster that is not drawn on a
phone, the running models, and a bulk backend update for the nodes that
drifted. A node is a page: state, vitals, a drain preview computed from
the loaded replicas and the rules, tabs for models, backends, logs and
capacity and labels, and Remove that asks for the node's name.

Placement rules are written as sentences, show where each model is
loaded now, and edit in a side sheet with a preview of the nodes a draft
could use. Deleting a rule waits a few seconds so it can be taken back.
Failover keeps its chains, adds what the router does when a worker stops
answering and a per-node preview of what would stop. Add a node covers a
registered worker, a peer instance and a memory shard, with a command to
copy and a live line that says when the machine arrived. P2P keeps its
page in the same vocabulary, and the node logs page follows the local
logs page.

Previews are labelled as worked out in the browser. Per-GPU readings and
node events are not drawn because the API does not return them. Failover
moves to Swarm when distributed mode is on. Legacy fleet components and
their styles are removed.

Assisted-by: Claude Code:claude-sonnet-5-5

* test(ui): cover the Swarm hub

New specs for adding a node (each join method, the command, copy,
waiting and found, approve, a single install, P2P, a phone), placement
rules (sentences, where models are loaded, the preview matrix, the sheet
and its preview, delete with undo) and failover on a cluster. Node
detail covers its tabs, the drain preview and its dialog, resume, remove
with the typed name, a node that stopped answering, and unload.

The nodes specs follow the new structure and keep their intent: the
table, filters, grouping, pagination, bulk actions, the map, and running
models with stop, logs and the loading, error and empty states. The
scheduling, failover, P2P, hub and smoke specs follow the renames.

Assisted-by: Claude Code:claude-sonnet-5-5

* docs: describe the rebuilt Swarm pages

Assisted-by: Claude Code:claude-sonnet-5-5

* feat(ui): add helpers for the Traffic pages

Pure functions for what the pages work out from the usage ledger, the
trace summary, the trace buffers and the resources reading: the shared
time window, grouping, sorting and filtering of usage rows, chart series
and axes that start at zero, the overview figures, per-model statistics,
the state of a trace and the words for a failure, the backend operations
that ran during a request, CSV export, the Prometheus metric list and
scrape config, and a bounded buffer of host readings.

A figure whose source cannot say is null, never zero. The trace summary
call takes the window in hours, and a helper reads /metrics with its
status.

Assisted-by: Claude Code:claude-sonnet-5-5

* feat(ui): rebuild the Traffic hub: overview, usage, models, host, traces, middleware

Traffic opens on an overview: five figures (requests, failed, p95, tokens
in and out) and three charts, each naming its source. A second row of
links reaches Usage, Models, GPU and host, Traces, Middleware and
Prometheus, and one time window is shared by the first three.

Usage groups by model, user or API key, filters, sorts, opens a row on its
own chart, exports the rows it holds as CSV or JSON in the browser, and
keeps the opt-in cost estimate and the quota forecast. A user who is not
an admin sees only their own numbers. Models joins the ledger, the
backend-operation buffer and the loaded models. GPU and host shows the
current reading and two charts of readings taken since the page opened.

Traces gets filters, a settings strip and an explained off state. An API
request is a page: the error LocalAI recorded, a timeline with the backend
operations that ran meanwhile, and bodies that stay closed until revealed.
Middleware draws the pipeline as five steps and shows the rules of the
selected step. Prometheus documents /metrics, checks it against the
server and gives a scrape config to copy.

Alerts is not built: LocalAI has no alert rules. Per-model latency
percentiles, GPU utilisation and compare with the previous period are not
drawn because the API does not return them. Legacy usage, trace and
middleware styles and the usage source components are removed.

Assisted-by: Claude Code:claude-sonnet-5-5

* test(ui): cover the Traffic hub

New specs for the overview (figures, charts with a data table and arrow
key readout, failed and first-run and tracing-off states, the shared
window, a phone), usage (group by, filters, sort, export, cost, quotas, a
non-admin, empty and loading), models, GPU and host (snapshot, the
since-opened labelling, a cluster), the traces list, a trace page (the
real error, the timeline, reveal, no headers, a trace that left the
buffer), Prometheus and the Middleware pipeline, with shared fixtures.

The usage, traces, middleware, hub and smoke specs follow the new
structure and keep their intent.

Assisted-by: Claude Code:claude-sonnet-5-5

* docs: describe the Traffic hub

Add an operations page for the Traffic tab: which record each page reads,
what it leaves out and why, the trace page and its reveal, the GPU and
host readings kept since the page opened, and the Prometheus endpoint.
Link it from the operations index, the tracing page and the middleware
page.

Assisted-by: Claude Code:claude-sonnet-5-5

* fix(ui): let metric names wrap in the Prometheus table on a phone

The long metric names pushed the type and "on this server" columns out of
view. Names now wrap inside the table.

Assisted-by: Claude Code:claude-sonnet-5-5

* feat(ui): rebuild Settings with groups by intent, search, a pending bar and history

The fifteen sections become eight groups by intent: memory and models,
speed and defaults, backends and galleries, access and security,
debugging and traces, agents and responses, swarm and sharing, look and
feel. Search covers names, descriptions, keys and the old section name, and
says where a result used to be.

Edits wait in a bar with Discard, Show diff and Apply. The diff lists old and
new values and the checks the browser can make: durations parse the way Go
parses them, a GPU memory budget is one the server accepts, a gallery box
holds JSON, and warnings repeat what the handler and the field text say.
Apply sends only the changed keys. Undo saves the previous values again; it is
a new save, not a rollback. History lists the changes applied from this
browser, since LocalAI keeps no settings log, and Revert stages the old value.

A value is marked as changed only where the built-in default is known from
the CLI defaults. A row says "Applies now" or "Needs restart" only where the
handler or the docs say so.

Three things were wrong before and are fixed with the rebuild: the gallery
boxes and the shared API keys box were sent under names the server ignores,
the "Enable CSRF Protection" switch showed the disable flag the wrong way
round, and every save restarted peer-to-peer networking because every field
was sent.

Assisted-by: Claude Code:claude-sonnet-5-5

* feat(ui): rebuild Users and keys, Account, sign-in, invite and the 404 page

Users and keys is a tabbed page under the Settings tab: people, invites and
API keys. The people table filters by state and role, sorts, approves or
disables (disabling offers an undo that sets the status back), and opens a
side sheet for one person's features, model allow-list and limits. Role,
password reset and delete sit in the row menu; delete asks for the name.
Invites choose a lifetime of 1, 7 or 30 days and show the link once. API
keys can be created with a lifetime, are shown once in full, can be paused,
and are revoked after a ten second undo window in which nothing is sent.
LocalAI lists keys only to their owner, so the tab shows the signed-in
person's own keys and says so.

Account has Profile, Security, API keys and Usage. Usage shows the last 30
days, tokens by model and the limits an admin set. The Security tab now
shows for a GitHub or SSO account and says the password is not theirs to
change.

Sign-in asks for one field per step and draws a provider button only for a
provider /api/auth/status lists. It has the notice for a sign-up that waits
for approval, the first-admin screen, the key-only screen and the invite
page. An address outside the app now gets the 404 page too, which names the
address and lists the places the sidebar lists, with the same gates.

Assisted-by: Claude Code:claude-sonnet-5-5

* test(ui): cover Settings, Users and keys, Account, sign-in and the 404 page

Settings: groups, search by name, key and old section, the changed marker
only where a default is known, apply hints, the pending bar and diff, the
checks, apply sending only changed keys, undo as a second save, discard,
history, the CSRF inversion and the gallery and API key wire forms, and the
phone layout.

Users and keys: the table, filters, sort, approve, disable with undo, the row
menu, the access sheet, invites, key creation with a one-time reveal, the
ten second revoke with undo and with a page leave, and the non-admin redirect.
Account, each sign-in variant (error, pending, first admin, key-only, invite,
provider buttons) and the 404 page have specs too. Fixtures are shared with
the screenshot scripts. Existing specs follow the new structure.

Assisted-by: Claude Code:claude-sonnet-5-5

* docs: describe the rebuilt Settings, Users and keys, Account and sign-in pages

Runtime settings: the eight groups and where each old section went, search,
the pending bar, the diff and its checks, apply, undo, the history, and which
settings show a default or an apply note and why. Authentication: the
sign-in screen variants, the Account tabs, key lifetimes, the one-time key
reveal, the revoke undo window, and the fact that keys are listed only to
their owner.

Assisted-by: Claude Code:claude-sonnet-5-5

* fix(ui): let the Settings undo toast stand alone and read back a generated P2P token

The saved message and the undo toast sat on the same spot at the bottom of the
page. The undo toast now carries the saved message.

A new P2P token is made by the server when the page sends 0. The page reads
it back after the save so the field shows the token and not the placeholder.

Assisted-by: Claude Code:claude-sonnet-5-5

* fix(ui): fit the users table, API keys and Account figures on a phone

On a phone the users table dropped its Role and Status columns off the screen
edge with the row actions. The role and state now sit under the name, so the
actions stay in view. API key rows no longer put the key icon on a line of its
own, and the three Account figures keep one row.

Assisted-by: Claude Code:claude-sonnet-5-5

* test(ui): cover the phone users table, reduced motion and the empty Account state

Assisted-by: Claude Code:claude-sonnet-5-5

* fix(ui): drop the apply note from three settings the save handler does not mention

Size-aware eviction, automatic backend upgrades and development backends said
Applies now, but nothing in the handler or the docs says when they take effect.
A row now carries a note only where the code or the docs say so.

Assisted-by: Claude Code:claude-sonnet-5-5

* feat(ui): rebuild Voices and Faces as one identity family

Voices is one page with three tabs: Speakers (voiceprints for recognising
who is speaking), Speech voices (the text-to-speech reference library,
kept apart because it is a different store) and From a recording (a link
into the diarization workspace). Faces uses the same layout.

Who is this and Same person? give the answer in a sentence with the real
distance and cut-off, a word for how far inside the cut-off it sits, and
a distance scale with the cut-off drawn on it. The cut-off slider re-reads
the answer in the browser; the identify call sends the cut-off, and verify
uses the threshold the model returns. The old confidence percentage is
gone because it is not a probability.

The server has no list call, so the people list stays in the browser and
the page says so. After a search that asked for more people than it got
back, a saved person the server did not return is marked, and people the
server returned that the browser does not know are listed. Nothing is
claimed from a short or cut-off search.

Enrolling is a sheet: sample, name, labels, permission. A copy of the
sample in the browser is opt-in, and an administrator can also keep the
recording as a speech voice in the same step. Removing a person waits ten
seconds behind an Undo toast and sends nothing before then.

Errors say what happened (no face found, model missing, call failed), a
blocked or missing microphone is explained, and a missing model or a
missing permission renders a page that says what turns the feature on
instead of a redirect. Analyze, detect and raw embedding move under
More tools, with attribute guesses off by default.

Assisted-by: Claude Code:claude-sonnet-5-5

* test(ui): cover the Voices and Faces pages

Specs for who is this (match, no match, working, failed, missing model),
the cut-off slider, same person, a blocked, allowed and insecure
microphone, the registry notes and the not-on-the-server marks, the
enrol sheet and its opt-in copy, delete with undo on a fake clock, the
disabled and no-permission states, the phone layout, reduced motion and
Faces. Existing library and diarization specs follow the new structure
and keep their intent. Node tests cover the distance words, scale
layout, stored list and error mapping.

Assisted-by: Claude Code:claude-sonnet-5-5

* docs: describe the Voices and Faces pages

Add a WebUI section to the voice and face recognition pages: the two
tools, the cut-off, what the people list is and why it can be stale, the
undo window, and what is stored where. Point the Voice Library and
Fish Audio notes at Build, Voices, Speech voices.

Assisted-by: Claude Code:claude-sonnet-5-5

* feat(ui): rebuild the Build landing, Fine-tune, Quantize, Import and Explorer

The Build landing says what each tool is for and what it needs from the
machine: the installed backend, the GPU memory, RAM and disk the server
reports, and a job that is running or the newest one when it failed. A
tool that cannot run says why and what enables it.

Fine-tune and Quantize share one page: set up, a check list that is
redrawn as the form changes, a run view with progress, stages and a log,
and a result with real next steps (export, import, chat, Models). The
checks state only what the server reports. A job needs no estimate the
server cannot make, so none is invented. Stop on a fine-tuning job asks
whether to keep a checkpoint, a failed job shows the server's message,
and a memory failure offers two changes that are applied to a copy of
the setup.

Import is a guided flow: source, review, import, done. The server
returns no preview before an import starts, so the review reads the
spelling of the source, prints the request the form will send and runs
the checks that can be made early. The estimate that arrives when the
import starts is set against free memory and disk. The ambiguity picker
and the Write YAML tab stay.

Explorer shows what GET /networks returns and lists a swarm with POST
/network/add, with a join sheet that carries the token and commands.
Build tools the account may not use say so instead of redirecting.

Assisted-by: Claude Code:claude-sonnet-5-5

* test(ui): cover the Build landing, the tool pages, Import and Explorer

Specs for the landing (a tool ready, missing a backend, with no GPU, a
running or failed job, a feature switched off, a member without admin,
phone, reduced motion), the shared tool pattern for Fine-tune and
Quantize (set up, live checks, start request, running with progress,
chart and log, the stop choice, failure with the server message, finish
with next steps, earlier jobs, the account-disabled page, phone), Import
(source detection, review, checks, ambiguity, running with the estimate
against free memory, done, Write YAML, phone) and Explorer (list, join,
list a swarm, empty, not an explorer, retry, phone).

Existing specs follow the new structure and keep their intent. Node
tests cover the machine facts, tool status, checks, log lines, source
detection, the import request and the join commands.

Assisted-by: Claude Code:claude-sonnet-5-5

* docs: describe the Build tool pages, the import flow and the Explorer

Fine-tuning and quantization now describe the set up, check, run and
result steps and what the check list can and cannot say. The import
section explains the review step and why the size and memory appear only
after the import starts. The distributed page describes the Explorer
list, the join sheet and what listing a swarm publishes.

Assisted-by: Claude Code:claude-sonnet-5-5

* feat(ui): turn the hardware recommendations into a "Best for this machine" shelf

The shelf in the Models inspector put five columns into a 400 px pane,
so long model ids wrapped letter by letter underneath the size and the
memory figures. Each row now stacks the tag, the id and the size and
memory facts beside one Install button, and the id wraps inside its own
column.

Once a model is installed the shelf narrows to the best fit and keeps
the others behind a "N more that fit" toggle. Specs cover the ranking,
the layout, the narrowing and the install request against a gallery
fixture that carries the 4K estimate the shelf sizes against.

Assisted-by: Claude Code:claude-sonnet-5-5

* fix(ui): quiet the Studio tab markers and say their state in words

The type tabs drew a saturated green dot for every modality that has a
model. The dot now uses a text colour, filled when a model is installed
and hollow when none is, and each tab carries "(model installed)" or
"(no model installed)" as hidden text so the state is not only a colour.

Assisted-by: Claude Code:claude-sonnet-5-5

* fix(ui): stack the model editor empty state and drop its section hues

"No fields configured" sat in a flex row, so the icon, the title and
the text ran together. It now uses the stacked empty-state layout. The
section icons took a different status colour each (amber, red, green);
they now share one quiet colour, with the accent on the current section.

Assisted-by: Claude Code:claude-sonnet-5-5

* fix(ui): keep the Home memory sentence whole on a phone and drop side rails

On a 390 px screen the memory strip clipped "2 models loaded" to make
room for the figure. The sentence now takes the first line and the
figure and device wrap under it.

The sweep also removed coloured left rails from the editor section
rail, the skill editor list, the install strip and the audio transform
notice (now an outlined note), plus unused chat rules that carried
rails and two glow animations that nothing referenced.

Assisted-by: Claude Code:claude-sonnet-5-5

* fix(ui): line up hub pages, list the model templates, and stop clipped text

Medium-width pages inside Build and Operate were centred while the tab
bar above them was flush left, so the title started 60 px right of the
first tab. They now start at the bar's edge.

Add Model offered nine templates as a grid of identical cards with chip
clouds and inline styles. It is now one list of rows, each with the
field names it fills in on a single muted line.

Two clipped strings are fixed: the Studio voice field cut its
placeholder mid-word, and the phone job list ended the schedule line in
an ellipsis. The recommendation shelf also separates size and memory
with a dot, and the docs describe the shelf.

Assisted-by: Claude Code:claude-sonnet-5-5

* fix(ui): keep the hidden Studio tab state inside its tab

The hidden state text added to each type tab was absolutely positioned
against the page, so on a phone it sat outside the scrolling tab row and
widened the page by hundreds of pixels. The tab is now the containing
block.

Assisted-by: Claude Code:claude-sonnet-5-5

* test(ui): cover the on-disk sizes on the Installed table, model page and cleanup sheet

The fixtures stub GET /api/models/storage. The default report is empty,
so existing specs keep the gallery estimates. makeStorage() builds a
report from files and the models that use them, the way the server
does, and storageSpec() is a models directory with shared and missing
files.

New specs cover the Size column and its shared line, the fallback when
the call fails or the user is not an admin, the files list on the model
page, a missing file, the bytes a removal frees with shared files, and
the cleanup findings. Node tests cover the storage helpers and the
batch arithmetic.

Assisted-by: Claude Code:claude-sonnet-5-5

* test(ui): wait for the page before pressing keys and ticking the clock

Two specs failed in loaded full runs and passed alone. The Alt+1 to
Alt+7 spec pressed a key before the composer had armed its key
handler. The capacity chart spec advanced the fake clock before the
poller had mounted, so it counted fewer readings than it expected.

Both now wait for the page to mount. The key spec retries a press that
lands during a re-render, and the clock spec advances in small steps and
polls for the row count.

Assisted-by: Claude Code:claude-sonnet-5-5

* fix(ui): hide the Installed footer when the storage report is empty

An empty report from the storage call made the footer read "0.0 GB on
disk" next to sizes taken from the gallery estimate. An empty report
says nothing about the disk, so the footer now shows only the model
count. A spec covers it.

Assisted-by: Claude Code:claude-sonnet-5-5

* fix(ui): keep Explore pane actions inside the pane

The inspector actions sat in a non-wrapping flex row beside the title,
so the buttons ran past the pane edge once it got narrow. The row now
takes its own line and wraps.

The primary action (Install, Retry, Open) comes first. Manage
installation becomes a ghost button, and Open details moves to the end
of the row, so one action stands out and the others are quiet. No
action or test id is removed.

Add a spec that checks, in light and dark at several widths and with a
pane forced to 320 px, that every action stays inside the pane box and
that the pane keeps its inner padding.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* refactor(ui): drop the type chips from the Studio composer

The Studio tabs and the composer's type chips listed the same seven
modes, so the page said the same thing twice. Keep the tabs as the one
place to switch modes.

The composer now shows the type it will open as a small label in its
header. The type suggestion from the typed words stays as the quiet
hint line under the prompt, and Alt+1 to Alt+7 still pick a type. The
composer root carries data-type, data-types and data-missing so tests
can read the state.

Specs pick a type through a shared Alt+digit helper and read a
missing model from the tab dot instead of a chip. Remove the unused
chip locale strings and CSS.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* refactor(ui): remove the section crumb above page titles

Page headers drew a small uppercase crumb with a short rule before it
above the title. On the hub pages it repeated the hub name, so Build
sat above a heading that also said Build.

PageHeader now renders only the title, the supporting line and the
actions. Drop the eyebrow prop, the route-derived section name, its CSS
and the unused section helper, and remove the explicit eyebrow props
from the pages that passed one. Pages stay reachable through the
sidebar and the hub tab bar.

Add a spec that checks several pages show their title with nothing
ahead of it in the header.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* refactor(ui): remove left accent rails from tiles, rows and quotes

Several surfaces marked state with a coloured strip on the left edge.
Replace each one with a cue that is not a rail:

- Stat cards lose the strip; the icon and value still carry the colour.
- The highlighted card is a raised surface with a firmer edge.
- The selected rail row is an accent wash with a hairline outline.
- The status stripe on rail items is a small status dot.
- The active failover row is a tinted row.
- Quotes in markdown and chat prose are italic instead of barred.
- The variant detail panel has a full hairline border.

Add a spec that walks the main routes in light and dark and fails on a
left border thicker than 1px, a sideways inset shadow, a narrow
absolute strip in ::before or ::after, or a narrow tall child pinned to
a left edge.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 22:03:07 +02:00
mudler-agentandEttore Di Giacinto 99043b442c feat(router): route with native decision models (#12449)
* fix(schema): preserve SystemOne image inputs

Assisted-by: OpenAI

* test(schema): follow Ginkgo conventions for decision inputs

Assisted-by: OpenAI

* feat(llama-cpp): dispatch native decisions through Score

Upgrade the stock dependency and reconcile Score/TTS patches. Reuse native decision parsing, tasks, formatting and response-reader cleanup; preserve ordinary scoring admission and guard older dependencies.

Assisted-by: OpenAI

* refactor(systemone): share request and model validation

Assisted-by: OpenAI:gpt-5

* fix(systemone): preserve HTTP wire-byte validation limit

Keep structural validation separate from the serialized internal request bound so HTML escaping cannot reject valid HTTP payloads.

Assisted-by: OpenAI:gpt-5

* feat(systemone): bound images and account native decisions

Preserve public wire limits independently from router serialization. Reject unsupported NER images, map native request/capability errors, and stamp explicit usage once. Advertise decisions for stock llama-cpp.

Assisted-by: OpenAI

* fix(systemone): record usage on registered native route

Exercise real registration and billing with a mock native backend. Reject empty native responses, malformed image URLs, trailing JSON, and wire overflow including whitespace.

Assisted-by: OpenAI

* feat(router): add lazy native decision transport

Bind named models through internal ModelSystemOne calls with shared validation and bounded abandoned operations. Remove request and echoed-error contents from decision traces.

Assisted-by: OpenAI:gpt-5

* feat(router): classify overlapping policies with native decisions

Ask independent noul questions, validate probabilities and preserve first-superset routing. Wire the central factory with config-sensitive invalidation and cancellation-safe resolution. Document native framing and bounded operation limits.

Assisted-by: OpenAI:gpt-5

* feat(gallery): add pinned Julia-1 native decision model

Add a separate text-only llama-cpp Q8 entry with pinned Apache-2.0 source provenance and checksum. Installed using the gallery installer and exercised choice, score and noul on CPU.

Assisted-by: OpenAI

* test(router): verify native decisions through central factory

Add an opt-in real-model Ginkgo integration covering the native Go loader and C++ transport, token usage, independent overlapping labels, and candidate selection. Document owned-server execution and the intentionally non-quality threshold.

Assisted-by: Codex:gpt-5

* fix(llama-cpp): align upstream pin and preserve decision signatures

Advance to bed0a856 without losing the automated upstream bump. Detect full-request fill_task support at compile time and forward every question for Nimble framing while retaining the earlier native signature. Preserve reconciled SCORE/TTS patches; add standalone compatibility coverage.

Assisted-by: Codex:gpt-5

* feat(gallery): add native decision family defaults

Pin Laya, Kev-4B, lev, OpenJev and Nimble artifacts. Verify Laya/Kev/lev gallery installs and CPU contracts on both native pins; clearly mark OpenJev/Nimble runtime validation pending and their noncommercial licenses.

Assisted-by: OpenAI

* docs(decisions): clarify integrated Nimble prerequisite

Record the exact combined backend pin while retaining pending OpenJev and Nimble installation/runtime validation status.

Assisted-by: Codex:gpt-5

* fix(gallery): indent native decision model sequences

Match repository yamllint indentation for Laya, Kev, lev and OpenJev list fields. Parsed gallery data is unchanged; reproduce CI gallery lint failure before the whitespace-only fix and pass the same command afterward.

Assisted-by: Codex:gpt-5

* docs(decisions): record OpenJev and Nimble CPU validation

Record gallery installation, checksum/metadata verification and multiquestion native smoke results on bed0a856. Retain noncommercial and text-only limitations without accuracy or deterministic-output claims.

Assisted-by: OpenAI

* fix(ui): expose native Decisions router classifiers

Select classifier models using metadata-driven capability routing, retain tuned thresholds, and validate native decision selections before saving. Cover both native backends and create/save/reopen in the real React editor.

Assisted-by: Codex:gpt-5

* fix(router): exclude aliases from native decision discovery

Check the originally named config before advertising native Decisions eligibility. Retain target capability inheritance for ordinary generation aliases. Exercise the actual capabilities endpoint with native models on both backends, aliases, and disabled models.

Assisted-by: Codex:gpt-5

* feat(systemone): share bounded multimodal input validation

Preserve text wire limits while admitting bounded PNG/JPEG decision input. Share collection and header validation across internal and public callers and keep the native runner response budget independent.

Assisted-by: OpenAI:API-assistant

* fix(systemone): bound admission lifetimes and validate complete images

Retain shared admission leases through actual work completion, including abandoned internal operations. Decode bounded image pixels, cap public native responses before usage stamping, and preserve oversized malformed text status precedence.

Assisted-by: OpenAI:API-assistant

* fix(router): classify images before media fetching

Preserve ordered structured probes for native decisions. Defer OpenAI
media preparation until routing selects the served model, so rejected
decision URLs cannot trigger downloads before shared validation.

Guard direct image collection with context-aware shared admission.
Keep text classifiers and embedding caches from discarding image input.
Retain fail-closed classifier configuration and runtime fallback policy.

Add middleware, typed-content, admission, cancellation and cache tests.

Assisted-by: OpenAI:API-assistant

* fix(router): bound extraction before serialization

Check probe budgets before copying text or marshaling message state.
Count JSON escaping so oversized internal inputs fail before allocation.

Preserve typed Anthropic blocks through selected-model conversion and
fallback. Keep retry coverage in Ginkgo without global test registration.

Assisted-by: OpenAI

* fix(router): bound supported probe serialization

Arbitrary structs can bypass the probe budget through pointer marshalers,
string tags, and promoted fields. Accept concrete chat schema types and
plain JSON values instead of emulating arbitrary struct serialization.

Budget escaped direct prompts before marshaling so raw length cannot hide
serialized expansion. Preserve runtime fallback and reject oversized
input before invoking the decision runner.

Add Ginkgo allocation, boundary, and marshaler invocation regressions.
Six-package tests, three-package race tests, and full-T2 delta lint pass.

Assisted-by: OpenAI:GPT-5 golangci-lint

* feat(decisions): enable bounded OpenJev images

Validate native decision images before permissive media parsing and pixel
allocation. Require both decision image support and a vision projector;
missing or audio-only projectors cannot silently become text decisions.

Pin the OpenJev Q8 projector and document its license and disk footprint.
Add native safety tests, canonical limit parity, gallery and load-option
checks, and a reproducible CPU direct-RPC contrasting-image smoke.

Assisted-by: OpenAI:GPT-5

* fix(decisions): reject incomplete image streams

stb accepts corrupt PNG Adler checksums and truncated JPEG scans.
Use bounded zlib validation and strict libjpeg decoding before parsing.
Keep dimension and aggregate pixel checks ahead of decoder allocations.

Wire decoder dependencies into native builds and runtime packaging.
Add regressions for appended EOI and embedded marker bypasses.

Assisted-by: OpenAI:GPT-5

* fix(ci): gate native decision image validation

Run the decoder security tests outside the stdlib-only native suite.
Fetch vendor headers at the backend pin and provision decoder dependencies.
Gate Go limit parity and production CMake wiring without model downloads.

Assisted-by: OpenAI:GPT-5

* test(decisions): cover multimodal public API paths

Exercise shared image contracts through the registered HTTP routes and
external mock backend. Add opt-in cached gallery installation and real
OpenJev image decisions through SystemOne and both routing APIs.

Assisted-by: Codex:gpt-5

* test(decisions): assert isolation and cache bypass

Observe external RPC calls and compare complete classifier history.
Winner-only and cache-miss checks could hide dropped history or cache use.

Give real inference its own application and model directory so shared
backend mappings and loaded processes cannot affect mixed suite order.

Assisted-by: OpenAI:ChatGPT

* test(decisions): isolate fixture globals

Disable optional global services in the isolated HTTP fixture and register
cleanup before setup assertions. Verify meter provider identity survives
fixture creation and destruction.

Snapshot observed usage before assertions so failures cannot retain the
mutex. Require a successful usage stamp before checking error responses.

Assisted-by: Codex:gpt-5 golangci-lint

* fix(application): honor optional telemetry controls

Skip failover gauge registration when metrics are disabled. Register
against the application meter rather than looking up the global provider.

Allow embedders to retain the bounded routing log without billing stats.
Keep the existing default when stats are disabled. The isolated HTTP
fixture uses this option without losing its native router assertions.

Assisted-by: Codex:gpt-5 golangci-lint

---------

Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-10-04 09:34:21 +02:00
Ettore Di Giacinto 70ce62901f refactor: name the capability decisions instead of systemone
The usecase describes what a model can do, and the category is the Decisions
API. SystemOne stays as the wire contract: the /v1/systemone routes, the
Score RPC question_type and the swagger tag are unchanged. The usecase,
flag, auth feature, UI label, gallery tags and docs page are now decisions.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-30 14:14:09 +00:00
Ettore Di Giacinto c7f278dd0d docs: document the systemone usecase and decisions API
Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-30 11:37:48 +00:00
mudler-agentandEttore Di Giacinto 50c284fdcc fix: make the remaining VerifyPath checks effective (#12326)
utils.VerifyPath joins its argument onto the base path, so a path that
the caller already joined always passes. Several callers gave it joined
paths, and their checks could not fail:

- modeladmin (config view, patch, edit, pin and state): the config file
  path from the loader. A config loaded from outside the models
  directory (--models-config-file) could be pinned, and the pin wrote
  the outside file. The patch and state paths stopped later, in the
  mutation snapshot, with a different error.
- core/backend/tts.go: the model path joined onto the models path.
- The trellis2cpp and stablediffusion-ggml backends: option paths
  (*_path) joined onto the model path. A "../" value outside the model
  directory was accepted.

Add utils.VerifyResolvedPath for a full path. modeladmin and tts use
it. The backends now check the relative option value before they join
it. A rename in modeladmin checks the new relative name.

For models from a config file outside the models directory, the admin
API and web UI now return ErrPathNotTrusted for view, edit, pin, and
enable or disable. The docs describe this.

Assisted-by: Claude:claude-opus-5-5 [Claude Code]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-28 10:04:22 +02:00
Ettore Di Giacinto 56d12338bf Merge PR #11546: docs: replace dead chatbot-ui example link with repo root
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-27 19:49:35 +00:00
Ettore Di Giacinto 40d37330bc docs: point the config example link at the configurations directory
The link text still said chatbot-ui, but it now pointed at the examples
repository root. Link the configurations directory, which holds the
example model config files, and describe it as such.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude:claude-opus-5-5 [Claude Code]
2026-09-27 19:06:33 +00:00
237cf456fd feat(gallery): galleries published as OCI artifacts (#12167)
* feat(oci): pull ORAS artifacts into a directory

Galleries published to an OCI registry arrive as an ORAS artifact whose
layers carry their tree path in org.opencontainers.image.title. Nothing in
pkg/oci could read a non-image manifest, so add PullArtifact: it lays the
layers out under a destination directory and returns the manifest digest so
callers can pin and verify signatures on it.

A registry is remote input and this writes files, so the manifest is
validated before any byte is fetched. A title that is absolute, escapes the
destination or is missing is refused, an artifact of an unexpected
artifactType is refused, and both the layer count and the total size are
capped with caller-settable limits.

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

* feat(gallery): fetch and verify galleries published as OCI artifacts

A gallery whose URL is oci://host/repo:tag is now pulled as an ORAS
artifact and its index.yaml read from the unpacked tree, alongside the
existing HTTP and file sources. Mirrors, the per-candidate timeout, the
failure cooldown and the last known good copy work the same for both
schemes, so a gallery can mirror an OCI primary with an HTTP fallback.

When the gallery declares a verification policy the artifact reference is
resolved to its manifest digest, the publisher signature is checked
against that digest, and only then is the same digest pulled. Nothing
unverified is written to disk or parsed. With strict integrity mode on,
an OCI gallery without a verification policy is refused instead of
warned about, which is what the flag already does for backend installs.

The pull lands in a staging directory that is renamed into the cache only
once the whole tree is on disk and the index reads back as an index, so a
failed or interrupted pull leaves nothing a later fetch would serve. A
fetch that finds a fresh cache entry does not contact the registry.

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

* feat(gallery): resolve entry urls relative to the gallery root

A gallery published as a self-contained tree, which is what an OCI
gallery is, names its base configs by their place in that tree. Such a
url reached the HTTP client verbatim and no entry of the gallery could
be installed.

A scheme-less entry url now resolves against the root the index came
from: the unpacked artifact for an oci:// gallery, and the directory of
the index URL otherwise. The relative path may not climb out of that
root, reusing the guard the artifact puller already applies to layer
titles, and an entry that does is dropped from the listing rather than
failing the whole gallery. An entry that names its own scheme is
untouched, so .ref indirection and local github: resolution behave as
before.

Reading an entry of an OCI gallery also needs the unpacked artifact
directory as its trusted root, since the cache is deliberately a sibling
of the models directory the downloader otherwise confines a file read
to.

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

* fix(oci): restrict artifact file permissions

Create artifact files with owner-only permissions to satisfy G302.
Document the existing path validation for the G304 scanner finding.
Check the permissions of downloaded layers in the artifact test.

Assisted-by: Codex:gpt-6 gosec

* fix(oci): confine artifact writes to their root

Use root-relative filesystem operations to reject escapes through existing
directory symlinks. Retain owner-only permissions and add a symlink escape
regression test, without suppressing the path traversal finding.

Assisted-by: Codex:gpt-6

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-09-21 09:23:05 +02:00
localai-org-maint-botandlocalai-org-maint-bot 956cf4b9b6 docs: explain mixed CPU/GPU inference (#12143)
Show partial layer offload and CPU expert placement for llama-cpp.
Correct the documented gpu_layers default to match the backend config.

Refs #10557

Assisted-by: Codex:gpt-6

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-09-19 19:48:55 +02:00
3dfb34376a feat(downloads): authenticate registries, galleries and downloads from a credentials file (#12028)
* feat(credentials): parse and match download credential rules

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

* fix(credentials): keep secrets out of parse errors and tighten URL matching

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

* feat(credentials): resolve secrets lazily and authenticate HTTP per hop

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

* fix(credentials): redact secrets in nested and store formatting

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

* feat(credentials): add registry keychain and oras credential adapters

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

* fix(credentials): match repository rules for Docker Hub in the oras adapter

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

* feat(downloads): authenticate HTTP downloads and gallery reads from the credentials store

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

* feat(oci): authenticate registry pulls, resumes, blobs and cosign from the credentials store

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

* feat(cli): load download credentials from --credentials-file

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

* docs(credentials): correct the local-network registry rules

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

* fix(credentials): keep secrets out of match and YAML parse errors

A match that fails to parse is no longer quoted in the Parse error, since
it may be a URL with a token in it. Userinfo is detected before the scheme
check, so ftp://user:token@host is refused as userinfo, and a match with a
query string or fragment is refused because it can never apply and a query
string is where signed URLs carry their token. Every YAML decode error is
now redacted, not only type errors: quoted scalars such as a secret under a
mismatched !!int tag are replaced and unquoted map keys are cut off.

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

* fix(downloads): make auth errors name the real cause and never retry unresolved secrets

AuthError now appends its cause, so a registry's DENIED or UNAUTHORIZED
detail reaches the operator. HTTP auth errors print only the status text in
place of the cause, because the downloader builds that cause from the
requested URL, which can carry a signed query string. Registry pulls say
that docker config credentials were tried too, and a download that carried
a caller-provided credential (WithBearerToken, or an explicit authorization
on gallery reads) reports that credential as rejected instead of blaming
the store.

The Range probe for a leftover partial file now returns an unresolved
secret as a permanent error, like the download request already did.

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

* fix(credentials): keep oras pulls anonymous on a broken docker helper and close bodies

When docker config names a credsStore helper that cannot run, the oras
credential func now logs at debug and returns no credential, so public
pulls keep working as they did before the adapter existed. The transport
closes the request body when a rule's secret cannot be resolved, as the
RoundTripper contract requires. The redirect spec now uses a custom header
rule on the origin, which net/http would not strip on its own, to prove the
transport does not carry credentials to the next hop.

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

* test(oci): cover FetchImageBlob authentication against a private registry

FetchImageBlob now has a spec that pulls a layer blob by digest from a
basic-auth registry through the oras credential adapter, and one that shows
the same fetch fails when no rule matches. oras only speaks HTTPS here, so
the registry serves TLS and the spec points http.DefaultTransport, which
retry.DefaultClient falls back to, at the test server's client for its
duration instead of adding a transport seam to production code.

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

* docs(credentials): document auth error wording, ollama manifests and registry tokens

The errors section now lists the registry and provided-credential messages
and says the server's reason is appended. ollama:// manifests are fetched
without credentials, so only blob downloads use the file. GHCR, Docker Hub
and Quay need basic auth with the token as password, and match rules must
not carry a query string or fragment. The backend gallery docs and the
container troubleshooting section now point to the private sources page.

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

* fix(credentials): document trusted file path

The credentials path comes from operator configuration. Mark the file
read with a scoped G304 explanation to resolve the gosec false positive.

Assisted-by: Codex:gpt-6 gosec

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-09-13 23:11:40 +02:00
Adira Denis Muhando 869000ceb7 fix(distributed): exclude pinned models from cluster eviction and idle scale-down
pinned: true was only honoured by the per-node watchdog. Every distributed
eviction path was pinned-blind: the router's LRU eviction (EvictLRU,
evictLRUAndFreeNode) and the replica reconciler's idle scale-down would
happily unload a pinned model — and since eviction is gated on
in_flight = 0, a pinned model became eviction-eligible the instant each
response completed. Under capacity pressure that surfaces as the backend
being freed immediately after every request (#11101).

Wire the model config loader into the router and reconciler through a new
PinnedModelResolver seam (mirroring ConcurrencyConflictResolver):

- EvictLRU passes the pinned set into FindLRUModel's query so the
  next-oldest unpinned model is selected instead of the attempt failing
- evictLRUAndFreeNode filters pinned models inside its locked selection
- scaleDownIdle skips pinned models entirely: trimming to the floor still
  means requests beyond the survivor's capacity pay a cold reload

Deliberate teardown (admin unload, model delete, node drain) intentionally
still applies to pinned models, as does dead-row reaping (state correction,
not eviction).

Regression specs verified to fail with the exclusion disabled.

Addresses the cluster-side eviction gap in #11101

Assisted-by: Claude Code:claude-fable-5 [Claude Code]
Signed-off-by: Adira Denis Muhando <dennisadira@gmail.com>
2026-09-11 23:08:26 +00:00
Ettore Di Giacinto 3e4a44be9d fix(diffusers): forward original config for single files
Assisted-by: Codex:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-11 21:47:55 +00:00
Ettore Di Giacinto 155804e144 docs: clarify model configuration precedence
Assisted-by: Codex:gpt-5 [Codex]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-11 21:40:10 +00:00
9bd7d17ff6 [model-config] feat: add environment variables support for backends (#10721)
* feat: add environment variables support for backends in model configurations

- Add field to model configuration to pass environment variables to backend processes
- Update backend options and model configuration handling
- Add documentation for environment variables configuration with examples including CUDA_VISIBLE_DEVICES

Assisted-by: qwen-agentworld-35b-a3b
Signed-off-by: nold <nold42@pm.me>

* fix(test):  Test environment variables configuration parsing from YAML

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Signed-off-by: nold <Nold360@users.noreply.github.com>

---------

Signed-off-by: nold <nold42@pm.me>
Signed-off-by: nold <Nold360@users.noreply.github.com>
Co-authored-by: nold <nold42@pm.me>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-09-11 18:02:08 +02:00
Stefan Walcz 109244a76a [chat] feat: template.system_messages_after_first — merge or forward late system turns (#11906)
* feat(chat): template.system_messages_after_first — merge or forward late system turns

Tokenizer chat templates such as Qwen3.8 / Qwen3.8-Flash-Next raise
'System message must be at the beginning' for system-role messages that
appear after the leading system block, while agent frameworks (cogito tool
selection and adjustment prompts) legitimately append system instructions
mid-conversation. Every such request failed with a 500 (48 errors in one
10-task agent run).

New per-model option template.system_messages_after_first:
  merge  fold late system turns into the leading system message
  user   forward them as user-role turns at their original position
Default (unset) keeps the current pass-through behaviour.

Fixes #11876

Assisted-by: Claude:claude-fable-5-1
Signed-off-by: Stefan Walcz <stefan.walcz@walcz.de>

* docs(model-config): document template.system_messages_after_first

Assisted-by: Claude:claude-fable-5-1
Signed-off-by: Stefan Walcz <stefan.walcz@walcz.de>

* fix(config/meta): register template.system_messages_after_first in the field registry

TestAllFieldsHaveRegistryEntries requires every model-config field to have
a registry entry. Adds the entry (templates section, select component) and
the option list for the new field so the coverage gate passes.

Assisted-by: Claude:claude-fable-5-1
Signed-off-by: Stefan Walcz <stefan.walcz@walcz.de>

---------

Signed-off-by: Stefan Walcz <stefan.walcz@walcz.de>
2026-09-09 22:21:32 +02:00
Plamen K. Kosseff ba88fb13ce fix(diffusers): auto-detect CUDA instead of defaulting to CPU (#11891)
The device fell back to CPU unless the model config set cuda: true,
while MPS right below was auto-detected — GPU hosts silently rendered
on CPU for any gallery entry missing the flag. Use CUDA whenever torch
reports it available (ROCm builds included), keep cuda: true as an
explicit force, and allow pinning with the device: model option (e.g.
options: ["device:cpu"]). Gallery entries stay untouched.

Assisted-by: Claude:claude-fable-5

Signed-off-by: Plamen K. Kosseff <p.kosseff@gmail.com>
2026-09-08 08:44:35 +02:00
陈志谦 24f897cd09 docs: fix dead anchors and a dead section link (#11885)
- middleware.md: the 'default detector' link used #instance-wide-defaults;
  the heading is 'Instance-wide default detector'
- the advanced/reference landing pages linked an ../installation/
  directory that does not exist in docs/content; dropped the dead
  bullets (deployment content lives under getting-started)
2026-09-05 23:47:13 +02:00
Abdullah Mansour | عبد الله منصور a98501d6ce docs(llama-cpp): clarify multimodal speculative decoding (#11700)
* docs(llama-cpp): clarify multimodal speculative decoding

Update the speculative decoding guidance now that modern llama.cpp backends can combine mmproj-based vision with speculative decoding, including MTP. Document compatibility checks, draft acceptance statistics, VRAM tradeoffs, and a combined configuration example.

Assisted-by: Codex:GPT-5.6-Sol [gh] [OpenStack] [Docker]
Signed-off-by: Abdullah Mansour <abdullahmansour.marketing@gmail.com>

* docs(llama-cpp): clarify multimodal MTP references

Distinguish the upstream change that removed the general multimodal speculative restriction from the later change that added MTP with explicit vision compatibility.

Assisted-by: Codex:GPT-5.6-Sol [gh] [Docker]
Signed-off-by: Abdullah Mansour <abdullahmansour.marketing@gmail.com>

---------

Signed-off-by: Abdullah Mansour <abdullahmansour.marketing@gmail.com>
2026-09-05 23:46:24 +02:00
Claudio Maradonna 30e53f8d9f fix(ds4): enforce generation boundaries (#11821)
Clamp requested generation to the usable context after prompt sync while preserving the legacy 256-token fallback for omitted limits.

Constrain each speculative MTP cycle to the remaining request budget so accepted tokens cannot advance beyond the visible output limit.

Assisted-by: Codex:gpt-5.6-sol

Signed-off-by: Claudio Maradonna <git@codeshifter.xyz>
2026-09-02 12:36:10 +02:00
localai-org-maint-botandlocalai-org-maint-bot d85577ff5c docs: add Apache APISIX reverse proxy example (#11294)
docs: add APISIX reverse proxy example

Document the route settings needed for forwarded headers, streaming responses, and long-running inference behind Apache APISIX.

Closes #11215

Assisted-by: Codex:gpt-5

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-28 08:51:38 +02:00
localai-org-maint-botandlocalai-org-maint-bot 8f56e4e042 fix(vram): persist remote probe metadata (#11487)
* fix(vram): persist remote probe metadata

The startup warmer repeated remote size and GGUF metadata probes after every restart because both caches lived only in memory. Store successful HTTP probes for 24 hours so frequent restarts reuse the prior results.

Bound the cache, reject invalid records, and purge it when gallery data changes. Local model files continue to bypass persistence.

Assisted-by: Codex:gpt-5

* fix(vram): check temporary file cleanup

The lint gate rejects the unchecked cleanup call in the persistent cache writer.

Assisted-by: Codex:gpt-5.6 [golangci-lint]

* fix(vram): make persistent cache optional

Remote metadata probes can transfer enough data that operators need
control over disk reuse and startup warming. Gallery autoload now gates
both behaviors, and the runtime setting applies changes immediately.

Assisted-by: Codex:gpt-5

* fix(ui): expose gallery startup pre-warm

The existing gallery autoload setting also gates the startup metadata warmer. Name both effects in Settings so operators can find the requested boot control.

Assisted-by: Codex:gpt-5

---------

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-23 08:55:13 +02:00
yzxcj797 4eff44a03e docs: replace dead chatbot-ui example link with repo root 2026-08-16 11:07:11 +08:00
Richard Palethorpe f8d3f31594 fix(vram): contain malformed GGUF metadata (#11374)
Recover parser panics at metadata boundaries, skip unneeded remote arrays, and use the parser's overflow-hardened release. Keep detached gallery workers and CrispASR probes from terminating their processes on malformed GGUF input. Disable startup warming in the provided Compose files as an operational fallback.

Assisted-by: Codex:gpt-5

Signed-off-by: Richard Palethorpe <io@richiejp.com>
2026-08-06 09:01:56 +02:00
mudler's LocalAI [bot]andEttore Di Giacinto 1aa97381f3 perf(gallery): warm variant descriptions alongside VRAM estimates (#11297)
Follow-up to #11288, which warmed the VRAM estimate caches at startup and left
the variant picker paying its own way.

Describing an entry's variants probes the weight files of every build it
offers, so the first time a model is opened costs 1.2-1.9s against a cold
cache. That is the same cost as an estimate wearing a different hat, and it
lands in the same caches underneath, so it belongs in the same pass rather than
in a second mechanism.

The warm-up now describes variants for the entries it walks. Entries that
declare none cost nothing: the call is gated on HasVariants rather than
attempted and discarded. The host resolve env is derived once for the run,
since it describes the machine rather than the entry.

Failure handling matches the estimate half. An entry whose variants cannot be
described is logged at debug and skipped, and the estimate for that same entry
is unaffected, because neither half is allowed to fail the other.

Measured against a live instance with 1,595 models, first ever call to
/api/models/variants/:id after a cold boot:

  before   1.2-1.9s
  after    2ms

The warm-up's own cost barely moves: 3m0s to 3m19s for 300 entries, of which
40 declared variants. It stays bounded by the same knobs, and
LOCALAI_VRAM_WARM_LIMIT=0 still turns the whole thing off.


Assisted-by: Claude:claude-opus-5 [Claude Code]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-02 19:42:54 +02:00
mudler's LocalAI [bot]andEttore Di Giacinto 74b7ea2829 feat(ui): replace the gallery and inventory tables with a rail and a detail pane (#11288)
* feat(ui): rename the Install Models nav entry to Discover

"Install Models" named the action rather than the destination, and it was
the only multi-word entry in a rail of one-word ones (Home, Chat, Studio,
Talk, Build, Operate). A bare "Models" was the obvious fix but it collides
with the installed-models view under Host, which is a different page for a
different job.

"Discover" keeps the rhythm and says what the page is for. The icon moves
from a download arrow to a compass for the same reason: the page is browsed
before it is installed from.

Translated in all seven locales rather than left to fall back, so a locale
switch does not leave the entry in English.

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

* feat(ui): replace the gallery table with a rail and a detail pane

The eight-column table was not the real problem; the click-to-expand row
underneath it was. Variants, files and a VRAM estimate never fitted inside a
<tr>, so they were pushed into a drawer that could hold one model at a time,
could not be linked to, and had no room to say anything useful.

The gallery is now a rail to scan and a pane that answers. The pane has two
states and no third: with nothing selected it is the discovery page, and with
a model selected it is that model's detail. Selection lives in the URL, so a
model is linkable and Back steps out of the detail instead of off the page.

The rail groups by capability while browsing and flattens to results the
moment a term is typed. That is a rule rather than a toggle: once someone has
said what they are looking for, the buckets are between them and the answer,
and making the user choose would be handing them our problem.

The detail pane plots VRAM against context length with the host's own limit
drawn across it. This is new information, not a restyle. A single number
invites "so will it run?", and the honest answer is usually "yes, up to a 32k
context", which is a shape rather than a number. The estimates were already
fetched for every context size, so it costs no new request. Backends that
take no context length say so instead of being given a meaningless chart, and
a host with no GPU gets no chart at all rather than bars with nothing to
compare against.

The split-button variant menu goes with the actions column. The pane lists
every build with its backend, quantization, size, fit and a details
disclosure, each installable, which is what the dropdown was a cramped
substitute for. Its tests move onto that list; the three contracts it alone
carried (fetch-once caching, the loading state, an unfit build staying
installable) are backfilled against the pane.

RecommendedModels moves inside the pane, where it has the width to argue for
a model instead of listing one, and keeps its own dismissal and collapse.

Rail entries carry no description. Two lines is the budget and the second is
better spent on whether the thing will run; the stripped-Markdown contract
moves to the pane's lede, tooltip included.

e2e: 123 passing across models-gallery, navigation, recommended-panel,
model-artifact-operation, operations-strip and page-render-smoke. Inline
styles in Models.jsx drop from 82 to 41.

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

* refactor(ui): extract the split view into shared components

Discover shipped its rail, pane and detail header as private functions inside
Models.jsx. Backends and Host have the same defect and want the same shape, so
leaving them there guarantees three rails that drift.

SplitView, EntityRail, DetailHeader and StatGrid now live under
components/split/. EntityRail is deliberately data-driven: a surface maps its
own entity onto { id, name, icon, meta, stripe, groupId } and keeps its
vocabulary to itself, which is what stops the rail learning about models,
backends and loaded state all at once.

The CSS moves with it. What was .discover__rail is .entity-rail, .discover__
pane is .split-view__pane and so on, because a class named after one page is a
lie on the next two. Only what is genuinely Discover's stays behind the old
prefix: the shelves, the hero and the VRAM-by-context chart.

Two additions the shared rail needs and Discover did not: a state stripe, for
surfaces read by condition before they are read by name, and an empty label.
Discover passes neither.

No behaviour change. e2e 100 passing across models-gallery, navigation and
models-recommended-panel.

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

* feat(ui): put the backend gallery on the split view

Same defect as the model gallery, so the same shape: a seven-column table over
a click-to-expand row that was the only place the repository, licence, tags and
links could go.

The rail groups backends by the use case they serve, sharing Discover's
taxonomy on purpose: a backend is the runtime a use case needs, so "vision"
ought to mean the same thing one level down. It flattens on a query for the
same reason it does on Discover.

The zero state is the one real departure. A backend's fitness is not free
memory, it is the accelerator and platform it was built for, so the pane leads
with what this host is, then what is not installed yet, then whether anything
installed has gone stale. The table listed 37 runtimes and left "which of these
can even run here" entirely to the reader.

Distribution moves into the pane, which is the one thing a row could never
carry: which nodes hold a copy and which do not, with the install-on-more
control next to it rather than squeezed against a chip.

The distributed and target-node action logic is unchanged, including the guard
that keeps a hardware-specific build off the fan-out path. The split-button
popover loses its per-row anchoring because there are no rows; one pane, one
anchor.

Selection lives in ?backend=, preserving the ?target= scope rather than
clobbering it.

e2e: 139 passing across models-gallery, navigation, backends-management,
models-recommended-panel, nodes-per-node-backend-actions, page-render-smoke,
operations-strip and model-artifact-operation. The backends spec gains six
split-view tests; its three description-cell tests move onto the pane lede.

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

* feat(ui): put the Host inventory on the split view

The last of the three surfaces, and the one that is not a catalog. Both tabs
had the same click-to-expand row, so the shell transfers; what does not
transfer is the zero state, because there is nothing to discover in your own
inventory.

With nothing selected the pane reports what is happening: how many models are
loaded, what failed, what has an update, and which models are holding VRAM
right now. Every number was already on the page. None of them had been
assembled into one statement, so "what is going on" was a question the tabs
could not answer however long you looked at them.

The rail buckets by state rather than capability - Running, Idle, Disabled for
models; Update available, Installed for backends - which is the opposite of the
galleries and deliberately so: nobody opens Host wondering which of their
models does vision. Entries carry a state stripe for the same reason.

Load and Stop are promoted out of the kebab, because that is what an operator
came for; the rest stays behind the menu rather than diluting it. Adopted,
pinned and alias badges follow the model into the pane: they are facts about
the thing, not about its state, and the rail line is spent on state.

Deliberately NOT done: folding the two tabs into one rail, as the mock had it.
It costs five URL parameters, the manage-tab localStorage key and the
stat-card shortcuts, all of which are live deep-links today. The tabs stay as
the group selector; merging them is a follow-up with its own migration.

e2e: full suite 355 passing. New host-split-view spec; alias-template,
manage-logs-link, manage-action-menu-position and model-editor-back-nav move
off `.table` and the row kebab onto the rail and the pane.

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

* polish(ui): accessibility and consistency pass over the three split views

Findings from a pass over what the previous four commits actually shipped,
rather than what they were supposed to.

The rail was not a listbox. ARIA lets a listbox contain options and groups,
and nothing else, but each group's collapse control is a button that has to
sit inside the scroller with the entries it folds. It is now a labelled group
of buttons, which is the honest description; selection is announced with
aria-current and the arrow keys are unaffected.

Every entry was its own tab stop, so tabbing past a forty-entry rail to reach
the pane took forty keystrokes. Roving tabindex makes the rail one stop, and
arrowing now moves focus with the selection instead of leaving it behind on an
entry Tab can no longer reach.

The rail rounds its corners with overflow:hidden, which was clipping the focus
ring off the first and last entries entirely. Inset outlines fix it.

A 30px row is fine under a mouse and too small under a thumb, so coarse
pointers get a 44px target without costing density on a desktop.

One slot said three different things: "9 models loaded" on Discover, "12
loaded" on Backends, "3 of 9" on Host. All three lists are a page of a larger
set, so all three now say so the same way.

Also removed: an emptyLabel prop on EntityRail that nothing passed, its dead
CSS rule, and MODELS_COLSPAN and ResourceRowDesc, which died with the tables.

e2e: full suite 355 passing.

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

* fix(ui): correct three defects only a real gallery exposed

Running the branch against a live instance with 1,595 models and 1,017
backends, rather than against mocked fixtures, surfaced three things the e2e
suite could not.

Grouping did nothing. The rails matched on the use-case keys the filter chips
send (`chat`, `tts`, `transcript`), but those are a server-side vocabulary the
handler maps onto entries. What entries actually carry is free-form and
inconsistent: models come back tagged `llm`, `gguf`, `vision`, `coding`, and
backends `LLM`, `text-to-text`, `audio-transcription`. Nothing matched, so
every model landed in "Everything else" and the feature was decorative.

Grouping now lives in utils/entityGroups.js, shared by both galleries, matching
case-insensitively against the vocabulary the API really uses, with the entry's
backend as a fallback signal - a backend named `whisper` is a speech backend
whatever its tags say. Order is specific before general and that is
load-bearing: a vision model is tagged `llm` too, so testing text first would
swallow it.

The zero state claimed GPU memory on a machine with no GPU. The resources
endpoint reports system RAM in the same field when gpu_count is 0, so the hero
read "84.4 GB of GPU memory" next to the recommendations panel correctly
saying "No GPU detected". The number was never wrong, only its label; it now
says system memory unless a GPU is actually present.

The page title still said "Install Models" under a nav entry saying Discover.

Also: the keyboard test named the model it expected to arrive at, which made it
a hostage of the grouping table and broke the moment the buckets were fixed. It
now asserts that the selection moves and returns.

e2e: full suite 355 passing.

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

* fix(ui): the filters and the rail were fighting over the same job

Four things you find odd on Discover, and they turn out to be one mistake seen
from four sides.

The rail grouped the current page. The listing is paginated at nine rows, so
those bucket headers described nine entries out of 1,595, and turning a page
reshuffled the sections under the reader. The structure was never stable
because it was computed over the wrong set.

The chips were redundant for the same reason, seen from the other side. They
send tag= and filter all 1,595 server-side. The rail grouped nine of them
client-side by the same axis. Two controls for one job, and the weaker one was
the one this branch added, so it goes. Grouping stays only on Host, where the
list is complete, local, and bucketed by state rather than capability.

The search bar felt odd because it sat in a full-width band while the thing it
narrowed was a 290px rail below and to the left. The whole band now lives in
the rail column: search, backend, use cases, refinements, then the list it
narrows. One column to say what you want, one to show what you got. Nineteen
chips do not fit at that width, so they fold into a disclosure that states the
selection. A disclosure and not a popover, deliberately: picking use cases is
multi-select and interleaves with the backend select and the toggles below,
and a popover dismisses itself the moment you touch either.

The header held two counts and two buttons at arm's length from all of it. The
counts were the third statement of the same number on one screen, after the
rail's "9 of 1,247" and the pane's own headline, so they go. The buttons move
into the pane's zero state, which is the surface that answers "what do I do
here".

Also: the two first-run empty states wore .loading-center, which is
display:flex in the default row direction because it exists to centre one
spinner. With four children that put the icon, the heading, the sentence and
the buttons on a single line with no gap. They are now a proper full-height
empty state.

e2e: full suite 353 passing. Grouping tests are replaced by ones asserting the
rail stays flat; chip tests open the disclosure first; two filter-layout tests
that asserted the old three-band arrangement now assert the column.

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

* polish(ui): make Discover a full-height view, group the chips, name the refinements

Four things, all of them the same complaint: the page read as a document with
controls scattered on it rather than as one view.

The header is fused. A title block with its own padding, a subtitle and two
counts made the split view look like an attachment to a document that happened
to sit below it. It is now a slim bar carrying the title, the count and the two
page-level actions, and the split fills the rest of the window. Rail and pane
scroll independently, so the filters and the pane's headline stay put while a
long list moves under them.

The chips group. Nineteen in a flat row is a lot to scan even behind a
disclosure, and they already belong to the four families the rest of the UI
speaks, so they are bucketed by those. "All" sits on its own above them without
a heading, because it is a reset rather than a use case.

The refinements stop looking dumped. When the band became a column they were
three controls left where they landed; they now read as a named section with
one control per row.

The zero state suggests again. It had decayed into a "Browsing / 9 of 1,247 /
select a model" line that restated the count for the third time on one screen.
It now offers the four use cases as tiles that set the filter, which is the
shelf idea from the mock without inventing curation or paying for a second
fetch.

Two bugs found by looking at it rather than at the tests: the disclosure was
clamped to 190px, which cut it off partway through its third section so two of
the five never appeared at all; and the creation actions rendered twice, once
in the new bar and once in the pane hero a few pixels away.

e2e: full suite 353 passing. The chip-row test now holds its contract across
the per-family rows rather than a single one, and additionally asserts every
family is present and non-empty.

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

* fix(ui): pin the split view's height so a long detail scrolls the pane

Selecting a model with a long description grew the whole page and dragged the
rail down with it, which is the opposite of what "full height" was supposed to
buy.

The flex chain was right and the ceiling was missing. .app-layout and
.main-content are min-height:100dvh, which is a floor: flex distributes free
space but nothing caps growth, so a pane taller than the viewport expanded the
column, the document scrolled, and the rail stretched to match. height:100% on
the pane then resolved against an auto-height parent and did nothing.

The chat route already solves this by pinning .main-content to 100dvh. The
same treatment now applies to any route containing a .page--app, selected with
:has() so the shell does not have to learn which pages happen to be split
views. Below the stacking breakpoint the pin is lifted, because two stacked
halves in two short scrollers is worse than a page that scrolls.

Measured on a live instance: document height stays at the viewport across
selection (950px either side) and the pane overflows internally instead.

Adds discover-height.spec.js, which asserts the page height and the rail height
are unchanged by selection and that the pane is the thing that scrolls. The
existing specs could not have caught this: they mock short descriptions, and
the bug only appears when the pane has more content than the viewport holds.

e2e: full suite 355 passing.

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

* feat(ui): give Backends and Host the full-height view, and fix the Update button

Backends now matches Discover: the header fuses into a slim bar carrying the
title, the count and the page-level actions, the filters move into the rail
column where they narrow the rail and nothing else, and the split fills the
window. Its seven chips fit at rail width, so unlike Discover's nineteen they
need no disclosure. Host gets the bar and the height; its resource monitor,
summary cards and tabs stay above the split, because those are read once while
the rail and the pane are worked in.

Two things the height change surfaced.

The console layout is a flex row with align-items:flex-start, so its body sizes
to content. Right for the pages it was built for, wrong for a split view, which
needs a ceiling to scroll inside: without it the Backends rail ran past the
viewport and over the footer. Pinned with :has() so only split-view routes are
affected.

The filters vanished when nothing matched. Both galleries swapped the whole
shell for an empty state, which took the search box and the chips with it, so
the page said "try adjusting your search or filters" while offering neither.
The shell now stays and the empty state moves into the pane.

Also fixes the Update control on Host, which had no className at all and
rendered as bare text, next to a status span that had picked up btn classes and
two copies of `fas` and so rendered as a button you cannot press. They have
swapped appearances back.

e2e: full suite 355 passing. The render-smoke selector learns .view-bar__title,
since the pages it checks no longer all use PageHeader.

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

* fix(ui): keep the view mounted while searching, and bring rail grouping back

Searching replaced the whole view with a loader. The search box lives in the
rail column, so every debounced refetch unmounted the field being typed into
and dropped its focus with it. The list, the filters and the pane went too.

The shell now stays and the rail says it is busy: a sweep bar under its header
and the stale list dimmed, so the eye knows the answer is being replaced
without losing its place. A cold start still gets the skeleton, because there
is nothing to keep.

The condition for that is "nothing has loaded yet", not "the list is empty".
Those differ exactly when someone is editing a query that matched nothing, and
getting it wrong there would unmount the view on the keystroke after a
no-results search - the worst possible moment.

Grouping comes back on both galleries. It was removed because nine rows could
not fill five buckets, so a page turn rebuilt the rail's whole structure. That
was a symptom of the page size rather than of grouping: the rail now asks for
30 rows instead of 9 (Backends 60 instead of 21), which is enough for the
sections to read as structure and turns five times fewer pages. The order of
the sections is fixed, so what changes between pages is membership, not
arrangement.

Grouped while browsing, flat while searching, as before: once a term is typed
the buckets stand between the reader and the answer.

Also gives GalleryLoader a class and a testid instead of six inline style
declarations on a bare div, which is why nothing could select it.

e2e: full suite 359 passing, including a new spec asserting the search box
keeps its focus and its value across a refetch, and that a cold start still
shows the skeleton.

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

* perf(gallery): stop invalidating the VRAM estimate caches on every request

Searching or turning a page felt slow. It was not the search and not the
listing: /api/models answers in 3-9ms. It was the VRAM estimate, which the
gallery asks for once per row, and which took ~2.3s every single time however
often the same model was asked about.

pkg/vram already caches what makes that expensive - the remote content-length
probes, the GGUF metadata reads and the HF repo sizes. Those caches key on a
gallery generation counter, and AvailableGalleryModelsCached triggered a
background refresh on every call, with each refresh bumping the counter. One
page view is one listing request plus thirty estimate requests, each of which
re-read the gallery and started another refresh, so the generation moved
constantly and every cache entry was stale before it could ever be read. The
caches were dead in production.

Three changes, each doing one thing:

A refresh interval. The cached list is still served immediately; this only
decides how often re-fetching from upstream is worth starting. Five minutes,
as a package variable so tests can drive it without waiting.

A generation bump only when the gallery actually changed. An unchanged gallery
re-fetched on schedule must not throw away work that is still valid, which is
the difference between an estimate costing nothing and costing a network round
trip.

A separate "loaded" flag. The cache engaged on `cached != nil`, so a gallery
that legitimately holds nothing read as never-loaded and took the blocking path
on every call, bumping the generation each time. Found by the test for the
interval, which could not pass while this was true.

Measured against a live instance with 1,595 models:

  one estimate, repeated     2.3s  -> 2ms
  a page of 30, in parallel  10s   -> 0.04s

A first, genuinely unseen model still costs its remote probe. That is inherent;
what changed is that it is now paid once per model per gallery version rather
than once per request.

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

* perf(gallery): warm VRAM estimates at startup, and stop the UI waiting on them

Two halves of the same complaint: the gallery stalls on VRAM estimation.

Server side, the estimates are now warmed in the background at startup.
Estimating an entry nobody has asked about costs a remote probe of its weight
files, and the gallery needs one per row, so the first visitor was paying for
the whole page. The warm-up walks the gallery in the order the UI lists it, so
the first page is ready before anyone reaches it.

It is bounded and it never blocks: 300 entries at 4 at a time by default, on
its own goroutine, stopping with the server's context. Warming the whole
gallery would be thousands of probes on every boot, which is rude to the
upstream and slow to finish; warming nothing leaves the first page paying two
seconds a row. Anything past the limit still warms itself on first view.
LOCALAI_VRAM_WARM_LIMIT=0 turns it off for an air-gapped host,
LOCALAI_VRAM_WARM_CONCURRENCY=1 slows it for a metered link.

Client side, the page no longer waits on estimates it does not need yet. It
fired one request per row at once; a browser allows about six connections per
host, so thirty estimates took every slot and the request behind a click - the
variant list, an install - queued behind work nobody asked for. That is the
freeze: the list was already usable, and the UI was busy fetching sizes. Four
at a time leaves room for the interactive request to overtake, and a row whose
estimate is still in flight says "sizing…" rather than leaving a blank where a
number will appear.

buildEstimateInput moves to core/gallery as EstimateInput, since the handler
and the warmer both need it.

Measured against 1,595 models, from a cold boot:

  page 1, 30 estimates in parallel   10s -> 0.04s
  full warm-up (299 of 300 entries)  3m, in the background

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

* chore: untrack data/.local_user_id and ignore the runtime data dir

`local-ai run` writes its instance state under ./data when started from the
repo root, which is exactly what a contributor testing a build does. The
identity file ended up committed on this branch by a `git add -A` while
verifying the gallery changes against a live instance.

Anchored, so it matches the runtime directory at the repo root and not a
`data` directory nested inside some package.

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

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-02 19:28:36 +02:00
Owen Adirah 1659365059 docs: add reverse proxy timeout guidance (#11195)
docs: clarify reverse proxy bulk job guidance

Mention ingress controllers as another place to configure equivalent upstream response timeouts, and include an example private LocalAI URL for trusted bulk jobs.

Assisted-by: Hephaestus:openai/gpt-5.5 [opencode]

Signed-off-by: Owen Adirah <owenadira@gmail.com>
2026-07-30 15:54:25 +02:00
localai-org-maint-botandlocalai-org-maint-bot ecdb32193d docs(proxy): cover long inference timeouts (#11065)
Document the reverse-proxy settings needed for long-running and multimodal requests, and distinguish edge-generated 504 responses from the optional LocalAI busy watchdog.

Assisted-by: Codex:gpt-5 [Codex]

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-07-29 16:37:03 +02:00
Richard Palethorpe 49ef40a187 feat(classifier/VAD): support voice control on low power devices (#10804)
* feat(llama-cpp): route Score through the slot loop

Score previously bypassed the slot loop with a direct llama_decode: a
conflict guard aborted the whole process if scoring raced generation, the
config validator had to reject score alongside chat/completion/embeddings,
and every candidate re-decoded the full shared prompt.

Add SERVER_TASK_TYPE_SCORE to the (patched) upstream server so score tasks
are scheduled like any other slot work: generation and scoring serialize
naturally, the shared prompt is decoded once per call, and the slot's
prompt cache carries the conversation prefix across calls. Context
checkpoints at the score boundary and at the cache-divergence point keep
SWA/hybrid/recurrent models (e.g. LFM2.5) from re-prefilling the whole
prompt per candidate: warm-turn scoring on a 6-option set drops from ~8s
to ~0.5s on a desktop CPU.

The conflict guard and the validation split are removed; declaring score
with generation usecases on one config is now supported and shares the
slot cache.

Assisted-by: Claude:claude-fable-5
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* feat(realtime): classifier wire types and pipeline config

Wire types and YAML config for realtime classifier mode: sessions carry a
localai_classifier extension (options with canned replies/tool calls,
softmax threshold, normalization, history trimming, fallback modes, and a
deterministic wake-word address gate), mirrored by pipeline.classifier in
the model YAML and surfaced in the config-meta registry. The
localai.classifier.result server event reports the full score distribution
per turn.

Assisted-by: Claude:claude-fable-5
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* feat(realtime): classifier response flow

Classifier-mode responses: instead of autoregressive generation, each user
turn is prefill-scored against the option list (router.ScoreClassifier
prompt/candidate shapes over the Score primitive) and the winning option's
canned reply and tool call are emitted through the existing response
machinery. Below-threshold turns take the configured fallback (none /
canned reply / generate); empty transcripts and unaddressed turns (wake
word not mentioned) skip scoring entirely. The scoring probe defaults to
the latest user message only — small scorers echo canned replies from
prior turns back as the top option otherwise.

Built for hardware that can afford prompt processing but not decode: with
slot-based Score the option list stays KV-cached across turns, so a turn
costs roughly one forward pass over the new words.

session_update_error events now carry the validation cause instead of a
generic message.

Assisted-by: Claude:claude-fable-5
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* fix(realtime): bound the VAD tick's scan window and buffer retention

The VAD tick loop re-scanned the entire input buffer every 300ms and only
trimmed it on zero-segment ticks or commits. Audio that keeps producing
segments without a committing pause (steady noise a mic pipeline lets
through, music, continuous speech) grew the buffer toward the 100MB cap
with each tick rescanning all of it — O(n^2), measured at ~3.3ms of silero
per buffered second: past ~90s retained, ticks run back to back and pin
~4 cores until the stream stops.

Silero's recurrent state only carries a few hundred ms of context, so
rescanning old audio buys nothing. Clip the slice handed to the VAD to the
largest silence the commit test can need to measure (server_vad silence
window or the semantic eagerness fallback) plus a warm-up margin, and
rebase the returned segment times so every downstream consumer keeps
whole-buffer coordinates. An open turn whose clipped window is all silence
now commits (the silence outran the window) instead of being discarded as
no-speech. Independently, retain at most 90s of raw buffer, rebasing the
live-feed and EOU cursors on trim — this also bounds the previously
unbounded VAD-error path. Turn boundaries are otherwise unchanged: no
forced commits, no new coordinator states.

pipeline.turn_detection.vad_window_sec can widen the scan window; values
below the automatic floor are ignored. The tick body is extracted into
vadTick so specs can drive turn detection synchronously (same shape as
classifySoundWindow); the babble reproduction that pinned 4 cores now
plateaus under 10% of one core.

Assisted-by: Claude:claude-fable-5
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* fix(backend): let per-model threads override the global default

ModelOptions overrode a set per-model threads value with the app-level
--threads whenever the latter was non-zero — and WithThreads defaults it
to the physical core count, so it always was. The YAML threads: knob has
been dead config: a tiny VAD model could never opt down from the global
pool size.

SetDefaults already fills an unset per-model value from the app config,
which is the intended precedence; resolve threads through a helper that
honors it (explicit threads: 0 still means unset).

Assisted-by: Claude:claude-fable-5
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* chore(gallery): single-thread the silero VAD

Silero is a ~2MB recurrent model with no exploitable graph parallelism:
measured per-call latency is identical at 1 and 10 ORT threads, while
every extra pool thread just spin-waits between the realtime loop's
frequent tiny inferences.

Assisted-by: Claude:claude-fable-5
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* docs(realtime): classifier mode, VAD scan window, threads precedence

Document the realtime classifier mode (options, threshold guidance,
wake-word address gate, empty-transcript handling), the VAD scan window
and 90s buffer retention (pipeline.turn_detection.vad_window_sec), the
per-model threads precedence, and the M3 classifier note in the realtime
state-machine design doc.

Assisted-by: Claude:claude-fable-5
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* perf(llama-cpp): score all candidates in one batched decode

One scoring call is now a single SERVER_TASK_TYPE_SCORE task: the slot
decodes the shared prefix (prompt + longest common candidate token
prefix) once, then forks one sequence per candidate off it
(metadata-only for the unified KV cache, copy-on-write for recurrent
state) and decodes every candidate's unique tail in one llama_decode.
Previously each candidate was its own task that restored the boundary
checkpoint and re-decoded its full tail sequentially, paying
per-candidate task and decode overhead.

The context reserves SERVER_SCORE_FORK_SEQS extra sequence ids (and
recurrent-state cells) beyond the parallel slots via the new
common_params::n_seq_score_forks. Forking requires the unified KV cache
(already this backend's default) since per-sequence streams would shrink
n_ctx_seq; an explicit kv_unified:false disables forking and Score calls
that need it fail cleanly. Candidates beyond the fork/output budget
decode in successive chunks.

Wire contract and scores are unchanged: per-token logprobs are stitched
from the shared region and the forked tails. Verified bitwise
deterministic call-to-call and independent of candidate order (no
cross-fork leakage via equal-length candidate swap); ranking matches the
per-candidate implementation on the drone battery (winner softmax
0.99996 vs 0.99997), and >16-candidate chunking, prefix-of-another and
empty candidates all pass.

Measured on a desktop CPU: warm /api/score calls 0.52s -> 0.23s; warm
realtime classifier turns 196-303ms. The 9-candidate drone turn decodes
~17 unique tail tokens in one batch instead of nine sequential ~220ms
checkpoint-restore tasks.

Assisted-by: Claude:claude-fable-5
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* fix(realtime): gate scoring capacity by model usecase

Reserve llama.cpp scoring slots only for models that explicitly declare the score usecase, while allowing score to coexist with chat and completion. Reject incompatible unified-KV settings and classifier activation on models without scoring capacity.

Propagate application defaults when resolving realtime and preload pipeline stages so unset thread counts are resolved consistently without overriding explicit model settings.

Assisted-by: Codex:gpt-5
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* fix(ci): honor APT mirrors in the prebuilt llama-cpp compile step

The builder-prebuilt path installs gcc-14 with apt directly and ignored
the APT_MIRROR/APT_PORTS_MIRROR build args the from-source path already
honors, so an ubuntu mirror outage broke every arm64 backend build. Pass
the args into the stage and run apt-mirror.sh (already in the build
context via COPY . /LocalAI) before the apt step.

Assisted-by: Claude:claude-fable-5
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* feat(realtime): classifier argument slots via constrained completion

Hybrid classify-then-complete: a classifier option's canned tool call can
declare typed argument slots (number | enum | string, with defaults and
prompt hints) referenced as "{{name}}" in the arguments template. When
the option wins, the slots are filled by a short grammar-constrained
completion that continues the exact scoring prompt — rendered by the same
cached ScoreClassifier, so the llama.cpp prompt cache is already warm —
with the chosen route JSON re-opened at the first slot field. A GBNF
grammar pins the field skeleton and frees only the values; temperature 0,
a couple dozen tokens at most (~300ms on a desktop CPU for two slots).

Slot declarations and hints ride the option descriptions in the shared
system prompt, informing scoring and the fill alike at no per-turn token
cost. The localai.classifier.result event carries the final arguments and
a fill_latency_ms. On inference failure the slots' defaults apply; a slot
without a default fails the response (or falls through with
fallback.mode: generate). Slot filling requires completion alongside
score in the scoring model's known_usecases.

Verified end-to-end on the Pi drone demo: "fly forward three meters" in
distance mode classifies forward and infers {"distance": 3, "units":
"meters"} in ~310ms, and the drone flies exactly 3 units.

Assisted-by: Claude:claude-fable-5
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* feat(realtime): splice filled slot values into classifier replies

A classifier option's spoken reply can now reference its tool's argument
slots ("Going forward {{distance}} {{units}}."): the values inferred by
the slot-fill completion — or the recovery defaults — are spliced into
the reply as plain text before it is emitted, so what the assistant says
confirms what it actually inferred. Placeholders without a value stay
literal, and options without slots are untouched.

FillToolArguments now returns the raw slot values alongside the spliced
arguments JSON to make the reply templating possible.

Assisted-by: Claude:claude-fable-5
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* fix(realtime): harden classifier slot completion

Reserve context for constrained slot filling, size completions from their encoded output, and encode enum grammar literals as valid JSON. Reject empty enum values and cover the failure modes with regression tests.

Assisted-by: Codex:gpt-5
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* feat(realtime): prewarm the classifier scoring prompt on registration

Swapping a session's classifier option list (a voice-switched command
mode, for instance) made the next turns pay a full re-prefill of the new
option-list prompt — measured 2.4s vs 0.3s warm on a desktop CPU, and
worse: on hybrid-memory models like LFM2.5, whose state cannot be
partially rewound (llama.cpp can only restore checkpoints), *every*
probe change re-prefilled from scratch whenever the last checkpoint
missed the probe boundary, so even same-list turns intermittently cost
full prefills.

Registering an option list (pipeline seed or session.update) now fires a
best-effort background prewarm: two throwaway scores with distinct
probes. The first prefills the new option-list prompt; the second,
diverging exactly where per-turn probe text starts, plants the backend's
rewind point (KV checkpoint) at the stable-prefix boundary that every
real turn reuses. The prewarm hides behind the canned mode-switch reply
— by the time it finishes speaking, the cache is warm. Idempotent per
option set, detached from the registering request's lifetime.

Measured on the drone demo (LFM2.5-1.2B, desktop CPU): first turn after
a mode switch 2374ms -> 340ms; intermittent same-list full prefills
(1.3-2.1s) all -> under 0.5s. For clients that swap lists frequently,
options: [parallel:2] on the scoring model additionally keeps one slot
per list via prefix-similarity routing (+26MB RSS, unified KV).

Assisted-by: Claude:claude-fable-5
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* perf(llama-cpp): checkpoint scoring at the caller-declared stable prefix

Hybrid-memory models (LFM2.5 shortconv, Qwen3.5 deltanet — where new
small models are headed) cannot rewind their state, so any prompt-cache
reuse that needs a rewind falls back to a full re-prefill. For classifier
scoring that meant every probe change re-processed the whole option-list
prompt: the server's checkpoints were placed reactively (at wherever the
previous task happened to diverge), so a checkpoint past the next
divergence was erased rather than restored — measured as intermittent
2-10s turns on prompts with a 95%+ common prefix.

The classifier now computes the probe-invariant prompt prefix once (the
byte-wise common prefix of two synthetic probe renders) and declares its
length with every Score request; the server maps it to a token boundary
and forces a KV checkpoint exactly there on each score prefill. That
checkpoint sits at or before every future divergence under the same
option list, so it always survives and always restores — repeat scoring
costs probe+candidates regardless of how the probe changes.

Also:
- prewarm reruns on every option-list registration instead of memoizing
  per list: with boundary checkpoints a redundant rewarm costs two
  probe-sized decodes, while skipping one after a slot eviction (three
  lists sharing fewer slots evict in LRU cascades) silently moves a full
  re-prefill onto the user's next turn
- new llama.cpp backend option rs_seq:N exposes bounded recurrent-state
  rollback outside speculative decoding; measured impractical for
  deltanet-scale states (65GB for 64 snapshots on Qwen3.5-4B) but cheap
  insurance for small-state models
- docs: the multi-list recipe (parallel:N + sps:0.5 — the default slot
  similarity threshold funnels distinct lists onto one slot)

Measured on the drone demo (LFM2.5-1.2B scorer, desktop CPU), steady
state: every turn 285-421ms including mode switches, vs 2.4s post-switch
and intermittent 1.3-2.9s re-prefills before.

Assisted-by: Claude:claude-fable-5
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* fix(realtime): align classifier cache guidance

Document the single-score prewarm behavior and clean the vendored score patch formatting.

Assisted-by: Codex:gpt-5
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* fix(llama-cpp): guard score task for fork backends

TurboQuant and Bonsai reuse the primary gRPC server against llama.cpp forks that do not carry LocalAI's slot-based Score patches. Compile the Score integration only for the patched primary backend and return UNIMPLEMENTED from fork builds instead of referencing absent task types and common_params fields.

Assisted-by: Codex:gpt-5 [gh]
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* fix(dev): generate gRPC code before commit lint

The coverage phase regenerates ignored protobuf bindings, but lint runs first and can fail against missing or stale output. Generate the pinned bindings before lint so the gate always type-checks the current schema.

Assisted-by: Codex:gpt-5
Signed-off-by: Richard Palethorpe <io@richiejp.com>

---------

Signed-off-by: Richard Palethorpe <io@richiejp.com>
2026-07-29 12:50:22 +02:00
Richard Palethorpe 9c43b2da8f fix(model): make backend shutdown model-scoped (#10865)
Avoid holding the global loader lock across backend lifecycle waits and propagate forced shutdown through distributed workers. Track parallel requests with in-flight counters and reserve worker ports until process termination.

Add focused race tests and an authoritative FizzBee lifecycle model with a fail-closed conformance target.

Assisted-by: Codex:GPT-5 [FizzBee] [Ginkgo]

Signed-off-by: Richard Palethorpe <io@richiejp.com>
2026-07-19 08:43:17 +02:00
mudler's LocalAI [bot]andEttore Di Giacinto 40d35c0385 docs: onboarding overhaul, dedup, and error docs (#7711) (#10895)
* docs: fix CPU image tag (latest, not latest-cpu)

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

* docs: use canonical localai/localai registry in models guide

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

* docs: replace dead llama-stable backend with llama-cpp

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

* docs: correct mitm-proxy intercept config and redaction tier

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

* docs: fix text-to-audio endpoint and broken notice block

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

* docs: fix VAD example, stale FAQ, broken link, CLI list, whats-new dump

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

* docs: render advanced/reference section indexes (consolidate _index)

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

* docs: remove duplicate getting-started build/kubernetes pages

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

* docs: fold container image reference into installation/containers

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

* docs: remove stale advanced fine-tuning page (superseded by features/fine-tuning)

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

* docs: fold distribution/longcat/sound pages into their parents

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

* docs: make getting-started index accurate and complete

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

* docs: carry one concrete model through the getting-started path

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

* docs: add end-to-end 'build your first agent' walkthrough

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

* docs: add runtime errors reference; consolidate troubleshooting from FAQ

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

* docs: add agent actions catalog

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

* docs: agent-scoped MCP, skills walkthrough, agentic disambiguation

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

* docs: add concrete gallery install lines to media feature pages

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

* docs: merge installation into getting-started (URLs preserved via aliases)

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

* docs: add Operations section; move operator pages and P2P API reference

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

* docs: journey-ordered top nav and grouped feature sections

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

* docs: add docs-with-code process gate (PR template + agent instructions)

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

* docs: remove em/en dashes from documentation prose

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>
2026-07-17 22:08:20 +02:00
pos-ei-don 06b4a29387 docs(config): document grpc.attempts timing + tuning guidance (#10868)
The gRPC configuration table only listed the two fields with a one-line
description each, without defaults, without explaining what the total
load window looks like, and without hinting when a user should adjust
them. In practice the default 20 attempts x 2 s = 40 s window is way
too tight for large NVFP4 / FP8 models on slow storage or first-run
CUDA-graph capture, and the resulting kill (exitCode=120, 'context
canceled') looks like a backend crash even though the backend is still
making legitimate forward progress.

Extend the section with:
- Defaults column (20 and 2) added to the table
- Prose explaining that these govern the readiness handshake between
  LocalAI and a freshly spawned backend (Health polling loop)
- Total-load-window formula
- Concrete failure signature so users can recognize a timeout-kill
  vs. a real backend crash
- Example configuration for a ~10 min cold-load window (grpc.attempts
  140, attempts_sleep_time 5), with a note that inference-timeouts and
  the watchdog are unaffected.
2026-07-16 22:18:47 +02:00
LocalAI [bot]andEttore Di Giacinto 8cec22c3b7 feat(vram): per-node VRAM allocation budget (LOCALAI_VRAM_BUDGET) (#10833)
* feat(vram): add vrambudget primitive for per-node VRAM caps

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

* feat(vram): apply default VRAM budget in xsysinfo aggregate getters

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

* feat(vram): wire LOCALAI_VRAM_BUDGET flag to xsysinfo default budget

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

* feat(vram): persist VRAM budget via runtime settings with live apply

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

* test(vram): reset process-global VRAM budget after runtime-settings spec

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

* feat(vram): add VRAM budget field to Settings page

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

* feat(vram): store and enforce per-node VRAM budget in the node registry

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

* feat(vram): apply per-node VRAM budget in router hardware defaults

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

* feat(vram): report worker VRAM budget in node registration

The distributed worker now reports its operator-set VRAM budget string
(LOCALAI_VRAM_BUDGET) to the server on registration. The worker keeps
reporting RAW total/available VRAM and never sets the xsysinfo
process-global budget (that stays standalone-only); the server resolves
and enforces the budget uniformly (Task 6).

Also closes a Task 6 gap: on re-registration, a struct Updates zero-skips
an empty budget, so a worker that dropped LOCALAI_VRAM_BUDGET left the
stale cap in place. For non-admin-override nodes the budget columns are
now force-written (map Updates) even when empty, so removing the env var
clears the cap; admin overrides are preserved unchanged.

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

* style(vram): drop em dash from worker-clear comment

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

* feat(vram): add node VRAM budget admin endpoints

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

* feat(vram): add node VRAM budget control to the node UI

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

* feat(vram): expose set_node_vram_budget MCP admin tool

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

* docs(vram): document LOCALAI_VRAM_BUDGET and node VRAM budget UI

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

* fix(vram): avoid double-applying VRAM budget in GetResourceAggregateInfo

The GPU-branch aggregate returned by GetResourceInfo is sourced from
GetGPUAggregateInfo, which already caps total/free/used against the
process-wide VRAM budget. GetResourceAggregateInfo then applied the
budget a second time. For an absolute budget this is idempotent, but for
a percentage budget b.Apply resolves the ceiling as a fraction of its
input total, so a second pass yields P*(P*T) instead of P*T and distorts
UsagePercent (read by the memory reclaimer in pkg/model/watchdog.go).

Remove the redundant second application so the budget is applied exactly
once, against the raw physical totals, upstream in GetGPUAggregateInfo.

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

* fix(vram): implement SetNodeVRAMBudget on mcp assistant test stub

The LocalAIClient interface gained SetNodeVRAMBudget; the stubClient in
core/http/endpoints/mcp used by the assistant tests is a separate
implementer and needs the method too (broke golangci-lint typecheck and
both test jobs).

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>
2026-07-15 09:58:45 +02:00
LocalAI [bot]andEttore Di Giacinto bcc41219f7 feat: materialize Hugging Face model artifacts (#10825)
* feat(config): add model artifact source contract

Assisted-by: Codex:GPT-5 [Codex]

* feat(downloader): add authenticated raw-byte progress

Assisted-by: Codex:GPT-5 [Codex]

* feat(huggingface): resolve immutable snapshot manifests

Assisted-by: Codex:GPT-5 [Codex]

* feat(models): add artifact storage primitives

Assisted-by: Codex:GPT-5 [Codex]

* feat(models): materialize pinned Hugging Face snapshots

Assisted-by: Codex:GPT-5 [Codex]

* feat(models): bind managed snapshots at runtime

Assisted-by: Codex:GPT-5 [Codex]

* feat(gallery): materialize model artifacts during install

Assisted-by: Codex:GPT-5 [Codex]

* feat(gallery): declare managed Hugging Face artifacts

Assisted-by: Codex:GPT-5 [Codex]

* feat(models): preload managed model artifacts

Assisted-by: Codex:GPT-5 [Codex]

* fix(gallery): retain shared artifact caches on delete

Assisted-by: Codex:GPT-5 [Codex]

* feat(models): report artifact acquisition progress

Assisted-by: Codex:GPT-5 [Codex]

* refactor(backends): load managed models from ModelFile

Assisted-by: Codex:GPT-5 [Codex]

* refactor(backends): load staged speech model snapshots

Assisted-by: Codex:GPT-5 [Codex]

* refactor(backends): use staged snapshots in engine backends

Assisted-by: Codex:GPT-5 [Codex]

* test(distributed): cover staged artifact snapshots

Assisted-by: Codex:GPT-5 [Codex]

* docs: explain managed model artifacts

Assisted-by: Codex:GPT-5 [Codex]

* docs: add product design context

Assisted-by: Codex:GPT-5 [Codex]

* feat(ui): show model artifact download progress

Assisted-by: Codex:GPT-5 [Codex]

* Eagerly materialize Hugging Face artifacts

Materialize HF-backed model references as managed GGUF artifacts during load, with lazy download retained only as fallback.

Assisted-by: Codex:GPT-5 [shell]

* Refactor HF
  downloads through a shared executor

Assisted-by: Codex:GPT-5 [shell]

* drop

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>
2026-07-15 01:09:33 +02:00
LocalAI [bot]andEttore Di Giacinto 4056283aa4 [voice] feat: add managed voice cloning profiles (#10799)
* feat(ui): add voice library workflow

Give administrators a production-ready flow to record or upload consented reference audio, manage reusable profiles, inspect API usage, discover compatible models, and hand a saved voice directly to text-to-speech.

Assisted-by: Codex:gpt-5

* feat(voice): add managed voice cloning profiles

Make reusable reference voices manageable through the admin API instead of requiring model-directory and YAML edits. Discover compatible installed and gallery models from server-side backend capabilities, retain explicit model configuration controls, and stage saved references for supported backends.

Expose profile management through REST and MCP, document backend-specific behavior, and cover the workflow from profile creation through real Qwen3-TTS synthesis. Harden the agent-job HTTP test against completion racing cancellation.

Assisted-by: Codex:gpt-5

---------

Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-13 09:54:46 +02:00
LocalAI [bot]andEttore Di Giacinto b00422e45f feat(backends): add LongCat video and avatar generation (#10792)
* feat(backends): add LongCat video and avatar generation

Assisted-by: Codex:GPT-5 [apply_patch] [exec_command] [web]

* refactor(config): declare model I/O modalities

Make model configs declare input and output modalities so capability discovery no longer branches on backend or checkpoint names. Complete the LongCat gallery and user documentation, make the SDPA patch apply to the pinned upstream revision, and stabilize the Agent Jobs race exposed by the required hook.

Assisted-by: Codex:GPT-5 [web]

---------

Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-12 23:58:46 +02:00
LocalAI [bot]andEttore Di Giacinto 5569b2de56 feat(config): context_size: -1 to auto-use model's full trained context (#10752)
* feat(config): clamp negative context_size to default in EffectiveContextSize

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude:claude-opus-4-8 [Claude Code]

* feat(config): resolve context_size=-1 to model trained max with VRAM warn

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude:claude-opus-4-8 [Claude Code]

* fix(config): treat negative context_size as unset when GGUF is unparseable

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude:claude-opus-4-8 [Claude Code]

* docs(config): document context_size=-1 auto-max

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude:claude-opus-4-8 [Claude Code]

* docs(backend): drop em dashes from EffectiveContextSize comment

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude:claude-opus-4-8 [Claude Code]

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-09 09:03:40 +02:00
LocalAI [bot]andEttore Di Giacinto 40dae953f4 feat: interleaved thinking with tool calls (reasoning_content alias + Anthropic thinking blocks) (#10744)
* feat(schema): accept reasoning_content as inbound alias for reasoning

Interleaved-thinking clients (cogito, vLLM/DeepSeek-style) emit reasoning_content
on assistant turns. Accept it as an inbound alias so reasoning survives the
tool-result loop; canonical reasoning wins when both are present. Emission is
unchanged (still reasoning).

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

* test(schema): pin interleaved reasoning+tool_calls round-trip

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

* test(openai): pin reachedTokenBudget truncation detection

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

* feat(anthropic): add thinking and signature fields to content blocks

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

* feat(anthropic): parse inbound thinking blocks into reasoning

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

* feat(anthropic): emit thinking blocks with synthetic signature on tool turns

Extract buildAnthropicContentBlocks so non-streaming content assembly is
unit-testable, and prepend a thinking block (with an opaque synthetic
signature) before text/tool_use blocks when the request opts into thinking.

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

* feat(anthropic): stream thinking_delta and signature_delta before tool_use

Extract anthropicStreamSequence so the streaming block order is unit-testable,
and emit content_block_start(thinking) -> thinking_delta -> signature_delta ->
content_block_stop before the tool_use block sequence when thinking is enabled.

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

* docs: add interleaved thinking with tool calls guide

Add a features guide describing interleaved thinking: an assistant turn
carrying reasoning and tool_calls together, the reasoning-round-trip
contract (including the reasoning_content inbound alias and Anthropic
thinking blocks with a synthetic signature), per-backend enablement
(reasoning_format for llama.cpp, reasoning_parser/tool_call_parser for
vLLM/SGLang plus the vLLM auto-config hook), a worked request/response
example, and known limitations. Cross-link from model-configuration,
text-generation, and openai-functions.

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>
2026-07-08 16:45:43 +00:00
Roman Mazurenkoandrvmzes a3fdfbc0d1 feat(llama-cpp): add device selection option (#10724)
Allow llama.cpp model configs to select the backend devices used for offload, matching upstream --device behavior so users can exclude a display or debug GPU.

Signed-off-by: rvmzes <rvmzes@rvmzess-MacBook-Pro.local>
Co-authored-by: rvmzes <rvmzes@rvmzess-MacBook-Pro.local>
2026-07-07 20:09:05 +00:00
Richard Palethorpe eb32cd9073 feat(realtime): eager blocking pipeline warm-up + /backend/load API (#10662)
Realtime sessions previously lazy-loaded each pipeline sub-model (VAD,
transcription, LLM, TTS) on first use, so every cold session paid a
per-request model-load stall and load errors only surfaced mid-stream.

Warm the whole pipeline eagerly and blockingly at session start
(including the voice-gate speaker-recognition model, which an enforced
gate blocks each utterance on; compaction's summary_model stays lazy
since it only runs off the response path):
- Add backend.PreloadModel / PreloadModelByName as the single load path
  for every modality (no transcription special-case; backend-omitted
  configs are deprecated).
- The realtime session blocks on Model.Warmup and returns a
  model_load_error to the client if any stage fails to load;
  updateSession warms in the background. Opt out per pipeline with
  pipeline.disable_warmup, exposed as a UI toggle via the
  config-metadata registry.

Add a LocalAI-native POST /backend/load (and /v1/backend/load) that
pre-loads a model -- expanding realtime pipelines into their sub-models
-- as the inverse of /backend/shutdown. There is one preload engine
(backend.PreloadStages): the realtime Warmup methods, /backend/load and
the --load-to-memory startup flag all use it, so --load-to-memory now
also expands pipeline models and records load-failure traces. Pipeline
sub-model alias resolution is likewise shared
(ModelConfigLoader.LoadResolvedModelConfig). Surface the endpoint
everywhere an admin manages models:
- MCP admin tool load_model (httpapi + inproc clients, safety/catalog
  prompts, catalog/dispatch tests).
- "Load into memory" action in the React models UI.
- Swagger regenerated; docs moved to the general backend-monitor page
  since it is not realtime-specific.

Fix a Traces UI crash ("json: unsupported value: -Inf"): audio-snippet
RMS/peak now floor at a finite dBFS, and backend-trace data is sanitized
to drop non-finite floats before marshaling. The sanitizer is
copy-on-write -- it runs on every RecordBackendTrace, so containers are
only re-allocated on the paths that actually changed.

Migrate core/http/openresponses_test.go onto the prebuilt mock-backend
the rest of the http suite already uses -- it was the last spec still
pointing at a real HuggingFace model, so it 404'd wherever no vision
backend was built -- and fix its item_reference specs to send the
spec's "id" field instead of "item_id", which the handler never
accepted.

Assisted-by: Claude:claude-opus-4-8 Claude Code

Signed-off-by: Richard Palethorpe <io@richiejp.com>
2026-07-03 18:00:37 +02:00