feat(ui): give Operate and Studio a front door, and fix two layout regressions (#11305)

* feat(ui): give Operate a front door and fold six rail groups into four

Opening Operate ran firstVisiblePath() and landed on Backends, because
Backends happens to be written first in operateConsole.groups. The section
that should answer "is anything wrong" opened on a package manager, and
nothing was reported until you visited it.

Adds /app/operate. Its one irreplaceable block is "Needs attention", which
is empty when nothing is wrong and says so in a line rather than rendering a
reassuring green panel. It collects stale backends, failed operations and
unhealthy nodes. Everything else on the page is a summary you could already
assemble by visiting four others.

The rail regroups from six headings to four: Inference and Activity were both
"the runtime right now", Access and System were both administration. No
destination is removed and no gate changes, so isConsoleItemVisible and
consolePaths are untouched. Overview leads the first group, which is what
makes firstVisiblePath() return it without knowing it exists.

Rail entries now carry a signal beside the label. This does not replace the
sidebar badge and is not built as if it does: the badge stays on the
always-visible sidebar entry for the reason recorded in Sidebar.jsx, that the
rail exists only on Operate routes and can be collapsed. The signals are
orientation while inside Operate, so they are aria-hidden and nothing urgent
depends on them alone.

OperateSummaryContext polls once for the whole console, following
OperationsContext, which exists because per-consumer setInterval against one
endpoint was the defect it fixed. It is mounted by ConsoleLayout for the
Operate console only, so "poll only while in Operate" needs no route check.
Built on usePolling, so it pauses on a hidden tab. Operations are read from
OperationsContext rather than polled a second time, and each source degrades
to no-signal on its own so one dead endpoint cannot blank the rest. It reads
the cached GET /api/backends/upgrades and never the POST that forces a real
registry check.

Traces and Usage get no signal yet: /api/traces returns the list, so a count
would mean fetching every trace to render one number. A counts endpoint is
the honest fix and is scoped separately.

Full e2e suite green (369 passed, 4 skipped), including a render-smoke entry
for the new route.

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

* feat(ui): open Studio on what this machine can actually make

Studio was a tab strip over six generators that opened on Images, which was
never a decision, only the first entry in BASE_TABS. Nothing said which
modalities this installation could run, so the way to learn that video had no
model was to pick the tab and find an empty select.

Adds an overview tab and makes it the fallback. Explicit tabs still win, so
existing deep links keep working; anything unrecognised or gated now lands on
the overview rather than Images.

Each tab carries a dot: filled when an installed model advertises that
modality, hollow when nothing serves it. That is the feature in one detail,
turning the strip from navigation into a report of what the machine can do
before anything is clicked. The dot is aria-hidden because the overview states
the same facts in words and the dots change as models load.

Two kinds of unavailable, which had to stop looking alike:
  - switched off, via a permission: no tab and no lane, unchanged
  - available with no model: a lane, and a route to installing one

Studio now owns one MODALITIES table so the tab strip and the overview cannot
disagree about what exists, and calls useModels() once, unfiltered, grouping in
the browser. useModels(capability) fetches the whole list and filters locally,
so a hook per modality would have been six identical requests to
/api/models/capabilities on every mount. There is a test for that.

Recent outputs read every localStorage store through a new
readAllMediaHistory(), which avoids mounting five hooks that carry save timers
the overview has no use for. 3D is read separately through use3DHistory rather
than folded in: its entries are GLB blobs in IndexedDB, so they cannot come
from the same synchronous read.

Typical cost is the median of this machine's own history, not a guess, and
renders as a dash when there is nothing to go on.

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

* fix(ui): stop the stat cards and the console rail breaking on small screens

Two unrelated causes behind one report that /app/manage looks wrong when the
window is narrow.

The stat cards were being laid out by the wrong rule at every width. Two
different components both claimed `.stat-grid`: the dashboard card strip that
holds .stat-card children, and the detail-pane StatGrid the split views
introduced further down App.css. Being later, the second won every shared
property, so the cards got its 120px columns and its 1px hairline gap in place
of their own 180px columns and spacing-md. Four cards were packed onto a row
that fits two, labels wrapped to three lines and clipped, and the icon crowded
the value. Renamed the strip to `.stat-cards`, after the children it actually
holds, which also removes the mismatch of a `.stat-grid` container full of
`.stat-card`s. The split-view component keeps `.stat-grid` and its BEM parts.

The expanded console rail had no bounded height. Thirteen destinations stacked
in one column is taller than a phone, so opening the menu pushed the page's own
heading past the fold: the menu replaced the page rather than annotating it.
Capped at 55vh with internal scrolling below 768px, so the content behind stays
reachable.

Both are asserted on behaviour rather than markup: no stat-card label may be
clipped, the card gap must not be the detail pane's hairline, and expanding the
rail must leave the page heading on screen.

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

* feat(ui): retemper the palette to localai.io and add the lane primitive

The token half of the style transfer, plus the shared list idiom the two
overviews had each grown their own copy of.

theme.css moves from Nord to the website's palette, variable names preserved so
every consumer moves with it: ground #13171f -> #0d1117, accent frost cyan
#88c0d0 -> action blue #4f8cff, success sage -> mint #56d6a4, warning -> the
amber #f1b95d the site spends only on the thing asking for a decision. Eyebrows
go mint. Dividers become an opaque #29384a hairline rather than alpha over a
varying surface, which is what makes stacked surfaces read crisply on the site.

Light is derived, not inverted. The site ships one theme and never had to
answer this, but the app does: blue darkens to #2f62d8, mint to #0d8b60 and
amber to #8a5d0b, all clearing 4.5:1 on a cool paper ground, where the
dark-mode values sit near 2:1. Same three roles, different values.

Three files restate the palette because CSS variables cannot reach them:
cmTheme.js (the whole CodeMirror theme), VoiceVisualizer and WaveformPlayer
(canvas). Left alone they would have quietly kept the app half-Nord.

The `.lane` primitive replaces the near-identical row CSS that OperateOverview
and StudioOverview had each written: a full-bleed row on a hairline that insets
on hover, with no card and no shadow. Callers supply only the column template.
Both pages now use it, along with `.lane-head` for section rhythm and a
`.page-pad` container for top-level pages outside a console shell — without
which Studio sat flush against the sidebar with its eyebrow clipped.

Studio's tab strip wraps rather than running off the edge at narrow widths.

Full e2e suite: 386 passed, 4 skipped.

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

* feat(ui): put Home's resident models on lanes and give the footer one line

Home's status line was three chips saying a thing was true. It now reports
figures: how many models are resident, how many nodes are healthy, what share
of memory is in use, set in tabular monospace so the digits line up. A chip
answers whether; a figure answers how much, which is what someone opening the
page at a glance is after.

Resident models move from status chips to lanes, with the id set in a new
`.lane__name--id` because an id is something you might type or paste and the UI
face makes it read as a label. /api/system-information carries only the id, so
there is deliberately no backend or memory column: inventing one would mean a
server change this does not make.

The footer was three centred rows and cost the bottom sixth of every page for
chrome. It is one line now, version left and links right, wrapping to centred
when the viewport is too narrow to hold both. Every link it had, it keeps.

Full e2e suite: 392 passed, 4 skipped.

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

* fix(ui): correct three contrast failures and stop a guaranteed-404 poll

A contrast audit of the new palette found three values below WCAG AA, one of
which the previous commit message claimed was fine:

- White on the #4f8cff button is 3.22:1, which is large-text only. The website
  does exactly this, but a button label in an app is not large text, so the
  label goes to dark ink at 5.88:1. Light mode keeps white, which is 5.44:1 on
  its darker blue.
- Light-mode success was 4.08:1 on paper, not the 4.5 claimed. Darkened to
  #0a734f, 5.56:1.
- Nord red was already 4.28:1 on raised surfaces, a pre-existing miss carried
  over unexamined. Lifted to #c96f78, 5.02:1.

Lanes gain the two states they were missing: a 44px target on coarse pointers,
matching what EntityRail already does so the two list idioms feel the same
under a thumb, and a reduced-motion variant that keeps the background feedback
while dropping the hover inset, which is a position change.

The Operate summary no longer asks for /api/nodes on a single-node install. The
cluster API answers 503 when distributed mode is off, so it was a guaranteed
miss every fifteen seconds; it is now gated on useDistributedMode, the same
condition the rail already uses for the Nodes entry.

Full e2e suite: 392 passed, 4 skipped.

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

* fix(ui): restore the gap between overview blocks, and stop claiming zero nodes

Two defects a design review surfaced.

`.lane-head:first-child { margin-top: 0 }` was meant to stop the first block on
a page carrying a top margin. But every <section> makes its lane-head a first
child, so the reset applied to all of them and the gap between blocks vanished:
"Sections" sat flush against the attention row above it. The header supplies its
own bottom margin, so a uniform top margin is correct everywhere.

The Cluster summary read "0 nodes" on a single-node install, which looks like a
fault when the cluster API is simply switched off. It now says "Single node".

Full e2e suite: 392 passed, 4 skipped.

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

* feat(ui): open dark by default, and stop clipping the collapsed sidebar footer

Dark is the identity rather than a preference: localai.io ships one theme and
it is this one, so an install should look like LocalAI before anyone has chosen
anything. The OS setting no longer selects light on first load. The toggle
still does, and a stored choice wins forever after, which the tests assert
both ways.

The collapsed sidebar footer stacked its controls but kept the expanded row's
inline padding, so their edges were clipped against the 51px rail.

Full e2e suite: 394 passed, 4 skipped.

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

* feat(api): count traces server-side and give the Operate overview real totals

The overview's headline block had no source. /api/traces returns the trace
list, so "37 errors in 24h" meant fetching every buffered exchange to count it
in the browser — waste that grows with the buffer, to produce three integers.

Adds GET /api/traces/summary: totals, failures, p95 and a bucketed series for
sparklines, over a window that defaults to 24 hours and is capped at a week.

Deliberate calls, each with a spec:
- A 4xx is the caller getting it wrong, not the installation being unhealthy,
  so only 5xx and transport errors count as failures.
- p95 is a nearest-rank percentile rather than the slowest request, which is
  what a max would report and what makes latency panels lie.
- Buckets are oldest-first so a sparkline reads left to right, and the slice is
  never nil: nil serialises as null and breaks .map() on the other side, which
  is a silent runtime error rather than an empty chart.
- Exchanges outside the window are not counted at all.

The route is registered before /api/traces/:id so "summary" is not captured as
a trace ID.

On the client, Traces and Usage gain the rail signals they were shipped
without, the Observability section summary now states counts instead of listing
its destinations, and an installation that has served nothing says so rather
than showing three zeroes dressed as telemetry.

Sparkline is a bare stroke with an emphasised endpoint and no axes: the figure
above it already states the value, so its only job is the shape.

Go: 185 middleware specs pass. Full e2e suite: 396 passed, 4 skipped.

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

* fix(ui): stop the memory chart calling a trade-off an error

The VRAM-by-context chart rendered any build over the limit in error red, and
escalated the verdict to the error tone as soon as two context sizes crossed
it. But an over-limit build still installs — #11288 keeps a test on exactly
that — so red overstates what is happening. A model that fits at 32k and not
64k is a trade-off, not a fault.

Over-limit bars and the limit line now use the warning tone, which is the
constraint colour used everywhere else in this branch: know what you are doing,
not you may not. The error tone is reserved for "fits nowhere", where the model
genuinely cannot run on this host.

Full e2e suite: 397 passed, 4 skipped.

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

* feat(ui): give the new surfaces orchestrated motion

Uses the reveal system already in the codebase rather than adding a library:
pageReveal, .reveal-stagger and staggerStyle() were built for exactly this, and
anime.js would be ~17KB duplicating four lines of CSS for list reveals.

The overview's headline figures, attention rows and section lanes stagger in,
as do Studio's modality lanes and recent outputs, so a page assembles in the
order it is read instead of appearing all at once.

Two additions beyond stagger. Rail signals transition on opacity when a poll
lands, so a number changing reads as an update rather than a jump cut, and it
stays on the compositor so it cannot reflow the rail. The attention block
animates its left edge in — the one thing on the page that should announce
itself, and on the border rather than the text so nothing moves under a reader.

Both are dropped entirely under prefers-reduced-motion, alongside the lane
hover inset already handled.

Full e2e suite: 397 passed, 4 skipped.

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

* feat(ui): put the generators on a hairline field stack and record the request

The workbench treatment from the mocks, applied where it costs least: both
changes land on shared surfaces, so all six generators get them at once rather
than drifting apart page by page.

The control column stops being a shadowed card of boxed groups and becomes a
hairline field stack — the panel is the page's left half, not an object
floating on it — with uppercase micro-labels matching the eyebrow treatment
used elsewhere. Because .media-controls is shared, Images, Video, 3D, Speech,
Sound and Audio FX all move together.

RequestPanel shows the request the form actually built, with a copy-as-curl.
LocalAI is API-first and Studio is the best place in the app to teach its own
endpoints: the form stops being a black box, and a result worth keeping can be
reproduced from a shell without reverse-engineering which fields the page sent.
It records what was sent rather than what the form currently holds, and renders
nothing until a request has been made — a panel describing a request nobody
made is a tutorial, not a record. Wired into Images and Speech.

Full e2e suite: 401 passed, 4 skipped.

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

* feat(ui): make Chat a transcript instead of a bubble thread

Rounded, filled, asymmetric bubbles fight a system built on hairlines, and they
carry the speaker in shape and side rather than in words. The assistant side
had already given up its bubble; this finishes the job.

Both roles now run full width down one column, separated by a rule, each with a
mono role label. The user turn keeps a left edge in the action tone so the two
are still told apart at a glance, without a fill or a corner radius. The
avatars go: the accent and the label carry the speaker, so the glyph was
decoration once neither side had a bubble.

Saying who is speaking in words rather than in geometry is also what survives
being read aloud, printed, or looked at by someone who cannot pick the sides
apart by colour.

Full e2e suite: 404 passed, 4 skipped.

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

* feat(ui): dress the API reference in LocalAI's palette

The Swagger page was the last surface still shipping in someone else's colours,
which is conspicuous now that everything it links from is dark.

Swagger UI has no theming hook, so rather than fork it we serve our own index
ahead of the library's wildcard and restate the palette over its stylesheet.
The library's own bundle and assets are still what load, so a swagger-ui
upgrade cannot silently break the page — this is a skin, not a fork.

Two things needed real care. Swagger tints the entire operation row per method
via .opblock.opblock-post and friends, so the palette had to match that
specificity rather than reach for !important; the method now lives on one edge
instead of washing across the row, because a page where every row is a status
colour has no status colour left. And the filled method chip put white on pale
green, which was the least readable thing on the page — it is an outlined mono
chip now, carrying the method in its border and text.

Palette values are copied from theme.css rather than referenced: this page is
served by Go and never sees the app's CSS. The comment says so, and says to
keep them in step.

Go: routes and middleware suites pass. Full e2e suite: 405 passed, 4 skipped.

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

* fix(ui): make tall split-view pages reachable, repair the Agents header, scale titles

Three things found by actually using the app rather than measuring it.

**Host was unusable.** The shell above a split view is overflow:hidden so the
document cannot grow, which left anything taller than the viewport simply
unreachable — and Host stacks a resources card, four stat cards and a tab bar
above its split, so the bottom of the pane fell off at every window height with
nothing to scroll. Every sweep I ran for this was horizontal, which is why it
kept coming back clean.

The page now scrolls inside the pinned shell. The pane keeps its own scroller:
letting it grow instead pushes the document taller and stretches the rail to
match, which is the regression e2e/discover-height.spec.js exists to catch, and
which the first version of this fix duly caused.

**The Agents header controls were unstyled** — "Create Agent" was rendering
with the browser's default chrome. The markup had been mangled at some point:
six unrelated classes merged into one string on the link, and the label and
button left with none at all and empty icons. Repaired, with the inline flex
replaced by a shared .header-actions class.

**Page titles take the editorial scale from the site**: larger, tracked at
-0.04em, on a line height near 1, so a two-word title reads as a statement
rather than a label. The typeface is unchanged — DESIGN.md keeps the existing
type system — so the whole difference is scale, tracking and leading, which is
where the site gets its voice from. This was the biggest reason the running app
still did not look like the mocks.

Full e2e suite: 404 passed, 4 skipped.

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

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
mudler's LocalAI [bot]
2026-08-03 00:16:08 +02:00
committed by GitHub
parent b89b0f73e5
commit 58ea2f5d79
79 changed files with 3027 additions and 288 deletions

View File

@@ -3,6 +3,7 @@ package localai
import (
"net/http"
"strconv"
"time"
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/http/middleware"
@@ -85,6 +86,35 @@ func GetAPITracesEndpoint() echo.HandlerFunc {
}
}
// GetAPITracesSummaryEndpoint returns counted totals over a recent window
// @Summary Summarize recent API traces
// @Description Returns request, failure and latency totals over a recent window, plus a bucketed series for sparklines. Exists so callers wanting three numbers do not have to fetch the whole trace list and count it themselves.
// @Tags monitoring
// @Produce json
// @Param hours query int false "Window in hours (default 24, max 168)"
// @Success 200 {object} middleware.TraceSummary "Counted trace totals"
// @Router /api/traces/summary [get]
func GetAPITracesSummaryEndpoint() echo.HandlerFunc {
return func(c echo.Context) error {
hours := 24
if raw := c.QueryParam("hours"); raw != "" {
if v, err := strconv.Atoi(raw); err == nil && v > 0 {
hours = v
}
}
// A week is plenty for a dashboard, and the trace buffer is bounded
// anyway; an unbounded window would just scan the whole buffer.
if hours > 168 {
hours = 168
}
return c.JSON(http.StatusOK, middleware.GetTracesSummary(time.Duration(hours)*time.Hour, traceSummaryBuckets))
}
}
// Enough columns for a sparkline to show a shape, few enough that each one
// still holds a meaningful count on a quiet installation.
const traceSummaryBuckets = 12
// GetAPITraceEndpoint returns a single API trace with its full payload
// @Summary Get one API trace
// @Description Returns a single captured API exchange, including the request and response bodies omitted from the list response

View File

@@ -0,0 +1,109 @@
// SPDX-License-Identifier: MIT
package middleware
import (
"math"
"slices"
"time"
)
// TraceSummary is the counted view of the trace buffer.
//
// It exists so a caller that wants "how many, how many failed, how slow" does
// not have to fetch every exchange and count them in the browser. The Operate
// overview needs exactly those three numbers, and the trace list is capped in
// the thousands, so shipping it across the wire to produce a single integer is
// waste that grows with the buffer.
type TraceSummary struct {
Total int `json:"total"`
Errors int `json:"errors"`
P95Millis int64 `json:"p95_ms"`
WindowHours int `json:"window_hours"`
Buckets []TraceBucket `json:"buckets"`
}
// TraceBucket is one column of a sparkline: oldest first, so the series reads
// left to right the way a chart is drawn.
type TraceBucket struct {
Start time.Time `json:"start"`
Count int `json:"count"`
Errors int `json:"errors"`
}
// GetTracesSummary counts the buffered exchanges over the given window.
func GetTracesSummary(window time.Duration, buckets int) TraceSummary {
return summarize(GetTraces(), window, buckets)
}
func summarize(traces []APIExchange, window time.Duration, buckets int) TraceSummary {
if buckets < 1 {
buckets = 1
}
now := time.Now()
cutoff := now.Add(-window)
summary := TraceSummary{
WindowHours: int(window.Hours()),
// Never nil: a nil slice serialises as null and breaks .map() on the
// other side, which is a silent runtime error rather than an empty chart.
Buckets: make([]TraceBucket, buckets),
}
bucketWidth := window / time.Duration(buckets)
for i := range summary.Buckets {
summary.Buckets[i].Start = cutoff.Add(time.Duration(i) * bucketWidth)
}
durations := make([]time.Duration, 0, len(traces))
for _, t := range traces {
if t.Timestamp.Before(cutoff) {
continue
}
summary.Total++
failed := isFailure(t)
if failed {
summary.Errors++
}
durations = append(durations, t.Duration)
// Clamp rather than skip: a request timestamped a hair in the future
// (clock skew, or arriving mid-call) still belongs in the newest column.
idx := int(t.Timestamp.Sub(cutoff) / bucketWidth)
if idx >= buckets {
idx = buckets - 1
}
if idx < 0 {
idx = 0
}
summary.Buckets[idx].Count++
if failed {
summary.Buckets[idx].Errors++
}
}
summary.P95Millis = percentileMillis(durations, 0.95)
return summary
}
// A 4xx is the caller getting it wrong, which is not the installation being
// unhealthy. Only 5xx and a transport-level error count against the runtime.
func isFailure(t APIExchange) bool {
return t.Error != "" || t.Response.Status >= 500
}
func percentileMillis(durations []time.Duration, p float64) int64 {
if len(durations) == 0 {
return 0
}
slices.Sort(durations)
// Nearest-rank: the smallest value at or above the pth percentile.
rank := int(math.Ceil(p*float64(len(durations)))) - 1
if rank < 0 {
rank = 0
}
if rank >= len(durations) {
rank = len(durations) - 1
}
return durations[rank].Milliseconds()
}

View File

@@ -0,0 +1,79 @@
// SPDX-License-Identifier: MIT
package middleware
import (
"time"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("API trace summary", func() {
exchange := func(age time.Duration, status int, dur time.Duration) APIExchange {
return APIExchange{
Timestamp: time.Now().Add(-age),
Duration: dur,
Response: APIExchangeResponse{Status: status},
}
}
It("counts only what falls inside the window", func() {
traces := []APIExchange{
exchange(1*time.Hour, 200, 10*time.Millisecond),
exchange(2*time.Hour, 200, 10*time.Millisecond),
// Older than the window: must not be counted at all.
exchange(48*time.Hour, 500, 10*time.Millisecond),
}
s := summarize(traces, 24*time.Hour, 6)
Expect(s.Total).To(Equal(2))
Expect(s.Errors).To(BeZero())
})
It("treats 5xx and a transport error as failures, but not 4xx", func() {
traces := []APIExchange{
exchange(time.Minute, 500, time.Millisecond),
exchange(time.Minute, 503, time.Millisecond),
// A client sending a bad request is not the server failing.
exchange(time.Minute, 404, time.Millisecond),
exchange(time.Minute, 200, time.Millisecond),
}
traces[3].Error = "connection reset"
s := summarize(traces, 24*time.Hour, 6)
Expect(s.Total).To(Equal(4))
Expect(s.Errors).To(Equal(3))
})
It("reports p95 as a real percentile rather than the slowest request", func() {
traces := make([]APIExchange, 0, 100)
for i := 1; i <= 100; i++ {
traces = append(traces, exchange(time.Minute, 200, time.Duration(i)*time.Millisecond))
}
s := summarize(traces, 24*time.Hour, 6)
// 95th of 1..100ms, not the 100ms max.
Expect(s.P95Millis).To(BeNumerically("~", 95, 1))
})
It("buckets oldest-first so a sparkline reads left to right", func() {
traces := []APIExchange{
exchange(30*time.Minute, 200, time.Millisecond),
exchange(30*time.Minute, 200, time.Millisecond),
exchange(5*time.Hour, 200, time.Millisecond),
}
s := summarize(traces, 6*time.Hour, 6)
Expect(s.Buckets).To(HaveLen(6))
Expect(s.Buckets[0].Count).To(Equal(1), "the 5h-old request lands in the first bucket")
Expect(s.Buckets[5].Count).To(Equal(2), "the recent pair lands in the last")
})
It("returns an empty, non-nil summary when nothing has been traced", func() {
s := summarize(nil, 24*time.Hour, 6)
Expect(s.Total).To(BeZero())
Expect(s.Errors).To(BeZero())
Expect(s.P95Millis).To(BeZero())
// A nil slice serialises as null and breaks .map() in the browser.
Expect(s.Buckets).NotTo(BeNil())
Expect(s.Buckets).To(HaveLen(6))
})
})

View File

@@ -5,7 +5,9 @@ test.describe('Admin console', () => {
await page.goto('/app/backends')
const rail = page.locator('.console-rail')
await expect(rail).toBeVisible()
for (const group of ['Inference', 'Cluster', 'Observability', 'Access', 'System']) {
// Four groups since the overview landed: Inference folded into Runtime
// (both are "the runtime right now"), Access and System into Administration.
for (const group of ['Runtime', 'Cluster', 'Observability', 'Administration']) {
await expect(rail.locator('.console-group-title', { hasText: group })).toBeVisible()
}
})

View File

@@ -0,0 +1,55 @@
import { test, expect } from './coverage-fixtures.js'
// Chat reads as a transcript rather than a bubble thread (mock 04).
const CHAT = {
chats: [{
id: 'c1', name: 'Transcript', model: 'mock-model',
history: [
{ role: 'user', content: 'Which backends do I have?' },
{ role: 'assistant', content: 'Seven are installed.' },
],
}],
activeChatId: 'c1',
}
test.describe('Chat transcript', () => {
test.beforeEach(async ({ page }) => {
await page.addInitScript(chat => {
localStorage.setItem('localai_chats_data', JSON.stringify(chat))
}, CHAT)
await page.goto('/app/chat')
})
test('neither role is a filled, rounded bubble', async ({ page }) => {
const user = page.locator('.chat-message-user .chat-message-content').first()
await expect(user).toBeVisible()
const cs = await user.evaluate(el => {
const s = getComputedStyle(el)
return { radius: s.borderTopLeftRadius, shadow: s.boxShadow }
})
// A rounded filled bubble carries the speaker in shape and side; a
// transcript carries it in words, which survives being read aloud.
expect(cs.radius).toBe('0px')
expect(cs.shadow).toBe('none')
})
test('both turns run full width in one column, not left and right', async ({ page }) => {
const user = page.locator('.chat-message-user').first()
const assistant = page.locator('.chat-message-assistant').first()
const [u, a] = [await user.boundingBox(), await assistant.boundingBox()]
expect(Math.abs(u.x - a.x)).toBeLessThan(2)
})
test('every turn says who is speaking', async ({ page }) => {
await expect(page.locator('.chat-message-user .chat-message-model')).toHaveText('You')
await expect(page.locator('.chat-message-assistant .chat-message-model').first())
.toHaveText('mock-model')
})
test('turns are separated by a rule', async ({ page }) => {
const border = await page.locator('.chat-message').first()
.evaluate(el => getComputedStyle(el).borderBottomStyle)
expect(border).toBe('solid')
})
})

View File

@@ -0,0 +1,68 @@
import { test, expect } from './coverage-fixtures.js'
// Small-screen behaviour of the Operate console and the dashboard stat cards.
//
// Both defects here are about a narrow viewport but neither is only a narrow
// viewport problem: the stat cards were being laid out by the wrong rule at
// every width, and the rail's height was never bounded.
test.describe('Operate console on a narrow screen', () => {
test('expanding the rail leaves the page still on screen', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 800 })
await page.goto('/app/manage')
const toggle = page.locator('.console-rail-toggle')
await expect(toggle).toBeVisible()
await toggle.click()
await expect(page.locator('.console-rail-groups')).toBeVisible()
// Thirteen destinations in one column is taller than a phone. If opening
// the menu pushes the page's own heading past the fold, the menu has
// replaced the page instead of annotating it.
// Manage titles itself with .view-bar__title rather than .page-title.
const heading = page.locator('.page-title, .view-bar__title').first()
const box = await heading.boundingBox()
expect(box).not.toBeNull()
expect(box.y).toBeLessThan(800)
})
test('the rail scrolls internally rather than growing without bound', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 800 })
await page.goto('/app/manage')
await page.locator('.console-rail-toggle').click()
const groups = page.locator('.console-rail-groups')
await expect(groups).toBeVisible()
const height = await groups.evaluate(el => el.getBoundingClientRect().height)
expect(height).toBeLessThan(800)
})
})
test.describe('Dashboard stat cards', () => {
// Two components both claimed `.stat-grid`: the dashboard card strip and the
// detail-pane StatGrid added with the split views. The later rule won, so the
// cards were laid out on 120px columns with a 1px gap meant for something
// else, and their labels were clipped.
for (const width of [768, 1024]) {
test(`labels are not clipped at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width, height: 1000 })
await page.goto('/app/manage')
const labels = page.locator('.stat-card__label')
await expect(labels.first()).toBeVisible()
const clipped = await labels.evaluateAll(els =>
els.filter(el => el.scrollWidth > el.clientWidth + 1).map(el => el.textContent))
expect(clipped).toEqual([])
})
}
test('cards keep the card gap, not the hairline gap of the detail pane grid', async ({ page }) => {
await page.setViewportSize({ width: 1024, height: 1000 })
await page.goto('/app/manage')
const strip = page.locator('.manage-summary')
await expect(strip).toBeVisible()
const gap = await strip.evaluate(el => parseFloat(getComputedStyle(el).columnGap))
// 1px is the detail-pane StatGrid's hairline; the card strip wants real space.
expect(gap).toBeGreaterThan(4)
})
})

View File

@@ -0,0 +1,76 @@
import { test, expect } from './coverage-fixtures.js'
// Home's resident-model list and the app footer.
const SYS_INFO = {
backends: ['llama-cpp'],
loaded_models: [{ id: 'qwen3-8b-instruct' }, { id: 'parakeet-tdt-0.6b' }],
}
async function mockLoaded(page) {
await page.route('**/system', route => route.fulfill({ json: SYS_INFO }))
await page.route('**/v1/models', route =>
route.fulfill({ json: { data: [{ id: 'qwen3-8b-instruct' }, { id: 'parakeet-tdt-0.6b' }] } }))
}
test.describe('Home resident models', () => {
test('resident models read as lanes, not status chips', async ({ page }) => {
await mockLoaded(page)
await page.goto('/app')
const lanes = page.locator('.home-loaded .lane')
await expect(lanes).toHaveCount(2)
// Model ids are identifiers, so they are set in mono like every other
// identifier in the app.
const family = await lanes.first().locator('.lane__name').evaluate(
el => getComputedStyle(el).fontFamily.toLowerCase())
expect(family).toMatch(/mono|consol|menlo/)
})
test('each lane keeps its stop control', async ({ page }) => {
await mockLoaded(page)
await page.goto('/app')
const lane = page.locator('.home-loaded .lane').first()
await expect(lane.getByRole('button', { name: /stop/i })).toBeVisible()
})
test('the header reports how many are resident as a figure', async ({ page }) => {
await mockLoaded(page)
await page.goto('/app')
const stat = page.locator('[data-testid="home-stat-loaded"]')
await expect(stat).toBeVisible()
await expect(stat).toContainText('2')
// Digits that sit in a column need to line up.
const numeric = await stat.locator('.home-stat__value').evaluate(
el => getComputedStyle(el).fontVariantNumeric)
expect(numeric).toContain('tabular-nums')
})
test('nothing resident still says so', async ({ page }) => {
await page.route('**/system', route =>
route.fulfill({ json: { backends: ['llama-cpp'], loaded_models: [] } }))
await page.route('**/v1/models', route => route.fulfill({ json: { data: [{ id: 'a-model' }] } }))
await page.goto('/app')
await expect(page.locator('.home-loaded-empty')).toBeVisible()
await expect(page.locator('.home-loaded .lane')).toHaveCount(0)
})
})
test.describe('App footer', () => {
test('is one line, not three stacked rows', async ({ page }) => {
await page.goto('/app')
const footer = page.locator('.app-footer')
await expect(footer).toBeVisible()
// Three centred rows of chrome cost more vertical space than the content
// they sit under is usually worth.
const height = await footer.evaluate(el => el.getBoundingClientRect().height)
expect(height).toBeLessThan(56)
})
test('keeps every link it had', async ({ page }) => {
await page.goto('/app')
const footer = page.locator('.app-footer')
for (const name of [/github/i, /documentation/i, /author/i]) {
await expect(footer.getByRole('link', { name })).toBeVisible()
}
})
})

View File

@@ -1786,6 +1786,19 @@ test.describe("Models Gallery - Discover split view", () => {
);
});
test("a build that fits at some context sizes warns rather than erroring", async ({
page,
}) => {
await railItem(page, "llama-model").click();
const verdict = page.locator(".discover__chart-verdict");
await expect(verdict).toBeVisible();
// A model that fits at 32k but not 64k is a trade-off, and #11288 keeps a
// test on such a build still being installable. Only "fits nowhere" earns
// the error tone; anything short of that warns.
await expect(verdict).toHaveClass(/discover__chart-verdict--warn/);
await expect(verdict).not.toHaveClass(/discover__chart-verdict--bad/);
});
test("a host with no GPU gets no chart rather than an unanchored one", async ({
page,
}) => {

View File

@@ -0,0 +1,158 @@
import { test, expect } from './coverage-fixtures.js'
// Operate overview (src/pages/OperateOverview.jsx).
//
// The page exists to answer "is anything wrong" without visiting four other
// pages, so the tests are written against that behaviour rather than against
// the markup: what does it say when nothing is wrong, and does each source of
// trouble actually surface.
const OVERVIEW = '[data-testid="operate-overview"]'
const CLEAR = '[data-testid="operate-attention-clear"]'
const ITEM = '[data-testid="operate-attention-item"]'
const NO_UPGRADES = {}
const ONE_UPGRADE = {
'llama-cpp': {
backend_name: 'llama-cpp',
installed_version: '0.9.4',
available_version: '0.9.7',
},
}
// A quiet installation: nothing running, nothing stale, every node healthy.
async function mockQuiet(page, { upgrades = NO_UPGRADES, operations = [] } = {}) {
await page.route('**/api/backends/upgrades', route =>
route.fulfill({ json: upgrades }))
await page.route('**/api/operations', route =>
route.fulfill({ json: operations }))
await page.route('**/api/nodes', route =>
route.fulfill({ json: [{ id: 'node-a', status: 'healthy', healthy: true }] }))
}
test.describe('Operate overview', () => {
test('Operate opens the overview, not whichever page happens to be first', async ({ page }) => {
await mockQuiet(page)
await page.goto('/app')
await page.locator('.sidebar-nav a.nav-item', { hasText: 'Operate' }).click()
// Today this lands on /app/backends purely because Backends is the first
// entry in operateConsole.groups — an ordering accident, not a decision.
await expect(page).toHaveURL(/\/app\/operate$/)
await expect(page.locator(OVERVIEW)).toBeVisible()
})
test('says so plainly when nothing needs attention', async ({ page }) => {
await mockQuiet(page)
await page.goto('/app/operate')
await expect(page.locator(CLEAR)).toBeVisible()
// The empty state is one line, not a panel full of reassuring green.
await expect(page.locator(ITEM)).toHaveCount(0)
})
test('a stale backend becomes an attention item naming the version jump', async ({ page }) => {
await mockQuiet(page, { upgrades: ONE_UPGRADE })
await page.goto('/app/operate')
const item = page.locator(ITEM, { hasText: 'llama-cpp' })
await expect(item).toBeVisible()
await expect(item).toContainText('0.9.4')
await expect(item).toContainText('0.9.7')
await expect(page.locator(CLEAR)).toHaveCount(0)
})
test('a failed operation becomes an attention item', async ({ page }) => {
await mockQuiet(page, {
operations: [{ id: 'op-1', name: 'qwen3-8b', type: 'install', error: 'no space left on device' }],
})
await page.goto('/app/operate')
await expect(page.locator(ITEM, { hasText: 'qwen3-8b' })).toBeVisible()
})
test('the rail reports backend updates alongside the label', async ({ page }) => {
await mockQuiet(page, { upgrades: ONE_UPGRADE })
await page.goto('/app/operate')
const backends = page.locator('.console-rail a.nav-item[href="/app/backends"]')
await expect(backends).toBeVisible()
await expect(backends.locator('.nav-signal')).toContainText('1')
})
test('the rail groups Runtime, Cluster, Observability and Administration', async ({ page }) => {
await mockQuiet(page)
await page.goto('/app/operate')
const rail = page.locator('.console-rail')
for (const group of ['Runtime', 'Cluster', 'Observability', 'Administration']) {
await expect(rail.locator('.console-group-title', { hasText: group })).toBeVisible()
}
// Six headings for thirteen items was the defect; the old pairs are gone.
for (const gone of ['Inference', 'Access']) {
await expect(rail.locator('.console-group-title', { hasText: new RegExp(`^${gone}$`) })).toHaveCount(0)
}
})
test('regrouping does not change what a non-distributed host can see', async ({ page }) => {
await page.route('**/api/features', route =>
route.fulfill({ json: { distributed: false, agents: true, mcp: true } }))
await mockQuiet(page)
await page.goto('/app/operate')
const rail = page.locator('.console-rail')
await expect(rail.locator('a.nav-item[href="/app/backends"]')).toBeVisible()
// Gating is the thing most likely to break silently when items move group.
await expect(rail.locator('a.nav-item[href="/app/nodes"]')).toHaveCount(0)
await expect(rail.locator('a.nav-item[href="/app/scheduling"]')).toHaveCount(0)
})
test('the sidebar keeps its operations badge', async ({ page }) => {
// Regression guard: this change edits the same config the badge reads, and
// the badge deliberately lives on the always-visible sidebar entry rather
// than the collapsible rail.
await mockQuiet(page, {
operations: [{ id: 'op-1', name: 'qwen3-8b', type: 'install', progress: 40 }],
})
await page.goto('/app')
await expect(page.locator('.sidebar-nav .nav-badge')).toBeVisible()
})
test('does not poll the summary away from Operate', async ({ page }) => {
let upgradeCalls = 0
await page.route('**/api/backends/upgrades', route => {
upgradeCalls += 1
route.fulfill({ json: NO_UPGRADES })
})
await page.route('**/api/operations', route => route.fulfill({ json: [] }))
await page.goto('/app/chat')
await expect(page.locator('.sidebar')).toBeVisible()
await page.waitForTimeout(1500)
// Nobody asked for this data outside Operate; a dashboard-shaped poll on
// every page is exactly what OperationsContext exists to avoid.
expect(upgradeCalls).toBe(0)
})
})
test.describe('Operate overview headline', () => {
const SUMMARY = {
total: 18402, errors: 37, p95_ms: 842, window_hours: 24,
buckets: Array.from({ length: 12 }, (_, i) => ({ count: 100 + i * 10, errors: i })),
}
test('reports counted totals rather than fetching the trace list', async ({ page }) => {
let listCalls = 0
await page.route('**/api/traces?**', route => { listCalls += 1; route.fulfill({ json: [] }) })
await page.route('**/api/traces/summary', route => route.fulfill({ json: SUMMARY }))
await mockQuiet(page)
await page.goto('/app/operate')
const headline = page.locator('.operate-headline')
await expect(headline).toBeVisible()
await expect(headline).toContainText('18,402')
await expect(headline).toContainText('37')
await expect(headline).toContainText('842')
// The whole point of the endpoint: three numbers, not the buffer.
expect(listCalls).toBe(0)
})
test('an installation that has served nothing says so instead of showing zeroes', async ({ page }) => {
await page.route('**/api/traces/summary', route =>
route.fulfill({ json: { total: 0, errors: 0, p95_ms: 0, window_hours: 24, buckets: [] } }))
await mockQuiet(page)
await page.goto('/app/operate')
await expect(page.locator('.operate-headline')).toHaveCount(0)
})
})

View File

@@ -19,6 +19,7 @@ const PAGES = [
['/app/account', 'Account'],
['/app/studio', 'Studio'],
['/app/manage', 'Manage'],
['/app/operate', 'Operate overview'],
['/app/backends', 'Backends'],
['/app/activity', 'Activity'],
['/app/settings', 'Settings'],

View File

@@ -0,0 +1,146 @@
import { test, expect } from './coverage-fixtures.js'
// Studio overview (src/pages/StudioOverview.jsx).
//
// Studio was a tab strip over six generators that opened on Images and told you
// nothing about what this machine could actually run. The tests are about that:
// what the strip reports before you click, and the difference between a
// modality that is switched off and one that merely has no model.
const OVERVIEW = '[data-testid="studio-overview"]'
const MODALITY = '[data-testid="studio-modality"]'
const tabFor = (page, key) => page.locator(`.studio-tab[data-tab="${key}"]`)
const model = (id, ...capabilities) => ({ id, capabilities })
// Images and speech covered, video and sound not. 3D and transform are feature
// flags rather than models, so they are controlled separately.
const SOME_MODELS = {
data: [
model('flux.1-schnell', 'FLAG_IMAGE'),
model('kokoro-82m', 'FLAG_TTS'),
model('qwen3-8b', 'FLAG_CHAT'),
],
}
async function mockCapabilities(page, payload = SOME_MODELS) {
await page.route('**/api/models/capabilities', route => route.fulfill({ json: payload }))
}
test.describe('Studio overview', () => {
test('Studio opens on the overview rather than dropping into Images', async ({ page }) => {
await mockCapabilities(page)
await page.goto('/app/studio')
await expect(page.locator(OVERVIEW)).toBeVisible()
})
test('an explicit tab still wins, so existing deep links keep working', async ({ page }) => {
await mockCapabilities(page)
await page.goto('/app/studio?tab=images')
await expect(page.locator(OVERVIEW)).toHaveCount(0)
await expect(page.locator('.media-layout')).toBeVisible()
})
test('an unrecognised tab falls back to the overview, not to Images', async ({ page }) => {
await mockCapabilities(page)
await page.goto('/app/studio?tab=nonsense')
await expect(page.locator(OVERVIEW)).toBeVisible()
})
test('the tab strip reports which modalities have a model', async ({ page }) => {
await mockCapabilities(page)
await page.goto('/app/studio')
// Filled: something installed advertises the capability.
await expect(tabFor(page, 'images').locator('.studio-tab__dot--on')).toBeVisible()
await expect(tabFor(page, 'tts').locator('.studio-tab__dot--on')).toBeVisible()
// Hollow: the modality is available, nothing serves it yet.
await expect(tabFor(page, 'video').locator('.studio-tab__dot--off')).toBeVisible()
await expect(tabFor(page, 'sound').locator('.studio-tab__dot--off')).toBeVisible()
})
test('a modality with no model offers a way to install one', async ({ page }) => {
await mockCapabilities(page)
await page.goto('/app/studio')
const video = page.locator(`${MODALITY}[data-modality="video"]`)
await expect(video).toBeVisible()
// The point of the lane: not a dead tab, a route to fixing it.
await expect(video.locator('a[href*="/app/models"]')).toBeVisible()
})
test('a modality with a model names it instead of offering an install', async ({ page }) => {
await mockCapabilities(page)
await page.goto('/app/studio')
const images = page.locator(`${MODALITY}[data-modality="images"]`)
await expect(images).toContainText('flux.1-schnell')
await expect(images.locator('a[href*="/app/models"]')).toHaveCount(0)
})
test('a disabled feature gets no tab and no lane at all', async ({ page }) => {
// Switched off is a different thing from "no model installed", and
// conflating them is how someone ends up staring at a control that cannot
// work. 3d is a permission rather than an /api/features entry, and
// hasFeature() short-circuits to true for admins and for auth-off
// installations, so withholding it needs a real non-admin session.
await page.route('**/api/auth/status', route => route.fulfill({
json: {
authEnabled: true,
user: { name: 'someone', role: 'user', permissions: { images: true, video: true, tts: true, sound: true } },
},
}))
await mockCapabilities(page)
await page.goto('/app/studio')
await expect(page.locator(OVERVIEW)).toBeVisible()
await expect(tabFor(page, 'threed')).toHaveCount(0)
await expect(page.locator(`${MODALITY}[data-modality="threed"]`)).toHaveCount(0)
})
test('asks the capabilities endpoint once, not once per modality', async ({ page }) => {
let calls = 0
await page.route('**/api/models/capabilities', route => {
calls += 1
route.fulfill({ json: SOME_MODELS })
})
await page.goto('/app/studio')
await expect(page.locator(OVERVIEW)).toBeVisible()
await page.waitForTimeout(500)
// useModels() fetches the whole list and filters in the browser, so one
// hook per modality would be six identical requests on every mount.
expect(calls).toBe(1)
})
test('an installation with no models at all still renders every modality', async ({ page }) => {
await mockCapabilities(page, { data: [] })
await page.goto('/app/studio')
await expect(page.locator(OVERVIEW)).toBeVisible()
await expect(page.locator(MODALITY).first()).toBeVisible()
await expect(page.locator('.studio-tab__dot--on')).toHaveCount(0)
})
test('recent outputs surface what was generated earlier', async ({ page }) => {
await mockCapabilities(page)
// History is localStorage, written by each generator. The overview is the
// first place it is read across modalities rather than within one.
await page.addInitScript(() => {
localStorage.setItem('localai_image_history', JSON.stringify([
{ id: 'i1', createdAt: Date.now(), model: 'flux.1-schnell', prompt: 'a brass orrery', elapsedMs: 6100 },
]))
})
await page.goto('/app/studio')
const shelf = page.locator('[data-testid="studio-recent"]')
await expect(shelf).toBeVisible()
await expect(shelf).toContainText('flux.1-schnell')
})
test('no history means no empty shelf', async ({ page }) => {
await mockCapabilities(page)
await page.goto('/app/studio')
await expect(page.locator('[data-testid="studio-recent"]')).toHaveCount(0)
})
test('the overview is reachable back from a generator tab', async ({ page }) => {
await mockCapabilities(page)
await page.goto('/app/studio?tab=images')
await tabFor(page, 'overview').click()
await expect(page.locator(OVERVIEW)).toBeVisible()
})
})

View File

@@ -0,0 +1,49 @@
import { test, expect } from './coverage-fixtures.js'
// The generator workbenches (mock 5b/5c): the control column and the record of
// what the form actually sent.
test.describe('Studio workbench', () => {
test('the control column is a hairline field stack, not a shadowed card', async ({ page }) => {
await page.goto('/app/studio?tab=images')
const controls = page.locator('.media-controls')
await expect(controls).toBeVisible()
const style = await controls.evaluate(el => {
const cs = getComputedStyle(el)
return { shadow: cs.boxShadow, radius: cs.borderTopLeftRadius }
})
expect(style.shadow).toBe('none')
expect(style.radius).toBe('0px')
})
test('fields are separated by a rule and labelled in caps', async ({ page }) => {
await page.goto('/app/studio?tab=images')
const label = page.locator('.media-controls .form-label').first()
await expect(label).toBeVisible()
const cs = await label.evaluate(el => getComputedStyle(el).textTransform)
expect(cs).toBe('uppercase')
})
test('no request is shown before one has been made', async ({ page }) => {
// A panel describing a request nobody sent is a tutorial, not a record.
await page.goto('/app/studio?tab=images')
await expect(page.locator('.request-panel')).toHaveCount(0)
})
test('generating records the request that was actually sent', async ({ page }) => {
await page.route('**/api/models/capabilities', route =>
route.fulfill({ json: { data: [{ id: 'flux-mock', capabilities: ['FLAG_IMAGE'] }] } }))
await page.route('**/v1/images/generations', route =>
route.fulfill({ json: { data: [{ url: 'https://example.invalid/a.png' }] } }))
await page.goto('/app/studio?tab=images')
await page.locator('.media-controls textarea').first().fill('a brass orrery')
await page.getByRole('button', { name: /generate/i }).click()
const panel = page.locator('.request-panel')
await expect(panel).toBeVisible()
await expect(panel).toContainText('/v1/images/generations')
await expect(panel).toContainText('a brass orrery')
await expect(panel.getByRole('button', { name: /curl/i })).toBeVisible()
})
})

View File

@@ -0,0 +1,15 @@
import { test, expect } from './coverage-fixtures.js'
test.describe('Theme default', () => {
test('a fresh install opens dark even when the OS prefers light', async ({ page }) => {
await page.emulateMedia({ colorScheme: 'light' })
await page.goto('/app')
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark')
})
test('a stored choice still wins', async ({ page }) => {
await page.addInitScript(() => localStorage.setItem('localai-theme', 'light'))
await page.goto('/app')
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light')
})
})

View File

@@ -278,7 +278,9 @@ test.describe('3D generation', () => {
await page.goto('/app/studio?tab=threed')
await expect(page.getByRole('button', { name: '3D', exact: true })).toHaveCount(0)
await expect(page.locator('.studio-tab', { hasText: 'Images' })).toHaveClass(/studio-tab-active/)
// Falls back to the overview rather than Images. Landing on Images was
// never a decision, only the first entry in the tab array.
await expect(page.locator('.studio-tab[data-tab="overview"]')).toHaveClass(/studio-tab-active/)
await page.goto('/app/3d')
await expect(page).toHaveURL(/\/app\/?$/)

View File

@@ -143,5 +143,35 @@
"explorer": {
"title": "Explorer",
"subtitle": "Dateien und Konfiguration durchsuchen"
},
"operate": {
"overview": {
"title": "Overview",
"subtitle": "Everything running on this installation, and anything that wants a decision.",
"attention": {
"heading": "Needs attention",
"clear": "Nothing needs attention. Backends are current, no operation has failed, and every node is healthy.",
"backendUpdate": "Update available: {{from}} → {{to}}"
},
"sections": {
"heading": "Sections",
"runtime": "Runtime",
"runtimeSummary": "{{updates}} backend updates · {{running}} operations running",
"cluster": "Cluster",
"clusterSummary": "{{nodes}} nodes",
"observability": "Observability",
"observabilitySummary": "Usage and traces",
"administration": "Administration",
"administrationSummary": "Users, middleware, host and settings",
"clusterSingle": "Single node",
"observabilityCounted": "{{requests}} requests · {{errors}} failed · p95 {{p95}} ms"
},
"headline": {
"requests": "Requests · {{hours}}h",
"errors": "Failed requests",
"p95": "p95 latency",
"quiet": "Nothing has been served yet. Totals appear once requests start arriving."
}
}
}
}

View File

@@ -118,5 +118,8 @@
"newChat": "Neuer Chat",
"clearAll": "Alle löschen",
"deleteAllTitle": "Alle Unterhaltungen löschen"
},
"message": {
"you": "You"
}
}

View File

@@ -17,7 +17,9 @@
"modelsLoaded_other": "{{count}} models loaded",
"noModelsLoaded": "No models loaded",
"nodes_one": "{{count}} node",
"nodes_other": "{{count}} nodes"
"nodes_other": "{{count}} nodes",
"loadedLabel": "Loaded",
"nodesLabel": "Nodes"
},
"assistant": {
"title": "LocalAI per Chat verwalten",
@@ -47,7 +49,8 @@
"count_one": "{{count}} Modell geladen",
"count_other": "{{count}} Modelle geladen",
"stop": "Modell stoppen",
"stopAll": "Alle stoppen"
"stopAll": "Alle stoppen",
"serving": "Serving"
},
"stopDialog": {
"title": "Modell stoppen",

View File

@@ -5,6 +5,32 @@
"video": "Video",
"tts": "TTS",
"sound": "Audio",
"transform": "Transform",
"overview": "Overview"
},
"overview": {
"eyebrow": "{{ready}} of {{total}} modalities ready",
"title": "Studio",
"subtitle": "Generate images, video, 3D, speech and sound with the models on this machine.",
"canMake": "What you can make",
"running": "Running now",
"recent": "Recent outputs",
"noModel": "No model installed",
"install": "Install a model",
"ready": "Ready",
"seconds": "{{seconds}}s",
"describe": {
"images": "Text to image, image to image, reference images",
"video": "Text to video and image to video",
"threed": "Image to mesh reconstruction",
"tts": "Text to speech using your voice library",
"sound": "Music and sound effects from a prompt",
"transform": "Separation, enhancement and voice conversion"
}
},
"groups": {
"create": "Create",
"voice": "Voice",
"transform": "Transform"
}
},
@@ -157,5 +183,10 @@
"clearMessage": "Alle Verlaufseinträge entfernen? Diese Aktion kann nicht rückgängig gemacht werden.",
"clearConfirm": "Löschen",
"cleared": "Verlauf gelöscht"
},
"request": {
"heading": "Request",
"copyCurl": "Copy as curl",
"copied": "Copied"
}
}

View File

@@ -24,7 +24,9 @@
"observability": "Observability",
"access": "Access",
"system": "System",
"activity": "Activity"
"activity": "Activity",
"runtime": "Laufzeit",
"administration": "Verwaltung"
},
"items": {
"home": "Start",
@@ -57,7 +59,8 @@
"settings": "Einstellungen",
"api": "API",
"middleware": "Middleware",
"activity": "Aktivität"
"activity": "Aktivität",
"overview": "Übersicht"
},
"footer": {
"github": "GitHub",

View File

@@ -166,5 +166,35 @@
"explorer": {
"title": "Explorer",
"subtitle": "Browse files and configuration"
},
"operate": {
"overview": {
"title": "Overview",
"subtitle": "Everything running on this installation, and anything that wants a decision.",
"attention": {
"heading": "Needs attention",
"clear": "Nothing needs attention. Backends are current, no operation has failed, and every node is healthy.",
"backendUpdate": "Update available: {{from}} → {{to}}"
},
"sections": {
"heading": "Sections",
"runtime": "Runtime",
"runtimeSummary": "{{updates}} backend updates · {{running}} operations running",
"cluster": "Cluster",
"clusterSummary": "{{nodes}} nodes",
"observability": "Observability",
"observabilitySummary": "Usage and traces",
"administration": "Administration",
"administrationSummary": "Users, middleware, host and settings",
"clusterSingle": "Single node",
"observabilityCounted": "{{requests}} requests · {{errors}} failed · p95 {{p95}} ms"
},
"headline": {
"requests": "Requests · {{hours}}h",
"errors": "Failed requests",
"p95": "p95 latency",
"quiet": "Nothing has been served yet. Totals appear once requests start arriving."
}
}
}
}

View File

@@ -124,5 +124,8 @@
"newChat": "New chat",
"clearAll": "Clear all",
"deleteAllTitle": "Delete all conversations"
},
"message": {
"you": "You"
}
}

View File

@@ -17,7 +17,9 @@
"modelsLoaded_other": "{{count}} models loaded",
"noModelsLoaded": "No models loaded",
"nodes_one": "{{count}} node",
"nodes_other": "{{count}} nodes"
"nodes_other": "{{count}} nodes",
"loadedLabel": "Loaded",
"nodesLabel": "Nodes"
},
"assistant": {
"title": "Manage LocalAI by chatting",
@@ -47,7 +49,8 @@
"count_one": "{{count}} model loaded",
"count_other": "{{count}} models loaded",
"stop": "Stop model",
"stopAll": "Stop all"
"stopAll": "Stop all",
"serving": "Serving"
},
"stopDialog": {
"title": "Stop Model",

View File

@@ -6,7 +6,33 @@
"tts": "TTS",
"sound": "Sound",
"transform": "Transform",
"threed": "3D"
"threed": "3D",
"overview": "Overview"
},
"overview": {
"eyebrow": "{{ready}} of {{total}} modalities ready",
"title": "Studio",
"subtitle": "Generate images, video, 3D, speech and sound with the models on this machine.",
"canMake": "What you can make",
"running": "Running now",
"recent": "Recent outputs",
"noModel": "No model installed",
"install": "Install a model",
"ready": "Ready",
"seconds": "{{seconds}}s",
"describe": {
"images": "Text to image, image to image, reference images",
"video": "Text to video and image to video",
"threed": "Image to mesh reconstruction",
"tts": "Text to speech using your voice library",
"sound": "Music and sound effects from a prompt",
"transform": "Separation, enhancement and voice conversion"
}
},
"groups": {
"create": "Create",
"voice": "Voice",
"transform": "Transform"
}
},
"image": {
@@ -426,5 +452,10 @@
"clearMessage": "Remove all history entries? This cannot be undone.",
"clearConfirm": "Clear",
"cleared": "History cleared"
},
"request": {
"heading": "Request",
"copyCurl": "Copy as curl",
"copied": "Copied"
}
}

View File

@@ -24,7 +24,9 @@
"observability": "Observability",
"access": "Access",
"system": "System",
"activity": "Activity"
"activity": "Activity",
"runtime": "Runtime",
"administration": "Administration"
},
"items": {
"home": "Home",
@@ -58,7 +60,8 @@
"system": "System",
"settings": "Settings",
"api": "API",
"activity": "Activity"
"activity": "Activity",
"overview": "Overview"
},
"footer": {
"github": "GitHub",

View File

@@ -143,5 +143,35 @@
"explorer": {
"title": "Explorador",
"subtitle": "Explora archivos y configuración"
},
"operate": {
"overview": {
"title": "Overview",
"subtitle": "Everything running on this installation, and anything that wants a decision.",
"attention": {
"heading": "Needs attention",
"clear": "Nothing needs attention. Backends are current, no operation has failed, and every node is healthy.",
"backendUpdate": "Update available: {{from}} → {{to}}"
},
"sections": {
"heading": "Sections",
"runtime": "Runtime",
"runtimeSummary": "{{updates}} backend updates · {{running}} operations running",
"cluster": "Cluster",
"clusterSummary": "{{nodes}} nodes",
"observability": "Observability",
"observabilitySummary": "Usage and traces",
"administration": "Administration",
"administrationSummary": "Users, middleware, host and settings",
"clusterSingle": "Single node",
"observabilityCounted": "{{requests}} requests · {{errors}} failed · p95 {{p95}} ms"
},
"headline": {
"requests": "Requests · {{hours}}h",
"errors": "Failed requests",
"p95": "p95 latency",
"quiet": "Nothing has been served yet. Totals appear once requests start arriving."
}
}
}
}

View File

@@ -118,5 +118,8 @@
"newChat": "Nuevo chat",
"clearAll": "Borrar todo",
"deleteAllTitle": "Eliminar todas las conversaciones"
},
"message": {
"you": "You"
}
}

View File

@@ -17,7 +17,9 @@
"modelsLoaded_other": "{{count}} models loaded",
"noModelsLoaded": "No models loaded",
"nodes_one": "{{count}} node",
"nodes_other": "{{count}} nodes"
"nodes_other": "{{count}} nodes",
"loadedLabel": "Loaded",
"nodesLabel": "Nodes"
},
"assistant": {
"title": "Administra LocalAI chateando",
@@ -47,7 +49,8 @@
"count_one": "{{count}} modelo cargado",
"count_other": "{{count}} modelos cargados",
"stop": "Detener modelo",
"stopAll": "Detener todos"
"stopAll": "Detener todos",
"serving": "Serving"
},
"stopDialog": {
"title": "Detener modelo",

View File

@@ -5,6 +5,32 @@
"video": "Video",
"tts": "TTS",
"sound": "Sonido",
"transform": "Transform",
"overview": "Overview"
},
"overview": {
"eyebrow": "{{ready}} of {{total}} modalities ready",
"title": "Studio",
"subtitle": "Generate images, video, 3D, speech and sound with the models on this machine.",
"canMake": "What you can make",
"running": "Running now",
"recent": "Recent outputs",
"noModel": "No model installed",
"install": "Install a model",
"ready": "Ready",
"seconds": "{{seconds}}s",
"describe": {
"images": "Text to image, image to image, reference images",
"video": "Text to video and image to video",
"threed": "Image to mesh reconstruction",
"tts": "Text to speech using your voice library",
"sound": "Music and sound effects from a prompt",
"transform": "Separation, enhancement and voice conversion"
}
},
"groups": {
"create": "Create",
"voice": "Voice",
"transform": "Transform"
}
},
@@ -157,5 +183,10 @@
"clearMessage": "¿Eliminar todas las entradas del historial? Esto no se puede deshacer.",
"clearConfirm": "Borrar",
"cleared": "Historial borrado"
},
"request": {
"heading": "Request",
"copyCurl": "Copy as curl",
"copied": "Copied"
}
}

View File

@@ -24,7 +24,9 @@
"observability": "Observability",
"access": "Access",
"system": "System",
"activity": "Activity"
"activity": "Activity",
"runtime": "Runtime",
"administration": "Administración"
},
"items": {
"home": "Inicio",
@@ -57,7 +59,8 @@
"settings": "Configuración",
"api": "API",
"middleware": "Middleware",
"activity": "Actividad"
"activity": "Actividad",
"overview": "Resumen"
},
"footer": {
"github": "GitHub",

View File

@@ -166,5 +166,35 @@
"explorer": {
"title": "Penjelajah",
"subtitle": "Jelajahi file dan konfigurasi"
},
"operate": {
"overview": {
"title": "Overview",
"subtitle": "Everything running on this installation, and anything that wants a decision.",
"attention": {
"heading": "Needs attention",
"clear": "Nothing needs attention. Backends are current, no operation has failed, and every node is healthy.",
"backendUpdate": "Update available: {{from}} → {{to}}"
},
"sections": {
"heading": "Sections",
"runtime": "Runtime",
"runtimeSummary": "{{updates}} backend updates · {{running}} operations running",
"cluster": "Cluster",
"clusterSummary": "{{nodes}} nodes",
"observability": "Observability",
"observabilitySummary": "Usage and traces",
"administration": "Administration",
"administrationSummary": "Users, middleware, host and settings",
"clusterSingle": "Single node",
"observabilityCounted": "{{requests}} requests · {{errors}} failed · p95 {{p95}} ms"
},
"headline": {
"requests": "Requests · {{hours}}h",
"errors": "Failed requests",
"p95": "p95 latency",
"quiet": "Nothing has been served yet. Totals appear once requests start arriving."
}
}
}
}

View File

@@ -118,5 +118,8 @@
"newChat": "Obrolan baru",
"clearAll": "Hapus semua",
"deleteAllTitle": "Hapus semua percakapan"
},
"message": {
"you": "You"
}
}

View File

@@ -17,7 +17,9 @@
"modelsLoaded_other": "{{count}} model dimuat",
"noModelsLoaded": "Tidak ada model yang dimuat",
"nodes_one": "{{count}} node",
"nodes_other": "{{count}} nodes"
"nodes_other": "{{count}} nodes",
"loadedLabel": "Loaded",
"nodesLabel": "Nodes"
},
"assistant": {
"title": "Kelola LocalAI melalui obrolan",
@@ -47,7 +49,8 @@
"count_one": "{{count}} model dimuat",
"count_other": "{{count}} model dimuat",
"stop": "Hentikan model",
"stopAll": "Hentikan semua"
"stopAll": "Hentikan semua",
"serving": "Serving"
},
"stopDialog": {
"title": "Hentikan Model",

View File

@@ -5,7 +5,33 @@
"video": "Video",
"tts": "TTS",
"sound": "Suara",
"transform": "Transformasi"
"transform": "Transformasi",
"overview": "Overview"
},
"overview": {
"eyebrow": "{{ready}} of {{total}} modalities ready",
"title": "Studio",
"subtitle": "Generate images, video, 3D, speech and sound with the models on this machine.",
"canMake": "What you can make",
"running": "Running now",
"recent": "Recent outputs",
"noModel": "No model installed",
"install": "Install a model",
"ready": "Ready",
"seconds": "{{seconds}}s",
"describe": {
"images": "Text to image, image to image, reference images",
"video": "Text to video and image to video",
"threed": "Image to mesh reconstruction",
"tts": "Text to speech using your voice library",
"sound": "Music and sound effects from a prompt",
"transform": "Separation, enhancement and voice conversion"
}
},
"groups": {
"create": "Create",
"voice": "Voice",
"transform": "Transform"
}
},
"image": {
@@ -204,5 +230,10 @@
"clearMessage": "Hapus semua entri riwayat? Tindakan ini tidak dapat dibatalkan.",
"clearConfirm": "Hapus",
"cleared": "Riwayat dihapus"
},
"request": {
"heading": "Request",
"copyCurl": "Copy as curl",
"copied": "Copied"
}
}

View File

@@ -24,7 +24,9 @@
"observability": "Observabilitas",
"access": "Akses",
"system": "Sistem",
"activity": "Activity"
"activity": "Activity",
"runtime": "Runtime",
"administration": "Administrasi"
},
"items": {
"home": "Beranda",
@@ -57,7 +59,8 @@
"system": "Sistem",
"settings": "Pengaturan",
"api": "API",
"activity": "Aktivitas"
"activity": "Aktivitas",
"overview": "Ikhtisar"
},
"footer": {
"github": "GitHub",

View File

@@ -143,5 +143,35 @@
"explorer": {
"title": "Esplora risorse",
"subtitle": "Sfoglia file e configurazioni"
},
"operate": {
"overview": {
"title": "Overview",
"subtitle": "Everything running on this installation, and anything that wants a decision.",
"attention": {
"heading": "Needs attention",
"clear": "Nothing needs attention. Backends are current, no operation has failed, and every node is healthy.",
"backendUpdate": "Update available: {{from}} → {{to}}"
},
"sections": {
"heading": "Sections",
"runtime": "Runtime",
"runtimeSummary": "{{updates}} backend updates · {{running}} operations running",
"cluster": "Cluster",
"clusterSummary": "{{nodes}} nodes",
"observability": "Observability",
"observabilitySummary": "Usage and traces",
"administration": "Administration",
"administrationSummary": "Users, middleware, host and settings",
"clusterSingle": "Single node",
"observabilityCounted": "{{requests}} requests · {{errors}} failed · p95 {{p95}} ms"
},
"headline": {
"requests": "Requests · {{hours}}h",
"errors": "Failed requests",
"p95": "p95 latency",
"quiet": "Nothing has been served yet. Totals appear once requests start arriving."
}
}
}
}

View File

@@ -118,5 +118,8 @@
"newChat": "Nuova chat",
"clearAll": "Cancella tutto",
"deleteAllTitle": "Elimina tutte le conversazioni"
},
"message": {
"you": "You"
}
}

View File

@@ -17,7 +17,9 @@
"modelsLoaded_other": "{{count}} modelli caricati",
"noModelsLoaded": "Nessun modello caricato",
"nodes_one": "{{count}} nodo",
"nodes_other": "{{count}} nodi"
"nodes_other": "{{count}} nodi",
"loadedLabel": "Loaded",
"nodesLabel": "Nodes"
},
"assistant": {
"title": "Gestisci LocalAI chattando",
@@ -47,7 +49,8 @@
"count_one": "{{count}} modello caricato",
"count_other": "{{count}} modelli caricati",
"stop": "Ferma modello",
"stopAll": "Ferma tutti"
"stopAll": "Ferma tutti",
"serving": "Serving"
},
"stopDialog": {
"title": "Ferma modello",

View File

@@ -5,6 +5,32 @@
"video": "Video",
"tts": "TTS",
"sound": "Audio",
"transform": "Transform",
"overview": "Overview"
},
"overview": {
"eyebrow": "{{ready}} of {{total}} modalities ready",
"title": "Studio",
"subtitle": "Generate images, video, 3D, speech and sound with the models on this machine.",
"canMake": "What you can make",
"running": "Running now",
"recent": "Recent outputs",
"noModel": "No model installed",
"install": "Install a model",
"ready": "Ready",
"seconds": "{{seconds}}s",
"describe": {
"images": "Text to image, image to image, reference images",
"video": "Text to video and image to video",
"threed": "Image to mesh reconstruction",
"tts": "Text to speech using your voice library",
"sound": "Music and sound effects from a prompt",
"transform": "Separation, enhancement and voice conversion"
}
},
"groups": {
"create": "Create",
"voice": "Voice",
"transform": "Transform"
}
},
@@ -157,5 +183,10 @@
"clearMessage": "Rimuovere tutte le voci della cronologia? Questa azione non può essere annullata.",
"clearConfirm": "Cancella",
"cleared": "Cronologia cancellata"
},
"request": {
"heading": "Request",
"copyCurl": "Copy as curl",
"copied": "Copied"
}
}

View File

@@ -24,7 +24,9 @@
"observability": "Observability",
"access": "Access",
"system": "System",
"activity": "Activity"
"activity": "Activity",
"runtime": "Runtime",
"administration": "Amministrazione"
},
"items": {
"home": "Home",
@@ -57,7 +59,8 @@
"settings": "Impostazioni",
"api": "API",
"middleware": "Middleware",
"activity": "Attività"
"activity": "Attività",
"overview": "Panoramica"
},
"footer": {
"github": "GitHub",

View File

@@ -166,5 +166,35 @@
"explorer": {
"title": "탐색기",
"subtitle": "파일과 구성을 둘러봅니다"
},
"operate": {
"overview": {
"title": "Overview",
"subtitle": "Everything running on this installation, and anything that wants a decision.",
"attention": {
"heading": "Needs attention",
"clear": "Nothing needs attention. Backends are current, no operation has failed, and every node is healthy.",
"backendUpdate": "Update available: {{from}} → {{to}}"
},
"sections": {
"heading": "Sections",
"runtime": "Runtime",
"runtimeSummary": "{{updates}} backend updates · {{running}} operations running",
"cluster": "Cluster",
"clusterSummary": "{{nodes}} nodes",
"observability": "Observability",
"observabilitySummary": "Usage and traces",
"administration": "Administration",
"administrationSummary": "Users, middleware, host and settings",
"clusterSingle": "Single node",
"observabilityCounted": "{{requests}} requests · {{errors}} failed · p95 {{p95}} ms"
},
"headline": {
"requests": "Requests · {{hours}}h",
"errors": "Failed requests",
"p95": "p95 latency",
"quiet": "Nothing has been served yet. Totals appear once requests start arriving."
}
}
}
}

View File

@@ -118,5 +118,8 @@
"newChat": "새 채팅",
"clearAll": "모두 지우기",
"deleteAllTitle": "모든 대화 삭제"
},
"message": {
"you": "You"
}
}

View File

@@ -17,7 +17,9 @@
"modelsLoaded_other": "{{count}} models loaded",
"noModelsLoaded": "No models loaded",
"nodes_one": "{{count}} node",
"nodes_other": "{{count}} nodes"
"nodes_other": "{{count}} nodes",
"loadedLabel": "Loaded",
"nodesLabel": "Nodes"
},
"assistant": {
"title": "채팅으로 LocalAI 관리",
@@ -47,7 +49,8 @@
"count_one": "모델 {{count}}개 로드됨",
"count_other": "모델 {{count}}개 로드됨",
"stop": "모델 중지",
"stopAll": "모두 중지"
"stopAll": "모두 중지",
"serving": "Serving"
},
"stopDialog": {
"title": "모델 중지",

View File

@@ -5,6 +5,32 @@
"video": "비디오",
"tts": "TTS",
"sound": "사운드",
"transform": "Transform",
"overview": "Overview"
},
"overview": {
"eyebrow": "{{ready}} of {{total}} modalities ready",
"title": "Studio",
"subtitle": "Generate images, video, 3D, speech and sound with the models on this machine.",
"canMake": "What you can make",
"running": "Running now",
"recent": "Recent outputs",
"noModel": "No model installed",
"install": "Install a model",
"ready": "Ready",
"seconds": "{{seconds}}s",
"describe": {
"images": "Text to image, image to image, reference images",
"video": "Text to video and image to video",
"threed": "Image to mesh reconstruction",
"tts": "Text to speech using your voice library",
"sound": "Music and sound effects from a prompt",
"transform": "Separation, enhancement and voice conversion"
}
},
"groups": {
"create": "Create",
"voice": "Voice",
"transform": "Transform"
}
},
@@ -157,5 +183,10 @@
"clearMessage": "모든 기록 항목을 제거하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
"clearConfirm": "지우기",
"cleared": "기록이 지워졌습니다"
},
"request": {
"heading": "Request",
"copyCurl": "Copy as curl",
"copied": "Copied"
}
}

View File

@@ -24,7 +24,9 @@
"observability": "Observability",
"access": "Access",
"system": "System",
"activity": "Activity"
"activity": "Activity",
"runtime": "런타임",
"administration": "관리"
},
"items": {
"home": "홈",
@@ -57,7 +59,8 @@
"system": "시스템",
"settings": "설정",
"api": "API",
"activity": "활동"
"activity": "활동",
"overview": "개요"
},
"footer": {
"github": "GitHub",

View File

@@ -143,5 +143,35 @@
"explorer": {
"title": "资源浏览器",
"subtitle": "浏览文件和配置"
},
"operate": {
"overview": {
"title": "Overview",
"subtitle": "Everything running on this installation, and anything that wants a decision.",
"attention": {
"heading": "Needs attention",
"clear": "Nothing needs attention. Backends are current, no operation has failed, and every node is healthy.",
"backendUpdate": "Update available: {{from}} → {{to}}"
},
"sections": {
"heading": "Sections",
"runtime": "Runtime",
"runtimeSummary": "{{updates}} backend updates · {{running}} operations running",
"cluster": "Cluster",
"clusterSummary": "{{nodes}} nodes",
"observability": "Observability",
"observabilitySummary": "Usage and traces",
"administration": "Administration",
"administrationSummary": "Users, middleware, host and settings",
"clusterSingle": "Single node",
"observabilityCounted": "{{requests}} requests · {{errors}} failed · p95 {{p95}} ms"
},
"headline": {
"requests": "Requests · {{hours}}h",
"errors": "Failed requests",
"p95": "p95 latency",
"quiet": "Nothing has been served yet. Totals appear once requests start arriving."
}
}
}
}

View File

@@ -118,5 +118,8 @@
"newChat": "新对话",
"clearAll": "清除全部",
"deleteAllTitle": "删除所有对话"
},
"message": {
"you": "You"
}
}

View File

@@ -17,7 +17,9 @@
"modelsLoaded_other": "{{count}} models loaded",
"noModelsLoaded": "No models loaded",
"nodes_one": "{{count}} node",
"nodes_other": "{{count}} nodes"
"nodes_other": "{{count}} nodes",
"loadedLabel": "Loaded",
"nodesLabel": "Nodes"
},
"assistant": {
"title": "通过聊天管理 LocalAI",
@@ -47,7 +49,8 @@
"count_one": "已加载 {{count}} 个模型",
"count_other": "已加载 {{count}} 个模型",
"stop": "停止模型",
"stopAll": "全部停止"
"stopAll": "全部停止",
"serving": "Serving"
},
"stopDialog": {
"title": "停止模型",

View File

@@ -5,6 +5,32 @@
"video": "视频",
"tts": "TTS",
"sound": "声音",
"transform": "Transform",
"overview": "Overview"
},
"overview": {
"eyebrow": "{{ready}} of {{total}} modalities ready",
"title": "Studio",
"subtitle": "Generate images, video, 3D, speech and sound with the models on this machine.",
"canMake": "What you can make",
"running": "Running now",
"recent": "Recent outputs",
"noModel": "No model installed",
"install": "Install a model",
"ready": "Ready",
"seconds": "{{seconds}}s",
"describe": {
"images": "Text to image, image to image, reference images",
"video": "Text to video and image to video",
"threed": "Image to mesh reconstruction",
"tts": "Text to speech using your voice library",
"sound": "Music and sound effects from a prompt",
"transform": "Separation, enhancement and voice conversion"
}
},
"groups": {
"create": "Create",
"voice": "Voice",
"transform": "Transform"
}
},
@@ -157,5 +183,10 @@
"clearMessage": "删除所有历史条目?此操作无法撤销。",
"clearConfirm": "清除",
"cleared": "历史已清除"
},
"request": {
"heading": "Request",
"copyCurl": "Copy as curl",
"copied": "Copied"
}
}

View File

@@ -24,7 +24,9 @@
"observability": "Observability",
"access": "Access",
"system": "System",
"activity": "Activity"
"activity": "Activity",
"runtime": "运行时",
"administration": "管理"
},
"items": {
"home": "首页",
@@ -57,7 +59,8 @@
"settings": "设置",
"api": "API",
"middleware": "Middleware",
"activity": "活动"
"activity": "活动",
"overview": "概览"
},
"footer": {
"github": "GitHub",

View File

@@ -51,18 +51,32 @@
}
/* Footer */
/* One line, not three stacked centred rows. The footer is chrome: it should
cost a line of height and be findable, not occupy the bottom sixth of every
page. Version left, links right, copyright folded in beside the version. */
.app-footer {
background: transparent;
border-top: 1px solid var(--color-border-divider);
padding: var(--spacing-md) var(--spacing-lg);
padding: var(--spacing-sm) var(--spacing-lg);
margin-top: auto;
}
.app-footer-inner {
display: flex;
flex-direction: column;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-sm);
justify-content: space-between;
gap: var(--spacing-xs) var(--spacing-md);
}
/* Links push right on a wide viewport and wrap under on a narrow one. */
.app-footer-links { margin-left: auto; }
/* Below the fold of a phone the three groups stack anyway; centre them so the
wrap does not read as a ragged left column. */
@media (max-width: 560px) {
.app-footer-inner { justify-content: center; }
.app-footer-links { margin-left: 0; }
}
.app-footer-version {
@@ -537,6 +551,9 @@
justify-content: center;
flex-direction: column;
gap: var(--spacing-xs);
/* The controls stack, but the row's inline padding still left them wider
than the collapsed rail, so their edges were clipped. */
padding-inline: 2px;
}
.sidebar.collapsed .theme-toggle {
@@ -1219,14 +1236,20 @@
margin-bottom: var(--spacing-xl);
}
/* The editorial title scale from the site: larger, set tighter, and on a line
height near 1 so a two-word title reads as a statement rather than a label.
The typeface is unchanged — DESIGN.md keeps the existing type system — so the
whole difference is scale, tracking and leading, which is where the site gets
its voice from in the first place. */
.page-title {
font-family: var(--font-sans);
font-size: clamp(1.5rem, 1.15rem + 1.4vw, var(--text-3xl));
font-size: clamp(1.875rem, 1.3rem + 2.2vw, 3rem);
font-weight: var(--font-weight-semibold);
letter-spacing: -0.018em;
line-height: var(--leading-tight);
letter-spacing: -0.04em;
line-height: 1.02;
margin-bottom: var(--spacing-xs);
color: var(--color-text-primary);
text-wrap: balance;
}
/* Mid hierarchy tier — between page title and the xs uppercase group labels */
@@ -2125,7 +2148,12 @@ select.input {
Left accent bar ties the color to the metric's semantic (success/warning/
error/primary), icon chip sits top-right, value is left-aligned and
prominent so you can scan a row of cards without reading labels. */
.stat-grid {
/* Renamed off `.stat-grid` in favour of the children it actually holds. The
split views introduced a second, unrelated `.stat-grid` for detail panes
further down this file, and being later it won every shared property: these
cards were being laid out on that component's 120px columns and 1px hairline
gap, which crushed their labels at every width. */
.stat-cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: var(--spacing-md);
@@ -3355,14 +3383,22 @@ button.collapsible-header:focus-visible {
.chat-message {
display: flex;
gap: var(--spacing-sm);
max-width: 88%;
align-self: stretch;
max-width: 100%;
min-width: 0;
padding-bottom: var(--spacing-md);
border-bottom: 1px solid var(--color-border-divider);
animation: messageSlideIn 250ms ease-out;
}
/* A transcript, not bubbles. Rounded filled asymmetric bubbles fight a system
built on hairlines, and they carry the speaker in shape and side rather than
in words — which is exactly what a mono role label does better, and what
survives being read aloud or printed. Both roles now run full width down one
column, separated by a rule. */
.chat-message-user {
align-self: flex-end;
flex-direction: row-reverse;
align-self: stretch;
flex-direction: row;
}
.chat-message-assistant {
@@ -3385,11 +3421,9 @@ button.collapsible-header:focus-visible {
color: var(--color-primary-text);
}
/* Assistant gets the left-border accent on the bubble; the avatar is
visual noise once that accent is in place. */
.chat-message-assistant .chat-message-avatar {
display: none;
}
/* The left-edge accent plus the role label carry the speaker, so the avatar is
noise for both roles now that neither side has a bubble. */
.chat-message-avatar { display: none; }
.chat-message-bubble {
display: flex;
@@ -3420,13 +3454,16 @@ button.collapsible-header:focus-visible {
color: var(--color-text-primary);
}
/* Same treatment as the assistant, in the action tone so the two are still
told apart at a glance without a fill or a corner radius. */
.chat-message-user .chat-message-content {
background: var(--color-primary-light);
background: transparent;
color: var(--color-text-primary);
border: 1px solid var(--color-primary-border);
border-radius: 16px 4px 16px 16px;
padding: var(--spacing-sm) var(--spacing-md);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
border: none;
border-left: 2px solid var(--color-primary);
border-radius: 0;
padding: 0 var(--spacing-md);
box-shadow: none;
}
.chat-message-content pre {
@@ -5081,6 +5118,7 @@ button.collapsible-header:focus-visible {
/* Studio tabs */
.studio-tabs {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-xs);
border-bottom: 1px solid var(--color-border-subtle);
padding: var(--spacing-sm) var(--spacing-xl) 0;
@@ -5135,17 +5173,43 @@ button.collapsible-header:focus-visible {
}
}
/* The workbench control column. A hairline field stack rather than a shadowed
card of boxed groups: the panel is the page's left half, not an object
floating on it, and the same treatment covers all six generators because they
share this class. */
.media-controls {
background: var(--color-surface-raised);
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
box-shadow: var(--shadow-subtle);
background: transparent;
border: 0;
border-top: 1px solid var(--color-border-default);
border-radius: 0;
padding: 0;
box-shadow: none;
position: sticky;
top: var(--spacing-lg);
}
/* Each control is a row on the stack, separated by the same hairline the lanes
use, so a form and a list read as the same system. */
.media-controls .form-group {
margin-bottom: var(--spacing-md);
margin-bottom: 0;
padding: var(--spacing-sm) 0;
border-bottom: 1px solid var(--color-border-default);
}
/* Uppercase micro-label, matching the eyebrow treatment used elsewhere: it
names the field without competing with the value. */
.media-controls .form-label {
font-size: 0.6875rem;
font-weight: 700;
letter-spacing: 0.07em;
text-transform: uppercase;
color: var(--color-text-muted);
}
/* The submit row closes the stack, so it carries no divider of its own. */
.media-controls .form-group:last-of-type,
.media-controls > form > .btn-full:last-child {
border-bottom: 0;
}
.media-controls .form-grid-2col,
.media-controls .form-grid-3col {
@@ -8763,7 +8827,7 @@ button.collapsible-header:focus-visible {
outline-offset: 2px;
}
/* Manage summary marker — same .stat-grid layout. Top margin separates the
/* Manage summary marker — same .stat-cards layout. Top margin separates the
cards from the System Resources card above (otherwise they sit too close
to the RAM bar) and bottom margin tightens the gap to the tabs below. */
.manage-summary {
@@ -8900,7 +8964,16 @@ button.collapsible-header:focus-visible {
.console-rail { position: static; flex-basis: auto; width: 100%; }
.console-rail-toggle { display: grid; }
.console-rail-groups { display: none; }
.console-rail--open .console-rail-groups { display: flex; }
/* Thirteen destinations stacked in one column is taller than a phone, so
opening the menu used to push the page's own heading past the fold: the
menu replaced the page rather than annotating it. Bound it and let it
scroll, so the content behind stays reachable. */
.console-rail--open .console-rail-groups {
display: flex;
max-height: 55vh;
overflow-y: auto;
overscroll-behavior: contain;
}
}
@media (prefers-reduced-motion: reduce) {
.console-rail.console-rail--enter { animation: none; }
@@ -12028,7 +12101,8 @@ button.collapsible-header:focus-visible {
left: 0;
right: 0;
bottom: var(--discover-limit, 0);
border-top: 1px dashed var(--color-error-border);
/* Same tone as the bars that cross it: one colour, one meaning. */
border-top: 1px dashed var(--color-warning-border);
pointer-events: none;
}
@@ -12075,7 +12149,10 @@ button.collapsible-header:focus-visible {
box-shadow: 0 0 0 2px var(--color-bg-secondary);
}
.discover__chart-bar--over { background: var(--color-error); }
/* Amber, not error red. An over-limit build still installs — #11288 keeps a
test on exactly that — so red would overstate it. Amber is the constraint
tone used everywhere else here: know what you are doing, not you may not. */
.discover__chart-bar--over { background: var(--color-warning); }
.discover__chart-col:hover .discover__chart-bar { filter: brightness(1.1); }
@@ -12217,6 +12294,13 @@ button.collapsible-header:focus-visible {
pushes the page taller instead of scrolling inside it. */
min-height: 0;
padding-bottom: var(--spacing-lg);
/* The shell above is overflow:hidden so the document cannot grow, which left
anything taller than the viewport simply unreachable — Host has a
resources card, four stat cards and a tab bar above its split, and at any
window height the bottom of the pane fell off with nothing to scroll.
The page scrolls inside the pinned shell; the rail and pane keep their own
inner scrollers for long lists and long details. */
overflow-y: auto;
}
/* The header, fused. A slim row that reads as the top of the view rather than
@@ -12266,6 +12350,10 @@ button.collapsible-header:focus-visible {
}
.page--app .split-view__pane {
/* The pane keeps its own scroller. Letting it grow instead would push the
document taller and stretch the rail to match, which is the regression
e2e/discover-height.spec.js exists to catch. Reaching tall page chrome is
the page's job (overflow-y above), not the pane's. */
height: 100%;
overflow-y: auto;
}
@@ -12489,3 +12577,365 @@ button.collapsible-header:focus-visible {
@media (prefers-reduced-motion: reduce) {
.entity-rail__meta--pending { animation: none; opacity: 0.75; }
}
/* ─── Lanes ───────────────────────────────────────────────────────────────
The list idiom carried over from localai.io: full-bleed rows separated by a
single hairline, insetting on hover, with no card and no shadow. Used
wherever records are flat, uniform and read in sequence. Where an entity
outgrows a row and candidates get compared before acting, use SplitView
instead — that is the other half of the rule and they should not be mixed.
Column templates belong to the caller: `.lanes--<name> .lane { grid-template-columns: ... }`. */
.lanes {
list-style: none;
margin: 0;
padding: 0;
border-top: 1px solid var(--color-border-default);
}
.lane {
display: grid;
align-items: center;
gap: var(--spacing-md);
width: 100%;
padding: var(--spacing-sm) var(--spacing-xs);
border: 0;
border-bottom: 1px solid var(--color-border-default);
background: transparent;
color: inherit;
text-align: left;
font: inherit;
text-decoration: none;
transition: background var(--duration-fast) var(--ease-default),
padding var(--duration-fast) var(--ease-default);
}
a.lane, button.lane { cursor: pointer; }
a.lane:hover,
button.lane:hover {
background: var(--color-bg-hover);
padding-inline: var(--spacing-sm);
}
.lane:focus-visible { outline: 2px solid var(--color-focus-ring); outline-offset: -2px; }
/* A 33px row is fine for a mouse and too small for a thumb. Matches the rule
EntityRail already applies, so the two list idioms feel the same on touch. */
@media (pointer: coarse) {
.lane { padding-top: 12px; padding-bottom: 12px; }
}
/* The hover inset is a position change, so it is motion. Hold the background
feedback and drop the movement. */
@media (prefers-reduced-motion: reduce) {
.lane { transition: background var(--duration-fast) var(--ease-default); }
a.lane:hover, button.lane:hover { padding-inline: var(--spacing-xs); }
}
/* Uppercase micro-label. Blue where it names a route, mint where it reports
live state — never both on one screen. */
.lane__tag {
color: var(--color-primary);
font-size: 0.6875rem;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.lane__main { min-width: 0; display: flex; flex-direction: column; gap: 1px; }
.lane__name { font-weight: var(--font-weight-medium); color: var(--color-text-primary); }
/* Identifiers are monospace, prose is not. A model id, a backend name or a
filename is a thing you might type or paste, and setting it in the UI face
makes it read as a label instead. */
.lane__name--id { font-family: var(--font-mono); font-size: 0.875rem; font-weight: 500; }
.lane__desc { color: var(--color-text-muted); font-size: 0.8125rem; }
/* Identifiers and quantities are monospace; prose is not. */
.lane__num {
font-family: var(--font-mono);
font-size: 0.75rem;
color: var(--color-text-tertiary);
font-variant-numeric: tabular-nums;
overflow: hidden;
text-overflow: ellipsis;
}
.lane__go { color: var(--color-text-tertiary); }
/* Section rhythm above a set of lanes. */
.lane-head {
display: flex;
align-items: baseline;
gap: var(--spacing-sm);
margin: var(--spacing-2xl) 0 var(--spacing-sm);
}
/* No :first-child reset. Every <section> makes its lane-head a first child, so
the reset silently killed the gap between every block — "Sections" sat flush
against the row above it. The header supplies its own bottom margin, so a
uniform top margin is right everywhere. */
.lane-head h2 { margin: 0; font-size: 0.9375rem; font-weight: 700; letter-spacing: -0.01em; }
.lane-head__meta {
margin-left: auto;
color: var(--color-text-tertiary);
font-family: var(--font-mono);
font-size: 0.75rem;
font-variant-numeric: tabular-nums;
}
/* Standard padding for pages that are not inside a console shell, which
supplies its own. Without this a top-level page sits flush against the
sidebar and its first character is clipped. */
.page-pad {
padding: var(--spacing-xl);
max-width: var(--page-max-medium);
width: 100%;
}
/* Home: resident models and the header figures. */
.lanes--resident .lane { grid-template-columns: minmax(0, 1fr) auto auto; }
.home-loaded-stop {
border: 0;
background: transparent;
color: var(--color-text-tertiary);
cursor: pointer;
padding: 2px 6px;
border-radius: var(--radius-sm);
}
.home-loaded-stop:hover { color: var(--color-error); background: var(--color-error-light); }
.home-stat { display: flex; align-items: baseline; gap: var(--spacing-xs); }
.home-stat__value {
font-family: var(--font-mono);
font-size: 1.125rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
color: var(--color-text-primary);
line-height: 1;
}
.home-stat__value--ok { color: var(--color-success); }
.home-stat__label {
font-size: 0.6875rem;
font-weight: 700;
letter-spacing: 0.07em;
text-transform: uppercase;
color: var(--color-text-muted);
}
/* Headline figures on the Operate overview, and the sparklines under them.
Hairline-gridded cells rather than cards: the same 1px grid the split-view
StatGrid uses, so the two read as one system. */
.operate-headline {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 1px;
margin: 0 0 var(--spacing-xl);
background: var(--color-border-default);
border: 1px solid var(--color-border-default);
border-radius: var(--radius-md);
overflow: hidden;
}
.operate-headline__cell {
background: var(--color-bg-primary);
padding: var(--spacing-sm) var(--spacing-md);
display: grid;
gap: 2px;
}
.operate-headline__cell dt {
color: var(--color-text-muted);
font-size: 0.625rem;
font-weight: 700;
letter-spacing: 0.07em;
text-transform: uppercase;
}
.operate-headline__value {
margin: 0;
font-family: var(--font-mono);
font-size: 1.5rem;
font-weight: 600;
line-height: 1.1;
letter-spacing: -0.02em;
font-variant-numeric: tabular-nums;
}
.operate-headline__value--primary { color: var(--color-text-primary); }
.operate-headline__value--success { color: var(--color-success); }
.operate-headline__value--warning { color: var(--color-warning); }
.operate-headline__value--muted { color: var(--color-text-tertiary); }
.sparkline { display: block; width: 100%; height: 28px; margin-top: 2px; }
.sparkline polyline { stroke: currentColor; }
.sparkline circle { fill: currentColor; }
.sparkline--primary { color: var(--color-primary); }
.sparkline--success { color: var(--color-success); }
.sparkline--warning { color: var(--color-warning); }
.sparkline--muted { color: var(--color-text-disabled); }
/* A signal changing as a poll lands should read as an update, not a jump cut.
Opacity only, so it stays on the compositor and cannot reflow the rail. */
.nav-signal { transition: opacity var(--duration-normal) var(--ease-default); }
/* The attention block is the one thing on the page that should announce
itself. A short, single reveal on the row's left edge, not on the text. */
@keyframes attentionEdge {
from { border-left-color: transparent; }
to { border-left-color: var(--color-warning); }
}
.lanes--attention .lane {
animation: attentionEdge var(--duration-reveal) var(--ease-reveal) both;
}
@media (prefers-reduced-motion: reduce) {
.nav-signal { transition: none; }
.lanes--attention .lane { animation: none; }
}
/* The request the form just built. Sunken and monospace: it is a record of what
was sent, not another control. */
.request-panel {
border: 1px solid var(--color-border-default);
border-radius: var(--radius-md);
background: var(--color-surface-sunken);
overflow: hidden;
margin-bottom: var(--spacing-md);
}
.request-panel__head {
display: flex;
align-items: center;
gap: var(--spacing-sm);
padding: 4px var(--spacing-sm);
border-bottom: 1px solid var(--color-border-default);
}
.request-panel__title {
font-size: 0.625rem;
font-weight: 700;
letter-spacing: 0.07em;
text-transform: uppercase;
color: var(--color-text-muted);
}
.request-panel__copy {
margin-left: auto;
border: 1px solid var(--color-border-default);
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-text-secondary);
font-size: 0.6875rem;
padding: 2px 8px;
cursor: pointer;
}
.request-panel__copy:hover { color: var(--color-primary); border-color: var(--color-primary-border); }
.request-panel__copy:focus-visible { outline: 2px solid var(--color-focus-ring); outline-offset: 1px; }
.request-panel__code {
margin: 0;
padding: var(--spacing-sm);
overflow-x: auto;
font-family: var(--font-mono);
font-size: 0.6875rem;
line-height: 1.6;
color: var(--color-text-secondary);
}
.request-panel__method { color: var(--color-text-tertiary); }
.request-panel__endpoint { color: var(--color-primary); font-weight: 500; }
/* A row of page-header controls. Replaces the inline flex that several pages
had each written out longhand. */
.header-actions {
display: flex;
align-items: center;
gap: var(--spacing-sm);
flex-wrap: wrap;
}
/* The file input is triggered by its label, which is styled as a button; the
input itself must not render or the control shows twice. */
.agents-import-input input[type="file"] { display: none; }
/* ─── Operate console: rail signals and the overview ──────────────────────
The signal is ambient state beside a rail label, not a badge: no pill, no
fill, just a dimmer tabular figure a scanning eye can ignore. The
alarm-shaped treatment belongs to .nav-badge on the sidebar, which stays
visible when this rail is collapsed or absent. */
.nav-signal {
margin-left: auto;
flex: none;
font-family: var(--font-mono);
font-size: 0.6875rem;
color: var(--color-text-tertiary);
font-variant-numeric: tabular-nums;
}
.nav-item.active .nav-signal { color: var(--color-primary); }
/* One line, deliberately quiet. Making "nothing is wrong" as loud as a problem
is how a status page teaches people to stop reading it. */
.operate-clear {
margin: 0;
color: var(--color-text-muted);
font-size: 0.875rem;
}
.lanes--attention .lane { grid-template-columns: minmax(0, auto) minmax(0, 1fr) auto; }
.lanes--sections .lane { grid-template-columns: minmax(0, 10rem) minmax(0, 1fr) auto; }
/* Amber on the row itself rather than a status chip: these are the only things
on the page asking for a decision, so they can afford the one accent. */
.lanes--attention .lane { border-left: 3px solid var(--color-warning); }
/* ─── Studio: capability dots ─────────────────────────────────────────────
Filled means a model on this machine serves that modality. aria-hidden,
because the overview states the same thing in words and a dot that changes
as models load would otherwise interrupt a reader mid-strip. */
.studio-tab__dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex: none;
margin-left: var(--spacing-xs);
}
.studio-tab__dot--on { background: var(--color-success); }
.studio-tab__dot--off {
background: transparent;
box-shadow: inset 0 0 0 1px var(--color-border-strong);
}
/* Room for the description to sit on one line at a comfortable width instead
of wrapping into a narrow column beside empty space. */
.lanes--modality .lane { grid-template-columns: 6rem minmax(0, 1fr) minmax(0, 14rem) 5rem auto; }
.lanes--takes .lane { grid-template-columns: minmax(0, 1fr) auto; }
.studio-modality--empty { cursor: default; }
.studio-modality__install { color: var(--color-primary); font-size: 0.8125rem; font-weight: 700; white-space: nowrap; }
.studio-modality__state { color: var(--color-success); font-size: 0.8125rem; font-weight: 600; }
.studio-running__meter { height: 4px; border-radius: 2px; background: var(--color-bg-tertiary); overflow: hidden; width: 8rem; }
.studio-running__meter i { display: block; height: 100%; background: var(--color-primary); }
.studio-overview__heading { display: flex; align-items: baseline; gap: var(--spacing-sm); }
.studio-overview__count {
margin-left: auto;
color: var(--color-text-tertiary);
font-family: var(--font-mono);
font-size: 0.75rem;
font-variant-numeric: tabular-nums;
}

View File

@@ -15,7 +15,7 @@ export default function ManageSummary({
const click = (tab, filter) => onCardClick && onCardClick(tab, filter)
return (
<div className="stat-grid manage-summary">
<div className="stat-cards manage-summary">
<StatCard
icon="fas fa-brain"
label="Models Installed"

View File

@@ -0,0 +1,54 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { copyToClipboard } from '../utils/clipboard'
import { apiUrl } from '../utils/basePath'
// The request the form just built.
//
// LocalAI is an API-first product and Studio is the best place in the app to
// teach its own endpoints: the form stops being a black box, and a result worth
// keeping can be reproduced from a shell without reverse-engineering which
// fields the page sent.
//
// Rendered only once there is something to show. A panel describing a request
// nobody has made yet is a tutorial, not a record.
export default function RequestPanel({ endpoint, body, method = 'POST' }) {
const { t } = useTranslation('media')
const [copied, setCopied] = useState(false)
if (!endpoint || !body) return null
const json = JSON.stringify(body, null, 2)
const curl = [
`curl -X ${method} ${window.location.origin}${apiUrl(endpoint)} \\`,
` -H 'Content-Type: application/json' \\`,
` -d '${JSON.stringify(body)}'`,
].join('\n')
const onCopy = async () => {
const ok = await copyToClipboard(curl)
if (!ok) return
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
return (
<section className="request-panel">
<div className="request-panel__head">
<span className="request-panel__title">{t('request.heading')}</span>
<button type="button" className="request-panel__copy" onClick={onCopy}>
{copied ? t('request.copied') : t('request.copyCurl')}
</button>
</div>
<pre className="request-panel__code">
<code>
<span className="request-panel__method">{method}</span>{' '}
<span className="request-panel__endpoint">{endpoint}</span>
{'\n'}
{json}
</code>
</pre>
</section>
)
}

View File

@@ -0,0 +1,40 @@
// A bare trend line: stroke, no fill, no axes, no gridlines.
//
// It sits under a figure that already states the value, so its only job is to
// say what shape got us here. An emphasised endpoint marks "now", because the
// most recent point is the one being read.
//
// aria-hidden: the number above it is the accessible content, and a
// twelve-point series read aloud is noise.
export default function Sparkline({ points, tone = 'primary', width = 120, height = 28 }) {
if (!Array.isArray(points) || points.length < 2) return null
const max = Math.max(...points)
// A flat run of zeroes would otherwise divide by zero and draw nothing;
// pinning it to the baseline is the honest picture of "no traffic".
const scale = max > 0 ? max : 1
const step = width / (points.length - 1)
const y = v => height - 2 - (v / scale) * (height - 4)
const path = points.map((v, i) => `${(i * step).toFixed(1)},${y(v).toFixed(1)}`).join(' ')
const lastX = width
const lastY = y(points[points.length - 1])
return (
<svg
className={`sparkline sparkline--${tone}`}
viewBox={`0 0 ${width} ${height}`}
preserveAspectRatio="none"
aria-hidden="true"
focusable="false"
>
<polyline
points={path}
fill="none"
strokeWidth="1.5"
vectorEffect="non-scaling-stroke"
/>
<circle cx={lastX} cy={lastY} r="2" />
</svg>
)
}

View File

@@ -65,7 +65,7 @@ export default function VoiceVisualizer({ audioRef, micStreamRef, status, active
let data = null
if (an) { data = new Uint8Array(an.frequencyBinCount); an.getByteFrequencyData(data) }
const color = getComputedStyle(canvas).getPropertyValue('--viz-color').trim() || '#88c0d0'
const color = getComputedStyle(canvas).getPropertyValue('--viz-color').trim() || '#4f8cff'
ctx.fillStyle = color
const slot = w / BARS
const bw = slot * 0.5

View File

@@ -44,7 +44,7 @@ export default function WaveformPlayer({
const accent =
getComputedStyle(canvas).getPropertyValue('--audio-wave').trim() ||
getComputedStyle(canvas).getPropertyValue('--color-primary').trim() ||
'#88c0d0'
'#4f8cff'
ctx.fillStyle = dimmed ? withAlpha(accent, 0.32) : accent
const mid = cssH / 2
const barW = Math.max(1, cssW / peaks.length)

View File

@@ -6,6 +6,7 @@ import { apiUrl } from '../../utils/basePath'
import { preloadRoute } from '../../router'
import RouteFallback from '../RouteFallback'
import { isConsoleItemVisible } from './consoleConfig'
import { OperateSummaryProvider, useOperateSummary } from '../../contexts/OperateSummaryContext'
// The App wraps the outlet in key={pathname}, so this layout remounts on every
// sub-navigation. Tracking the last-entered console id across mounts lets us
@@ -24,6 +25,11 @@ let featuresCache = {}
// route in router.jsx — wrapped pages keep their existing flat URLs.
function RailItem({ item, label }) {
// Null outside Operate, where no provider is mounted — the rail then renders
// exactly as it did before signals existed.
const summary = useOperateSummary()
const signal = item.signal ? summary?.signals?.[item.signal] : null
if (item.external) {
return (
<a className="nav-item" href={apiUrl(item.href)} target="_blank" rel="noopener noreferrer">
@@ -42,11 +48,15 @@ function RailItem({ item, label }) {
>
<i className={`${item.icon} nav-icon`} />
<span className="nav-label">{label}</span>
{/* Ambient, and hidden from assistive tech: it changes under the reader
and is never the only place a fact appears. The overview states the
same things in prose, where they can be read deliberately. */}
{signal != null && <span className="nav-signal" aria-hidden="true">{signal}</span>}
</NavLink>
)
}
export default function ConsoleLayout({ config }) {
function ConsoleLayoutInner({ config }) {
const { t } = useTranslation('nav')
const { isAdmin, authEnabled, hasFeature } = useAuth()
const [features, setFeatures] = useState(featuresCache)
@@ -115,3 +125,15 @@ export default function ConsoleLayout({ config }) {
</div>
)
}
// The summary provider wraps the Operate console and nothing else. That is the
// whole of "poll only while the user is in Operate": elsewhere the provider is
// not mounted, so no timer exists to gate. Build gets the plain layout.
export default function ConsoleLayout({ config }) {
if (config.id !== 'operate') return <ConsoleLayoutInner config={config} />
return (
<OperateSummaryProvider>
<ConsoleLayoutInner config={config} />
</OperateSummaryProvider>
)
}

View File

@@ -44,28 +44,38 @@ export const buildConsole = {
],
}
// Four groups, not six. Inference and Activity were both "the runtime right
// now"; Access and System were both administration. Six headings over thirteen
// items is a list with extra steps. Nothing is removed and no gate changes —
// the heading an item sits under is the only thing that moves.
//
// Overview leads the first group so firstVisiblePath() returns it without
// needing to know it exists. Before it, opening Operate landed on Backends
// because Backends happened to be written first.
//
// `signal` names a value the rail renders beside the label (see
// OperateSummaryContext). It is orientation while inside Operate, NOT an
// alarm: the rail exists only on Operate routes and can be collapsed, which is
// precisely why the operations badge stays on the sidebar entry. Nothing
// urgent may depend on a rail signal alone.
export const operateConsole = {
id: 'operate',
titleKey: 'sections.operate',
icon: 'fas fa-sliders',
groups: [
{
titleKey: 'operate.inference',
titleKey: 'operate.runtime',
items: [
{ path: '/app/backends', icon: 'fas fa-server', labelKey: 'items.backends', adminOnly: true },
{ path: '/app/operate', icon: 'fas fa-gauge-high', labelKey: 'items.overview', adminOnly: true, signal: 'attention' },
{ path: '/app/backends', icon: 'fas fa-server', labelKey: 'items.backends', adminOnly: true, signal: 'backends' },
{ path: '/app/voice-library', icon: 'fas fa-wave-square', labelKey: 'items.voiceLibrary', adminOnly: true },
],
},
{
titleKey: 'operate.activity',
items: [
{ path: '/app/activity', icon: 'fas fa-download', labelKey: 'items.activity', adminOnly: true, badge: 'operations' },
{ path: '/app/activity', icon: 'fas fa-download', labelKey: 'items.activity', adminOnly: true, badge: 'operations', signal: 'activity' },
],
},
{
titleKey: 'operate.cluster',
items: [
{ path: '/app/nodes', icon: 'fas fa-network-wired', labelKey: 'items.nodes', adminOnly: true, feature: 'distributed' },
{ path: '/app/nodes', icon: 'fas fa-network-wired', labelKey: 'items.nodes', adminOnly: true, feature: 'distributed', signal: 'nodes' },
{ path: '/app/scheduling', icon: 'fas fa-calendar-alt', labelKey: 'items.scheduling', adminOnly: true, feature: 'distributed' },
{ path: '/app/p2p', icon: 'fas fa-circle-nodes', labelKey: 'items.swarm', adminOnly: true },
],
@@ -73,21 +83,16 @@ export const operateConsole = {
{
titleKey: 'operate.observability',
items: [
{ path: '/app/usage', icon: 'fas fa-chart-bar', labelKey: 'items.usage', adminOnly: true },
{ path: '/app/traces', icon: 'fas fa-chart-line', labelKey: 'items.traces', adminOnly: true },
{ path: '/app/usage', icon: 'fas fa-chart-bar', labelKey: 'items.usage', adminOnly: true, signal: 'usage' },
{ path: '/app/traces', icon: 'fas fa-chart-line', labelKey: 'items.traces', adminOnly: true, signal: 'traces' },
],
},
{
titleKey: 'operate.access',
titleKey: 'operate.administration',
items: [
{ path: '/app/users', icon: 'fas fa-users', labelKey: 'items.users', adminOnly: true, authOnly: true },
{ path: '/app/middleware', icon: 'fas fa-shield-halved', labelKey: 'items.middleware', adminOnly: true },
],
},
{
titleKey: 'operate.system',
items: [
{ path: '/app/manage', icon: 'fas fa-desktop', labelKey: 'items.host', adminOnly: true },
{ path: '/app/manage', icon: 'fas fa-desktop', labelKey: 'items.host', adminOnly: true, signal: 'host' },
{ path: '/app/settings', icon: 'fas fa-cog', labelKey: 'items.settings', adminOnly: true },
{ href: '/swagger/index.html', icon: 'fas fa-code', labelKey: 'items.api', external: true, adminOnly: true },
],

View File

@@ -0,0 +1,154 @@
import { createContext, useContext, useState, useCallback, useMemo } from 'react'
import { backendsApi, nodesApi, resourcesApi, tracesApi } from '../utils/api'
import { usePolling } from '../hooks/usePolling'
import { useOperations } from '../hooks/useOperations'
import { useDistributedMode } from '../hooks/useDistributedMode'
// The state of the installation, assembled once for everything in the Operate
// console: the rail's per-item signals and the overview's attention list.
//
// One poller, following OperationsContext — which exists because every
// consumer running its own setInterval against the same endpoint was the
// defect it was created to fix. Four endpoints doing that would be worse.
//
// The provider is mounted by ConsoleLayout for the Operate console only, so
// "poll only while the user is in Operate" needs no route check: away from
// Operate the provider is not mounted and nothing runs.
//
// Operations are NOT fetched here. OperationsContext already polls them for
// the sidebar badge and the operations strip, so this reads that instead of
// adding a second poll of /api/operations.
const OperateSummaryContext = createContext(null)
// Slower than the operations poll (1s) on purpose. Nothing here changes on a
// human timescale — a backend does not go stale mid-glance — and the values
// are ambient rather than awaited.
const POLL_INTERVAL_MS = 15_000
// A failed summary is not an event. Nobody asked for this data; it decorates a
// rail and fills a panel. Each source degrades to "no signal" on its own so one
// dead endpoint cannot blank the other three.
async function settle(promise, fallback) {
try {
return await promise
} catch {
return fallback
}
}
export function OperateSummaryProvider({ children, pollInterval = POLL_INTERVAL_MS }) {
const [upgrades, setUpgrades] = useState({})
const [nodes, setNodes] = useState([])
const [resources, setResources] = useState(null)
const [traces, setTraces] = useState(null)
const { operations } = useOperations()
// The cluster API answers 503 when distributed mode is off, so asking for it
// on a single-node install is a guaranteed miss on every tick. The rail gates
// the Nodes entry the same way.
const { enabled: distributed } = useDistributedMode()
const fetchSummary = useCallback(async () => {
const [u, n, r, tr] = await Promise.all([
// GET /api/backends/upgrades returns the upgrade checker's cached view.
// Never POST /upgrades/check on a timer — that forces a real registry
// check.
settle(backendsApi.checkUpgrades(), {}),
distributed ? settle(nodesApi.list(), []) : Promise.resolve([]),
settle(resourcesApi.get(), null),
settle(tracesApi.summary(), null),
])
setUpgrades(u && typeof u === 'object' ? u : {})
setNodes(Array.isArray(n) ? n : (n?.nodes || []))
setResources(r)
setTraces(tr)
}, [distributed])
usePolling(fetchSummary, pollInterval)
const value = useMemo(() => {
const upgradeList = Object.values(upgrades || {})
const failedOps = operations.filter(op => op.error)
const unhealthyNodes = nodes.filter(n => nodeUnhealthy(n))
// Only things a person would act on. An operation in flight is not a
// problem, so a running install belongs in the rail's activity count and
// not in here.
const attention = [
...upgradeList.map(u => ({
id: `upgrade:${u.backend_name}`,
kind: 'backend-update',
name: u.backend_name,
from: u.installed_version,
to: u.available_version,
})),
...failedOps.map(op => ({
id: `op:${op.id || op.uid || op.name}`,
kind: 'operation-failed',
name: op.name || op.id,
detail: op.error,
})),
...unhealthyNodes.map(n => ({
id: `node:${n.id || n.name}`,
kind: 'node-unhealthy',
name: n.id || n.name,
detail: n.status,
})),
]
return {
upgrades,
nodes,
resources,
operations,
traces,
attention,
signals: {
attention: attention.length || null,
backends: upgradeList.length || null,
activity: operations.length || null,
nodes: nodes.length ? `${nodes.length - unhealthyNodes.length}/${nodes.length}` : null,
host: memoryPercent(resources),
traces: traces?.errors || null,
usage: traces?.total ? compact(traces.total) : null,
},
}
}, [upgrades, nodes, resources, operations, traces])
return (
<OperateSummaryContext.Provider value={value}>
{children}
</OperateSummaryContext.Provider>
)
}
// Node payloads have carried `healthy`, `status` and neither at different
// points. Treat an explicit negative as unhealthy and anything unrecognised as
// fine, so a shape change makes the panel quiet rather than crying wolf.
function nodeUnhealthy(node) {
if (node?.healthy === false) return true
if (typeof node?.status === 'string') {
return ['unhealthy', 'down', 'offline', 'error'].includes(node.status.toLowerCase())
}
return false
}
// 18402 -> 18.4k. A rail signal has room for a shape, not a full figure.
function compact(n) {
if (n < 1000) return String(n)
if (n < 1_000_000) return `${(n / 1000).toFixed(n < 10_000 ? 1 : 0)}k`
return `${(n / 1_000_000).toFixed(1)}M`
}
function memoryPercent(resources) {
const ram = resources?.ram || resources?.aggregate?.ram
const total = ram?.total ?? ram?.total_bytes
const used = ram?.used ?? ram?.used_bytes
if (!(total > 0) || !(used >= 0)) return null
return `${Math.round((used / total) * 100)}%`
}
// Returns null outside the Operate console, where the provider is not mounted.
// Callers render nothing rather than guessing at a value.
export function useOperateSummary() {
return useContext(OperateSummaryContext)
}

View File

@@ -2,10 +2,13 @@ import { createContext, useContext, useState, useEffect } from 'react'
const ThemeContext = createContext()
// Dark is the identity, not a preference: localai.io ships one theme and it is
// this one, so an install should look like LocalAI before anyone has chosen
// anything. The OS setting no longer picks light on first load — the toggle
// does, and once used it is remembered and wins forever after.
function getInitialTheme() {
const stored = localStorage.getItem('localai-theme')
if (stored) return stored
if (window.matchMedia?.('(prefers-color-scheme: light)').matches) return 'light'
return 'dark'
}

View File

@@ -12,6 +12,21 @@ const STORAGE_KEYS = {
const SAVE_DEBOUNCE_MS = 500
const MAX_ENTRIES = 100
// Read every store at once, without mounting a hook per media type.
//
// The Studio overview reads across all of them and writes to none, while
// useMediaHistory() carries save timers and selection state it would have no
// use for. Note there is no 'threed' store here: 3D history lives in IndexedDB
// rather than localStorage because its entries carry multi-megabyte GLB blobs,
// so callers wanting 3D read use3DHistory alongside this.
export function readAllMediaHistory() {
const all = {}
for (const [mediaType, key] of Object.entries(STORAGE_KEYS)) {
all[mediaType] = loadEntries(key)
}
return all
}
function loadEntries(key) {
try {
const stored = localStorage.getItem(key)

View File

@@ -186,18 +186,20 @@ export default function Agents() {
title={t('title')}
supporting={t('subtitle')}
actions={
<div style={{ display: 'flex', gap: 'var(--spacing-sm)', alignItems: 'center' }}>
<div className="header-actions">
{agentHubURL && (
<a className="btn btn-secondary fas fa-store fa-file-import agents-import-input btn-primary fa-plus" href={agentHubURL} target="_blank" rel="noopener noreferrer">
<i /> {t('actions.agentHub')}
<a className="btn btn-secondary" href={agentHubURL} target="_blank" rel="noopener noreferrer">
<i className="fas fa-store" /> {t('actions.agentHub')}
</a>
)}
<label>
<i /> {t('actions.import')}
{/* A label styled as a button, wrapping the file input it triggers,
so the control looks and behaves like its neighbours. */}
<label className="btn btn-secondary agents-import-input">
<i className="fas fa-file-import" /> {t('actions.import')}
<input type="file" accept=".json" onChange={handleImport} />
</label>
<button onClick={() => navigate('/app/agents/new')}>
<i /> {t('actions.createAgent')}
<button className="btn btn-primary" onClick={() => navigate('/app/agents/new')}>
<i className="fas fa-plus" /> {t('actions.createAgent')}
</button>
</div>
}

View File

@@ -1214,9 +1214,15 @@ export default function Chat() {
<i className={`fas ${msg.role === 'user' ? 'fa-user' : 'fa-robot'}`} />
</div>
<div className="chat-message-bubble">
{/* Both roles are labelled now that neither is a bubble.
A transcript needs to say who is speaking; a bubble said
it by shape and side. */}
{msg.role === 'assistant' && activeChat.model && (
<span className="chat-message-model">{activeChat.model}</span>
)}
{msg.role === 'user' && (
<span className="chat-message-model">{t('message.you')}</span>
)}
{editingMessageIndex === i ? (
<div className="chat-message-edit">
<textarea

View File

@@ -378,7 +378,7 @@ function TrainingMonitor({ job, onStop }) {
{latest && (
<>
<div className="stat-grid">
<div className="stat-cards">
<StatCard icon="fas fa-circle-notch" label="Status" value={latest.status} accentVar="--color-info" />
<StatCard icon="fas fa-percent" label="Progress" value={`${latest.progress_percent?.toFixed(1)}%`} accentVar="--color-primary" />
<StatCard icon="fas fa-shoe-prints" label="Step" value={`${latest.current_step} / ${latest.total_steps}`} />

View File

@@ -295,21 +295,26 @@ export default function Home() {
<span className="home-eyebrow">{branding.instanceName}</span>
<h1 className="home-greeting">{t(`greeting.${greetingKey()}`)}</h1>
</div>
{/* Telemetry as figures rather than chips. A chip says a thing is
true; a figure says how much, which is what someone opening the
page at a glance is actually after. */}
<div className="home-status-line" style={staggerStyle(1)}>
<StatusPill
status={loadedCount > 0 ? 'healthy' : 'idle'}
label={loadedCount > 0 ? t('statusLine.modelsLoaded', { count: loadedCount }) : t('statusLine.noModelsLoaded')}
/>
<span className="home-stat" data-testid="home-stat-loaded">
<b className={`home-stat__value${loadedCount > 0 ? ' home-stat__value--ok' : ''}`}>{loadedCount}</b>
<span className="home-stat__label">{t('statusLine.loadedLabel')}</span>
</span>
{distributedMode && clusterData && (
<StatusPill
status={clusterData.healthyCount > 0 ? 'healthy' : 'error'}
label={t('statusLine.nodes', { count: clusterData.totalCount })}
/>
<span className="home-stat" data-testid="home-stat-nodes">
<b className="home-stat__value">{clusterData.healthyCount}/{clusterData.totalCount}</b>
<span className="home-stat__label">{t('statusLine.nodesLabel')}</span>
</span>
)}
{!distributedMode && resources && (
<span className="status-pill">
<i className={`fas ${resType === 'gpu' ? 'fa-microchip' : 'fa-memory'}`} aria-hidden="true" />
{(resType === 'gpu' ? t('resourceGpu') : t('resourceRam'))} {usagePct.toFixed(0)}%
<span className="home-stat" data-testid="home-stat-resource">
<b className="home-stat__value">{usagePct.toFixed(0)}%</b>
<span className="home-stat__label">
{resType === 'gpu' ? t('resourceGpu') : t('resourceRam')}
</span>
</span>
)}
</div>
@@ -457,12 +462,19 @@ export default function Home() {
<Skeleton variant="line" count={2} />
) : loadedCount > 0 ? (
<>
<ul className="home-loaded-list reveal-stagger">
{/* Lanes: uniform records read in sequence. The id is the
identifier, so it is mono; the state is a pill because it is
a state. /api/system-information carries only the id, so
there is nothing honest to put in a backend or memory column
without a server change. */}
<ul className="lanes lanes--resident reveal-stagger">
{[...loadedModels].sort((a, b) => a.id.localeCompare(b.id)).map((m, i) => (
<li key={m.id} className="home-loaded-item" style={staggerStyle(i)}>
<StatusPill status="healthy" label={m.id} />
<li key={m.id} className="lane" style={staggerStyle(i)}>
<span className="lane__name lane__name--id">{m.id}</span>
<StatusPill status="healthy" label={t('loadedModels.serving')} />
<button
type="button"
className="home-loaded-stop"
onClick={() => handleStopModel(m.id)}
title={t('loadedModels.stop')}
aria-label={t('loadedModels.stop')}

View File

@@ -1,4 +1,5 @@
import { useState, useRef } from 'react'
import RequestPanel from '../components/RequestPanel'
import { useParams, useOutletContext } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import ModelSelector from '../components/ModelSelector'
@@ -36,6 +37,9 @@ export default function ImageGen() {
const refRef = useRef(null)
const { addEntry, selectEntry, selectedEntry, historyProps } = useMediaHistory('image')
const [lightboxIdx, setLightboxIdx] = useState(null)
// The body of the last request, kept so the panel can show what was actually
// sent rather than what the form currently holds.
const [lastRequest, setLastRequest] = useState(null)
// The images currently on screen (a picked history entry, else the latest run).
const displayImages = selectedEntry
@@ -60,6 +64,8 @@ export default function ImageGen() {
if (sourceImage) body.file = sourceImage
if (refImages.length > 0) body.ref_images = refImages
setLastRequest(body)
try {
const data = await imageApi.generate(body)
const results = data?.data || []
@@ -154,6 +160,7 @@ export default function ImageGen() {
</div>
<div className="media-preview">
<RequestPanel endpoint="/v1/images/generations" body={lastRequest} />
<div className="media-result">
{loading ? (
<GenerationProgress count={count} label={t('image.actions.generating')} />

View File

@@ -1244,7 +1244,10 @@ function VramByContext({ estimate, contextSize, onPickContext, totalGpuMemory, t
verdictClass = 'bad'
verdict = t('chart.fitsNowhere')
} else if (over > 0) {
verdictClass = over === 1 ? 'warn' : 'bad'
// Warn, however many sizes are over. A model that fits at 8k but not 32k is
// a trade-off, not a fault, and it stays installable — reserving the error
// tone for "fits nowhere" keeps that distinction legible.
verdictClass = 'warn'
verdict = t('chart.fitsUpTo', { context: lastFitting.label })
}

View File

@@ -0,0 +1,164 @@
import { Link } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import PageHeader from '../components/PageHeader'
import Sparkline from '../components/Sparkline'
import { useOperateSummary } from '../contexts/OperateSummaryContext'
import { staggerStyle } from '../hooks/useStagger'
// The front door to Operate.
//
// Everything here except one block is a summary you could assemble by visiting
// four other pages. The exception is "Needs attention", which is the reason the
// page exists: a single place that is EMPTY when nothing is wrong. That is the
// statement no other page in the app makes.
//
// Not a SplitView. Rail-and-pane is for surfaces where an entity outgrows a row
// and candidates get compared before acting; an overview is read top to bottom
// and clicked through.
const ATTENTION_ROUTE = {
'backend-update': '/app/backends',
'operation-failed': '/app/activity',
'node-unhealthy': '/app/nodes',
}
export default function OperateOverview() {
const { t } = useTranslation('admin')
const summary = useOperateSummary()
const attention = summary?.attention || []
const nodes = summary?.nodes || []
const operations = summary?.operations || []
const upgradeCount = Object.keys(summary?.upgrades || {}).length
const traces = summary?.traces
return (
<div className="page-pad" data-testid="operate-overview">
<PageHeader
title={t('operate.overview.title')}
supporting={t('operate.overview.subtitle')}
/>
{/* Headline numbers. Only rendered once the summary has answered — an
installation that has served nothing yet is told so in one line rather
than shown three zeroes dressed as telemetry. */}
{traces && (
traces.total > 0 ? (
<dl className="operate-headline reveal-stagger">
<HeadlineStat
label={t('operate.overview.headline.requests', { hours: traces.window_hours })}
value={traces.total.toLocaleString()}
series={traces.buckets?.map(b => b.count)}
tone="primary"
/>
<HeadlineStat
label={t('operate.overview.headline.errors')}
value={traces.errors.toLocaleString()}
series={traces.buckets?.map(b => b.errors)}
tone={traces.errors > 0 ? 'warning' : 'muted'}
/>
<HeadlineStat
label={t('operate.overview.headline.p95')}
value={`${traces.p95_ms.toLocaleString()} ms`}
tone="success"
/>
</dl>
) : (
<p className="operate-clear">{t('operate.overview.headline.quiet')}</p>
)
)}
<section>
<div className="lane-head"><h2>{t('operate.overview.attention.heading')}</h2></div>
{attention.length === 0 ? (
// One line, not a panel. A green reassurance card would make "fine"
// as loud as "broken", which is the opposite of the point.
<p className="operate-clear" data-testid="operate-attention-clear">
{t('operate.overview.attention.clear')}
</p>
) : (
<ul className="lanes lanes--attention reveal-stagger">
{attention.map((item, i) => (
<li key={item.id} data-testid="operate-attention-item" style={staggerStyle(i)}>
<Link to={ATTENTION_ROUTE[item.kind] || '/app/operate'} className="lane">
<span className="lane__name">{item.name}</span>
<span className="lane__desc">
{item.kind === 'backend-update'
? t('operate.overview.attention.backendUpdate', { from: item.from, to: item.to })
: item.detail}
</span>
<span className="lane__go" aria-hidden="true"></span>
</Link>
</li>
))}
</ul>
)}
</section>
<section>
<div className="lane-head"><h2>{t('operate.overview.sections.heading')}</h2></div>
<ul className="lanes lanes--sections reveal-stagger">
<OperateSection
index={0}
to="/app/backends"
label={t('operate.overview.sections.runtime')}
summary={t('operate.overview.sections.runtimeSummary', {
updates: upgradeCount,
running: operations.length,
})}
/>
<OperateSection
index={1}
to="/app/nodes"
label={t('operate.overview.sections.cluster')}
summary={nodes.length
? t('operate.overview.sections.clusterSummary', { nodes: nodes.length })
// "0 nodes" reads as a fault on a single-node install, where the
// cluster API is simply switched off.
: t('operate.overview.sections.clusterSingle')}
/>
<OperateSection
index={2}
to="/app/traces"
label={t('operate.overview.sections.observability')}
summary={traces?.total
? t('operate.overview.sections.observabilityCounted', {
requests: traces.total.toLocaleString(),
errors: traces.errors.toLocaleString(),
p95: traces.p95_ms.toLocaleString(),
})
: t('operate.overview.sections.observabilitySummary')}
/>
<OperateSection
index={3}
to="/app/manage"
label={t('operate.overview.sections.administration')}
summary={t('operate.overview.sections.administrationSummary')}
/>
</ul>
</section>
</div>
)
}
function HeadlineStat({ label, value, series, tone }) {
return (
<div className="operate-headline__cell">
<dt>{label}</dt>
<dd className={`operate-headline__value operate-headline__value--${tone}`}>{value}</dd>
{series?.length > 1 && <Sparkline points={series} tone={tone} />}
</div>
)
}
function OperateSection({ to, label, summary, index = 0 }) {
return (
<li style={staggerStyle(index)}>
<Link to={to} className="lane">
<span className="lane__tag">{label}</span>
<span className="lane__desc">{summary}</span>
<span className="lane__go" aria-hidden="true"></span>
</Link>
</li>
)
}

View File

@@ -1,3 +1,4 @@
import { useMemo } from 'react'
import { useSearchParams } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import ImageGen from './ImageGen'
@@ -6,17 +7,30 @@ import ThreeDGen from './ThreeDGen'
import TTS from './TTS'
import Sound from './Sound'
import AudioTransform from './AudioTransform'
import StudioOverview from './StudioOverview'
import { useAuth } from '../context/AuthContext'
import { useModels } from '../hooks/useModels'
import { useOperations } from '../hooks/useOperations'
import { readAllMediaHistory } from '../hooks/useMediaHistory'
import { use3DHistory } from '../hooks/use3DHistory'
import {
CAP_IMAGE, CAP_VIDEO, CAP_3D, CAP_TTS, CAP_SOUND_GENERATION, CAP_AUDIO_TRANSFORM,
} from '../utils/capabilities'
const BASE_TABS = [
{ key: 'images', labelKey: 'studio.tabs.images', icon: 'fas fa-image' },
{ key: 'video', labelKey: 'studio.tabs.video', icon: 'fas fa-video' },
{ key: 'threed', labelKey: 'studio.tabs.threed', icon: 'fas fa-cube' },
{ key: 'tts', labelKey: 'studio.tabs.tts', icon: 'fas fa-headphones' },
{ key: 'sound', labelKey: 'studio.tabs.sound', icon: 'fas fa-music' },
// One table for the six generators: the capability that makes a modality
// usable, the feature flag that can remove it entirely, and the group it reads
// under. Studio owns this so the tab strip and the overview cannot disagree
// about what exists.
const MODALITIES = [
{ key: 'images', capability: CAP_IMAGE, icon: 'fas fa-image', group: 'create', history: 'image' },
{ key: 'video', capability: CAP_VIDEO, icon: 'fas fa-video', group: 'create', history: 'video' },
{ key: 'threed', capability: CAP_3D, icon: 'fas fa-cube', group: 'create', feature: '3d' },
{ key: 'tts', capability: CAP_TTS, icon: 'fas fa-headphones', group: 'voice', history: 'tts' },
{ key: 'sound', capability: CAP_SOUND_GENERATION, icon: 'fas fa-music', group: 'voice', history: 'sound' },
{ key: 'transform', capability: CAP_AUDIO_TRANSFORM, icon: 'fas fa-wave-square', group: 'transform', feature: 'audio_transform' },
]
const TRANSFORM_TAB = { key: 'transform', labelKey: 'studio.tabs.transform', icon: 'fas fa-wave-square' }
const OVERVIEW_TAB = { key: 'overview', icon: 'fas fa-compass' }
const TAB_COMPONENTS = {
images: ImageGen,
@@ -31,39 +45,110 @@ export default function Studio() {
const { t } = useTranslation('media')
const { hasFeature } = useAuth()
const [searchParams, setSearchParams] = useSearchParams()
const requestedTab = searchParams.get('tab') || 'images'
const threeDEnabled = hasFeature('3d')
const transformEnabled = hasFeature('audio_transform')
const activeTab =
((requestedTab === 'threed' && !threeDEnabled) ||
(requestedTab === 'transform' && !transformEnabled))
? 'images'
: requestedTab
const { operations } = useOperations()
const enabledTabs = BASE_TABS.filter(tab => tab.key !== 'threed' || threeDEnabled)
const tabs = transformEnabled ? [...enabledTabs, TRANSFORM_TAB] : enabledTabs
// Once, unfiltered. useModels(capability) fetches the whole list and filters
// in the browser, so a hook per modality would be six identical requests to
// /api/models/capabilities on every mount.
const { models } = useModels()
const setTab = (key) => {
setSearchParams({ tab: key }, { replace: true })
// A modality whose feature is off is not listed at all. That is a different
// thing from having no model, and the two must not look alike.
const available = useMemo(
() => MODALITIES.filter(m => !m.feature || hasFeature(m.feature)),
[hasFeature],
)
// Read once and share. 3D is deliberately separate: its history is IndexedDB
// (the entries carry GLB blobs), so it arrives asynchronously and cannot come
// from the same synchronous read as the other five.
const history = useMemo(() => readAllMediaHistory(), [])
const { entries: threeDEntries } = use3DHistory()
const modalities = useMemo(() => available.map(m => ({
...m,
installed: models
.filter(model => model.capabilities?.includes(m.capability))
.map(model => model.id),
typical: typicalCost(m.key === 'threed' ? threeDEntries : history[m.history]),
})), [available, models, history, threeDEntries])
const tabs = [OVERVIEW_TAB, ...available]
const requested = searchParams.get('tab')
// Overview is the fallback for anything unrecognised or gated off. Landing on
// Images was never a decision, only the first entry in an array.
const activeTab = tabs.some(tab => tab.key === requested) ? requested : 'overview'
const setTab = (key) => setSearchParams({ tab: key }, { replace: true })
const dotFor = (tab) => {
if (tab.key === 'overview') return null
const known = modalities.find(m => m.key === tab.key)
return known?.installed.length > 0 ? 'on' : 'off'
}
const ActiveComponent = TAB_COMPONENTS[activeTab] || ImageGen
const ActiveComponent = TAB_COMPONENTS[activeTab]
return (
<div>
<div className="studio-tabs">
{tabs.map(tab => (
<button
key={tab.key}
className={`studio-tab${activeTab === tab.key ? ' studio-tab-active' : ''}`}
onClick={() => setTab(tab.key)}
>
<i className={tab.icon} />
<span>{t(tab.labelKey)}</span>
</button>
))}
{tabs.map(tab => {
const dot = dotFor(tab)
return (
<button
key={tab.key}
data-tab={tab.key}
className={`studio-tab${activeTab === tab.key ? ' studio-tab-active' : ''}`}
onClick={() => setTab(tab.key)}
>
<i className={tab.icon} />
<span>{tab.key === 'overview' ? t('studio.tabs.overview') : t(`studio.tabs.${tab.key}`)}</span>
{/* Filled means a model on this machine serves the modality.
Decorative on its own: the overview states the same thing in
words, so a reader who cannot see the dot loses nothing. */}
{dot && <span className={`studio-tab__dot studio-tab__dot--${dot}`} aria-hidden="true" />}
</button>
)
})}
</div>
<ActiveComponent />
{ActiveComponent ? (
<ActiveComponent />
) : (
<StudioOverview
modalities={modalities}
recent={recentAcross(available, history, threeDEntries)}
running={operations.filter(isGeneration)}
onPick={setTab}
/>
)}
</div>
)
}
// Median rather than mean: one cold first run on a model that was still loading
// would otherwise set the expectation for every run after it.
function typicalCost(entries) {
const times = (entries || []).map(e => e.elapsedMs).filter(ms => ms > 0).sort((a, b) => a - b)
if (times.length === 0) return null
const median = times[Math.floor(times.length / 2)]
return `~${(median / 1000).toFixed(median < 10_000 ? 1 : 0)}s`
}
function recentAcross(available, history, threeDEntries) {
const fromLocalStorage = available
.filter(m => m.history)
.flatMap(m => (history[m.history] || []).map(e => ({ ...e, modality: m.key })))
const from3D = available.some(m => m.key === 'threed')
? (threeDEntries || []).map(e => ({ ...e, modality: 'threed' }))
: []
return [...fromLocalStorage, ...from3D]
.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0))
.slice(0, 6)
}
// Media generation only. A backend install is an operation too, and it belongs
// on Activity rather than in a page about making things.
function isGeneration(op) {
return ['image', 'video', 'tts', 'sound', 'transform', '3d'].includes(op.type)
}

View File

@@ -0,0 +1,134 @@
import { Link } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import PageHeader from '../components/PageHeader'
import { formatBytes } from '../utils/format'
import { staggerStyle } from '../hooks/useStagger'
// What this machine can actually make.
//
// Studio used to open on Images and say nothing about the other five
// modalities, so the only way to learn that video had no model was to pick the
// tab and find an empty select. This page answers that before the click.
//
// Two kinds of unavailable, and they must not read the same:
// - switched off server-side -> no tab, no lane, nothing (handled upstream
// in Studio.jsx, which never puts a gated modality in the list)
// - available, no model yet -> a lane with a route to installing one
//
// Lanes, not a SplitView: six modalities each carrying one decision-relevant
// fact are read in sequence, not compared as candidates.
export default function StudioOverview({ modalities, recent, running, onPick }) {
const { t } = useTranslation('media')
const ready = modalities.filter(m => m.installed.length > 0).length
return (
<div data-testid="studio-overview" className="page-pad">
{/* The shared header, not a bespoke one: every other page in the app
announces itself with .page-title, and the render-smoke gate looks
for exactly that. */}
<PageHeader
title={t('studio.overview.title')}
supporting={t('studio.overview.subtitle')}
/>
<div className="lane-head">
<h2>{t('studio.overview.canMake')}</h2>
<span className="lane-head__meta">
{t('studio.overview.eyebrow', { ready, total: modalities.length })}
</span>
</div>
<ul className="lanes lanes--modality reveal-stagger">
{modalities.map((m, i) => (
<li key={m.key} data-testid="studio-modality" data-modality={m.key} style={staggerStyle(i)}>
<ModalityLane modality={m} onPick={onPick} t={t} />
</li>
))}
</ul>
{running.length > 0 && (
<>
<div className="lane-head"><h2>{t('studio.overview.running')}</h2></div>
<ul className="lanes lanes--takes" data-testid="studio-running">
{running.map(op => (
<li key={op.id || op.name} className="lane">
<span className="lane__name">{op.name || op.id}</span>
{typeof op.progress === 'number' && (
<span className="studio-running__meter">
<i style={{ width: `${Math.max(0, Math.min(100, op.progress))}%` }} />
</span>
)}
</li>
))}
</ul>
</>
)}
{/* Absent rather than empty. A shelf with nothing on it is furniture. */}
{recent.length > 0 && (
<>
<div className="lane-head"><h2>{t('studio.overview.recent')}</h2></div>
<ul className="lanes lanes--takes reveal-stagger" data-testid="studio-recent">
{recent.map((entry, i) => (
<li key={entry.id} style={staggerStyle(i)}>
<button type="button" className="lane" onClick={() => onPick(entry.modality)}>
<span className="lane__name">{entry.model || entry.modality}</span>
<span className="lane__num">{describeEntry(entry, t)}</span>
</button>
</li>
))}
</ul>
</>
)}
</div>
)
}
function ModalityLane({ modality, onPick, t }) {
const { key, installed, typical } = modality
const hasModel = installed.length > 0
const body = (
<>
<span className="lane__tag">{t(`studio.groups.${modality.group}`)}</span>
<span className="lane__main">
<b className="lane__name">{t(`studio.tabs.${key}`)}</b>
<span className="lane__desc">{t(`studio.overview.describe.${key}`)}</span>
</span>
<span className="lane__num">
{hasModel
? installed[0] + (installed.length > 1 ? ` +${installed.length - 1}` : '')
: t('studio.overview.noModel')}
</span>
{/* Dash, not a guess. Cost is measured from this machine's own history. */}
<span className="lane__num">{typical || '—'}</span>
</>
)
if (!hasModel) {
return (
<span className="lane studio-modality--empty">
{body}
<Link className="studio-modality__install" to={`/app/models?capability=${key}`}>
{t('studio.overview.install')}
</Link>
</span>
)
}
return (
<button type="button" className="lane" onClick={() => onPick(key)}>
{body}
<span className="studio-modality__state">{t('studio.overview.ready')}</span>
</button>
)
}
function describeEntry(entry, t) {
const bits = []
if (entry.size) bits.push(entry.size)
if (entry.bytes) bits.push(formatBytes(entry.bytes))
if (entry.elapsedMs) bits.push(t('studio.overview.seconds', { seconds: (entry.elapsedMs / 1000).toFixed(1) }))
return bits.join(' · ')
}

View File

@@ -1,4 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import RequestPanel from '../components/RequestPanel'
import { Link, useParams, useOutletContext, useSearchParams } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import ModelSelector from '../components/ModelSelector'
@@ -33,6 +34,8 @@ export default function TTS() {
const [text, setText] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
// What was actually sent, so the request panel records rather than predicts.
const [lastRequest, setLastRequest] = useState(null)
const [audioUrl, setAudioUrl] = useState(null)
const appliedVoiceLinkRef = useRef('')
const { addEntry, selectEntry, selectedEntry, historyProps } = useMediaHistory('tts')
@@ -72,6 +75,7 @@ export default function TTS() {
const selectedVoice = supportsVoiceProfiles ? selectedProfile?.voice : manualVoice.trim()
const request = { model, input: text.trim() }
if (selectedVoice) request.voice = selectedVoice
setLastRequest(request)
const { blob, serverUrl } = await ttsApi.generate(request)
const url = URL.createObjectURL(blob)
setAudioUrl(url)
@@ -172,6 +176,7 @@ export default function TTS() {
</div>
<div className="media-preview">
<RequestPanel endpoint="/v1/audio/speech" body={lastRequest} />
<div className="media-result">
{loading ? (
<GenerationProgress label={t('tts.actions.generating')} />

View File

@@ -43,6 +43,9 @@ const TTS = page('tts', () => import('./pages/TTS'))
const Sound = page('sound', () => import('./pages/Sound'))
const AudioTransform = page('transform', () => import('./pages/AudioTransform'))
const Talk = page('talk', () => import('./pages/Talk'))
// Referenced only from JSX below — same blind spot as Activity further down.
// eslint-disable-next-line no-unused-vars
const OperateOverview = page('operate', () => import('./pages/OperateOverview'))
const Backends = page('backends', () => import('./pages/Backends'))
// Only referenced from JSX below, which eslint cannot see without
// eslint-plugin-react. Suppressed here rather than left to widen the file's
@@ -158,6 +161,7 @@ const appChildren = [
{
element: <ConsoleLayout config={operateConsole} />,
children: [
{ path: 'operate', element: <Admin><OperateOverview /></Admin> },
{ path: 'backends', element: <Admin><Backends /></Admin> },
{ path: 'activity', element: <Admin><Activity /></Admin> },
{ path: 'voice-library', element: <Admin><VoiceLibrary /></Admin> },

View File

@@ -1,87 +1,97 @@
/* LocalAI Theme — Nord palette (polar night + frost + aurora).
Adapted from claudemaster's Nord preset. Variable names preserved. */
/* LocalAI Theme — the localai.io palette.
Retempered from Nord to match the website: the ground goes deeper, the
accent from frost cyan to a saturated action blue, and success from sage to
mint, which doubles as the "local / resident / live" signal. Amber is spent
sparingly, on the one thing asking for a decision.
Source of truth: docs/static/css/localai-home.css. Variable names preserved,
so every consumer moves together. */
:root,
[data-theme="dark"] {
/* Surfaces — deep blue-black, beyond polar night */
--color-bg-primary: #13171f; /* page — very dark, cool */
--color-bg-secondary: #1a1f2a; /* sidebar, headers, cards */
--color-bg-tertiary: #242a36; /* wells / sunken rows */
--color-bg-overlay: rgba(19, 23, 31, 0.92);
--color-bg-hover: #242a36;
--color-bg-primary: #0d1117; /* page — the site's ground */
--color-bg-secondary: #131a23; /* sidebar, headers, cards */
--color-bg-tertiary: #192330; /* wells / sunken rows */
--color-bg-overlay: rgba(13, 17, 23, 0.92);
--color-bg-hover: #192330;
--color-surface-raised: #1a1f2a;
--color-surface-sunken: #0e1117;
--color-surface-hover: #242a36;
--color-surface-elevated: #2f3644;
--color-surface-raised: #131a23;
--color-surface-sunken: #080b0f;
--color-surface-hover: #192330;
--color-surface-elevated: #223046;
/* Primary — frost cyan (nord8) */
--color-primary: #88c0d0;
--color-primary-hover: #9ccbd9;
--color-primary-active: #7ab4c4;
--color-primary-text: #2e3440;
--color-primary-light: rgba(136, 192, 208, 0.14);
--color-primary-border: rgba(136, 192, 208, 0.34);
/* Primary — action blue. Anything that routes or commits.
Dark enough to carry white label text, which frost cyan was not. */
--color-primary: #4f8cff;
--color-primary-hover: #70a2ff;
--color-primary-active: #3d78e8;
--color-primary-text: #0d1117; /* 5.88:1 on the blue; white would be 3.22:1 */
--color-primary-light: rgba(79, 140, 255, 0.14);
--color-primary-border: rgba(79, 140, 255, 0.34);
--color-secondary: #81a1c1; /* nord9 */
--color-secondary-hover: #8faed0;
--color-secondary-light: rgba(129, 161, 193, 0.12);
--color-secondary: #8aa6cc;
--color-secondary-hover: #9db6d8;
--color-secondary-light: rgba(138, 166, 204, 0.12);
/* Accent alias — frost cyan remains the brand accent */
--color-accent: #88c0d0;
--color-accent-hover: #9ccbd9;
--color-accent-light: rgba(136, 192, 208, 0.14);
--color-accent-border: rgba(136, 192, 208, 0.34);
/* Accent alias — action blue is the brand accent */
--color-accent: #4f8cff;
--color-accent-hover: #70a2ff;
--color-accent-light: rgba(79, 140, 255, 0.14);
--color-accent-border: rgba(79, 140, 255, 0.34);
/* Text — snow storm scale */
--color-text-primary: #eceff4; /* nord6 */
--color-text-secondary: #d8dee9; /* nord4 */
--color-text-muted: #a1acb9;
--color-text-tertiary: #8a96a5; /* slightly dimmer than muted, still WCAG AA on dark surfaces — used for metadata */
--color-text-disabled: #6e7a8c;
--color-text-inverse: #2e3440;
--color-text-primary: #edf4fc; /* a hair cooler, to sit with blue */
--color-text-secondary: #d3dee9;
--color-text-muted: #9aabc0;
--color-text-tertiary: #8595aa; /* dimmer than muted, still WCAG AA on the new ground — metadata */
--color-text-disabled: #67748a;
--color-text-inverse: #0d1117;
/* Borders — cool blue-gray */
--color-border-subtle: rgba(216, 222, 233, 0.06);
--color-border-default: rgba(216, 222, 233, 0.12);
--color-border-strong: rgba(216, 222, 233, 0.24);
--color-border-divider: rgba(216, 222, 233, 0.05);
--color-border-primary: rgba(136, 192, 208, 0.45);
--color-border-focus: rgba(136, 192, 208, 0.45);
/* The site's dividers are an opaque hairline rather than alpha over a
varying surface, which reads crisper wherever surfaces stack. */
--color-border-subtle: #1c2837;
--color-border-default: #29384a;
--color-border-strong: #3a4c63;
--color-border-divider: #18222f;
--color-border-primary: rgba(79, 140, 255, 0.45);
--color-border-focus: rgba(79, 140, 255, 0.45);
/* Status — aurora */
--color-success: #a3be8c; /* nord14 */
--color-success-light: rgba(163, 190, 140, 0.14);
--color-success-border: rgba(163, 190, 140, 0.32);
--color-warning: #ebcb8b; /* nord13 */
--color-warning-light: rgba(235, 203, 139, 0.14);
--color-warning-border: rgba(235, 203, 139, 0.32);
--color-error: #bf616a; /* nord11 */
--color-error-light: rgba(191, 97, 106, 0.14);
--color-error-border: rgba(191, 97, 106, 0.32);
--color-info: #81a1c1; /* nord9 */
--color-info-light: rgba(129, 161, 193, 0.14);
--color-info-border: rgba(129, 161, 193, 0.32);
/* Mint also carries "local / resident / live", which is the same state
often enough that a second token would mostly duplicate this one. */
--color-success: #56d6a4;
--color-success-light: rgba(86, 214, 164, 0.14);
--color-success-border: rgba(86, 214, 164, 0.32);
--color-warning: #f1b95d; /* the evidence accent, spent sparingly */
--color-warning-light: rgba(241, 185, 93, 0.14);
--color-warning-border: rgba(241, 185, 93, 0.32);
--color-error: #c96f78; /* 5.02:1 on raised surfaces; #bf616a was 4.28 */
--color-error-light: rgba(201, 111, 120, 0.14);
--color-error-border: rgba(201, 111, 120, 0.32);
--color-info: #7aa7e8;
--color-info-light: rgba(122, 167, 232, 0.14);
--color-info-border: rgba(122, 167, 232, 0.32);
--color-modal-backdrop: rgba(8, 11, 17, 0.68);
--color-focus-ring: rgba(136, 192, 208, 0.7); /* was 0.34 - AA-visible */
--color-eyebrow: #d8b48c; /* muted Nord-aurora brass for editorial eyebrows */
--color-focus-ring: rgba(79, 140, 255, 0.7);
--color-eyebrow: #56d6a4; /* mint, matching the site's uppercase micro-labels */
/* Data viz — full aurora + frost palette */
--color-data-1: #88c0d0; /* frost cyan */
--color-data-2: #bf616a; /* red */
--color-data-1: #4f8cff; /* action blue */
--color-data-2: #c96f78; /* red */
--color-data-3: #b48ead; /* purple */
--color-data-4: #ebcb8b; /* yellow */
--color-data-5: #a3be8c; /* green */
--color-data-4: #f1b95d; /* amber */
--color-data-5: #56d6a4; /* mint */
--color-data-6: #d08770; /* orange */
--color-data-7: #81a1c1; /* blue */
--color-data-7: #7aa7e8; /* soft blue */
--color-data-8: #8fbcbb; /* teal */
/* Log streams — tuned to Nord aurora */
--color-log-stdout: #d8dee9;
--color-log-stderr: #bf616a;
--color-log-info: #88c0d0;
--color-log-warn: #ebcb8b;
--color-log-stdout: #d3dee9;
--color-log-stderr: #c96f78;
--color-log-info: #4f8cff;
--color-log-warn: #f1b95d;
/* Shadows — cool, deeper */
--shadow-subtle: 0 1px 2px rgba(0, 0, 0, 0.5);
@@ -89,7 +99,7 @@
--shadow-md: 0 2px 8px rgba(0, 0, 0, 0.55), 0 20px 48px rgba(0, 0, 0, 0.65);
--shadow-lg: 0 2px 8px rgba(0, 0, 0, 0.6), 0 28px 64px rgba(0, 0, 0, 0.7);
--shadow-glow: var(--shadow-md);
--shadow-sidebar: 1px 0 0 rgba(216, 222, 233, 0.06);
--shadow-sidebar: 1px 0 0 #1c2837;
--shadow-inset-top: inset 0 1px 0 rgba(255, 255, 255, 0.05);
--shadow-inset-hi: inset 0 1px 0 rgba(255, 255, 255, 0.18);
@@ -162,82 +172,85 @@
--bp-tablet: 1024px;
}
/* The site ships one theme and never had to answer this, so the light palette
is derived rather than inverted: the three roles keep their meaning and take
values that clear 4.5:1 on paper, where the dark-mode blue and mint sit near
2:1. Ground is a cool paper rather than white. */
[data-theme="light"] {
/* Snow storm */
--color-bg-primary: #eceff4; /* nord6 */
--color-bg-primary: #f7f9fc;
--color-bg-secondary: #ffffff;
--color-bg-tertiary: #e5e9f0; /* nord5 */
--color-bg-overlay: rgba(236, 239, 244, 0.92);
--color-bg-hover: #e5e9f0;
--color-bg-tertiary: #eef2f8;
--color-bg-overlay: rgba(247, 249, 252, 0.92);
--color-bg-hover: #eef2f8;
--color-surface-raised: #ffffff;
--color-surface-sunken: #e5e9f0;
--color-surface-hover: #d8dee9;
--color-surface-elevated: #d8dee9; /* nord4 */
--color-surface-sunken: #e7edf5;
--color-surface-hover: #e2e9f3;
--color-surface-elevated: #dde5f0;
/* Primary — deeper frost for WCAG on snow storm */
--color-primary: #5e81ac; /* nord10 */
--color-primary-hover: #4c6d92;
--color-primary-active: #3e5b7c;
--color-primary-text: #eceff4;
--color-primary-light: rgba(94, 129, 172, 0.12);
--color-primary-border: rgba(94, 129, 172, 0.34);
/* Primary — the site's blue darkened until it carries white text on paper */
--color-primary: #2f62d8;
--color-primary-hover: #2753bd;
--color-primary-active: #1f459e;
--color-primary-text: #ffffff;
--color-primary-light: rgba(47, 98, 216, 0.12);
--color-primary-border: rgba(47, 98, 216, 0.34);
--color-secondary: #4c566a; /* nord3 */
--color-secondary-hover: #3b4252;
--color-secondary-light: rgba(76, 86, 106, 0.1);
--color-secondary: #4a5769;
--color-secondary-hover: #3a4553;
--color-secondary-light: rgba(74, 87, 105, 0.1);
--color-accent: #5e81ac;
--color-accent-hover: #4c6d92;
--color-accent-light: rgba(94, 129, 172, 0.12);
--color-accent-border: rgba(94, 129, 172, 0.32);
--color-accent: #2f62d8;
--color-accent-hover: #2753bd;
--color-accent-light: rgba(47, 98, 216, 0.12);
--color-accent-border: rgba(47, 98, 216, 0.32);
--color-text-primary: #2e3440; /* nord0 */
--color-text-secondary: #3b4252; /* nord1 */
--color-text-muted: #6e7a8c;
--color-text-tertiary: #6e7a8c; /* matches muted in light theme — going lighter would fail contrast on white */
--color-text-disabled: #a1acb9;
--color-text-primary: #0d1117;
--color-text-secondary: #2b3648;
--color-text-muted: #55647a;
--color-text-tertiary: #55647a; /* matches muted lighter would fail contrast on paper */
--color-text-disabled: #97a3b4;
--color-text-inverse: #ffffff;
--color-border-subtle: rgba(46, 52, 64, 0.08);
--color-border-default: rgba(46, 52, 64, 0.14);
--color-border-strong: rgba(46, 52, 64, 0.28);
--color-border-divider: rgba(46, 52, 64, 0.06);
--color-border-primary: rgba(94, 129, 172, 0.45);
--color-border-focus: rgba(94, 129, 172, 0.45);
--color-border-subtle: #e3e9f1;
--color-border-default: #d3dbe6;
--color-border-strong: #b3bfd0;
--color-border-divider: #e9eef5;
--color-border-primary: rgba(47, 98, 216, 0.45);
--color-border-focus: rgba(47, 98, 216, 0.45);
/* Status — darker aurora for light mode contrast */
--color-success: #6b8a5a;
--color-success-light: rgba(107, 138, 90, 0.12);
--color-success-border: rgba(107, 138, 90, 0.3);
--color-warning: #b08334;
--color-warning-light: rgba(176, 131, 52, 0.12);
--color-warning-border: rgba(176, 131, 52, 0.3);
/* Mint and amber darkened to clear 4.5:1 on paper. Same roles, new values. */
--color-success: #0a734f; /* 5.56:1 on paper; #0d8b60 was only 4.08 */
--color-success-light: rgba(10, 115, 79, 0.12);
--color-success-border: rgba(10, 115, 79, 0.3);
--color-warning: #8a5d0b;
--color-warning-light: rgba(138, 93, 11, 0.12);
--color-warning-border: rgba(138, 93, 11, 0.3);
--color-error: #a13e47;
--color-error-light: rgba(161, 62, 71, 0.1);
--color-error-border: rgba(161, 62, 71, 0.3);
--color-info: #4c6d92;
--color-info-light: rgba(76, 109, 146, 0.12);
--color-info-border: rgba(76, 109, 146, 0.3);
--color-info: #2f62d8;
--color-info-light: rgba(47, 98, 216, 0.12);
--color-info-border: rgba(47, 98, 216, 0.3);
--color-modal-backdrop: rgba(46, 52, 64, 0.38);
--color-modal-backdrop: rgba(13, 17, 23, 0.38);
--color-focus-ring: rgba(94, 129, 172, 0.6); /* was 0.34 */
--color-eyebrow: #9a6b3f; /* darker brass for contrast on snow storm */
--color-focus-ring: rgba(47, 98, 216, 0.6);
--color-eyebrow: #0a734f; /* mint, darkened until it clears AA on paper */
/* Data viz — muted aurora for light mode */
--color-data-1: #5e81ac;
/* Data viz — darkened for light mode */
--color-data-1: #2f62d8;
--color-data-2: #a13e47;
--color-data-3: #8b5a92;
--color-data-4: #b08334;
--color-data-5: #6b8a5a;
--color-data-4: #8a5d0b;
--color-data-5: #0a734f;
--color-data-6: #b8684f;
--color-data-7: #4c6d92;
--color-data-8: #5a9090;
--color-log-stdout: #2e3440;
--color-log-stderr: #a13e47;
--color-log-info: #4c6d92;
--color-log-info: #2f62d8;
--color-log-warn: #b08334;
/* Soft cool shadows */

View File

@@ -231,6 +231,8 @@ async function fetchTracePage(endpoint, { limit = DEFAULT_TRACE_PAGE_SIZE, offse
export const tracesApi = {
get: (opts) => fetchTracePage(API_CONFIG.endpoints.traces, opts),
// Counted totals, so a dashboard does not fetch the whole list to size it.
summary: () => fetchJSON(API_CONFIG.endpoints.tracesSummary),
getOne: (id) => fetchJSON(API_CONFIG.endpoints.trace(id)),
clear: () => postJSON(API_CONFIG.endpoints.clearTraces, {}),
getBackend: (opts) => fetchTracePage(API_CONFIG.endpoints.backendTraces, opts),

View File

@@ -2,65 +2,66 @@ import { EditorView } from '@codemirror/view'
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language'
import { tags } from '@lezer/highlight'
// Dark theme — Nord polar-night surfaces with aurora syntax highlighting
// Dark theme — restated from theme.css because CodeMirror cannot read CSS
// variables. Keep in step with the tokens or the editor drifts off-palette.
const darkEditorTheme = EditorView.theme({
'&': {
backgroundColor: '#13171f',
color: '#eceff4',
backgroundColor: '#0d1117',
color: '#edf4fc',
fontFamily: 'var(--font-mono)',
fontSize: '0.8125rem',
lineHeight: '1.5',
},
'.cm-content': {
caretColor: '#88c0d0',
caretColor: '#4f8cff',
padding: '0',
},
'.cm-cursor, .cm-dropCursor': { borderLeftColor: '#88c0d0', borderLeftWidth: '2px' },
'.cm-cursor, .cm-dropCursor': { borderLeftColor: '#4f8cff', borderLeftWidth: '2px' },
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection': {
backgroundColor: 'rgba(136, 192, 208, 0.25)',
backgroundColor: 'rgba(79, 140, 255, 0.25)',
},
'.cm-gutters': {
backgroundColor: '#1a1f2a',
color: '#6e7a8c',
borderRight: '1px solid #2f3644',
backgroundColor: '#131a23',
color: '#67748a',
borderRight: '1px solid #223046',
},
'.cm-activeLineGutter': { backgroundColor: 'rgba(136, 192, 208, 0.1)', color: '#a1acb9' },
'.cm-activeLine': { backgroundColor: 'rgba(136, 192, 208, 0.06)' },
'.cm-foldPlaceholder': { backgroundColor: '#2f3644', border: 'none', color: '#a1acb9' },
'.cm-matchingBracket': { backgroundColor: 'rgba(136, 192, 208, 0.22)', outline: '1px solid rgba(136, 192, 208, 0.5)' },
'.cm-activeLineGutter': { backgroundColor: 'rgba(79, 140, 255, 0.1)', color: '#9aabc0' },
'.cm-activeLine': { backgroundColor: 'rgba(79, 140, 255, 0.06)' },
'.cm-foldPlaceholder': { backgroundColor: '#223046', border: 'none', color: '#9aabc0' },
'.cm-matchingBracket': { backgroundColor: 'rgba(79, 140, 255, 0.22)', outline: '1px solid rgba(79, 140, 255, 0.5)' },
'.cm-tooltip': {
backgroundColor: '#1a1f2a',
border: '1px solid #2f3644',
backgroundColor: '#131a23',
border: '1px solid #223046',
borderRadius: 'var(--radius-md)',
boxShadow: '0 4px 16px rgba(0,0,0,0.5)',
},
'.cm-tooltip-autocomplete': {
'& > ul': { fontFamily: 'var(--font-mono)', fontSize: '0.8125rem' },
'& > ul > li': { padding: 'var(--spacing-xs) var(--spacing-sm)' },
'& > ul > li[aria-selected]': { backgroundColor: 'rgba(136, 192, 208, 0.22)', color: '#eceff4' },
'& > ul > li[aria-selected]': { backgroundColor: 'rgba(79, 140, 255, 0.22)', color: '#edf4fc' },
},
'.cm-tooltip.cm-completionInfo': { padding: 'var(--spacing-sm)', maxWidth: '300px' },
'.cm-completionDetail': { color: '#a1acb9', fontStyle: 'italic', marginLeft: '0.5em' },
'.cm-panels': { backgroundColor: '#1a1f2a', color: '#eceff4' },
'.cm-panels.cm-panels-top': { borderBottom: '1px solid #2f3644' },
'.cm-panels.cm-panels-bottom': { borderTop: '1px solid #2f3644' },
'.cm-completionDetail': { color: '#9aabc0', fontStyle: 'italic', marginLeft: '0.5em' },
'.cm-panels': { backgroundColor: '#131a23', color: '#edf4fc' },
'.cm-panels.cm-panels-top': { borderBottom: '1px solid #223046' },
'.cm-panels.cm-panels-bottom': { borderTop: '1px solid #223046' },
'.cm-searchMatch': { backgroundColor: 'rgba(235, 203, 139, 0.2)', outline: '1px solid rgba(235, 203, 139, 0.45)' },
'.cm-searchMatch.cm-searchMatch-selected': { backgroundColor: 'rgba(235, 203, 139, 0.42)' },
'.cm-selectionMatch': { backgroundColor: 'rgba(136, 192, 208, 0.12)' },
'.cm-selectionMatch': { backgroundColor: 'rgba(79, 140, 255, 0.12)' },
}, { dark: true })
const darkHighlightStyle = HighlightStyle.define([
{ tag: tags.propertyName, color: '#88c0d0', fontWeight: '500' }, // YAML keys — frost cyan
{ tag: tags.string, color: '#a3be8c' }, // strings — aurora green
{ tag: tags.propertyName, color: '#4f8cff', fontWeight: '500' }, // YAML keys — action blue
{ tag: tags.string, color: '#56d6a4' }, // strings — mint
{ tag: tags.number, color: '#d08770' }, // numbers — aurora orange
{ tag: tags.bool, color: '#b48ead' }, // booleans — aurora purple
{ tag: tags.null, color: '#b48ead' }, // null — aurora purple
{ tag: tags.keyword, color: '#81a1c1' }, // keywords — frost blue
{ tag: tags.comment, color: '#6e7a8c', fontStyle: 'italic' }, // comments — muted
{ tag: tags.meta, color: '#d8dee9' }, // directives — snow storm
{ tag: tags.punctuation, color: '#8fbcbb' }, // colons, dashes — frost teal
{ tag: tags.keyword, color: '#7aa7e8' }, // keywords — frost blue
{ tag: tags.comment, color: '#67748a', fontStyle: 'italic' }, // comments — muted
{ tag: tags.meta, color: '#d3dee9' }, // directives — snow storm
{ tag: tags.punctuation, color: '#5ec8c0' }, // colons, dashes — frost teal
{ tag: tags.atom, color: '#bf616a' }, // special values — aurora red
{ tag: tags.labelName, color: '#88c0d0', fontWeight: '500' }, // anchors/aliases
{ tag: tags.labelName, color: '#4f8cff', fontWeight: '500' }, // anchors/aliases
])
// Light theme — Nord snow-storm surfaces with darkened aurora highlighting
@@ -82,16 +83,16 @@ const lightEditorTheme = EditorView.theme({
},
'.cm-gutters': {
backgroundColor: '#e5e9f0',
color: '#6e7a8c',
borderRight: '1px solid #d8dee9',
color: '#67748a',
borderRight: '1px solid #d3dee9',
},
'.cm-activeLineGutter': { backgroundColor: 'rgba(94, 129, 172, 0.1)', color: '#3b4252' },
'.cm-activeLine': { backgroundColor: 'rgba(94, 129, 172, 0.05)' },
'.cm-foldPlaceholder': { backgroundColor: '#d8dee9', border: 'none', color: '#4c566a' },
'.cm-foldPlaceholder': { backgroundColor: '#d3dee9', border: 'none', color: '#4c566a' },
'.cm-matchingBracket': { backgroundColor: 'rgba(94, 129, 172, 0.18)', outline: '1px solid rgba(94, 129, 172, 0.35)' },
'.cm-tooltip': {
backgroundColor: '#ffffff',
border: '1px solid #d8dee9',
border: '1px solid #d3dee9',
borderRadius: 'var(--radius-md)',
boxShadow: '0 4px 16px rgba(46, 52, 64, 0.12)',
},
@@ -101,10 +102,10 @@ const lightEditorTheme = EditorView.theme({
'& > ul > li[aria-selected]': { backgroundColor: 'rgba(94, 129, 172, 0.14)', color: '#2e3440' },
},
'.cm-tooltip.cm-completionInfo': { padding: 'var(--spacing-sm)', maxWidth: '300px' },
'.cm-completionDetail': { color: '#6e7a8c', fontStyle: 'italic', marginLeft: '0.5em' },
'.cm-completionDetail': { color: '#67748a', fontStyle: 'italic', marginLeft: '0.5em' },
'.cm-panels': { backgroundColor: '#e5e9f0', color: '#2e3440' },
'.cm-panels.cm-panels-top': { borderBottom: '1px solid #d8dee9' },
'.cm-panels.cm-panels-bottom': { borderTop: '1px solid #d8dee9' },
'.cm-panels.cm-panels-top': { borderBottom: '1px solid #d3dee9' },
'.cm-panels.cm-panels-bottom': { borderTop: '1px solid #d3dee9' },
'.cm-searchMatch': { backgroundColor: 'rgba(176, 131, 52, 0.22)', outline: '1px solid rgba(176, 131, 52, 0.45)' },
'.cm-searchMatch.cm-searchMatch-selected': { backgroundColor: 'rgba(176, 131, 52, 0.4)' },
'.cm-selectionMatch': { backgroundColor: 'rgba(94, 129, 172, 0.1)' },

View File

@@ -38,6 +38,7 @@ export const API_CONFIG = {
// Traces
traces: '/api/traces',
tracesSummary: '/api/traces/summary',
trace: (id) => `/api/traces/${encodeURIComponent(id)}`,
clearTraces: '/api/traces/clear',
backendTraces: '/api/backend-traces',

View File

@@ -30,6 +30,8 @@ func RegisterLocalAIRoutes(router *echo.Echo,
mcpJobsMw echo.MiddlewareFunc,
mcpMw echo.MiddlewareFunc) {
// Themed index first, then the library's wildcard for its own assets.
RegisterSwaggerTheme(router)
router.GET("/swagger/*", echoswagger.EchoWrapHandler(func(c *echoswagger.Config) {
c.URLs = []string{"doc.json"}
}))
@@ -236,6 +238,8 @@ func RegisterLocalAIRoutes(router *echo.Echo,
// Traces and backend logs (monitoring)
router.GET("/api/traces", localai.GetAPITracesEndpoint(), adminMiddleware)
// Registered before /:id so "summary" is not captured as a trace ID.
router.GET("/api/traces/summary", localai.GetAPITracesSummaryEndpoint(), adminMiddleware)
router.GET("/api/traces/:id", localai.GetAPITraceEndpoint(), adminMiddleware)
router.POST("/api/traces/clear", localai.ClearAPITracesEndpoint(), adminMiddleware)
router.GET("/api/backend-traces", localai.GetBackendTracesEndpoint(), adminMiddleware)
@@ -274,6 +278,7 @@ func RegisterLocalAIRoutes(router *echo.Echo,
"system": "/system",
"version": "/version",
"traces": "/api/traces",
"traces_summary": "/api/traces/summary",
"trace": "/api/traces/:id",
"traces_clear": "/api/traces/clear",
"backend_traces": "/api/backend-traces",

View File

@@ -0,0 +1,198 @@
// SPDX-License-Identifier: MIT
package routes
import (
"net/http"
"github.com/labstack/echo/v4"
)
// The API reference is the one page in LocalAI that still shipped in someone
// else's colours. Swagger UI has no theming hook, so rather than fight it we
// serve our own index ahead of the library's wildcard and restate the palette
// over its stylesheet. The library's own assets are still what load — this is a
// skin, not a fork, so a swagger-ui upgrade cannot silently break the page.
//
// Values are copied from react-ui/src/theme.css rather than referenced: this
// page is served by Go and never sees the app's CSS. Keep them in step; the
// commit that changes one should change the other.
const swaggerThemeCSS = `
:root {
--lai-bg: #0d1117;
--lai-surface: #131a23;
--lai-sunken: #080b0f;
--lai-line: #29384a;
--lai-ink: #edf4fc;
--lai-muted: #9aabc0;
--lai-blue: #4f8cff;
--lai-mint: #56d6a4;
--lai-amber: #f1b95d;
--lai-red: #c96f78;
}
body { background: var(--lai-bg); color: var(--lai-ink); }
.swagger-ui, .swagger-ui .info .title, .swagger-ui .opblock-tag,
.swagger-ui .opblock .opblock-summary-operation-id,
.swagger-ui .opblock .opblock-summary-path,
.swagger-ui .opblock .opblock-summary-description,
.swagger-ui table thead tr td, .swagger-ui table thead tr th,
.swagger-ui .parameter__name, .swagger-ui .parameter__type,
.swagger-ui .response-col_status, .swagger-ui label,
.swagger-ui .tab li, .swagger-ui .model-title, .swagger-ui .model {
color: var(--lai-ink);
}
.swagger-ui .info li, .swagger-ui .info p, .swagger-ui .info table,
.swagger-ui .markdown p, .swagger-ui .renderedMarkdown p,
.swagger-ui .opblock-description-wrapper p, .swagger-ui .response-col_links,
.swagger-ui .parameter__in, .swagger-ui .opblock-title_normal p {
color: var(--lai-muted);
}
/* The topbar is the library's branding; the reference is ours. */
.swagger-ui .topbar { background: var(--lai-surface); border-bottom: 1px solid var(--lai-line); }
.swagger-ui .topbar .download-url-wrapper { display: none; }
/* Operations as hairline rows rather than tinted, shadowed cards, matching the
lane idiom the rest of the app uses. Method colour carries the meaning. */
.swagger-ui .opblock {
background: var(--lai-surface);
border: 1px solid var(--lai-line);
border-radius: 6px;
box-shadow: none;
margin: 0 0 8px;
}
.swagger-ui .opblock .opblock-summary { border-color: var(--lai-line); }
/* Swagger tints the whole row per method (.opblock.opblock-post etc). Matching
its specificity rather than reaching for !important: the method belongs on
one edge, not washed across the row, or every row is a status colour and
none of them mean anything. */
.swagger-ui .opblock.opblock-get,
.swagger-ui .opblock.opblock-post,
.swagger-ui .opblock.opblock-put,
.swagger-ui .opblock.opblock-patch,
.swagger-ui .opblock.opblock-delete,
.swagger-ui .opblock.opblock-head,
.swagger-ui .opblock.opblock-options {
background: var(--lai-surface);
border-color: var(--lai-line);
}
.swagger-ui .opblock.opblock-get { border-left: 3px solid var(--lai-blue); }
.swagger-ui .opblock.opblock-post { border-left: 3px solid var(--lai-mint); }
.swagger-ui .opblock.opblock-put,
.swagger-ui .opblock.opblock-patch { border-left: 3px solid var(--lai-amber); }
.swagger-ui .opblock.opblock-delete { border-left: 3px solid var(--lai-red); }
/* An outlined chip, not a filled one. White on pale green was the least
readable thing on the page. */
.swagger-ui .opblock .opblock-summary-method,
.swagger-ui .opblock.opblock-get .opblock-summary-method,
.swagger-ui .opblock.opblock-post .opblock-summary-method,
.swagger-ui .opblock.opblock-put .opblock-summary-method,
.swagger-ui .opblock.opblock-patch .opblock-summary-method,
.swagger-ui .opblock.opblock-delete .opblock-summary-method {
background: transparent;
border: 1px solid currentColor;
border-radius: 3px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.75rem;
font-weight: 600;
text-shadow: none;
min-width: 68px;
}
.swagger-ui .opblock.opblock-get .opblock-summary-method { color: var(--lai-blue); }
.swagger-ui .opblock.opblock-post .opblock-summary-method { color: var(--lai-mint); }
.swagger-ui .opblock.opblock-put .opblock-summary-method,
.swagger-ui .opblock.opblock-patch .opblock-summary-method { color: var(--lai-amber); }
.swagger-ui .opblock.opblock-delete .opblock-summary-method { color: var(--lai-red); }
.swagger-ui .opblock-tag { border-bottom: 1px solid var(--lai-line); }
.swagger-ui section.models, .swagger-ui section.models .model-container {
background: var(--lai-surface);
border-color: var(--lai-line);
}
.swagger-ui select, .swagger-ui input[type=text], .swagger-ui textarea {
background: var(--lai-sunken);
color: var(--lai-ink);
border: 1px solid var(--lai-line);
border-radius: 4px;
}
.swagger-ui .btn {
background: transparent;
color: var(--lai-ink);
border: 1px solid var(--lai-line);
border-radius: 5px;
box-shadow: none;
}
.swagger-ui .btn.execute { background: var(--lai-blue); border-color: var(--lai-blue); color: var(--lai-bg); }
.swagger-ui .btn.authorize { color: var(--lai-mint); border-color: var(--lai-mint); }
.swagger-ui .btn.authorize svg { fill: var(--lai-mint); }
.swagger-ui .highlight-code, .swagger-ui .microlight,
.swagger-ui .responses-inner pre, .swagger-ui .body-param pre {
background: var(--lai-sunken) !important;
border: 1px solid var(--lai-line);
border-radius: 5px;
}
.swagger-ui .scheme-container { background: var(--lai-surface); box-shadow: none; border-bottom: 1px solid var(--lai-line); }
.swagger-ui .dialog-ux .modal-ux { background: var(--lai-surface); border: 1px solid var(--lai-line); }
.swagger-ui .dialog-ux .modal-ux-header { border-bottom: 1px solid var(--lai-line); }
.swagger-ui svg.arrow { fill: var(--lai-muted); }
.swagger-ui a { color: var(--lai-blue); }
@media (prefers-reduced-motion: reduce) {
.swagger-ui * { transition: none !important; animation: none !important; }
}
`
// swaggerIndexHTML loads the library's own bundle from the same directory, so
// the only thing we own is the skin.
const swaggerIndexHTML = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>LocalAI API reference</title>
<link rel="stylesheet" href="./swagger-ui.css">
<style>` + swaggerThemeCSS + `</style>
</head>
<body>
<div id="swagger-ui"></div>
<script src="./swagger-ui-bundle.js"></script>
<script src="./swagger-ui-standalone-preset.js"></script>
<script>
window.onload = function () {
window.ui = SwaggerUIBundle({
url: "doc.json",
dom_id: "#swagger-ui",
deepLinking: true,
presets: [SwaggerUIBundle.presets.apis, SwaggerUIStandalonePreset],
plugins: [SwaggerUIBundle.plugins.DownloadUrl],
layout: "StandaloneLayout",
persistAuthorization: true,
})
}
</script>
</body>
</html>`
// RegisterSwaggerTheme serves the themed index. It must be registered BEFORE
// the /swagger/* wildcard: echo prefers the more specific route, but relying on
// registration order as well costs nothing and makes the intent obvious.
func RegisterSwaggerTheme(router *echo.Echo) {
handler := func(c echo.Context) error {
return c.HTMLBlob(http.StatusOK, []byte(swaggerIndexHTML))
}
router.GET("/swagger/", handler)
router.GET("/swagger/index.html", handler)
}

View File

@@ -163,7 +163,7 @@ When authentication is enabled, the following endpoints require admin role:
- `GET /backends`, `GET /backends/available`, `GET /backends/galleries`
**System & Monitoring:**
- `GET /api/traces`, `GET /api/traces/{id}`, `POST /api/traces/clear`
- `GET /api/traces`, `GET /api/traces/summary`, `GET /api/traces/{id}`, `POST /api/traces/clear`
- `GET /api/backend-traces`, `GET /api/backend-traces/{id}`, `POST /api/backend-traces/clear`
- `GET /api/backend-logs/*`, `POST /api/backend-logs/*/clear`
- `GET /api/resources`, `GET /api/settings`, `POST /api/settings`

View File

@@ -0,0 +1,61 @@
+++
title = "Operate overview"
weight = 1
+++
`/app/operate` is the front door to the Operate console. It answers one
question — is anything wrong — without you having to open four other pages.
## Needs attention
The block the page exists for. It lists only things that want a decision:
- a backend with an update available
- an operation that failed
- a node reporting unhealthy
**When nothing needs attention it says so in one line and renders nothing
else.** There is no green panel: a status page that shouts when everything is
fine teaches you to stop reading it.
## Headline totals
Requests, failed requests and p95 latency over the last 24 hours, each with a
sparkline of the trend. These come from `GET /api/traces/summary`, which counts
the trace buffer server-side:
```bash
curl http://localhost:8080/api/traces/summary?hours=24 \
-H "Authorization: Bearer <admin-key>"
```
```json
{
"total": 18402,
"errors": 37,
"p95_ms": 842,
"window_hours": 24,
"buckets": [{ "start": "2026-08-02T09:00:00Z", "count": 1520, "errors": 3 }]
}
```
`hours` defaults to 24 and is capped at 168. Only 5xx responses and transport
errors count as failures — a 4xx is the caller getting it wrong, not the
installation being unhealthy. `p95_ms` is a nearest-rank percentile, not the
slowest request.
The endpoint exists so a dashboard wanting three numbers does not fetch the
whole trace list to count it. An installation that has served nothing yet says
so rather than showing three zeroes dressed as telemetry.
## The rail
The Operate rail groups its thirteen destinations under four headings —
Runtime, Cluster, Observability and Administration — and shows a live value
beside several of them: pending backend updates, running operations, healthy
node count, host memory, request volume and error count.
Those values are **orientation, not an alarm**. The rail only exists on Operate
routes and can be collapsed, so anything urgent also appears in Needs attention
and on the operations badge attached to the sidebar entry, which is always
visible.