mirror of
https://github.com/mudler/LocalAI.git
synced 2026-08-04 04:12:22 -04:00
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>
This commit is contained in:
committed by
GitHub
parent
8a80830f33
commit
74b7ea2829
5
.gitignore
vendored
5
.gitignore
vendored
@@ -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/
|
||||
|
||||
@@ -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)
|
||||
|
||||
188
core/gallery/estimate_warm.go
Normal file
188
core/gallery/estimate_warm.go
Normal file
@@ -0,0 +1,188 @@
|
||||
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 VRAM estimate caches in the background.
|
||||
//
|
||||
// An estimate for an entry the server has never seen costs a network probe of
|
||||
// its weight files, seconds of it, and the UI asks for one per row. Doing that
|
||||
// work at startup rather than on the first click is the difference between a
|
||||
// gallery that reads instantly and one that spends ten seconds filling in its
|
||||
// own sizes while somebody watches.
|
||||
//
|
||||
// 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
|
||||
}
|
||||
|
||||
var (
|
||||
wg sync.WaitGroup
|
||||
cursor = make(chan *GalleryModel)
|
||||
warmed int
|
||||
mu sync.Mutex
|
||||
)
|
||||
|
||||
for i := 0; i < cfg.Concurrency; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for m := range cursor {
|
||||
input := EstimateInput(m)
|
||||
if len(input.Files) == 0 && input.HFRepo == "" && input.Size == "" {
|
||||
continue
|
||||
}
|
||||
// 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)
|
||||
_, err := vram.EstimateModelMultiContext(entryCtx, input, cfg.Contexts)
|
||||
cancel()
|
||||
if err != nil {
|
||||
xlog.Debug("VRAM estimate warm-up failed for entry", "model", m.GetName(), "error", err)
|
||||
continue
|
||||
}
|
||||
mu.Lock()
|
||||
warmed++
|
||||
mu.Unlock()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
feed:
|
||||
for _, m := range models {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
break feed
|
||||
case cursor <- m:
|
||||
}
|
||||
}
|
||||
close(cursor)
|
||||
wg.Wait()
|
||||
|
||||
if ctx.Err() != nil {
|
||||
xlog.Debug("VRAM estimate warm-up stopped", "warmed", warmed)
|
||||
return
|
||||
}
|
||||
xlog.Info("VRAM estimate cache warmed", "entries", warmed, "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
|
||||
}
|
||||
106
core/gallery/estimate_warm_test.go
Normal file
106
core/gallery/estimate_warm_test.go
Normal file
@@ -0,0 +1,106 @@
|
||||
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("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))
|
||||
})
|
||||
|
||||
})
|
||||
@@ -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 {
|
||||
|
||||
80
core/gallery/gallery_refresh_throttle_test.go
Normal file
80
core/gallery/gallery_refresh_throttle_test.go
Normal 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()))
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
72
core/http/react-ui/e2e/discover-height.spec.js
Normal file
72
core/http/react-ui/e2e/discover-height.spec.js
Normal 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')
|
||||
})
|
||||
})
|
||||
52
core/http/react-ui/e2e/discover-search-focus.spec.js
Normal file
52
core/http/react-ui/e2e/discover-search-focus.spec.js
Normal 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 })
|
||||
})
|
||||
})
|
||||
69
core/http/react-ui/e2e/host-split-view.spec.js
Normal file
69
core/http/react-ui/e2e/host-split-view.spec.js
Normal 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=/)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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, '\\/') + '$'))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
},
|
||||
"items": {
|
||||
"home": "Start",
|
||||
"installModels": "Modelle installieren",
|
||||
"discover": "Entdecken",
|
||||
"chat": "Chat",
|
||||
"studio": "Studio",
|
||||
"talk": "Sprechen",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
},
|
||||
"items": {
|
||||
"home": "Home",
|
||||
"installModels": "Install Models",
|
||||
"discover": "Discover",
|
||||
"chat": "Chat",
|
||||
"studio": "Studio",
|
||||
"talk": "Talk",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
},
|
||||
"items": {
|
||||
"home": "Inicio",
|
||||
"installModels": "Instalar modelos",
|
||||
"discover": "Descubrir",
|
||||
"chat": "Chat",
|
||||
"studio": "Studio",
|
||||
"talk": "Hablar",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
},
|
||||
"items": {
|
||||
"home": "Beranda",
|
||||
"installModels": "Instal Model",
|
||||
"discover": "Jelajahi",
|
||||
"chat": "Obrolan",
|
||||
"studio": "Studio",
|
||||
"talk": "Bicara",
|
||||
|
||||
@@ -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 d’uso",
|
||||
"pickText": "Chat, ragionamento, embedding",
|
||||
"pickVision": "Leggere immagini e documenti",
|
||||
"pickAudio": "Voce in ingresso e in uscita",
|
||||
"pickVisual": "Generare immagini e video"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
},
|
||||
"items": {
|
||||
"home": "Home",
|
||||
"installModels": "Installa modelli",
|
||||
"discover": "Esplora",
|
||||
"chat": "Chat",
|
||||
"studio": "Studio",
|
||||
"talk": "Conversazione",
|
||||
|
||||
@@ -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": "이미지와 영상 생성"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
},
|
||||
"items": {
|
||||
"home": "홈",
|
||||
"installModels": "모델 설치",
|
||||
"discover": "둘러보기",
|
||||
"chat": "채팅",
|
||||
"studio": "스튜디오",
|
||||
"talk": "대화",
|
||||
|
||||
@@ -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": "生成图像与视频"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
},
|
||||
"items": {
|
||||
"home": "首页",
|
||||
"installModels": "安装模型",
|
||||
"discover": "发现",
|
||||
"chat": "聊天",
|
||||
"studio": "工作室",
|
||||
"talk": "通话",
|
||||
|
||||
@@ -11595,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; }
|
||||
}
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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
|
||||
|
||||
39
core/http/react-ui/src/components/split/DetailHeader.jsx
Normal file
39
core/http/react-ui/src/components/split/DetailHeader.jsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
166
core/http/react-ui/src/components/split/EntityRail.jsx
Normal file
166
core/http/react-ui/src/components/split/EntityRail.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
21
core/http/react-ui/src/components/split/SplitView.jsx
Normal file
21
core/http/react-ui/src/components/split/SplitView.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
24
core/http/react-ui/src/components/split/StatGrid.jsx
Normal file
24
core/http/react-ui/src/components/split/StatGrid.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
@@ -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 -> {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 -> {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>
|
||||
)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -176,7 +176,7 @@ const appChildren = [
|
||||
],
|
||||
},
|
||||
|
||||
// Models management (Install Models) — top-level destination, full-width.
|
||||
// Model gallery (Discover) — top-level destination, full-width.
|
||||
{ path: 'models', element: <Admin><Models /></Admin> },
|
||||
{ path: 'voice-library/new', element: <Admin><VoiceProfileCreate /></Admin> },
|
||||
{ path: 'model-editor', element: <Admin><ModelEditor /></Admin> },
|
||||
|
||||
70
core/http/react-ui/src/utils/entityGroups.js
vendored
Normal file
70
core/http/react-ui/src/utils/entityGroups.js
vendored
Normal file
@@ -0,0 +1,70 @@
|
||||
// Grouping for the rails on Discover and Backends.
|
||||
//
|
||||
// The gallery does not tag entries with the use-case keys the filter chips
|
||||
// send. Those keys (`chat`, `tts`, `transcript`, …) are a server-side
|
||||
// vocabulary the handler maps onto entries; what an entry actually carries is
|
||||
// free-form and inconsistent: models come back tagged `llm`, `gguf`, `vision`,
|
||||
// `coding`, and backends `LLM`, `text-to-text`, `audio-transcription`, `CUDA`.
|
||||
// An earlier version of this matched on the filter keys and put all 1,595
|
||||
// models into "Everything else", which is how this file came to exist.
|
||||
//
|
||||
// Order is specific before general, and that is load-bearing. A vision model is
|
||||
// tagged `llm` as well as `vision`, so testing text first would swallow it.
|
||||
const GROUPS = [
|
||||
{
|
||||
id: 'audio',
|
||||
labelKey: 'groups.audio',
|
||||
icon: 'fa-wave-square',
|
||||
tags: ['tts', 'stt', 'asr', 'transcript', 'transcription', 'audio-transcription',
|
||||
'speech', 'speech-to-text', 'text-to-speech', 'audio', 'voice', 'voice-cloning',
|
||||
'whisper', 'diarization', 'sound', 'music', 'vad'],
|
||||
backends: ['whisper', 'parakeet', 'kokoro', 'bark', 'piper', 'vibevoice', 'qwentts',
|
||||
'crispasr', 'moss-transcribe', 'omnivoice', 'ced', 'silero'],
|
||||
},
|
||||
{
|
||||
id: 'visual',
|
||||
labelKey: 'groups.visual',
|
||||
icon: 'fa-image',
|
||||
tags: ['image', 'image-generation', 'text-to-image', 'video', 'text-to-video', '3d',
|
||||
'sd', 'diffusion', 'stable-diffusion', 'flux'],
|
||||
backends: ['stablediffusion', 'diffusers', 'flux'],
|
||||
},
|
||||
{
|
||||
id: 'vision',
|
||||
labelKey: 'groups.vision',
|
||||
icon: 'fa-eye',
|
||||
tags: ['vision', 'multimodal', 'vlm', 'image-to-text', 'detection', 'ocr'],
|
||||
backends: [],
|
||||
},
|
||||
{
|
||||
id: 'text',
|
||||
labelKey: 'groups.text',
|
||||
icon: 'fa-brain',
|
||||
tags: ['llm', 'text-to-text', 'text-generation', 'chat', 'completion', 'coding',
|
||||
'reasoning', 'thinking', 'agent', 'tool-use', 'embeddings', 'rerank'],
|
||||
backends: ['llama', 'vllm', 'sglang', 'ds4', 'bonsai', 'transformers', 'exllama',
|
||||
'mlx', 'rerankers', 'bert'],
|
||||
},
|
||||
{ id: 'other', labelKey: 'groups.other', icon: 'fa-cube', tags: [], backends: [] },
|
||||
]
|
||||
|
||||
export const ENTITY_GROUPS = GROUPS
|
||||
|
||||
// Tags are matched case-insensitively because the two galleries disagree on
|
||||
// case for the same concept (`llm` on models, `LLM` on backends).
|
||||
export function groupForEntity({ tags, backend, name } = {}) {
|
||||
const owned = new Set((tags || []).map(t => String(t).toLowerCase()))
|
||||
for (const g of GROUPS) {
|
||||
if (g.tags.some(tag => owned.has(tag))) return g
|
||||
}
|
||||
// Nothing matched, so fall back to what runs it. A backend named `whisper`
|
||||
// is a speech backend whatever its tags say, and for the backend gallery the
|
||||
// entry's own name is that signal.
|
||||
const hint = String(backend || name || '').toLowerCase()
|
||||
if (hint) {
|
||||
for (const g of GROUPS) {
|
||||
if (g.backends.some(b => hint.includes(b))) return g
|
||||
}
|
||||
}
|
||||
return GROUPS[GROUPS.length - 1]
|
||||
}
|
||||
2
core/http/react-ui/src/utils/section.js
vendored
2
core/http/react-ui/src/utils/section.js
vendored
@@ -6,7 +6,7 @@ const CREATE_PATHS = ['/app/chat', '/app/studio', '/app/talk']
|
||||
// The section/console an app page belongs to, returned as a `nav` i18n key for
|
||||
// use as the PageHeader eyebrow. Console pages map to their console title
|
||||
// (Build / Operate); the inline Create group maps to sections.create; any other
|
||||
// top-level page (Home, Install Models, Account, ...) has no eyebrow.
|
||||
// top-level page (Home, Discover, Account, ...) has no eyebrow.
|
||||
export function sectionKeyForPath(pathname) {
|
||||
for (const c of consoles) {
|
||||
if (consolePaths(c).some(p => pathname === p || pathname.startsWith(p + '/'))) {
|
||||
|
||||
@@ -62,40 +62,6 @@ var usecaseFilters = map[string]config.ModelConfigUsecase{
|
||||
config.UsecaseTokenClassify: config.FLAG_TOKEN_CLASSIFY,
|
||||
}
|
||||
|
||||
// extractHFRepo tries to find a HuggingFace repo ID from model 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 ""
|
||||
}
|
||||
|
||||
// buildEstimateInput creates a vram.ModelEstimateInput from gallery model metadata.
|
||||
func buildEstimateInput(m *gallery.GalleryModel) vram.ModelEstimateInput {
|
||||
var input vram.ModelEstimateInput
|
||||
input.Size = m.Size
|
||||
if hfRepoID := extractHFRepo(m.Overrides, m.URLs); hfRepoID != "" {
|
||||
input.HFRepo = hfRepoID
|
||||
}
|
||||
for _, f := range m.AdditionalFiles {
|
||||
if vram.IsWeightFile(f.URI) {
|
||||
input.Files = append(input.Files, vram.FileInput{URI: f.URI, Size: 0})
|
||||
}
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
// parseContextSizes parses a comma-separated list of context sizes from a query param.
|
||||
// Returns a default of [8192] if the param is empty or unparseable.
|
||||
func parseContextSizes(raw string) []uint32 {
|
||||
@@ -916,7 +882,7 @@ func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model
|
||||
return c.JSON(http.StatusNotFound, map[string]any{"error": "model not found"})
|
||||
}
|
||||
|
||||
input := buildEstimateInput(model)
|
||||
input := gallery.EstimateInput(model)
|
||||
if len(input.Files) == 0 && input.HFRepo == "" && input.Size == "" {
|
||||
return c.JSON(200, vram.MultiContextEstimate{})
|
||||
}
|
||||
|
||||
@@ -452,6 +452,37 @@ Conversely, you can pre-load a model into memory ahead of its first request with
|
||||
5. **Consider model size**: Ensure your VRAM can accommodate at least one of your largest models
|
||||
6. **Use quantization**: Smaller quantized models use less VRAM and allow more flexibility
|
||||
|
||||
## VRAM estimates in the model gallery
|
||||
|
||||
The model gallery shows an estimated VRAM footprint per model, at several
|
||||
context lengths, so you can see whether something will run before installing it.
|
||||
|
||||
Working that out means reading the metadata of a model's weight files, which for
|
||||
a model you have not installed is a request to the host that serves them. It
|
||||
takes a second or two the first time, and the gallery needs one per row. LocalAI
|
||||
caches the result, and warms that cache in the background at startup so the
|
||||
gallery reads instantly rather than filling in its own numbers while you watch.
|
||||
|
||||
The warm-up is bounded, and covers the entries at the top of the gallery: the
|
||||
ones you see first. Anything past it is estimated on first view and cached from
|
||||
then on.
|
||||
|
||||
| Variable | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `LOCALAI_VRAM_WARM_LIMIT` | `300` | How many gallery entries to warm at startup. Set to `0` to disable the warm-up entirely. |
|
||||
| `LOCALAI_VRAM_WARM_CONCURRENCY` | `4` | How many estimates to run at once. |
|
||||
|
||||
```bash
|
||||
# Air-gapped, or you would rather not make the requests at all
|
||||
LOCALAI_VRAM_WARM_LIMIT=0 local-ai run
|
||||
|
||||
# A slow or metered link: warm the same entries, more gently
|
||||
LOCALAI_VRAM_WARM_CONCURRENCY=1 local-ai run
|
||||
```
|
||||
|
||||
The warm-up never blocks startup, and never fails it: an entry whose weight
|
||||
files cannot be reached is left cold and estimated later, if anyone asks.
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- See [Advanced Usage]({{%relref "advanced/advanced-usage" %}}) for other configuration options
|
||||
|
||||
@@ -190,7 +190,7 @@ When authentication is enabled, the following endpoints require admin role:
|
||||
When auth is enabled, the React UI sidebar dynamically shows/hides sections based on the user's role:
|
||||
|
||||
- **All users see**: Home, Chat, Images, Video, TTS, Sound, Talk, Usage, API docs link
|
||||
- **Admins also see**: Install Models, Agents section (Agents, Skills, Memory, MCP CI Jobs), System section (Backends, Traces, Swarm, System, Settings)
|
||||
- **Admins also see**: Discover, Agents section (Agents, Skills, Memory, MCP CI Jobs), System section (Backends, Traces, Swarm, System, Settings)
|
||||
|
||||
Admin-only pages are also protected at the router level - navigating directly to an admin URL redirects non-admin users to the home page.
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ For NVIDIA GPUs, add `--gpus all`. For AMD/Intel/Vulkan, add the appropriate `--
|
||||
Open **http://localhost:8080** in your browser. The web interface lets you:
|
||||
|
||||
- **Chat** with any installed model
|
||||
- **Install models** from the built-in gallery (Models page)
|
||||
- **Install models** from the built-in gallery (Discover page)
|
||||
- **Generate images**, audio, and more
|
||||
- **Create and manage AI agents** with MCP tool support
|
||||
- **Monitor system resources** and loaded models
|
||||
|
||||
Reference in New Issue
Block a user