Compare commits

...

38 Commits

Author SHA1 Message Date
localai-org-maint-bot
c49caac64d 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
2026-08-02 18:02:50 +00:00
mudler's LocalAI [bot]
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]
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
mudler's LocalAI [bot]
8a80830f33 chore: ⬆️ Update ggml-org/llama.cpp to a7a6d0d269c896218b6c78e0933bd6a17519d3f6 (#11283)
⬆️ Update ggml-org/llama.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-08-02 18:15:49 +02:00
localai-org-maint-bot
7621939028 gallery: add Qwythos 27B variants (#11292)
Add the recommended Q4_K_M build and an MTP-enabled variant with the shared vision projector. Tag the existing Qwythos 9B MTP entry so serving-feature ranking recognizes it.

Assisted-by: Codex:gpt-5

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-02 18:14:40 +02:00
localai-org-maint-bot
cff69a05bf gallery: add Qwen3.6 27B Q8 variant (#11293)
Assisted-by: Codex:gpt-5

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-02 18:14:08 +02:00
localai-org-maint-bot
896b4b6785 gallery: add VibeVoice ASR BitNet variants (#11296)
Add the recommended TQ2 build and a smaller aggressive quantization for the CrispASR backend.

Assisted-by: Codex:gpt-5

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-02 18:13:49 +02:00
mudler's LocalAI [bot]
0990be35b7 chore: ⬆️ Update PrismML-Eng/llama.cpp to 9ca265a57f85f2117942490f421f64a226dd9847 (#11280)
⬆️ Update PrismML-Eng/llama.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-08-02 17:55:32 +02:00
mudler's LocalAI [bot]
d0119bf62c feat(chat): local-ai chat is now a terminal agent (#11291)
* chore(deps): bump cogito to v0.11 ahead of the nib harness

nib is the agent harness that becomes 'local-ai chat'. It requires cogito
v0.11, so pull that bump forward on its own: minimal version selection would
apply it to LocalAI anyway, and both repos use cogito and cogito/clients.
Landing it separately keeps the harness change reviewable.

nib itself is not pinned yet. Nothing in LocalAI imports it, and 'go mod
tidy' runs as a goreleaser before-hook in CI, so an unimported require line
does not survive. It lands with its first importer.

No LocalAI call site needed a change. Both cogito.WithMaxAttempts callers
guard the argument above zero, so v0.11's new clamp is unreachable, and
LocalAI's Multimedia values implement only URL(), so v0.11's new
TypedMultimedia routing treats them as images exactly as v0.10 did.

Binary size (cmd/local-ai): 200,301,381 -> 200,336,045 bytes (+34,664).
A throwaway probe that links nib measured 201,042,243 bytes (+740,862 over
the pre-change baseline).

Assisted-by: Claude Code:claude-opus-5 [Bash] [Edit] [Write]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(chat): resolve and seed the agent state directory

Assisted-by: Claude Code:claude-opus-5 [Bash] [Edit] [Write]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(chat): write the agent config atomically and tighten its modes

Replacing config.yaml in place truncated it first, so an interrupted write
would have destroyed the api_key nib keeps in the same file. Stage through a
sibling temp file and rename over the target instead, and match nib's 0700
directory mode.

Assisted-by: Claude Code:claude-opus-5 [Bash] [Edit] [Write]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(chat): probe the endpoint and classify failures

Probe lists what a LocalAI endpoint advertises and separates the two
failures that need different advice: nothing listening, and rejected
credentials.

go-openai reports a rejected key as one of two concrete types depending
on the error body, and both occur against a real LocalAI. The normal
error handler sends an OpenAI error envelope, which arrives as
*openai.APIError; the opaque-errors handler replies with a bare status
and no body, which arrives as *openai.RequestError. Classifying on only
one of them misses half the cases, so the status is read from either.

A cancelled probe is not reported as an unreachable server, because it
learned nothing about the endpoint, and neither is a reply that could
not be parsed, because something did answer. Both would otherwise send
the user off to start a server that may already be running.

The model list is returned verbatim and in server order. LocalAI lists
whatever it finds in the models directory, including stray archives and
dotfiles, and deciding which advertised ids are real belongs to whoever
presents them.

Assisted-by: Claude Code:claude-opus-5 [Bash] [Edit] [Write]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(chat): resolve the model from flag, config, or the server

Assisted-by: Claude Code:claude-opus-5 [Bash] [Edit] [Write]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(chat): pin that model resolution sorts a copy of the caller's slice

The sort spec asserted only on what the chooser was offered, so replacing the
defensive copy with an in-place sort of req.Available still passed all 37
specs. Assert the input slice's order after the call, so the guarantee cannot
be dropped silently by a later refactor.

Assisted-by: Claude Code:claude-opus-5 [Bash] [Edit] [Write]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(chat): offer to start a server when none is reachable

Assisted-by: Claude Code:claude-opus-5 [Bash] [Edit] [Write]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(chat): bound the server wait and pin readiness and stop semantics

Set cmd.WaitDelay so a backend subprocess holding the child's stderr pipe
cannot block cmd.Wait forever, which would leave exited unclosed, burn the
whole shutdown grace on a clean exit, and leak the waiter goroutine.

Two test gaps closed alongside it: the readiness spec now counts polls, so
treating 503 as ready is observable, and Stop's single-interrupt contract is
pinned by giving StartedServer interrupt/kill hooks that a spec can count.

Assisted-by: Claude Code:claude-opus-5 [Bash] [Edit] [Write]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* refactor(chat): drive Stop through one process interface, hide exec plumbing

Two independent interrupt/kill func fields plus a nil check admitted wirings
no test could distinguish: the pair swapped, so a SIGKILL would strand the
backends SIGINT exists to let local-ai run clean up, or kill left nil, so a
wedged server never escalates. One two-method interface that *os.Process
already satisfies leaves nothing to swap and nothing to nil.

Also translate exec.ErrWaitDelay, whose text names an os/exec struct field,
into what the user can act on. os/exec only substitutes that sentinel when the
process exited without an error of its own, so no exit status is swallowed.

Assisted-by: Claude Code:claude-opus-5 [Bash] [Edit] [Write]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(chat): replace the REPL with the built-in agent

local-ai chat is now the nib agent harness compiled into the binary: tool
use behind an approval gate, sub-agents, MCP, plugins, and skills, all
auto-configured against the local server.

The REPL goes with it. Its model listing and its 401 classifier were
duplicates of the ones Probe now owns, and the classifier was the version
that misreads a bare 401 with no OpenAI error envelope, so keeping either
would leave the package with two divergent answers to the same question.

github.com/mudler/nib lands in go.mod in this commit rather than earlier:
go mod tidy runs as a goreleaser before-hook on every PR, so a require
line with no importer is stripped before it reaches CI.

Assisted-by: Claude Code:claude-opus-5 [Bash] [Edit] [Write]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* refactor(chat): split the pre-agent phase out of Run and pin it

Everything before the handoff is testable and nothing after it is: once
app.Run owns the terminal there is no seam left. prepare draws that line,
takes interactivity as a parameter so the prompts can be driven over a
pipe, and hands Run the state dir, the model, and any server it started.

The questions move onto one prompter that owns its buffered reader. A
fresh bufio.Reader per question reads ahead and discards what it buffered,
so the model choice typed behind an answer to "start a server?" was lost
and the next question saw EOF.

choose answers with a list index and refuses an empty offer, so a value
that was never on the list cannot reach ResolveModel, which persists it
and starts every later run against it.

Assisted-by: Claude Code:claude-opus-5 [Bash] [Edit] [Write]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(chat): bound each server check with a deadline

Nothing bounded the model listing, so pointing chat at an address that
accepts the connection and then never replies left the user with no
output and no offer to start a server.

The budget is context.WithTimeout rather than a cancel plus a timer.
Probe deliberately refuses to call an endpoint unreachable on a
context.Canceled, since a caller who gave up learned nothing about the
server, and only honours a deadline. A cancel-based budget therefore
expires as the one error that suppresses ErrUnreachable, exactly for the
hung servers the offer exists to rescue.

Assisted-by: Claude Code:claude-opus-5 [Bash] [Edit] [Write]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(chat): tell the user when their model choice cannot be saved

The choice is meant to be asked for once. When saving it fails the user
is silently asked again on the next run, and the only trace was an
xlog.Warn: the agent runs at log level error, and a --log-level=error run
swallows it entirely.

ModelRequest gains Notify for exactly this class of problem, one that is
worth telling the user about but not worth failing over, and the chat
wiring points it at the same writer the question was asked on.

Assisted-by: Claude Code:claude-opus-5 [Bash] [Edit] [Write]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(chat): stop a session's server when the process is signalled

A server started for the session is stopped by a deferred call, and a
signal skips deferred calls: a SIGTERM between the spawn and the exit
left 'local-ai run' reparented to init with nothing left that knew to
shut it down. Ctrl+C was already safe, but only incidentally, because the
child shares this process' foreground process group.

A signal handler rather than Pdeathsig on the child. Pdeathsig is
Linux-only and, in Go, is delivered when the OS thread that forked exits
rather than when the process does, so it can fire on a healthy parent.
Setpgid would break the Ctrl+C that works today by taking the child out
of the foreground group.

SIGHUP joins SIGINT and SIGTERM: a terminal program whose terminal is
gone has nobody left to talk to. The same context is what cancels the
agent, which nib leaves to its embedder.

Assisted-by: Claude Code:claude-opus-5 [Bash] [Edit] [Write]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(chat): only skip the server checks for work that stays local

Two argument shapes were classified wrongly. Every 'mcp ...' invocation
counted as management, so 'local-ai chat mcp --stdio', which serves the
agent over MCP and needs a model like any other session, was handed an
empty one. And --init, whose shell snippet a user pastes into an rc file
long before any server exists, went the other way: it demanded a running
server to print a static string.

The mcp split is asked of nib's own IsMCPManageSubcommand rather than
restated here, so a verb added upstream cannot drift out of this list.

Assisted-by: Claude Code:claude-opus-5 [Bash] [Edit] [Write]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(chat): exit with the agent's status instead of reporting it twice

nib writes what went wrong to stderr and returns nothing but an exit
code, so returning that error unchanged had main log "Error running the
application error=exit status 1" underneath the message the user had just
read. The refusal to render the full-screen interface into a pipe is the
one they meet in practice: it names --cli, and burying that hides the fix.

ExitCodeError says "already reported, exit with this status". main
honours it and prints nothing more, so a piped or redirected chat still
fails a script the way it should.

Assisted-by: Claude Code:claude-opus-5 [Bash] [Edit] [Write]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* style(chat): route interactive chatter through one writer helper

The prompts and notices all write to a terminal, where a failed write is
not worth failing the session over and the read that follows the question
reports the real problem. say says that once instead of five discarded
error returns.

The command's one-line help comes along: chat is no longer "an
interactive chat session".

Assisted-by: Claude Code:claude-opus-5 [Bash] [Edit] [Write]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* docs(chat): record why the agent gets this process' streams

Injecting them is what makes nib refuse to draw its full-screen interface
into a pipe and name --cli, instead of rendering onto a terminal the
caller may not own. The tradeoff is worth stating where the wiring is.

Assisted-by: Claude Code:claude-opus-5 [Bash] [Edit] [Write]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(chat): stop the session's server on cancellation, not on the way out

The deferred Stop is reached only if the agent returns, and cancelling
the context does not make it: nib hands the TUI to bubbletea without the
context, so what actually unwinds a running session today is bubbletea's
own SIGINT and SIGTERM handler. SIGHUP has no such backstop, and
registering for it removed the default disposition that used to end the
process outright, so kill -HUP left a live TUI with a cancelled context
and the started server still running.

runSession watches the context alongside the agent and stops the server
the moment it is cancelled, so the guarantee no longer depends on what
the agent does with cancellation. Stop is idempotent, so the deferred
call stays correct and free.

The doc comment on shutdownContext described the mechanism it was
supposed to work by rather than the one that does. Corrected, bubbletea's
handler included.

ResolveModel now checks the chooser's answer against what it offered.
The shipped chooser answers by list index and cannot be wrong, but
ModelChooser is exported, the answer is persisted, and every later run
starts against it, so the invariant belongs at the consumer.

Assisted-by: Claude Code:claude-opus-5 [Bash] [Edit] [Write]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* chore(chat): bump nib to v0.5.1

v0.5.1 carries four fixes that matter to 'local-ai chat':

- --init now names the embedder's command, so the emitted widget invokes
  'local-ai chat' rather than a bare 'nib' the user does not have.
- A piped CLI session that succeeds exits 0 instead of failing with EOF.
- EOF at a tool-approval prompt denies the call rather than approving it,
  and the session exits 3 (app.ExitCodeApprovalNoInput) so a script can tell
  "answered" from "refused to act" without reading stdout. Read-only tools
  are unaffected and still run. ExitStatus already unwraps app.ExitError,
  so the code propagates with no change here.
- RunTUI passes the context to bubbletea and gives up bubbletea's own signal
  handler, which makes shutdownContext the single owner of the signal and
  stops a SIGHUP leaving a wedged TUI behind.

Verified against a live server on 127.0.0.1:8080: the three --init shells,
a piped prompt exiting 0, a denied 'touch' that left no file and exited 3,
a read-only 'ls' that still ran and exited 0, and a SIGHUP that unwound a
TUI running under a pty.

Two comment blocks in run.go described the old TUI behavior and are now
wrong, so they are corrected in the same change. No behavior change: both
shutdownContext and runSession are untouched, and stopping the server on
cancellation is still worth keeping independent of how promptly nib unwinds.

One known gap, not addressed here. The widget --init now emits runs
'output=$(local-ai chat --height 50%)', and runAgent injects Stdout
unconditionally, so under $(...) nib refuses the TUI for a non-terminal
stream. This is the cost the runAgent comment already anticipated, now that
the snippets no longer hardcode standalone nib. Ctrl+Space should not be
documented until that is decided.

Assisted-by: Claude Code:claude-opus-5 [Bash] [Edit] [Write]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(chat): let nib own stdout, so the Ctrl+Space widget works

The widget 'local-ai chat --init' emits runs
'output=$(local-ai chat --height 50%)', which puts a pipe on stdout by
construction. runAgent injected os.Stdout unconditionally, and nib refuses
every mode but --cli when a stream it was handed is not a terminal, so
Ctrl+Space printed "Re-run with --cli to use the injected streams" and
inserted nothing. Verified against a pty before and after.

nib reads a nil stream as "not injected" and falls back to the process
stream, which is how an embedder asks for nib's own behavior. That is what
stdout needs: the interface renders on /dev/tty but writes the chosen
command to stdout even when stdout is a pipe, and that write is the whole
of the shell-capture idiom.

Stdin is deliberately left injected. A piped or redirected stdin really is
ignored by the interface, so the refusal is the honest answer there, and it
is the one users meet: 'echo q | local-ai chat' still says to re-run with
--cli, once, exit 1. Nilling stdin the way stdout is nilled would delete
that silently. Stderr is not gated by nib at all and is unchanged.

One case does change and cannot be kept: 'local-ai chat > out.txt' from a
terminal no longer refuses, because it is indistinguishable from the
widget. It renders on /dev/tty and writes the capture line to the file,
which is what standalone nib does.

The app.Options literal moves into agentOptions so the decision is
reachable from a spec rather than being a detail of a function that takes
the terminal. Both sides of the asymmetry are pinned: reinstating
'Stdout: opts.Out' fails "hands nib nothing for the process stdout", and
nilling stdin fails "hands the process stdin over".

Also rewrites the last comments describing the pre-v0.5.1 behavior.

Assisted-by: Claude Code:claude-opus-5 [Bash] [Edit] [Write]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* docs(chat): say what the stream refusal actually keys on

Two comments still called it the refusal to render the interface "into a
pipe". That was true when both stdin and stdout were injected, but a pipe on
stdout no longer refuses, so the wording now points at precisely the case
that was un-refused to make Ctrl+Space work. Only a stdin that cannot be
read triggers it, and both comments now say so and name the command a user
meets it with, 'echo q | local-ai chat'.

The agentOptions doc also said a "file a caller chose" stays injected and
refused, which reads as though 'local-ai chat > out.txt' still refuses. It
does not: a shell redirect arrives as os.Stdout and is nil-ed like the
widget's pipe, because the two differ only in being a regular file rather
than a FIFO and nib's gate does not look at that. What stays injected is a
writer an in-process caller chose for itself. Says that now, in the doc and
in the spec comment that had the same ambiguity.

Comments only. No behavior change.

Assisted-by: Claude Code:claude-opus-5 [Bash] [Edit] [Write]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* docs: local-ai chat is now the built-in terminal agent

`local-ai chat` was a plain chat prompt and is now an agent that runs
shell commands behind an approval gate, so the pages that described a
REPL were wrong rather than merely thin.

Adds a Terminal agent feature page at /features/terminal-agent covering
the approval gate, piped runs and their exit codes, Ctrl+Space, model
resolution, state directory, and the pass-through management commands
(including the `--yes` caveat that leaves a plugin installed but
disabled in a script).

The three-way "looking for something else" notice becomes four-way and
moves into an agentic-routing shortcode. Four hand-kept copies of the
same paragraph is what produced the drift the new page would otherwise
have added to; the shortcode takes `current=` so each page still marks
itself, and errors the build on a name that is not one of the four.

Assisted-by: Claude Code:claude-opus-5 [Bash] [Edit] [Write]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* website: the agent is in the binary, not a second install

The nib section sold a separate tool you also install, with a GitHub
link as the only way in, which is now the wrong order: the agent ships
compiled into local-ai, and the standalone binary is the second reason
to care rather than the first.

Leads with `local-ai chat`, keeps nib as the SSH-anywhere story, and
adds a docs CTA pointing at the new Terminal agent page. id="nib" is
left alone because localai.io/#nib is linked from outside.

The two credits on the demo clip named nib as the thing that drove the
machine; they now credit the agent in LocalAI, which is the same agent.

Assisted-by: Claude Code:claude-opus-5 [Bash] [Edit] [Write]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* website: fix the exit keys, the plugin warning, and the redirect gap

Three claims on the chat-agent pages that the code does not back.

try-it-out told readers to press Ctrl+D. nib has no Ctrl+D handler: the
full-screen interface quits on Esc or Ctrl+C, and Ctrl+D is only an exit
in --cli, where it arrives as ordinary tty EOF. That sentence had
replaced the removed /exit and /quit text, so the page was left with no
working way to leave a session. Document both modes, since they differ.

The plugin warning said nothing tells you the install stopped short. It
does: the command prints that the plugin was left disabled. What it does
not do is say so in its exit code, which is 0 either way. That is the
part a script cannot work around, and it is the reason to pass --yes.
Overstating it in the paragraph that gives the advice only makes the
advice easier to dismiss.

Redirecting stdout no longer refuses; the interface goes to /dev/tty and
only the yanked command reaches the file. It is what lets the Ctrl+Space
widget capture a command at all, since a redirect and out=$(...) are the
same thing to the stream gate. It was documented nowhere. A non-terminal
stdin is still refused, and the new text says which of the two it is.

Assisted-by: Claude Code:claude-opus-5 [Bash] [Edit] [Write]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(chat): make the CLI flags outrank the agent config file

local-ai chat routed --endpoint, --model, --api-key, --trace-dir and --yolo
through nib's app.Options.Defaults. Defaults are seeds: they sit beneath the
config file, so the file silently undoes them. That made the flags accepted and
inert, and not in an edge case, since EnsureStateDir writes base_url on the
first run and the interactive picker writes model, so from the second run on
the file carried a value for both.

Observed against a live server: with base_url: http://127.0.0.1:9999/v1 in the
config and --endpoint http://127.0.0.1:8080 on the command line, the probe hit
8080 and every agent turn posted to 9999. With model: gemma-4-e2b-it-qat-q4_0
in the config, --model lfm2.5-8b-a1b was ignored on the wire.

nib v0.6.0 adds app.Options.Overrides, applied above the config file and above
the bare environment block. Move the whole block there: all five values are
decisions this invocation already made on the user's behalf, and a flag the
config file can undo is not a flag. Nothing is left in Defaults, because
LocalAI's one genuine seed, the initial base_url, is written into the config
file by EnsureStateDir rather than handed to nib.

Two limits come with the channel and are documented on agentOptions rather than
worked around. An override can only raise a field, since nib cannot tell "set
to the zero value" from "not set", so --yolo can turn approval off but nothing
on the command line turns it back on over an approval_mode: auto in the file.
And nib's own NIB_TRACE_DIR and NIB_YOLO are resolved after the config load and
still outrank these, deliberately, upstream.

The existing spec pinned that the right values reach app.Options, which they
always did, which is exactly why it could not see nib discarding them. The new
specs resolve the config the way app.Run resolves it, against a real config
file that disagrees with every flag, and one asserts Defaults stays empty.

docs/content/features/terminal-agent.md already documented --model as winning
over the saved model; that was false before this change and is true now, so no
docs edit was needed.

Assisted-by: Claude Code:claude-opus-5 [Bash] [Edit] [Write]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(chat): document intentional config file read

Assisted-by: Codex:gpt-5 [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-08-02 09:23:26 +02:00
mudler's LocalAI [bot]
359bd4850d docs(blog): bring the 4.8 release post up to the final changelog (#11287)
The post was written against the first draft of the release notes, when the
cycle stood at 214 PRs over thirteen days. It closed at 321 PRs over eighteen
days, and three of the larger user-facing changes landed after it was written.

- Correct the counts throughout: 321 PRs, eighteen days, 24 contributors
  (11 first-time), gallery 1,221 to 1,505.
- Add sections for the three new capabilities: 3D generation as a modality
  (Generate3D, FLAG_3D, /v1/3d/generations, trellis2cpp), audio.cpp serving
  six audio endpoints from one process, and the operations bar becoming the
  Activity page.
- Cover the two further hardening fixes (tar hardlink escape, cyclic $ref
  stack overflow) alongside the TRL one.
- Note the Valkey store, systemd socket activation, persistent trace history,
  in-place chat edits, the self-contained SYCL backend and the site split.
- Group the new-engine sections together rather than splitting them across
  the operational ones.

Embeds the existing vllm-race and magpie clips, and adds a 3D generation clip
cut from the demo recording to the conventions in .agents/preparing-a-release.md
(no audio track, 14s, named for the feature). blog.css styled figure img but
not figure video, so a clip in a post rendered outside the card; both selectors
now share the rule.


Assisted-by: Claude Code:claude-opus-5 [Read] [Edit] [Bash]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-02 00:37:19 +02:00
mudler's LocalAI [bot]
a49f115b0d chore: ⬆️ Update ikawrakow/ik_llama.cpp to 0be97a7a5ad113f33e08729261649ccea2cdc5ff (#11282)
⬆️ Update ikawrakow/ik_llama.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-08-01 23:53:14 +02:00
mudler's LocalAI [bot]
0d6b38e709 chore: ⬆️ Update 0xShug0/audio.cpp to 545e29a6f2fde24298cb3b0f07baab4352987ac9 (#11281)
⬆️ Update 0xShug0/audio.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-08-01 23:52:58 +02:00
mudler's LocalAI [bot]
bef30732cd chore: ⬆️ Update CrispStrobe/CrispASR to 66ac7843e319b588f5410051c575affd19424fb3 (#11279)
⬆️ Update CrispStrobe/CrispASR

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-08-01 23:52:44 +02:00
mudler's LocalAI [bot]
5b7ca31bd1 chore(model-gallery): ⬆️ update checksum (#11285)
⬆️ Checksum updates in gallery/index.yaml

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-08-01 23:52:32 +02:00
mudler's LocalAI [bot]
21ecc799e5 fix(qwen3-tts-cpp): hold qwentts.cpp at 35ebe537, upstream master hangs in synthesis (#11286)
tests-qwen3-tts-cpp has been failing on master since 2026-07-31. The suite
loads every component fine and then stops: TTS() never returns from the
native call, so a job that takes ~5 minutes runs into the 20 minute Go test
timeout instead.

    goroutine 74 [syscall, 19 minutes]:
    github.com/ebitengine/purego.RegisterFunc.func4
    qwen3-tts-cpp.(*Qwen3TtsCpp).TTS  goqwen3ttscpp.go:154
    qwen3-tts-cpp.init.func2.4        e2e_test.go:90

Not a flake: reproduced on master and again on an explicit re-run.

Bisected across this cycle's eight qwentts.cpp bumps by their own check:
10832, 10850, 10902, 10964, 11006, 11039 and 11127 all pass in ~5 minutes;
11241 (abab6b3) fails at 1h58m. That PR was merged with this check already
red, which is how the hang reached master.

35ebe537..abab6b3 is three upstream commits, and the only functional one is
26dd8adb, "predictor: unroll the frame into one cgraph and sample in standard
ops", which is consistent with a generation loop that never reaches its stop
condition.

Hold the pin at the last known-good commit. The bump entry is commented out
rather than left in place, because it tracks upstream master and would put
the hang straight back on the next nightly run. Both spots carry a pointer to
the other so the hold is discoverable, and restoring it is uncommenting four
lines once upstream is fixed.


Assisted-by: Claude Code:claude-opus-5 [Read] [Edit] [Bash]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-01 23:40:35 +02:00
localai-org-maint-bot
9fe1165f61 fix(turboquant): retain CPU variants in GPU builds (#11276)
Select the CPU_ALL_VARIANTS target for x86 GPU images so partial offload uses runtime-selected host kernels. Keep GPU arm64 builds on the portable fallback until their toolchains consistently provide gcc-14.

Assisted-by: Codex:gpt-5

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-01 16:06:36 +02:00
mudler's LocalAI [bot]
ad2be8a856 chore: ⬆️ Update ggml-org/llama.cpp to 876a4321163249c43ca4e986818fab5ab081f282 (#11177)
* ⬆️ Update ggml-org/llama.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>

* fix(llama-cpp): drop merged MiniMax-M3 patch

The bumped llama.cpp revision includes the MiniMax-M3 parser and template detection, so the carried patch now rejects during backend preparation. Remove the obsolete patch while retaining the independent score-task patch.

Assisted-by: Codex:gpt-5 [systematic-debugging]

---------

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-01 16:06:13 +02:00
localai-org-maint-bot
3c02d2aa4d gallery: add Inkling Small GGUF variants (#11273)
Add Q4_K_M and IQ2_M sharded llama.cpp entries with the BF16 multimodal projector.

Assisted-by: Codex:gpt-5

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-01 14:14:59 +02:00
localai-org-maint-bot
c0a9c42771 gallery: add Fara1.5 9B GGUF variants (#11271)
Add the new 9B Fara computer-use model alongside its existing 27B sibling, with Q4_K_M and Q8_0 llama.cpp variants plus the required vision projector.

Assisted-by: Codex:gpt-5 [web]

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-01 11:49:26 +02:00
localai-org-maint-bot
cedcbf97a9 fix(llama-cpp): retain CPU variants in GPU builds (#11255)
Build the runtime CPU variant set alongside x86 GPU backends so partial offload uses the host's SIMD kernels instead of the scalar fallback. Keep arm64 GPU images on the portable binary until their builders consistently provide gcc-14.

Assisted-by: Codex:gpt-5

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-01 09:26:23 +02:00
mudler's LocalAI [bot]
7e4a60c701 chore: ⬆️ Update TheTom/llama-cpp-turboquant to 8a891f4b566efdbd3cea92fafee3227a0a267683 (#11258)
⬆️ Update TheTom/llama-cpp-turboquant

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-08-01 09:25:51 +02:00
Zelys
76927ccde3 fix(utils): reject tar hardlinks that escape the extraction root (#11266)
* fix(utils): reject tar hardlinks that escape the extraction root

ExtractArchive pre-scans archive members and rejects symlinks, but tar
hardlink entries carry a regular file mode and so pass that check.
Header.Linkname was never validated, so an archive could create a link
to a path outside the destination directory.

Validate Linkname with the same path check already applied to member
names. Hardlinks that resolve inside the extraction root still extract,
so ordinary archives are unaffected.

pkg/oci/image.go already resolves tar.TypeLink targets before using
them; this brings the archive extraction path in line with it.

Assisted-by: Claude:claude-opus-5
Signed-off-by: Zelys-DFKH <zelys@dfkhelper.com>

* test(utils): cover hardlink overwrite and in-root hardlinks

The existing hardlink test names a link target two levels above the
extraction root, so its final assertion checked a path the link never
resolved to and could not fail. Point the target one level up instead,
at the path that assertion already names.

Add two cases. The first uses a .tar.gz, where ExtractArchive binds a
Tar config with OverwriteExisting set, and follows the link entry with a
regular entry of the same name. Before the fix that pair linked to a
file outside the root and then truncated it through the link, which the
plain .tar case does not reach. The second extracts a hardlink whose
target is an earlier member of the same archive, covering the claim that
ordinary archives are unaffected.

Assisted-by: Claude:claude-opus-5
Signed-off-by: Zelys-DFKH <zelys@dfkhelper.com>

---------

Signed-off-by: Zelys-DFKH <zelys@dfkhelper.com>
2026-08-01 09:25:35 +02:00
localai-org-maint-bot
fca7ab2df4 fix(gallery): correct Nanbeige 4.2 artifacts (#11269)
Use the case-sensitive Hugging Face filenames and refresh the linked SHA256 values for both gallery variants.

Assisted-by: Codex:gpt-5

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-01 09:13:59 +02:00
mudler's LocalAI [bot]
04764bbe89 chore(model gallery): 🤖 add 1 new models via gallery agent (#11268)
chore(model gallery): 🤖 add new models via gallery agent

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-08-01 09:13:25 +02:00
localai-org-maint-bot
2f3dd404b5 feat(import): route MLX TTS models to mlx-audio (#11267)
Detect text-to-speech MLX repositories during model import and emit a TTS-ready mlx-audio configuration. Expose mlx-audio in the backend preference dropdown for repositories without complete metadata.

Assisted-by: Codex:gpt-5

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-01 09:12:54 +02:00
mudler's LocalAI [bot]
a7440f032d chore: ⬆️ Update PrismML-Eng/llama.cpp to 4dd165625bb6c020285eec8b342af25cf60233dd (#11259)
⬆️ Update PrismML-Eng/llama.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-08-01 09:11:48 +02:00
mudler's LocalAI [bot]
4a6cd227a3 chore: ⬆️ Update 0xShug0/audio.cpp to f78227c52736a4792a50aa3f82ead7e7385c891b (#11261)
⬆️ Update 0xShug0/audio.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-08-01 01:23:41 +02:00
mudler's LocalAI [bot]
740d8684b5 chore: ⬆️ Update ggml-org/whisper.cpp to 2ca53bb45e38748d07b310eeb36245a7157ac882 (#11263)
⬆️ Update ggml-org/whisper.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-08-01 01:23:29 +02:00
mudler's LocalAI [bot]
cb432c4c99 chore: ⬆️ Update CrispStrobe/CrispASR to b5211ac635489049ee8ce86a82d69faa18e8d8da (#11264)
⬆️ Update CrispStrobe/CrispASR

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-08-01 01:23:18 +02:00
mudler's LocalAI [bot]
a4cd387100 chore: ⬆️ Update localai-org/rf-detr.cpp to 98d0f381b832ef08a608b65c7dd78db066ed8b9a (#11260)
⬆️ Update localai-org/rf-detr.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-08-01 00:49:42 +02:00
Dimitris Karakasilis
c089caf320 feat(sycl): make the intel llama.cpp backend self-contained on any host (#10991)
* feat(sycl): make the intel llama.cpp backend self-contained on any host

The SYCL backend shipped an incomplete oneAPI runtime AND relied on a
host-provided GPU driver, so it only ran inside the build container. On a
bare host it died with "libze_loader.so.1 / libdnnl.so.3: cannot open
shared object file", and even with the host's Intel driver installed it
SIGSEGV'd during SYCL init when the host driver was built against a newer
glibc than the backend's bundled loader (rolling-release distros).

package_intel_libs now bundles the complete, coherent oneAPI runtime
(the missing MKL ILP64 / sycl_blas / tbb_thread + oneDNN + the dlopen'd
UR adapters, plus a sweep of the backend binaries' own direct deps) and
the Intel GPU userspace driver (libze_intel_gpu + libigdrcl + IGC + gmm)
with its OpenCL ICD manifest, mirroring how package_vulkan_libs bundles
Mesa. run.sh points the Level Zero and OpenCL loaders at the bundled
driver, and install-base-deps.sh installs it in the SYCL build image.
Bundling the driver is safe across kernels because it talks to the host
i915/xe via the stable DRM UAPI (unlike NVIDIA's kernel-locked
userspace).

Validated on Arch (glibc 2.43, i915): the backend loads and runs on an
Iris Xe with no host Intel packages installed.

Assisted-by: Claude:claude-opus-4-8

Signed-off-by: Dimitris Karakasilis <dimitris@karakasilis.me>

* fix(sycl): install a driver that exists, and let the user choose their own

The driver install added earlier in this branch asked apt for
intel-level-zero-gpu, which is not a package in Ubuntu 24.04. apt fails
outright on an unknown name, so neither driver was installed, nothing was there
to copy, and the images carried no driver at all.

It now comes from Intel's own repository, which has 25.18 for this Ubuntu
release, against 23.43 from late 2023 in the Ubuntu archive. The archive driver
does not know any card released since, so a machine with a recent Intel GPU
would end up carrying a driver that cannot drive it. Anything that goes wrong
during that install fails the build on purpose: an unreachable repository is a
passing problem that a retry fixes, while quietly carrying a different driver,
or none, is a difference nobody would notice until a user reports an idle GPU.

run.sh used to overwrite whatever driver the user had chosen. Level Zero uses
only the driver it is given, so on a machine with a card too new for the
carried driver, the GPU would go unused with no way back. Both that setting and
the OpenCL one are now left alone when already set, and the docs say how to
point a backend at the machine's own driver.

The OpenCL setting also used to be applied whenever the backend held a driver
list, even when the driver it named had not been copied, which leaves OpenCL
with nothing instead of falling back to the machine's own driver. It now
requires the copied driver to be present, and the packaging leaves out the list
entry of any driver it did not copy. The oneAPI images list a processor-only
OpenCL library, which was being carried with nothing behind it.

Two more corrections in the packaging. The scan for libraries a program is
linked against only looked at files named llama-cpp-*, so turboquant and bonsai,
which are also built for Intel GPUs, were left with the incomplete set of
libraries this branch set out to fix; it now looks at every program in the
directory. And a build that should carry a driver but ends up without one now
says so, which is what a stale prebuilt base image looks like: such a backend
still runs on a machine that has its own driver, so nothing fails and the only
other symptom is a user reporting an idle GPU.

Backends now also ask the driver to report how much graphics memory is free,
without which llama.cpp reads zero on an integrated GPU, since such a chip
shares the system memory instead of having its own. turboquant and bonsai get
the same run.sh handling as llama.cpp.

The driver is only carried by the builds that start through run.sh, because
run.sh is what points Level Zero and OpenCL at it. The Python backends for
Intel GPUs start differently and would never load it, so they keep using the
machine's own driver rather than carrying several hundred megabytes they cannot
use.

Checked in a container on Ubuntu 24.04: the install brings driver 25.18 with
the files where the packaging expects them, an unreachable repository fails the
build, and the copied set resolves on its own once the machine's Intel packages
are moved away.

Assisted-by: Claude:claude-opus-5
Signed-off-by: Dimitris Karakasilis <dimitris@karakasilis.me>

* fix(ci): rebuild every Linux backend when the GPU packaging script changes

scripts/build/package-gpu-libs.sh decides which GPU libraries end up inside an
image. The filter that builds the backend matrix listed it as an input of the
Python images only, so changing it rebuilt no Go and no C++ backend, even
though those run it from their own package.sh. A packaging fix aimed at the
Intel llama.cpp backend could merge and reach no image, which is the same
failure this rule was written to prevent.

Assisted-by: Claude:claude-opus-5
Signed-off-by: Dimitris Karakasilis <dimitris@karakasilis.me>

* fix(sycl): carry only the driver Level Zero uses, not the OpenCL one

llama.cpp reaches an Intel GPU through Level Zero, which hands the driver
programs that are already compiled and so needs only the back end of the
graphics compiler. The OpenCL driver can be handed source code instead, so it
needs the compiler's front end as well, and that arrives with its own copy of
clang. Carrying it cost about 139 MB in every backend built for Intel GPUs, and
took the carried set from 123 MB to 261 MB.

Nothing here takes that path. No LocalAI code selects an OpenCL device, each
backend image holds one backend, and the documentation never described OpenCL
as a way to run models: the only mentions are a stale clblas row in the
BUILD_TYPE table, for a llama.cpp backend that no longer exists and that no
build matrix entry uses, and the sycl-ls troubleshooting hint. Before this
branch the packaging carried the OpenCL loader and adapter but no driver, so
the path could not work in a released image either. There is nobody to keep
working.

The driver list that OpenCL reads is no longer carried, and run.sh no longer
sets OCL_ICD_VENDORS, so OpenCL inside a container keeps using whatever the
image provides rather than being pointed at a directory with no driver in it.

Checked in a container against the real 25.18 driver: the carried set is 123 MB
with nothing unresolved, and Level Zero still reports the GPU with the
machine's own Intel packages moved out of the way. Neither the Level Zero
driver nor the compiler back end names the front end or clang among the
libraries it opens by name, so the leaner set is complete for this path.

Assisted-by: Claude:claude-opus-5
Signed-off-by: Dimitris Karakasilis <dimitris@karakasilis.me>

---------

Signed-off-by: Dimitris Karakasilis <dimitris@karakasilis.me>
Co-authored-by: localai-org-maint-bot <bot-opensource@localaisrl.com>
2026-07-31 23:39:53 +02:00
localai-org-maint-bot
9584377a50 feat(chat): edit saved conversation messages (#11189)
* feat(chat): edit saved conversation messages

Add inline edit, save, and cancel controls for stored user and assistant messages without triggering inference. Preserve structured message attachments and cancel edits when streaming starts.

Assisted-by: Codex:gpt-5

* test(chat): preserve seeded conversation on reload

The saved-message edit test reloads the page to verify persistence, but its init script was replacing localStorage with the original fixture on every navigation. Seed only an empty store so reloads exercise the data written by the application.

Assisted-by: Codex:gpt-5 [Codex]

---------

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-07-31 23:38:03 +02:00
mudler's LocalAI [bot]
51c9cc1934 chore: ⬆️ Update ikawrakow/ik_llama.cpp to 3f53a059024039358e9fef75b5dc0c99dbcb40f9 (#11262)
⬆️ Update ikawrakow/ik_llama.cpp

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-31 23:37:41 +02:00
localai-org-maint-bot
22e401b43d docs: fix local Hugo working directory (#11183)
Direct repository-root users to the supported make docs target and document the equivalent direct Hugo invocation from docs/.

Fixes #10062

Assisted-by: Codex:gpt-5

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-07-31 23:37:27 +02:00
mudler's LocalAI [bot]
11403f4797 chore(model-gallery): ⬆️ update checksum (#11265)
⬆️ Checksum updates in gallery/index.yaml

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-07-31 23:36:46 +02:00
Ettore Di Giacinto
aa5a9c483a fix(website): connect the runtime, the engines and APEX into one thread
The page reads as a list of features with nothing joining them, so two
things did not land.

The engines section never said these are the backends LocalAI loads. The
runtime section describes a core that pulls each engine in on demand, and
the engines section describes engines written from scratch, and nothing on
the page connected the two sentences. Readers were taking parakeet.cpp and
the rest for unrelated side projects by the same people. The lede now says
whose backends they are before it says anything else.

APEX was used as a known term on first appearance, in a section that opened
onto a benchmark table. Nothing said what it is or why it follows the
engines. It now opens by placing itself in the stack: the engine decides how
fast a model runs, the weights decide whether it runs at all, and APEX is
the second of those. Then the numbers.

Also drops "Most backends wrap somebody else's engine. These do not", which
is the machine-written antithesis shape, and fixes a list that broke its own
parallel halfway through.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-opus-5[1m]
2026-07-31 21:28:21 +00:00
Ettore Di Giacinto
4b3978dcba chore(website): derive the counters from data, refresh them weekly
The star, fork, contributor and release counts were typed into the templates
by hand, so they only moved when somebody remembered. They had already
drifted: stars read 48,042 against 48,067, forks 4,314 against 4,320, and
contributors 224 against 225.

They move to website/data/stats.yaml, which .github/ci/refresh-site-counters.sh
rewrites from the GitHub API, run weekly by a new workflow. The contributors
and releases endpoints never report a total, so the script asks for one item
per page and reads the count out of the Link header. It refuses to write a
zero or a non-number, which is what a rate-limited or failed call looks like,
and the workflow commits only when a number actually moved. The Discord count
has no API behind it, so the script reads the existing value back and carries
it through.

The engine count was wrong in a second way. The hero said 18, the section
heading said "Eighteen engines", the timeline said "Nineteen engines of our
own", and the /engines/ page derived 19 from the data file. All of them now
derive from that same file, so they cannot disagree again.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-opus-5[1m]
2026-07-31 21:23:54 +00:00
localai-org-maint-bot
3f4e446adc gallery: add Qwopus3.6 27B Fusion variants (#11257)
Add Q4_K_M and Q8_0 llama.cpp entries for the newly released Qwopus3.6-27B Fusion reasoning and coding merge, with MTP enabled.

Assisted-by: Codex:gpt-5 [Hugging Face API]

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-07-31 23:05:02 +02:00
138 changed files with 9564 additions and 2556 deletions

View File

@@ -125,7 +125,7 @@ The per-backend prefix match only sees files under a backend's own directory, so
| `backend/backend.proto` | nothing if the edit is additive-only, otherwise everything (see below) |
| `backend/Dockerfile.<x>` | the Linux entries whose `dockerfile:` names it |
| `backend/python/common/` | Python, Linux + Darwin |
| `scripts/build/package-gpu-libs.sh` | Python, Linux only |
| `scripts/build/package-gpu-libs.sh` | every Linux entry (Python, Go and C++ all run it) |
| `scripts/build/<lang>-darwin.sh` | the Darwin entries that build target routes to |
| `.github/workflows/backend_build[_darwin].yml` | everything on that OS |
| anything else under `scripts/build/` (except `*_test.sh`) | everything — conservative default for unclassified packaging inputs |

View File

@@ -113,6 +113,54 @@ if [ "${BUILD_TYPE:-}" = "vulkan" ] && [ "${SKIP_DRIVERS:-false}" = "false" ]; t
rm -rf /var/lib/apt/lists/*
fi
# --- 2b. Intel graphics driver (BUILD_TYPE=sycl*) ---
# The Intel oneAPI base image brings the compilers and the oneAPI libraries, but
# not the driver that talks to the graphics card. The packaging step copies that
# driver into the backend, so that the backend works on a machine which has no
# Intel graphics packages of its own, for the same reason the Vulkan section
# above installs the Mesa drivers. Install it here so there is something to copy.
#
# Only the sycl builds are covered, because those are the ones whose packaging
# copies the driver. See package_intel_libs in scripts/build/package-gpu-libs.sh.
#
# The driver comes from Intel's own package repository, not from the Ubuntu
# archive. The archive has 23.43 from late 2023, which does not know any card
# released since, so a machine with a recent Intel GPU would end up carrying a
# driver that cannot drive it. Intel's repository has 25.18 for the same Ubuntu
# release.
#
# Anything that goes wrong here fails the build, on purpose. An unreachable
# repository is a passing problem that a retry fixes, whereas carrying a
# different driver than intended, or none, is a difference nobody would notice
# until a user reports an idle GPU.
if case "${BUILD_TYPE:-}" in sycl*) true;; *) false;; esac \
&& [ "${SKIP_DRIVERS:-false}" = "false" ]; then
# Ubuntu release name, which is what the repository is indexed by.
ubuntu_codename=$(. /etc/os-release && echo "${VERSION_CODENAME:-}")
if [ -z "$ubuntu_codename" ]; then
echo "ERROR: cannot tell which Ubuntu release this image is, so cannot pick the Intel driver repository" >&2
exit 1
fi
# The key is armored text, which apt reads directly from a .asc file, so
# there is no need for gnupg here. "unified" is the component Intel ships
# its current driver in.
mkdir -p /usr/share/keyrings
curl -fsSL https://repositories.intel.com/gpu/intel-graphics.key \
-o /usr/share/keyrings/intel-graphics.asc
echo "deb [arch=amd64 signed-by=/usr/share/keyrings/intel-graphics.asc] https://repositories.intel.com/gpu/ubuntu ${ubuntu_codename} unified" \
> /etc/apt/sources.list.d/intel-graphics.list
apt-get update
# The first package holds the driver OpenCL talks to, the second the driver
# Level Zero talks to. Between them they pull in the compiler and the memory
# manager that both need.
apt-get install -y --no-install-recommends \
intel-opencl-icd \
libze-intel-gpu1
apt-get clean
rm -rf /var/lib/apt/lists/*
fi
# --- 3. CUDA toolkit (BUILD_TYPE=cublas|l4t) ---
if { [ "${BUILD_TYPE:-}" = "cublas" ] || [ "${BUILD_TYPE:-}" = "l4t" ]; } && [ "${SKIP_DRIVERS:-false}" = "false" ]; then
apt-get update

View File

@@ -0,0 +1,14 @@
#!/usr/bin/env bash
set -euo pipefail
arch=${1:?target architecture is required}
build_type=${2-}
# GPU arm64 base images do not consistently provide the gcc-14 toolchain needed
# to compile ggml's armv9.2 CPU variants. Keep their portable fallback until the
# builder images can supply that compiler.
if [ "$arch" = "arm64" ] && [ -n "$build_type" ]; then
echo llama-cpp-fallback
else
echo llama-cpp-cpu-all
fi

View File

@@ -18,10 +18,12 @@ if [[ -n "${CUDA_DOCKER_ARCH:-}" ]]; then
fi
cd /LocalAI/backend/cpp/llama-cpp
if [ -z "${BUILD_TYPE:-}" ]; then
# Pure CPU image (BUILD_TYPE empty): one build with ggml CPU_ALL_VARIANTS replaces the
# per-microarch binaries (x86: avx/avx2/avx512/fallback; arm64: armv8.x/armv9.x). ggml
# dlopens the best libggml-cpu-*.so at runtime by probing host CPU features.
BUILD_TARGET=$(/LocalAI/.docker/llama-cpp-build-target.sh "${TARGETARCH}" "${BUILD_TYPE:-}")
if [ "$BUILD_TARGET" = "llama-cpp-cpu-all" ]; then
# One build with ggml CPU_ALL_VARIANTS replaces the per-microarch binaries (x86:
# avx/avx2/avx512/fallback; arm64: armv8.x/armv9.x). BUILD_TYPE remains in the
# environment, so GPU builds retain their accelerator backend while ggml dlopens the
# best CPU library when work is offloaded to the host.
#
# arm64: the CPU_ALL_VARIANTS table includes armv9.2 SME variants whose -march=...+sme is
# rejected by the Ubuntu 24.04 default gcc-13. gcc-14 accepts it, so build the arm64
@@ -35,14 +37,8 @@ if [ -z "${BUILD_TYPE:-}" ]; then
apt-get update -qq && apt-get install -y -qq gcc-14 g++-14
export CC=gcc-14 CXX=g++-14
fi
make llama-cpp-cpu-all
else
# GPU build (cublas/hipblas/sycl/vulkan/...): the accelerator does the compute, so a
# single fallback CPU build is enough - no per-microarch CPU variants needed. (This also
# keeps the heavy GPU backend compile from also building the whole CPU variant matrix,
# and avoids the gcc-14 apt step on GPU base images such as nvidia l4t.)
make llama-cpp-fallback
fi
make "$BUILD_TARGET"
make llama-cpp-grpc
make llama-cpp-rpc-server

View File

@@ -0,0 +1,14 @@
#!/usr/bin/env bash
set -euo pipefail
arch=${1:?target architecture is required}
build_type=${2-}
# GPU arm64 base images do not consistently provide the gcc-14 toolchain needed
# to compile ggml's armv9.2 CPU variants. Keep their portable fallback until the
# builder images can supply that compiler.
if [ "$arch" = "arm64" ] && [ -n "$build_type" ]; then
echo turboquant-fallback
else
echo turboquant-cpu-all
fi

View File

@@ -19,20 +19,18 @@ fi
cd /LocalAI/backend/cpp/turboquant
if [ -z "${BUILD_TYPE:-}" ]; then
# Pure CPU image: one ggml CPU_ALL_VARIANTS build replaces the per-microarch binaries.
BUILD_TARGET=$(/LocalAI/.docker/turboquant-build-target.sh "${TARGETARCH}" "${BUILD_TYPE:-}")
if [ "$BUILD_TARGET" = "turboquant-cpu-all" ]; then
# BUILD_TYPE remains in the environment, so GPU builds retain their accelerator while
# ggml selects the best CPU library when model work is offloaded to the host.
# arm64: the armv9.2 SME variants need gcc-14 (gcc-13 rejects +sme).
if [ "${TARGETARCH}" = "arm64" ]; then
sh /LocalAI/.docker/apt-mirror.sh || true
apt-get update -qq && apt-get install -y -qq gcc-14 g++-14
export CC=gcc-14 CXX=g++-14
fi
make turboquant-cpu-all
else
# GPU build (cublas/hipblas/sycl/vulkan/...): single fallback CPU build, the accelerator
# does the compute. Keeps the GPU compile from also building the CPU variant matrix and
# avoids the gcc-14 apt step on GPU base images such as nvidia l4t.
make turboquant-fallback
fi
make "$BUILD_TARGET"
make turboquant-grpc
make turboquant-rpc-server

64
.github/ci/refresh-site-counters.sh vendored Executable file
View File

@@ -0,0 +1,64 @@
#!/usr/bin/env bash
# Refreshes the counters shown on the landing page from the GitHub API.
#
# The numbers used to be typed into the templates by hand, which meant they
# only moved when somebody remembered, and a stale star count on the front
# page is worse than no star count. Everything the API can answer for lives
# in website/data/stats.yaml and is rewritten wholesale by this script.
#
# Anything the API cannot answer for (the Discord member count) is read back
# out of the existing file and carried through untouched.
set -euo pipefail
REPO="${REPO:-mudler/LocalAI}"
OUT="${OUT:-website/data/stats.yaml}"
# The contributors and releases endpoints are paginated and never report a
# total. Asking for one item per page makes the last page number equal to the
# item count, which the Link header hands over.
count_via_link_header() {
local path="$1" link last
link=$(gh api -i "${path}?per_page=1" 2>/dev/null | tr -d '\r' | grep -i '^link:' || true)
if [ -z "$link" ]; then
# No Link header means a single page, so count that page directly.
gh api "${path}?per_page=100" --jq 'length'
return
fi
last=$(sed -n 's/.*[?&]page=\([0-9]*\)>; rel="last".*/\1/p' <<<"$link")
[ -n "$last" ] || { gh api "${path}?per_page=100" --jq 'length'; return; }
printf '%s\n' "$last"
}
read -r stars forks < <(gh api "repos/${REPO}" --jq '"\(.stargazers_count) \(.forks_count)"')
contributors=$(count_via_link_header "repos/${REPO}/contributors")
releases=$(count_via_link_header "repos/${REPO}/releases")
# Not derivable from the GitHub API, so keep whatever is already on disk.
discord=$(sed -n 's/^discord: *\([0-9]*\).*/\1/p' "$OUT" 2>/dev/null | head -1)
discord="${discord:-0}"
for n in stars forks contributors releases; do
v="${!n}"
[[ "$v" =~ ^[0-9]+$ ]] && [ "$v" -gt 0 ] || {
echo "refusing to write: ${n} came back as '${v}'" >&2
exit 1
}
done
cat > "$OUT" <<YAML
# Counters shown on the landing page.
#
# The four GitHub fields are rewritten by .github/ci/refresh-site-counters.sh,
# which runs weekly from .github/workflows/refresh-site-counters.yml. Editing
# them by hand works but will be overwritten on the next run.
stars: ${stars}
forks: ${forks}
contributors: ${contributors}
releases: ${releases}
# The GitHub API cannot answer for this one, so it is maintained by hand and
# the refresh script carries it through untouched.
discord: ${discord}
YAML
echo "stars=${stars} forks=${forks} contributors=${contributors} releases=${releases} discord=${discord}"

View File

@@ -110,10 +110,14 @@ jobs:
variable: "LOCATEANYTHING_VERSION"
branch: "master"
file: "backend/go/locate-anything-cpp/Makefile"
- repository: "ServeurpersoCom/qwentts.cpp"
variable: "QWEN3TTS_CPP_VERSION"
branch: "master"
file: "backend/go/qwen3-tts-cpp/Makefile"
# qwentts.cpp is held, not tracked: upstream master hangs in synthesis
# (see the comment on QWEN3TTS_CPP_VERSION in the backend Makefile).
# Leaving it here would re-bump the pin back onto the hang every night.
# Restore this entry once the upstream fix lands.
# - repository: "ServeurpersoCom/qwentts.cpp"
# variable: "QWEN3TTS_CPP_VERSION"
# branch: "master"
# file: "backend/go/qwen3-tts-cpp/Makefile"
- repository: "ServeurpersoCom/omnivoice.cpp"
variable: "OMNIVOICE_VERSION"
branch: "master"

View File

@@ -0,0 +1,44 @@
name: Refresh site counters
# The landing page shows a star count, a contributor count and a release
# count. They were typed in by hand, so they drifted the moment somebody
# forgot. This pulls the real numbers once a week and commits them only when
# they have actually moved, which in turn triggers the usual Pages deploy.
on:
schedule:
# Mondays, 06:17 UTC. Off the hour on purpose, since the scheduler queues
# everything that asks for :00 and drops what it cannot run.
- cron: '17 6 * * 1'
workflow_dispatch:
permissions:
contents: write
concurrency:
group: refresh-site-counters
cancel-in-progress: false
jobs:
refresh:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Read the counts off the GitHub API
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: ./.github/ci/refresh-site-counters.sh
- name: Commit only if something moved
run: |
if git diff --quiet -- website/data/stats.yaml; then
echo "counters unchanged, nothing to commit"
exit 0
fi
git diff --unified=0 -- website/data/stats.yaml
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add website/data/stats.yaml
git commit -m "chore(website): refresh the counters"
git push

5
.gitignore vendored
View File

@@ -124,3 +124,8 @@ formal-verification/out/
# package directory itself and untrack the source.
/apexentries
/.github/ci/apexentries/apexentries
# Runtime state written by `local-ai run` when it is started from the repo
# root, which is what a contributor testing a build does. Nothing under here is
# source: it is the instance's own models, outputs, traces and identity.
/data/

View File

@@ -161,7 +161,7 @@ local-ai run https://gist.githubusercontent.com/.../phi-2.yaml
local-ai run oci://localai/phi-2:latest
```
To test a running LocalAI server from the terminal, open an interactive chat session from another shell. Inside the prompt, `/models` lists installed models and `/model <name>` switches between them.
To work with a running LocalAI server from the terminal, start the built-in agent from another shell. It answers questions, reads your files and runs commands on your machine, asking you to approve anything that changes state. Inside a session, `/models` lists installed models and `/model <name>` switches between them. See the [Terminal agent](https://localai.io/docs/features/terminal-agent/) docs.
```bash
# Terminal 1

View File

@@ -9,7 +9,7 @@
# recipe is a make target (not a prepare.sh) so 'make purge && make' is a clean
# rebuild and so the bump bot can see the pin.
AUDIO_CPP_VERSION?=f32876cfb45732dd4f43264e9104d229e95b0bc3
AUDIO_CPP_VERSION?=545e29a6f2fde24298cb3b0f07baab4352987ac9
AUDIO_CPP_REPO?=https://github.com/0xShug0/audio.cpp
CURRENT_MAKEFILE_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))

View File

@@ -1,7 +1,7 @@
# Pinned to the HEAD of the `prism` branch on https://github.com/PrismML-Eng/llama.cpp.
# Auto-bumped nightly by .github/workflows/bump_deps.yaml.
BONSAI_VERSION?=7529fdaaf99ffdc5ca71ace9c7409a56b27ad92f
BONSAI_VERSION?=9ca265a57f85f2117942490f421f64a226dd9847
LLAMA_REPO?=https://github.com/PrismML-Eng/llama.cpp
CMAKE_ARGS?=

View File

@@ -40,6 +40,27 @@ else
if [ -d "$CURDIR/lib/hipblaslt/library" ]; then
export HIPBLASLT_TENSILE_LIBPATH="$CURDIR"/lib/hipblaslt/library
fi
# Backends built for Intel GPUs carry a copy of the Intel graphics driver,
# and libze_loader is only there in those builds. Level Zero looks for a
# driver on its own, so point it at the copy that came with this backend: it
# was built against the same C library, while the machine's own driver may
# not have been, and loading that one can crash on start.
#
# Anything the user set is left alone, so a machine with a graphics card
# newer than the driver carried here can still be told to use its own.
# Nothing is said about OpenCL: no OpenCL driver is carried, so anything we
# set there would leave OpenCL worse off than the machine's own setup.
if [ -e "$CURDIR/lib/libze_loader.so.1" ]; then
if [ -e "$CURDIR/lib/libze_intel_gpu.so.1" ] && [ -z "${ZE_ENABLE_ALT_DRIVERS:-}" ]; then
export ZE_ENABLE_ALT_DRIVERS="$CURDIR"/lib/libze_intel_gpu.so.1
fi
# Ask the driver how much graphics memory is free. Without this, the
# backend reads zero on an integrated graphics chip, because such a chip
# shares the system memory instead of having its own.
if [ -z "${ZES_ENABLE_SYSMAN:-}" ]; then
export ZES_ENABLE_SYSMAN=1
fi
fi
fi
# If there is a lib/ld.so, use it

View File

@@ -1,5 +1,5 @@
IK_LLAMA_VERSION?=9992f6b515ee63c7d6f7beee6b8414b0a6d1dd43
IK_LLAMA_VERSION?=0be97a7a5ad113f33e08729261649ccea2cdc5ff
LLAMA_REPO?=https://github.com/ikawrakow/ik_llama.cpp
CMAKE_ARGS?=

View File

@@ -1,5 +1,5 @@
LLAMA_VERSION?=1cbfd1988311775425d36c0ce066590f7d3049cf
LLAMA_VERSION?=a7a6d0d269c896218b6c78e0933bd6a17519d3f6
LLAMA_REPO?=https://github.com/ggerganov/llama.cpp
CMAKE_ARGS?=

View File

@@ -1,225 +0,0 @@
# MiniMax-M3 chat-template parser, vendored from upstream llama.cpp PR #24523.
#
# Upstream has since merged the *model* half of #24523 (LLM_ARCH_MINIMAX_M3,
# src/models/minimax-m3.cpp, the gguf-py constants and conversion/minimax.py), so
# only the chat half is carried here: M3's namespace token "]<]minimax[>[" collides
# with the autoparser's markup delimiters, so common/chat.cpp needs a dedicated
# template detection + PEG parser that upstream does not have yet.
#
# Rebased against LLAMA_VERSION 0d47ea7427463093e69128bf2c2f9cd06b3ee5b3, which also
# renamed common_chat_params::thinking_end_tag to thinking_end_tags (a vector).
# LLAMA_VERSION is auto-bumped nightly; if a bump rejects this patch, re-vendor from
# #24523 — or, once the chat half merges upstream, delete this file.
# See https://github.com/mudler/LocalAI/issues/10820 and PR #10837.
diff --git a/common/chat.cpp b/common/chat.cpp
index 7a6e7238c..2dd015a2e 100644
--- a/common/chat.cpp
+++ b/common/chat.cpp
@@ -2121,6 +2121,191 @@ static common_chat_params common_chat_params_init_deepseek_v3_2(const common_cha
return data;
}
+static common_chat_params common_chat_params_init_minimax_m3(const common_chat_template & tmpl,
+ const autoparser::generation_params & inputs) {
+ common_chat_params data;
+
+ data.prompt = common_chat_template_direct_apply_impl(tmpl, inputs);
+ data.generation_prompt = common_chat_template_generation_prompt_impl(tmpl, inputs);
+ data.format = COMMON_CHAT_FORMAT_PEG_NATIVE;
+ data.supports_thinking = true;
+ data.thinking_start_tag = "<mm:think>";
+ data.thinking_end_tags = {"</mm:think>"};
+
+ // M3 prefixes every tool tag with the namespace token "]<]minimax[>[";
+ // params use the parameter name as the tag (<file_path>...</file_path>).
+ const std::string NS = "]<]minimax[>[";
+ const std::string THINK_START = "<mm:think>";
+ const std::string THINK_END = "</mm:think>";
+ const std::string FC_START = NS + "<tool_call>";
+ const std::string FC_END = NS + "</tool_call>";
+ const std::string INVOKE_END = NS + "</invoke>";
+
+ data.preserved_tokens = {
+ NS,
+ "<tool_call>",
+ "</tool_call>",
+ THINK_START,
+ THINK_END,
+ };
+
+ auto has_tools = inputs.tools.is_array() && !inputs.tools.empty();
+ auto has_response_format = !inputs.json_schema.is_null() && inputs.json_schema.is_object();
+ auto extract_reasoning = inputs.reasoning_format != COMMON_REASONING_FORMAT_NONE;
+ auto include_grammar = has_response_format || (has_tools && inputs.tool_choice != COMMON_CHAT_TOOL_CHOICE_NONE);
+
+ const std::string GEN_PROMPT = data.generation_prompt;
+
+ if (inputs.has_continuation()) {
+ const auto & msg = inputs.continue_msg;
+
+ data.generation_prompt = GEN_PROMPT + THINK_START + msg.reasoning_content;
+ if (inputs.continue_final_message == COMMON_CHAT_CONTINUATION_CONTENT) {
+ data.generation_prompt += THINK_END + msg.render_content();
+ }
+
+ data.prompt += data.generation_prompt;
+ }
+
+ auto parser = build_chat_peg_parser([&](common_chat_peg_builder & p) {
+ auto generation_prompt = p.literal(GEN_PROMPT);
+ auto end = p.end();
+
+ auto reasoning = p.eps();
+ // M3 can emit a bare </mm:think> (no opener) after tool results; keep the opener optional.
+ if (extract_reasoning && inputs.enable_thinking) {
+ reasoning = p.optional(p.optional(p.literal(THINK_START)) + p.reasoning(p.until(THINK_END)) + THINK_END);
+ } else if (extract_reasoning) {
+ reasoning = p.optional(p.optional(p.literal(THINK_START)) + p.until(THINK_END) + p.literal(THINK_END));
+ }
+
+ if (has_response_format) {
+ auto response_format = p.rule("response-format",
+ p.literal("```json") + p.space() +
+ p.content(p.schema(p.json(), "response-format-schema", inputs.json_schema)) +
+ p.space() + p.literal("```"));
+ return generation_prompt + reasoning + response_format + end;
+ }
+
+ if (!has_tools || inputs.tool_choice == COMMON_CHAT_TOOL_CHOICE_NONE) {
+ return generation_prompt + reasoning + p.content(p.rest()) + end;
+ }
+
+ auto tool_choice = p.choice();
+ foreach_function(inputs.tools, [&](const json & tool) {
+ const auto & function = tool.at("function");
+ std::string name = function.at("name");
+ auto params = function.contains("parameters") ? function.at("parameters") : json::object();
+ const auto & props = params.contains("properties") ? params.at("properties") : json::object();
+
+ std::set<std::string> required;
+ if (params.contains("required")) {
+ params.at("required").get_to(required);
+ }
+
+ auto schema_info = common_schema_info();
+ schema_info.resolve_refs(params);
+
+ std::vector<common_peg_parser> required_parsers;
+ std::vector<common_peg_parser> optional_parsers;
+ for (const auto & [param_name, param_schema] : props.items()) {
+ bool is_required = required.find(param_name) != required.end();
+ bool is_string = schema_info.resolves_to_string(param_schema);
+
+ const std::string p_close = NS + "</" + param_name + ">";
+
+ auto arg = p.tool_arg(
+ p.tool_arg_open(
+ p.literal(NS + "<") +
+ p.tool_arg_name(p.literal(param_name)) +
+ p.literal(">")) +
+ (is_string
+ ? p.ac(p.tool_arg_string_value(p.until(p_close)) +
+ p.tool_arg_close(p.literal(p_close)), p_close)
+ : p.tool_arg_json_value(p.schema(p.json(),
+ "tool-" + name + "-arg-" + param_name + "-schema",
+ param_schema, false)) +
+ p.tool_arg_close(p.literal(p_close))));
+
+ auto named_arg = p.rule("tool-" + name + "-arg-" + param_name, arg);
+ if (is_required) {
+ required_parsers.push_back(named_arg);
+ } else {
+ optional_parsers.push_back(named_arg);
+ }
+ }
+
+ common_peg_parser args_seq = p.eps();
+ for (size_t i = 0; i < required_parsers.size(); i++) {
+ if (i > 0) {
+ args_seq = args_seq + p.space();
+ }
+ args_seq = args_seq + required_parsers[i];
+ }
+
+ if (!optional_parsers.empty()) {
+ common_peg_parser any_opt = p.choice();
+ for (const auto & opt : optional_parsers) {
+ any_opt |= opt;
+ }
+ args_seq = args_seq + p.repeat(p.space() + any_opt, 0, -1);
+ }
+
+ common_peg_parser invoke_body = args_seq;
+ auto func_parser = p.tool(
+ p.tool_open(p.literal(NS + "<invoke name=\"") +
+ p.tool_name(p.literal(name)) + p.literal("\">")) +
+ p.space() + invoke_body + p.space() +
+ p.tool_close(p.literal(INVOKE_END)));
+
+ tool_choice |= p.rule("tool-" + name, func_parser);
+ });
+
+ auto require_tools = inputs.tool_choice == COMMON_CHAT_TOOL_CHOICE_REQUIRED;
+
+ common_peg_parser tool_calls = p.eps();
+ if (inputs.parallel_tool_calls) {
+ tool_calls = p.trigger_rule("tool-call",
+ p.literal(FC_START) + p.space() + tool_choice +
+ p.zero_or_more(p.space() + tool_choice) + p.space() + p.literal(FC_END));
+ } else {
+ tool_calls = p.trigger_rule("tool-call",
+ p.literal(FC_START) + p.space() + tool_choice + p.space() + p.literal(FC_END));
+ }
+
+ if (!require_tools) {
+ tool_calls = p.optional(tool_calls);
+ }
+
+ auto content_before_tools = p.content(p.until(FC_START));
+ return generation_prompt + reasoning + content_before_tools + tool_calls + end;
+ });
+
+ data.parser = parser.save();
+
+ if (include_grammar) {
+ data.grammar_lazy = !(has_response_format || (has_tools && inputs.tool_choice == COMMON_CHAT_TOOL_CHOICE_REQUIRED));
+ data.grammar = build_grammar([&](const common_grammar_builder & builder) {
+ foreach_function(inputs.tools, [&](const json & tool) {
+ const auto & function = tool.at("function");
+ auto schema = function.contains("parameters") ? function.at("parameters") : json::object();
+ builder.resolve_refs(schema);
+ });
+ if (has_response_format) {
+ auto schema = inputs.json_schema;
+ builder.resolve_refs(schema);
+ }
+ parser.build_grammar(builder, data.grammar_lazy);
+ });
+
+ data.grammar_triggers = {
+ { COMMON_GRAMMAR_TRIGGER_TYPE_WORD, FC_START },
+ };
+ }
+
+ return data;
+}
+
// Cohere2 MoE (a.k.a. "North Code") parser.
//
// The assistant turn is fully marker-wrapped:
@@ -2707,6 +2892,15 @@ std::optional<common_chat_params> common_chat_try_specialized_template(
return common_chat_params_init_gigachat_v3(tmpl, params);
}
+ // MiniMax-M3: the namespace token "]<]minimax[>[" collides with the autoparser's
+ // markup delimiters, so detect the template and use a dedicated parser.
+ if (src.find("]<]minimax[>[") != std::string::npos &&
+ src.find("<tool_call>") != std::string::npos &&
+ src.find("<invoke name=") != std::string::npos) {
+ LOG_DBG("Using specialized template: MiniMax-M3\n");
+ return common_chat_params_init_minimax_m3(tmpl, params);
+ }
+
// DeepSeek V3.2/V4 format detection: template defines dsml_token and uses it for tool calls.
// The template source contains the token as a variable assignment, not as a literal in markup.
// V3.2 names the tool call block "function_calls", V4 names it "tool_calls".

View File

@@ -12,10 +12,10 @@ grep -e "flags" /proc/cpuinfo | head -1
BINARY=llama-cpp-fallback
# CPU images (x86, arm64, darwin) ship a single llama-cpp-cpu-all built with ggml
# CPU images and x86 GPU images ship a single llama-cpp-cpu-all built with ggml
# CPU_ALL_VARIANTS: ggml's backend registry dlopens the best libggml-cpu-*.so for this
# host, so no shell-side AVX probing. GPU images (cublas/sycl/vulkan/hipblas) ship only
# llama-cpp-fallback (the accelerator does the compute), so fall back to it when absent.
# host, so no shell-side AVX probing. GPU arm64 images still ship llama-cpp-fallback
# until their builder toolchains support ggml's complete arm variant matrix.
if [ -e "$CURDIR"/llama-cpp-cpu-all ]; then
BINARY=llama-cpp-cpu-all
fi
@@ -42,6 +42,27 @@ else
if [ -d "$CURDIR/lib/hipblaslt/library" ]; then
export HIPBLASLT_TENSILE_LIBPATH="$CURDIR"/lib/hipblaslt/library
fi
# Backends built for Intel GPUs carry a copy of the Intel graphics driver,
# and libze_loader is only there in those builds. Level Zero looks for a
# driver on its own, so point it at the copy that came with this backend: it
# was built against the same C library, while the machine's own driver may
# not have been, and loading that one can crash on start.
#
# Anything the user set is left alone, so a machine with a graphics card
# newer than the driver carried here can still be told to use its own.
# Nothing is said about OpenCL: no OpenCL driver is carried, so anything we
# set there would leave OpenCL worse off than the machine's own setup.
if [ -e "$CURDIR/lib/libze_loader.so.1" ]; then
if [ -e "$CURDIR/lib/libze_intel_gpu.so.1" ] && [ -z "${ZE_ENABLE_ALT_DRIVERS:-}" ]; then
export ZE_ENABLE_ALT_DRIVERS="$CURDIR"/lib/libze_intel_gpu.so.1
fi
# Ask the driver how much graphics memory is free. Without this,
# llama.cpp reads zero on an integrated graphics chip, because such a
# chip shares the system memory instead of having its own.
if [ -z "${ZES_ENABLE_SYSMAN:-}" ]; then
export ZES_ENABLE_SYSMAN=1
fi
fi
fi
# If there is a lib/ld.so, use it
@@ -55,4 +76,4 @@ echo "Using binary: $BINARY"
exec "$CURDIR"/$BINARY "$@"
# We should never reach this point, however just in case we do, run fallback
exec "$CURDIR"/llama-cpp-fallback "$@"
exec "$CURDIR"/llama-cpp-fallback "$@"

View File

@@ -1,7 +1,7 @@
# Pinned to the HEAD of feature/turboquant-kv-cache on https://github.com/TheTom/llama-cpp-turboquant.
# Auto-bumped nightly by .github/workflows/bump_deps.yaml.
TURBOQUANT_VERSION?=c26cbdffcf6fc9b7430cd6b117757e9a3f70b7ea
TURBOQUANT_VERSION?=8a891f4b566efdbd3cea92fafee3227a0a267683
LLAMA_REPO?=https://github.com/TheTom/llama-cpp-turboquant
CMAKE_ARGS?=

View File

@@ -12,9 +12,11 @@ grep -e "flags" /proc/cpuinfo | head -1
BINARY=turboquant-fallback
# x86/arm64 ship a single turboquant-cpu-all built with ggml CPU_ALL_VARIANTS: ggml's
# CPU images and x86 GPU images ship a single turboquant-cpu-all built with ggml
# CPU_ALL_VARIANTS: ggml's
# backend registry dlopens the best libggml-cpu-*.so for this host, so no shell-side
# probing. ROCm ships only turboquant-fallback, so fall back to it when cpu-all is absent.
# probing. GPU arm64 images still ship turboquant-fallback until their builder toolchains
# support ggml's complete arm variant matrix.
if [ -e "$CURDIR"/turboquant-cpu-all ]; then
BINARY=turboquant-cpu-all
fi
@@ -40,6 +42,27 @@ else
if [ -d "$CURDIR/lib/hipblaslt/library" ]; then
export HIPBLASLT_TENSILE_LIBPATH="$CURDIR"/lib/hipblaslt/library
fi
# Backends built for Intel GPUs carry a copy of the Intel graphics driver,
# and libze_loader is only there in those builds. Level Zero looks for a
# driver on its own, so point it at the copy that came with this backend: it
# was built against the same C library, while the machine's own driver may
# not have been, and loading that one can crash on start.
#
# Anything the user set is left alone, so a machine with a graphics card
# newer than the driver carried here can still be told to use its own.
# Nothing is said about OpenCL: no OpenCL driver is carried, so anything we
# set there would leave OpenCL worse off than the machine's own setup.
if [ -e "$CURDIR/lib/libze_loader.so.1" ]; then
if [ -e "$CURDIR/lib/libze_intel_gpu.so.1" ] && [ -z "${ZE_ENABLE_ALT_DRIVERS:-}" ]; then
export ZE_ENABLE_ALT_DRIVERS="$CURDIR"/lib/libze_intel_gpu.so.1
fi
# Ask the driver how much graphics memory is free. Without this, the
# backend reads zero on an integrated graphics chip, because such a chip
# shares the system memory instead of having its own.
if [ -z "${ZES_ENABLE_SYSMAN:-}" ]; then
export ZES_ENABLE_SYSMAN=1
fi
fi
fi
# If there is a lib/ld.so, use it

View File

@@ -8,7 +8,7 @@ JOBS?=$(shell nproc --ignore=1)
# CrispASR version (release tag)
CRISPASR_REPO?=https://github.com/CrispStrobe/CrispASR
CRISPASR_VERSION?=677e95d0e60010f10636c3a0b1ba215b38a4a943
CRISPASR_VERSION?=66ac7843e319b588f5410051c575affd19424fb3
SO_TARGET?=libgocrispasr.so
CMAKE_ARGS+=-DBUILD_SHARED_LIBS=OFF

View File

@@ -7,8 +7,18 @@ GO_TAGS?=
JOBS?=$(shell nproc --ignore=1)
# qwentts.cpp version
#
# Held at 35ebe537 rather than tracking latest: abab6b3 hangs in synthesis.
# TTS() never returns from the native call, so tests-qwen3-tts-cpp goes from
# ~5 minutes to the 20 minute Go test timeout. Reproduced on master on
# 2026-08-01 and again on re-run, and the bump PR (#11241) was merged with
# this same check already red.
#
# The regression is in 35ebe537..abab6b3, three upstream commits whose only
# functional change is 26dd8adb, "predictor: unroll the frame into one cgraph
# and sample in standard ops". Restore the bump once that is fixed upstream.
QWEN3TTS_REPO?=https://github.com/ServeurpersoCom/qwentts.cpp
QWEN3TTS_CPP_VERSION?=abab6b3bf317cfa1b788efce1d25f4f9239395ad
QWEN3TTS_CPP_VERSION?=35ebe5376b82a0a59d008586d55bbe623d449011
SO_TARGET?=libgoqwen3ttscpp.so
CMAKE_ARGS+=-DBUILD_SHARED_LIBS=OFF

View File

@@ -11,7 +11,7 @@ JOBS?=$(shell nproc --ignore=1)
# build; leaving this on `master` always picks up the latest C-API surface
# (incl. the per-detection accessor functions used by gorfdetrcpp.go).
RFDETR_REPO?=https://github.com/localai-org/rf-detr.cpp.git
RFDETR_VERSION?=65c0ffcc9a9bc9dae38252f63d0417c9845a6cf7
RFDETR_VERSION?=98d0f381b832ef08a608b65c7dd78db066ed8b9a
ifeq ($(NATIVE),false)
CMAKE_ARGS+=-DGGML_NATIVE=OFF

View File

@@ -8,7 +8,7 @@ JOBS?=$(shell nproc --ignore=1)
# whisper.cpp version
WHISPER_REPO?=https://github.com/ggml-org/whisper.cpp
WHISPER_CPP_VERSION?=4523d0ce373ee4b2176b3251fff29fd4864fcf38
WHISPER_CPP_VERSION?=2ca53bb45e38748d07b310eeb36245a7157ac882
SO_TARGET?=libgowhisper.so
CMAKE_ARGS+=-DBUILD_SHARED_LIBS=OFF

View File

@@ -1,6 +1,7 @@
package main
import (
"errors"
"os"
"path/filepath"
@@ -107,6 +108,13 @@ For documentation and support:
// Run the thing!
err = ctx.Run(&cli.CLI.Context)
if err != nil {
// A command that has already told the user what went wrong returns
// only a status. Logging it as well would print a bare "exit status 1"
// underneath the explanation they just read.
var reported cli.ExitCodeError
if errors.As(err, &reported) {
os.Exit(reported.Code)
}
xlog.Fatal("Error running the application", "error", err)
}
}

View File

@@ -444,6 +444,13 @@ func New(opts ...config.AppOption) (*Application, error) {
// when gallery data refreshes instead of using a fixed TTL.
vram.SetGalleryGenerationFunc(gallery.GalleryGeneration)
// Fill those caches ahead of the first visitor. An estimate for an entry
// nobody has asked about yet costs a remote probe of its weight files, and
// the model gallery asks for one per row, so without this the first page
// spends seconds filling in its own sizes while somebody watches it.
// Non-blocking, and bounded: see DefaultEstimateWarmConfig.
gallery.WarmEstimateCache(options.Context, options.Galleries, options.SystemState, gallery.EstimateWarmConfigFromEnv())
if options.ConfigFile != "" {
if err := application.ModelConfigLoader().LoadMultipleModelConfigsSingleFile(options.ConfigFile, configLoaderOpts...); err != nil {
xlog.Error("error loading config file", "error", err)

View File

@@ -1,30 +0,0 @@
package chat
import (
"context"
"io"
"strings"
)
type Options struct {
Model string
BaseURL string
APIKey string
In io.Reader
Out io.Writer
}
func Run(ctx context.Context, opts Options) error {
if opts.In == nil {
opts.In = strings.NewReader("")
}
if opts.Out == nil {
opts.Out = io.Discard
}
session, err := newChatSession(ctx, newLocalAIChatClient(opts.BaseURL, opts.APIKey), opts.Model)
if err != nil {
return err
}
return runTerminalChat(ctx, session, opts.In, opts.Out)
}

View File

@@ -1,172 +0,0 @@
package chat
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Run chat", func() {
It("streams a single chat response", func() {
var capturedModel string
var capturedAuth string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/v1/models" {
w.Header().Set("Content-Type", "application/json")
writeResponse(w, `{"object":"list","data":[{"id":"test-model","object":"model"}]}`)
return
}
Expect(r.URL.Path).To(Equal("/v1/chat/completions"))
capturedAuth = r.Header.Get("Authorization")
var body struct {
Model string `json:"model"`
Messages []struct {
Role string `json:"role"`
Content string `json:"content"`
} `json:"messages"`
}
Expect(json.NewDecoder(r.Body).Decode(&body)).To(Succeed())
capturedModel = body.Model
Expect(body.Messages).To(HaveLen(1))
Expect(body.Messages[0].Role).To(Equal("user"))
Expect(body.Messages[0].Content).To(Equal("hello"))
w.Header().Set("Content-Type", "text/event-stream")
writeResponse(w, "data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"hi\"}}]}\n\n")
writeResponse(w, "data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"!\"}}]}\n\n")
writeResponse(w, "data: [DONE]\n\n")
}))
defer server.Close()
var out bytes.Buffer
err := Run(GinkgoT().Context(), Options{
Model: "test-model",
BaseURL: server.URL + "/v1",
APIKey: "secret",
In: strings.NewReader("hello\n/exit\n"),
Out: &out,
})
Expect(err).ToNot(HaveOccurred())
Expect(capturedModel).To(Equal("test-model"))
Expect(capturedAuth).To(Equal("Bearer secret"))
Expect(out.String()).To(ContainSubstring("assistant: hi!"))
Expect(out.String()).To(ContainSubstring("bye"))
})
It("auto-selects the only available model", func() {
server := chatTestServer([]string{"solo"}, nil)
defer server.Close()
var out bytes.Buffer
err := Run(GinkgoT().Context(), Options{
BaseURL: server.URL + "/v1",
In: strings.NewReader("/exit\n"),
Out: &out,
})
Expect(err).ToNot(HaveOccurred())
Expect(out.String()).To(ContainSubstring("LocalAI chat (solo)"))
})
It("returns an actionable error when no models are installed", func() {
server := chatTestServer(nil, nil)
defer server.Close()
err := Run(GinkgoT().Context(), Options{
BaseURL: server.URL + "/v1",
In: strings.NewReader(""),
})
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("no chat models are installed"))
Expect(err.Error()).To(ContainSubstring("local-ai models install <model>"))
})
It("returns an actionable error when multiple models are available without a selection", func() {
server := chatTestServer([]string{"alpha", "beta"}, nil)
defer server.Close()
err := Run(GinkgoT().Context(), Options{
BaseURL: server.URL + "/v1",
In: strings.NewReader(""),
})
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("multiple models are available"))
Expect(err.Error()).To(ContainSubstring("--model"))
Expect(err.Error()).To(ContainSubstring("alpha"))
Expect(err.Error()).To(ContainSubstring("beta"))
})
It("lists and switches models inside the chat", func() {
requestedModels := []string{}
server := chatTestServer([]string{"alpha", "beta"}, func(model string) {
requestedModels = append(requestedModels, model)
})
defer server.Close()
var out bytes.Buffer
err := Run(GinkgoT().Context(), Options{
Model: "alpha",
BaseURL: server.URL + "/v1",
In: strings.NewReader("/models\n/model beta\nhello\n/exit\n"),
Out: &out,
})
Expect(err).ToNot(HaveOccurred())
Expect(out.String()).To(ContainSubstring("* alpha"))
Expect(out.String()).To(ContainSubstring(" beta"))
Expect(out.String()).To(ContainSubstring("switched to beta; conversation cleared"))
Expect(requestedModels).To(Equal([]string{"beta"}))
})
})
func chatTestServer(models []string, onChat func(model string)) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v1/models":
w.Header().Set("Content-Type", "application/json")
writeResponse(w, `{"object":"list","data":[`)
for i, model := range models {
if i > 0 {
writeResponse(w, ",")
}
writeResponsef(w, `{"id":%q,"object":"model"}`, model)
}
writeResponse(w, `]}`)
case "/v1/chat/completions":
var body struct {
Model string `json:"model"`
}
Expect(json.NewDecoder(r.Body).Decode(&body)).To(Succeed())
if onChat != nil {
onChat(body.Model)
}
w.Header().Set("Content-Type", "text/event-stream")
writeResponse(w, "data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"ok\"}}]}\n\n")
writeResponse(w, "data: [DONE]\n\n")
default:
w.WriteHeader(http.StatusNotFound)
}
}))
}
func writeResponse(w io.Writer, text string) {
_, err := fmt.Fprint(w, text)
Expect(err).ToNot(HaveOccurred())
}
func writeResponsef(w io.Writer, format string, args ...any) {
_, err := fmt.Fprintf(w, format, args...)
Expect(err).ToNot(HaveOccurred())
}

View File

@@ -1,114 +0,0 @@
package chat
import (
"context"
"errors"
"fmt"
"io"
"sort"
"strings"
openai "github.com/sashabaranov/go-openai"
)
type chatClient interface {
ListModels(ctx context.Context) ([]string, error)
StreamChat(ctx context.Context, model string, messages []chatMessage, out io.Writer) (string, error)
}
type localAIChatClient struct {
client *openai.Client
}
func newLocalAIChatClient(baseURL string, apiKey string) *localAIChatClient {
cfg := openai.DefaultConfig(apiKey)
cfg.BaseURL = baseURL
return &localAIChatClient{client: openai.NewClientWithConfig(cfg)}
}
func (c *localAIChatClient) ListModels(ctx context.Context) ([]string, error) {
resp, err := c.client.ListModels(ctx)
if err != nil {
return nil, err
}
models := make([]string, 0, len(resp.Models))
for _, model := range resp.Models {
if model.ID != "" {
models = append(models, model.ID)
}
}
sort.Strings(models)
return models, nil
}
func (c *localAIChatClient) StreamChat(ctx context.Context, model string, messages []chatMessage, out io.Writer) (string, error) {
stream, err := c.client.CreateChatCompletionStream(ctx, openai.ChatCompletionRequest{
Model: model,
Messages: openAIChatMessages(messages),
})
if err != nil {
return "", friendlyChatError(err, model)
}
defer func() {
_ = stream.Close()
}()
var answer strings.Builder
for {
resp, err := stream.Recv()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return answer.String(), friendlyChatError(err, model)
}
if len(resp.Choices) == 0 {
continue
}
token := resp.Choices[0].Delta.Content
if token == "" {
continue
}
answer.WriteString(token)
if _, err := fmt.Fprint(out, token); err != nil {
return answer.String(), err
}
}
return answer.String(), nil
}
func openAIChatMessages(messages []chatMessage) []openai.ChatCompletionMessage {
converted := make([]openai.ChatCompletionMessage, len(messages))
for i, message := range messages {
converted[i] = openai.ChatCompletionMessage{
Role: message.Role,
Content: message.Content,
}
}
return converted
}
func friendlyChatError(err error, model string) error {
var apiErr *openai.APIError
if errors.As(err, &apiErr) {
switch apiErr.HTTPStatusCode {
case 404:
return fmt.Errorf("model %q is not available. Run `local-ai models list`, install a model with `local-ai models install <model>`, or switch with `/model <name>`", model)
case 403:
return fmt.Errorf("model %q is disabled. Enable it from LocalAI settings or choose another model with `/model <name>`", model)
}
if apiErr.Message != "" {
return errors.New(apiErr.Message)
}
}
msg := err.Error()
if strings.Contains(msg, "model") && strings.Contains(msg, "not found") {
return fmt.Errorf("model %q is not available. Run `local-ai models list`, install a model with `local-ai models install <model>`, or switch with `/model <name>`", model)
}
return err
}

View File

@@ -1,17 +0,0 @@
package chat
import "strings"
func formatChatModelList(models []string, current string) string {
var b strings.Builder
for _, model := range models {
prefix := " "
if model == current {
prefix = "* "
}
b.WriteString(prefix)
b.WriteString(model)
b.WriteByte('\n')
}
return b.String()
}

153
core/cli/chat/paths.go Normal file
View File

@@ -0,0 +1,153 @@
package chat
import (
"fmt"
"os"
"path/filepath"
"gopkg.in/yaml.v3"
)
// stateDirMode matches the mode nib uses for the same directory. The directory
// holds an API key, so it stays owner-only.
const stateDirMode = 0o700
// configFileMode keeps the config owner-only: nib stores the user's API key in
// it alongside the keys written here.
const configFileMode = 0o600
// StateDir resolves where the chat agent keeps its config, plugins, and
// skills. This is user-scoped rather than server-scoped: chat is a client that
// may target a remote LocalAI, so it does not belong under LOCALAI_CONFIG_DIR.
func StateDir(override string) (string, error) {
if override != "" {
return override, nil
}
if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" {
return filepath.Join(xdg, "localai", "chat"), nil
}
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("resolving home directory for the agent state dir: %w", err)
}
return filepath.Join(home, ".config", "localai", "chat"), nil
}
// ConfigPath is the agent's config file inside dir.
func ConfigPath(dir string) string { return filepath.Join(dir, "config.yaml") }
// EnsureStateDir creates dir and, on first run only, seeds a config file
// pointing at baseURL. It deliberately does not seed a model: a baked-in model
// name goes stale as soon as the user installs a different one.
//
// The config file is machine-managed from here on: nib rewrites it whenever it
// self-configures, so hand-written comments in it do not survive.
func EnsureStateDir(dir, baseURL string) error {
if err := os.MkdirAll(dir, stateDirMode); err != nil {
return fmt.Errorf("creating agent state dir %s: %w", dir, err)
}
path := ConfigPath(dir)
if _, err := os.Stat(path); err == nil {
return nil // already configured; never overwrite the user's file
} else if !os.IsNotExist(err) {
return fmt.Errorf("checking agent config %s: %w", path, err)
}
seed := map[string]string{"base_url": baseURL}
data, err := yaml.Marshal(seed)
if err != nil {
return fmt.Errorf("encoding seed agent config: %w", err)
}
if err := writeConfigFile(path, data); err != nil {
return fmt.Errorf("writing seed agent config: %w", err)
}
return nil
}
// PersistModel records the chosen model in the agent config, preserving every
// other key the user may have set, including the api_key nib writes there.
//
// The file is machine-managed: this overlays the model onto the parsed keys and
// re-marshals, which drops comments. That is deliberate rather than an
// oversight, because nib's own save path does the same thing and would erase
// them on its next write regardless.
func PersistModel(dir, model string) error {
// PersistModel is callable before EnsureStateDir, so it cannot assume the
// directory exists.
if err := os.MkdirAll(dir, stateDirMode); err != nil {
return fmt.Errorf("creating agent state dir %s: %w", dir, err)
}
path := ConfigPath(dir)
values := map[string]any{}
// #nosec G304 -- path is the fixed config.yaml name under the user-selected
// chat state directory; selecting that directory is the documented override.
data, err := os.ReadFile(path)
if err != nil && !os.IsNotExist(err) {
return fmt.Errorf("reading agent config %s: %w", path, err)
}
if err == nil {
if err := yaml.Unmarshal(data, &values); err != nil {
return fmt.Errorf("parsing agent config %s: %w", path, err)
}
}
values["model"] = model
out, err := yaml.Marshal(values)
if err != nil {
return fmt.Errorf("encoding agent config: %w", err)
}
if err := writeConfigFile(path, out); err != nil {
return fmt.Errorf("writing agent config: %w", err)
}
return nil
}
// writeConfigFile replaces path with data atomically: it writes a temporary
// file next to the target and renames it over the target. Writing the target in
// place would truncate it first, so an interrupted or out-of-disk write would
// leave a half-written config and destroy the api_key nib keeps in the same
// file. The temporary file must share the directory because rename is only
// atomic within one filesystem.
func writeConfigFile(path string, data []byte) error {
dir := filepath.Dir(path)
// A randomized name rather than a fixed config.yaml.tmp, so two concurrent
// writers cannot corrupt each other's temporary file.
tmp, err := os.CreateTemp(dir, "config.yaml.*.tmp")
if err != nil {
return fmt.Errorf("creating temp file in %s: %w", dir, err)
}
tmpPath := tmp.Name()
renamed := false
defer func() {
if !renamed {
// Leave no litter behind on any failure path.
_ = os.Remove(tmpPath)
}
}()
if _, err := tmp.Write(data); err != nil {
_ = tmp.Close()
return fmt.Errorf("writing %s: %w", tmpPath, err)
}
// Flush before the rename: renaming a file whose contents are still only in
// the page cache can still lose them across a crash.
if err := tmp.Sync(); err != nil {
_ = tmp.Close()
return fmt.Errorf("syncing %s: %w", tmpPath, err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("closing %s: %w", tmpPath, err)
}
// CreateTemp already asks for 0600, but the umask can only ever clear bits,
// so set the mode explicitly rather than inheriting whatever survived.
if err := os.Chmod(tmpPath, configFileMode); err != nil {
return fmt.Errorf("setting mode on %s: %w", tmpPath, err)
}
if err := os.Rename(tmpPath, path); err != nil {
return fmt.Errorf("replacing %s: %w", path, err)
}
renamed = true
return nil
}

186
core/cli/chat/paths_test.go Normal file
View File

@@ -0,0 +1,186 @@
package chat
import (
"os"
"path/filepath"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"gopkg.in/yaml.v3"
)
// richConfig stands in for a config nib has already taken ownership of: a
// comment, a secret, and a nested block. A flat scalar alone would not catch a
// writer that mangles structure or drops a key it does not know about.
const richConfig = `# hand written note
base_url: http://x.invalid/v1
api_key: secret-token
mcp_servers:
files:
command: mcp-files
args:
- --root
- /tmp
`
var _ = Describe("Agent state directory", func() {
Describe("StateDir", func() {
It("prefers an explicit override", func() {
Expect(StateDir("/custom/dir")).To(Equal("/custom/dir"))
})
It("uses XDG_CONFIG_HOME when set", func() {
tmp := GinkgoT().TempDir()
GinkgoT().Setenv("XDG_CONFIG_HOME", tmp)
Expect(StateDir("")).To(Equal(filepath.Join(tmp, "localai", "chat")))
})
It("falls back to ~/.config/localai/chat", func() {
tmp := GinkgoT().TempDir()
GinkgoT().Setenv("XDG_CONFIG_HOME", "")
GinkgoT().Setenv("HOME", tmp)
Expect(StateDir("")).To(Equal(filepath.Join(tmp, ".config", "localai", "chat")))
})
It("fails when neither XDG_CONFIG_HOME nor a home directory is resolvable", func() {
GinkgoT().Setenv("XDG_CONFIG_HOME", "")
GinkgoT().Setenv("HOME", "")
dir, err := StateDir("")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("agent state dir"))
// No silent fallback to a relative path: writing an API key into the
// working directory would be worse than refusing.
Expect(dir).To(BeEmpty())
})
})
Describe("EnsureStateDir", func() {
It("creates the directory and seeds base_url on first run", func() {
dir := filepath.Join(GinkgoT().TempDir(), "chat")
Expect(EnsureStateDir(dir, "http://127.0.0.1:8080/v1")).To(Succeed())
data, err := os.ReadFile(ConfigPath(dir))
Expect(err).ToNot(HaveOccurred())
Expect(string(data)).To(ContainSubstring("base_url: http://127.0.0.1:8080/v1"))
// A model must NOT be seeded: it goes stale as soon as the user
// installs a different one.
Expect(string(data)).ToNot(ContainSubstring("model:"))
})
It("keeps the seeded config and its directory owner-only", func() {
dir := filepath.Join(GinkgoT().TempDir(), "chat")
Expect(EnsureStateDir(dir, "http://127.0.0.1:8080/v1")).To(Succeed())
// nib writes the user's api_key into this same file, so the modes are
// load-bearing, not cosmetic.
config, err := os.Stat(ConfigPath(dir))
Expect(err).ToNot(HaveOccurred())
Expect(config.Mode().Perm()).To(Equal(os.FileMode(0o600)))
state, err := os.Stat(dir)
Expect(err).ToNot(HaveOccurred())
Expect(state.Mode().Perm()).To(Equal(os.FileMode(0o700)))
})
It("leaves an existing config byte-for-byte untouched", func() {
dir := GinkgoT().TempDir()
Expect(os.WriteFile(ConfigPath(dir), []byte(richConfig), 0o600)).To(Succeed())
Expect(EnsureStateDir(dir, "http://127.0.0.1:8080/v1")).To(Succeed())
data, err := os.ReadFile(ConfigPath(dir))
Expect(err).ToNot(HaveOccurred())
// Byte-exact against a fixture carrying a comment and a nested block:
// an implementation that "preserves" by re-marshaling through a map
// fails here rather than passing on a flat scalar.
Expect(string(data)).To(Equal(richConfig))
})
})
Describe("PersistModel", func() {
It("adds a model to an existing config, preserving other keys", func() {
dir := GinkgoT().TempDir()
Expect(os.WriteFile(ConfigPath(dir), []byte("base_url: http://x.invalid/v1\n"), 0o600)).To(Succeed())
Expect(PersistModel(dir, "chosen-model")).To(Succeed())
data, err := os.ReadFile(ConfigPath(dir))
Expect(err).ToNot(HaveOccurred())
Expect(string(data)).To(ContainSubstring("base_url: http://x.invalid/v1"))
Expect(string(data)).To(ContainSubstring("model: chosen-model"))
})
It("replaces an existing model rather than duplicating the key", func() {
dir := GinkgoT().TempDir()
Expect(os.WriteFile(ConfigPath(dir), []byte("model: old\nbase_url: http://x.invalid/v1\n"), 0o600)).To(Succeed())
Expect(PersistModel(dir, "new")).To(Succeed())
data, err := os.ReadFile(ConfigPath(dir))
Expect(err).ToNot(HaveOccurred())
Expect(string(data)).To(ContainSubstring("model: new"))
Expect(string(data)).ToNot(ContainSubstring("model: old"))
})
It("preserves secrets and nested blocks it does not understand", func() {
dir := GinkgoT().TempDir()
Expect(os.WriteFile(ConfigPath(dir), []byte(richConfig), 0o600)).To(Succeed())
Expect(PersistModel(dir, "chosen-model")).To(Succeed())
data, err := os.ReadFile(ConfigPath(dir))
Expect(err).ToNot(HaveOccurred())
var got map[string]any
Expect(yaml.Unmarshal(data, &got)).To(Succeed())
Expect(got).To(HaveKeyWithValue("model", "chosen-model"))
Expect(got).To(HaveKeyWithValue("base_url", "http://x.invalid/v1"))
// Losing this key logs the user out of their own server.
Expect(got).To(HaveKeyWithValue("api_key", "secret-token"))
Expect(got).To(HaveKeyWithValue("mcp_servers",
HaveKeyWithValue("files", And(
HaveKeyWithValue("command", "mcp-files"),
HaveKeyWithValue("args", ConsistOf("--root", "/tmp")),
)),
))
// Documented, accepted behavior rather than an aspiration: the overlay
// re-marshals, so comments do not survive. nib's own save path erases
// them too, so preserving them here would buy nothing.
Expect(string(data)).ToNot(ContainSubstring("# hand written note"))
})
It("keeps the rewritten config owner-only and leaves no temp file behind", func() {
dir := GinkgoT().TempDir()
Expect(os.WriteFile(ConfigPath(dir), []byte(richConfig), 0o600)).To(Succeed())
Expect(PersistModel(dir, "chosen-model")).To(Succeed())
info, err := os.Stat(ConfigPath(dir))
Expect(err).ToNot(HaveOccurred())
Expect(info.Mode().Perm()).To(Equal(os.FileMode(0o600)))
// The atomic write stages through a sibling temp file; it must not
// survive a successful write.
entries, err := os.ReadDir(dir)
Expect(err).ToNot(HaveOccurred())
names := []string{}
for _, entry := range entries {
names = append(names, entry.Name())
}
Expect(names).To(ConsistOf("config.yaml"))
})
It("creates the state directory when it does not exist yet", func() {
// Task 4 may persist a picked model before anything else has run.
dir := filepath.Join(GinkgoT().TempDir(), "chat")
Expect(PersistModel(dir, "chosen-model")).To(Succeed())
data, err := os.ReadFile(ConfigPath(dir))
Expect(err).ToNot(HaveOccurred())
Expect(string(data)).To(ContainSubstring("model: chosen-model"))
})
})
})

86
core/cli/chat/probe.go Normal file
View File

@@ -0,0 +1,86 @@
package chat
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
openai "github.com/sashabaranov/go-openai"
)
var (
// ErrUnreachable means nothing answered at the endpoint. Callers use this
// to decide whether offering to start a server makes sense.
ErrUnreachable = errors.New("no LocalAI server reachable")
// ErrUnauthorized means the server answered but rejected the credentials.
ErrUnauthorized = errors.New("LocalAI server rejected the API key")
)
// Probe lists the models the endpoint advertises. It classifies the two
// failures that need different advice: nothing listening, and bad credentials.
//
// The returned list is what the server advertises, verbatim and in server
// order. LocalAI happily lists non-model entries it finds in the models
// directory (stray archives, dotfiles), and guessing which advertised IDs are
// real belongs to whoever presents them, not here.
func Probe(ctx context.Context, baseURL, apiKey string) ([]string, error) {
cfg := openai.DefaultConfig(apiKey)
cfg.BaseURL = baseURL
resp, err := openai.NewClientWithConfig(cfg).ListModels(ctx)
if err != nil {
if status, answered := responseStatus(err); answered {
if status == http.StatusUnauthorized || status == http.StatusForbidden {
return nil, fmt.Errorf("%w: %w", ErrUnauthorized, err)
}
// The server answered, so it is up; surface its error as-is.
return nil, fmt.Errorf("listing models at %s: %w", baseURL, err)
}
// A caller who cancelled the probe learned nothing about the endpoint,
// so claiming it is unreachable would send them to fix a server that
// may be fine. A deadline is left alone: an endpoint that cannot answer
// within the probe's budget is unreachable for our purposes.
var urlErr *url.Error
if errors.As(err, &urlErr) && !errors.Is(err, context.Canceled) {
// Only a failure to complete the round trip means nothing is
// listening. A reply we could not parse is a different problem,
// so it falls through to the generic error below.
return nil, fmt.Errorf("%w at %s: %w", ErrUnreachable, baseURL, err)
}
return nil, fmt.Errorf("listing models at %s: %w", baseURL, err)
}
models := make([]string, 0, len(resp.Models))
for _, m := range resp.Models {
if m.ID != "" {
models = append(models, m.ID)
}
}
return models, nil
}
// responseStatus reports the HTTP status a failed call came back with, and
// whether there was one at all.
//
// go-openai splits this across two types depending on the error body, and both
// occur against a real LocalAI: it returns *openai.APIError when the body
// parses as an OpenAI error envelope, which is what LocalAI's normal error
// handler sends, and *openai.RequestError when it does not, which is what
// LocalAI sends when started with opaque errors, since that handler replies
// with a bare status and no body.
func responseStatus(err error) (int, bool) {
// *RequestError is checked first because it is the outer type when
// go-openai nests one error inside the other; the inner value in that case
// carries no status.
var reqErr *openai.RequestError
if errors.As(err, &reqErr) {
return reqErr.HTTPStatusCode, true
}
var apiErr *openai.APIError
if errors.As(err, &apiErr) {
return apiErr.HTTPStatusCode, true
}
return 0, false
}

169
core/cli/chat/probe_test.go Normal file
View File

@@ -0,0 +1,169 @@
package chat
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"time"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Probe", func() {
It("returns the advertised models", func() {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
Expect(json.NewEncoder(w).Encode(map[string]any{
"object": "list",
"data": []map[string]string{
{"id": "model-a", "object": "model"},
{"id": "model-b", "object": "model"},
},
})).To(Succeed())
}))
defer srv.Close()
models, err := Probe(context.Background(), srv.URL+"/v1", "")
Expect(err).ToNot(HaveOccurred())
Expect(models).To(Equal([]string{"model-a", "model-b"}))
})
It("reports an unreachable server distinguishably", func() {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
url := srv.URL
srv.Close() // nothing is listening now
_, err := Probe(context.Background(), url+"/v1", "")
Expect(err).To(HaveOccurred())
Expect(errors.Is(err, ErrUnreachable)).To(BeTrue(), "want ErrUnreachable, got %v", err)
})
It("reports an auth failure distinguishably", func() {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
}))
defer srv.Close()
_, err := Probe(context.Background(), srv.URL+"/v1", "bad-key")
Expect(err).To(HaveOccurred())
Expect(errors.Is(err, ErrUnauthorized)).To(BeTrue(), "want ErrUnauthorized, got %v", err)
})
// LocalAI's normal error handler replies with an OpenAI error envelope, and
// its opaque-errors handler replies with a bare status and no body. Those
// reach the client as two different go-openai types, so both have to be
// classified the same way.
It("reports an auth failure carrying an error envelope distinguishably", func() {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
Expect(json.NewEncoder(w).Encode(map[string]any{
"error": map[string]any{"message": "invalid api key", "code": http.StatusUnauthorized},
})).To(Succeed())
}))
defer srv.Close()
_, err := Probe(context.Background(), srv.URL+"/v1", "bad-key")
Expect(err).To(HaveOccurred())
Expect(errors.Is(err, ErrUnauthorized)).To(BeTrue(), "want ErrUnauthorized, got %v", err)
})
It("does not call a server that answered with an error unreachable", func() {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer srv.Close()
_, err := Probe(context.Background(), srv.URL+"/v1", "")
Expect(err).To(HaveOccurred())
Expect(errors.Is(err, ErrUnreachable)).To(BeFalse(), "a server that replied is not unreachable, got %v", err)
Expect(errors.Is(err, ErrUnauthorized)).To(BeFalse(), "500 is not an auth failure, got %v", err)
})
// Pointing chat at some other service that happens to be listening is a
// different problem from nothing listening, and needs different advice.
It("does not call a reply it could not parse unreachable", func() {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
_, err := w.Write([]byte("<html><body>not LocalAI</body></html>"))
Expect(err).ToNot(HaveOccurred())
}))
defer srv.Close()
_, err := Probe(context.Background(), srv.URL+"/v1", "")
Expect(err).To(HaveOccurred())
Expect(errors.Is(err, ErrUnreachable)).To(BeFalse(), "something answered, got %v", err)
})
It("returns every advertised id, including ones that are not models", func() {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
Expect(json.NewEncoder(w).Encode(map[string]any{
"object": "list",
"data": []map[string]string{
{"id": "zeta", "object": "model"},
{"id": ".gitignore", "object": "model"},
{"id": "alpha", "object": "model"},
{"id": "voice.tar.bz2", "object": "model"},
},
})).To(Succeed())
}))
defer srv.Close()
// Verbatim and in server order: deciding which of these are real, and
// what order to show them in, belongs to the caller.
models, err := Probe(context.Background(), srv.URL+"/v1", "")
Expect(err).ToNot(HaveOccurred())
Expect(models).To(Equal([]string{"zeta", ".gitignore", "alpha", "voice.tar.bz2"}))
})
It("stops early when the context is already cancelled", func() {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
Expect(json.NewEncoder(w).Encode(map[string]any{"object": "list", "data": []any{}})).To(Succeed())
}))
defer srv.Close()
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err := Probe(ctx, srv.URL+"/v1", "")
Expect(err).To(HaveOccurred())
Expect(errors.Is(err, context.Canceled)).To(BeTrue(), "want the cancellation preserved, got %v", err)
// A cancelled probe learned nothing about the endpoint, so it must not
// send the caller off to start a server that may already be running.
Expect(errors.Is(err, ErrUnreachable)).To(BeFalse(), "cancelling is not a verdict on the server, got %v", err)
})
It("reports a server that never answers as unreachable", func() {
release := make(chan struct{})
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
<-release
}))
defer srv.Close()
defer close(release)
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
_, err := Probe(ctx, srv.URL+"/v1", "")
Expect(err).To(HaveOccurred())
Expect(errors.Is(err, ErrUnreachable)).To(BeTrue(), "want ErrUnreachable, got %v", err)
Expect(errors.Is(err, context.DeadlineExceeded)).To(BeTrue(), "want the deadline preserved, got %v", err)
})
It("returns an empty list when the server has no models", func() {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
Expect(json.NewEncoder(w).Encode(map[string]any{"object": "list", "data": []any{}})).To(Succeed())
}))
defer srv.Close()
models, err := Probe(context.Background(), srv.URL+"/v1", "")
Expect(err).ToNot(HaveOccurred())
Expect(models).To(BeEmpty())
})
})

95
core/cli/chat/resolve.go Normal file
View File

@@ -0,0 +1,95 @@
package chat
import (
"errors"
"fmt"
"slices"
"sort"
"strings"
"github.com/mudler/xlog"
)
// ModelChooser asks the user to pick one of models. It is nil when the session
// is not interactive.
type ModelChooser func(models []string) (string, error)
// ModelRequest is everything model resolution needs.
type ModelRequest struct {
Flag string // --model
Configured string // model recorded in the agent config
Available []string // models the server advertises
StateDir string // where an interactive choice is persisted
Choose ModelChooser // nil means non-interactive
// Notify reports a problem that is worth telling the user about but not
// worth failing over. Nil discards it. It exists because the one such
// problem here, a choice that could not be saved, changes what the user
// should expect next: they will be asked again. A log line does not reach
// them, since the agent runs at log level error by default.
Notify func(message string)
}
// ResolveModel picks the model for this invocation. A flag or a configured
// value wins outright and is not persisted; only an interactive choice is
// written back, so the prompt appears at most once.
//
// Available is used exactly as the server gave it. LocalAI advertises stray
// files it finds in the models directory alongside real models, but real model
// IDs contain dots too (lfm2.5-8b-a1b), so any client-side "looks like a
// filename" heuristic would eventually hide a model the user has. Deciding
// which advertised IDs are real belongs to the endpoint, not to a guess here.
func ResolveModel(req ModelRequest) (string, error) {
if req.Flag != "" {
return req.Flag, nil
}
if req.Configured != "" {
return req.Configured, nil
}
// The server's /v1/models ordering is not stable between calls, so sort
// before showing or listing: the same number must mean the same model on
// the next run. Sort a copy; the caller's slice is not ours to reorder.
available := append([]string(nil), req.Available...)
sort.Strings(available)
switch len(available) {
case 0:
return "", errors.New("the LocalAI server has no models installed. Install one with 'local-ai models install <name>', then run 'local-ai chat' again")
case 1:
return available[0], nil
}
if req.Choose == nil {
return "", fmt.Errorf(
"several models are available; pick one with --model. Available: %s",
strings.Join(available, ", "),
)
}
chosen, err := req.Choose(available)
if err != nil {
return "", err
}
// Choose is an interface, so its answer is checked rather than trusted.
// What comes back is persisted and every later run starts against it, so a
// chooser that returns an empty string or a name of its own would record a
// model the server never offered and there would be nothing left to catch
// it.
if !slices.Contains(available, chosen) {
return "", fmt.Errorf(
"the model chooser answered %q, which is not one of the available models: %s",
chosen, strings.Join(available, ", "),
)
}
if req.StateDir != "" {
if err := PersistModel(req.StateDir, chosen); err != nil {
// A failure to remember the choice must not block the session: the
// user picked a model, so honour it and say what will happen.
xlog.Warn("could not save the model choice", "error", err, "model", chosen)
if req.Notify != nil {
req.Notify(fmt.Sprintf("Your choice of %s could not be saved, so this question comes back next time: %v", chosen, err))
}
}
}
return chosen, nil
}

View File

@@ -0,0 +1,156 @@
package chat
import (
"errors"
"os"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("ResolveModel", func() {
It("prefers the flag over everything", func() {
got, err := ResolveModel(ModelRequest{
Flag: "from-flag",
Configured: "from-config",
Available: []string{"a", "b"},
})
Expect(err).ToNot(HaveOccurred())
Expect(got).To(Equal("from-flag"))
})
It("uses the configured model when no flag is given", func() {
got, err := ResolveModel(ModelRequest{
Configured: "from-config",
Available: []string{"a", "b"},
})
Expect(err).ToNot(HaveOccurred())
Expect(got).To(Equal("from-config"))
})
It("auto-selects when the server offers exactly one model", func() {
got, err := ResolveModel(ModelRequest{Available: []string{"only-one"}})
Expect(err).ToNot(HaveOccurred())
Expect(got).To(Equal("only-one"))
})
It("errors and lists the options when several models exist and there is no chooser", func() {
_, err := ResolveModel(ModelRequest{Available: []string{"a", "b"}})
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("a"))
Expect(err.Error()).To(ContainSubstring("b"))
Expect(err.Error()).To(ContainSubstring("--model"))
})
It("sorts before offering, so the same number means the same model next run", func() {
var offered []string
available := []string{"zeta", "alpha", "mid"}
_, err := ResolveModel(ModelRequest{
Available: available,
StateDir: GinkgoT().TempDir(),
Choose: func(models []string) (string, error) {
offered = models
return models[0], nil
},
})
Expect(err).ToNot(HaveOccurred())
// The server's /v1/models ordering is unstable between calls.
Expect(offered).To(Equal([]string{"alpha", "mid", "zeta"}))
// Sorting must happen on a copy: the caller still owns this slice, and
// reordering it under them would move whatever they index into it.
Expect(available).To(Equal([]string{"zeta", "alpha", "mid"}))
})
It("lists models in sorted order in the several-models error", func() {
_, err := ResolveModel(ModelRequest{Available: []string{"zeta", "alpha"}})
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("alpha, zeta"))
})
It("asks the chooser when several models exist, and persists the answer", func() {
dir := GinkgoT().TempDir()
got, err := ResolveModel(ModelRequest{
Available: []string{"a", "b"},
StateDir: dir,
Choose: func(models []string) (string, error) { return models[1], nil },
})
Expect(err).ToNot(HaveOccurred())
Expect(got).To(Equal("b"))
data, err := os.ReadFile(ConfigPath(dir))
Expect(err).ToNot(HaveOccurred())
Expect(string(data)).To(ContainSubstring("model: b"))
})
// The answer is persisted and every later run starts against it, and
// ModelChooser is exported, so the invariant has to hold for choosers this
// package did not write.
DescribeTable("refuses an answer the chooser was not offered",
func(answer string) {
dir := GinkgoT().TempDir()
got, err := ResolveModel(ModelRequest{
Available: []string{"alpha", "zeta"},
StateDir: dir,
Choose: func([]string) (string, error) { return answer, nil },
})
Expect(err).To(HaveOccurred())
Expect(got).To(BeEmpty())
Expect(err.Error()).To(ContainSubstring("alpha, zeta"))
_, statErr := os.Stat(ConfigPath(dir))
Expect(os.IsNotExist(statErr)).To(BeTrue(), "nothing may be recorded for an answer that was refused")
},
Entry("nothing at all", ""),
Entry("a model the server never offered", "gamma"),
Entry("an offered model with stray whitespace", " alpha"),
Entry("an offered model in the wrong case", "Alpha"),
)
It("notifies, and still honours the choice, when it cannot be persisted", func() {
dir := GinkgoT().TempDir()
// A directory where the config file belongs: the write fails for any
// user, including root.
Expect(os.MkdirAll(ConfigPath(dir), 0o700)).To(Succeed())
var notices []string
got, err := ResolveModel(ModelRequest{
Available: []string{"a", "b"},
StateDir: dir,
Choose: func(models []string) (string, error) { return models[0], nil },
Notify: func(message string) { notices = append(notices, message) },
})
Expect(err).ToNot(HaveOccurred())
Expect(got).To(Equal("a"))
Expect(notices).To(HaveLen(1))
Expect(notices[0]).To(ContainSubstring("a"))
Expect(notices[0]).To(ContainSubstring("could not be saved"))
})
It("says nothing when the choice was saved", func() {
var notices []string
_, err := ResolveModel(ModelRequest{
Available: []string{"a", "b"},
StateDir: GinkgoT().TempDir(),
Choose: func(models []string) (string, error) { return models[0], nil },
Notify: func(message string) { notices = append(notices, message) },
})
Expect(err).ToNot(HaveOccurred())
Expect(notices).To(BeEmpty())
})
It("propagates a chooser cancellation", func() {
cancelled := errors.New("cancelled")
_, err := ResolveModel(ModelRequest{
Available: []string{"a", "b"},
StateDir: GinkgoT().TempDir(),
Choose: func([]string) (string, error) { return "", cancelled },
})
Expect(errors.Is(err, cancelled)).To(BeTrue())
})
It("errors with an install hint when the server has no models", func() {
_, err := ResolveModel(ModelRequest{Available: nil})
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("local-ai models install"))
})
})

475
core/cli/chat/run.go Normal file
View File

@@ -0,0 +1,475 @@
package chat
import (
"bufio"
"context"
"errors"
"fmt"
"io"
"os"
"os/signal"
"strconv"
"strings"
"syscall"
"time"
"github.com/mudler/nib/app"
nibcmd "github.com/mudler/nib/cmd"
nibconfig "github.com/mudler/nib/config"
nibtypes "github.com/mudler/nib/types"
"golang.org/x/term"
)
// Options is everything the chat command passes down from its flags.
type Options struct {
Args []string // forwarded to the agent verbatim
Endpoint string // the server root, e.g. http://127.0.0.1:8080
BaseURL string // the API base, e.g. http://127.0.0.1:8080/v1
APIKey string
Model string
StateDir string
TraceDir string
Yolo bool
// ProbeTimeout bounds each check of the server. Zero means
// defaultProbeTimeout.
ProbeTimeout time.Duration
In io.Reader
Out io.Writer
ErrOut io.Writer
}
// ExitStatus reports the status the process should exit with for an agent run
// that failed, and whether err is such a failure.
//
// nib writes what went wrong to the error stream itself and hands back nothing
// but a code, so an error that satisfies this has already been explained to the
// user and must not be reported a second time. The refusal to open a
// full-screen session on a stdin that cannot be read arrives this way, and it
// is the one a user is most likely to meet: 'echo q | local-ai chat' names
// --cli, and burying that under a second message would hide the fix.
func ExitStatus(err error) (int, bool) {
var exit app.ExitError
if errors.As(err, &exit) {
return exit.Code, true
}
return 0, false
}
// shutdownSignals end the session. SIGHUP is one of them because this is a
// terminal program: once the terminal is gone there is nobody left to talk to,
// and a server started for the session has to go with it.
var shutdownSignals = []os.Signal{os.Interrupt, syscall.SIGTERM, syscall.SIGHUP}
// shutdownContext derives a context that is cancelled when the process is
// asked to stop.
//
// Without it a signal kills this process where it stands, skipping every
// deferred call, and a 'local-ai run' started for the session is reparented to
// init with nothing left that knows to shut it down. An interactive Ctrl+C is
// safe on its own, because the child shares this process' foreground process
// group and the terminal signals all of it, but a SIGTERM from a supervisor or
// a script reaches only this process.
//
// Since nib v0.5.1 cancelling this context does end the session: RunTUI passes
// it to bubbletea, which unwinds the program and reports the context's own
// error. The server is still stopped on cancellation rather than on the way
// out (see runSession), because registering here removes SIGHUP's default
// terminate disposition, and a guarantee about a server this process owns is
// not worth resting on how promptly a third party unwinds its interface.
//
// A handler rather than SysProcAttr.Pdeathsig on the child: Pdeathsig is
// Linux-only, and in Go it is delivered when the OS thread that forked exits
// rather than when the process does, so it can fire on a perfectly healthy
// parent. Setpgid is not an alternative either, since taking the child out of
// the foreground process group is what would break the Ctrl+C that works
// today. SIGKILL stays uncovered, as it must: nothing in the process can
// observe it.
func shutdownContext(parent context.Context) (context.Context, context.CancelFunc) {
return signal.NotifyContext(parent, shutdownSignals...)
}
// Run starts the agent: resolve where state lives, make sure a server is
// reachable, pick a model, then hand off to nib.
func Run(ctx context.Context, opts Options) error {
ctx, stop := shutdownContext(ctx)
defer stop()
p, err := prepare(ctx, opts, isTerminal(opts.In))
if err != nil {
return err
}
// A server this process started belongs to this session, and Stop is
// nil-safe and idempotent, so one defer covers both cases and costs nothing
// when runSession has already stopped it.
defer p.server.Stop()
return runSession(ctx, p.server, func(ctx context.Context) error {
return runAgent(ctx, p.dir, p.model, opts)
})
}
// runSession hands the terminal to agent, and stops a server started for this
// session as soon as the context is cancelled rather than when agent returns.
//
// The difference matters because the deferred Stop in Run is only reached once
// agent returns, and how long that takes is nib's business rather than ours.
// nib v0.5.1 does unwind the TUI on a cancelled context, so it does return; a
// SIGHUP no longer leaves the interface on screen with the server behind it,
// which it did before, when bubbletea's own SIGINT and SIGTERM handler was the
// only thing that ever quit the program and registering for SIGHUP had removed
// the default disposition that used to end the process. Watching the context
// keeps the guarantee independent of what the agent does with it.
func runSession(ctx context.Context, server *StartedServer, agent func(context.Context) error) error {
returned := make(chan struct{})
defer close(returned)
go func() {
select {
case <-ctx.Done():
server.Stop()
case <-returned:
}
}()
return agent(ctx)
}
// preparation is what the agent needs once the environment is ready: where its
// state lives, which model to talk to, and the server this process started on
// the user's behalf, if any.
type preparation struct {
dir string
model string
server *StartedServer
}
// prepare does everything that has to happen before the agent takes over the
// terminal. It is split out of Run because all of it is testable and none of
// what follows is: once app.Run has the terminal there is no seam left.
//
// interactive says whether there is a user to prompt. It is a parameter rather
// than a second read of opts.In so the prompts can be driven over a pipe.
func prepare(ctx context.Context, opts Options, interactive bool) (_ *preparation, err error) {
dir, dirErr := StateDir(opts.StateDir)
if dirErr != nil {
return nil, dirErr
}
if err := EnsureStateDir(dir, opts.BaseURL); err != nil {
return nil, err
}
if isLocalOnlyArgs(opts.Args) {
return &preparation{dir: dir}, nil
}
// One prompter for every question this run asks; see its doc comment for
// why the reader cannot be rebuilt per question.
var prompts *prompter
if interactive {
prompts = newPrompter(opts.In, opts.ErrOut)
}
var started *StartedServer
defer func() {
// Nothing after the spawn may leave a server behind: the caller only
// learns about it through a successful return.
if err != nil {
started.Stop()
}
}()
models, err := probeModels(ctx, opts)
if err != nil {
if errors.Is(err, ErrUnauthorized) {
return nil, fmt.Errorf("the LocalAI server at %s rejected the API key. Pass --api-key or set LOCALAI_API_KEY", opts.Endpoint)
}
if !errors.Is(err, ErrUnreachable) {
return nil, err
}
var confirm Confirmer
if interactive {
confirm = prompts.yesNo
}
var startErr error
started, startErr = OfferToStart(ctx, StartOptions{
Endpoint: opts.Endpoint,
Confirm: confirm,
Stderr: opts.ErrOut,
})
if startErr != nil {
err = startErr
if errors.Is(startErr, ErrDeclined) {
err = fmt.Errorf("no LocalAI server at %s. Start one with 'local-ai run', or point elsewhere with --endpoint", opts.Endpoint)
}
return nil, err
}
say(opts.ErrOut, "Started a temporary LocalAI server; it stops when you exit. Use 'local-ai run' for a persistent one.\n")
if models, err = probeModels(ctx, opts); err != nil {
return nil, err
}
}
var chooser ModelChooser
if interactive {
chooser = prompts.choose
}
model, err := ResolveModel(ModelRequest{
Flag: opts.Model,
Configured: configuredModel(dir),
Available: models,
StateDir: dir,
Choose: chooser,
Notify: func(message string) { say(opts.ErrOut, "%s\n", message) },
})
if err != nil {
return nil, err
}
return &preparation{dir: dir, model: model, server: started}, nil
}
func runAgent(ctx context.Context, dir, model string, opts Options) error {
return app.Run(ctx, agentOptions(dir, model, opts))
}
// agentOptions builds the request handed to nib. It is split out of runAgent
// because app.Run takes the terminal and cannot be called from a test, while
// what is asked of it is exactly the part worth pinning.
//
// The stream fields are the interesting ones, and they are not symmetric.
//
// nib reads a non-nil stream as "the embedder wants this used", and refuses
// every mode but --cli when such a stream is not a terminal, because the
// full-screen interface renders on /dev/tty and would otherwise ignore it in
// silence. Nil means "not injected": nib falls back to the process stream and
// behaves as standalone nib does.
//
// Stdin is passed through as it comes. A piped or redirected stdin really is
// ignored by the interface, so the refusal is the honest answer there, and it
// is the one users meet: 'echo q | local-ai chat' says to re-run with --cli
// rather than opening a full-screen session that will never read the question.
//
// Stdout is different, and the process stream is deliberately sent as nil. The
// interface does write to stdout even when it is a pipe: that is the whole of
// nib's shell-capture idiom, out=$(local-ai chat --height 50%), which is what
// the Ctrl+Space widget emitted by --init is built on. Injecting os.Stdout
// there would refuse the widget for a stream nib was going to use anyway.
//
// The test is identity with os.Stdout rather than whether it happens to be a
// terminal, which means a shell redirect goes the same way as the widget:
// 'local-ai chat > out.txt' no longer refuses either, and renders on /dev/tty
// with the capture line landing in the file. That is not a second decision, it
// is the same one. Both are the process stdout as the shell handed it over,
// differing only in being a pipe rather than a regular file, which nib's gate
// does not look at and should not. Refusing one would refuse the other.
//
// What stays injected, and so stays subject to the refusal, is a writer some
// in-process caller chose for itself rather than inherited: a bytes.Buffer, or
// an *os.File it opened. The specs rely on that.
//
// Stderr is never gated by nib, so it is passed through unchanged.
//
// The config values go through Overrides rather than Defaults, and that is not
// a detail. Defaults are seeds: they sit BENEATH the config file, so the file
// silently undoes them. Everything here is a decision this invocation already
// made on the user's behalf, and a flag that the file can undo is not a flag.
// It was not a rare case either, since EnsureStateDir writes base_url on the
// first run and an interactive choice writes model, so from the second run on
// the file carried a value for both and --endpoint and --model did nothing.
//
// The one asymmetry to plan around is that nib cannot tell "set to the zero
// value" from "not set", so an override only ever raises a field. --yolo can
// turn approval off, but nothing on the command line can turn it back on over
// an approval_mode: auto in the file; that needs a config edit. Same shape for
// the strings, which is what makes an unset --api-key or --trace-dir leave the
// file's value standing, as it should.
//
// nib's own --trace-dir and --yolo, and their NIB_TRACE_DIR and NIB_YOLO twins,
// are resolved after the config load and so still outrank these. That is
// deliberate upstream: they are instructions to nib rather than ambient
// environment.
func agentOptions(dir, model string, opts Options) app.Options {
// Model is the model this run resolved, which already prefers --model and
// falls back to the file's own model, so the override restates the file's
// value rather than fighting it whenever no flag was given.
//
// BaseURL is the endpoint this run probed, offered to start a server for,
// and seeded the config with. Handing nib a different one is precisely the
// split that made --endpoint a no-op, so the agent talks to the server
// LocalAI checked. Pointing somewhere else for good is LOCALAI_CHAT_ENDPOINT
// or --endpoint, not a hand-edited base_url the probe never reads.
//
// APIKey and TraceDir are the flags as given, empty when they were not, and
// an empty override leaves the file alone. TraceDir is runtime-only in nib
// (yaml:"-"), so no file value exists for it to beat today; it belongs here
// with the other flags rather than one rung down for a reason that could
// quietly stop being true.
overrides := nibtypes.Config{
Model: model,
APIKey: opts.APIKey,
BaseURL: opts.BaseURL,
TraceDir: opts.TraceDir,
}
if opts.Yolo {
overrides.ApprovalMode = "auto"
}
return app.Options{
Args: opts.Args,
ProgramName: "local-ai chat",
BaseDir: dir,
Overrides: overrides,
SkipSetup: true,
SkipBareEnv: true,
Stdin: opts.In,
Stdout: ownStdout(opts.Out),
Stderr: opts.ErrOut,
}
}
// ownStdout reports the writer as nib's own rather than as an injected one when
// it is the process stdout, by answering nil for it. See agentOptions for why
// that distinction is the difference between a working Ctrl+Space widget and a
// refused one.
func ownStdout(w io.Writer) io.Writer {
if f, ok := w.(*os.File); ok && f == os.Stdout {
return nil
}
return w
}
// defaultProbeTimeout bounds a check of the server. Listing models is cheap,
// so this is long enough that a loaded server is never given up on and short
// enough that a hung one does not leave the user staring at nothing.
const defaultProbeTimeout = 30 * time.Second
// probeModels lists what the endpoint offers, under a budget.
func probeModels(ctx context.Context, opts Options) ([]string, error) {
timeout := opts.ProbeTimeout
if timeout <= 0 {
timeout = defaultProbeTimeout
}
// A real deadline rather than a cancel plus a timer. Probe reads
// context.Canceled as "the caller gave up", which is a statement about the
// caller and not about the endpoint, and only a deadline as "nothing
// answered in time". Expiring the budget as a cancellation would stop
// ErrUnreachable firing for precisely the hung servers that the offer to
// start one exists for.
probeCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
return Probe(probeCtx, opts.BaseURL, opts.APIKey)
}
// isLocalOnlyArgs reports whether the forwarded arguments do their work
// without ever reaching a model, in which case demanding a running server (and
// offering to start one) would be an obstacle rather than a service.
//
// Two groups qualify. The management subcommands edit nib's own state: plugin,
// skill, and the mcp verbs that add or remove configured servers, which is
// asked of nib rather than restated, because bare 'mcp' and its transport
// flags do serve the agent and do need a model. The other group is the flags
// that only print something, above all --init: its shell snippet goes into an
// rc file, typically long before any server exists.
func isLocalOnlyArgs(args []string) bool {
if len(args) == 0 {
return false
}
// A scan rather than a look at args[0]: the mode flags this command
// translates are prepended, so --init is not necessarily first. Positional
// text cannot be mistaken for a flag here, since nib ignores what is left
// after flag parsing.
for _, a := range args {
switch {
case a == "--init", a == "-init", strings.HasPrefix(a, "--init="), strings.HasPrefix(a, "-init="):
return true
case a == "--version", a == "-version":
return true
}
}
switch args[0] {
case "plugin", "skill":
return true
case "mcp":
return len(args) >= 2 && nibcmd.IsMCPManageSubcommand(args[1])
}
return false
}
// configuredModel reads the model already recorded in the agent config, if any.
func configuredModel(dir string) string {
cfg := nibconfig.LoadWith(nibconfig.LoadOptions{BaseDir: dir, SkipBareEnv: true})
return cfg.Model
}
func isTerminal(in io.Reader) bool {
f, ok := in.(*os.File)
return ok && term.IsTerminal(int(f.Fd()))
}
// say writes a line of interactive chatter: a question, or a notice about
// something that did not stop the session. A write that fails is not worth
// failing over, and when the terminal really is gone the read that follows the
// question says so.
func say(w io.Writer, format string, args ...any) {
_, _ = fmt.Fprintf(w, format, args...)
}
// prompter asks this run's questions on the user's terminal.
//
// It owns the buffered reader rather than wrapping opts.In per question,
// because bufio reads ahead: a throwaway reader for the "start a server?"
// question swallows the model choice that was typed behind it, and the next
// question then sees EOF. A real run asks both, one after the other.
type prompter struct {
in *bufio.Reader
out io.Writer
}
func newPrompter(in io.Reader, out io.Writer) *prompter {
return &prompter{in: bufio.NewReader(in), out: out}
}
// yesNo satisfies Confirmer. Anything that is not an explicit yes is a no, so
// a closed stream declines rather than proceeding on the user's behalf.
func (p *prompter) yesNo(question string) (bool, error) {
say(p.out, "%s [y/N]: ", question)
line, err := p.in.ReadString('\n')
if err != nil && !errors.Is(err, io.EOF) {
return false, fmt.Errorf("reading the answer: %w", err)
}
switch strings.ToLower(strings.TrimSpace(line)) {
case "y", "yes":
return true, nil
}
return false, nil
}
// choose satisfies ModelChooser. It answers with a list index rather than with
// what the user typed, so the result can only ever be one of the models it was
// offered: a model name is not something to accept unvalidated here, since
// ResolveModel persists whatever comes back and every later run then starts
// against it.
func (p *prompter) choose(models []string) (string, error) {
if len(models) == 0 {
return "", errors.New("there is nothing to choose from")
}
say(p.out, "Several models are available:\n")
for i, m := range models {
say(p.out, " %d) %s\n", i+1, m)
}
say(p.out, "Pick one [1-%d]: ", len(models))
line, err := p.in.ReadString('\n')
if err != nil && !errors.Is(err, io.EOF) {
return "", fmt.Errorf("reading the choice: %w", err)
}
answer := strings.TrimSpace(line)
n, err := strconv.Atoi(answer)
if err != nil || n < 1 || n > len(models) {
return "", fmt.Errorf("not a valid choice: %q. Pick a number between 1 and %d, or pass --model", answer, len(models))
}
return models[n-1], nil
}

629
core/cli/chat/run_test.go Normal file
View File

@@ -0,0 +1,629 @@
package chat
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"syscall"
"time"
"github.com/mudler/nib/app"
nibconfig "github.com/mudler/nib/config"
nibtypes "github.com/mudler/nib/types"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// modelServer answers /v1/models with the given ids, as LocalAI does.
func modelServer(ids ...string) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
data := make([]map[string]string, 0, len(ids))
for _, id := range ids {
data = append(data, map[string]string{"id": id, "object": "model"})
}
w.Header().Set("Content-Type", "application/json")
Expect(json.NewEncoder(w).Encode(map[string]any{"object": "list", "data": data})).To(Succeed())
}))
}
var _ = Describe("prepare", func() {
var (
dir string
errOut *bytes.Buffer
)
BeforeEach(func() {
dir = GinkgoT().TempDir()
errOut = &bytes.Buffer{}
})
// optionsFor points a run at srv, with no input to read: the default is a
// session nobody can be asked anything in.
optionsFor := func(srv *httptest.Server) Options {
endpoint := "http://127.0.0.1:0"
base := endpoint + "/v1"
if srv != nil {
endpoint, base = srv.URL, srv.URL+"/v1"
}
return Options{
Endpoint: endpoint,
BaseURL: base,
StateDir: dir,
In: strings.NewReader(""),
Out: &bytes.Buffer{},
ErrOut: errOut,
}
}
It("uses the only model the server offers", func() {
srv := modelServer("the-only-model")
defer srv.Close()
p, err := prepare(context.Background(), optionsFor(srv), false)
Expect(err).ToNot(HaveOccurred())
Expect(p.model).To(Equal("the-only-model"))
Expect(p.dir).To(Equal(dir))
Expect(p.server).To(BeNil(), "nothing was started, so nothing is owned")
})
It("seeds the agent config with the endpoint on first run", func() {
srv := modelServer("m")
defer srv.Close()
_, err := prepare(context.Background(), optionsFor(srv), false)
Expect(err).ToNot(HaveOccurred())
data, err := os.ReadFile(ConfigPath(dir))
Expect(err).ToNot(HaveOccurred())
Expect(string(data)).To(ContainSubstring(srv.URL + "/v1"))
})
It("lets --model win over what the server offers", func() {
srv := modelServer("a", "b")
defer srv.Close()
opts := optionsFor(srv)
opts.Model = "not-listed-yet"
p, err := prepare(context.Background(), opts, false)
Expect(err).ToNot(HaveOccurred())
Expect(p.model).To(Equal("not-listed-yet"))
})
It("advises about the API key when the server rejects it", func() {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
}))
defer srv.Close()
_, err := prepare(context.Background(), optionsFor(srv), false)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("--api-key"))
Expect(err.Error()).To(ContainSubstring(srv.URL))
})
// Not interactive means nobody can answer the offer, so the advice has to
// stand on its own.
It("advises how to start a server when none is reachable", func() {
srv := modelServer()
url := srv.URL
srv.Close() // nothing is listening now
opts := optionsFor(nil)
opts.Endpoint, opts.BaseURL = url, url+"/v1"
_, err := prepare(context.Background(), opts, false)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("local-ai run"))
Expect(err.Error()).To(ContainSubstring(url))
})
// A server that accepts the connection and then never replies is the case
// the offer to start one exists for, so the budget has to expire as a
// deadline: Probe reads a cancellation as "the caller gave up" and refuses
// to call the endpoint unreachable on the strength of it.
It("treats a server that never answers as one that is not there", func(ctx SpecContext) {
release := make(chan struct{})
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
select {
case <-release:
case <-r.Context().Done():
}
}))
defer srv.Close()
defer close(release)
opts := optionsFor(srv)
opts.ProbeTimeout = 100 * time.Millisecond
_, err := prepare(context.Background(), opts, false)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("local-ai run"), "want the offer-a-server advice, got %v", err)
}, SpecTimeout(30*time.Second))
It("asks which model to use and remembers the answer", func() {
srv := modelServer("zeta", "alpha")
defer srv.Close()
opts := optionsFor(srv)
opts.In = strings.NewReader("2\n")
p, err := prepare(context.Background(), opts, true)
Expect(err).ToNot(HaveOccurred())
// The list is sorted before it is shown, so 2 is zeta, not the second
// thing the server happened to name.
Expect(p.model).To(Equal("zeta"))
Expect(errOut.String()).To(ContainSubstring("1) alpha"))
Expect(errOut.String()).To(ContainSubstring("2) zeta"))
data, err := os.ReadFile(ConfigPath(dir))
Expect(err).ToNot(HaveOccurred())
Expect(string(data)).To(ContainSubstring("zeta"))
})
// The choice is prompted for once and remembered. When remembering it fails
// the user is about to be asked again on every future run, so they have to
// be told here: a log line is invisible at the default log level.
It("says so on the prompt when the choice cannot be remembered", func() {
srv := modelServer("zeta", "alpha")
defer srv.Close()
// A directory where the config file belongs: writable state dir,
// unwritable config, on any platform and as any user.
Expect(os.MkdirAll(ConfigPath(dir), 0o700)).To(Succeed())
opts := optionsFor(srv)
opts.In = strings.NewReader("1\n")
p, err := prepare(context.Background(), opts, true)
// Failing to remember the choice must not cost the user their session.
Expect(err).ToNot(HaveOccurred())
Expect(p.model).To(Equal("alpha"))
Expect(errOut.String()).To(ContainSubstring("could not be saved"), "the user has to learn they will be asked again")
})
It("does not ask again once a model is recorded", func() {
srv := modelServer("zeta", "alpha")
defer srv.Close()
Expect(PersistModel(dir, "alpha")).To(Succeed())
opts := optionsFor(srv)
opts.In = strings.NewReader("") // an answer would have nothing to read
p, err := prepare(context.Background(), opts, true)
Expect(err).ToNot(HaveOccurred())
Expect(p.model).To(Equal("alpha"))
Expect(errOut.String()).To(BeEmpty())
})
It("says what to install when the server has no models", func() {
srv := modelServer()
defer srv.Close()
_, err := prepare(context.Background(), optionsFor(srv), false)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("models install"))
})
Describe("arguments that only touch local state", func() {
unreachable := func(args ...string) Options {
opts := optionsFor(nil) // port 0: nothing can ever answer here
opts.Args = args
return opts
}
DescribeTable("skips the server entirely",
func(args ...string) {
p, err := prepare(context.Background(), unreachable(args...), false)
Expect(err).ToNot(HaveOccurred())
Expect(p.model).To(BeEmpty())
Expect(p.server).To(BeNil())
},
Entry("plugin", "plugin", "list"),
Entry("skill", "skill", "list"),
Entry("mcp add", "mcp", "add", "srv"),
Entry("mcp list", "mcp", "list"),
// The shell snippet is what a user puts in their rc file, long
// before any server exists.
Entry("the shell integration script", "--init", "zsh"),
Entry("the version", "--version"),
)
// Bare 'mcp' and its transport flags serve the agent over MCP, so they
// need a model like any other session. Only the verbs that edit the
// configured servers are local.
DescribeTable("still needs a server",
func(args ...string) {
_, err := prepare(context.Background(), unreachable(args...), false)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("local-ai run"))
},
Entry("mcp over stdio", "mcp", "--stdio"),
Entry("bare mcp", "mcp"),
)
})
// A reader per question would read ahead into a buffer it then discards, so
// the second question would see EOF whenever both answers were typed ahead.
// That is the shape of a real run: the offer to start a server is followed
// by the model prompt.
It("keeps reading answers from the same stream across questions", func() {
out := &bytes.Buffer{}
p := newPrompter(strings.NewReader("y\n2\n"), out)
yes, err := p.yesNo("Start one now?")
Expect(err).ToNot(HaveOccurred())
Expect(yes).To(BeTrue())
chosen, err := p.choose([]string{"alpha", "zeta"})
Expect(err).ToNot(HaveOccurred())
Expect(chosen).To(Equal("zeta"))
})
// Whatever the chooser returns is persisted and used for every later run,
// so an answer that is not one of the offered models must never come back
// as one.
Describe("the model prompt", func() {
offered := []string{"alpha", "zeta"}
DescribeTable("refuses an answer that is not one of the numbers shown",
func(answer string) {
chosen, err := newPrompter(strings.NewReader(answer), &bytes.Buffer{}).choose(offered)
Expect(err).To(HaveOccurred())
Expect(chosen).To(BeEmpty())
},
Entry("nothing at all", ""),
Entry("a blank line", "\n"),
Entry("only spaces", " \n"),
Entry("zero", "0\n"),
Entry("past the end", "3\n"),
Entry("negative", "-1\n"),
Entry("a model name", "zeta\n"),
Entry("a number with a suffix", "1x\n"),
)
It("says how to answer when the answer was not a number", func() {
_, err := newPrompter(strings.NewReader("banana\n"), &bytes.Buffer{}).choose(offered)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("between 1 and 2"))
Expect(err.Error()).To(ContainSubstring("--model"))
})
It("returns the model shown against the number", func() {
chosen, err := newPrompter(strings.NewReader("1\n"), &bytes.Buffer{}).choose(offered)
Expect(err).ToNot(HaveOccurred())
Expect(chosen).To(Equal("alpha"))
})
It("refuses to ask when there is nothing to offer", func() {
chosen, err := newPrompter(strings.NewReader("1\n"), &bytes.Buffer{}).choose(nil)
Expect(err).To(HaveOccurred())
Expect(chosen).To(BeEmpty())
})
})
// A server started for this session is stopped by a deferred call, which a
// signal skips: the process dies where it stands and leaves 'local-ai run'
// reparented to init.
Describe("shutdown signals", func() {
It("ends the session when the terminal goes away", func() {
ctx, stop := shutdownContext(context.Background())
defer stop()
self, err := os.FindProcess(os.Getpid())
Expect(err).ToNot(HaveOccurred())
Expect(self.Signal(syscall.SIGHUP)).To(Succeed())
Eventually(ctx.Done()).WithTimeout(5 * time.Second).Should(BeClosed())
Expect(ctx.Err()).To(MatchError(context.Canceled))
})
// SIGINT and SIGTERM cannot be delivered here to prove the same thing:
// Ginkgo registers for both to abort the suite, and a signal goes to
// every registered listener.
It("also listens for an interrupt and a terminate", func() {
Expect(shutdownSignals).To(ContainElements(os.Signal(os.Interrupt), os.Signal(syscall.SIGTERM)))
})
})
// Cancelling the context does unwind nib's TUI since v0.5.1, but how long
// that takes is nib's business, and the deferred Stop in Run is only reached
// once the agent returns. A server this process started is ours to end, so
// the guarantee is made here instead, where it does not depend on the agent
// at all. Before v0.5.1 there was no guarantee to be had on the SIGHUP path:
// bubbletea's own SIGINT and SIGTERM handler was the only thing that ever
// quit the program, and registering for SIGHUP took away the default
// disposition that used to end the process.
Describe("runSession", func() {
It("stops the session's server on cancellation, without waiting for the agent", func() {
server, proc := stoppableServer()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
err := runSession(ctx, server, func(ctx context.Context) error {
cancel()
Eventually(func() int32 { return proc.interrupts.Load() }).
WithTimeout(5 * time.Second).
Should(BeNumerically(">", 0), "the server has to be stopped while the agent is still running")
return nil
})
Expect(err).ToNot(HaveOccurred())
Expect(proc.lastSignal.Load()).To(Equal(os.Interrupt))
})
It("leaves the server alone for as long as the session lasts", func() {
server, proc := stoppableServer()
Expect(runSession(context.Background(), server, func(context.Context) error {
return nil
})).To(Succeed())
Expect(proc.interrupts.Load()).To(BeZero())
Expect(proc.kills.Load()).To(BeZero())
})
It("returns what the agent returned", func() {
failed := errors.New("the agent gave up")
server, _ := stoppableServer()
Expect(runSession(context.Background(), server, func(context.Context) error {
return failed
})).To(MatchError(failed))
})
// Most sessions run against a server the user already had, and there is
// nothing to stop then.
It("copes with a session that started no server", func() {
ctx, cancel := context.WithCancel(context.Background())
cancel()
Expect(runSession(ctx, nil, func(context.Context) error {
return nil
})).To(Succeed())
})
})
// Which streams reach nib decides two user-visible behaviours at once, and
// they pull in opposite directions, so both are pinned here rather than left
// to whoever next edits the literal.
//
// nib refuses every mode but --cli when a stream it was handed is not a
// terminal. That refusal is wanted for stdin, where it is what tells someone
// piping a question to re-run with --cli. It is not wanted for the process
// stdout, where it would refuse the Ctrl+Space widget that --init emits:
// out=$(local-ai chat --height 50%) puts a pipe on stdout by construction,
// and writing the chosen command into that pipe is the entire point.
Describe("agentOptions", func() {
// optionsWithStreams is a request that differs from the next only in
// what it was told to read and write.
optionsWithStreams := func(in io.Reader, out, errOut io.Writer) Options {
return Options{
BaseURL: "http://127.0.0.1:8080/v1",
In: in,
Out: out,
ErrOut: errOut,
}
}
Describe("stdout", func() {
// The regression this exists to catch: reinstating
// 'Stdout: opts.Out' breaks Ctrl+Space and nothing else notices.
It("hands nib nothing for the process stdout, so the capture widget is not refused", func() {
o := agentOptions(dir, "a-model", optionsWithStreams(os.Stdin, os.Stdout, os.Stderr))
Expect(o.Stdout).To(BeNil(), "injecting os.Stdout is what refuses out=$(local-ai chat)")
})
It("keeps a stdout the caller chose, which the refusal still guards", func() {
out := &bytes.Buffer{}
o := agentOptions(dir, "a-model", optionsWithStreams(os.Stdin, out, os.Stderr))
Expect(o.Stdout).To(BeIdenticalTo(out))
})
// Being an *os.File is not what makes a stream nib's own; being the
// process stdout is. This is a file an in-process caller opened for
// itself, not one a shell redirect handed over as stdout, which
// still arrives as os.Stdout and is still nil-ed. It was never going
// to receive the interface, so it stays injected and stays refused.
It("keeps a file that is not the process stdout", func() {
f, err := os.CreateTemp(GinkgoT().TempDir(), "captured")
Expect(err).ToNot(HaveOccurred())
DeferCleanup(f.Close)
o := agentOptions(dir, "a-model", optionsWithStreams(os.Stdin, f, os.Stderr))
Expect(o.Stdout).To(BeIdenticalTo(f))
})
})
Describe("stdin", func() {
// The opposite regression: nilling stdin the way stdout is nilled
// would silently drop the refusal that names --cli.
It("hands the process stdin over, so a piped session is still refused", func() {
o := agentOptions(dir, "a-model", optionsWithStreams(os.Stdin, os.Stdout, os.Stderr))
Expect(o.Stdin).To(BeIdenticalTo(os.Stdin))
})
It("hands over a stdin the caller chose", func() {
in := strings.NewReader("a question")
o := agentOptions(dir, "a-model", optionsWithStreams(in, os.Stdout, os.Stderr))
Expect(o.Stdin).To(BeIdenticalTo(in))
})
})
// nib gates stdin and stdout and nothing else, so there is no reason to
// hide the error stream from it.
It("hands the error stream over whatever it is", func() {
errOut := &bytes.Buffer{}
o := agentOptions(dir, "a-model", optionsWithStreams(os.Stdin, os.Stdout, errOut))
Expect(o.Stderr).To(BeIdenticalTo(errOut))
o = agentOptions(dir, "a-model", optionsWithStreams(os.Stdin, os.Stdout, os.Stderr))
Expect(o.Stderr).To(BeIdenticalTo(os.Stderr))
})
It("names the command a user would type, not the binary nib ships as", func() {
o := agentOptions(dir, "a-model", optionsWithStreams(os.Stdin, os.Stdout, os.Stderr))
Expect(o.ProgramName).To(Equal("local-ai chat"),
"the --init widget invokes this name, so a user has to be able to run it")
})
It("carries the resolved session through to nib", func() {
opts := optionsWithStreams(os.Stdin, os.Stdout, os.Stderr)
opts.Args = []string{"--cli"}
opts.APIKey = "a-key"
opts.TraceDir = "/traces"
o := agentOptions(dir, "the-model", opts)
Expect(o.Args).To(Equal([]string{"--cli"}))
Expect(o.BaseDir).To(Equal(dir))
Expect(o.Overrides.Model).To(Equal("the-model"))
Expect(o.Overrides.APIKey).To(Equal("a-key"))
Expect(o.Overrides.BaseURL).To(Equal("http://127.0.0.1:8080/v1"))
Expect(o.Overrides.TraceDir).To(Equal("/traces"))
// The model and the server are settled before nib starts, and the
// bare MODEL and API_KEY variables belong to some other tool.
Expect(o.SkipSetup).To(BeTrue())
Expect(o.SkipBareEnv).To(BeTrue())
})
// Defaults sit beneath the config file. Anything routed through them is
// accepted from the command line and then thrown away the moment the
// file carries the same key, which is the normal state rather than an
// edge case. Nothing this command resolves belongs there, so the channel
// stays empty and this says so: it is what fails if the block is moved
// back a rung.
It("seeds nothing, because a seed is not a flag", func() {
opts := optionsWithStreams(os.Stdin, os.Stdout, os.Stderr)
opts.APIKey = "a-key"
opts.TraceDir = "/traces"
opts.Yolo = true
Expect(agentOptions(dir, "the-model", opts).Defaults).To(Equal(nibtypes.Config{}),
"Defaults lose to the config file, so a value placed there is a flag that does nothing")
})
It("asks for automatic approval only when --yolo was given", func() {
opts := optionsWithStreams(os.Stdin, os.Stdout, os.Stderr)
Expect(agentOptions(dir, "a-model", opts).Overrides.ApprovalMode).To(BeEmpty())
opts.Yolo = true
Expect(agentOptions(dir, "a-model", opts).Overrides.ApprovalMode).To(Equal("auto"))
})
// The specs above pin what is handed over. These pin what nib does with
// it, which is the part that was wrong: every value below reached
// app.Options intact and was then discarded by the config load, so a
// spec that stops at the struct cannot see the bug. Resolving the config
// the way app.Run resolves it can.
Describe("the config nib actually resolves", func() {
// writeConfig puts a config file where nib will read it, with values
// that disagree with every flag under test.
writeConfig := func(body string) {
Expect(os.WriteFile(ConfigPath(dir), []byte(body), 0o600)).To(Succeed())
}
// resolve loads the config exactly as app.Run does, so the precedence
// under test is nib's own rather than a restatement of it here.
resolve := func(o app.Options) nibtypes.Config {
return nibconfig.LoadWith(nibconfig.LoadOptions{
BaseDir: o.BaseDir,
Defaults: o.Defaults,
Overrides: o.Overrides,
SkipBareEnv: o.SkipBareEnv,
})
}
It("sends the requests to the endpoint the flag named, not the one on disk", func() {
writeConfig("base_url: http://127.0.0.1:9999/v1\n")
opts := optionsWithStreams(os.Stdin, os.Stdout, os.Stderr)
opts.BaseURL = "http://127.0.0.1:8080/v1"
cfg := resolve(agentOptions(dir, "a-model", opts))
Expect(cfg.BaseURL).To(Equal("http://127.0.0.1:8080/v1"),
"--endpoint probed 8080; every turn has to go there too")
})
It("uses the model the flag named, not the one the picker recorded", func() {
writeConfig("model: recorded-model\n")
cfg := resolve(agentOptions(dir, "flag-model", optionsWithStreams(os.Stdin, os.Stdout, os.Stderr)))
Expect(cfg.Model).To(Equal("flag-model"))
})
It("uses the key the flag named, not the one nib saved", func() {
writeConfig("api_key: saved-key\n")
opts := optionsWithStreams(os.Stdin, os.Stdout, os.Stderr)
opts.APIKey = "flag-key"
cfg := resolve(agentOptions(dir, "a-model", opts))
Expect(cfg.APIKey).To(Equal("flag-key"))
})
It("turns approval off for --yolo even when the file demands it", func() {
writeConfig("approval_mode: prompt\n")
opts := optionsWithStreams(os.Stdin, os.Stdout, os.Stderr)
opts.Yolo = true
cfg := resolve(agentOptions(dir, "a-model", opts))
Expect(cfg.ApprovalMode).To(Equal("auto"))
})
// The other half of the same rule, and the reason an unset flag is
// not a demand for the empty string: an override only ever raises a
// field, so what the user configured survives a run that said
// nothing about it.
It("leaves what the file configured alone when no flag was given", func() {
writeConfig("api_key: saved-key\napproval_mode: prompt\n")
cfg := resolve(agentOptions(dir, "a-model", optionsWithStreams(os.Stdin, os.Stdout, os.Stderr)))
Expect(cfg.APIKey).To(Equal("saved-key"))
Expect(cfg.ApprovalMode).To(Equal("prompt"))
})
})
})
// nib reports its own failures on the error stream and returns nothing but
// a status, so anything that reaches here as one has already been explained
// once. The refusal to open a full-screen session on a stdin that cannot be
// read is the one users meet: 'echo q | local-ai chat' names --cli, and a
// second message on top would bury the fix.
Describe("ExitStatus", func() {
It("recognises a status the agent already explained", func() {
code, reported := ExitStatus(app.ExitError{Code: 2})
Expect(reported).To(BeTrue())
Expect(code).To(Equal(2))
})
It("finds one that has been wrapped", func() {
code, reported := ExitStatus(fmt.Errorf("running the agent: %w", app.ExitError{Code: 1}))
Expect(reported).To(BeTrue())
Expect(code).To(Equal(1))
})
It("leaves an ordinary failure to be reported", func() {
_, reported := ExitStatus(errors.New("no LocalAI server at http://127.0.0.1:8080"))
Expect(reported).To(BeFalse())
})
It("says nothing about a run that succeeded", func() {
_, reported := ExitStatus(nil)
Expect(reported).To(BeFalse())
})
})
It("reports a state dir it cannot create", func() {
blocked := filepath.Join(dir, "a-file")
Expect(os.WriteFile(blocked, []byte("not a dir"), 0o600)).To(Succeed())
opts := optionsFor(nil)
opts.StateDir = filepath.Join(blocked, "chat")
_, err := prepare(context.Background(), opts, false)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("agent state dir"))
})
})

276
core/cli/chat/server.go Normal file
View File

@@ -0,0 +1,276 @@
package chat
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"strings"
"sync"
"time"
"github.com/mudler/LocalAI/pkg/httpclient"
)
// ErrDeclined means no server was started, either because the session is not
// interactive or because the user said no.
var ErrDeclined = errors.New("no server started")
// errServerExited means the process we spawned died before it ever reported
// ready, so there is no point in polling out the rest of the budget.
var errServerExited = errors.New("the LocalAI server exited before it became ready")
const (
// defaultReadyTimeout bounds the wait for a freshly spawned server. A cold
// start probes hardware and may pull a backend, so the budget is generous.
defaultReadyTimeout = 2 * time.Minute
// readyPollInterval is how long to wait between readiness polls.
readyPollInterval = 500 * time.Millisecond
// readyProbeTimeout bounds a single readiness request, so one connection
// that hangs cannot swallow the whole budget.
readyProbeTimeout = 5 * time.Second
// shutdownGrace is how long a server we started gets to unload models and
// stop its backends after SIGINT before it is killed outright.
shutdownGrace = 10 * time.Second
// childOutputDrainDelay bounds how long cmd.Wait keeps copying the child's
// output after the child itself has exited.
//
// This is not a theoretical guard for LocalAI. 'local-ai run' spawns backend
// subprocesses, and they inherit the write end of the pipe exec created for
// the child's stderr. A backend that outlives its parent holds that pipe
// open, so an unbounded cmd.Wait would block on the copy goroutine long
// after the server itself is gone: exited would never close, Stop would burn
// its whole grace period even on a clean shutdown, and the waiter goroutine
// would leak.
//
// The value is long enough that a legitimate final burst of logs is never
// truncated even on a loaded machine, where the copy itself takes
// microseconds. It must stay strictly below shutdownGrace: at or above it,
// every wedged-pipe shutdown would exhaust the grace period and then SIGKILL
// a process that had already exited cleanly.
childOutputDrainDelay = 5 * time.Second
)
// Confirmer asks a yes/no question. Nil means the session is not interactive.
type Confirmer func(question string) (bool, error)
// StartOptions configures OfferToStart.
type StartOptions struct {
// Endpoint is the address the user expected a server on, used in the
// question and polled for readiness. This is the endpoint root, not the
// /v1 API base URL: readiness is served at the root.
Endpoint string
// Confirm asks whether to start a server. Nil means never start.
Confirm Confirmer
// Stderr receives the child's output.
Stderr io.Writer
// Executable overrides the binary to run. Empty means os.Executable().
Executable string
// ReadyTimeout bounds the wait for readiness. Zero means defaultReadyTimeout.
ReadyTimeout time.Duration
}
// StartedServer is a server this process started and is responsible for.
type StartedServer struct {
// exited is closed once the child has been reaped. One background waiter
// owns cmd.Wait: it may only be called once, and it is what closes the
// pipes exec created for Stdout/Stderr and joins the goroutines copying
// them, so calling os.Process.Wait directly instead would leak both.
exited chan struct{}
// waitErr is the child's exit status. It is written before exited is
// closed and must only be read after that channel is observed closed.
waitErr error
// proc is the child. It is an interface rather than *os.Process so that
// Stop's contract, in particular that the child is asked to stop exactly
// once however often Stop is called, can be pinned without a live process
// to signal. Nil means nothing was ever started.
proc processControl
stopOnce sync.Once
}
// processControl is the part of *os.Process that Stop needs.
//
// One interface rather than a pair of independent function fields: two fields
// can be wired to each other's operation, or one left nil, and no test can tell,
// because a fake satisfies any combination. There is nothing to swap or forget
// here, since the sole implementation is the real process and the method names
// carry the meaning.
type processControl interface {
Signal(os.Signal) error
Kill() error
}
// *os.Process satisfies processControl unmodified, so production needs no
// adapter and no nil branch: the wiring is a single assignment.
var _ processControl = (*os.Process)(nil)
// newServerCommand builds the child process. Split out from OfferToStart so the
// process' configuration can be asserted on without spawning anything.
func newServerCommand(bin string, stderr io.Writer) *exec.Cmd {
cmd := exec.Command(bin, "run")
// Stdin is left nil, so the child gets /dev/null: it is a background
// server, and sharing the terminal would have it stealing keystrokes from
// the agent.
cmd.Stdout = stderr // the child's logs are diagnostics, not chat output
cmd.Stderr = stderr
// Bound the wait for the child's output pipes; see childOutputDrainDelay.
cmd.WaitDelay = childOutputDrainDelay
return cmd
}
// OfferToStart asks whether to start a LocalAI server and, if allowed, spawns
// one and waits for it to report ready.
//
// A child process rather than an in-process boot: RunCMD.Run installs its own
// signal handling and blocks until shutdown, so re-entering it from a chat
// session would entangle two lifecycles in one process.
func OfferToStart(ctx context.Context, opts StartOptions) (*StartedServer, error) {
if opts.Confirm == nil {
// Not interactive. Spawning a server nobody asked for is the one thing
// this function must never do: in CI, in a pipeline, or under a
// supervisor there is no one to see it or shut it down.
return nil, ErrDeclined
}
ok, err := opts.Confirm(fmt.Sprintf("No LocalAI server at %s. Start one now?", opts.Endpoint))
if err != nil {
return nil, fmt.Errorf("asking whether to start a server: %w", err)
}
if !ok {
return nil, ErrDeclined
}
bin := opts.Executable
if bin == "" {
if bin, err = os.Executable(); err != nil {
return nil, fmt.Errorf("locating the local-ai binary: %w", err)
}
}
cmd := newServerCommand(bin, opts.Stderr)
if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("starting a LocalAI server with %s: %w", bin, err)
}
s := &StartedServer{exited: make(chan struct{}), proc: cmd.Process}
go func() {
s.waitErr = cmd.Wait()
close(s.exited)
}()
timeout := opts.ReadyTimeout
if timeout <= 0 {
timeout = defaultReadyTimeout
}
if err := waitReady(ctx, opts.Endpoint, timeout, s.exited); err != nil {
if errors.Is(err, errServerExited) {
// Safe to read: errServerExited is only returned once exited has
// been observed closed, which happens after waitErr is written.
err = describeExit(err, s.waitErr)
}
s.Stop()
return nil, fmt.Errorf("%w. Run 'local-ai run' in another terminal to see why it did not come up", err)
}
return s, nil
}
// describeExit adds what is known about how the child died to exitErr, without
// putting os/exec's plumbing in front of the user.
//
// waitErr is exec.ErrWaitDelay when the child exited cleanly but something it
// spawned still held its output pipe open past childOutputDrainDelay. The
// sentinel's own text names the WaitDelay field, which is meaningless to a
// user, so it is translated. Nothing is swallowed: os/exec only substitutes
// ErrWaitDelay when the process itself exited without an error of its own (see
// Cmd.Wait, "Report an error from the copying goroutines only if the program
// otherwise exited normally"), so it can never stand in for an *ExitError.
func describeExit(exitErr, waitErr error) error {
switch {
case waitErr == nil:
return exitErr
case errors.Is(waitErr, exec.ErrWaitDelay):
return fmt.Errorf("%w, and left a subprocess of its own still running", exitErr)
default:
return fmt.Errorf("%w: %w", exitErr, waitErr)
}
}
// Stop terminates the server this process started, giving it a chance to shut
// down cleanly first. It is safe to call on a nil or never-started server, and
// safe to call more than once.
func (s *StartedServer) Stop() {
if s == nil || s.proc == nil {
return
}
s.stopOnce.Do(func() {
// SIGINT rather than SIGKILL: local-ai run installs its own handler and
// needs it to unload models and stop backend subprocesses. Killing it
// outright would strand those children.
_ = s.proc.Signal(os.Interrupt)
select {
case <-s.exited:
case <-time.After(shutdownGrace):
// It ignored the interrupt or wedged on the way down. The user is
// waiting on their shell prompt, so stop being polite.
_ = s.proc.Kill()
}
})
}
// waitReady polls the endpoint's /readyz until the server reports ready, the
// budget expires, the caller gives up, or exited signals that the process we
// are waiting on is gone. A nil exited channel means there is no process to
// watch.
//
// Readiness lives on the endpoint ROOT, not under the /v1 API base URL, and it
// answers 503 for as long as startup is still in progress.
func waitReady(ctx context.Context, endpoint string, timeout time.Duration, exited <-chan struct{}) error {
url := strings.TrimSuffix(endpoint, "/") + "/readyz"
// A real deadline rather than context.WithCancel plus a timer: the latter
// expires as context.Canceled, which every classifier here reads as "the
// caller gave up" rather than "the endpoint never answered".
waitCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
client := httpclient.NewWithTimeout(readyProbeTimeout)
ticker := time.NewTicker(readyPollInterval)
defer ticker.Stop()
for {
select {
case <-exited:
return errServerExited
case <-waitCtx.Done():
// Distinguish our budget from the caller's: only ours is advice
// about the server.
if err := ctx.Err(); err != nil {
return err
}
return fmt.Errorf("the LocalAI server did not become ready within %s", timeout)
case <-ticker.C:
}
req, err := http.NewRequestWithContext(waitCtx, http.MethodGet, url, nil)
if err != nil {
return fmt.Errorf("building the readiness request for %s: %w", url, err)
}
resp, err := client.Do(req)
if err != nil {
continue // nothing listening yet
}
// Drain before closing so the next poll can reuse the connection
// instead of opening a socket every 500ms for two minutes.
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
if resp.StatusCode == http.StatusOK {
return nil
}
// Anything else means startup is still in progress; keep polling.
}
}

View File

@@ -0,0 +1,375 @@
package chat
import (
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// unusedPort is a loopback address nothing listens on, used wherever a spec
// needs a readiness poll to keep failing. Port 1 is privileged, so no test
// process could have bound it.
const unusedPort = "http://127.0.0.1:1"
var _ = Describe("OfferToStart", func() {
It("never spawns anything when there is no confirmer", func() {
started, err := OfferToStart(context.Background(), StartOptions{
Endpoint: "http://127.0.0.1:59999",
Confirm: nil,
Stderr: io.Discard,
Executable: "/nonexistent/binary-that-must-not-run",
})
Expect(err).To(HaveOccurred())
Expect(errors.Is(err, ErrDeclined)).To(BeTrue(), "want ErrDeclined, got %v", err)
Expect(started).To(BeNil())
})
It("does not spawn when the user declines", func() {
asked := false
started, err := OfferToStart(context.Background(), StartOptions{
Endpoint: "http://127.0.0.1:59999",
Confirm: func(string) (bool, error) {
asked = true
return false, nil
},
Stderr: io.Discard,
Executable: "/nonexistent/binary-that-must-not-run",
})
Expect(asked).To(BeTrue(), "the user should have been asked")
Expect(errors.Is(err, ErrDeclined)).To(BeTrue())
Expect(started).To(BeNil())
})
It("names the endpoint in the question", func() {
var question string
_, _ = OfferToStart(context.Background(), StartOptions{
Endpoint: "http://example.invalid:9090",
Confirm: func(q string) (bool, error) {
question = q
return false, nil
},
Stderr: io.Discard,
Executable: "/nonexistent/binary-that-must-not-run",
})
Expect(question).To(ContainSubstring("http://example.invalid:9090"))
})
It("propagates a confirmer error", func() {
boom := errors.New("boom")
_, err := OfferToStart(context.Background(), StartOptions{
Endpoint: "http://127.0.0.1:59999",
Confirm: func(string) (bool, error) { return false, boom },
Stderr: io.Discard,
Executable: "/nonexistent/binary-that-must-not-run",
})
Expect(errors.Is(err, boom)).To(BeTrue())
})
It("reports which binary it failed to launch", func() {
started, err := OfferToStart(context.Background(), StartOptions{
Endpoint: "http://127.0.0.1:59999",
Confirm: func(string) (bool, error) { return true, nil },
Stderr: io.Discard,
Executable: "/nonexistent/binary-that-must-not-run",
})
Expect(started).To(BeNil())
Expect(err).To(MatchError(ContainSubstring("starting a LocalAI server")))
Expect(err).To(MatchError(ContainSubstring("/nonexistent/binary-that-must-not-run")))
})
It("stops waiting as soon as the process it started exits", func() {
// A harmless no-op binary rather than a real server: this exercises the
// early-exit path without starting LocalAI, binding a port, or running
// 'local-ai run'. Without early-exit detection the call would sit here
// polling until ReadyTimeout.
bin, lookErr := exec.LookPath("true")
if lookErr != nil {
Skip("no 'true' binary on PATH to stand in for a server that dies at once")
}
start := time.Now()
started, err := OfferToStart(context.Background(), StartOptions{
Endpoint: unusedPort,
Confirm: func(string) (bool, error) { return true, nil },
Stderr: io.Discard,
Executable: bin,
ReadyTimeout: 30 * time.Second,
})
Expect(started).To(BeNil())
Expect(err).To(MatchError(ContainSubstring("exited before it became ready")))
Expect(time.Since(start)).To(BeNumerically("<", 10*time.Second),
"the wait should end with the process, not with the readiness budget")
})
It("gives up on a child whose grandchildren still hold its output pipe", func() {
// The real LocalAI shape: 'local-ai run' exits but a backend
// subprocess it spawned inherited the stderr pipe and keeps it open.
// Without cmd.WaitDelay, cmd.Wait blocks on the copy goroutine, exited
// never closes, and the readiness wait runs out the full budget instead
// of reporting that the server died.
sh, lookErr := exec.LookPath("sh")
if lookErr != nil {
Skip("no 'sh' binary on PATH to stand in for a server with a lingering child")
}
dir := GinkgoT().TempDir()
pidFile := filepath.Join(dir, "grandchild.pid")
script := filepath.Join(dir, "server-with-lingering-child")
// #nosec G306 -- this has to be executable to stand in for a binary.
Expect(os.WriteFile(script,
[]byte("#!"+sh+"\nsleep 30 &\necho $! > "+pidFile+"\nexit 0\n"),
0o700)).To(Succeed())
// Reap the grandchild whatever happens: it outlives its own parent by
// design, so nothing else will clean it up.
DeferCleanup(func() {
raw, err := os.ReadFile(pidFile)
if err != nil {
return
}
pid, err := strconv.Atoi(strings.TrimSpace(string(raw)))
if err != nil {
return
}
proc, err := os.FindProcess(pid)
if err != nil {
return
}
_ = proc.Kill()
_, _ = proc.Wait()
})
start := time.Now()
started, err := OfferToStart(context.Background(), StartOptions{
Endpoint: unusedPort,
Confirm: func(string) (bool, error) { return true, nil },
Stderr: io.Discard,
Executable: script,
ReadyTimeout: 25 * time.Second,
})
elapsed := time.Since(start)
Expect(started).To(BeNil())
Expect(err).To(MatchError(ContainSubstring("exited before it became ready")),
"an unbounded cmd.Wait would report a readiness timeout instead")
Expect(elapsed).To(BeNumerically("<", 20*time.Second),
"the wait must be bounded by the output drain, not by the readiness budget")
// This is the case where cmd.Wait returns exec.ErrWaitDelay, whose own
// text names a struct field of os/exec. Users get told what happened
// instead.
Expect(err).NotTo(MatchError(ContainSubstring("WaitDelay")),
"os/exec plumbing must not reach the user")
Expect(err).NotTo(MatchError(ContainSubstring("exec:")))
Expect(err).To(MatchError(ContainSubstring("left a subprocess of its own still running")))
})
It("reports the exit status of a server that failed outright", func() {
// The counterpart to the case above: translating ErrWaitDelay must not
// cost a real exit status, which is the one diagnostic worth having.
bin, lookErr := exec.LookPath("false")
if lookErr != nil {
Skip("no 'false' binary on PATH to stand in for a server that fails")
}
_, err := OfferToStart(context.Background(), StartOptions{
Endpoint: unusedPort,
Confirm: func(string) (bool, error) { return true, nil },
Stderr: io.Discard,
Executable: bin,
ReadyTimeout: 30 * time.Second,
})
Expect(err).To(MatchError(ContainSubstring("exited before it became ready")))
Expect(err).To(MatchError(ContainSubstring("exit status 1")))
})
})
var _ = Describe("StartedServer.Stop", func() {
It("is a no-op on a server that was never started", func() {
var nilServer *StartedServer
Expect(nilServer.Stop).NotTo(Panic())
Expect((&StartedServer{}).Stop).NotTo(Panic())
})
It("interrupts the child exactly once however often it is called", func() {
s, proc := stoppableServer()
s.Stop()
s.Stop()
s.Stop()
Expect(proc.interrupts.Load()).To(Equal(int32(1)),
"a second Stop must not signal the child again")
Expect(proc.kills.Load()).To(BeZero(), "a child that already exited must not be killed")
})
It("interrupts the child exactly once when called concurrently", func() {
// The realistic double-Stop: a deferred Stop on the way out racing the
// signal handler that also owns shutting the server down.
const callers = 8
s, proc := stoppableServer()
var wg sync.WaitGroup
wg.Add(callers)
for range callers {
go func() {
defer GinkgoRecover()
defer wg.Done()
s.Stop()
}()
}
wg.Wait()
Expect(proc.interrupts.Load()).To(Equal(int32(1)))
Expect(proc.kills.Load()).To(BeZero())
})
It("asks the child to interrupt rather than killing it outright", func() {
// The escalation order is the whole point of the grace period: SIGKILL
// first would strand the backend subprocesses local-ai run owns.
s, proc := stoppableServer()
s.Stop()
Expect(proc.lastSignal.Load()).To(Equal(os.Interrupt))
Expect(proc.kills.Load()).To(BeZero())
})
})
// countingProcess stands in for the *os.Process that Stop drives, recording
// what it was asked to do.
type countingProcess struct {
interrupts atomic.Int32
kills atomic.Int32
lastSignal atomic.Value
}
func (p *countingProcess) Signal(sig os.Signal) error {
p.interrupts.Add(1)
p.lastSignal.Store(sig)
return nil
}
func (p *countingProcess) Kill() error {
p.kills.Add(1)
return nil
}
// stoppableServer builds a StartedServer whose child has already exited, driven
// by a countingProcess rather than a real one. Nothing is spawned.
func stoppableServer() (*StartedServer, *countingProcess) {
proc := &countingProcess{}
exited := make(chan struct{})
close(exited)
return &StartedServer{exited: exited, proc: proc}, proc
}
var _ = Describe("newServerCommand", func() {
It("bounds how long it will wait for the child's output pipes", func() {
cmd := newServerCommand("/nonexistent/binary-that-must-not-run", io.Discard)
// An unbounded wait is the failure mode: backend subprocesses inherit
// the child's stderr pipe and can hold it open long after the server
// itself is gone.
Expect(cmd.WaitDelay).To(BeNumerically(">", 0), "cmd.Wait must not be unbounded")
Expect(cmd.WaitDelay).To(BeNumerically("<", shutdownGrace),
"a drain longer than the shutdown grace would kill a cleanly exited server")
})
It("runs the server subcommand without giving it the terminal", func() {
cmd := newServerCommand("/nonexistent/binary-that-must-not-run", io.Discard)
Expect(cmd.Args).To(Equal([]string{"/nonexistent/binary-that-must-not-run", "run"}))
Expect(cmd.Stdin).To(BeNil(), "the child must not compete with the agent for stdin")
Expect(cmd.Stdout).NotTo(BeNil())
Expect(cmd.Stderr).NotTo(BeNil())
})
})
var _ = Describe("waitReady", func() {
It("polls /readyz on the endpoint root and returns only once it answers 200", func() {
// readyOnPoll is deliberately above 1. A handler that answers 200 to the
// first poll cannot tell a correct implementation apart from one that
// treats 503 as ready, because both return after a single request; the
// poll count is what makes 503-as-ready observable.
const readyOnPoll = 3
var polls atomic.Int32
var paths atomic.Value
paths.Store("")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
paths.Store(r.URL.Path)
if polls.Add(1) < readyOnPoll {
// What LocalAI answers while startup is still in progress.
w.WriteHeader(http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
Expect(waitReady(context.Background(), srv.URL, 20*time.Second, nil)).To(Succeed())
Expect(paths.Load()).To(Equal("/readyz"), "readiness lives on the endpoint root, not under /v1")
Expect(polls.Load()).To(BeNumerically(">=", readyOnPoll),
"503 means startup is still in progress and must never be accepted as ready")
})
It("tolerates a trailing slash on the endpoint", func() {
var path atomic.Value
path.Store("")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path.Store(r.URL.Path)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
Expect(waitReady(context.Background(), srv.URL+"/", 20*time.Second, nil)).To(Succeed())
Expect(path.Load()).To(Equal("/readyz"))
})
It("reports a timeout, not a cancellation, when the budget runs out", func() {
err := waitReady(context.Background(), unusedPort, 1200*time.Millisecond, nil)
Expect(err).To(HaveOccurred())
// A budget built from context.WithCancel plus a timer would surface as
// context.Canceled, which downstream code reads as "the caller gave up"
// and would stop classifying a hung server as unreachable.
Expect(errors.Is(err, context.Canceled)).To(BeFalse(), "got %v", err)
Expect(err).To(MatchError(ContainSubstring("did not become ready")))
})
It("returns the caller's cancellation when the caller gives up", func() {
ctx, cancel := context.WithCancel(context.Background())
go func() {
defer GinkgoRecover()
time.Sleep(200 * time.Millisecond)
cancel()
}()
defer cancel()
err := waitReady(ctx, unusedPort, time.Minute, nil)
Expect(errors.Is(err, context.Canceled)).To(BeTrue(), "got %v", err)
})
It("gives up when the process it is waiting on has exited", func() {
exited := make(chan struct{})
close(exited)
err := waitReady(context.Background(), unusedPort, time.Minute, exited)
Expect(err).To(MatchError(ContainSubstring("exited before it became ready")))
})
})

View File

@@ -1,112 +0,0 @@
package chat
import (
"context"
"errors"
"fmt"
"io"
"slices"
"strings"
)
const (
chatRoleUser = "user"
chatRoleAssistant = "assistant"
)
type chatMessage struct {
Role string
Content string
}
type chatSession struct {
client chatClient
model string
models []string
messages []chatMessage
}
func newChatSession(ctx context.Context, client chatClient, requestedModel string) (*chatSession, error) {
models, err := client.ListModels(ctx)
if err != nil {
return nil, fmt.Errorf("list models: %w", err)
}
model, err := resolveChatModel(requestedModel, models)
if err != nil {
return nil, err
}
return &chatSession{
client: client,
model: model,
models: models,
}, nil
}
func (s *chatSession) CurrentModel() string {
return s.model
}
func (s *chatSession) Models() []string {
models := make([]string, len(s.models))
copy(models, s.models)
return models
}
func (s *chatSession) Clear() {
s.messages = nil
}
func (s *chatSession) SwitchModel(model string) error {
if !slices.Contains(s.models, model) {
return fmt.Errorf("model %q is not available. Use /models to see installed models", model)
}
s.model = model
s.Clear()
return nil
}
func (s *chatSession) Send(ctx context.Context, prompt string, out io.Writer) error {
s.messages = append(s.messages, chatMessage{
Role: chatRoleUser,
Content: prompt,
})
answer, err := s.client.StreamChat(ctx, s.model, s.messages, out)
if err != nil {
return err
}
s.messages = append(s.messages, chatMessage{
Role: chatRoleAssistant,
Content: answer,
})
return nil
}
func resolveChatModel(requested string, models []string) (string, error) {
switch {
case requested == "" && len(models) == 0:
return "", errors.New(`no chat models are installed.
Install a model first, for example:
local-ai models list
local-ai models install <model>
local-ai run
Then start a chat session:
local-ai chat --model <model>`)
case requested == "" && len(models) == 1:
return models[0], nil
case requested == "" && len(models) > 1:
var b strings.Builder
b.WriteString("multiple models are available; choose one with --model:\n")
b.WriteString(formatChatModelList(models, ""))
return "", errors.New(b.String())
case !slices.Contains(models, requested):
return "", fmt.Errorf("model %q is not available. Use `local-ai models list` and `local-ai models install <model>`, or pass an installed model with --model", requested)
default:
return requested, nil
}
}

View File

@@ -1,56 +0,0 @@
package chat
import (
"context"
"io"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Chat session", func() {
It("keeps model switching and message history out of the terminal adapter", func() {
client := &fakeChatClient{
models: []string{"alpha", "beta"},
answer: "pong",
}
session, err := newChatSession(context.Background(), client, "alpha")
Expect(err).ToNot(HaveOccurred())
Expect(session.CurrentModel()).To(Equal("alpha"))
Expect(session.SwitchModel("beta")).To(Succeed())
Expect(session.CurrentModel()).To(Equal("beta"))
Expect(session.Send(context.Background(), "ping", io.Discard)).To(Succeed())
Expect(client.requests).To(HaveLen(1))
Expect(client.requests[0].model).To(Equal("beta"))
Expect(client.requests[0].messages).To(HaveLen(1))
Expect(client.requests[0].messages[0].Content).To(Equal("ping"))
})
})
type fakeChatClient struct {
models []string
answer string
requests []fakeChatRequest
}
type fakeChatRequest struct {
model string
messages []chatMessage
}
func (c *fakeChatClient) ListModels(context.Context) ([]string, error) {
return c.models, nil
}
func (c *fakeChatClient) StreamChat(_ context.Context, model string, messages []chatMessage, out io.Writer) (string, error) {
copied := make([]chatMessage, len(messages))
copy(copied, messages)
c.requests = append(c.requests, fakeChatRequest{model: model, messages: copied})
if _, err := io.WriteString(out, c.answer); err != nil {
return "", err
}
return c.answer, nil
}

View File

@@ -1,93 +0,0 @@
package chat
import (
"bufio"
"context"
"fmt"
"io"
"strings"
)
func runTerminalChat(ctx context.Context, session *chatSession, in io.Reader, out io.Writer) error {
scanner := bufio.NewScanner(in)
scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
if err := writeChat(out, "LocalAI chat (%s)\n", session.CurrentModel()); err != nil {
return err
}
if err := writeChat(out, "Type /exit to quit, /clear to reset the conversation, /models to list models.\n"); err != nil {
return err
}
for {
if err := writeChat(out, "\n> "); err != nil {
return err
}
if !scanner.Scan() {
break
}
prompt := strings.TrimSpace(scanner.Text())
switch prompt {
case "":
continue
case "/bye", "/exit", "/quit":
return writeChat(out, "bye\n")
case "/clear":
session.Clear()
if err := writeChat(out, "conversation cleared\n"); err != nil {
return err
}
continue
case "/models":
if err := printChatModels(out, session.Models(), session.CurrentModel()); err != nil {
return err
}
continue
}
if nextModel, ok := strings.CutPrefix(prompt, "/model "); ok {
nextModel = strings.TrimSpace(nextModel)
if nextModel == "" {
if err := writeChat(out, "usage: /model <name>\n"); err != nil {
return err
}
continue
}
if err := session.SwitchModel(nextModel); err != nil {
if writeErr := writeChat(out, "%s\n", err); writeErr != nil {
return writeErr
}
continue
}
if err := writeChat(out, "switched to %s; conversation cleared\n", session.CurrentModel()); err != nil {
return err
}
continue
}
if err := writeChat(out, "assistant: "); err != nil {
return err
}
if err := session.Send(ctx, prompt, out); err != nil {
return err
}
if err := writeChat(out, "\n"); err != nil {
return err
}
}
return scanner.Err()
}
func printChatModels(out io.Writer, models []string, current string) error {
if len(models) == 0 {
return writeChat(out, "no models installed\n")
}
return writeChat(out, "%s", formatChatModelList(models, current))
}
func writeChat(out io.Writer, format string, args ...any) error {
_, err := fmt.Fprintf(out, format, args...)
return err
}

View File

@@ -8,18 +8,72 @@ import (
cliContext "github.com/mudler/LocalAI/core/cli/context"
)
// ChatCMD runs the built-in terminal agent. Everything after the first
// positional argument is forwarded to the agent verbatim, so its own
// subcommands (plugin, skill, mcp) and their flags work unchanged. LocalAI's
// own flags must therefore come first.
type ChatCMD struct {
Model string `short:"m" help:"Model name to use. Defaults to the only model returned by the server when exactly one is available"`
Endpoint string `env:"LOCALAI_CHAT_ENDPOINT" default:"http://127.0.0.1:8080" help:"LocalAI server endpoint. The /v1 path is added automatically when omitted"`
APIKey string `env:"LOCALAI_API_KEY,API_KEY" help:"API key to use when the LocalAI server requires authentication"`
Model string `short:"m" help:"Model to use. Defaults to the only model the server offers, or asks when there are several"`
Endpoint string `env:"LOCALAI_CHAT_ENDPOINT" default:"http://127.0.0.1:8080" help:"LocalAI server endpoint. The /v1 path is added automatically when omitted"`
APIKey string `env:"LOCALAI_API_KEY,API_KEY" help:"API key to use when the LocalAI server requires authentication"`
ConfigDir string `env:"LOCALAI_CHAT_CONFIG_DIR" help:"Directory holding the agent's config, plugins, and skills. Defaults to ~/.config/localai/chat" type:"path"`
TraceDir string `env:"LOCALAI_CHAT_TRACE_DIR" help:"Write a session LLM trace (NDJSON) to this directory" type:"path"`
CLI bool `help:"Run in plain CLI mode instead of the full-screen interface"`
TUI bool `help:"Force the full-screen interface"`
Height string `help:"Run as an inline drop-down of this height, e.g. '40%'"`
Tmux bool `help:"Run in a tmux split"`
NoTmux bool `name:"no-tmux" help:"Never use a tmux split, even inside tmux"`
Init string `help:"Print the shell integration script for Ctrl+Space (zsh, bash, or fish)"`
Yolo bool `env:"LOCALAI_CHAT_YOLO" help:"Auto-approve every tool call without prompting"`
Args []string `arg:"" optional:"" passthrough:"" help:"Arguments forwarded to the agent, e.g. 'plugin install <url>', 'skill list', 'mcp add'"`
}
func (c *ChatCMD) Run(ctx *cliContext.Context) error {
return chatcli.Run(context.Background(), chatcli.Options{
Model: c.Model,
BaseURL: chatAPIBaseURL(c.Endpoint),
APIKey: c.APIKey,
In: os.Stdin,
Out: os.Stdout,
err := chatcli.Run(context.Background(), chatcli.Options{
Args: c.agentArgs(),
Endpoint: c.Endpoint,
BaseURL: chatAPIBaseURL(c.Endpoint),
APIKey: c.APIKey,
Model: c.Model,
StateDir: c.ConfigDir,
TraceDir: c.TraceDir,
Yolo: c.Yolo,
In: os.Stdin,
Out: os.Stdout,
ErrOut: os.Stderr,
})
// The agent explains its own failures on stderr and hands back a code, so
// carry the code out and leave the explanation to stand alone.
if code, reported := chatcli.ExitStatus(err); reported {
return ExitCodeError{Code: code}
}
return err
}
// agentArgs rebuilds the argument vector the agent expects: LocalAI's mode
// flags are declared here for discoverability and shell completion, so they
// have to be translated back into the agent's own flag names.
func (c *ChatCMD) agentArgs() []string {
var args []string
if c.CLI {
args = append(args, "--cli")
}
if c.TUI {
args = append(args, "--tui")
}
if c.Height != "" {
args = append(args, "--height", c.Height)
}
if c.Tmux {
args = append(args, "--tmux")
}
if c.NoTmux {
args = append(args, "--no-tmux")
}
if c.Init != "" {
args = append(args, "--init", c.Init)
}
return append(args, c.Args...)
}

View File

@@ -1,6 +1,10 @@
package cli
import (
"errors"
"fmt"
"github.com/alecthomas/kong"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
@@ -24,4 +28,70 @@ var _ = Describe("Chat command wiring", func() {
Expect(chatAPIBaseURL("http://127.0.0.1:8080/localai")).To(Equal("http://127.0.0.1:8080/localai/v1"))
})
})
Describe("argument parsing", func() {
parse := func(args ...string) *ChatCMD {
var cli struct {
Chat ChatCMD `cmd:""`
}
parser, err := kong.New(&cli)
Expect(err).ToNot(HaveOccurred())
_, err = parser.Parse(append([]string{"chat"}, args...))
Expect(err).ToNot(HaveOccurred())
return &cli.Chat
}
It("leaves Args empty for a bare invocation", func() {
Expect(parse().Args).To(BeEmpty())
})
It("binds flags that precede the forwarded arguments", func() {
c := parse("--endpoint", "http://host:9090", "--model", "m", "plugin", "list")
Expect(c.Endpoint).To(Equal("http://host:9090"))
Expect(c.Model).To(Equal("m"))
Expect(c.Args).To(Equal([]string{"plugin", "list"}))
})
It("forwards flags that follow the first positional to the agent", func() {
c := parse("plugin", "install", "https://example.invalid/p", "--yes")
Expect(c.Args).To(Equal([]string{"plugin", "install", "https://example.invalid/p", "--yes"}))
})
It("parses its own mode flags", func() {
c := parse("--cli")
Expect(c.CLI).To(BeTrue())
Expect(c.Args).To(BeEmpty())
})
})
// The agent prints its own diagnosis and hands back a status. main exits
// with that status and prints nothing more, so the user reads one message
// rather than an "exit status 1" stacked under it.
Describe("ExitCodeError", func() {
It("carries the status out", func() {
Expect(ExitCodeError{Code: 2}.Code).To(Equal(2))
})
It("is recognisable after wrapping", func() {
var got ExitCodeError
Expect(errors.As(fmt.Errorf("chat: %w", ExitCodeError{Code: 2}), &got)).To(BeTrue())
Expect(got.Code).To(Equal(2))
})
})
Describe("agentArgs", func() {
It("translates mode flags into the agent's own flags", func() {
c := &ChatCMD{CLI: true}
Expect(c.agentArgs()).To(Equal([]string{"--cli"}))
})
It("puts forwarded arguments after the translated flags", func() {
c := &ChatCMD{Height: "40%", Args: []string{"plugin", "list"}}
Expect(c.agentArgs()).To(Equal([]string{"--height", "40%", "plugin", "list"}))
})
It("returns nothing for a bare invocation", func() {
Expect((&ChatCMD{}).agentArgs()).To(BeEmpty())
})
})
})

View File

@@ -9,7 +9,7 @@ var CLI struct {
cliContext.Context `embed:""`
Run RunCMD `cmd:"" help:"Run LocalAI, this the default command if no other command is specified. Run 'local-ai run --help' for more information" default:"withargs"`
Chat ChatCMD `cmd:"" help:"Open an interactive chat session against a running LocalAI server"`
Chat ChatCMD `cmd:"" help:"Run the built-in terminal agent against a LocalAI server"`
Federated FederatedCLI `cmd:"" help:"Run LocalAI in federated mode"`
Models ModelsCMD `cmd:"" help:"Manage LocalAI models and definitions"`
Backends BackendsCMD `cmd:"" help:"Manage LocalAI backends and definitions"`

15
core/cli/exit.go Normal file
View File

@@ -0,0 +1,15 @@
package cli
import "fmt"
// ExitCodeError is a failure a command has already reported to the user. It
// carries nothing but the status the process should exit with, and main prints
// nothing more for it.
//
// It exists for commands that hand their terminal to something that does its
// own error reporting. Returning that subordinate's error instead would put a
// bare "exit status 1" underneath the explanation the user has just read, and
// returning nil would tell a script the run succeeded.
type ExitCodeError struct{ Code int }
func (e ExitCodeError) Error() string { return fmt.Sprintf("exit status %d", e.Code) }

View File

@@ -0,0 +1,211 @@
package gallery
import (
"context"
"os"
"strconv"
"strings"
"sync"
"time"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/pkg/system"
"github.com/mudler/LocalAI/pkg/vram"
"github.com/mudler/xlog"
)
// EstimateInput builds the VRAM estimator's input from a gallery entry.
//
// It lives here rather than beside the HTTP handler because two callers need
// it: the handler answering one model, and the warmer below answering all of
// them ahead of time.
func EstimateInput(m *GalleryModel) vram.ModelEstimateInput {
var input vram.ModelEstimateInput
input.Size = m.Size
if repoID := extractHFRepo(m.Overrides, m.URLs); repoID != "" {
input.HFRepo = repoID
}
for _, f := range m.AdditionalFiles {
if vram.IsWeightFile(f.URI) {
input.Files = append(input.Files, vram.FileInput{URI: f.URI, Size: 0})
}
}
return input
}
// extractHFRepo finds a HuggingFace repo ID in a model's overrides or URLs.
func extractHFRepo(overrides map[string]any, urls []string) string {
if overrides != nil {
if params, ok := overrides["parameters"].(map[string]any); ok {
if modelRef, ok := params["model"].(string); ok {
if repoID, ok := vram.ExtractHFRepoID(modelRef); ok {
return repoID
}
}
}
}
for _, u := range urls {
if repoID, ok := vram.ExtractHFRepoID(u); ok {
return repoID
}
}
return ""
}
// EstimateWarmConfig bounds the background warm-up.
type EstimateWarmConfig struct {
// Limit is how many gallery entries to warm, in gallery order. Zero
// disables warming entirely. The order matters: it is the order the UI
// lists them in, so the entries a user sees first are warmed first.
Limit int
// Concurrency is how many estimates run at once. Each one can be a remote
// probe, so this is deliberately small: the point is to be finished before
// anybody looks, not to saturate the link or the upstream.
Concurrency int
// Contexts are the context lengths to estimate at. These want to match what
// the UI asks for, or the warmed entry is not the one it reads.
Contexts []uint32
}
// DefaultEstimateWarmConfig is what the server uses unless told otherwise.
//
// The limit is a deliberate compromise. Warming the whole gallery would be
// thousands of remote probes on every boot, which is rude to the upstream and
// slow to finish; warming nothing leaves the first page of the model gallery
// paying two seconds per row. A few hundred covers what anyone browses in a
// sitting, and everything past it still warms itself on first view.
var DefaultEstimateWarmConfig = EstimateWarmConfig{
Limit: 300,
Concurrency: 4,
Contexts: []uint32{8192, 16384, 32768, 65536, 131072, 262144},
}
// WarmEstimateCache fills the gallery's derived caches in the background.
//
// Two things are warmed, and they are the same cost wearing different hats.
// An estimate for an entry the server has never seen costs a network probe of
// its weight files, and describing an entry's variants costs one probe per
// build it offers. The UI asks for an estimate per row and a variant
// description per model opened, so without this the first visitor pays for
// both: ten seconds of a page filling in its own sizes, then another second
// and a half the first time they click anything.
//
// Both land in the same caches underneath, which is why one pass covers them.
//
// It returns immediately; the work happens on its own goroutine and stops when
// ctx is done. Failures are logged at debug and otherwise ignored: a warm-up
// that cannot reach an upstream must never stop the server from starting, and
// the entry it failed on simply stays cold.
func WarmEstimateCache(ctx context.Context, galleries []config.Gallery, systemState *system.SystemState, cfg EstimateWarmConfig) {
if cfg.Limit <= 0 || cfg.Concurrency <= 0 {
return
}
go func() {
started := time.Now()
models, err := AvailableGalleryModelsCached(galleries, systemState)
if err != nil {
xlog.Debug("VRAM estimate warm-up skipped, gallery unavailable", "error", err)
return
}
if len(models) > cfg.Limit {
models = models[:cfg.Limit]
}
if len(models) == 0 {
return
}
// The host gate the variant picker resolves against. Derived once: it
// describes this machine, not this entry, and HostResolveEnv reads the
// system state to build it.
env := HostResolveEnv(ctx, systemState)
var (
wg sync.WaitGroup
cursor = make(chan *GalleryModel)
warmed int
warmedVariants int
mu sync.Mutex
)
for i := 0; i < cfg.Concurrency; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for m := range cursor {
// Per entry, not for the run: one unreachable weight file
// must not hold a worker for the whole warm-up.
entryCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
input := EstimateInput(m)
if len(input.Files) > 0 || input.HFRepo != "" || input.Size != "" {
if _, err := vram.EstimateModelMultiContext(entryCtx, input, cfg.Contexts); err != nil {
xlog.Debug("VRAM estimate warm-up failed for entry", "model", m.GetName(), "error", err)
} else {
mu.Lock()
warmed++
mu.Unlock()
}
}
// Describing variants probes each build the entry offers.
// An entry that declares none costs nothing here, so this is
// gated rather than attempted and discarded.
if m.HasVariants() {
if _, err := DescribeVariants(models, m, env); err != nil {
xlog.Debug("variant warm-up failed for entry", "model", m.GetName(), "error", err)
} else {
mu.Lock()
warmedVariants++
mu.Unlock()
}
}
cancel()
}
}()
}
feed:
for _, m := range models {
select {
case <-ctx.Done():
break feed
case cursor <- m:
}
}
close(cursor)
wg.Wait()
if ctx.Err() != nil {
xlog.Debug("gallery warm-up stopped", "estimates", warmed, "variants", warmedVariants)
return
}
xlog.Info("gallery caches warmed", "estimates", warmed, "variants", warmedVariants, "of", len(models), "took", time.Since(started).Round(time.Second))
}()
}
// EstimateWarmConfigFromEnv reads the warm-up bounds from the environment,
// falling back to the defaults.
//
// LOCALAI_VRAM_WARM_LIMIT entries to warm; 0 disables the warm-up
// LOCALAI_VRAM_WARM_CONCURRENCY estimates in flight at once
//
// Env rather than a flag because it is an operational tuning knob, not part of
// what the server does: an air-gapped host wants it off, and a host behind a
// slow link wants it slower, and neither is a decision the CLI should carry.
func EstimateWarmConfigFromEnv() EstimateWarmConfig {
cfg := DefaultEstimateWarmConfig
if v, ok := os.LookupEnv("LOCALAI_VRAM_WARM_LIMIT"); ok {
if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil && n >= 0 {
cfg.Limit = n
}
}
if v, ok := os.LookupEnv("LOCALAI_VRAM_WARM_CONCURRENCY"); ok {
if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil && n > 0 {
cfg.Concurrency = n
}
}
return cfg
}

View File

@@ -0,0 +1,115 @@
package gallery_test
import (
"context"
"os"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/gallery"
"github.com/mudler/LocalAI/pkg/system"
)
var _ = Describe("VRAM estimate warm-up", func() {
var state *system.SystemState
BeforeEach(func() {
dir, err := os.MkdirTemp("", "warm")
Expect(err).ToNot(HaveOccurred())
DeferCleanup(func() { os.RemoveAll(dir) })
state, err = system.GetSystemState(system.WithModelPath(dir))
Expect(err).ToNot(HaveOccurred())
gallery.ResetGalleryModelCache()
DeferCleanup(gallery.ResetGalleryModelCache)
})
It("does nothing when disabled, and returns without blocking", func() {
cfg := gallery.DefaultEstimateWarmConfig
cfg.Limit = 0
done := make(chan struct{})
go func() {
defer close(done)
gallery.WarmEstimateCache(context.Background(), []config.Gallery{}, state, cfg)
}()
Eventually(done, "1s").Should(BeClosed())
})
It("returns immediately even when there is work to do", func() {
// The caller is a server still starting up: warming must never be on
// the path to listening.
done := make(chan struct{})
go func() {
defer close(done)
gallery.WarmEstimateCache(context.Background(), []config.Gallery{}, state, gallery.DefaultEstimateWarmConfig)
}()
Eventually(done, "1s").Should(BeClosed())
})
It("stops when its context is cancelled", func() {
ctx, cancel := context.WithCancel(context.Background())
gallery.WarmEstimateCache(ctx, []config.Gallery{}, state, gallery.DefaultEstimateWarmConfig)
cancel()
// Nothing to assert beyond not hanging or panicking: an aborted warm-up
// leaves entries cold, which is the state they were already in.
Consistently(func() bool { return true }, "100ms").Should(BeTrue())
})
Describe("configuration from the environment", func() {
AfterEach(func() {
os.Unsetenv("LOCALAI_VRAM_WARM_LIMIT")
os.Unsetenv("LOCALAI_VRAM_WARM_CONCURRENCY")
})
It("falls back to the defaults", func() {
cfg := gallery.EstimateWarmConfigFromEnv()
Expect(cfg.Limit).To(Equal(gallery.DefaultEstimateWarmConfig.Limit))
Expect(cfg.Concurrency).To(Equal(gallery.DefaultEstimateWarmConfig.Concurrency))
})
It("lets an operator turn it off entirely", func() {
os.Setenv("LOCALAI_VRAM_WARM_LIMIT", "0")
Expect(gallery.EstimateWarmConfigFromEnv().Limit).To(BeZero())
})
It("lets an operator slow it down", func() {
os.Setenv("LOCALAI_VRAM_WARM_CONCURRENCY", "1")
Expect(gallery.EstimateWarmConfigFromEnv().Concurrency).To(Equal(1))
})
It("ignores values that are not usable", func() {
os.Setenv("LOCALAI_VRAM_WARM_LIMIT", "not-a-number")
os.Setenv("LOCALAI_VRAM_WARM_CONCURRENCY", "0")
cfg := gallery.EstimateWarmConfigFromEnv()
Expect(cfg.Limit).To(Equal(gallery.DefaultEstimateWarmConfig.Limit))
// Zero workers would be a warm-up that never runs while looking
// enabled, so it keeps the default rather than honouring it.
Expect(cfg.Concurrency).To(Equal(gallery.DefaultEstimateWarmConfig.Concurrency))
})
})
It("warms variant descriptions as well as estimates", func() {
// Both are the same cost wearing different hats - a probe of an entry's
// weight files - and both land in the same caches, so a warm-up that
// covered only one would leave the first click paying for the other.
// Asserted through the shared config rather than by observing network
// calls: the gallery here is empty by design.
Expect(gallery.DefaultEstimateWarmConfig.Limit).To(BeNumerically(">", 0))
})
It("keeps the estimate contexts the UI actually asks for", func() {
// A warmed entry at the wrong context lengths is a cache the gallery
// never reads, so this pins them together.
Expect(gallery.DefaultEstimateWarmConfig.Contexts).To(ContainElements(
uint32(8192), uint32(16384), uint32(32768), uint32(65536), uint32(131072), uint32(262144),
))
})
It("bounds concurrency so a warm-up cannot saturate the link", func() {
Expect(gallery.DefaultEstimateWarmConfig.Concurrency).To(BeNumerically("<=", 8))
Expect(gallery.DefaultEstimateWarmConfig.Concurrency).To(BeNumerically(">", 0))
})
})

View File

@@ -325,10 +325,32 @@ func AvailableGalleryModels(galleries []config.Gallery, systemState *system.Syst
var (
availableModelsMu sync.RWMutex
availableModelsCache GalleryElements[*GalleryModel]
refreshing atomic.Bool
galleryGeneration atomic.Uint64
// Whether a load has happened, tracked apart from the slice itself. A
// gallery that legitimately holds nothing caches as an empty (often nil)
// slice, and testing the slice for nil read that as "never loaded": every
// call then took the blocking path and bumped the generation, which is the
// same cache-defeating loop the refresh interval exists to stop.
availableModelsLoaded bool
refreshing atomic.Bool
galleryGeneration atomic.Uint64
lastRefreshUnixNano atomic.Int64
)
// How often the cached model list may be refreshed from upstream.
//
// This is a floor on refresh frequency, not a TTL: the cache is served
// regardless, and this only decides how often a background re-fetch is worth
// starting. It matters far more than it looks, because a refresh bumps
// galleryGeneration, and that invalidates every VRAM estimate cache in
// pkg/vram. Refreshing on every call therefore kept those caches permanently
// cold: the gallery listing is one request but the UI asks for one VRAM
// estimate per row, so a single page view triggered dozens of refreshes and
// every estimate paid full price for a remote probe it had already made.
//
// A package variable rather than a constant so tests can drive refreshes
// without waiting.
var GalleryRefreshInterval = 5 * time.Minute
// GalleryGeneration returns a counter that increments each time the gallery
// model list is refreshed from upstream. VRAM estimation caches use this to
// invalidate entries when the gallery data changes.
@@ -352,7 +374,11 @@ func ResetGalleryModelCache() {
}
availableModelsMu.Lock()
availableModelsCache = nil
availableModelsLoaded = false
availableModelsMu.Unlock()
// Also clear the refresh stamp, or a suite that reset the cache would find
// the next refresh throttled by the previous spec's clock.
lastRefreshUnixNano.Store(0)
}
// AvailableGalleryModelsCached returns gallery models from an in-memory cache.
@@ -363,9 +389,10 @@ func ResetGalleryModelCache() {
func AvailableGalleryModelsCached(galleries []config.Gallery, systemState *system.SystemState) (GalleryElements[*GalleryModel], error) {
availableModelsMu.RLock()
cached := availableModelsCache
loaded := availableModelsLoaded
availableModelsMu.RUnlock()
if cached != nil {
if loaded {
// Refresh installed status under write lock to avoid races with
// concurrent readers and the background refresh goroutine.
availableModelsMu.Lock()
@@ -387,8 +414,10 @@ func AvailableGalleryModelsCached(galleries []config.Gallery, systemState *syste
availableModelsMu.Lock()
availableModelsCache = models
availableModelsLoaded = true
galleryGeneration.Add(1)
availableModelsMu.Unlock()
lastRefreshUnixNano.Store(time.Now().UnixNano())
return models, nil
}
@@ -397,9 +426,18 @@ func AvailableGalleryModelsCached(galleries []config.Gallery, systemState *syste
// gallery model cache. Only one refresh runs at a time; concurrent calls
// are no-ops.
func triggerGalleryRefresh(galleries []config.Gallery, systemState *system.SystemState) {
if GalleryRefreshInterval > 0 {
last := lastRefreshUnixNano.Load()
if last != 0 && time.Since(time.Unix(0, last)) < GalleryRefreshInterval {
return
}
}
if !refreshing.CompareAndSwap(false, true) {
return
}
// Stamped before the fetch rather than after, so a slow upstream cannot
// let a queue of callers each start their own refresh behind this one.
lastRefreshUnixNano.Store(time.Now().UnixNano())
go func() {
defer refreshing.Store(false)
models, err := AvailableGalleryModels(galleries, systemState)
@@ -408,12 +446,37 @@ func triggerGalleryRefresh(galleries []config.Gallery, systemState *system.Syste
return
}
availableModelsMu.Lock()
changed := !sameModelSet(availableModelsCache, models)
availableModelsCache = models
galleryGeneration.Add(1)
availableModelsLoaded = true
// Only a real change invalidates the VRAM caches. 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.
if changed {
galleryGeneration.Add(1)
}
availableModelsMu.Unlock()
}()
}
// sameModelSet reports whether two model lists describe the same gallery, for
// the purpose of deciding whether derived caches are still valid. Names and
// order are enough: a change to an entry's files or size arrives with a new
// gallery index, and comparing every field on every entry would cost more than
// the caches save.
func sameModelSet(a, b GalleryElements[*GalleryModel]) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i].GetName() != b[i].GetName() {
return false
}
}
return true
}
// List available backends
func AvailableBackends(galleries []config.Gallery, systemState *system.SystemState) (GalleryElements[*GalleryBackend], error) {
return availableBackendsWithFilter(galleries, systemState, func(backend *GalleryBackend) bool {

View File

@@ -0,0 +1,80 @@
package gallery_test
import (
"os"
"time"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/gallery"
"github.com/mudler/LocalAI/pkg/system"
)
// The gallery generation counter is what every VRAM estimate cache keys on, so
// how often it moves decides whether those caches are worth having. Refreshing
// on every call kept them permanently cold: one page of the model gallery asks
// for a VRAM estimate per row, and each of those requests re-read the gallery,
// triggering a refresh that invalidated the estimate the previous row had just
// paid a network round trip for.
var _ = Describe("Gallery refresh throttling", func() {
var (
tmp *system.SystemState
galleries []config.Gallery
origInterval time.Duration
)
BeforeEach(func() {
dir, err := os.MkdirTemp("", "gallery-throttle")
Expect(err).ToNot(HaveOccurred())
DeferCleanup(func() { os.RemoveAll(dir) })
tmp, err = system.GetSystemState(system.WithModelPath(dir))
Expect(err).ToNot(HaveOccurred())
// No upstream: the list comes back empty, which is all this needs. What
// is under test is how often a refresh is started, not what it returns.
galleries = []config.Gallery{}
origInterval = gallery.GalleryRefreshInterval
gallery.ResetGalleryModelCache()
})
AfterEach(func() {
gallery.GalleryRefreshInterval = origInterval
gallery.ResetGalleryModelCache()
})
It("does not bump the generation once per call", func() {
gallery.GalleryRefreshInterval = time.Hour
_, err := gallery.AvailableGalleryModelsCached(galleries, tmp)
Expect(err).ToNot(HaveOccurred())
start := gallery.GalleryGeneration()
// Stands in for one page view: many callers in quick succession.
for i := 0; i < 30; i++ {
_, err := gallery.AvailableGalleryModelsCached(galleries, tmp)
Expect(err).ToNot(HaveOccurred())
}
// Let any refresh that did start finish, so this cannot pass by racing.
Eventually(func() uint64 { return gallery.GalleryGeneration() }, "2s", "50ms").
Should(Equal(start))
})
It("still refreshes once the interval has passed", func() {
gallery.GalleryRefreshInterval = time.Millisecond
_, err := gallery.AvailableGalleryModelsCached(galleries, tmp)
Expect(err).ToNot(HaveOccurred())
time.Sleep(5 * time.Millisecond)
_, err = gallery.AvailableGalleryModelsCached(galleries, tmp)
Expect(err).ToNot(HaveOccurred())
// An empty gallery refreshing to an empty gallery is unchanged, so the
// generation must hold: only a real change may invalidate the caches.
Consistently(func() uint64 { return gallery.GalleryGeneration() }, "300ms", "50ms").
Should(Equal(gallery.GalleryGeneration()))
})
})

View File

@@ -3,6 +3,7 @@ package importers
import (
"encoding/json"
"path/filepath"
"slices"
"strings"
"github.com/mudler/LocalAI/core/config"
@@ -31,7 +32,7 @@ func (i *MLXImporter) Match(details Details) bool {
}
b, ok := preferencesMap["backend"].(string)
if ok && b == "mlx" || b == "mlx-vlm" {
if ok && slices.Contains([]string{"mlx", "mlx-vlm", "mlx-audio"}, b) {
return true
}
@@ -71,19 +72,32 @@ func (i *MLXImporter) Import(details Details) (gallery.ModelConfig, error) {
// (issue #10269). Send them to the mlx-vlm backend, which applies the
// processor-aware chat template.
backend := "mlx"
if details.HuggingFace != nil && details.HuggingFace.PipelineTag == "image-text-to-text" {
backend = "mlx-vlm"
usecases := []string{config.UsecaseChat}
useTokenizerTemplate := true
if details.HuggingFace != nil {
switch details.HuggingFace.PipelineTag {
case "image-text-to-text":
backend = "mlx-vlm"
case "text-to-speech":
backend = "mlx-audio"
usecases = []string{config.UsecaseTTS}
useTokenizerTemplate = false
}
}
// An explicit backend preference always wins.
b, ok := preferencesMap["backend"].(string)
if ok {
backend = b
if backend == "mlx-audio" {
usecases = []string{config.UsecaseTTS}
useTokenizerTemplate = false
}
}
modelConfig := config.ModelConfig{
Name: name,
Description: description,
KnownUsecaseStrings: []string{config.UsecaseChat},
KnownUsecaseStrings: usecases,
Backend: backend,
PredictionOptions: schema.PredictionOptions{
BasicModelRequest: schema.BasicModelRequest{
@@ -91,7 +105,7 @@ func (i *MLXImporter) Import(details Details) (gallery.ModelConfig, error) {
},
},
TemplateConfig: config.TemplateConfig{
UseTokenizerTemplate: true,
UseTokenizerTemplate: useTokenizerTemplate,
},
}

View File

@@ -48,6 +48,16 @@ var _ = Describe("MLXImporter", func() {
Expect(result).To(BeTrue())
})
It("should match when backend preference is mlx-audio", func() {
preferences := json.RawMessage(`{"backend": "mlx-audio"}`)
details := importers.Details{
URI: "https://example.com/model",
Preferences: preferences,
}
Expect(importer.Match(details)).To(BeTrue())
})
It("should not match when URI does not contain mlx-community/ and no backend preference", func() {
details := importers.Details{
URI: "https://huggingface.co/other-org/test-model",
@@ -123,6 +133,21 @@ var _ = Describe("MLXImporter", func() {
Expect(modelConfig.ConfigFile).To(ContainSubstring("backend: mlx-vlm"))
})
It("should configure explicit mlx-audio imports for text-to-speech", func() {
preferences := json.RawMessage(`{"backend": "mlx-audio"}`)
details := importers.Details{
URI: "https://huggingface.co/mlx-community/Kokoro-82M-4bit",
Preferences: preferences,
}
modelConfig, err := importer.Import(details)
Expect(err).ToNot(HaveOccurred())
Expect(modelConfig.ConfigFile).To(ContainSubstring("backend: mlx-audio"))
Expect(modelConfig.ConfigFile).To(ContainSubstring("- tts"))
Expect(modelConfig.ConfigFile).ToNot(ContainSubstring("use_tokenizer_template: true"))
})
It("should auto-route vision-language models to the mlx-vlm backend", func() {
// gemma-4 E4B and similar VLMs declare pipeline_tag
// "image-text-to-text" on HuggingFace. The text-only mlx-lm
@@ -143,6 +168,23 @@ var _ = Describe("MLXImporter", func() {
Expect(modelConfig.ConfigFile).To(ContainSubstring("backend: mlx-vlm"))
})
It("should auto-route text-to-speech models to the mlx-audio backend", func() {
details := importers.Details{
URI: "https://huggingface.co/mlx-community/Kokoro-82M-4bit",
HuggingFace: &hfapi.ModelDetails{
ModelID: "mlx-community/Kokoro-82M-4bit",
PipelineTag: "text-to-speech",
},
}
modelConfig, err := importer.Import(details)
Expect(err).ToNot(HaveOccurred())
Expect(modelConfig.ConfigFile).To(ContainSubstring("backend: mlx-audio"))
Expect(modelConfig.ConfigFile).To(ContainSubstring("- tts"))
Expect(modelConfig.ConfigFile).ToNot(ContainSubstring("use_tokenizer_template: true"))
})
It("should keep text-only models on the plain mlx backend", func() {
details := importers.Details{
URI: "https://huggingface.co/mlx-community/Llama-3.2-1B-Instruct-4bit",

View File

@@ -38,6 +38,7 @@ var knownPrefOnlyBackends = []schema.KnownBackend{
{Name: "whisperx", Modality: "asr", AutoDetect: false, Description: "WhisperX transcription (preference-only)"},
{Name: "crispasr", Modality: "asr", AutoDetect: false, Description: "CrispASR multi-architecture transcription (preference-only)"},
// TTS
{Name: "mlx-audio", Modality: "tts", AutoDetect: false, Description: "MLX-Audio text-to-speech models (auto-detected; pref-only fallback)"},
{Name: "kokoros", Modality: "tts", AutoDetect: false, Description: "Kokoros TTS (preference-only)"},
{Name: "qwen-tts", Modality: "tts", AutoDetect: false, Description: "Qwen TTS (preference-only)"},
{Name: "qwen3-tts-cpp", Modality: "tts", AutoDetect: false, Description: "Qwen3 TTS C++ (preference-only)"},

View File

@@ -152,6 +152,7 @@ var _ = Describe("Backend Endpoints", func() {
expectPrefOnly("tinygrad", "text")
expectPrefOnly("trl", "text")
expectPrefOnly("mlx-vlm", "text")
expectPrefOnly("mlx-audio", "tts")
expectPrefOnly("whisperx", "asr")
expectPrefOnly("crispasr", "asr")
expectPrefOnly("kokoros", "tts")

View File

@@ -69,9 +69,9 @@ test.describe('Manage - alias badge', () => {
test('renders a read-only alias -> target badge on aliased rows', async ({ page }) => {
await page.goto('/app/manage')
await expect(page.locator('.table')).toBeVisible({ timeout: 10_000 })
// The aliased row shows the target; the plain model row does not.
// The badge moved off the row and into the pane: it is a fact about the
// model, and the rail line is spent on state.
await page.locator('[data-entity="gpt-4"]').click()
await expect(page.getByText('alias -> fast-llm')).toBeVisible({ timeout: 10_000 })
})
})

View File

@@ -1,6 +1,9 @@
import { test, expect } from './coverage-fixtures.js'
// Backends admin page (src/pages/Backends.jsx).
const PANE = '[data-testid="backends-pane"]'
const railItem = (page, name) => page.locator(`[data-entity="${name}"]`)
test.describe('Backends management page', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/app/backends')
@@ -49,11 +52,14 @@ test.describe('Backends management page - Markdown descriptions', () => {
})
})
await page.goto('/app/backends')
await expect(page.locator('th', { hasText: 'Description' })).toBeVisible({ timeout: 10_000 })
// Rendered means the rail has entries. The old gate waited on a column
// header, and there are no columns now.
await expect(railItem(page, 'markdown-backend')).toBeVisible({ timeout: 10_000 })
})
test('table cell shows the description as clean text, not raw Markdown', async ({ page }) => {
const cell = page.locator('tr', { hasText: 'markdown-backend' }).locator('span[title]', { hasText: 'InsightFace' })
test('the pane lede shows the description as clean text, not raw Markdown', async ({ page }) => {
await railItem(page, 'markdown-backend').click()
const cell = page.locator('.detail-pane__lede')
await expect(cell).toHaveText(STRIPPED_DESCRIPTION)
// The syntax itself must be gone, not merely rendered somewhere.
@@ -65,15 +71,77 @@ test.describe('Backends management page - Markdown descriptions', () => {
await expect(cell.locator('h1')).toHaveCount(0)
})
test('title tooltip carries the stripped text, not raw Markdown', async ({ page }) => {
const cell = page.locator('tr', { hasText: 'markdown-backend' }).locator('span[title]', { hasText: 'InsightFace' })
await expect(cell).toHaveAttribute('title', STRIPPED_DESCRIPTION)
test("the lede's tooltip carries the stripped text, not raw Markdown", async ({ page }) => {
await railItem(page, 'markdown-backend').click()
await expect(page.locator('.detail-pane__lede')).toHaveAttribute('title', STRIPPED_DESCRIPTION)
})
test('a backend with no description still shows the placeholder', async ({ page }) => {
const row = page.locator('tr', { hasText: 'plain-backend' })
await expect(row.locator('span[title=""]')).toHaveText('-')
test('a backend with no description renders no lede rather than a blank one', async ({ page }) => {
// The table needed a placeholder because an empty cell in a grid of full
// ones reads as a fault. The pane has no grid to keep aligned, so it omits
// the line - but must never print "undefined".
await railItem(page, 'plain-backend').click()
await expect(page.locator(PANE)).toContainText('plain-backend')
await expect(page.locator('.detail-pane__lede')).toHaveCount(0)
await expect(page.locator(PANE)).not.toContainText('undefined')
})
})
test.describe('Backends gallery - split view', () => {
test.beforeEach(async ({ page }) => {
await page.route('**/api/backends*', (route) => {
route.fulfill({
contentType: 'application/json',
body: JSON.stringify({
backends: [
{ name: 'llama-cpp', description: 'GGUF inference', installed: true, version: '1.52.0', license: 'MIT', tags: ['chat'] },
{ name: 'whisper', description: 'Speech to text', installed: true, version: '1.8.2', license: 'MIT', tags: ['transcript'] },
{ name: 'diffusers', description: 'Image generation', installed: false, license: 'Apache-2.0', tags: ['image'] },
],
}),
})
})
await page.goto('/app/backends')
await expect(railItem(page, 'llama-cpp')).toBeVisible({ timeout: 10_000 })
})
test('the gallery renders no table', async ({ page }) => {
await expect(page.locator('[data-testid="backends"]')).toBeVisible()
await expect(page.locator('table thead th')).toHaveCount(0)
})
test('with nothing selected the pane describes the host', async ({ page }) => {
await expect(page.locator(PANE)).toContainText('This host')
await expect(page.locator('[data-testid="backends-back"]')).toHaveCount(0)
})
test('choosing a backend turns the pane into its detail, and back returns', async ({ page }) => {
await railItem(page, 'llama-cpp').click()
await expect(page.locator(PANE)).toContainText('llama-cpp')
await expect(page.locator(PANE)).toContainText('MIT')
await expect(page.locator(PANE)).not.toContainText('This host')
await page.locator('[data-testid="backends-back"]').click()
await expect(page.locator(PANE)).toContainText('This host')
})
test('the selection lives in the URL and survives a reload', async ({ page }) => {
await railItem(page, 'whisper').click()
await expect(page).toHaveURL(/[?&]backend=whisper/)
await page.reload()
await expect(railItem(page, 'whisper')).toBeVisible({ timeout: 10_000 })
await expect(page.locator('[data-testid="backends-back"]')).toBeVisible()
})
test('the rail groups while browsing and flattens on a query', async ({ page }) => {
await expect(page.locator('[data-testid^="backends-rail-group-"]').first()).toBeVisible()
await page.locator('input[placeholder*="Search backends"]').fill('llama')
await expect(page.locator('[data-testid^="backends-rail-group-"]')).toHaveCount(0)
})
test('an installed backend states its version, an absent one says so', async ({ page }) => {
await expect(railItem(page, 'llama-cpp')).toContainText('v1.52.0')
await expect(railItem(page, 'diffusers')).toContainText('not installed')
})
})

View File

@@ -0,0 +1,72 @@
import { test, expect } from './coverage-fixtures.js'
// The split view is meant to scroll inside itself. It is easy to regress into
// scrolling the document instead, because the shell's height rules are floors
// (min-height: 100dvh) rather than ceilings, so any tall pane silently grows
// the whole column and takes the rail with it.
// A description long enough that the detail pane must overflow, which is the
// only condition under which the bug shows.
const LONG = Array.from({ length: 60 }, (_, i) =>
`Paragraph ${i + 1}. This entry carries a long description so the detail pane has more content than the viewport can hold.`,
).join('\n\n')
const MOCK = {
models: [
{ name: 'long-model', description: LONG, backend: 'llama-cpp', installed: false, tags: ['llm'] },
{ name: 'short-model', description: 'Short.', backend: 'llama-cpp', installed: false, tags: ['llm'] },
],
allBackends: ['llama-cpp'], allTags: ['llm'],
availableModels: 2, installedModels: 0, totalPages: 1, currentPage: 1,
}
test.describe('Discover - the view scrolls, not the page', () => {
test.beforeEach(async ({ page }) => {
await page.route('**/api/models*', (route) =>
route.fulfill({ contentType: 'application/json', body: JSON.stringify(MOCK) }))
})
test('a long detail scrolls the pane and leaves the page height alone', async ({ page }) => {
await page.setViewportSize({ width: 1400, height: 900 })
await page.goto('/app/models')
await expect(page.locator('[data-testid="discover-rail-item"]').first()).toBeVisible({ timeout: 10_000 })
const pageHeight = () => page.evaluate(() => document.documentElement.scrollHeight)
const railHeight = () => page.evaluate(
() => document.querySelector('.entity-rail')?.getBoundingClientRect().height,
)
const beforePage = await pageHeight()
const beforeRail = await railHeight()
await page.locator('[data-testid="discover-rail-item"]').first().click()
await expect(page.locator('[data-testid="discover-back"]')).toBeVisible()
// Selecting something must not make the document taller, and must not
// stretch the rail to match the pane.
expect(await pageHeight()).toBe(beforePage)
// Sub-pixel: layout can settle a fraction differently without the rail
// having grown. A pixel of tolerance keeps this about the bug it guards.
expect(Math.abs((await railHeight()) - beforeRail)).toBeLessThan(1)
// The pane is the thing that scrolls.
const paneOverflows = await page.evaluate(() => {
const el = document.querySelector('.split-view__pane')
return el ? getComputedStyle(el).overflowY : null
})
expect(paneOverflows).toBe('auto')
})
test('stacked below the breakpoint it scrolls with the document again', async ({ page }) => {
// Pinning the height when the columns stack would trap both halves in short
// scrollers, so the constraint is lifted there on purpose.
await page.setViewportSize({ width: 700, height: 800 })
await page.goto('/app/models')
await expect(page.locator('[data-testid="discover-rail-item"]').first()).toBeVisible({ timeout: 10_000 })
const overflow = await page.evaluate(() => {
const el = document.querySelector('.split-view__pane')
return el ? getComputedStyle(el).overflowY : null
})
expect(overflow).toBe('visible')
})
})

View File

@@ -0,0 +1,52 @@
import { test, expect } from './coverage-fixtures.js'
// Searching triggers a refetch. The search box lives in the rail column, so if
// a refetch unmounts the view it takes the field you are typing into with it,
// dropping focus and the caret. That is what this guards.
const MOCK = {
models: [
{ name: 'alpha-model', description: 'a', backend: 'llama-cpp', installed: false, tags: ['llm'] },
{ name: 'beta-model', description: 'b', backend: 'llama-cpp', installed: false, tags: ['llm'] },
],
allBackends: ['llama-cpp'], allTags: ['llm'],
availableModels: 2, installedModels: 0, totalPages: 1, currentPage: 1,
}
test.describe('Discover - searching keeps the view', () => {
test('a refetch keeps the search box, its focus and its value', async ({ page }) => {
let calls = 0
await page.route('**/api/models*', async (route) => {
calls += 1
// Slow the refetch so the loading window is real and observable.
if (calls > 1) await new Promise((r) => setTimeout(r, 600))
await route.fulfill({ contentType: 'application/json', body: JSON.stringify(MOCK) })
})
await page.goto('/app/models')
const search = page.locator('.filter-bar-group__search input')
await expect(search).toBeVisible({ timeout: 10_000 })
await search.click()
await search.fill('alpha')
// Mid-refetch: the field is still mounted, still focused, still holding
// what was typed, and the rail is marked busy rather than replaced.
await expect(search).toBeFocused()
await expect(search).toHaveValue('alpha')
await expect(page.locator('.entity-rail')).toBeVisible()
await page.waitForTimeout(900)
await expect(search).toBeFocused()
await expect(search).toHaveValue('alpha')
})
test('the first load still shows a skeleton, not an empty shell', async ({ page }) => {
// Nothing to keep on a cold start, so the skeleton is still right there.
await page.route('**/api/models*', async (route) => {
await new Promise((r) => setTimeout(r, 800))
await route.fulfill({ contentType: 'application/json', body: JSON.stringify(MOCK) })
})
await page.goto('/app/models')
await expect(page.getByTestId('gallery-loader')).toBeVisible({ timeout: 5_000 })
})
})

View File

@@ -3,6 +3,7 @@ import { test, expect } from './coverage-fixtures.js'
// Seeds two-message chat into localStorage so we don't need a live model.
async function seedChat(page, history) {
await page.addInitScript((h) => {
if (localStorage.getItem('localai_chats_data')) return
const chat = {
id: 'seed1', name: 'Seeded Chat', model: 'test-model',
history: h, systemPrompt: '', mcpMode: false, mcpServers: [],
@@ -33,6 +34,56 @@ const TWO_TURNS = [
{ role: 'assistant', content: 'second answer' },
]
test('saved message edits persist without sending a completion request', async ({ page }) => {
await mockModels(page)
let completionRequests = 0
await page.route('**/v1/chat/completions', (route) => {
completionRequests++
route.abort()
})
await seedChat(page, TWO_TURNS)
await page.goto('/app/chat')
const firstUser = page.locator('.chat-message-user').first()
await firstUser.hover()
await firstUser.getByTitle('Edit').click()
await firstUser.getByRole('textbox').fill('edited first question')
await firstUser.getByRole('button', { name: 'Save' }).click()
const firstAssistant = page.locator('.chat-message-assistant').first()
await firstAssistant.hover()
await firstAssistant.getByTitle('Edit').click()
await firstAssistant.getByRole('textbox').fill('edited first answer')
await firstAssistant.getByRole('button', { name: 'Save' }).click()
await expect(firstUser).toContainText('edited first question')
await expect(firstAssistant).toContainText('edited first answer')
await expect.poll(() => page.evaluate(() => {
const data = JSON.parse(localStorage.getItem('localai_chats_data'))
return data.chats[0].history.slice(0, 2).map(message => message.content)
})).toEqual(['edited first question', 'edited first answer'])
await page.reload()
await expect(page.locator('.chat-message-user').first()).toContainText('edited first question')
await expect(page.locator('.chat-message-assistant').first()).toContainText('edited first answer')
expect(completionRequests).toBe(0)
})
test('cancelling a message edit leaves the original content unchanged', async ({ page }) => {
await mockModels(page)
await seedChat(page, TWO_TURNS)
await page.goto('/app/chat')
const firstUser = page.locator('.chat-message-user').first()
await firstUser.hover()
await firstUser.getByTitle('Edit').click()
await firstUser.getByRole('textbox').fill('discard this draft')
await firstUser.getByRole('button', { name: 'Cancel' }).click()
await expect(firstUser).toContainText('first question')
await expect(firstUser).not.toContainText('discard this draft')
})
test('duplicate creates an independent copy and switches to it', async ({ page }) => {
await mockModels(page)
await seedChat(page, TWO_TURNS)
@@ -112,6 +163,29 @@ const FILE_TURNS = [
{ role: 'assistant', content: 'nope, that is it' },
]
test('editing a file prompt preserves its content blocks and attachment metadata', async ({ page }) => {
await mockModels(page)
await seedChat(page, FILE_TURNS)
await page.goto('/app/chat')
const firstUser = page.locator('.chat-message-user').first()
await firstUser.hover()
await firstUser.getByTitle('Edit').click()
await firstUser.getByRole('textbox').fill('edited file question')
await firstUser.getByRole('button', { name: 'Save' }).click()
await expect.poll(() => page.evaluate(() => {
const data = JSON.parse(localStorage.getItem('localai_chats_data'))
return data.chats[0].history[0]
})).toEqual({
...FILE_TURNS[0],
content: [
{ type: 'text', text: 'edited file question' },
FILE_TURNS[0].content[1],
],
})
})
test('regenerating a non-last answer in a fork still sends the uploaded file content', async ({ page }) => {
await mockModels(page)
let sentMessages = null

View File

@@ -0,0 +1,69 @@
import { test, expect } from './coverage-fixtures.js'
// Host is an inventory, not a catalog, so its split view differs from the two
// galleries in exactly one place: the pane with nothing selected reports what
// is happening rather than offering something to install.
const PANE = '[data-testid="host-pane"]'
const railItems = (page) => page.locator('[data-testid="host-rail-item"]')
const railItem = (page, id) => page.locator(`[data-entity="${id}"]`)
test.describe('Host - split view', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/app/manage')
await expect(railItems(page).first()).toBeVisible({ timeout: 10_000 })
})
test('the inventory renders no table', async ({ page }) => {
await expect(page.locator('[data-testid="host"]')).toBeVisible()
await expect(page.locator('table thead th')).toHaveCount(0)
})
test('with nothing selected the pane reports the current state', async ({ page }) => {
await expect(page.locator(PANE)).toContainText('Right now')
await expect(page.locator(PANE)).toContainText('Loaded')
await expect(page.locator('[data-testid="host-back"]')).toHaveCount(0)
})
test('choosing a model turns the pane into its detail, and back returns', async ({ page }) => {
const first = railItems(page).first()
const name = await first.getAttribute('data-entity')
await first.click()
await expect(page.locator(PANE)).toContainText(name)
await expect(page.locator(PANE)).toContainText('State')
await expect(page.locator(PANE)).not.toContainText('Right now')
await page.locator('[data-testid="host-back"]').click()
await expect(page.locator(PANE)).toContainText('Right now')
})
test('the selection lives in the URL', async ({ page }) => {
const first = railItems(page).first()
const name = await first.getAttribute('data-entity')
await first.click()
await expect(page).toHaveURL(new RegExp(`[?&]sel=${encodeURIComponent(name)}`))
})
test('the rail buckets by state rather than by capability', async ({ page }) => {
// The opposite of the galleries, and deliberately so: nobody opens Host
// wondering which of their models does vision.
const groups = page.locator('[data-testid^="host-rail-group-"]')
await expect(groups.first()).toBeVisible()
const ids = await groups.evaluateAll(els => els.map(e => e.dataset.testid))
for (const id of ids) {
expect(['host-rail-group-running', 'host-rail-group-idle', 'host-rail-group-disabled']).toContain(id)
}
})
test('switching tabs drops a selection that belonged to the other tab', async ({ page }) => {
await railItems(page).first().click()
await expect(page.locator('[data-testid="host-back"]')).toBeVisible()
// The other tab may legitimately be empty on a fresh host, so the contract
// is that the stale selection is gone, not that a pane appears.
await page.locator('.tab', { hasText: 'Backends' }).click()
await expect(page.locator('[data-testid="host-back"]')).toHaveCount(0)
await expect(page).not.toHaveURL(/[?&]sel=/)
})
})

View File

@@ -7,11 +7,11 @@ import { test, expect } from './coverage-fixtures.js'
// inside a row whose hover `transform` re-anchored it. Fix portals the popover
// to document.body, positions it before paint, and focuses without scrolling.
test.describe('Manage Page - Action menu positioning', () => {
test('opening a row menu keeps scroll stable and places the menu by its trigger', async ({ page }) => {
test('opening the pane menu keeps scroll stable and places it by its trigger', async ({ page }) => {
// Small viewport so the page is scrollable and a scroll jump is observable.
await page.setViewportSize({ width: 1024, height: 500 })
await page.goto('/app/manage')
await expect(page.locator('.table')).toBeVisible({ timeout: 10_000 })
await page.locator('[data-testid="host-rail-item"]').first().click()
const trigger = page.locator('button.action-menu__trigger').first()
await expect(trigger).toBeVisible()

View File

@@ -1,11 +1,11 @@
import { test, expect } from './coverage-fixtures.js'
test.describe('Manage Page - Backend Logs Link', () => {
test('row action menu exposes Backend logs entry with terminal icon', async ({ page }) => {
test('the pane action menu exposes Backend logs with a terminal icon', async ({ page }) => {
await page.goto('/app/manage')
await expect(page.locator('.table')).toBeVisible({ timeout: 10_000 })
// Row actions live behind the kebab (ActionMenu) — open the first row's menu.
// Actions moved out of the row and into the pane, so reaching them is now a
// selection followed by the pane's kebab.
await page.locator('[data-testid="host-rail-item"]').first().click()
const trigger = page.locator('button.action-menu__trigger').first()
await expect(trigger).toBeVisible()
await trigger.click()
@@ -17,8 +17,7 @@ test.describe('Manage Page - Backend Logs Link', () => {
test('Backend logs menu item navigates to backend-logs page', async ({ page }) => {
await page.goto('/app/manage')
await expect(page.locator('.table')).toBeVisible({ timeout: 10_000 })
await page.locator('[data-testid="host-rail-item"]').first().click()
const trigger = page.locator('button.action-menu__trigger').first()
await expect(trigger).toBeVisible()
await trigger.click()

View File

@@ -46,9 +46,8 @@ test.describe('Model Editor — Back navigation', () => {
test('Back returns to Manage with a "Back to System" caption', async ({ page }) => {
await page.goto('/app/manage')
await expect(page.locator('.table')).toBeVisible({ timeout: 10_000 })
// Open the first row's action menu and pick "Edit configuration".
// Actions live in the pane now, so select something first.
await page.locator('[data-testid="host-rail-item"]').first().click()
const trigger = page.locator('button.action-menu__trigger').first()
await expect(trigger).toBeVisible()
await trigger.click()

View File

File diff suppressed because it is too large Load Diff

View File

@@ -111,8 +111,11 @@ test.describe("Models gallery - recommended panel prominence", () => {
await expect(page.evaluate((k) => localStorage.getItem(k), DISMISS_KEY)).resolves.toBe("1");
await page.reload();
// The table is the marker that the page finished rendering without the panel.
await expect(page.locator("table tbody tr").first()).toBeVisible({ timeout: 20_000 });
// The rail having entries is the marker that the page finished rendering
// without the panel. It used to be the table, which no longer exists.
await expect(
page.locator('[data-testid="discover-rail-item"]').first(),
).toBeVisible({ timeout: 20_000 });
await expect(panel(page)).toHaveCount(0);
});

View File

@@ -12,10 +12,15 @@ test.describe('Navigation', () => {
await expect(page.locator('.home-page')).toBeVisible()
})
test('top menu exposes Home and Install Models', async ({ page }) => {
test('top menu exposes Home and Discover', async ({ page }) => {
await page.goto('/app')
await expect(page.locator('.sidebar-nav a.nav-item[href="/app"]')).toBeVisible()
await expect(page.locator('.sidebar-nav a.nav-item[href="/app/models"]')).toBeVisible()
const discover = page.locator('.sidebar-nav a.nav-item[href="/app/models"]')
await expect(discover).toBeVisible()
// The label is asserted, not just the destination: a bare "Models" would
// name the same thing as the installed-models view under Host, which is
// the collision the rename exists to remove.
await expect(discover.locator('.nav-label')).toHaveText('Discover')
})
test('Create stays an inline tier with Chat, Studio and Talk', async ({ page }) => {

View File

@@ -38,7 +38,7 @@ test.describe('Page render smoke', () => {
await page.goto(path)
// .page-title for the normal header; .empty-state-title for pages that
// render a gated/empty state (e.g. Account when auth is disabled).
await expect(page.locator('.page-title, .empty-state-title').first()).toBeVisible({ timeout: 15_000 })
await expect(page.locator('.page-title, .view-bar__title, .empty-state-title').first()).toBeVisible({ timeout: 15_000 })
await expect(page).toHaveURL(new RegExp(path.replace(/\//g, '\\/') + '$'))
})
}

View File

@@ -71,6 +71,10 @@
},
"actions": {
"copy": "Kopieren",
"edit": "Bearbeiten",
"editMessage": "Nachricht bearbeiten",
"save": "Speichern",
"cancel": "Abbrechen",
"regenerate": "Neu generieren",
"jumpToLatest": "Jump to latest"
},

View File

@@ -1,5 +1,5 @@
{
"title": "Modelle installieren",
"title": "Entdecken",
"subtitle": "Durchsuchen und installieren Sie KI-Modelle aus der Galerie",
"recommended": {
"title": "Empfohlen für Ihre Hardware",
@@ -40,7 +40,9 @@
"searchBackends": "Backends suchen...",
"contextSize": "Kontext:",
"useCaseLabel": "Nach Anwendungsfall filtern",
"unavailableForBackend": "Für das gewählte Backend nicht verfügbar"
"unavailableForBackend": "Für das gewählte Backend nicht verfügbar",
"someSelected": "{{count}} ausgewählt",
"refineLabel": "Verfeinern"
},
"search": {
"placeholder": "Modelle suchen...",
@@ -81,7 +83,10 @@
"fileCount_other": "{{count}} Dateien",
"filename": "Dateiname",
"uri": "URI",
"sha256": "SHA256"
"sha256": "SHA256",
"backToAll": "Alle Modelle",
"vramAt": "VRAM bei {{context}}",
"headroom": "Spielraum"
},
"empty": {
"title": "Keine Modelle gefunden",
@@ -121,5 +126,44 @@
"dflash": "Schneller: DFlash",
"mtp": "Schneller: MTP"
}
},
"rail": {
"sortLabel": "Modelle sortieren",
"downloadingPct": "Download {{percent}}%",
"tooLarge": "{{size}} · zu groß",
"fitsSize": "{{size}} · passt",
"previousPage": "Vorherige Seite",
"nextPage": "Nächste Seite",
"showingCount": "{{shown}} von {{total}}",
"sizing": "wird berechnet…"
},
"groups": {
"text": "Text und Reasoning",
"vision": "Bildverstehen",
"audio": "Sprache und Audio",
"visual": "Bild und Video",
"other": "Alles Übrige"
},
"chart": {
"title": "VRAM nach Kontextlänge",
"available": "{{vram}} verfügbar",
"barTitle": "Kontext {{context}} benötigt {{vram}}",
"fitsEverywhere": "Läuft auf diesem Host bei jeder Kontextlänge.",
"fitsNowhere": "Passt auf diesem Host bei keiner Kontextlänge.",
"fitsUpTo": "Passt bis zu einem Kontext von {{context}}."
},
"shelves": {
"hostLabel": "Dein Host",
"heroWithGpu": "{{vram}} GPU-Speicher, {{count}} Modelle in der Galerie.",
"heroNoGpu": "{{count}} Modelle in der Galerie.",
"heroHint": "Wähle links ein Modell, um Größe, Varianten und Lauffähigkeit zu sehen.",
"browsing": "Durchsuchen",
"pickHint": "Wähle ein Modell, um die Details zu sehen.",
"heroWithRam": "{{ram}} Systemspeicher, {{count}} Modelle in der Galerie.",
"byUseCase": "Oder mit einem Anwendungsfall starten",
"pickText": "Chat, Reasoning, Embeddings",
"pickVision": "Bilder und Dokumente lesen",
"pickAudio": "Sprache rein, Sprache raus",
"pickVisual": "Bilder und Video erzeugen"
}
}

View File

@@ -28,7 +28,7 @@
},
"items": {
"home": "Start",
"installModels": "Modelle installieren",
"discover": "Entdecken",
"chat": "Chat",
"studio": "Studio",
"talk": "Sprechen",

View File

@@ -71,6 +71,10 @@
},
"actions": {
"copy": "Copy",
"edit": "Edit",
"editMessage": "Edit message",
"save": "Save",
"cancel": "Cancel",
"regenerate": "Regenerate",
"branch": "Branch from here",
"jumpToLatest": "Jump to latest"

View File

@@ -1,5 +1,5 @@
{
"title": "Install Models",
"title": "Discover",
"subtitle": "Browse and install AI models from the gallery",
"models": "Models",
"recommended": {
@@ -50,7 +50,9 @@
"searchBackends": "Search backends...",
"contextSize": "Context:",
"useCaseLabel": "Filter by use case",
"unavailableForBackend": "Not available for the selected backend"
"unavailableForBackend": "Not available for the selected backend",
"someSelected": "{{count}} selected",
"refineLabel": "Refine"
},
"search": {
"placeholder": "Search models...",
@@ -91,7 +93,10 @@
"fileCount_other": "{{count}} files",
"filename": "Filename",
"uri": "URI",
"sha256": "SHA256"
"sha256": "SHA256",
"backToAll": "All models",
"vramAt": "VRAM at {{context}}",
"headroom": "Headroom"
},
"empty": {
"title": "No models found",
@@ -137,5 +142,44 @@
"dflash": "Faster: DFlash",
"mtp": "Faster: MTP"
}
},
"rail": {
"sortLabel": "Sort models",
"downloadingPct": "downloading {{percent}}%",
"tooLarge": "{{size}} · too large",
"fitsSize": "{{size}} · fits",
"previousPage": "Previous page",
"nextPage": "Next page",
"showingCount": "{{shown}} of {{total}}",
"sizing": "sizing…"
},
"groups": {
"text": "Text and reasoning",
"vision": "Vision",
"audio": "Speech and audio",
"visual": "Image and video",
"other": "Everything else"
},
"chart": {
"title": "VRAM by context length",
"available": "{{vram}} available",
"barTitle": "{{context}} context needs {{vram}}",
"fitsEverywhere": "Runs at every context length on this host.",
"fitsNowhere": "Will not fit on this host at any context length.",
"fitsUpTo": "Fits up to a {{context}} context."
},
"shelves": {
"hostLabel": "Your host",
"heroWithGpu": "{{vram}} of GPU memory, {{count}} models in the gallery.",
"heroNoGpu": "{{count}} models in the gallery.",
"heroHint": "Pick anything on the left to see its size, variants and whether it will run here.",
"browsing": "Browsing",
"pickHint": "Select a model to see its detail.",
"heroWithRam": "{{ram}} of system memory, {{count}} models in the gallery.",
"byUseCase": "Or start with a use case",
"pickText": "Chat, reasoning, embeddings",
"pickVision": "Read images and documents",
"pickAudio": "Speech in and speech out",
"pickVisual": "Generate images and video"
}
}

View File

@@ -28,7 +28,7 @@
},
"items": {
"home": "Home",
"installModels": "Install Models",
"discover": "Discover",
"chat": "Chat",
"studio": "Studio",
"talk": "Talk",

View File

@@ -71,6 +71,10 @@
},
"actions": {
"copy": "Copiar",
"edit": "Editar",
"editMessage": "Editar mensaje",
"save": "Guardar",
"cancel": "Cancelar",
"regenerate": "Regenerar",
"jumpToLatest": "Jump to latest"
},

View File

@@ -1,5 +1,5 @@
{
"title": "Instalar modelos",
"title": "Descubrir",
"subtitle": "Explora e instala modelos de IA desde la galería",
"recommended": {
"title": "Recomendado para tu hardware",
@@ -40,7 +40,9 @@
"searchBackends": "Buscar backends...",
"contextSize": "Contexto:",
"useCaseLabel": "Filtrar por caso de uso",
"unavailableForBackend": "No disponible para el backend seleccionado"
"unavailableForBackend": "No disponible para el backend seleccionado",
"someSelected": "{{count}} seleccionados",
"refineLabel": "Refinar"
},
"search": {
"placeholder": "Buscar modelos...",
@@ -81,7 +83,10 @@
"fileCount_other": "{{count}} archivos",
"filename": "Nombre del archivo",
"uri": "URI",
"sha256": "SHA256"
"sha256": "SHA256",
"backToAll": "Todos los modelos",
"vramAt": "VRAM a {{context}}",
"headroom": "Margen"
},
"empty": {
"title": "No se encontraron modelos",
@@ -121,5 +126,44 @@
"dflash": "Más rápido: DFlash",
"mtp": "Más rápido: MTP"
}
},
"rail": {
"sortLabel": "Ordenar modelos",
"downloadingPct": "descargando {{percent}}%",
"tooLarge": "{{size}} · demasiado grande",
"fitsSize": "{{size}} · cabe",
"previousPage": "Página anterior",
"nextPage": "Página siguiente",
"showingCount": "{{shown}} de {{total}}",
"sizing": "calculando…"
},
"groups": {
"text": "Texto y razonamiento",
"vision": "Visión",
"audio": "Voz y audio",
"visual": "Imagen y vídeo",
"other": "Todo lo demás"
},
"chart": {
"title": "VRAM por longitud de contexto",
"available": "{{vram}} disponibles",
"barTitle": "Un contexto de {{context}} necesita {{vram}}",
"fitsEverywhere": "Funciona con cualquier longitud de contexto en este host.",
"fitsNowhere": "No cabe en este host con ninguna longitud de contexto.",
"fitsUpTo": "Cabe hasta un contexto de {{context}}."
},
"shelves": {
"hostLabel": "Tu host",
"heroWithGpu": "{{vram}} de memoria GPU, {{count}} modelos en la galería.",
"heroNoGpu": "{{count}} modelos en la galería.",
"heroHint": "Elige un modelo a la izquierda para ver su tamaño, variantes y compatibilidad.",
"browsing": "Explorando",
"pickHint": "Selecciona un modelo para ver su detalle.",
"heroWithRam": "{{ram}} de memoria del sistema, {{count}} modelos en la galería.",
"byUseCase": "O empieza por un caso de uso",
"pickText": "Chat, razonamiento, embeddings",
"pickVision": "Leer imágenes y documentos",
"pickAudio": "Voz de entrada y de salida",
"pickVisual": "Generar imágenes y vídeo"
}
}

View File

@@ -28,7 +28,7 @@
},
"items": {
"home": "Inicio",
"installModels": "Instalar modelos",
"discover": "Descubrir",
"chat": "Chat",
"studio": "Studio",
"talk": "Hablar",

View File

@@ -71,6 +71,10 @@
},
"actions": {
"copy": "Salin",
"edit": "Edit",
"editMessage": "Edit pesan",
"save": "Simpan",
"cancel": "Batal",
"regenerate": "Hasilkan ulang",
"jumpToLatest": "Lompat ke terbaru"
},

View File

@@ -1,5 +1,5 @@
{
"title": "Instal Model",
"title": "Jelajahi",
"subtitle": "Telusuri dan instal model AI dari galeri",
"models": "Model",
"recommended": {
@@ -47,7 +47,9 @@
"searchBackends": "Cari backends...",
"contextSize": "Konteks:",
"useCaseLabel": "Filter berdasarkan kasus penggunaan",
"unavailableForBackend": "Tidak tersedia untuk backend yang dipilih"
"unavailableForBackend": "Tidak tersedia untuk backend yang dipilih",
"someSelected": "{{count}} dipilih",
"refineLabel": "Persempit"
},
"search": {
"placeholder": "Cari model...",
@@ -88,7 +90,10 @@
"fileCount_other": "{{count}} file",
"filename": "Nama file",
"uri": "URI",
"sha256": "SHA256"
"sha256": "SHA256",
"backToAll": "Semua model",
"vramAt": "VRAM pada {{context}}",
"headroom": "Sisa ruang"
},
"empty": {
"title": "Model tidak ditemukan",
@@ -134,5 +139,44 @@
"dflash": "Lebih cepat: DFlash",
"mtp": "Lebih cepat: MTP"
}
},
"rail": {
"sortLabel": "Urutkan model",
"downloadingPct": "mengunduh {{percent}}%",
"tooLarge": "{{size}} · terlalu besar",
"fitsSize": "{{size}} · muat",
"previousPage": "Halaman sebelumnya",
"nextPage": "Halaman berikutnya",
"showingCount": "{{shown}} dari {{total}}",
"sizing": "menghitung…"
},
"groups": {
"text": "Teks dan penalaran",
"vision": "Visi",
"audio": "Suara dan audio",
"visual": "Gambar dan video",
"other": "Lainnya"
},
"chart": {
"title": "VRAM menurut panjang konteks",
"available": "{{vram}} tersedia",
"barTitle": "Konteks {{context}} membutuhkan {{vram}}",
"fitsEverywhere": "Berjalan pada setiap panjang konteks di host ini.",
"fitsNowhere": "Tidak muat di host ini pada panjang konteks mana pun.",
"fitsUpTo": "Muat hingga konteks {{context}}."
},
"shelves": {
"hostLabel": "Host Anda",
"heroWithGpu": "Memori GPU {{vram}}, {{count}} model di galeri.",
"heroNoGpu": "{{count}} model di galeri.",
"heroHint": "Pilih model di sebelah kiri untuk melihat ukuran, varian, dan kecocokannya.",
"browsing": "Menjelajah",
"pickHint": "Pilih model untuk melihat detailnya.",
"heroWithRam": "Memori sistem {{ram}}, {{count}} model di galeri.",
"byUseCase": "Atau mulai dari kasus penggunaan",
"pickText": "Obrolan, penalaran, embedding",
"pickVision": "Membaca gambar dan dokumen",
"pickAudio": "Suara masuk dan keluar",
"pickVisual": "Membuat gambar dan video"
}
}

View File

@@ -28,7 +28,7 @@
},
"items": {
"home": "Beranda",
"installModels": "Instal Model",
"discover": "Jelajahi",
"chat": "Obrolan",
"studio": "Studio",
"talk": "Bicara",

View File

@@ -71,6 +71,10 @@
},
"actions": {
"copy": "Copia",
"edit": "Modifica",
"editMessage": "Modifica messaggio",
"save": "Salva",
"cancel": "Annulla",
"regenerate": "Rigenera",
"jumpToLatest": "Torna in fondo"
},

View File

@@ -1,5 +1,5 @@
{
"title": "Installa modelli",
"title": "Esplora",
"subtitle": "Sfoglia e installa modelli AI dalla galleria",
"recommended": {
"title": "Consigliati per il tuo hardware",
@@ -40,7 +40,9 @@
"searchBackends": "Cerca backend...",
"contextSize": "Contesto:",
"useCaseLabel": "Filtra per caso d'uso",
"unavailableForBackend": "Non disponibile per il backend selezionato"
"unavailableForBackend": "Non disponibile per il backend selezionato",
"someSelected": "{{count}} selezionati",
"refineLabel": "Affina"
},
"search": {
"placeholder": "Cerca modelli...",
@@ -81,7 +83,10 @@
"fileCount_other": "{{count}} file",
"filename": "Nome file",
"uri": "URI",
"sha256": "SHA256"
"sha256": "SHA256",
"backToAll": "Tutti i modelli",
"vramAt": "VRAM a {{context}}",
"headroom": "Margine"
},
"empty": {
"title": "Nessun modello trovato",
@@ -121,5 +126,44 @@
"dflash": "Più veloce: DFlash",
"mtp": "Più veloce: MTP"
}
},
"rail": {
"sortLabel": "Ordina modelli",
"downloadingPct": "download {{percent}}%",
"tooLarge": "{{size}} · troppo grande",
"fitsSize": "{{size}} · compatibile",
"previousPage": "Pagina precedente",
"nextPage": "Pagina successiva",
"showingCount": "{{shown}} di {{total}}",
"sizing": "calcolo…"
},
"groups": {
"text": "Testo e ragionamento",
"vision": "Visione",
"audio": "Voce e audio",
"visual": "Immagini e video",
"other": "Tutto il resto"
},
"chart": {
"title": "VRAM per lunghezza del contesto",
"available": "{{vram}} disponibili",
"barTitle": "Un contesto {{context}} richiede {{vram}}",
"fitsEverywhere": "Funziona con qualsiasi lunghezza di contesto su questo host.",
"fitsNowhere": "Non entra in memoria su questo host con nessuna lunghezza di contesto.",
"fitsUpTo": "Entra fino a un contesto {{context}}."
},
"shelves": {
"hostLabel": "Il tuo host",
"heroWithGpu": "{{vram}} di memoria GPU, {{count}} modelli nella galleria.",
"heroNoGpu": "{{count}} modelli nella galleria.",
"heroHint": "Scegli un modello a sinistra per vederne dimensione, varianti e compatibilità.",
"browsing": "Esplorazione",
"pickHint": "Seleziona un modello per vederne i dettagli.",
"heroWithRam": "{{ram}} di memoria di sistema, {{count}} modelli nella galleria.",
"byUseCase": "Oppure parti da un caso duso",
"pickText": "Chat, ragionamento, embedding",
"pickVision": "Leggere immagini e documenti",
"pickAudio": "Voce in ingresso e in uscita",
"pickVisual": "Generare immagini e video"
}
}

View File

@@ -28,7 +28,7 @@
},
"items": {
"home": "Home",
"installModels": "Installa modelli",
"discover": "Esplora",
"chat": "Chat",
"studio": "Studio",
"talk": "Conversazione",

View File

@@ -71,6 +71,10 @@
},
"actions": {
"copy": "복사",
"edit": "편집",
"editMessage": "메시지 편집",
"save": "저장",
"cancel": "취소",
"regenerate": "다시 생성",
"jumpToLatest": "Jump to latest"
},

View File

@@ -1,5 +1,5 @@
{
"title": "모델 설치",
"title": "둘러보기",
"subtitle": "갤러리에서 AI 모델을 둘러보고 설치합니다",
"recommended": {
"title": "하드웨어에 맞는 추천",
@@ -46,7 +46,9 @@
"searchBackends": "백엔드 검색...",
"contextSize": "컨텍스트:",
"useCaseLabel": "사용 사례로 필터링",
"unavailableForBackend": "선택한 백엔드에서 사용할 수 없음"
"unavailableForBackend": "선택한 백엔드에서 사용할 수 없음",
"someSelected": "{{count}}개 선택됨",
"refineLabel": "세부 조건"
},
"search": {
"placeholder": "모델 검색...",
@@ -87,7 +89,10 @@
"fileCount_other": "파일 {{count}}개",
"filename": "파일 이름",
"uri": "URI",
"sha256": "SHA256"
"sha256": "SHA256",
"backToAll": "모든 모델",
"vramAt": "{{context}}에서의 VRAM",
"headroom": "여유 공간"
},
"empty": {
"title": "모델을 찾을 수 없습니다",
@@ -105,5 +110,44 @@
"loadFailed": "모델을 불러오지 못했습니다: {{message}}",
"installFailed": "설치 실패: {{message}}",
"deleteFailed": "삭제 실패: {{message}}"
},
"rail": {
"sortLabel": "모델 정렬",
"downloadingPct": "내려받는 중 {{percent}}%",
"tooLarge": "{{size}} · 너무 큼",
"fitsSize": "{{size}} · 실행 가능",
"previousPage": "이전 페이지",
"nextPage": "다음 페이지",
"showingCount": "{{total}}개 중 {{shown}}개",
"sizing": "계산 중…"
},
"groups": {
"text": "텍스트 및 추론",
"vision": "비전",
"audio": "음성 및 오디오",
"visual": "이미지 및 비디오",
"other": "기타"
},
"chart": {
"title": "컨텍스트 길이별 VRAM",
"available": "{{vram}} 사용 가능",
"barTitle": "{{context}} 컨텍스트에 {{vram}} 필요",
"fitsEverywhere": "이 호스트에서 모든 컨텍스트 길이로 실행됩니다.",
"fitsNowhere": "이 호스트에서는 어떤 컨텍스트 길이로도 실행할 수 없습니다.",
"fitsUpTo": "{{context}} 컨텍스트까지 실행 가능합니다."
},
"shelves": {
"hostLabel": "내 호스트",
"heroWithGpu": "GPU 메모리 {{vram}}, 갤러리에 모델 {{count}}개.",
"heroNoGpu": "갤러리에 모델 {{count}}개.",
"heroHint": "왼쪽에서 모델을 선택하면 크기, 변형, 실행 가능 여부를 볼 수 있습니다.",
"browsing": "둘러보기",
"pickHint": "모델을 선택하면 상세 정보가 표시됩니다.",
"heroWithRam": "시스템 메모리 {{ram}}, 갤러리에 모델 {{count}}개.",
"byUseCase": "또는 용도로 시작하기",
"pickText": "채팅, 추론, 임베딩",
"pickVision": "이미지와 문서 읽기",
"pickAudio": "음성 입력과 출력",
"pickVisual": "이미지와 영상 생성"
}
}

View File

@@ -28,7 +28,7 @@
},
"items": {
"home": "홈",
"installModels": "모델 설치",
"discover": "둘러보기",
"chat": "채팅",
"studio": "스튜디오",
"talk": "대화",

View File

@@ -71,6 +71,10 @@
},
"actions": {
"copy": "复制",
"edit": "编辑",
"editMessage": "编辑消息",
"save": "保存",
"cancel": "取消",
"regenerate": "重新生成",
"jumpToLatest": "Jump to latest"
},

View File

@@ -1,5 +1,5 @@
{
"title": "安装模型",
"title": "发现",
"subtitle": "从模型库浏览和安装 AI 模型",
"recommended": {
"title": "适合你硬件的推荐",
@@ -40,7 +40,9 @@
"searchBackends": "搜索后端...",
"contextSize": "上下文:",
"useCaseLabel": "按用例筛选",
"unavailableForBackend": "所选后端不支持"
"unavailableForBackend": "所选后端不支持",
"someSelected": "已选 {{count}} 项",
"refineLabel": "细化"
},
"search": {
"placeholder": "搜索模型...",
@@ -81,7 +83,10 @@
"fileCount_other": "{{count}} 个文件",
"filename": "文件名",
"uri": "URI",
"sha256": "SHA256"
"sha256": "SHA256",
"backToAll": "全部模型",
"vramAt": "{{context}} 时显存",
"headroom": "剩余显存"
},
"empty": {
"title": "未找到模型",
@@ -121,5 +126,44 @@
"dflash": "更快DFlash",
"mtp": "更快MTP"
}
},
"rail": {
"sortLabel": "排序模型",
"downloadingPct": "下载中 {{percent}}%",
"tooLarge": "{{size}} · 过大",
"fitsSize": "{{size}} · 可运行",
"previousPage": "上一页",
"nextPage": "下一页",
"showingCount": "{{total}} 个中的 {{shown}} 个",
"sizing": "计算中…"
},
"groups": {
"text": "文本与推理",
"vision": "视觉",
"audio": "语音与音频",
"visual": "图像与视频",
"other": "其他"
},
"chart": {
"title": "各上下文长度所需显存",
"available": "可用 {{vram}}",
"barTitle": "{{context}} 上下文需要 {{vram}}",
"fitsEverywhere": "在此主机上可以任意上下文长度运行。",
"fitsNowhere": "在此主机上任何上下文长度都无法运行。",
"fitsUpTo": "最高可在 {{context}} 上下文下运行。"
},
"shelves": {
"hostLabel": "你的主机",
"heroWithGpu": "{{vram}} 显存,图库中有 {{count}} 个模型。",
"heroNoGpu": "图库中有 {{count}} 个模型。",
"heroHint": "在左侧选择模型,即可查看大小、变体以及能否在本机运行。",
"browsing": "浏览中",
"pickHint": "选择一个模型以查看详情。",
"heroWithRam": "{{ram}} 系统内存,图库中有 {{count}} 个模型。",
"byUseCase": "或从用途开始",
"pickText": "对话、推理、向量",
"pickVision": "读取图像与文档",
"pickAudio": "语音输入与输出",
"pickVisual": "生成图像与视频"
}
}

View File

@@ -28,7 +28,7 @@
},
"items": {
"home": "首页",
"installModels": "安装模型",
"discover": "发现",
"chat": "聊天",
"studio": "工作室",
"talk": "通话",

View File

@@ -3540,6 +3540,37 @@ button.collapsible-header:focus-visible {
background: var(--color-primary-light);
}
.chat-message-edit {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
min-width: min(32rem, 60vw);
}
.chat-message-edit-input {
width: 100%;
min-height: 6rem;
resize: vertical;
border: 1px solid var(--color-primary-border);
border-radius: var(--radius-md);
background: var(--color-bg-primary);
color: var(--color-text-primary);
font: inherit;
line-height: 1.5;
padding: var(--spacing-sm);
}
.chat-message-edit-input:focus {
outline: 2px solid var(--color-primary-light);
outline-offset: 1px;
}
.chat-message-edit-actions {
display: flex;
justify-content: flex-end;
gap: var(--spacing-xs);
}
.chat-message-system {
align-self: center;
max-width: 90%;
@@ -11564,3 +11595,897 @@ button.collapsible-header:focus-visible {
.ajd-code--tall { max-height: 300px; }
.ajd-code--taller { max-height: 500px; }
.ajd-code--error { background: var(--color-error-light); color: var(--color-error); max-height: none; }
/* ==========================================================================
SplitView: the shell shared by Discover, Backends and Host.
The rail is scanned, the pane answers. The pane has exactly two states -
a zero state with nothing selected and one entity's detail with something
selected - which is what replaces the click-to-expand table row. What the
zero state says is the surface's business: a catalog offers what to install,
an inventory reports what is running.
========================================================================== */
.split-view {
display: grid;
grid-template-columns: minmax(300px, 360px) minmax(0, 1fr);
gap: var(--spacing-md);
align-items: start;
}
.split-view__rail-col {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
min-width: 0;
}
.entity-rail {
background: var(--color-bg-secondary);
border: 1px solid var(--color-border-default);
border-radius: var(--radius-lg);
overflow: hidden;
}
.entity-rail__head {
display: flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-xs) var(--spacing-sm);
border-bottom: 1px solid var(--color-border-subtle);
flex-wrap: wrap;
}
.entity-rail__count {
font-size: 0.75rem;
color: var(--color-text-muted);
}
.entity-rail__sort {
display: flex;
gap: 2px;
margin-left: auto;
}
/* Sorting lost its home when the column headers went. It reappears here rather
than in the filter band above, because it orders this list and nothing else. */
.entity-rail__sort-btn {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 0.6875rem;
color: var(--color-text-muted);
background: transparent;
border: 1px solid transparent;
border-radius: var(--radius-sm);
padding: 2px 6px;
cursor: pointer;
}
.entity-rail__sort-btn:hover {
color: var(--color-text-primary);
border-color: var(--color-border-default);
}
.entity-rail__sort-btn.active {
color: var(--color-primary);
border-color: var(--color-primary-border);
background: var(--color-primary-light);
}
.entity-rail__list {
max-height: 640px;
overflow-y: auto;
}
.entity-rail__list:focus-visible {
outline: 2px solid var(--color-focus-ring);
outline-offset: -2px;
}
.entity-rail__group-head {
display: flex;
align-items: center;
gap: var(--spacing-xs);
width: 100%;
padding: 6px var(--spacing-sm);
background: var(--color-surface-sunken);
border: 0;
border-top: 1px solid var(--color-border-subtle);
color: var(--color-text-secondary);
cursor: pointer;
text-align: left;
position: sticky;
top: 0;
z-index: 1;
}
.entity-rail__group:first-child .entity-rail__group-head { border-top: 0; }
.entity-rail__caret { font-size: 0.5625rem; color: var(--color-text-muted); width: 10px; }
.entity-rail__group-icon { font-size: 0.6875rem; color: var(--color-text-muted); }
.entity-rail__group-label {
font-size: 0.6875rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.entity-rail__group-count {
margin-left: auto;
font-family: var(--font-mono);
font-size: 0.6875rem;
color: var(--color-text-muted);
font-variant-numeric: tabular-nums;
}
.entity-rail__item {
display: flex;
align-items: center;
gap: var(--spacing-sm);
width: 100%;
padding: 7px var(--spacing-sm);
background: transparent;
border: 0;
border-top: 1px solid var(--color-border-divider);
color: inherit;
text-align: left;
cursor: pointer;
}
.entity-rail__item:hover { background: var(--color-bg-hover); }
/* The rail rounds its corners with overflow:hidden, which clips an outline
drawn outside the element. Inset it so the first and last entries keep a
visible focus ring. */
.entity-rail__item:focus-visible,
.entity-rail__group-head:focus-visible {
outline: 2px solid var(--color-focus-ring);
outline-offset: -2px;
}
/* Touch. A 30px row is fine for a mouse and too small for a thumb, so coarse
pointers get the 44px target without costing density on a desktop. */
@media (pointer: coarse) {
.entity-rail__item { padding-top: 12px; padding-bottom: 12px; }
.entity-rail__group-head { padding-top: 10px; padding-bottom: 10px; }
}
.entity-rail__item--on {
background: var(--color-primary-light);
box-shadow: inset 2px 0 0 var(--color-primary);
}
.entity-rail__icon {
flex: none;
width: 16px;
font-size: 0.75rem;
color: var(--color-text-muted);
text-align: center;
}
.entity-rail__item--on .entity-rail__icon { color: var(--color-primary); }
.entity-rail__main { min-width: 0; display: flex; flex-direction: column; gap: 1px; }
.entity-rail__name {
font-size: 0.8125rem;
font-weight: 500;
color: var(--color-text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.entity-rail__meta {
font-family: var(--font-mono);
font-size: 0.6875rem;
color: var(--color-text-tertiary);
font-variant-numeric: tabular-nums;
}
/* A left edge for surfaces read by condition before they are read by name. An
inventory has one; a catalog does not, because "available" is not a state
worth a colour on every row. */
.entity-rail__stripe {
width: 3px;
align-self: stretch;
border-radius: 2px;
flex: none;
}
.entity-rail__stripe--run { background: var(--color-success); }
.entity-rail__stripe--idle { background: var(--color-border-strong); }
.entity-rail__stripe--err { background: var(--color-error); }
.entity-rail__stripe--off { background: transparent; }
.entity-rail__meta--bad { color: var(--color-error); }
.entity-rail__meta--ok { color: var(--color-success); }
.entity-rail__meta--busy { color: var(--color-primary); }
.split-view__pager { margin: 0; }
.split-view__pager-label {
font-size: 0.8125rem;
color: var(--color-text-secondary);
padding: 0 var(--spacing-sm);
font-variant-numeric: tabular-nums;
}
/* --- Pane ---------------------------------------------------------------- */
.split-view__pane {
background: var(--color-bg-secondary);
border: 1px solid var(--color-border-default);
border-radius: var(--radius-lg);
min-height: 420px;
padding: var(--spacing-md);
}
.zero-pane { display: flex; flex-direction: column; gap: var(--spacing-lg); }
.zero-pane__hero { display: flex; flex-direction: column; gap: 4px; }
.zero-pane__eyebrow {
font-family: var(--font-mono);
font-size: 0.625rem;
letter-spacing: 0.11em;
text-transform: uppercase;
color: var(--color-eyebrow);
}
.zero-pane__title {
font-size: 1.0625rem;
font-weight: 600;
letter-spacing: -0.015em;
text-wrap: balance;
}
.zero-pane__text { font-size: 0.8125rem; color: var(--color-text-muted); }
.zero-pane__shelf-head { display: flex; align-items: baseline; gap: var(--spacing-sm); }
.zero-pane__shelf-title { font-size: 0.875rem; font-weight: 600; }
.zero-pane__shelf-meta {
margin-left: auto;
font-family: var(--font-mono);
font-size: 0.6875rem;
color: var(--color-text-muted);
}
.zero-pane__shelf-hint { font-size: 0.8125rem; color: var(--color-text-muted); margin-top: 4px; }
/* Curated tiles. A catalog spends its zero state arguing for something; the
tile is where that argument gets the width a rail line never has. */
.zero-pane__tiles {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: var(--spacing-sm);
}
.zero-pane__tile {
background: var(--color-bg-primary);
border: 1px solid var(--color-border-default);
border-radius: var(--radius-md);
padding: var(--spacing-sm);
display: flex;
flex-direction: column;
gap: 6px;
text-align: left;
cursor: pointer;
color: inherit;
}
.zero-pane__tile:hover { border-color: var(--color-border-strong); background: var(--color-bg-hover); }
.zero-pane__tile--feat {
border-color: var(--color-primary-border);
background: linear-gradient(150deg, var(--color-primary-light), transparent 68%), var(--color-bg-primary);
}
.zero-pane__tile-name { font-size: 0.8125rem; font-weight: 550; }
.zero-pane__tile-foot { display: flex; align-items: center; gap: var(--spacing-xs); margin-top: auto; }
/* One line that changes what you would do next. Reserved for that; a pane of
these has no emphasis left. */
.zero-pane__alert {
display: flex;
align-items: center;
gap: var(--spacing-sm);
font-size: 0.8125rem;
padding: 6px var(--spacing-sm);
border-radius: var(--radius-sm);
border: 1px solid;
}
.zero-pane__alert--warn { color: var(--color-warning); background: var(--color-warning-light); border-color: var(--color-warning-border); }
.zero-pane__alert--bad { color: var(--color-error); background: var(--color-error-light); border-color: var(--color-error-border); }
.zero-pane__alert > :last-child { margin-left: auto; }
.detail-pane__label {
display: block;
font-family: var(--font-mono);
font-size: 0.625rem;
letter-spacing: 0.11em;
text-transform: uppercase;
color: var(--color-text-muted);
margin-bottom: 6px;
}
/* --- Detail -------------------------------------------------------------- */
.detail-pane { display: flex; flex-direction: column; gap: var(--spacing-md); }
.detail-pane__back {
align-self: flex-start;
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.75rem;
color: var(--color-text-muted);
background: transparent;
border: 1px solid var(--color-border-default);
border-radius: var(--radius-sm);
padding: 3px 8px;
cursor: pointer;
}
.detail-pane__back:hover { color: var(--color-text-primary); border-color: var(--color-border-strong); }
.detail-pane__head { display: flex; gap: var(--spacing-sm); align-items: flex-start; flex-wrap: wrap; }
.detail-pane__icon {
flex: none;
font-size: 1.25rem;
color: var(--color-primary);
padding-top: 2px;
}
.detail-pane__title { flex: 1 1 260px; min-width: 0; }
.detail-pane__name {
font-size: 1.0625rem;
font-weight: 600;
letter-spacing: -0.015em;
word-break: break-word;
}
.detail-pane__lede { font-size: 0.8125rem; color: var(--color-text-muted); margin-top: 2px; }
.detail-pane__actions { display: flex; gap: var(--spacing-xs); align-items: center; flex: none; }
.discover__progress { flex: none; width: 120px; margin-top: 4px; }
.detail-pane__warning {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.75rem;
color: var(--color-error);
background: var(--color-error-light);
border: 1px solid var(--color-error-border);
border-radius: var(--radius-sm);
padding: 6px var(--spacing-sm);
}
.stat-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 1px;
background: var(--color-border-default);
border: 1px solid var(--color-border-default);
border-radius: var(--radius-md);
overflow: hidden;
}
.stat-grid__item { background: var(--color-surface-sunken); padding: 6px var(--spacing-sm); }
.stat-grid__item dt {
font-family: var(--font-mono);
font-size: 0.5625rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--color-text-muted);
}
.stat-grid__item dd {
font-size: 0.9375rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
margin-top: 1px;
}
.stat-grid__value--ok { color: var(--color-success); }
.stat-grid__value--bad { color: var(--color-error); }
.stat-grid__value--warn { color: var(--color-warning); }
/* --- VRAM by context ----------------------------------------------------- */
.discover__chart { display: flex; flex-direction: column; gap: 6px; }
.discover__chart-title {
font-family: var(--font-mono);
font-size: 0.625rem;
letter-spacing: 0.11em;
text-transform: uppercase;
color: var(--color-text-muted);
}
.discover__chart-plot {
/* Matches CHART_HEIGHT in Models.jsx: the bars and the limit line are both
resolved in pixels against this, so they share one scale. */
position: relative;
height: 96px;
display: flex;
align-items: flex-end;
gap: 8px;
padding-top: 15px;
}
/* The limit is what makes the bars mean anything, so it is drawn across all of
them rather than annotated on each. */
.discover__chart-limit {
position: absolute;
left: 0;
right: 0;
bottom: var(--discover-limit, 0);
border-top: 1px dashed var(--color-error-border);
pointer-events: none;
}
.discover__chart-limit-label {
position: absolute;
right: 0;
top: -1.05rem;
font-family: var(--font-mono);
font-size: 0.5625rem;
letter-spacing: 0.05em;
color: var(--color-error);
background: var(--color-bg-secondary);
padding: 0 3px;
}
.discover__chart-col {
flex: 1;
display: flex;
flex-direction: column;
justify-content: flex-end;
gap: 3px;
height: 100%;
background: transparent;
border: 0;
padding: 0;
cursor: pointer;
min-width: 0;
}
.discover__chart-value {
font-family: var(--font-mono);
font-size: 0.5625rem;
color: var(--color-text-tertiary);
text-align: center;
font-variant-numeric: tabular-nums;
}
.discover__chart-bar {
height: var(--discover-bar, 0);
min-height: 3px;
border-radius: 4px 4px 0 0;
background: var(--color-success);
/* A surface-coloured ring keeps adjacent bars from reading as one block. */
box-shadow: 0 0 0 2px var(--color-bg-secondary);
}
.discover__chart-bar--over { background: var(--color-error); }
.discover__chart-col:hover .discover__chart-bar { filter: brightness(1.1); }
/* Its own row rather than a third line inside each column: one baseline for
every label, whatever the values above them do. */
.discover__chart-axis {
display: flex;
gap: 8px;
border-top: 1px solid var(--color-border-default);
padding-top: 3px;
}
.discover__chart-axis span {
flex: 1;
min-width: 0;
text-align: center;
font-family: var(--font-mono);
font-size: 0.5625rem;
letter-spacing: 0.05em;
color: var(--color-text-muted);
}
.discover__chart-axis-on { color: var(--color-primary); font-weight: 600; }
.discover__chart-col--on .discover__chart-value {
color: var(--color-primary);
font-weight: 600;
}
.discover__chart-verdict { display: flex; align-items: center; gap: 6px; font-size: 0.75rem; }
.discover__chart-verdict--ok { color: var(--color-success); }
.discover__chart-verdict--warn { color: var(--color-warning); }
.discover__chart-verdict--bad { color: var(--color-error); }
/* --- Narrow ------------------------------------------------------------- */
/* Below this the two columns stop being two columns. With a model selected the
pane becomes the page and the rail steps aside, which is the same trade a
pushed route would make without the routing. */
@media (max-width: 900px) {
.split-view { grid-template-columns: minmax(0, 1fr); }
.entity-rail__list { max-height: 320px; }
.split-view--detail .split-view__rail-col { display: none; }
}
/* Compact list rows inside a pane: a state chip, a name, and one number. Used
by the Host status page for "loaded now" and by detail panes for anything
that is a list of facts rather than a table. */
.rowlist { display: flex; flex-direction: column; gap: 4px; }
.rowline {
display: flex;
align-items: center;
gap: var(--spacing-sm);
padding: 5px var(--spacing-sm);
border: 1px solid var(--color-border-default);
border-radius: var(--radius-sm);
font-size: 0.8125rem;
color: var(--color-text-secondary);
}
.rowline__num { margin-left: auto; font-variant-numeric: tabular-nums; }
/* A first run is a whole screen, not a card wedged under a filter bar. These
two states are the only thing on the page, so they get the height to say so.
They previously wore .loading-center, which is display:flex with 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 between them. */
.empty-state--page {
min-height: 52vh;
justify-content: center;
align-items: center;
text-align: center;
padding-block: var(--spacing-3xl);
}
.empty-state--page .empty-state-icon { font-size: 2rem; }
.empty-state--page .empty-state-text { text-wrap: balance; }
/* The filter band moved into the rail column, so it stacks instead of laying
out three horizontal bands across the page. It sits there because it narrows
the rail and nothing else: one column to say what you want, one to show what
you got. */
.split-view__rail-col .models-filters {
margin-bottom: 0;
}
.split-view__rail-col .models-filters__query,
.split-view__rail-col .models-filters__refine {
flex-direction: column;
align-items: stretch;
gap: var(--spacing-xs);
}
.split-view__rail-col .models-filters__backend { width: 100%; }
/* Nineteen chips do not fit beside a 360px rail. The trigger states the
selection and the popover carries the full set, which also stops the chip
row and the rail competing to be the same control. */
.models-filters__usecase-trigger {
display: flex;
align-items: center;
gap: var(--spacing-xs);
width: 100%;
padding: 6px var(--spacing-sm);
background: var(--color-bg-secondary);
border: 1px solid var(--color-border-default);
border-radius: var(--radius-md);
color: var(--color-text-primary);
font-size: 0.8125rem;
cursor: pointer;
text-align: left;
}
.models-filters__usecase-trigger:hover { border-color: var(--color-border-strong); }
.models-filters__usecase-caret { margin-left: auto; font-size: 0.625rem; color: var(--color-text-muted); }
.models-filters__usecases {
margin: 0;
padding: var(--spacing-xs) 0 0;
max-height: 46vh;
overflow-y: auto;
}
/* ==========================================================================
Full-height view: the split fills the window instead of sitting in a
document that scrolls. Rail and pane scroll independently, so the filters
and the pane's headline both stay put while a long list moves under them.
========================================================================== */
.page--app {
display: flex;
flex-direction: column;
flex: 1;
/* A flex item's automatic minimum is its content, so without this the list
pushes the page taller instead of scrolling inside it. */
min-height: 0;
padding-bottom: var(--spacing-lg);
}
/* The header, fused. A slim row that reads as the top of the view rather than
a title block the view happens to sit under. */
.view-bar {
display: flex;
align-items: baseline;
gap: var(--spacing-sm);
padding-bottom: var(--spacing-md);
flex-wrap: wrap;
}
.view-bar__title {
font-size: 1.375rem;
font-weight: 600;
letter-spacing: -0.02em;
}
.view-bar__count {
font-family: var(--font-mono);
font-size: 0.75rem;
color: var(--color-text-muted);
font-variant-numeric: tabular-nums;
}
.view-bar__actions { display: flex; gap: var(--spacing-xs); margin-left: auto; }
.page--app .split-view { flex: 1; min-height: 0; }
.page--app .split-view__rail-col {
height: 100%;
min-height: 0;
}
/* The filters keep their height; the list absorbs the rest and scrolls. */
.page--app .entity-rail {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.page--app .entity-rail__list {
flex: 1;
min-height: 0;
max-height: none;
}
.page--app .split-view__pane {
height: 100%;
overflow-y: auto;
}
/* Grouped chips. Four families and a reset, rather than nineteen in a row. */
.models-filters__usecase-group {
display: flex;
flex-direction: column;
gap: 4px;
}
.models-filters__usecase-group + .models-filters__usecase-group {
margin-top: var(--spacing-sm);
padding-top: var(--spacing-sm);
border-top: 1px solid var(--color-border-divider);
}
.models-filters__usecase-label {
font-family: var(--font-mono);
font-size: 0.625rem;
letter-spacing: 0.11em;
text-transform: uppercase;
color: var(--color-text-muted);
}
.models-filters__usecases .filter-bar { margin: 0; }
/* The refinements read as a section with a name, not three controls left where
they landed when the band became a column. */
.split-view__rail-col .models-filters__refine {
margin-top: var(--spacing-sm);
padding-top: var(--spacing-sm);
border-top: 1px solid var(--color-border-divider);
gap: 6px;
}
.models-filters__refine-label {
font-family: var(--font-mono);
font-size: 0.625rem;
letter-spacing: 0.11em;
text-transform: uppercase;
color: var(--color-text-muted);
}
.split-view__rail-col .filter-bar-group__toggle {
justify-content: flex-start;
width: 100%;
padding: 2px 0;
}
.split-view__rail-col .models-filters__context {
display: flex;
align-items: center;
gap: var(--spacing-xs);
width: 100%;
}
.split-view__rail-col .models-filters__context input[type='range'] { flex: 1; min-width: 0; }
@media (max-width: 900px) {
/* Stacked, the view cannot be height-constrained without trapping the list
in a short scroller, so it goes back to scrolling with the document. */
.page--app { display: block; }
.page--app .split-view__pane { height: auto; overflow: visible; }
.page--app .entity-rail__list { max-height: 320px; }
}
/* Pin the layout for split-view routes, the way the chat route already does.
.app-layout and .main-content are min-height:100dvh, which is a floor, not a
ceiling: a tall pane grew the whole column past the viewport, so selecting a
model with a long detail pushed the page down and the rail grew with it. The
"full height" view was scrolling the document instead of scrolling inside
itself.
:has() rather than a route flag in App.jsx, so the shell stays unaware of
which pages happen to be split views. */
.app-layout:has(.page--app) {
height: 100vh;
height: 100dvh;
}
.app-layout:has(.page--app) .main-content {
min-height: 0;
overflow: hidden;
}
/* Below the split's breakpoint the two columns stack, and a stacked view
cannot be height-pinned without trapping both halves in short scrollers. It
goes back to scrolling with the document. */
@media (max-width: 900px) {
.app-layout:has(.page--app) {
height: auto;
min-height: 100dvh;
}
.app-layout:has(.page--app) .main-content {
min-height: 100dvh;
overflow: visible;
}
}
/* Backends: the filters stack in the rail column the way Discover's do. Seven
chips fit at this width, so they need no disclosure. */
.bk-filters {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
margin-bottom: var(--spacing-sm);
}
.bk-filters .search-bar { width: 100%; }
.bk-filters .filter-bar { margin: 0; }
.split-view__rail-col .bk-toggles {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
}
/* Host keeps its resource monitor, summary cards and tabs above the split, so
only what is left after them is pinned. Those are the page's own chrome and
they are read once, unlike the rail and the pane, which are worked in. */
.page--app > .tabs { flex: none; }
.page--app > .view-bar { flex: none; }
/* The console layout is a flex row with align-items:flex-start, so its body
sizes to content. That is right for the pages it was built for and wrong for
a split view, which needs a ceiling to scroll inside: without it the rail ran
past the viewport and over the footer.
Scoped with :has() so only the split-view routes are pinned; every other
console page keeps sizing to its content. */
.console-layout:has(.page--app) {
flex: 1;
min-height: 0;
align-items: stretch;
}
.console-layout:has(.page--app) > .console-body {
min-height: 0;
display: flex;
flex-direction: column;
}
@media (max-width: 900px) {
.console-layout:has(.page--app) {
flex: none;
align-items: flex-start;
}
.console-layout:has(.page--app) > .console-body { display: block; }
}
/* Refetching is not the same event as first load. A first load has nothing to
show, so it gets a skeleton; a refetch already has a list on screen and must
keep it, because the search box lives in this column and unmounting it drops
the field mid-keystroke along with the focus. */
.entity-rail__progress {
height: 2px;
background: transparent;
overflow: hidden;
flex: none;
}
.entity-rail--busy .entity-rail__progress {
background: linear-gradient(
90deg,
transparent 0%,
var(--color-primary) 40%,
var(--color-primary) 60%,
transparent 100%
);
background-size: 40% 100%;
background-repeat: no-repeat;
animation: entity-rail-sweep 1.1s var(--ease-default) infinite;
}
@keyframes entity-rail-sweep {
from { background-position: -40% 0; }
to { background-position: 140% 0; }
}
/* The stale list recedes rather than disappearing, so the eye knows the answer
is being replaced without losing the place it was reading. */
.entity-rail--busy .entity-rail__list {
opacity: 0.55;
transition: opacity var(--duration-normal) var(--ease-default);
pointer-events: none;
}
@media (prefers-reduced-motion: reduce) {
.entity-rail--busy .entity-rail__progress { animation: none; background-size: 100% 100%; }
.entity-rail--busy .entity-rail__list { transition: none; }
}
/* The first-load skeleton. It was six inline style declarations on a bare div,
which is also why nothing could reliably select it. */
.gallery-loader {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--spacing-xl) var(--spacing-md);
min-height: 280px;
gap: var(--spacing-lg);
}
/* An estimate still in flight. Muted and gently pulsing, so the row reads as
working rather than as missing a value: what is pending is the "will it fit"
answer, not the entry itself, which is installable already. */
.entity-rail__meta--pending {
color: var(--color-text-tertiary);
font-style: italic;
animation: entity-rail-pending 1.6s var(--ease-default) infinite;
}
@keyframes entity-rail-pending {
0%, 100% { opacity: 0.55; }
50% { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.entity-rail__meta--pending { animation: none; opacity: 0.75; }
}

View File

@@ -30,11 +30,7 @@ export default function GalleryLoader() {
const phrase = LOADING_PHRASES[idx]
return (
<div style={{
display: 'flex', flexDirection: 'column', alignItems: 'center',
justifyContent: 'center', padding: 'var(--spacing-xl) var(--spacing-md)',
minHeight: '280px', gap: 'var(--spacing-lg)',
}}>
<div className="gallery-loader" data-testid="gallery-loader">
<div className="hstack">
{[0, 1, 2, 3, 4].map(i => (
<div key={i} style={{

View File

@@ -15,7 +15,11 @@ const SECTIONS_KEY = 'localai_sidebar_sections'
const topItems = [
{ path: '/app', icon: 'fas fa-home', labelKey: 'items.home' },
{ path: '/app/models', icon: 'fas fa-download', labelKey: 'items.installModels', adminOnly: true },
// "Discover" rather than "Models": the installed-models view lives under
// Host, so a bare "Models" here would name two different pages. The compass
// replaces a download arrow because the page is now browsed before it is
// installed from.
{ path: '/app/models', icon: 'fas fa-compass', labelKey: 'items.discover', adminOnly: true },
]
// Create stays inline (frequent, one-click creative destinations). The Build

View File

@@ -0,0 +1,39 @@
// DetailHeader is the top of the pane once something is selected: the way back
// out, what you are looking at, and what you can do to it.
//
// The back control is the piece the expand-row never had. Selection lives in
// the URL on all three surfaces, so leaving the detail is a real navigation
// rather than a second click on the thing you just opened.
export default function DetailHeader({
icon, name, lede, ledeTitle, actions, onBack, backLabel, warning,
testId = 'detail',
}) {
return (
<>
{onBack && (
<button type="button" className="detail-pane__back" onClick={onBack} data-testid={`${testId}-back`}>
<i className="fas fa-arrow-left" aria-hidden="true" /> {backLabel}
</button>
)}
<div className="detail-pane__head">
{icon && <i className={`fas ${icon} detail-pane__icon`} aria-hidden="true" />}
<div className="detail-pane__title">
<h2 className="detail-pane__name">{name}</h2>
{lede && (
// Capped by CSS, with the whole of it on the title so nothing is
// lost to the truncation.
<p className="detail-pane__lede" title={ledeTitle || undefined}>{lede}</p>
)}
</div>
{actions && <div className="detail-pane__actions">{actions}</div>}
</div>
{warning && (
<p className="detail-pane__warning">
<i className="fas fa-circle-exclamation" aria-hidden="true" /> {warning}
</p>
)}
</>
)
}

View File

@@ -0,0 +1,166 @@
import { useRef } from 'react'
// EntityRail is the scannable half of SplitView: one line per entity, grouped
// while browsing and flat while searching.
//
// It is deliberately data-driven rather than aware of models, backends or
// anything else. Each surface maps its own entity onto { id, name, icon, meta }
// and keeps its vocabulary to itself, which is what stops three pages from
// growing three subtly different rails.
//
// Counts describe what is loaded, never a share of some catalog total. Two of
// the three callers page server-side, so a header claiming a total would be
// inventing a number the component cannot know.
//
// items: [{ id, name, icon, meta, metaTone, stripe, groupId }]
// groups: [{ id, label, icon }] - pass null, or grouped=false, for a flat list.
// metaTone: 'ok' | 'bad' | 'warn' | 'busy'
// stripe: 'run' | 'idle' | 'err' | 'off' - a left edge for surfaces read by
// condition before they are read by name. Omit where state is not
// the point.
export default function EntityRail({
items,
groups = null,
grouped = false,
collapsedGroups,
onToggleGroup,
selectedId,
onSelect,
countLabel,
actions = null,
ariaLabel,
testId = 'entity-rail',
busy = false,
}) {
const railRef = useRef(null)
// Up/Down moves the selection so the pane can be stepped through without
// going back to the mouse.
//
// The handler sits on the list AND on every entry. Clicking an entry leaves
// focus on that <button>, and the key does not reach the container from
// there, so a container-only handler did nothing on the ordinary path:
// click a thing, then arrow to the next one.
const onKeyDown = (e) => {
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return
const ids = Array.from(railRef.current?.querySelectorAll('[data-entity]') || [])
.map(el => el.dataset.entity)
if (ids.length === 0) return
e.preventDefault()
// Whichever of the two handlers has focus must consume the key, or the
// other acts on it as well and the selection jumps two.
e.stopPropagation()
const at = ids.indexOf(selectedId)
const next = e.key === 'ArrowDown'
? (at < 0 ? 0 : Math.min(ids.length - 1, at + 1))
: (at < 0 ? ids.length - 1 : Math.max(0, at - 1))
onSelect(ids[next])
// Move focus with the selection, not just the highlight. Roving tabindex
// means the newly selected entry is the one tab stop, so leaving focus
// behind would strand the keyboard on an entry that is no longer reachable
// by Tab.
const el = railRef.current?.querySelector(`[data-entity="${CSS.escape(ids[next])}"]`)
el?.scrollIntoView({ block: 'nearest' })
el?.focus()
}
// Roving tabindex: the rail is one tab stop, and the arrows move inside it.
// Without this every entry is its own stop, so tabbing past a forty-entry
// rail to reach the pane is forty keystrokes.
const firstId = items[0]?.id
const tabbableId = items.some(i => i.id === selectedId) ? selectedId : firstId
const renderItem = (item) => (
<RailItem
key={item.id}
item={item}
selected={item.id === selectedId}
tabbable={item.id === tabbableId}
onSelect={onSelect}
onKeyDown={onKeyDown}
testId={testId}
/>
)
const useGroups = grouped && Array.isArray(groups) && groups.length > 0
return (
<div className={`entity-rail${busy ? ' entity-rail--busy' : ''}`}>
<div className="entity-rail__head">
<span className="entity-rail__count">{countLabel}</span>
{actions}
</div>
{/* A refetch dims and bars the list it is replacing rather than
unmounting the view. Unmounting took the search box with it, so the
field you were typing into vanished and focus went to the body. */}
<div className="entity-rail__progress" aria-hidden={!busy} />
{/* Not role="listbox". A listbox may only contain options and groups, and
the collapse control for each group is a button that has to live
inside the scroller with the entries it folds. Buttons in a labelled
group is the honest description of what this is, and it costs nothing:
selection is announced by aria-current and the arrow keys still work. */}
<div
className="entity-rail__list"
role="group"
aria-label={ariaLabel}
aria-busy={busy || undefined}
ref={railRef}
onKeyDown={onKeyDown}
>
{useGroups
? groups.map(group => {
const inGroup = items.filter(i => i.groupId === group.id)
if (inGroup.length === 0) return null
const open = !collapsedGroups?.has(group.id)
return (
<div className="entity-rail__group" key={group.id}>
<button
type="button"
className="entity-rail__group-head"
aria-expanded={open}
data-testid={`${testId}-group-${group.id}`}
onClick={() => onToggleGroup(group.id)}
>
<i className={`fas fa-chevron-${open ? 'down' : 'right'} entity-rail__caret`} aria-hidden="true" />
{group.icon && <i className={`fas ${group.icon} entity-rail__group-icon`} aria-hidden="true" />}
<span className="entity-rail__group-label">{group.label}</span>
<span className="entity-rail__group-count">{inGroup.length}</span>
</button>
{open && inGroup.map(renderItem)}
</div>
)
})
: items.map(renderItem)}
</div>
</div>
)
}
// One line: what it is, and its single most decision-relevant fact. Anything
// that needs a sentence belongs in the pane.
function RailItem({ item, selected, tabbable, onSelect, onKeyDown, testId }) {
return (
<button
type="button"
aria-current={selected ? 'true' : undefined}
tabIndex={tabbable ? 0 : -1}
data-entity={item.id}
data-testid={`${testId}-item`}
className={`entity-rail__item${selected ? ' entity-rail__item--on' : ''}`}
onClick={() => onSelect(item.id)}
onKeyDown={onKeyDown}
>
{item.stripe && <span className={`entity-rail__stripe entity-rail__stripe--${item.stripe}`} />}
{item.icon && <i className={`fas ${item.icon} entity-rail__icon`} aria-hidden="true" />}
<span className="entity-rail__main">
<span className="entity-rail__name">{item.name}</span>
{item.meta && (
<span className={`entity-rail__meta${item.metaTone ? ` entity-rail__meta--${item.metaTone}` : ''}`}>
{item.meta}
</span>
)}
</span>
</button>
)
}

View File

@@ -0,0 +1,21 @@
// SplitView is the shell three admin surfaces share: a rail you scan on the
// left, a pane that answers on the right.
//
// It exists because Discover, Backends and Host all had the same defect - an
// eight-column table over a click-to-expand row - and the fix is the same
// shape every time. What differs between them is what the rail lists and what
// the pane says when nothing is selected, so those are the props.
//
// `detail` is not decoration. Below the breakpoint the two columns cannot both
// survive, and a selected entity means the pane is the page, so the rail steps
// aside. That is the trade a pushed route would make, without the routing.
export default function SplitView({ rail, pane, detail = false, testId }) {
return (
<div className={`split-view${detail ? ' split-view--detail' : ''}`} data-testid={testId}>
<div className="split-view__rail-col">{rail}</div>
<div className="split-view__pane" data-testid={testId ? `${testId}-pane` : undefined}>
{pane}
</div>
</div>
)
}

View File

@@ -0,0 +1,24 @@
// StatGrid is the headline numbers at the top of a detail pane.
//
// A description list rather than a row of divs, because that is what it is:
// each cell is a term and its value, and a screen reader should be able to say
// so. Values are tabular-figured in CSS so two panes read as the same table
// even though they are not one.
//
// stats: [{ label, value, tone }] - tone is 'ok' | 'bad' | 'warn', reserved for
// the cell whose value changes what you would do next. A grid where every cell
// is coloured has no emphasis left to spend.
export default function StatGrid({ stats }) {
const shown = stats.filter(Boolean)
if (shown.length === 0) return null
return (
<dl className="stat-grid">
{shown.map(s => (
<div className="stat-grid__item" key={s.label}>
<dt>{s.label}</dt>
<dd className={s.tone ? `stat-grid__value--${s.tone}` : undefined}>{s.value}</dd>
</div>
))}
</dl>
)
}

View File

@@ -15,6 +15,12 @@ import Toggle from '../components/Toggle'
import NodeDistributionChip from '../components/NodeDistributionChip'
import NodeInstallPicker from '../components/NodeInstallPicker'
import Popover from '../components/Popover'
import SplitView from '../components/split/SplitView'
import EntityRail from '../components/split/EntityRail'
import DetailHeader from '../components/split/DetailHeader'
import StatGrid from '../components/split/StatGrid'
import { useResources } from '../hooks/useResources'
import { ENTITY_GROUPS, groupForEntity } from '../utils/entityGroups'
export default function Backends() {
const { addToast } = useOutletContext()
@@ -22,6 +28,7 @@ export default function Backends() {
const { t } = useTranslation('admin')
const [searchParams, setSearchParams] = useSearchParams()
const { operations } = useOperations()
const { resources } = useResources()
const { enabled: distributedEnabled, nodes: clusterNodes, refetch: refetchNodes } = useDistributedMode()
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState('')
@@ -34,7 +41,12 @@ export default function Backends() {
const [manualUri, setManualUri] = useState('')
const [manualName, setManualName] = useState('')
const [manualAlias, setManualAlias] = useState('')
const [expandedRow, setExpandedRow] = useState(null)
// Which backend the pane is showing, or null for the host page. In the URL
// for the same reasons as Discover: a backend is linkable, and Back leaves
// the detail rather than the page.
// True once any listing has come back. Distinguishes a cold start, which has
// nothing to keep on screen, from a refetch, which does.
const loadedOnce = useRef(false)
const [confirmDialog, setConfirmDialog] = useState(null)
const [allBackends, setAllBackends] = useState([])
const [upgrades, setUpgrades] = useState({})
@@ -44,16 +56,42 @@ export default function Backends() {
const [preferDevLoaded, setPreferDevLoaded] = useState(false)
const [pickerBackend, setPickerBackend] = useState(null)
const [pickerInitialSelection, setPickerInitialSelection] = useState([])
const [splitMenuFor, setSplitMenuFor] = useState(null)
// Anchor ref for the currently-open split-button chevron. Only one row's
// menu can be open at a time, so a single ref is enough — re-attached
// whenever splitMenuFor changes to a different row index.
const [splitMenuOpen, setSplitMenuOpen] = useState(false)
// Anchor for the split-button chevron. One pane, so one anchor.
const splitMenuAnchorRef = useRef(null)
// Target-node mode: set when navigated from /app/nodes via "+ Add backend".
// The gallery page header banners the scope; rows collapse their split-button
// to a single Install-on-this-node action; manual install posts to the
// per-node endpoint.
const selectedName = searchParams.get('backend')
// Selection is a URL edit that preserves everything else in the query, so it
// composes with the target-node scope rather than clobbering it.
const selectBackend = useCallback((name) => {
setSearchParams(prev => {
const next = new URLSearchParams(prev)
if (name) next.set('backend', name)
else next.delete('backend')
return next
}, { replace: !name })
setSplitMenuOpen(false)
}, [setSearchParams])
const selectedBackend = selectedName
? (allBackends.find(b => (b.name || b.id) === selectedName) || null)
: null
const [collapsedGroups, setCollapsedGroups] = useState(() => new Set())
const toggleGroup = useCallback((id) => {
setCollapsedGroups(prev => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}, [])
const targetNodeId = searchParams.get('target') || ''
const targetNode = targetNodeId
? clusterNodes.find(n => n.id === targetNodeId) || null
@@ -85,6 +123,7 @@ export default function Backends() {
} catch (err) {
addToast(`Failed to load backends: ${err.message}`, 'error')
} finally {
loadedOnce.current = true
setLoading(false)
}
}, [search, sortBy, sortOrder, addToast])
@@ -136,7 +175,7 @@ export default function Backends() {
})()
// Client-side pagination
const ITEMS_PER_PAGE = 21
const ITEMS_PER_PAGE = 60
const totalPages = Math.max(1, Math.ceil(filteredBackends.length / ITEMS_PER_PAGE))
const backends = filteredBackends.slice((page - 1) * ITEMS_PER_PAGE, page * ITEMS_PER_PAGE)
@@ -309,20 +348,8 @@ export default function Backends() {
{ key: 'vision', label: 'Vision', icon: 'fa-eye' },
]
const SortHeader = ({ col, children }) => (
<th
onClick={() => handleSort(col)}
className="sortable-th nowrap"
>
{children}
{sortBy === col && (
<i className={`fas fa-sort-${sortOrder === 'asc' ? 'up' : 'down'} ml-xs text-xs text-primary`} />
)}
</th>
)
return (
<div className="page page--wide">
<div className="page page--wide page--app">
{/* Target-node banner: when this gallery is scoped to one node via
?target=<id> (entered from /app/nodes), show the scope clearly and
give a fast way to clear it. Visually a primary-tinted strip so the
@@ -341,37 +368,20 @@ export default function Backends() {
)}
{/* Header */}
<PageHeader
title={t('backends.title')}
supporting={t('backends.subtitle')}
actions={
<div className="hstack hstack--md">
<div className="bk-counts">
<div style={{ textAlign: 'center' }}>
<div className="bk-count tone-primary">{filteredBackends.length}</div>
<div style={{ color: 'var(--color-text-muted)' }}>Available</div>
</div>
<div style={{ textAlign: 'center' }}>
<a onClick={() => navigate('/app/manage')} style={{ cursor: 'pointer' }}>
<div className="bk-count tone-success">{installedCount}</div>
<div style={{ color: 'var(--color-text-muted)' }}>Installed</div>
</a>
</div>
{Object.keys(upgrades).length > 0 && (
<div style={{ textAlign: 'center' }}>
<div className="bk-count tone-warning">
{Object.keys(upgrades).length}
</div>
<div style={{ color: 'var(--color-text-muted)' }}>Updates</div>
</div>
)}
</div>
<a className="btn btn-secondary btn-sm" href="https://localai.io/docs/getting-started/manual/" target="_blank" rel="noopener noreferrer">
<i className="fas fa-book" /> Docs
</a>
<div className="view-bar">
<h1 className="view-bar__title">{t('backends.title')}</h1>
<span className="view-bar__count">{backends.length} of {allBackends.length}</span>
<div className="view-bar__actions">
{Object.keys(upgrades).length > 0 && (
<button className="btn btn-primary btn-sm" onClick={handleUpgradeAll} disabled={upgradingAll}>
<i className={`fas ${upgradingAll ? 'fa-spinner fa-spin' : 'fa-arrow-up'}`} /> Upgrade all ({Object.keys(upgrades).length})
</button>
)}
<button className="btn btn-secondary btn-sm" onClick={() => setShowManualInstall(!showManualInstall)}>
<i className={`fas ${showManualInstall ? 'fa-chevron-up' : 'fa-plus'}`} /> Manual Install
</button>
</div>
}
/>
</div>
{/* Upgrade Banner */}
{Object.keys(upgrades).length > 0 && (
@@ -393,13 +403,6 @@ export default function Backends() {
</div>
)}
{/* Manual Install */}
<div style={{ marginBottom: 'var(--spacing-md)' }}>
<button className="btn btn-secondary btn-sm" onClick={() => setShowManualInstall(!showManualInstall)}>
<i className={`fas ${showManualInstall ? 'fa-chevron-up' : 'fa-plus'}`} /> Manual Install
</button>
</div>
{showManualInstall && (
<form onSubmit={handleManualInstall} className="card" style={{ marginBottom: 'var(--spacing-md)' }}>
<h3 className="text-base fw-semibold mb-sm">
@@ -426,329 +429,249 @@ export default function Backends() {
</form>
)}
{/* Search + Filters */}
<div className="hstack mb-md">
<div className="search-bar search-grow">
<i className="fas fa-search search-icon" />
<input className="input" placeholder="Search backends by name, description, or type..." value={search} onChange={(e) => handleSearch(e.target.value)} />
</div>
</div>
<div className="hstack hstack--md mb-md">
<div className="filter-bar m-0 flex-1">
{FILTERS.map(f => (
<button
key={f.key}
className={`filter-btn ${filter === f.key ? 'active' : ''}`}
onClick={() => { setFilter(f.key); setPage(1) }}
>
<i className={`fas ${f.icon}`} style={{ marginRight: 4 }} />
{f.label}
</button>
))}
</div>
<div className="bk-toggles">
<label style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-xs)', fontSize: '0.75rem', color: 'var(--color-text-secondary)', cursor: 'pointer', userSelect: 'none', whiteSpace: 'nowrap' }}>
<Toggle checked={showAllBackends} onChange={handleToggleAllBackends} />
<i className="fas fa-cubes" style={{ fontSize: '0.625rem' }} />
Show all
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-xs)', fontSize: '0.75rem', color: 'var(--color-text-secondary)', cursor: 'pointer', userSelect: 'none', whiteSpace: 'nowrap' }}>
<Toggle checked={showDevelopment} onChange={handleToggleDev} />
<i className="fas fa-flask" style={{ fontSize: '0.625rem' }} />
Development
</label>
</div>
</div>
{/* Table */}
{loading ? (
{/* The gallery, as a rail and a pane. Same shell as Discover, because it
is the same defect: a seven-column table whose expand-row was the
only place the repository, licence, tags and links could go. */}
{loading && !loadedOnce.current ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: 'var(--spacing-xl)' }}><LoadingSpinner size="lg" /></div>
) : backends.length === 0 ? (
<div className="empty-state">
<div className="empty-state-icon"><i className="fas fa-server" /></div>
<h2 className="empty-state-title">No backends found</h2>
<p className="empty-state-text">
{search || filter ? 'Try adjusting your search or filters.' : 'No backends available in the gallery.'}
</p>
</div>
) : (
<div className="table-container">
<table className="table">
<thead>
<tr>
<th style={{ width: 30 }}></th>
<th style={{ width: 40 }}></th>
<SortHeader col="name">Backend</SortHeader>
<th>Description</th>
<SortHeader col="repository">Repository</SortHeader>
<SortHeader col="license">License</SortHeader>
<SortHeader col="status">Status</SortHeader>
{distributedEnabled && !targetNode && <th>Nodes</th>}
<th style={{ textAlign: 'right' }}>Actions</th>
</tr>
</thead>
<tbody>
{backends.map((b, idx) => {
const op = getBackendOp(b)
// A failed op is intentionally kept in the operations list so the
// OperationsBar can surface the error + Dismiss; it must NOT render
// as a perpetual "Installing..." spinner here (mirrors Models.jsx).
const isProcessing = !!op && !op.error
const isExpanded = expandedRow === idx
<SplitView
testId="backends"
detail={!!selectedBackend}
rail={
<>
{/* The filters narrow the rail and nothing else, so they live with it. */}
<div className="bk-filters">
<div className="search-bar search-grow">
<i className="fas fa-search search-icon" />
<input className="input" placeholder="Search backends by name, description, or type..." value={search} onChange={(e) => handleSearch(e.target.value)} />
</div>
</div>
return (
<React.Fragment key={b.name || b.id}>
<tr
onClick={() => setExpandedRow(isExpanded ? null : idx)}
style={{ cursor: 'pointer' }}
<div className="bk-filters">
<div className="filter-bar m-0 flex-1">
{FILTERS.map(f => (
<button
key={f.key}
className={`filter-btn ${filter === f.key ? 'active' : ''}`}
onClick={() => { setFilter(f.key); setPage(1) }}
>
{/* Chevron */}
<td style={{ width: 30 }}>
<i className={`fas fa-chevron-${isExpanded ? 'down' : 'right'}`} style={{ fontSize: '0.625rem', color: 'var(--color-text-muted)', transition: 'transform 150ms' }} />
</td>
{/* Icon */}
<td>
{b.icon ? (
<img src={b.icon} alt="" className="bk-icon" />
) : (
<div className="bk-icon-fallback">
<i className="fas fa-cog" style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)' }} />
</div>
)}
</td>
<i className={`fas ${f.icon}`} style={{ marginRight: 4 }} />
{f.label}
</button>
))}
</div>
{/* Name */}
<td>
<span style={{ fontWeight: 500 }}>{b.name || b.id}</span>
{b.version && (
<span className="badge badge--tiny badge--soft ml-xs">
v{b.version}
</span>
)}
</td>
<span className="models-filters__refine-label">Refine</span>
<div className="bk-toggles">
<label style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-xs)', fontSize: '0.75rem', color: 'var(--color-text-secondary)', cursor: 'pointer', userSelect: 'none', whiteSpace: 'nowrap' }}>
<Toggle checked={showAllBackends} onChange={handleToggleAllBackends} />
<i className="fas fa-cubes" style={{ fontSize: '0.625rem' }} />
Show all
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-xs)', fontSize: '0.75rem', color: 'var(--color-text-secondary)', cursor: 'pointer', userSelect: 'none', whiteSpace: 'nowrap' }}>
<Toggle checked={showDevelopment} onChange={handleToggleDev} />
<i className="fas fa-flask" style={{ fontSize: '0.625rem' }} />
Development
</label>
</div>
</div>
<EntityRail
items={backends.map(b => railItemForBackend(b, { getBackendOp, upgrades }))}
groups={ENTITY_GROUPS.map(g => ({ id: g.id, label: BACKEND_GROUP_LABELS[g.id], icon: g.icon }))}
grouped={!search.trim()}
collapsedGroups={collapsedGroups}
onToggleGroup={toggleGroup}
busy={loading}
selectedId={selectedName}
onSelect={selectBackend}
countLabel={`${backends.length} of ${allBackends.length}`}
ariaLabel="Backends"
testId="backends-rail"
actions={
<div className="entity-rail__sort" role="group" aria-label="Sort backends">
<BackendSortButton col="name" label="Name" sortBy={sortBy} sortOrder={sortOrder} onSort={handleSort} />
<BackendSortButton col="status" label="Status" sortBy={sortBy} sortOrder={sortOrder} onSort={handleSort} />
</div>
}
/>
{/* Description */}
<td>
{(() => {
// Gallery descriptions are Markdown. This cell is a single
// truncated line, so it gets the text without the syntax;
// the full Markdown is rendered in the detail panel instead.
const desc = stripMarkdown(b.description)
return (
<span className="bk-desc" title={desc}>
{desc || '-'}
{totalPages > 1 && (
<div className="pagination split-view__pager">
<button className="pagination-btn" onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page <= 1} aria-label="Previous page">
<i className="fas fa-chevron-left" />
</button>
<span className="split-view__pager-label">{page} / {totalPages}</span>
<button className="pagination-btn" onClick={() => setPage(p => Math.min(totalPages, p + 1))} disabled={page >= totalPages} aria-label="Next page">
<i className="fas fa-chevron-right" />
</button>
</div>
)}
</>
}
pane={backends.length === 0 ? (
<div className="empty-state">
<div className="empty-state-icon"><i className="fas fa-server" /></div>
<h2 className="empty-state-title">No backends found</h2>
<p className="empty-state-text">
{search || filter ? 'Try adjusting your search or filters.' : 'No backends available in the gallery.'}
</p>
</div>
) : selectedBackend ? (() => {
const b = selectedBackend
const name = b.name || b.id
const op = getBackendOp(b)
const isProcessing = !!op && !op.error
const upgrade = upgrades[name]
return (
<div className="detail-pane">
<DetailHeader
testId="backends"
icon={groupForEntity(b).icon}
name={name}
lede={b.description ? stripMarkdown(b.description).slice(0, 220) : null}
ledeTitle={b.description ? stripMarkdown(b.description) : null}
onBack={() => selectBackend(null)}
backLabel="All backends"
actions={
isProcessing ? (
<div className="inline-install">
<div className="inline-install__row">
<div className="operation-spinner" />
<span className="inline-install__label">
{op.isDeletion ? 'Deleting...' : op.isQueued ? 'Queued' : `Installing${op.progress > 0 ? ` · ${Math.round(op.progress)}%` : '...'}`}
</span>
)
})()}
</td>
{/* Repository */}
<td>
{b.gallery ? (
<span className="badge badge-info" style={{ fontSize: '0.6875rem' }}>{typeof b.gallery === 'string' ? b.gallery : b.gallery.name || '-'}</span>
) : '-'}
</td>
{/* License */}
<td>
{b.license ? (
<span className="badge badge--soft text-xs">{b.license}</span>
) : '-'}
</td>
{/* Status — in distributed mode the Nodes column is the
installed signal, so we drop the global "Installed"
badge here and only keep operation-progress / update
signals to avoid stacking 6 badges in one cell. */}
<td>
{isProcessing ? (
<div className="inline-install">
<div className="inline-install__row">
<div className="operation-spinner" />
<span className="inline-install__label">
{op.isDeletion ? 'Deleting...' : op.isQueued ? 'Queued' : `Installing${op.progress > 0 ? ` · ${Math.round(op.progress)}%` : '...'}`}
</span>
</div>
{op.progress > 0 && (
<div className="operation-bar-container bk-progress">
<div className="operation-bar" style={{ width: `${op.progress}%` }} />
</div>
{op.progress > 0 && (
<div className="operation-bar-container bk-progress">
<div className="operation-bar" style={{ width: `${op.progress}%` }} />
</div>
)}
</div>
) : b.installed ? (
<div className="hstack hstack--xs">
{!distributedEnabled && (
<span className="badge badge-success">
<i className="fas fa-check icon-tiny" /> Installed
</span>
)}
{b.version && (
<span className="badge badge--tiny badge--soft">
v{b.version}
</span>
)}
{upgrades[b.name] && (
<span className="badge badge--tiny badge--warn-soft">
<i className="fas fa-arrow-up icon-tiny" />
{upgrades[b.name].available_version ? `v${upgrades[b.name].available_version}` : 'Update'}
</span>
)}
</div>
) : (
<span className="badge" style={{ background: 'var(--color-surface-sunken)', color: 'var(--color-text-muted)', border: '1px solid var(--color-border-default)' }}>
<i className="fas fa-circle icon-tiny" /> Not Installed
</span>
)}
</td>
{/* Nodes column (distributed mode only, hidden in target
mode since it's redundant with the banner). The chip
is read-only inspection; the adjacent + button is the
write affordance — keeping them visually separate so
users don't accidentally trigger the picker by clicking
to read distribution. */}
{distributedEnabled && !targetNode && (
<td>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-xs)' }}>
<NodeDistributionChip nodes={b.nodes || []} />
{(() => {
const missing = missingNodesFor(b)
if (missing.length === 0 || isProcessing) return null
return (
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={(e) => { e.stopPropagation(); openPicker(b, missing) }}
title={`Install on ${missing.length} more node${missing.length === 1 ? '' : 's'}`}
aria-label="Install on more nodes"
className="pill-xs"
>
<i className="fas fa-plus" style={{ fontSize: '0.6875rem' }} />
</button>
)
})()}
</div>
</td>
)}
{/* Actions */}
<td>
<div style={{ display: 'flex', gap: 'var(--spacing-xs)', justifyContent: 'flex-end' }} onClick={e => e.stopPropagation()}>
{targetNode ? (
// Target-node mode: collapse to a single per-node
// action. The split-button is overkill when scope is
// already pinned by the URL.
(b.nodes || []).some(n => (n.node_id ?? n.NodeID) === targetNode.id) ? (
<>
<button className="btn btn-secondary btn-sm" onClick={() => handleInstallOnTarget(b.name || b.id)} disabled={isProcessing}
title={`Reinstall on ${targetNode.name}`}>
<i className={`fas ${isProcessing ? 'fa-spinner fa-spin' : 'fa-rotate'}`} /> Reinstall
</button>
<button className="btn btn-danger btn-sm" onClick={async () => {
try {
await nodesApi.deleteBackend(targetNode.id, b.name || b.id)
addToast(`Removed ${b.name} from ${targetNode.name}`, 'success')
setTimeout(() => { fetchBackends(); refetchNodes() }, 600)
} catch (err) {
addToast(`Remove failed: ${err.message}`, 'error')
}
}} title={`Remove from ${targetNode.name}`} disabled={isProcessing}>
<i className="fas fa-trash" />
</button>
</>
) : (
<button className="btn btn-primary btn-sm" onClick={() => handleInstallOnTarget(b.name || b.id)} disabled={isProcessing}>
<i className={`fas ${isProcessing ? 'fa-spinner fa-spin' : 'fa-download'}`} /> Install on {targetNode.name}
</button>
)
) : b.installed ? (
<>
{upgrades[b.name] ? (
<button className="btn btn-primary btn-sm" onClick={() => handleUpgrade(b.name || b.id)} title={`Upgrade to ${upgrades[b.name]?.available_version ? 'v' + upgrades[b.name].available_version : 'latest'}`} disabled={isProcessing}>
<i className={`fas ${isProcessing ? 'fa-spinner fa-spin' : 'fa-arrow-up'}`} />
</button>
) : (
<button className="btn btn-secondary btn-sm" onClick={() => handleInstall(b.name || b.id)} title="Reinstall" disabled={isProcessing}>
<i className={`fas ${isProcessing ? 'fa-spinner fa-spin' : 'fa-rotate'}`} />
</button>
)}
<button className="btn btn-danger btn-sm" onClick={() => handleDelete(b.name || b.id)} title="Delete" disabled={isProcessing}>
<i className="fas fa-trash" />
</button>
</>
) : distributedEnabled ? (
// Split-button. Auto-resolving (meta) keeps fan-out
// as the primary; hardware-specific routes the
// primary directly to the picker — fan-out for a
// CPU build is the silent footgun this guard exists
// to prevent. Both share a chevron menu for the
// alternate path.
b.isMeta ? (
<div className="inline-flex">
<button className="btn btn-primary btn-sm" onClick={() => handleInstall(b.name || b.id)} disabled={isProcessing} title="Install on all nodes" style={{ borderTopRightRadius: 0, borderBottomRightRadius: 0 }}>
<i className={`fas ${isProcessing ? 'fa-spinner fa-spin' : 'fa-download'}`} /> Install on all
</button>
<button
ref={splitMenuFor === idx ? splitMenuAnchorRef : undefined}
className="btn btn-primary btn-sm bk-split-btn"
onClick={() => setSplitMenuFor(splitMenuFor === idx ? null : idx)}
aria-haspopup="menu"
aria-expanded={splitMenuFor === idx}
aria-label="More install options"
disabled={isProcessing}
>
<i className={`fas fa-chevron-${splitMenuFor === idx ? 'up' : 'down'}`} style={{ fontSize: '0.6875rem' }} />
</button>
</div>
) : (
<button
className="btn btn-primary btn-sm"
onClick={() => openPicker(b)}
disabled={isProcessing}
title="Choose nodes to install on"
>
<i className={`fas ${isProcessing ? 'fa-spinner fa-spin' : 'fa-server'}`} /> Choose nodes
</button>
)
) : (
<button className="btn btn-primary btn-sm" onClick={() => handleInstall(b.name || b.id)} title="Install" disabled={isProcessing}>
<i className={`fas ${isProcessing ? 'fa-spinner fa-spin' : 'fa-download'}`} />
</button>
)}
</div>
</td>
</tr>
{/* Expanded detail row */}
{isExpanded && (
<tr>
<td colSpan={distributedEnabled && !targetNode ? 9 : 8} style={{ padding: 0 }}>
<BackendDetail backend={b} />
</td>
</tr>
)}
</React.Fragment>
)
})}
</tbody>
</table>
</div>
)}
) : targetNode ? (
// Target-node mode: one per-node action. The split button
// is overkill when the URL has already pinned the scope.
(b.nodes || []).some(n => (n.node_id ?? n.NodeID) === targetNode.id) ? (
<>
<button className="btn btn-secondary btn-sm" onClick={() => handleInstallOnTarget(name)} title={`Reinstall on ${targetNode.name}`}>
<i className="fas fa-rotate" /> Reinstall
</button>
<button className="btn btn-danger btn-sm" onClick={async () => {
try {
await nodesApi.deleteBackend(targetNode.id, name)
addToast(`Removed ${b.name} from ${targetNode.name}`, 'success')
setTimeout(() => { fetchBackends(); refetchNodes() }, 600)
} catch (err) {
addToast(`Remove failed: ${err.message}`, 'error')
}
}} title={`Remove from ${targetNode.name}`}>
<i className="fas fa-trash" /> Remove
</button>
</>
) : (
<button className="btn btn-primary btn-sm" onClick={() => handleInstallOnTarget(name)} data-testid="backends-install">
<i className="fas fa-download" /> Install on {targetNode.name}
</button>
)
) : b.installed ? (
<>
{upgrade ? (
<button className="btn btn-primary btn-sm" onClick={() => handleUpgrade(name)} title={`Upgrade to ${upgrade.available_version ? 'v' + upgrade.available_version : 'latest'}`}>
<i className="fas fa-arrow-up" /> Upgrade
</button>
) : (
<button className="btn btn-secondary btn-sm" onClick={() => handleInstall(name)} title="Reinstall">
<i className="fas fa-rotate" /> Reinstall
</button>
)}
<button className="btn btn-danger btn-sm" onClick={() => handleDelete(name)} title="Delete">
<i className="fas fa-trash" /> Delete
</button>
</>
) : distributedEnabled ? (
// Auto-resolving (meta) entries keep fan-out as the
// primary; a hardware-specific build routes straight to
// the picker, because fanning a CPU build out to every
// node is the silent footgun this guard exists to stop.
b.isMeta ? (
<div className="inline-flex">
<button className="btn btn-primary btn-sm" onClick={() => handleInstall(name)} title="Install on all nodes" style={{ borderTopRightRadius: 0, borderBottomRightRadius: 0 }} data-testid="backends-install">
<i className="fas fa-download" /> Install on all
</button>
<button
ref={splitMenuAnchorRef}
className="btn btn-primary btn-sm bk-split-btn"
onClick={() => setSplitMenuOpen(v => !v)}
aria-haspopup="menu"
aria-expanded={splitMenuOpen}
aria-label="More install options"
>
<i className={`fas fa-chevron-${splitMenuOpen ? 'up' : 'down'}`} style={{ fontSize: '0.6875rem' }} />
</button>
</div>
) : (
<button className="btn btn-primary btn-sm" onClick={() => openPicker(b)} title="Choose nodes to install on" data-testid="backends-install">
<i className="fas fa-server" /> Choose nodes
</button>
)
) : (
<button className="btn btn-primary btn-sm" onClick={() => handleInstall(name)} title="Install" data-testid="backends-install">
<i className="fas fa-download" /> Install
</button>
)
}
/>
{/* Pagination */}
{totalPages > 1 && (
<div className="pagination-row mt-md">
<button className="btn btn-secondary btn-sm" onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page <= 1}>
<i className="fas fa-chevron-left" /> Previous
</button>
<span style={{ fontSize: '0.8125rem', color: 'var(--color-text-secondary)' }}>
Page {page} of {totalPages}
</span>
<button className="btn btn-secondary btn-sm" onClick={() => setPage(p => Math.min(totalPages, p + 1))} disabled={page >= totalPages}>
Next <i className="fas fa-chevron-right" />
</button>
</div>
<StatGrid
stats={[
{ label: 'Installed', value: b.installed ? (b.version ? `v${b.version}` : 'yes') : 'no', tone: b.installed ? 'ok' : undefined },
upgrade ? { label: 'Available', value: upgrade.available_version ? `v${upgrade.available_version}` : 'update', tone: 'warn' } : null,
{ label: 'License', value: b.license || '—' },
{ label: 'Repository', value: b.gallery ? (typeof b.gallery === 'string' ? b.gallery : b.gallery.name || '—') : '—' },
]}
/>
{/* Distribution is the one fact the pane can state that a row
never could: which nodes hold a copy, and which do not. */}
{distributedEnabled && !targetNode && (
<div>
<span className="detail-pane__label">Installed on</span>
<div className="hstack hstack--xs">
<NodeDistributionChip nodes={b.nodes || []} />
{(() => {
const missing = missingNodesFor(b)
if (missing.length === 0 || isProcessing) return null
return (
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={() => openPicker(b, missing)}
aria-label="Install on more nodes"
>
<i className="fas fa-plus" style={{ fontSize: '0.6875rem' }} /> {missing.length} more
</button>
)
})()}
</div>
</div>
)}
<BackendDetail backend={b} />
</div>
)
})() : (
<BackendHostPane
resources={resources}
backends={allBackends}
installedCount={installedCount}
upgrades={upgrades}
onSelect={selectBackend}
onUpgradeAll={handleUpgradeAll}
upgradingAll={upgradingAll}
/>
)}
/>
)}
<ConfirmDialog
@@ -761,14 +684,14 @@ export default function Backends() {
onCancel={() => setConfirmDialog(null)}
/>
{/* Single popover instance for the split-button menu, anchored to
whichever row's chevron is currently active. Reusing the existing
Popover gives us .card surface + outside-click + Escape + focus
return for free. */}
{/* The split-button menu, anchored to the pane's own chevron. It used to
be re-anchored per row; there is one selected backend now, so there is
one anchor. Popover still gives us the card surface, outside-click,
Escape and focus return. */}
<Popover
anchor={splitMenuAnchorRef}
open={splitMenuFor !== null}
onClose={() => setSplitMenuFor(null)}
open={splitMenuOpen}
onClose={() => setSplitMenuOpen(false)}
ariaLabel="Install options"
>
<div className="action-menu">
@@ -776,8 +699,8 @@ export default function Backends() {
type="button"
className="action-menu__item"
onClick={() => {
const b = backends[splitMenuFor]
if (b) openPicker(b)
setSplitMenuOpen(false)
if (selectedBackend) openPicker(selectedBackend)
}}
>
<i className="fas fa-server action-menu__icon" />
@@ -861,3 +784,131 @@ function BackendDetail({ backend }) {
</div>
)
}
// The rail line spends its one fact on the thing that decides what you do
// next: whether it is here, whether it is stale, whether it is moving.
function railItemForBackend(backend, { getBackendOp, upgrades }) {
const name = backend.name || backend.id
const op = getBackendOp(backend)
const processing = !!op && !op.error
const upgrade = upgrades[name]
let meta = backend.version ? `v${backend.version}` : 'not installed'
let metaTone
if (processing) {
meta = op.isDeletion ? 'deleting' : op.isQueued ? 'queued' : `installing${op.progress > 0 ? ` ${Math.round(op.progress)}%` : ''}`
metaTone = 'busy'
} else if (upgrade) {
meta = upgrade.available_version ? `v${backend.version} → v${upgrade.available_version}` : 'update available'
metaTone = 'warn'
} else if (backend.installed) {
meta = backend.version ? `v${backend.version} · installed` : 'installed'
metaTone = 'ok'
}
return { id: name, name, icon: groupForEntity(backend).icon, meta, metaTone, groupId: groupForEntity(backend).id }
}
function BackendSortButton({ col, label, sortBy, sortOrder, onSort }) {
const active = sortBy === col
return (
<button
type="button"
className={`entity-rail__sort-btn${active ? ' active' : ''}`}
aria-pressed={active}
onClick={() => onSort(col)}
>
{label}
{active && <i className={`fas fa-arrow-${sortOrder === 'asc' ? 'up' : 'down'}`} aria-hidden="true" />}
</button>
)
}
// BackendHostPane is the pane with nothing selected.
//
// A backend's fitness is not free memory, it is the accelerator and platform it
// was built for, so this leads with what the host actually is. That is the
// question the table never answered: it listed 37 runtimes and left "which of
// these can even run here" entirely to the reader.
function BackendHostPane({ resources, backends, installedCount, upgrades, onSelect, onUpgradeAll, upgradingAll }) {
const gpu = resources?.gpus?.[0]
const accelerator = gpu ? `${gpu.name}${gpu.vendor ? ` (${gpu.vendor})` : ''}` : null
const staleNames = Object.keys(upgrades)
// Not installed, and near the top of whatever order the gallery returned,
// which is the gallery's own notion of prominence rather than one invented
// here. Anything cleverer needs a ranking rule somebody owns.
const suggestions = backends.filter(b => !b.installed).slice(0, 3)
return (
<div className="zero-pane">
<div className="zero-pane__hero">
<span className="zero-pane__eyebrow">This host</span>
<h2 className="zero-pane__title">
{accelerator
? `${accelerator}. ${backends.length} backends in the gallery, ${installedCount} installed.`
: `${backends.length} backends in the gallery, ${installedCount} installed.`}
</h2>
<p className="zero-pane__text">
A backend is a runtime, so what decides it is your accelerator and platform rather than free memory.
Pick one on the left for its builds, licence and repository.
</p>
</div>
{staleNames.length > 0 && (
<div className="zero-pane__alert zero-pane__alert--warn">
<i className="fas fa-arrow-up" aria-hidden="true" />
<span>
{staleNames.length === 1
? '1 installed backend has a newer build.'
: `${staleNames.length} installed backends have a newer build.`}
{' '}{staleNames.slice(0, 3).join(', ')}{staleNames.length > 3 ? '…' : ''}
</span>
<button className="btn btn-secondary btn-sm" onClick={onUpgradeAll} disabled={upgradingAll}>
<i className={`fas ${upgradingAll ? 'fa-spinner fa-spin' : 'fa-arrow-up'}`} /> Upgrade all
</button>
</div>
)}
{suggestions.length > 0 && (
<div className="zero-pane__shelf">
<div className="zero-pane__shelf-head">
<h3 className="zero-pane__shelf-title">Not installed yet</h3>
<span className="zero-pane__shelf-meta">{backends.length - installedCount} available</span>
</div>
<div className="zero-pane__tiles">
{suggestions.map((b, i) => {
const name = b.name || b.id
return (
<button
type="button"
key={name}
className={`zero-pane__tile${i === 0 ? ' zero-pane__tile--feat' : ''}`}
onClick={() => onSelect(name)}
>
<span className="hstack hstack--xs">
<i className={`fas ${groupForEntity(b).icon}`} aria-hidden="true" />
<span className="zero-pane__tile-name">{name}</span>
</span>
<span className="text-sm text-muted">{stripMarkdown(b.description).slice(0, 90) || '—'}</span>
<span className="zero-pane__tile-foot">
<span className="badge badge--tiny badge--soft">{b.license || 'no licence'}</span>
</span>
</button>
)
})}
</div>
</div>
)}
</div>
)
}
// Backends is not translated (6 t() calls in the whole page), so the shared
// group ids get literal labels here rather than i18n keys.
const BACKEND_GROUP_LABELS = {
text: 'Text and reasoning',
vision: 'Vision',
audio: 'Speech and audio',
visual: 'Image and video',
other: 'Everything else',
}

View File

@@ -287,6 +287,24 @@ function UserMessageContent({ content, files }) {
)
}
function editableMessageText(message) {
if (typeof message.content === 'string') return message.content
if (!Array.isArray(message.content)) return null
const textBlock = message.content.find(block => block?.type === 'text')
return typeof textBlock?.text === 'string' ? textBlock.text : null
}
function withEditedMessageText(message, text) {
if (typeof message.content === 'string') return { ...message, content: text }
const textIndex = message.content.findIndex(block => block?.type === 'text')
return {
...message,
content: message.content.map((block, index) =>
index === textIndex ? { ...block, text } : block
),
}
}
export default function Chat() {
const { model: urlModel } = useParams()
const { addToast } = useOutletContext()
@@ -329,6 +347,8 @@ export default function Chat() {
const [clientMCPServers, setClientMCPServers] = useState(() => loadClientMCPServers())
const [confirmDialog, setConfirmDialog] = useState(null)
const [completionGlowIdx, setCompletionGlowIdx] = useState(-1)
const [editingMessageIndex, setEditingMessageIndex] = useState(null)
const [messageEditDraft, setMessageEditDraft] = useState('')
const prevStreamingRef = useRef(false)
const {
connect: mcpConnect, disconnect: mcpDisconnect, disconnectAll: mcpDisconnectAll,
@@ -545,6 +565,33 @@ export default function Chat() {
updateChatSettings(activeChat.id, { clientMCPServers: next })
}, [activeChat, updateChatSettings])
const startMessageEdit = useCallback((index, message) => {
const text = editableMessageText(message)
if (text === null) return
setEditingMessageIndex(index)
setMessageEditDraft(text)
}, [])
const cancelMessageEdit = useCallback(() => {
setEditingMessageIndex(null)
setMessageEditDraft('')
}, [])
const saveMessageEdit = useCallback(() => {
if (!activeChat || isStreaming || editingMessageIndex === null || !messageEditDraft.trim()) return
const message = activeChat.history[editingMessageIndex]
if (!message || editableMessageText(message) === null) return
const history = activeChat.history.map((item, index) =>
index === editingMessageIndex ? withEditedMessageText(item, messageEditDraft) : item
)
updateChatSettings(activeChat.id, { history })
cancelMessageEdit()
}, [activeChat, isStreaming, editingMessageIndex, messageEditDraft, updateChatSettings, cancelMessageEdit])
useEffect(() => {
cancelMessageEdit()
}, [activeChat?.id, isStreaming, cancelMessageEdit])
// Load initial message from home page
const homeDataProcessed = useRef(false)
useEffect(() => {
@@ -1170,40 +1217,80 @@ export default function Chat() {
{msg.role === 'assistant' && activeChat.model && (
<span className="chat-message-model">{activeChat.model}</span>
)}
<div className="chat-message-content">
{msg.role === 'user' ? (
<UserMessageContent content={msg.content} files={msg.files} />
) : (
<div dangerouslySetInnerHTML={{
__html: canvasMode
? renderMarkdownWithArtifacts(typeof msg.content === 'string' ? msg.content : '', i)
: renderMarkdown(typeof msg.content === 'string' ? msg.content : '')
}} />
)}
</div>
{editingMessageIndex === i ? (
<div className="chat-message-edit">
<textarea
autoFocus
className="chat-message-edit-input"
value={messageEditDraft}
onChange={(event) => setMessageEditDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Escape') cancelMessageEdit()
}}
aria-label={t('actions.editMessage')}
/>
<div className="chat-message-edit-actions">
<button
type="button"
className="btn btn-primary btn-sm"
onClick={saveMessageEdit}
disabled={!messageEditDraft.trim()}
>
{t('actions.save')}
</button>
<button
type="button"
className="btn btn-secondary btn-sm"
onClick={cancelMessageEdit}
>
{t('actions.cancel')}
</button>
</div>
</div>
) : (
<div className="chat-message-content">
{msg.role === 'user' ? (
<UserMessageContent content={msg.content} files={msg.files} />
) : (
<div dangerouslySetInnerHTML={{
__html: canvasMode
? renderMarkdownWithArtifacts(typeof msg.content === 'string' ? msg.content : '', i)
: renderMarkdown(typeof msg.content === 'string' ? msg.content : '')
}} />
)}
</div>
)}
{msg.role === 'assistant' && typeof msg.content === 'string' && msg.content.includes('Error:') && (
<a href="/app/traces?tab=backend" className="chat-error-trace-link">
<i className="fas fa-wave-square" /> {t('errors.viewTraces')}
</a>
)}
<div className="chat-message-actions">
<button onClick={() => copyMessage(msg.content)} title={t('actions.copy')}>
<i className="fas fa-copy" />
</button>
{msg.role === 'assistant' && !isStreaming && (
<button onClick={() => handleRegenerate(i)} title={t('actions.regenerate')}>
<i className="fas fa-rotate" />
{editingMessageIndex !== i && (
<div className="chat-message-actions">
<button onClick={() => copyMessage(msg.content)} title={t('actions.copy')}>
<i className="fas fa-copy" />
</button>
)}
{msg.role === 'assistant' && !isStreaming && (
<button
onClick={() => { forkChat(activeChat.id, i + 1); addToast(t('toasts.forked'), 'success', 2000) }}
title={t('actions.branch')}
>
<i className="fas fa-code-branch" />
</button>
)}
</div>
{(msg.role === 'user' || msg.role === 'assistant') &&
editableMessageText(msg) !== null && !isStreaming && (
<button onClick={() => startMessageEdit(i, msg)} title={t('actions.edit')}>
<i className="fas fa-pen" />
</button>
)}
{msg.role === 'assistant' && !isStreaming && (
<button onClick={() => handleRegenerate(i)} title={t('actions.regenerate')}>
<i className="fas fa-rotate" />
</button>
)}
{msg.role === 'assistant' && !isStreaming && (
<button
onClick={() => { forkChat(activeChat.id, i + 1); addToast(t('toasts.forked'), 'success', 2000) }}
title={t('actions.branch')}
>
<i className="fas fa-code-branch" />
</button>
)}
</div>
)}
</div>
</div>
)

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from 'react'
import { useState, useEffect, useCallback, useRef } from 'react'
import { useNavigate, useOutletContext, useSearchParams, useLocation } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { fromState } from '../utils/editorNav'
@@ -11,8 +11,10 @@ import GalleryLoader from '../components/GalleryLoader'
import ManageSummary from '../components/ManageSummary'
import MetaBadgeRow from '../components/MetaBadgeRow'
import ActionMenu from '../components/ActionMenu'
import ResourceRow, { ChevronCell, IconCell, StopPropagationCell } from '../components/ResourceRow'
import ResponsiveTable from '../components/ResponsiveTable'
import SplitView from '../components/split/SplitView'
import EntityRail from '../components/split/EntityRail'
import DetailHeader from '../components/split/DetailHeader'
import StatGrid from '../components/split/StatGrid'
import { useModels } from '../hooks/useModels'
import { useGalleryEnrichment } from '../hooks/useGalleryEnrichment'
import { useOperations } from '../hooks/useOperations'
@@ -58,7 +60,6 @@ const USE_CASES = [
// Number of columns the expandable detail row spans, per tab. Kept as
// constants so adding/removing a column doesn't silently break the colSpan.
const MODELS_COLSPAN = 7 // chevron, icon, name, status, backend, use cases, actions
// formatInstalledAt renders an installed_at timestamp as a short relative/abs
// string suitable for dense tables. Returns the raw value if parsing fails so
@@ -124,11 +125,6 @@ function formatBackendVersion(metadata) {
// Gallery descriptions are Markdown. The row preview is a single truncated
// line, so it shows the text without the syntax; the full Markdown is rendered
// in the expanded detail panel instead.
function ResourceRowDesc({ description }) {
const text = stripMarkdown(description)
if (!text) return null
return <span className="resource-row__desc" title={text}>{text}</span>
}
export default function Manage() {
const { addToast } = useOutletContext()
@@ -148,6 +144,9 @@ export default function Manage() {
const [aliasTargets, setAliasTargets] = useState({})
const [backends, setBackends] = useState([])
const [backendsLoading, setBackendsLoading] = useState(true)
// See Models.jsx: a cold start has nothing to keep, a refetch does.
const modelsLoadedOnce = useRef(false)
const backendsLoadedOnce = useRef(false)
const [reloading, setReloading] = useState(false)
const [reinstallingBackends, setReinstallingBackends] = useState(new Set())
const [upgrades, setUpgrades] = useState({})
@@ -156,9 +155,12 @@ export default function Manage() {
const [togglingModels, setTogglingModels] = useState(new Set())
const [pinningModels, setPinningModels] = useState(new Set())
const [loadingModels, setLoadingModels] = useState(new Set())
// Expanded row state — keyed by `${tab}:${id}` so switching tabs doesn't
// collide and a single row is open at a time per tab.
const [expandedKey, setExpandedKey] = useState(null)
// Which entity the pane is showing, or null for the status page. The tab
// already disambiguates models from backends, so the id alone is enough.
// In the URL for the same reasons as the two galleries: a model is linkable
// and Back leaves the detail rather than the page.
const selectedId = searchParams.get('sel')
const [collapsedGroups, setCollapsedGroups] = useState(() => new Set())
// Filter state per tab. Persisted in the URL query so switching tabs
// doesn't lose the filter the operator just set.
const [modelsSearch, setModelsSearch] = useState(() => searchParams.get('mq') || '')
@@ -197,7 +199,7 @@ export default function Manage() {
const handleTabChange = (tab) => {
setActiveTab(tab)
setExpandedKey(null)
selectEntity(null)
localStorage.setItem('manage-tab', tab)
setSearchParams({ tab })
}
@@ -206,7 +208,7 @@ export default function Manage() {
// double as shortcuts to a filtered slice instead of being purely visual.
const handleSummaryClick = (tab, filter) => {
setActiveTab(tab)
setExpandedKey(null)
selectEntity(null)
localStorage.setItem('manage-tab', tab)
if (tab === 'models') setModelsFilter(filter)
if (tab === 'backends') setBackendsFilter(filter)
@@ -215,10 +217,23 @@ export default function Manage() {
setSearchParams(p, { replace: true })
}
const toggleExpanded = (tab, id) => {
const key = `${tab}:${id}`
setExpandedKey(prev => (prev === key ? null : key))
}
const selectEntity = useCallback((id) => {
setSearchParams(prev => {
const next = new URLSearchParams(prev)
if (id) next.set('sel', id)
else next.delete('sel')
return next
}, { replace: !id })
}, [setSearchParams])
const toggleGroup = useCallback((id) => {
setCollapsedGroups(prev => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}, [])
const fetchLoadedModels = useCallback(async () => {
try {
@@ -238,6 +253,7 @@ export default function Manage() {
} catch {
setBackends([])
} finally {
backendsLoadedOnce.current = true
setBackendsLoading(false)
}
}, [])
@@ -489,14 +505,39 @@ export default function Manage() {
}
// Counts for the summary header — derived in-memory; no extra API calls.
useEffect(() => {
if (!modelsLoading) modelsLoadedOnce.current = true
}, [modelsLoading])
const runningCount = models.filter(m =>
!m.disabled && (loadedModelIds.has(m.id) || (Array.isArray(m.loaded_on) && m.loaded_on.length > 0))
).length
const updatesCount = Object.keys(upgrades).length
// A backend is mid-flight if an operation names it, or if a reinstall was
// just fired from this page and the operation has not landed yet.
const isBackendProcessing = useCallback((backend) => {
const name = backend?.Name
if (!name) return false
if (reinstallingBackends.has(name)) return true
return operations.some(op => op.name === name && !op.completed && !op.error)
}, [reinstallingBackends, operations])
const selectedModel = selectedId && activeTab === 'models'
? (models.find(m => m.id === selectedId) || null)
: null
const selectedBackend = selectedId && activeTab === 'backends'
? (backends.find(b => b.Name === selectedId) || null)
: null
return (
<div className="page page--wide">
<PageHeader title={t('manage.title')} supporting={t('manage.subtitle')} />
<div className="page page--wide page--app">
<div className="view-bar">
<h1 className="view-bar__title">{t('manage.title')}</h1>
<span className="view-bar__count">
{modelsLoading ? '—' : models.length} models · {backendsLoading ? '—' : backends.length} backends
</span>
</div>
{/* Resource Monitor */}
<ResourceMonitor />
@@ -566,29 +607,36 @@ export default function Manage() {
onFilterChange={setModelsFilter}
rightSlot={(
<>
{/* A status line, not a control. It had picked up btn classes and
two copies of `fas`, so it rendered as a button you cannot
press next to a button that looked like text. */}
{distributedMode && (
<span className={`cell-muted fas fa-rotate btn btn-secondary btn-sm fas ${reloading ? 'fa-spinner fa-spin' : 'fa-rotate'}`} title="Auto-refreshes every 10s in distributed mode so ghost models clear promptly">
<i /> Last synced {lastSyncedAgo}
<span
className="cell-muted text-xs nowrap"
title="Auto-refreshes every 10s in distributed mode so ghost models clear promptly"
>
<i className={`fas ${reloading ? 'fa-spinner fa-spin' : 'fa-rotate'} icon-before`} aria-hidden="true" />
Last synced {lastSyncedAgo}
</span>
)}
<button onClick={handleReload} disabled={reloading}>
<i />
{reloading ? ' Updating...' : ' Update'}
<button className="btn btn-secondary btn-sm" onClick={handleReload} disabled={reloading}>
<i className={`fas ${reloading ? 'fa-spinner fa-spin' : 'fa-rotate'}`} aria-hidden="true" />
{reloading ? 'Updating' : 'Update'}
</button>
</>
)}
/>
{modelsLoading ? (
{modelsLoading && !modelsLoadedOnce.current ? (
<GalleryLoader />
) : models.length === 0 ? (
<div className="card loading-center text-center">
<i className="fas fa-exclamation-triangle" style={{ fontSize: '2rem', color: 'var(--color-warning)', marginBottom: 'var(--spacing-md)' }} />
<h3 className="mb-sm">No models installed yet</h3>
<p className="text-base text-secondary mb-md">
Install a model from the gallery to get started.
<div className="empty-state empty-state--page">
<div className="empty-state-icon"><i className="fas fa-brain" /></div>
<h2 className="empty-state-title">No models installed yet</h2>
<p className="empty-state-text">
Install a model from the gallery to get started, or import one you already have on disk.
</p>
<div className="hstack hstack--center">
<div className="empty-state__actions">
<button className="btn btn-primary btn-sm" onClick={() => navigate('/app/models')}>
<i className="fas fa-store" /> Browse Gallery
</button>
@@ -607,140 +655,149 @@ export default function Manage() {
<button className="btn btn-ghost btn-sm" onClick={() => { setModelsSearch(''); setModelsFilter('all') }}>Clear filters</button>
</div>
) : (
<ResponsiveTable>
<thead>
<tr>
<th className="col-w-30"></th>
<th className="col-w-64"></th>
<th>Model</th>
<th>Status</th>
<th>Backend</th>
<th>Use cases</th>
<th className="col-w-40"></th>
</tr>
</thead>
<tbody>
{visibleModels.map(model => {
const enriched = enrichModel(model.id)
const isExpanded = expandedKey === `models:${model.id}`
const isRunning = loadedModelIds.has(model.id) || (Array.isArray(model.loaded_on) && model.loaded_on.length > 0)
const caps = Array.isArray(model.capabilities) ? model.capabilities : []
const matchedCaps = USE_CASES.filter(uc => caps.includes(uc.cap) && !(uc.hideIf && caps.includes(uc.hideIf)))
return (
<ResourceRow
key={model.id}
expanded={isExpanded}
onToggleExpand={() => toggleExpanded('models', model.id)}
colSpan={MODELS_COLSPAN}
dimmed={!!model.disabled}
detail={(
<ModelDetail
model={model}
enriched={enriched}
matchedCaps={matchedCaps}
distributedMode={distributedMode}
onNavigate={navigate}
/>
)}
>
<ChevronCell expanded={isExpanded} />
<IconCell icon={enriched?.icon} fallback="fa-brain" alt="" />
<td>
<div style={{ display: 'flex', flexDirection: 'column', minWidth: 0 }}>
<span style={{ fontWeight: 600, fontSize: 'var(--text-sm)' }}>{model.id}</span>
{enriched?.description && (
<ResourceRowDesc description={enriched.description} />
)}
</div>
</td>
<td>
<div className="cell-stack">
{model.disabled ? (
<span className="badge chip-neutral">
<i className="fas fa-ban" /> Disabled
</span>
) : Array.isArray(model.loaded_on) && model.loaded_on.length > 0 ? (
<NodeDistributionChip nodes={model.loaded_on} context="models" />
) : loadedModelIds.has(model.id) ? (
<span className="badge badge-success">
<i className="fas fa-circle" style={{ fontSize: '6px' }} /> Running
</span>
) : (
<span className="badge chip-neutral">
<i className="fas fa-circle" style={{ fontSize: '6px' }} /> Idle
</span>
)}
{model.source === 'registry-only' && (
<span className="badge badge-warning" title="Discovered on a worker but not configured locally. Persist the config to make it permanent.">
<i className="fas fa-ghost" /> Adopted
</span>
)}
{model.pinned && (
<span className="badge badge-warning" title="Pinned — won't be idle-unloaded">
<i className="fas fa-thumbtack" /> Pinned
</span>
)}
{aliasTargets[model.id] && (
<span className="badge badge-info" title={`Alias -> ${aliasTargets[model.id]}`}>
<i className="fas fa-arrow-right-arrow-left" /> alias -&gt; {aliasTargets[model.id]}
</span>
)}
</div>
</td>
<td>
<span className="badge badge-info">{model.backend || 'Auto'}</span>
</td>
<td>
<div className="badge-row">
{matchedCaps.length === 0 ? (
<span className="cell-muted"></span>
) : matchedCaps.map(uc => uc.route ? (
<a
key={uc.cap}
href="#"
onClick={(e) => { e.preventDefault(); e.stopPropagation(); navigate(uc.route(model.id)) }}
className="badge badge-info badge-link"
>{uc.label}</a>
) : (
<span key={uc.cap} className="badge">{uc.label}</span>
))}
</div>
</td>
<StopPropagationCell className="text-right">
<SplitView
testId="host"
detail={!!selectedModel}
rail={
<EntityRail
items={visibleModels.map(m => railItemForManagedModel(m, { loadedModelIds, enrichModel, loadingModels }))}
groups={MODEL_STATE_GROUPS}
grouped={!modelsSearch.trim()}
collapsedGroups={collapsedGroups}
onToggleGroup={toggleGroup}
busy={modelsLoading}
selectedId={selectedId}
onSelect={selectEntity}
countLabel={`${visibleModels.length} of ${models.length}`}
ariaLabel="Installed models"
testId="host-rail"
/>
}
pane={selectedModel ? (() => {
const enriched = enrichModel(selectedModel.id)
const caps = Array.isArray(selectedModel.capabilities) ? selectedModel.capabilities : []
const matchedCaps = USE_CASES.filter(uc => caps.includes(uc.cap) && !(uc.hideIf && caps.includes(uc.hideIf)))
const isRunning = loadedModelIds.has(selectedModel.id) || (Array.isArray(selectedModel.loaded_on) && selectedModel.loaded_on.length > 0)
return (
<div className="detail-pane">
<DetailHeader
testId="host"
icon="fa-brain"
name={selectedModel.id}
lede={enriched?.description ? stripMarkdown(enriched.description).slice(0, 220) : null}
ledeTitle={enriched?.description ? stripMarkdown(enriched.description) : null}
onBack={() => selectEntity(null)}
backLabel="All models"
actions={
<>
{!selectedModel.disabled && !isRunning && (
<button className="btn btn-primary btn-sm" onClick={() => handleLoadModel(selectedModel.id)} disabled={loadingModels.has(selectedModel.id)}>
<i className="fas fa-bolt" /> {loadingModels.has(selectedModel.id) ? 'Loading…' : 'Load'}
</button>
)}
{isRunning && (
<button className="btn btn-secondary btn-sm" onClick={() => handleStopModel(selectedModel.id)}>
<i className="fas fa-stop" /> Stop
</button>
)}
{/* The rest stays behind a menu. Load/Stop is what an
operator came for; everything else is occasional and
would only dilute it. */}
<ActionMenu
ariaLabel={`Actions for ${model.id}`}
triggerLabel={`Actions for ${model.id}`}
ariaLabel={`Actions for ${selectedModel.id}`}
triggerLabel={`Actions for ${selectedModel.id}`}
items={[
{ key: 'toggle', icon: model.disabled ? 'fa-toggle-on' : 'fa-toggle-off',
label: model.disabled ? 'Enable model' : 'Disable model',
onClick: () => handleToggleModel(model.id, model.disabled),
disabled: togglingModels.has(model.id) },
{ key: 'load', icon: 'fa-bolt',
label: loadingModels.has(model.id) ? 'Loading…' : 'Load into memory',
onClick: () => handleLoadModel(model.id),
hidden: isRunning || !!model.disabled,
disabled: loadingModels.has(model.id) },
{ key: 'stop', icon: 'fa-stop', label: 'Stop model',
onClick: () => handleStopModel(model.id), hidden: !isRunning },
{ key: 'toggle', icon: selectedModel.disabled ? 'fa-toggle-on' : 'fa-toggle-off',
label: selectedModel.disabled ? 'Enable model' : 'Disable model',
onClick: () => handleToggleModel(selectedModel.id, selectedModel.disabled),
disabled: togglingModels.has(selectedModel.id) },
{ key: 'pin', icon: 'fa-thumbtack',
label: model.pinned ? 'Unpin (allow idle unload)' : 'Pin (prevent idle unload)',
onClick: () => handleTogglePinned(model.id, model.pinned),
disabled: pinningModels.has(model.id) || !!model.disabled },
label: selectedModel.pinned ? 'Unpin (allow idle unload)' : 'Pin (prevent idle unload)',
onClick: () => handleTogglePinned(selectedModel.id, selectedModel.pinned),
disabled: pinningModels.has(selectedModel.id) || !!selectedModel.disabled },
{ key: 'edit', icon: 'fa-pen-to-square', label: 'Edit configuration',
onClick: () => navigate(`/app/model-editor/${encodeURIComponent(model.id)}`, { state: fromState(location, t('manage.title')) }) },
onClick: () => navigate(`/app/model-editor/${encodeURIComponent(selectedModel.id)}`, { state: fromState(location, t('manage.title')) }) },
{ key: 'logs', icon: 'fa-terminal', label: 'Backend logs',
onClick: () => navigate(`/app/backend-logs/${encodeURIComponent(model.id)}`) },
onClick: () => navigate(`/app/backend-logs/${encodeURIComponent(selectedModel.id)}`) },
{ divider: true },
{ key: 'delete', icon: 'fa-trash', label: 'Delete model', danger: true,
onClick: () => handleDeleteModel(model.id) },
onClick: () => handleDeleteModel(selectedModel.id) },
]}
/>
</StopPropagationCell>
</ResourceRow>
)
})}
</tbody>
</ResponsiveTable>
</>
}
/>
<StatGrid
stats={[
{ label: 'State',
value: selectedModel.disabled ? 'Disabled' : isRunning ? 'Running' : 'Idle',
tone: selectedModel.disabled ? undefined : isRunning ? 'ok' : undefined },
{ label: 'Backend', value: selectedModel.backend || 'Auto' },
enriched?.estimated_vram_display && enriched.estimated_vram_display !== '0 B'
? { label: 'VRAM', value: enriched.estimated_vram_display } : null,
selectedModel.pinned ? { label: 'Pinned', value: 'yes', tone: 'warn' } : null,
]}
/>
{/* Adopted, pinned and alias are row badges that lost their
cell. They are facts about the model, not about its state,
so they sit under the numbers rather than in the rail. */}
{(aliasTargets[selectedModel.id] || selectedModel.source === 'registry-only') && (
<div className="badge-row">
{selectedModel.source === 'registry-only' && (
<span className="badge badge-warning" title="Discovered on a worker but not configured locally. Persist the config to make it permanent.">
<i className="fas fa-ghost" /> Adopted
</span>
)}
{aliasTargets[selectedModel.id] && (
<span className="badge badge-info" title={`Alias -> ${aliasTargets[selectedModel.id]}`}>
<i className="fas fa-arrow-right-arrow-left" /> alias -&gt; {aliasTargets[selectedModel.id]}
</span>
)}
</div>
)}
{matchedCaps.length > 0 && (
<div>
<span className="detail-pane__label">Use cases</span>
<div className="badge-row">
{matchedCaps.map(uc => uc.route ? (
<a
key={uc.cap}
href="#"
onClick={(e) => { e.preventDefault(); navigate(uc.route(selectedModel.id)) }}
className="badge badge-info badge-link"
>{uc.label}</a>
) : (
<span key={uc.cap} className="badge">{uc.label}</span>
))}
</div>
</div>
)}
<ModelDetail
model={selectedModel}
enriched={enriched}
matchedCaps={matchedCaps}
distributedMode={distributedMode}
onNavigate={navigate}
/>
</div>
)
})() : (
<HostStatusPane
models={models}
backends={backends}
loadedModelIds={loadedModelIds}
upgrades={upgrades}
operations={operations}
enrichModel={enrichModel}
onJump={handleSummaryClick}
/>
)}
/>
)}
</div>
)
@@ -771,16 +828,16 @@ export default function Manage() {
</div>
)}
{backendsLoading ? (
{backendsLoading && !backendsLoadedOnce.current ? (
<GalleryLoader />
) : backends.length === 0 ? (
<div className="card loading-center text-center">
<i className="fas fa-server" style={{ fontSize: '2rem', color: 'var(--color-text-muted)', marginBottom: 'var(--spacing-md)' }} />
<h3 className="mb-sm">No backends installed yet</h3>
<p className="text-base text-secondary mb-md">
Install backends from the gallery to extend functionality.
<div className="empty-state empty-state--page">
<div className="empty-state-icon"><i className="fas fa-server" /></div>
<h2 className="empty-state-title">No backends installed yet</h2>
<p className="empty-state-text">
A backend is the runtime that actually runs a model. Install one from the gallery to give this host something to run with.
</p>
<div className="hstack hstack--center">
<div className="empty-state__actions">
<button className="btn btn-primary btn-sm" onClick={() => navigate('/app/backends')}>
<i className="fas fa-server" /> Browse Backend Gallery
</button>
@@ -908,136 +965,49 @@ export default function Manage() {
return (
<>
{filterBar}
<ResponsiveTable>
<thead>
<tr>
<th className="col-w-30"></th>
<th className="col-w-64"></th>
<th>Backend</th>
<th>Version</th>
{distributedMode && <th>Nodes</th>}
<th>Installed</th>
<th className="col-w-40"></th>
</tr>
</thead>
<tbody>
{visibleBackends.map((backend) => {
const upgradeInfo = upgrades[backend.Name]
const hasDrift = upgradeInfo?.node_drift?.length > 0
const nodes = backend.Nodes || backend.nodes || []
const enriched = enrichBackend(backend.Name)
const isExpanded = expandedKey === `backends:${backend.Name}`
const isDevelopment = !!(enriched?.isDevelopment)
const isProcessing = reinstallingBackends.has(backend.Name)
return (
<ResourceRow
key={backend.Name}
expanded={isExpanded}
onToggleExpand={() => toggleExpanded('backends', backend.Name)}
colSpan={colSpan}
detail={(
<BackendDetail
backend={backend}
enriched={enriched}
upgradeInfo={upgradeInfo}
nodes={nodes}
distributedMode={distributedMode}
/>
)}
>
<ChevronCell expanded={isExpanded} />
<IconCell icon={enriched?.icon} fallback="fa-cogs" alt="" />
<td>
<div style={{ display: 'flex', flexDirection: 'column', minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-xs)', flexWrap: 'wrap' }}>
<span style={{ fontWeight: 600, fontSize: 'var(--text-sm)' }}>{backend.Name}</span>
<MetaBadgeRow
isSystem={!!backend.IsSystem}
isMeta={!!backend.IsMeta}
isDevelopment={isDevelopment}
/>
{backend.Metadata?.alias && backend.Metadata.alias !== backend.Name && (
<span className="cell-subtle" style={{ marginLeft: 0 }}>· alias {backend.Metadata.alias}</span>
)}
{backend.Metadata?.meta_backend_for && (
<span className="cell-subtle" style={{ marginLeft: 0 }}>· for {backend.Metadata.meta_backend_for}</span>
)}
</div>
{(enriched?.description) && (
<ResourceRowDesc description={enriched.description} />
)}
</div>
</td>
<td>
{(() => {
const v = formatBackendVersion(backend.Metadata)
return (
<div className="cell-stack">
<span className="cell-mono" title={v.full || undefined}>{v.label}</span>
{upgradeInfo && (
<span className="badge badge-warning" title={upgradeInfo.available_version ? `Upgrade to v${upgradeInfo.available_version}` : 'Update available'}>
<i className="fas fa-arrow-up" />
{upgradeInfo.available_version ? ` v${upgradeInfo.available_version}` : ' Update'}
</span>
)}
{hasDrift && (
<span
className="badge badge-warning"
title={`Drift: ${upgradeInfo.node_drift.map(d => `${d.node_name}${d.version ? ' v' + d.version : ''}`).join(', ')}`}
>
<i className="fas fa-code-branch" />
{' '}Drift: {upgradeInfo.node_drift.length} node{upgradeInfo.node_drift.length === 1 ? '' : 's'}
</span>
)}
</div>
)
})()}
</td>
{distributedMode && (
<td>
<NodeDistributionChip nodes={nodes} context="backends" />
</td>
)}
<td>
<span
className="cell-muted cell-mono"
title={backend.Metadata?.installed_at ? formatInstalledAtFull(backend.Metadata.installed_at) : undefined}
>
{backend.Metadata?.installed_at ? formatInstalledAt(backend.Metadata.installed_at) : '—'}
</span>
</td>
<StopPropagationCell className="text-right">
{backend.IsSystem ? (
<span className="badge" title="System backends are managed outside the gallery">
<i className="fas fa-lock" /> Protected
</span>
) : (
<ActionMenu
ariaLabel={`Actions for ${backend.Name}`}
triggerLabel={`Actions for ${backend.Name}`}
items={[
{ key: 'upgrade', icon: 'fa-arrow-up',
label: upgradeInfo?.available_version ? `Upgrade to v${upgradeInfo.available_version}` : 'Upgrade',
onClick: () => handleUpgradeBackend(backend.Name),
disabled: isProcessing,
hidden: !upgradeInfo },
{ key: 'reinstall', icon: 'fa-rotate', label: 'Reinstall backend',
onClick: () => handleReinstallBackend(backend.Name),
disabled: isProcessing },
{ divider: true },
{ key: 'delete', icon: 'fa-trash',
label: 'Delete backend',
danger: true,
onClick: () => handleDeleteBackend(backend.Name) },
]}
/>
)}
</StopPropagationCell>
</ResourceRow>
)
})}
</tbody>
</ResponsiveTable>
<SplitView
testId="host"
detail={!!selectedBackend}
rail={
<EntityRail
items={visibleBackends.map(b => railItemForManagedBackend(b, { upgrades, isBackendProcessing }))}
groups={BACKEND_STATE_GROUPS}
grouped={!backendsSearch.trim()}
collapsedGroups={collapsedGroups}
onToggleGroup={toggleGroup}
busy={backendsLoading}
selectedId={selectedId}
onSelect={selectEntity}
countLabel={`${visibleBackends.length} of ${backends.length}`}
ariaLabel="Installed backends"
testId="host-rail"
/>
}
pane={selectedBackend ? (
<ManagedBackendPane
backend={selectedBackend}
enriched={enrichBackend(selectedBackend.Name)}
upgradeInfo={upgrades[selectedBackend.Name]}
processing={isBackendProcessing(selectedBackend)}
distributedMode={distributedMode}
onBack={() => selectEntity(null)}
onUpgrade={handleUpgradeBackend}
onReinstall={handleReinstallBackend}
onDelete={handleDeleteBackend}
/>
) : (
<HostStatusPane
models={models}
backends={backends}
loadedModelIds={loadedModelIds}
upgrades={upgrades}
operations={operations}
enrichModel={enrichModel}
onJump={handleSummaryClick}
/>
)}
/>
</>
)
})()}
@@ -1295,3 +1265,217 @@ function BackendDetail({ backend, enriched, upgradeInfo, nodes, distributedMode
</div>
)
}
// State groups. An inventory is read by condition before it is read by name, so
// the rail is bucketed by what a thing is doing rather than by what it is for.
// That is the opposite of the galleries, and deliberately so: nobody opens Host
// wondering which of their models does vision.
const MODEL_STATE_GROUPS = [
{ id: 'running', label: 'Running', icon: 'fa-circle-play' },
{ id: 'idle', label: 'Idle', icon: 'fa-pause' },
{ id: 'disabled', label: 'Disabled', icon: 'fa-ban' },
]
const BACKEND_STATE_GROUPS = [
{ id: 'update', label: 'Update available', icon: 'fa-arrow-up' },
{ id: 'installed', label: 'Installed', icon: 'fa-check' },
]
function railItemForManagedModel(model, { loadedModelIds, enrichModel, loadingModels }) {
const running = loadedModelIds.has(model.id) || (Array.isArray(model.loaded_on) && model.loaded_on.length > 0)
const enriched = enrichModel(model.id)
const vram = enriched?.estimated_vram_display
const hasVram = vram && vram !== '0 B'
let groupId = 'idle'
let stripe = 'idle'
let meta = hasVram ? `idle · ${vram}` : 'idle'
let metaTone
if (model.disabled) {
groupId = 'disabled'
stripe = 'off'
meta = 'disabled'
} else if (loadingModels.has(model.id)) {
groupId = 'idle'
stripe = 'idle'
meta = 'loading…'
metaTone = 'busy'
} else if (running) {
groupId = 'running'
stripe = 'run'
meta = hasVram ? `running · ${vram}` : 'running'
metaTone = 'ok'
}
return { id: model.id, name: model.id, icon: 'fa-brain', meta, metaTone, stripe, groupId }
}
function railItemForManagedBackend(backend, { upgrades, isBackendProcessing }) {
const name = backend.Name
const upgrade = upgrades[name]
const version = backend.Metadata?.version || backend.Version
let groupId = 'installed'
let stripe = 'idle'
let meta = version ? `v${version}` : 'installed'
let metaTone
if (isBackendProcessing(backend)) {
meta = 'working…'
metaTone = 'busy'
} else if (upgrade) {
groupId = 'update'
stripe = 'err'
meta = upgrade.available_version ? `v${version} → v${upgrade.available_version}` : 'update available'
metaTone = 'warn'
}
return { id: name, name, icon: 'fa-server', meta, metaTone, stripe, groupId }
}
// ManagedBackendPane is the detail for one installed backend. System backends
// keep their protection: they are managed outside the gallery, so the pane
// states that rather than offering actions that would fail.
function ManagedBackendPane({ backend, enriched, upgradeInfo, processing, distributedMode, onBack, onUpgrade, onReinstall, onDelete }) {
const name = backend.Name
const version = backend.Metadata?.version || backend.Version
return (
<div className="detail-pane">
<DetailHeader
testId="host"
icon="fa-server"
name={name}
lede={enriched?.description ? stripMarkdown(enriched.description).slice(0, 220) : null}
ledeTitle={enriched?.description ? stripMarkdown(enriched.description) : null}
onBack={onBack}
backLabel="All backends"
actions={
backend.IsSystem ? (
<span className="badge" title="System backends are managed outside the gallery">
<i className="fas fa-lock" /> Protected
</span>
) : (
<>
{upgradeInfo && (
<button className="btn btn-primary btn-sm" onClick={() => onUpgrade(name)} disabled={processing}>
<i className="fas fa-arrow-up" /> {upgradeInfo.available_version ? `Upgrade to v${upgradeInfo.available_version}` : 'Upgrade'}
</button>
)}
<ActionMenu
ariaLabel={`Actions for ${name}`}
triggerLabel={`Actions for ${name}`}
items={[
{ key: 'reinstall', icon: 'fa-rotate', label: 'Reinstall backend',
onClick: () => onReinstall(name), disabled: processing },
{ divider: true },
{ key: 'delete', icon: 'fa-trash', label: 'Delete backend', danger: true,
onClick: () => onDelete(name) },
]}
/>
</>
)
}
/>
<StatGrid
stats={[
{ label: 'Version', value: version ? `v${version}` : '—' },
upgradeInfo ? { label: 'Available', value: upgradeInfo.available_version ? `v${upgradeInfo.available_version}` : 'update', tone: 'warn' } : null,
{ label: 'Managed', value: backend.IsSystem ? 'system' : 'gallery' },
]}
/>
<BackendDetail
backend={backend}
enriched={enriched}
upgradeInfo={upgradeInfo}
nodes={backend.nodes}
distributedMode={distributedMode}
/>
</div>
)
}
// HostStatusPane is the pane with nothing selected.
//
// Nobody opens Host to discover anything, so the zero state is not a catalog
// front page: it is the answer to the question people actually arrive with.
// What is loaded, what is stale, and what fell over. Every number here already
// existed on the page; none of them had been assembled into one statement.
function HostStatusPane({ models, backends, loadedModelIds, upgrades, operations, enrichModel, onJump }) {
const running = models.filter(m => !m.disabled && (loadedModelIds.has(m.id) || (Array.isArray(m.loaded_on) && m.loaded_on.length > 0)))
const disabled = models.filter(m => m.disabled)
const idle = models.length - running.length - disabled.length
const staleNames = Object.keys(upgrades)
// Failures are kept in the operations list on purpose so they can be seen and
// dismissed; unseen is exactly what a red badge in a scrolled-off row was.
const failures = operations.filter(op => op.error)
return (
<div className="zero-pane">
<div className="zero-pane__hero">
<span className="zero-pane__eyebrow">Right now</span>
<h2 className="zero-pane__title">
{running.length === 0
? `Nothing loaded. ${models.length} models and ${backends.length} backends installed.`
: `${running.length} of ${models.length} models loaded, ${backends.length} backends installed.`}
</h2>
<p className="zero-pane__text">Pick anything on the left to load it, stop it, or see its configuration.</p>
</div>
{failures.length > 0 && (
<div className="zero-pane__alert zero-pane__alert--bad" role="status">
<i className="fas fa-circle-exclamation" aria-hidden="true" />
<span>
{failures.length === 1 ? '1 operation failed' : `${failures.length} operations failed`}
{': '}{failures.slice(0, 2).map(op => op.name).join(', ')}{failures.length > 2 ? '…' : ''}
</span>
</div>
)}
{staleNames.length > 0 && (
<div className="zero-pane__alert zero-pane__alert--warn">
<i className="fas fa-arrow-up" aria-hidden="true" />
<span>
{staleNames.length === 1 ? '1 backend has an update' : `${staleNames.length} backends have updates`}
{': '}{staleNames.slice(0, 3).join(', ')}{staleNames.length > 3 ? '…' : ''}
</span>
<button className="btn btn-secondary btn-sm" onClick={() => onJump('backends', 'updates')}>
Review
</button>
</div>
)}
<StatGrid
stats={[
{ label: 'Loaded', value: running.length, tone: running.length > 0 ? 'ok' : undefined },
{ label: 'Idle', value: idle },
{ label: 'Disabled', value: disabled.length },
{ label: 'Updates', value: staleNames.length, tone: staleNames.length > 0 ? 'warn' : undefined },
]}
/>
{running.length > 0 && (
<div className="zero-pane__shelf">
<div className="zero-pane__shelf-head">
<h3 className="zero-pane__shelf-title">Loaded now</h3>
<span className="zero-pane__shelf-meta">estimated VRAM</span>
</div>
<div className="rowlist">
{running.slice(0, 6).map(m => {
const vram = enrichModel(m.id)?.estimated_vram_display
return (
<div className="rowline" key={m.id}>
<span className="badge badge-success"><i className="fas fa-circle icon-tiny" /> running</span>
<span>{m.id}</span>
<span className="cell-mono cell-muted rowline__num">{vram && vram !== '0 B' ? vram : '—'}</span>
</div>
)
})}
</div>
</div>
)}
</div>
)
}

View File

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More