feat(ui): editorial UI/UX overhaul - design language, shell/nav, conversation/canvas, sub-menus (#10390)

* feat(ui): add Fraunces variable serif + --font-serif token

Assisted-by: Claude:claude-opus-4-8 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(ui): serif display tier + section-heading typography scale

Assisted-by: Claude:claude-opus-4-8 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(ui): un-overload accent — nav rail, stronger focus ring, neutral hover

Assisted-by: Claude:claude-opus-4-8 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(ui): orchestrated page reveal + stagger motion primitives

Assisted-by: Claude:claude-opus-4-8 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* refactor(ui): fix dead token refs + dedupe toggle to one primitive

Migrate all .toggle-slider consumers (Users, Chat, AgentChat) to the
canonical BEM toggle primitive and delete the legacy duplicate CSS block.

Assisted-by: Claude:claude-opus-4-8 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* refactor(ui): route boot fallback through the LoadingSpinner primitive

Assisted-by: Claude:claude-opus-4-8 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(ui): EmptyState primitive with serif title

Assisted-by: Claude:claude-opus-4-8 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(ui): Skeleton shimmer primitive

Assisted-by: Claude:claude-opus-4-8 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(ui): PageHeader + SectionHeading editorial primitives

Assisted-by: Claude:claude-opus-4-8 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(ui): StatusPill primitive + time-of-day greeting helper

Assisted-by: Claude:claude-opus-4-8 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(ui): Home editorial header + status line (north-star redesign)

Assisted-by: Claude:claude-opus-4-8 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(ui): Home loaded-models skeleton list, button hierarchy, EmptyState wizard

Assisted-by: Claude:claude-opus-4-8 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(ui): single focus ring (no double-ring) + neutralize stagger delay under reduced motion

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude:claude-opus-4-8 [Claude Code]

* refactor(ui): all-sans editorial headings + tint-only active nav

Per design review, pivot the heading strategy from hybrid-serif to a
refined grotesk: drop the Fraunces dependency, token, and import; page
titles, the Home greeting, and section/empty-state titles now use Geist
at semibold with the editorial fluid sizing and tight tracking. No serif
anywhere.

Active sidebar item is now a tint-only treatment (accent text + tinted
background); the left accent rail is removed and the shared base
.nav-item.active inset bar is suppressed in the sidebar (as the console
rail already does). Update the design-system e2e specs to assert the
sans display font and the tinted-background active state.

Assisted-by: Claude:claude-opus-4-8 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(e2e): add --host flag to ui-test-server

Allow binding the e2e/preview server to an arbitrary address (e.g.
0.0.0.0 to review the UI from another device on the LAN). Defaults to
127.0.0.1 so existing e2e behavior is unchanged.

Assisted-by: Claude:claude-opus-4-8 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* refactor(ui): declutter Home - discoverable + dismissable API, vertical balance

Home felt overloaded and top-heavy. Three changes from review:
- The API endpoint catalog (12 endpoints) is collapsed by default behind a
  "Browse the API" disclosure; only the base URL + copy stay visible, so the
  catalog is discoverable without dominating the page.
- The whole connect card is dismissable (x): dismissing unmounts it so the
  vertical space is recovered, and the choice is remembered (localStorage).
- .home-page now fills its column and vertically centers its content when
  there is slack, so sparse states (no models / card dismissed) read as a
  balanced launcher instead of content jammed at the top. Overflow-safe:
  tall content flows from the top and scrolls.

Adds connect.browse / connect.hide / connect.dismiss i18n keys to all locales.

Assisted-by: Claude:claude-opus-4-8 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(ui): editorial PageHeader with section eyebrow + scroll-to-top on nav

PageHeader now derives its eyebrow from the route's section/console (Build /
Operate / Create) via sectionKeyForPath, so pages get a consistent, meaningful
eyebrow with no per-page wiring (override with the eyebrow prop, suppress with
eyebrow={null}). Settings adopts it as the first consumer.

Also fix a navigation scroll bug: the default layout uses the document as its
scroll container and route changes did not reset it, so navigating the console
rail from a scrolled page landed mid-view. App now scrolls to top on pathname
change.

Assisted-by: Claude:claude-opus-4-8 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* refactor(ui): adopt PageHeader on agent/media/import/backend pages (batch A)

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude:claude-opus-4-8 [Claude Code]

* refactor(ui): adopt PageHeader on ops/admin/media pages (batch B)

Replace hand-rolled .page-header title blocks with the shared editorial
PageHeader component across 14 pages (Manage, Middleware, Models,
NodeBackendLogs, Nodes, P2P, SkillEdit, Skills, Sound, Traces, TTS, Usage,
Users, VideoGen). Title/subtitle move into PageHeader; header-own action
clusters (Models stats+buttons, Skills search+buttons) move into the actions
slot. Tabs, filters, stat cards, ResourceMonitor and page body stay as
siblings. Eyebrow is left to auto-derive from the route.

Assisted-by: Claude:claude-opus-4-8 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(ui): home greeting asserts sans font, not the dropped serif

The greeting render-smoke still asserted Fraunces; update it to assert the
Geist sans display font (and not Fraunces), matching the all-sans direction.

Assisted-by: Claude:claude-opus-4-8 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(ui): ThemeToggle i18n + animated icon, drop transition:all

The theme toggle hard-coded its English tooltip; route it through the existing
nav switchToLightMode/switchToDarkMode keys and add an aria-label. The sun/moon
icon now replays a small rotate+fade on theme change (keyed remount; honored by
the global reduced-motion block). Replace the .theme-toggle `transition: all`
with explicit properties.

Assisted-by: Claude:claude-opus-4-8 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(ui): canvas drag-to-resize + slide-in, fix hooks order, typed download

Canvas was a fixed pane; make it a workbench:
- Drag the panel's left edge to resize (clamped 360px..75vw), persisted to
  localStorage, double-click to reset; hidden and full-width on narrow screens.
- Slide-in/fade on open via canvasSlideIn (honored by reduced-motion).
- Fix a rules-of-hooks bug: the `if (!current) return null` early return sat
  above useEffect, so the hook count changed when artifacts emptied. All hooks
  now run unconditionally before the guard.
- Downloads use the artifact language's real extension + MIME (a Python
  artifact saves as .py, not .txt) via extensionForLanguage.

Assisted-by: Claude:claude-opus-4-8 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(ui): per-message code blocks get a language header + copy button

Chat code blocks now render inside a framed block with a header showing the
language and a copy button (delegated handler, copies the block and flips to a
check briefly). Decoration + highlighting run from a MutationObserver scoped to
the messages container, which fires reliably for streamed responses AND for
chats loaded/switched from storage - the prior render-keyed effect missed the
load path (code was left unhighlighted on reload). The observer disconnects
while mutating so it does not retrigger on its own edits.

Assisted-by: Claude:claude-opus-4-8 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(ui): image attachments show a thumbnail in the composer

Staged image attachments now preview as a 28px thumbnail (from their data URL)
instead of a bare file icon; other types keep the icon. File names truncate and
the remove button gets an aria-label.

Assisted-by: Claude:claude-opus-4-8 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(ui): jump-to-latest pill when scrolled up in chat

When the user scrolls away from the bottom of a conversation, a floating
"Jump to latest" pill appears (sticky, centered above the composer); clicking
it smooth-scrolls to the newest message and re-pins auto-scroll. Resets on
chat switch. Adds the chat.actions.jumpToLatest i18n key to all locales.

Assisted-by: Claude:claude-opus-4-8 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(ui): canvas fullscreen toggle + keyboard tab navigation

The canvas header gains a fullscreen toggle (expands the panel to cover the
viewport; resize handle hidden while fullscreen). The artifact tab strip is now
a proper ARIA tablist with roving tabindex and Left/Right arrow-key navigation.

Assisted-by: Claude:claude-opus-4-8 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(ui): image result lightbox (zoom, prev/next, download, keyboard)

Generated/history images on the Image page are now clickable, opening a
fullscreen Lightbox with a download button, prev/next navigation, an N/M
counter, and keyboard control (Esc to close, Left/Right to navigate). Adds a
reusable `Lightbox` component (usable later for Video) and the media.image
.actions.view i18n key.

Assisted-by: Claude:claude-opus-4-8 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(ui): generation progress with placeholder tiles + elapsed timer

Image generation replaces the bare spinner with a GenerationProgress scaffold:
shimmer placeholder tiles matching the requested count plus a live elapsed-time
readout, so the (often slow) wait feels accountable. Reusable for the other
media generation pages.

Assisted-by: Claude:claude-opus-4-8 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(ui): generation progress on Video, TTS, and Sound pages

Reuse GenerationProgress (placeholder tile + elapsed timer) in place of the
bare spinner on the remaining media generation pages, so every slow generation
gives the same accountable feedback.

Assisted-by: Claude:claude-opus-4-8 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(ui): agent chat gets per-message code-copy + reliable highlighting

AgentChat now shares Chat's code-block treatment: it runs highlightAll +
enhanceCodeBlocks from a MutationObserver on its messages container (the same
proven path), so agent responses get language headers, copy buttons, and
highlighting that fires for both streamed and loaded messages - closing the
divergence with the main chat without a large refactor.

Assisted-by: Claude:claude-opus-4-8 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(ui): Talk voice visualizer

Add a hero frequency-bar visualizer at the top of the Talk page so users get
ambient feedback that they are heard and that the assistant is speaking - the
audit's main Talk gap (the only prior feedback was a small status pill; the
waveform was buried in the dev diagnostics panel).

VoiceVisualizer is self-contained: it builds its own AudioContext + analysers
from the output <audio> stream (speaking) and the mic stream (listening) so it
does not touch the existing WebRTC/diagnostics graph. Bars are status-tinted
(idle/connected/listening/speaking/error) and animate with a gentle idle wave
when not connected. Live mic/output animation is exercised on a real session.

Assisted-by: Claude:claude-opus-4-8 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
LocalAI [bot]
2026-06-18 23:03:27 +02:00
committed by GitHub
parent c3b3336654
commit f68edfc85f
81 changed files with 4738 additions and 1173 deletions

View File

@@ -0,0 +1,48 @@
import { test, expect } from './coverage-fixtures.js'
test.describe('Editorial design system', () => {
test('page titles render in the sans display font (no serif)', async ({ page }) => {
await page.goto('/app/settings')
const title = page.locator('.page-title').first()
await expect(title).toBeVisible({ timeout: 15_000 })
const family = await title.evaluate(el => getComputedStyle(el).fontFamily)
// Editorial-grotesk direction: headings use the Geist sans family, no serif.
expect(family.toLowerCase()).toContain('geist')
expect(family.toLowerCase()).not.toContain('fraunces')
})
test('active nav item is highlighted with a tinted background (no rail)', async ({ page }) => {
await page.goto('/app/settings')
await expect(page.locator('.page-title').first()).toBeVisible({ timeout: 15_000 })
const active = page.locator('.sidebar-nav .nav-item.active').first()
await expect(active).toBeVisible()
const bg = await active.evaluate(el => getComputedStyle(el).backgroundColor)
// Tint-only active treatment: a non-transparent tinted background.
expect(bg).not.toBe('rgba(0, 0, 0, 0)')
expect(bg).not.toBe('transparent')
})
test('page reveal animation is defined on .page-transition', async ({ page }) => {
await page.goto('/app/settings')
const pt = page.locator('.page-transition').first()
await expect(pt).toBeVisible({ timeout: 15_000 })
const name = await pt.evaluate(el => getComputedStyle(el).animationName)
expect(name).toBe('pageReveal')
})
})
test.describe('reduced motion', () => {
test('stagger animation-delay is neutralized under reduced motion', async ({ page }) => {
// Emulate prefers-reduced-motion explicitly. (The fixture-option form
// test.use({ reducedMotion }) does not propagate through our extended
// coverage `page` fixture, so set it on the page directly.)
await page.emulateMedia({ reducedMotion: 'reduce' })
await page.goto('/app') // Home renders .reveal-stagger children
// .home-status-line is staggerStyle(1) -> 60ms delay without the fix.
const child = page.locator('.home-status-line').first()
await expect(child).toBeVisible({ timeout: 15_000 })
const delay = await child.evaluate(el => getComputedStyle(el).animationDelay)
// Under reduced motion the per-child delay must be ~0 (not 60ms+).
expect(parseFloat(delay)).toBeLessThan(0.05)
})
})

View File

@@ -0,0 +1,31 @@
import { test, expect } from './coverage-fixtures.js'
test.describe('Home editorial redesign', () => {
test('renders the editorial greeting header in the sans display font', async ({ page }) => {
await page.goto('/app')
const greeting = page.locator('.home-greeting')
await expect(greeting).toBeVisible({ timeout: 15_000 })
const family = await greeting.evaluate(el => getComputedStyle(el).fontFamily)
// Refined-grotesk direction: the greeting uses Geist (no serif).
expect(family.toLowerCase()).toContain('geist')
expect(family.toLowerCase()).not.toContain('fraunces')
})
test('quick links expose a single primary action', async ({ page }) => {
await page.goto('/app')
await expect(page.locator('.home-greeting, .empty-state-title').first()).toBeVisible({ timeout: 15_000 })
const primaries = page.locator('.home-quick-links .btn-primary')
// At most one primary CTA in the quick-links row.
expect(await primaries.count()).toBeLessThanOrEqual(1)
})
test('loaded-models block uses an editorial section heading', async ({ page }) => {
await page.goto('/app')
await expect(page.locator('.home-greeting').first()).toBeVisible({ timeout: 15_000 })
// The refined loaded-models block introduces a SectionHeading; the legacy
// inline ".home-loaded-text" label is gone.
const heading = page.locator('.home-loaded .section-heading')
await expect(heading).toBeVisible({ timeout: 15_000 })
await expect(heading).toHaveText(/active models/i)
})
})

View File

File diff suppressed because it is too large Load Diff

View File

@@ -71,7 +71,8 @@
},
"actions": {
"copy": "Kopieren",
"regenerate": "Neu generieren"
"regenerate": "Neu generieren",
"jumpToLatest": "Jump to latest"
},
"streaming": {
"transferring": "Modell wird übertragen...",

View File

@@ -6,6 +6,19 @@
},
"resourceGpu": "GPU",
"resourceRam": "RAM",
"greeting": {
"morning": "Good morning",
"afternoon": "Good afternoon",
"evening": "Good evening",
"night": "Working late"
},
"statusLine": {
"modelsLoaded_one": "{{count}} model loaded",
"modelsLoaded_other": "{{count}} models loaded",
"noModelsLoaded": "No models loaded",
"nodes_one": "{{count}} node",
"nodes_other": "{{count}} nodes"
},
"assistant": {
"title": "LocalAI per Chat verwalten",
"description": "Modelle installieren, Backends wechseln, Konfigurationen bearbeiten und Status prüfen — durch Gespräche mit LocalAI.",
@@ -30,6 +43,7 @@
"documentation": "Dokumentation"
},
"loadedModels": {
"heading": "Aktive Modelle",
"count_one": "{{count}} Modell geladen",
"count_other": "{{count}} Modelle geladen",
"stop": "Modell stoppen",
@@ -70,6 +84,9 @@
"compatTitle": "Drop-in-Kompatibilität",
"apiReference": "Vollständige API-Referenz",
"copy": "Kopieren",
"copied": "Kopiert"
"copied": "Kopiert",
"browse": "Browse the API",
"hide": "Hide endpoints",
"dismiss": "Dismiss"
}
}

View File

@@ -30,6 +30,7 @@
"refImagesAdded_other": "{{count}} Bilder hinzugefügt"
},
"actions": {
"view": "View",
"generate": "Generieren",
"generating": "Generieren..."
},

View File

@@ -71,7 +71,8 @@
},
"actions": {
"copy": "Copy",
"regenerate": "Regenerate"
"regenerate": "Regenerate",
"jumpToLatest": "Jump to latest"
},
"streaming": {
"transferring": "Transferring model...",

View File

@@ -6,6 +6,19 @@
},
"resourceGpu": "GPU",
"resourceRam": "RAM",
"greeting": {
"morning": "Good morning",
"afternoon": "Good afternoon",
"evening": "Good evening",
"night": "Working late"
},
"statusLine": {
"modelsLoaded_one": "{{count}} model loaded",
"modelsLoaded_other": "{{count}} models loaded",
"noModelsLoaded": "No models loaded",
"nodes_one": "{{count}} node",
"nodes_other": "{{count}} nodes"
},
"assistant": {
"title": "Manage LocalAI by chatting",
"description": "Install models, switch backends, edit configs and check status by talking to LocalAI.",
@@ -30,6 +43,7 @@
"documentation": "Documentation"
},
"loadedModels": {
"heading": "Active models",
"count_one": "{{count}} model loaded",
"count_other": "{{count}} models loaded",
"stop": "Stop model",
@@ -70,6 +84,9 @@
"compatTitle": "Drop-in compatibility",
"apiReference": "Full API reference",
"copy": "Copy",
"copied": "Copied"
"copied": "Copied",
"browse": "Browse the API",
"hide": "Hide endpoints",
"dismiss": "Dismiss"
}
}

View File

@@ -30,6 +30,7 @@
"refImagesAdded_other": "{{count}} images added"
},
"actions": {
"view": "View",
"generate": "Generate",
"generating": "Generating..."
},

View File

@@ -71,7 +71,8 @@
},
"actions": {
"copy": "Copiar",
"regenerate": "Regenerar"
"regenerate": "Regenerar",
"jumpToLatest": "Jump to latest"
},
"streaming": {
"transferring": "Transfiriendo modelo...",

View File

@@ -6,6 +6,19 @@
},
"resourceGpu": "GPU",
"resourceRam": "RAM",
"greeting": {
"morning": "Good morning",
"afternoon": "Good afternoon",
"evening": "Good evening",
"night": "Working late"
},
"statusLine": {
"modelsLoaded_one": "{{count}} model loaded",
"modelsLoaded_other": "{{count}} models loaded",
"noModelsLoaded": "No models loaded",
"nodes_one": "{{count}} node",
"nodes_other": "{{count}} nodes"
},
"assistant": {
"title": "Administra LocalAI chateando",
"description": "Instala modelos, cambia backends, edita configuraciones y consulta el estado hablando con LocalAI.",
@@ -30,6 +43,7 @@
"documentation": "Documentación"
},
"loadedModels": {
"heading": "Modelos activos",
"count_one": "{{count}} modelo cargado",
"count_other": "{{count}} modelos cargados",
"stop": "Detener modelo",
@@ -70,6 +84,9 @@
"compatTitle": "Compatibilidad directa",
"apiReference": "Referencia completa de la API",
"copy": "Copiar",
"copied": "Copiado"
"copied": "Copiado",
"browse": "Browse the API",
"hide": "Hide endpoints",
"dismiss": "Dismiss"
}
}

View File

@@ -30,6 +30,7 @@
"refImagesAdded_other": "{{count}} imágenes añadidas"
},
"actions": {
"view": "View",
"generate": "Generar",
"generating": "Generando..."
},

View File

@@ -71,7 +71,8 @@
},
"actions": {
"copy": "Salin",
"regenerate": "Hasilkan ulang"
"regenerate": "Hasilkan ulang",
"jumpToLatest": "Jump to latest"
},
"streaming": {
"transferring": "Mentransfer model...",

View File

@@ -6,6 +6,19 @@
},
"resourceGpu": "GPU",
"resourceRam": "RAM",
"greeting": {
"morning": "Good morning",
"afternoon": "Good afternoon",
"evening": "Good evening",
"night": "Working late"
},
"statusLine": {
"modelsLoaded_one": "{{count}} model loaded",
"modelsLoaded_other": "{{count}} models loaded",
"noModelsLoaded": "No models loaded",
"nodes_one": "{{count}} node",
"nodes_other": "{{count}} nodes"
},
"assistant": {
"title": "Kelola LocalAI melalui obrolan",
"description": "Instal model, ganti backend, edit konfigurasi dan periksa status dengan berbicara pada LocalAI.",
@@ -30,6 +43,7 @@
"documentation": "Dokumentasi"
},
"loadedModels": {
"heading": "Model aktif",
"count_one": "{{count}} model dimuat",
"count_other": "{{count}} model dimuat",
"stop": "Hentikan model",
@@ -70,6 +84,9 @@
"compatTitle": "Kompatibilitas drop-in",
"apiReference": "Referensi API lengkap",
"copy": "Salin",
"copied": "Disalin"
"copied": "Disalin",
"browse": "Browse the API",
"hide": "Hide endpoints",
"dismiss": "Dismiss"
}
}

View File

@@ -30,6 +30,7 @@
"refImagesAdded_other": "{{count}} gambar ditambahkan"
},
"actions": {
"view": "View",
"generate": "Hasilkan",
"generating": "Menghasilkan..."
},

View File

@@ -71,7 +71,8 @@
},
"actions": {
"copy": "Copia",
"regenerate": "Rigenera"
"regenerate": "Rigenera",
"jumpToLatest": "Torna in fondo"
},
"streaming": {
"transferring": "Trasferimento del modello...",

View File

@@ -6,6 +6,19 @@
},
"resourceGpu": "GPU",
"resourceRam": "RAM",
"greeting": {
"morning": "Buongiorno",
"afternoon": "Buon pomeriggio",
"evening": "Buonasera",
"night": "Al lavoro fino a tardi"
},
"statusLine": {
"modelsLoaded_one": "{{count}} modello caricato",
"modelsLoaded_other": "{{count}} modelli caricati",
"noModelsLoaded": "Nessun modello caricato",
"nodes_one": "{{count}} nodo",
"nodes_other": "{{count}} nodi"
},
"assistant": {
"title": "Gestisci LocalAI chattando",
"description": "Installa modelli, cambia backend, modifica configurazioni e controlla lo stato parlando con LocalAI.",
@@ -30,6 +43,7 @@
"documentation": "Documentazione"
},
"loadedModels": {
"heading": "Modelli attivi",
"count_one": "{{count}} modello caricato",
"count_other": "{{count}} modelli caricati",
"stop": "Ferma modello",
@@ -70,6 +84,9 @@
"compatTitle": "Compatibilità drop-in",
"apiReference": "Riferimento API completo",
"copy": "Copia",
"copied": "Copiato"
"copied": "Copiato",
"browse": "Esplora le API",
"hide": "Nascondi gli endpoint",
"dismiss": "Ignora"
}
}

View File

@@ -30,6 +30,7 @@
"refImagesAdded_other": "{{count}} immagini aggiunte"
},
"actions": {
"view": "Visualizza",
"generate": "Genera",
"generating": "Generazione..."
},

View File

@@ -71,7 +71,8 @@
},
"actions": {
"copy": "복사",
"regenerate": "다시 생성"
"regenerate": "다시 생성",
"jumpToLatest": "Jump to latest"
},
"streaming": {
"transferring": "모델 전송 중...",

View File

@@ -6,6 +6,19 @@
},
"resourceGpu": "GPU",
"resourceRam": "RAM",
"greeting": {
"morning": "Good morning",
"afternoon": "Good afternoon",
"evening": "Good evening",
"night": "Working late"
},
"statusLine": {
"modelsLoaded_one": "{{count}} model loaded",
"modelsLoaded_other": "{{count}} models loaded",
"noModelsLoaded": "No models loaded",
"nodes_one": "{{count}} node",
"nodes_other": "{{count}} nodes"
},
"assistant": {
"title": "채팅으로 LocalAI 관리",
"description": "LocalAI와 대화하여 모델을 설치하고, 백엔드를 전환하고, 구성을 편집하고, 상태를 확인하세요.",
@@ -30,6 +43,7 @@
"documentation": "문서"
},
"loadedModels": {
"heading": "활성 모델",
"count_one": "모델 {{count}}개 로드됨",
"count_other": "모델 {{count}}개 로드됨",
"stop": "모델 중지",
@@ -70,6 +84,9 @@
"compatTitle": "드롭인 호환성",
"apiReference": "전체 API 레퍼런스",
"copy": "복사",
"copied": "복사됨"
"copied": "복사됨",
"browse": "Browse the API",
"hide": "Hide endpoints",
"dismiss": "Dismiss"
}
}

View File

@@ -30,6 +30,7 @@
"refImagesAdded_other": "이미지 {{count}}개 추가됨"
},
"actions": {
"view": "View",
"generate": "생성",
"generating": "생성 중..."
},

View File

@@ -71,7 +71,8 @@
},
"actions": {
"copy": "复制",
"regenerate": "重新生成"
"regenerate": "重新生成",
"jumpToLatest": "Jump to latest"
},
"streaming": {
"transferring": "正在传输模型...",

View File

@@ -6,6 +6,19 @@
},
"resourceGpu": "GPU",
"resourceRam": "RAM",
"greeting": {
"morning": "Good morning",
"afternoon": "Good afternoon",
"evening": "Good evening",
"night": "Working late"
},
"statusLine": {
"modelsLoaded_one": "{{count}} model loaded",
"modelsLoaded_other": "{{count}} models loaded",
"noModelsLoaded": "No models loaded",
"nodes_one": "{{count}} node",
"nodes_other": "{{count}} nodes"
},
"assistant": {
"title": "通过聊天管理 LocalAI",
"description": "通过与 LocalAI 对话来安装模型、切换后端、编辑配置和查看状态。",
@@ -30,6 +43,7 @@
"documentation": "文档"
},
"loadedModels": {
"heading": "活动模型",
"count_one": "已加载 {{count}} 个模型",
"count_other": "已加载 {{count}} 个模型",
"stop": "停止模型",
@@ -70,6 +84,9 @@
"compatTitle": "即插即用兼容",
"apiReference": "完整 API 参考",
"copy": "复制",
"copied": "已复制"
"copied": "已复制",
"browse": "Browse the API",
"hide": "Hide endpoints",
"dismiss": "Dismiss"
}
}

View File

@@ -30,6 +30,7 @@
"refImagesAdded_other": "已添加 {{count}} 张图像"
},
"actions": {
"view": "View",
"generate": "生成",
"generating": "生成中..."
},

View File

@@ -354,6 +354,7 @@
.sidebar-nav .nav-item.active {
background: var(--color-primary-light);
color: var(--color-primary);
font-weight: var(--font-weight-medium);
box-shadow: none;
}
.sidebar-nav .nav-item.active .nav-icon { color: var(--color-primary); }
@@ -551,12 +552,22 @@
border-radius: var(--radius-md);
cursor: pointer;
font-size: 0.875rem;
transition: all var(--duration-fast) var(--ease-default);
transition: color var(--duration-fast) var(--ease-default),
border-color var(--duration-fast) var(--ease-default),
background var(--duration-fast) var(--ease-default);
}
.theme-toggle:hover {
color: var(--color-primary);
border-color: var(--color-primary-border);
}
.theme-toggle__icon {
display: inline-block;
animation: themeIconIn var(--duration-normal) var(--ease-default);
}
@keyframes themeIconIn {
from { opacity: 0; transform: rotate(-90deg) scale(0.7); }
to { opacity: 1; transform: rotate(0) scale(1); }
}
/* Language switcher */
.language-switcher {
@@ -974,6 +985,26 @@
.spinner-md .spinner-ring { width: 24px; height: 24px; }
.spinner-lg .spinner-ring { width: 40px; height: 40px; }
/* Skeleton shimmer placeholders */
.skeleton {
display: block;
border-radius: var(--radius-sm);
background: linear-gradient(
100deg,
var(--color-surface-sunken) 30%,
var(--color-surface-hover) 50%,
var(--color-surface-sunken) 70%
);
background-size: 200% 100%;
animation: skeletonShimmer 1.4s ease-in-out infinite;
}
.skeleton--line { height: 0.85em; margin: 0.35em 0; width: 100%; }
.skeleton--block { height: 100%; width: 100%; border-radius: var(--radius-md); }
@keyframes skeletonShimmer {
from { background-position: 200% 0; }
to { background-position: -200% 0; }
}
/* Model selector */
.model-selector {
background: var(--color-bg-tertiary);
@@ -1118,14 +1149,56 @@
}
.page-title {
font-size: var(--text-2xl);
font-family: var(--font-sans);
font-size: clamp(1.5rem, 1.15rem + 1.4vw, var(--text-3xl));
font-weight: var(--font-weight-semibold);
letter-spacing: -0.015em;
letter-spacing: -0.018em;
line-height: var(--leading-tight);
margin-bottom: var(--spacing-xs);
color: var(--color-text-primary);
}
/* Mid hierarchy tier — between page title and the xs uppercase group labels */
.section-heading {
font-family: var(--font-sans);
font-size: var(--text-lg);
font-weight: var(--font-weight-medium);
letter-spacing: -0.005em;
line-height: var(--leading-snug);
color: var(--color-text-primary);
margin: 0;
}
.page-header--editorial {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: var(--spacing-lg);
flex-wrap: wrap;
margin-bottom: var(--space-section, var(--spacing-xl));
}
.page-header__lead { display: flex; flex-direction: column; gap: var(--spacing-xs); min-width: 0; }
.page-header__eyebrow {
font-family: var(--font-mono);
font-size: var(--text-xs);
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
letter-spacing: 0.14em;
color: var(--color-eyebrow);
display: inline-flex;
align-items: center;
gap: var(--spacing-xs);
}
.page-header__eyebrow::before {
content: '';
width: 18px;
height: 1px;
background: var(--color-eyebrow);
opacity: 0.7;
}
.page-header__supporting { color: var(--color-text-secondary); font-size: var(--text-base); margin: 0; max-width: 60ch; }
.page-header__meta { display: flex; align-items: center; gap: var(--spacing-sm); flex-wrap: wrap; }
.page-subtitle {
font-size: var(--text-sm);
color: var(--color-text-secondary);
@@ -1605,7 +1678,11 @@
box-shadow: 0 0 0 3px var(--color-focus-ring);
}
/* Global focus ring any interactive that isn't a .btn */
/* Global focus ring - any interactive that isn't a .btn. This box-shadow ring
is the single focus technique app-wide (it covers .btn plus the :where(...)
list below). The strengthened --color-focus-ring keeps it WCAG-AA visible
(>=3:1), so no separate solid-outline rule is needed; a bare :focus-visible
outline here would double-ring every element. */
:where(a, button, input, select, textarea, [tabindex]:not([tabindex="-1"])):focus-visible {
outline: none;
box-shadow: 0 0 0 3px var(--color-focus-ring);
@@ -2011,7 +2088,7 @@ select.input {
.stat-card:hover {
transform: translateY(-1px);
box-shadow: var(--shadow-sm);
border-color: var(--color-border);
border-color: var(--color-border-strong);
}
.stat-card__body {
@@ -2378,53 +2455,7 @@ select.input {
}
.table tr:hover td {
background: var(--color-primary-light);
}
/* Toggle switch */
.toggle {
position: relative;
display: inline-block;
width: 36px;
height: 20px;
}
.toggle input {
opacity: 0;
width: 0;
height: 0;
}
.toggle-slider {
position: absolute;
cursor: pointer;
inset: 0;
background: var(--color-toggle-off, #CBD5E1);
border-radius: var(--radius-full);
transition: background var(--duration-fast);
}
.toggle-slider::before {
content: '';
position: absolute;
height: 14px;
width: 14px;
left: 2px;
bottom: 2px;
background: white;
border-radius: 50%;
transition: transform var(--duration-fast);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
}
.toggle input:checked + .toggle-slider {
background: var(--color-primary);
border-color: var(--color-primary);
}
.toggle input:checked + .toggle-slider::before {
transform: translateX(16px);
background: white;
background: var(--color-surface-hover);
}
/* Model checkbox list */
@@ -2787,10 +2818,10 @@ select.input {
}
.empty-state-title {
font-family: var(--font-mono);
font-family: var(--font-sans);
font-size: clamp(1.5rem, 3vw, var(--text-3xl));
font-weight: var(--font-weight-regular);
letter-spacing: -0.03em;
font-weight: var(--font-weight-semibold);
letter-spacing: -0.02em;
color: var(--color-text-primary);
margin: 0;
line-height: 1.15;
@@ -2823,6 +2854,13 @@ select.input {
background: var(--color-border-strong);
}
.empty-state__actions {
display: flex;
gap: var(--spacing-sm);
flex-wrap: wrap;
margin-top: var(--spacing-xs);
}
/* Animations */
@keyframes spin {
to { transform: rotate(360deg); }
@@ -2880,7 +2918,7 @@ select.input {
/* Page route transitions */
.page-transition {
animation: fadeIn 200ms ease;
animation: pageReveal var(--duration-reveal) var(--ease-reveal) both;
display: flex;
flex-direction: column;
flex: 1;
@@ -2888,6 +2926,18 @@ select.input {
min-width: 0;
}
@keyframes pageReveal {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
/* Orchestrated child stagger - opt-in via .reveal-stagger on a container.
Each direct child reveals with an incremental delay driven by --reveal-index. */
.reveal-stagger > * {
animation: pageReveal var(--duration-reveal) var(--ease-reveal) both;
animation-delay: calc(var(--reveal-index, 0) * 60ms);
}
/* Chat-specific styles */
.chat-layout {
display: flex;
@@ -3040,6 +3090,28 @@ select.input {
}
.chat-messages::-webkit-scrollbar { width: 6px; }
.chat-messages::-webkit-scrollbar-track { background: transparent; }
/* Floating "jump to latest" pill, shown when scrolled away from the bottom. */
.chat-jump-latest {
position: sticky;
bottom: 12px;
align-self: center;
display: inline-flex;
align-items: center;
gap: var(--spacing-xs);
padding: var(--spacing-xs) var(--spacing-md);
background: var(--color-surface-raised);
color: var(--color-text-primary);
border: 1px solid var(--color-border-default);
border-radius: var(--radius-full);
box-shadow: var(--shadow-md);
font-size: var(--text-sm);
font-weight: var(--font-weight-medium);
cursor: pointer;
z-index: 5;
animation: pageReveal var(--duration-fast) var(--ease-default) both;
}
.chat-jump-latest:hover { border-color: var(--color-primary-border); color: var(--color-primary); }
.chat-messages::-webkit-scrollbar-thumb {
background: var(--color-border-subtle);
border-radius: 3px;
@@ -3135,6 +3207,49 @@ select.input {
font-size: 0.8125rem;
}
/* Code block with a header (language + copy), injected by enhanceCodeBlocks. */
.code-block {
margin: var(--spacing-sm) 0;
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-md);
overflow: hidden;
background: var(--color-bg-primary);
}
.code-block__head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 2px var(--spacing-sm);
background: var(--color-surface-sunken);
border-bottom: 1px solid var(--color-border-subtle);
}
.code-block__lang {
font-family: var(--font-mono);
font-size: var(--text-xs);
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-text-muted);
}
.code-block .code-copy-btn {
background: none;
border: none;
color: var(--color-text-muted);
cursor: pointer;
padding: 4px 6px;
border-radius: var(--radius-sm);
font-size: var(--text-sm);
transition: color var(--duration-fast) var(--ease-default),
background var(--duration-fast) var(--ease-default);
}
.code-block .code-copy-btn:hover { color: var(--color-text-primary); background: var(--color-bg-hover); }
.code-block .code-copy-btn--ok { color: var(--color-success); }
/* Inner pre sits flush inside the framed block. */
.code-block pre {
margin: 0 !important;
border: none !important;
border-radius: 0 !important;
}
.chat-message-user .chat-message-content pre {
background: var(--color-surface-sunken);
border-color: var(--color-border-subtle);
@@ -4671,6 +4786,24 @@ select.input {
border-radius: var(--radius-full);
font-size: 0.75rem;
color: var(--color-text-secondary);
max-width: 220px;
}
/* Image attachments preview as a thumbnail instead of a bare icon. */
.chat-file-badge--image {
padding: 3px 8px 3px 3px;
border-radius: var(--radius-lg);
}
.chat-file-thumb {
width: 28px;
height: 28px;
border-radius: var(--radius-sm);
object-fit: cover;
flex-shrink: 0;
}
.chat-file-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-file-badge button {
@@ -4808,6 +4941,137 @@ select.input {
width: 100%;
}
/* Clickable result thumbnail (opens the lightbox). */
.media-result-thumb {
position: relative;
display: block;
padding: 0;
border: none;
background: none;
cursor: zoom-in;
border-radius: var(--radius-md);
overflow: hidden;
}
.media-result-thumb img {
display: block;
width: 100%;
border-radius: var(--radius-md);
}
.media-result-thumb__zoom {
position: absolute;
top: var(--spacing-xs);
right: var(--spacing-xs);
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: var(--radius-sm);
background: var(--color-bg-overlay);
color: var(--color-text-primary);
font-size: var(--text-xs);
opacity: 0;
transition: opacity var(--duration-fast) var(--ease-default);
}
.media-result-thumb:hover .media-result-thumb__zoom,
.media-result-thumb:focus-visible .media-result-thumb__zoom { opacity: 1; }
/* Fullscreen image lightbox. */
.lightbox {
position: fixed;
inset: 0;
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
padding: var(--spacing-xl);
background: var(--color-modal-backdrop);
animation: fadeIn var(--duration-fast) var(--ease-default);
}
.lightbox__img {
max-width: 92vw;
max-height: 88vh;
object-fit: contain;
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
}
.lightbox__toolbar {
position: absolute;
top: var(--spacing-md);
right: var(--spacing-md);
display: inline-flex;
align-items: center;
gap: var(--spacing-sm);
}
.lightbox__count { color: var(--color-text-secondary); font-size: var(--text-sm); margin-right: var(--spacing-xs); }
.lightbox__nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
display: inline-flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border: 1px solid var(--color-border-default);
border-radius: var(--radius-full);
background: var(--color-surface-raised);
color: var(--color-text-primary);
cursor: pointer;
box-shadow: var(--shadow-md);
}
.lightbox__nav:hover { border-color: var(--color-primary-border); color: var(--color-primary); }
.lightbox__nav--prev { left: var(--spacing-lg); }
.lightbox__nav--next { right: var(--spacing-lg); }
/* Generation progress: shimmer placeholder tiles + elapsed time. */
.gen-progress {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--spacing-lg);
width: 100%;
}
.gen-progress__tiles {
display: grid;
gap: var(--spacing-sm);
width: 100%;
max-width: 520px;
}
.gen-progress__tiles--n1 { grid-template-columns: 1fr; }
.gen-progress__tiles--n2,
.gen-progress__tiles--n3,
.gen-progress__tiles--n4 { grid-template-columns: repeat(2, 1fr); }
.gen-progress__tile {
width: 100%;
aspect-ratio: 1 / 1;
height: auto;
border-radius: var(--radius-md);
}
.gen-progress__status {
display: inline-flex;
align-items: center;
gap: var(--spacing-sm);
color: var(--color-text-secondary);
font-size: var(--text-sm);
}
.gen-progress__time { color: var(--color-text-muted); }
/* Talk voice visualizer: status-tinted frequency bars. */
.voice-viz {
display: block;
width: 100%;
height: 88px;
margin-bottom: var(--spacing-md);
--viz-color: var(--color-text-muted);
transition: opacity var(--duration-normal) var(--ease-default);
}
.voice-viz--disconnected { --viz-color: var(--color-text-disabled); opacity: 0.55; }
.voice-viz--connected { --viz-color: var(--color-primary); }
.voice-viz--listening { --viz-color: var(--color-success); }
.voice-viz--speaking { --viz-color: var(--color-accent); }
.voice-viz--error { --viz-color: var(--color-error); }
/* Media generation history */
.media-history {
margin-top: var(--spacing-md);
@@ -5169,14 +5433,51 @@ select.input {
/* Canvas panel */
.canvas-panel {
position: relative;
width: 45%;
max-width: 720px;
flex-shrink: 1;
flex-shrink: 0;
border-left: 1px solid var(--color-border-subtle);
display: flex;
flex-direction: column;
background: var(--color-bg-primary);
overflow: hidden;
animation: canvasSlideIn var(--duration-reveal) var(--ease-reveal) both;
}
@keyframes canvasSlideIn {
from { opacity: 0; transform: translateX(24px); }
to { opacity: 1; transform: translateX(0); }
}
/* Drag-to-resize handle on the panel's left edge. */
.canvas-resize-handle {
position: absolute;
top: 0;
left: -3px;
width: 7px;
height: 100%;
cursor: col-resize;
z-index: 2;
background: transparent;
transition: background var(--duration-fast) var(--ease-default);
}
.canvas-resize-handle:hover,
.canvas-resize-handle:active {
background: var(--color-primary-light);
}
/* On narrow screens the canvas takes the full width; no edge-drag resize. */
@media (max-width: 1024px) {
.canvas-resize-handle { display: none; }
.canvas-panel { width: 100% !important; max-width: none !important; }
}
.canvas-header-actions { display: inline-flex; gap: var(--spacing-xs); }
/* Fullscreen: the canvas covers the viewport. */
.canvas-panel--fullscreen {
position: fixed;
inset: 0;
width: 100% !important;
max-width: none !important;
z-index: 60;
border-left: none;
}
.canvas-panel-header {
display: flex;
@@ -5576,16 +5877,43 @@ select.input {
/* Home page */
.home-page {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
justify-content: center;
max-width: 52rem;
gap: var(--space-section);
max-width: var(--page-max-medium);
margin: 0 auto;
padding: var(--spacing-2xl) var(--spacing-xl);
padding: var(--space-section) var(--spacing-lg);
width: 100%;
}
.home-header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: var(--spacing-lg);
flex-wrap: wrap;
}
.home-eyebrow {
font-family: var(--font-mono);
font-size: var(--text-xs);
text-transform: uppercase;
letter-spacing: 0.14em;
color: var(--color-eyebrow);
}
.home-greeting {
font-family: var(--font-sans);
font-size: clamp(2rem, 1.4rem + 3vw, var(--text-4xl));
font-weight: var(--font-weight-semibold);
letter-spacing: -0.02em;
line-height: 1.05;
margin: var(--spacing-xs) 0 0;
}
.home-status-line {
display: flex;
align-items: center;
gap: var(--spacing-md);
flex-wrap: wrap;
}
.home-hero {
text-align: center;
@@ -5891,33 +6219,21 @@ select.input {
transform: translateY(-1px);
box-shadow: var(--shadow-sm);
}
/* Quiet variant: a deliberately understated link (e.g. Documentation) so the
quick-links row keeps a single clear primary action. */
.home-link-btn--quiet { opacity: 0.8; }
/* Home loaded models */
.home-loaded-models {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-sm) var(--spacing-md);
background: var(--color-surface-raised);
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-lg);
font-size: var(--text-xs);
color: var(--color-text-secondary);
.home-loaded {
width: 100%;
box-shadow: var(--shadow-subtle);
}
.home-loaded-dot {
width: 8px;
height: 8px;
border-radius: var(--radius-full);
background: var(--color-success);
}
.home-loaded-text {
font-weight: var(--font-weight-medium);
color: var(--color-text-primary);
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
}
.home-loaded-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: var(--spacing-xs);
@@ -5926,12 +6242,11 @@ select.input {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 10px;
padding: 3px 6px 3px 10px;
background: var(--color-surface-sunken);
border: 1px solid var(--color-border-divider);
border-radius: var(--radius-full);
font-size: var(--text-xs);
font-family: var(--font-mono);
}
.home-loaded-item button {
background: none;
@@ -5939,18 +6254,16 @@ select.input {
color: var(--color-error);
cursor: pointer;
padding: 0;
line-height: 1;
font-size: 0.625rem;
}
.home-loaded-empty {
color: var(--color-text-secondary);
font-size: var(--text-sm);
margin: 0;
}
.home-stop-all {
margin-left: auto;
background: none;
border: 1px solid var(--color-error);
color: var(--color-error);
padding: 2px 8px;
border-radius: var(--radius-full);
font-size: 0.75rem;
cursor: pointer;
font-family: inherit;
align-self: flex-start;
}
/* Home wizard (no models) */
@@ -6071,6 +6384,39 @@ select.input {
padding: 0;
}
.home-connect-url .btn { flex-shrink: 0; display: inline-flex; align-items: center; gap: 6px; }
.home-connect-dismiss {
margin-left: auto;
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
background: none;
border: none;
border-radius: var(--radius-md);
color: var(--color-text-muted);
cursor: pointer;
transition: background var(--duration-fast) var(--ease-default), color var(--duration-fast) var(--ease-default);
}
.home-connect-dismiss:hover { background: var(--color-surface-hover); color: var(--color-text-primary); }
.home-connect-toggle {
display: inline-flex;
align-items: center;
gap: var(--spacing-xs);
margin-top: var(--spacing-md);
padding: var(--spacing-xs) 0;
background: none;
border: none;
color: var(--color-primary);
font: inherit;
font-size: var(--text-sm);
font-weight: var(--font-weight-medium);
cursor: pointer;
}
.home-connect-toggle:hover { color: var(--color-primary-hover); }
.home-connect-toggle i { font-size: 0.7em; }
.home-connect-endpoints { margin-top: var(--spacing-sm); }
.home-connect-block { margin-top: var(--spacing-md); }
.home-connect-block-head {
display: flex;
@@ -8077,3 +8423,17 @@ select.input {
.console-rail.console-rail--enter { animation: none; }
.console-rail .nav-item:hover:not(.active) { transform: none; }
}
.status-pill {
display: inline-flex;
align-items: center;
gap: var(--spacing-xs);
font-size: var(--text-sm);
color: var(--color-text-secondary);
}
.status-pill__dot { width: 7px; height: 7px; border-radius: var(--radius-full); background: var(--color-text-muted); flex-shrink: 0; }
.status-pill--success .status-pill__dot { background: var(--color-success); }
.status-pill--warning .status-pill__dot { background: var(--color-warning); }
.status-pill--error .status-pill__dot { background: var(--color-error); }
.status-pill--info .status-pill__dot { background: var(--color-info); }
.status-pill--muted .status-pill__dot { background: var(--color-text-muted); }

View File

@@ -75,6 +75,14 @@ export default function App() {
}
}, [sidebarOpen])
// Reset scroll to the top on every route change. The default (non-chat)
// layout uses the document as its scroll container, so without this a new
// page opens at the previous page's scroll position - navigating the console
// rail from a scrolled page would land mid-view instead of at the top.
useEffect(() => {
window.scrollTo(0, 0)
}, [location.pathname])
const layoutClasses = [
'app-layout',
isChatRoute ? 'app-layout-chat' : '',

View File

@@ -1,29 +1,65 @@
import { useState, useEffect, useRef } from 'react'
import { renderMarkdown } from '../utils/markdown'
import { getArtifactIcon } from '../utils/artifacts'
import { getArtifactIcon, extensionForLanguage } from '../utils/artifacts'
import { safeHref } from '../utils/url'
import { copyToClipboard } from '../utils/clipboard'
import DOMPurify from 'dompurify'
import hljs from '../utils/hljs'
const WIDTH_KEY = 'localai_canvas_width'
const MIME_BY_EXT = { html: 'text/html', svg: 'image/svg+xml', json: 'application/json', css: 'text/css' }
export default function CanvasPanel({ artifacts, selectedId, onSelect, onClose }) {
const [showPreview, setShowPreview] = useState(true)
const [copySuccess, setCopySuccess] = useState(false)
// Persisted drag-to-resize width (px). null = use the CSS default (45%).
const [width, setWidth] = useState(() => {
try { const v = localStorage.getItem(WIDTH_KEY); return v ? Number(v) : null } catch { return null }
})
const [fullscreen, setFullscreen] = useState(false)
const codeRef = useRef(null)
const panelRef = useRef(null)
const current = artifacts.find(a => a.id === selectedId) || artifacts[0]
if (!current) return null
const hasPreview = current.type === 'code' && ['html', 'svg', 'md', 'markdown'].includes(current.language)
const hasPreview = !!current && current.type === 'code' && ['html', 'svg', 'md', 'markdown'].includes(current.language)
// All hooks must run unconditionally (no early return above them).
useEffect(() => {
if (codeRef.current && !showPreview && current.type === 'code') {
if (codeRef.current && !showPreview && current?.type === 'code') {
codeRef.current.querySelectorAll('pre code').forEach(block => {
hljs.highlightElement(block)
})
}
}, [current, showPreview])
// Drag the left edge to resize; clamp to a sane range; persist on release.
const startResize = (e) => {
e.preventDefault()
const startX = e.clientX
const startW = panelRef.current?.offsetWidth || 0
const maxW = Math.round(window.innerWidth * 0.75)
const onMove = (ev) => {
const next = Math.min(Math.max(startW + (startX - ev.clientX), 360), maxW)
setWidth(next)
}
const onUp = () => {
window.removeEventListener('mousemove', onMove)
window.removeEventListener('mouseup', onUp)
document.body.style.userSelect = ''
try { localStorage.setItem(WIDTH_KEY, String(panelRef.current?.offsetWidth || '')) } catch { /* ignore */ }
}
document.body.style.userSelect = 'none'
window.addEventListener('mousemove', onMove)
window.addEventListener('mouseup', onUp)
}
const resetWidth = () => {
setWidth(null)
try { localStorage.removeItem(WIDTH_KEY) } catch { /* ignore */ }
}
if (!current) return null
const handleCopy = async () => {
const text = current.code || current.url || ''
const ok = await copyToClipboard(text)
@@ -35,11 +71,15 @@ export default function CanvasPanel({ artifacts, selectedId, onSelect, onClose }
const handleDownload = () => {
if (current.type === 'code') {
const blob = new Blob([current.code], { type: 'text/plain' })
const ext = extensionForLanguage(current.language)
const blob = new Blob([current.code], { type: MIME_BY_EXT[ext] || 'text/plain' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = current.title || 'download.txt'
// Keep a title that already has an extension; otherwise slugify + add ext.
a.download = current.title && /\.[a-z0-9]+$/i.test(current.title)
? current.title
: `${(current.title || 'artifact').replace(/[^\w.-]+/g, '-').replace(/^-+|-+$/g, '') || 'artifact'}.${ext}`
a.click()
URL.revokeObjectURL(url)
} else if (current.url) {
@@ -110,24 +150,65 @@ export default function CanvasPanel({ artifacts, selectedId, onSelect, onClose }
}
return (
<div className="canvas-panel">
<div
className={`canvas-panel${fullscreen ? ' canvas-panel--fullscreen' : ''}`}
ref={panelRef}
style={!fullscreen && width ? { width: `${width}px`, maxWidth: 'none' } : undefined}
>
{!fullscreen && (
<div
className="canvas-resize-handle"
onMouseDown={startResize}
onDoubleClick={resetWidth}
role="separator"
aria-orientation="vertical"
aria-label="Resize canvas (double-click to reset)"
title="Drag to resize, double-click to reset"
/>
)}
<div className="canvas-panel-header">
<span className="canvas-panel-title">{current.title || 'Artifact'}</span>
<button className="btn btn-secondary btn-sm" onClick={onClose} title="Close canvas">
<i className="fas fa-times" />
</button>
<div className="canvas-header-actions">
<button
className="btn btn-secondary btn-sm"
onClick={() => setFullscreen(f => !f)}
title={fullscreen ? 'Exit fullscreen' : 'Fullscreen'}
aria-label={fullscreen ? 'Exit fullscreen' : 'Fullscreen'}
>
<i className={`fas ${fullscreen ? 'fa-compress' : 'fa-expand'}`} aria-hidden="true" />
</button>
<button className="btn btn-secondary btn-sm" onClick={onClose} title="Close canvas" aria-label="Close canvas">
<i className="fas fa-times" aria-hidden="true" />
</button>
</div>
</div>
{artifacts.length > 1 && (
<div className="canvas-panel-tabs">
<div
className="canvas-panel-tabs"
role="tablist"
aria-label="Artifacts"
onKeyDown={(e) => {
if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return
e.preventDefault()
const idx = artifacts.findIndex(a => a.id === current.id)
const n = e.key === 'ArrowRight'
? (idx + 1) % artifacts.length
: (idx - 1 + artifacts.length) % artifacts.length
onSelect(artifacts[n].id)
}}
>
{artifacts.map(a => (
<button
key={a.id}
role="tab"
aria-selected={a.id === current.id}
tabIndex={a.id === current.id ? 0 : -1}
className={`canvas-panel-tab${a.id === (current?.id) ? ' active' : ''}`}
onClick={() => onSelect(a.id)}
title={a.title}
>
<i className={`fas ${getArtifactIcon(a.type, a.language)}`} />
<i className={`fas ${getArtifactIcon(a.type, a.language)}`} aria-hidden="true" />
<span>{a.title}</span>
</button>
))}

View File

@@ -0,0 +1,13 @@
// Editorial empty state: optional eyebrow + icon, serif title, lede, actions.
// Wraps the existing .empty-state CSS so legacy callers keep working.
export default function EmptyState({ icon, eyebrow, title, body, actions, className = '' }) {
return (
<div className={`empty-state ${className}`.trim()}>
{eyebrow && <span className="empty-state__eyebrow">{eyebrow}</span>}
{icon && <i className={`empty-state-icon fas ${icon}`} aria-hidden="true" />}
{title && <h2 className="empty-state-title">{title}</h2>}
{body && <p className="empty-state-text">{body}</p>}
{actions && <div className="empty-state__actions">{actions}</div>}
</div>
)
}

View File

@@ -0,0 +1,29 @@
import { useState, useEffect } from 'react'
// Loading feedback for slow media generation: shimmer placeholder tiles that
// match the requested count, plus a live elapsed-time readout. Replaces a bare
// spinner so the wait feels accountable.
export default function GenerationProgress({ count = 1, label }) {
const [elapsed, setElapsed] = useState(0)
useEffect(() => {
const t = setInterval(() => setElapsed(e => e + 1), 1000)
return () => clearInterval(t)
}, [])
const tiles = Math.min(Math.max(count, 1), 4)
const mm = Math.floor(elapsed / 60)
const ss = String(elapsed % 60).padStart(2, '0')
return (
<div className="gen-progress" role="status" aria-live="polite">
<div className={`gen-progress__tiles gen-progress__tiles--n${tiles}`}>
{Array.from({ length: tiles }).map((_, i) => (
<span key={i} className="gen-progress__tile skeleton skeleton--block" aria-hidden="true" />
))}
</div>
<div className="gen-progress__status">
<i className="fas fa-circle-notch fa-spin" aria-hidden="true" />
<span>{label || 'Generating'}</span>
<span className="gen-progress__time mono">{mm}:{ss}</span>
</div>
</div>
)
}

View File

@@ -27,6 +27,14 @@ const COMPAT = [
export default function HomeConnect() {
const { t } = useTranslation('home')
const [copied, setCopied] = useState(false)
// Endpoint catalog is collapsed by default so Home stays uncluttered; the
// base URL stays visible and the full list is one click away (discoverable).
const [showEndpoints, setShowEndpoints] = useState(false)
// Dismissable: hiding the card unmounts it entirely so the vertical space is
// recovered, and the choice is remembered across visits.
const [dismissed, setDismissed] = useState(() => {
try { return localStorage.getItem('localai_home_connect_dismissed') === '1' } catch { return false }
})
// Absolute base for this instance, honouring any sub-path mount.
const base = new URL(apiUrl('/'), window.location.origin).href.replace(/\/$/, '')
@@ -39,6 +47,13 @@ export default function HomeConnect() {
} catch (_) { /* clipboard blocked — the URL is selectable anyway */ }
}
const dismiss = () => {
try { localStorage.setItem('localai_home_connect_dismissed', '1') } catch { /* ignore */ }
setDismissed(true)
}
if (dismissed) return null
return (
<section className="home-connect card" aria-labelledby="home-connect-title">
<div className="home-connect-head">
@@ -47,6 +62,9 @@ export default function HomeConnect() {
<h2 id="home-connect-title" className="home-connect-title">{t('connect.title')}</h2>
<p className="home-connect-sub">{t('connect.subtitle')}</p>
</div>
<button type="button" className="home-connect-dismiss" onClick={dismiss} aria-label={t('connect.dismiss')} title={t('connect.dismiss')}>
<i className="fas fa-times" aria-hidden="true" />
</button>
</div>
<div className="home-connect-url">
@@ -57,6 +75,19 @@ export default function HomeConnect() {
</button>
</div>
<button
type="button"
className="home-connect-toggle"
aria-expanded={showEndpoints}
aria-controls="home-connect-endpoints"
onClick={() => setShowEndpoints(v => !v)}
>
<i className={`fas fa-chevron-${showEndpoints ? 'up' : 'down'}`} aria-hidden="true" />
<span>{showEndpoints ? t('connect.hide') : t('connect.browse')}</span>
</button>
{showEndpoints && (
<div id="home-connect-endpoints" className="home-connect-endpoints">
<div className="home-connect-block">
<div className="home-connect-block-head">
<span className="home-connect-block-title">{t('connect.nativeTitle')}</span>
@@ -87,6 +118,8 @@ export default function HomeConnect() {
))}
</ul>
</div>
</div>
)}
</section>
)
}

View File

@@ -0,0 +1,55 @@
import { useEffect, useCallback } from 'react'
// Fullscreen image viewer with prev/next, download, and keyboard control
// (Esc to close, Left/Right to navigate). `images` is [{ url, alt }]; `index`
// is the active entry; `onIndex` and `onClose` are controlled by the parent.
export default function Lightbox({ images, index, onClose, onIndex }) {
const has = Array.isArray(images) && images.length > 0
const count = has ? images.length : 0
const go = useCallback((delta) => {
if (count < 2) return
onIndex(((index + delta) % count + count) % count)
}, [count, index, onIndex])
useEffect(() => {
const onKey = (e) => {
if (e.key === 'Escape') onClose()
else if (e.key === 'ArrowRight') go(1)
else if (e.key === 'ArrowLeft') go(-1)
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [onClose, go])
if (!has) return null
const img = images[index] || images[0]
return (
<div className="lightbox" role="dialog" aria-modal="true" onClick={onClose}>
<div className="lightbox__toolbar" onClick={(e) => e.stopPropagation()}>
{count > 1 && <span className="lightbox__count">{index + 1} / {count}</span>}
<a className="btn btn-secondary btn-sm" href={img.url} download target="_blank" rel="noopener noreferrer" aria-label="Download">
<i className="fas fa-download" aria-hidden="true" />
</a>
<button type="button" className="btn btn-secondary btn-sm" onClick={onClose} aria-label="Close">
<i className="fas fa-times" aria-hidden="true" />
</button>
</div>
{count > 1 && (
<button type="button" className="lightbox__nav lightbox__nav--prev" onClick={(e) => { e.stopPropagation(); go(-1) }} aria-label="Previous">
<i className="fas fa-chevron-left" aria-hidden="true" />
</button>
)}
<img src={img.url} alt={img.alt || ''} className="lightbox__img" onClick={(e) => e.stopPropagation()} />
{count > 1 && (
<button type="button" className="lightbox__nav lightbox__nav--next" onClick={(e) => { e.stopPropagation(); go(1) }} aria-label="Next">
<i className="fas fa-chevron-right" aria-hidden="true" />
</button>
)}
</div>
)
}

View File

@@ -1,7 +1,11 @@
export default function LoadingSpinner({ size = 'md', className = '' }) {
const sizeClass = size === 'sm' ? 'spinner-sm' : size === 'lg' ? 'spinner-lg' : 'spinner-md'
const sizeClass =
size === 'sm' ? 'spinner-sm'
: size === 'lg' ? 'spinner-lg'
: size === 'boot' ? 'spinner-lg'
: 'spinner-md'
return (
<div className={`spinner ${sizeClass} ${className}`}>
<div className={`spinner ${sizeClass} ${className}`} role="status" aria-label="Loading">
<div className="spinner-ring" />
</div>
)

View File

@@ -0,0 +1,24 @@
import { useLocation } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { sectionKeyForPath } from '../utils/section'
// Editorial page header: left eyebrow + title + supporting line, right-aligned
// meta/actions slot. The eyebrow defaults to the page's section/console name
// (derived from the route) so headers stay consistent without per-page wiring;
// pass `eyebrow` to override, or `eyebrow={null}` to suppress it.
export default function PageHeader({ eyebrow, title, supporting, actions, className = '' }) {
const { t } = useTranslation('nav')
const { pathname } = useLocation()
const autoKey = sectionKeyForPath(pathname)
const resolvedEyebrow = eyebrow !== undefined ? eyebrow : (autoKey ? t(autoKey) : null)
return (
<header className={`page-header page-header--editorial ${className}`.trim()}>
<div className="page-header__lead">
{resolvedEyebrow && <span className="page-header__eyebrow">{resolvedEyebrow}</span>}
{title && <h1 className="page-title">{title}</h1>}
{supporting && <p className="page-header__supporting">{supporting}</p>}
</div>
{actions && <div className="page-header__meta">{actions}</div>}
</header>
)
}

View File

@@ -0,0 +1,3 @@
export default function SectionHeading({ children, className = '' }) {
return <h3 className={`section-heading ${className}`.trim()}>{children}</h3>
}

View File

@@ -0,0 +1,16 @@
// Content-shaped shimmer placeholders. Render `count` rows.
export default function Skeleton({ variant = 'line', width, height, count = 1, className = '' }) {
const items = Array.from({ length: count })
return (
<>
{items.map((_, i) => (
<span
key={i}
className={`skeleton skeleton--${variant} ${className}`.trim()}
style={{ width, height }}
aria-hidden="true"
/>
))}
</>
)
}

View File

@@ -0,0 +1,21 @@
// Single source for status visuals. Maps a semantic status to a token-driven
// dot + label. Replaces per-page hex status maps over time.
const STATUS = {
healthy: 'success',
online: 'success',
warning: 'warning',
draining: 'warning',
error: 'error',
unhealthy: 'error',
loading: 'info',
idle: 'muted',
}
export default function StatusPill({ status, label, className = '' }) {
const tone = STATUS[status] || 'muted'
return (
<span className={`status-pill status-pill--${tone} ${className}`.trim()}>
<span className="status-pill__dot" aria-hidden="true" />
{label != null ? label : status}
</span>
)
}

View File

@@ -1,15 +1,20 @@
import { useTranslation } from 'react-i18next'
import { useTheme } from '../contexts/ThemeContext'
export default function ThemeToggle() {
const { theme, toggleTheme } = useTheme()
const { t } = useTranslation('nav')
const label = theme === 'dark' ? t('switchToLightMode') : t('switchToDarkMode')
return (
<button
onClick={toggleTheme}
className="theme-toggle"
title={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
title={label}
aria-label={label}
>
<i className={`fas ${theme === 'dark' ? 'fa-sun' : 'fa-moon'}`} />
{/* key on theme so the icon remounts and replays the rotate/fade */}
<i key={theme} className={`fas ${theme === 'dark' ? 'fa-sun' : 'fa-moon'} theme-toggle__icon`} aria-hidden="true" />
</button>
)
}

View File

@@ -0,0 +1,106 @@
import { useRef, useEffect } from 'react'
// Self-contained voice visualizer for the Talk page. Owns its own AudioContext
// and analysers (built from the output <audio> stream and the mic stream) so it
// does not touch the existing WebRTC/diagnostics graph. Renders frequency bars
// scaled by the mic level while listening and the assistant's output level
// while speaking; a gentle idle animation otherwise.
const BARS = 32
export default function VoiceVisualizer({ audioRef, micStreamRef, status, active }) {
const canvasRef = useRef(null)
const rafRef = useRef(null)
const acRef = useRef(null)
const outRef = useRef(null)
const micRef = useRef(null)
// Keep the latest status without restarting the animation loop.
const statusRef = useRef(status)
statusRef.current = status
useEffect(() => {
let setupTimer
const setup = () => {
if (!active) return
try {
const AC = window.AudioContext || window.webkitAudioContext
if (!AC) return
if (!acRef.current) acRef.current = new AC()
const ac = acRef.current
if (!outRef.current && audioRef.current?.srcObject) {
const a = ac.createAnalyser(); a.fftSize = 1024; a.smoothingTimeConstant = 0.75
ac.createMediaStreamSource(audioRef.current.srcObject).connect(a)
outRef.current = a
}
if (!micRef.current && micStreamRef.current) {
const a = ac.createAnalyser(); a.fftSize = 1024; a.smoothingTimeConstant = 0.75
ac.createMediaStreamSource(micStreamRef.current).connect(a)
micRef.current = a
}
} catch { /* analyser unavailable; idle animation still renders */ }
}
// The draw loop always runs (idle wave); analysers attach only once
// connected, and streams can arrive a beat after connect.
if (active) {
setup()
setupTimer = setInterval(() => { if (outRef.current && micRef.current) clearInterval(setupTimer); else setup() }, 400)
}
const draw = () => {
rafRef.current = requestAnimationFrame(draw)
const canvas = canvasRef.current
if (!canvas) return
const ctx = canvas.getContext('2d')
const dpr = window.devicePixelRatio || 1
const w = canvas.clientWidth || 1
const h = canvas.clientHeight || 1
if (canvas.width !== Math.round(w * dpr)) { canvas.width = Math.round(w * dpr); canvas.height = Math.round(h * dpr) }
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
ctx.clearRect(0, 0, w, h)
const st = statusRef.current
const an = st === 'listening' ? micRef.current
: st === 'speaking' ? outRef.current
: (outRef.current || micRef.current)
let data = null
if (an) { data = new Uint8Array(an.frequencyBinCount); an.getByteFrequencyData(data) }
const color = getComputedStyle(canvas).getPropertyValue('--viz-color').trim() || '#88c0d0'
ctx.fillStyle = color
const slot = w / BARS
const bw = slot * 0.5
const radius = bw / 2
const now = Date.now()
for (let i = 0; i < BARS; i++) {
let level
if (data) {
const idx = Math.floor((i / BARS) * (data.length * 0.55))
level = data[idx] / 255
} else {
level = 0.10 + 0.05 * Math.sin(now / 320 + i * 0.45)
}
const bh = Math.max(bw, level * h)
const x = i * slot + (slot - bw) / 2
const y = (h - bh) / 2
// rounded bar
ctx.beginPath()
ctx.roundRect ? ctx.roundRect(x, y, bw, bh, radius) : ctx.rect(x, y, bw, bh)
ctx.fill()
}
}
draw()
return () => {
clearInterval(setupTimer)
cancelAnimationFrame(rafRef.current)
}
}, [active, audioRef, micStreamRef])
// Close the audio context only on final unmount.
useEffect(() => () => {
try { acRef.current?.close() } catch { /* ignore */ }
acRef.current = null; outRef.current = null; micRef.current = null
}, [])
return <canvas ref={canvasRef} className={`voice-viz voice-viz--${status}`} aria-hidden="true" />
}

View File

@@ -0,0 +1,8 @@
// Returns an inline style setting --reveal-index for orchestrated reveals.
// Usage: <div className="reveal-stagger"> {items.map((it, i) => (
// <Row key={it.id} style={staggerStyle(i)} />))} </div>
// Provided as a plain helper (not a hook) so it can be called in a map
// without violating rules-of-hooks. File name kept for discoverability.
export function staggerStyle(index) {
return { '--reveal-index': index }
}

View File

@@ -53,8 +53,8 @@ h1, h2, h3, h4, h5, h6 {
color: var(--color-text-primary);
line-height: var(--leading-tight);
}
h1 { font-size: var(--text-3xl); font-weight: var(--font-weight-medium); letter-spacing: -0.02em; }
h2 { font-size: var(--text-2xl); font-weight: var(--font-weight-medium); letter-spacing: -0.015em; }
h1 { font-family: var(--font-sans); font-size: clamp(1.75rem, 1.2rem + 2.4vw, var(--text-4xl)); font-weight: 600; letter-spacing: -0.02em; }
h2 { font-family: var(--font-sans); font-size: clamp(1.4rem, 1.05rem + 1.6vw, var(--text-3xl)); font-weight: 600; letter-spacing: -0.015em; }
h3 { font-size: var(--text-xl); font-weight: var(--font-weight-medium); letter-spacing: -0.01em; }
h4 { font-size: var(--text-lg); font-weight: var(--font-weight-medium); letter-spacing: -0.005em; }
h5 { font-size: var(--text-base);font-weight: var(--font-weight-semibold); }
@@ -93,6 +93,9 @@ a:hover {
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
/* Neutralize per-child stagger delays (e.g. .reveal-stagger) so content
does not sit at the hidden "from" keyframe before snapping in. */
animation-delay: 0s !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;

View File

@@ -13,11 +13,12 @@ import '@fontsource-variable/geist-mono'
import './index.css'
import './theme.css'
import './App.css'
import LoadingSpinner from './components/LoadingSpinner'
function BootFallback() {
return (
<div className="app-boot-spinner" role="status" aria-label="Loading">
<div className="app-boot-spinner-dot" />
<div className="app-boot-spinner">
<LoadingSpinner size="boot" />
</div>
)
}

View File

@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'
import { useAuth } from '../context/AuthContext'
import { apiKeysApi, profileApi } from '../utils/api'
import LoadingSpinner from '../components/LoadingSpinner'
import PageHeader from '../components/PageHeader'
import SettingRow from '../components/SettingRow'
import ConfirmDialog from '../components/ConfirmDialog'
import './auth.css'
@@ -452,10 +453,7 @@ export default function Account() {
return (
<div className="page page--narrow account-page">
{/* Header */}
<div className="page-header">
<h1 className="page-title">{t('account.title')}</h1>
<p className="page-subtitle">{t('account.subtitle')}</p>
</div>
<PageHeader title={t('account.title')} supporting={t('account.subtitle')} />
{/* Tab bar */}
<div className="auth-tab-bar auth-tab-bar--flush">

View File

@@ -2,7 +2,7 @@ import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
import { useParams, useNavigate, useOutletContext, useSearchParams } from 'react-router-dom'
import { agentsApi } from '../utils/api'
import { apiUrl } from '../utils/basePath'
import { renderMarkdown, highlightAll } from '../utils/markdown'
import { renderMarkdown, highlightAll, enhanceCodeBlocks } from '../utils/markdown'
import { extractCodeArtifacts, extractMetadataArtifacts, renderMarkdownWithArtifacts } from '../utils/artifacts'
import CanvasPanel from '../components/CanvasPanel'
import ResourceCards from '../components/ResourceCards'
@@ -288,10 +288,24 @@ export default function AgentChat() {
messagesEndRef.current?.scrollIntoView({ behavior: 'auto' })
}, [activeId])
// Highlight code blocks
// Highlight code blocks + add per-block copy buttons (parity with Chat). A
// MutationObserver on the messages container fires reliably for streamed and
// loaded messages; it disconnects while mutating so its own edits do not
// retrigger it.
useEffect(() => {
if (messagesRef.current) highlightAll(messagesRef.current)
}, [messages])
const el = messagesRef.current
if (!el) return
let obs
const run = () => {
obs?.disconnect()
highlightAll(el)
enhanceCodeBlocks(el)
obs?.observe(el, { childList: true, subtree: true })
}
obs = new MutationObserver(run)
run()
return () => obs.disconnect()
}, [])
const agentMessages = useMemo(() => messages.filter(m => m.sender === 'agent'), [messages])
const codeArtifacts = useMemo(
@@ -556,7 +570,7 @@ export default function AgentChat() {
<label className="canvas-mode-toggle" title="Extract code blocks and media into a side panel for preview, copy, and download">
<i className="fas fa-columns" />
<span className="canvas-mode-label">Canvas</span>
<span className="toggle">
<span className={`toggle${canvasMode ? ' toggle--on' : ''}`}>
<input
type="checkbox"
checked={canvasMode}
@@ -565,7 +579,9 @@ export default function AgentChat() {
if (!e.target.checked) setCanvasOpen(false)
}}
/>
<span className="toggle-slider" />
<span className="toggle__track">
<span className="toggle__thumb" />
</span>
</span>
</label>
{canvasMode && artifacts.length > 0 && !canvasOpen && (

View File

@@ -2,6 +2,7 @@ import { useState, useEffect, useMemo } from 'react'
import { useParams, useNavigate, useLocation, useOutletContext, useSearchParams } from 'react-router-dom'
import { agentsApi, skillsApi } from '../utils/api'
import SearchableModelSelect from '../components/SearchableModelSelect'
import PageHeader from '../components/PageHeader'
import { CAP_CHAT, CAP_TRANSCRIPT, CAP_TTS } from '../utils/capabilities'
import Toggle from '../components/Toggle'
import SettingRow from '../components/SettingRow'
@@ -930,12 +931,14 @@ export default function AgentCreate() {
}
`}</style>
<div className="page-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h1 className="page-title">{isEdit ? `Edit Agent: ${name}` : importedConfig ? 'Import Agent' : 'Create Agent'}</h1>
<button className="btn btn-secondary btn-sm" onClick={() => navigate('/app/agents')}>
<i className="fas fa-arrow-left" /> Back
</button>
</div>
<PageHeader
title={isEdit ? `Edit Agent: ${name}` : importedConfig ? 'Import Agent' : 'Create Agent'}
actions={
<button className="btn btn-secondary btn-sm" onClick={() => navigate('/app/agents')}>
<i className="fas fa-arrow-left" /> Back
</button>
}
/>
<form onSubmit={handleSubmit} noValidate>
<div className="agent-form-container">

View File

@@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from 'react'
import { useParams, useNavigate, useOutletContext } from 'react-router-dom'
import { agentJobsApi } from '../utils/api'
import LoadingSpinner from '../components/LoadingSpinner'
import PageHeader from '../components/PageHeader'
const traceColors = {
reasoning: { bg: 'rgba(99,102,241,0.1)', border: 'rgba(99,102,241,0.3)', icon: 'fa-brain', color: 'var(--color-primary)' },
@@ -178,22 +179,22 @@ export default function AgentJobDetails() {
return (
<div className="page page--narrow">
<div className="page-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<h1 className="page-title">Job Details</h1>
<p className="page-subtitle">Live status and reasoning traces</p>
</div>
<div style={{ display: 'flex', gap: 'var(--spacing-sm)' }}>
{(job.status === 'running' || job.status === 'pending') && (
<button className="btn btn-danger" onClick={handleCancel}>
<i className="fas fa-stop" /> Cancel
<PageHeader
title="Job Details"
supporting="Live status and reasoning traces"
actions={
<div style={{ display: 'flex', gap: 'var(--spacing-sm)' }}>
{(job.status === 'running' || job.status === 'pending') && (
<button className="btn btn-danger" onClick={handleCancel}>
<i className="fas fa-stop" /> Cancel
</button>
)}
<button className="btn btn-secondary" onClick={() => navigate('/app/agent-jobs')}>
<i className="fas fa-arrow-left" /> Back
</button>
)}
<button className="btn btn-secondary" onClick={() => navigate('/app/agent-jobs')}>
<i className="fas fa-arrow-left" /> Back
</button>
</div>
</div>
</div>
}
/>
{/* Status Card */}
<div className="card" style={{ marginBottom: 'var(--spacing-md)' }}>

View File

@@ -6,6 +6,7 @@ import { useModels } from '../hooks/useModels'
import { useAuth } from '../context/AuthContext'
import { useUserMap } from '../hooks/useUserMap'
import LoadingSpinner from '../components/LoadingSpinner'
import PageHeader from '../components/PageHeader'
import { fileToBase64 } from '../utils/api'
import Modal from '../components/Modal'
import UserGroupSection from '../components/UserGroupSection'
@@ -216,10 +217,7 @@ export default function AgentJobs() {
if (!loading && models.length === 0) {
return (
<div className="page page--wide">
<div className="page-header">
<h1 className="page-title">Agent Jobs</h1>
<p className="page-subtitle">Manage agent tasks and automated workflows</p>
</div>
<PageHeader title="Agent Jobs" supporting="Manage agent tasks and automated workflows" />
<div className="card" style={{ textAlign: 'center', padding: 'var(--spacing-xl)' }}>
<i className="fas fa-exclamation-triangle" style={{ fontSize: '3rem', color: 'var(--color-warning)', marginBottom: 'var(--spacing-md)' }} />
<h2 style={{ marginBottom: 'var(--spacing-sm)' }}>No Models Installed</h2>
@@ -243,10 +241,7 @@ export default function AgentJobs() {
if (!loading && models.length > 0 && !hasMCPModels && tasks.length === 0) {
return (
<div className="page page--wide">
<div className="page-header">
<h1 className="page-title">Agent Jobs</h1>
<p className="page-subtitle">Manage agent tasks and automated workflows</p>
</div>
<PageHeader title="Agent Jobs" supporting="Manage agent tasks and automated workflows" />
<div className="card" style={{ textAlign: 'center', padding: 'var(--spacing-xl)' }}>
<i className="fas fa-plug" style={{ fontSize: '3rem', color: 'var(--color-primary)', marginBottom: 'var(--spacing-md)' }} />
<h2 style={{ marginBottom: 'var(--spacing-sm)' }}>MCP Not Configured</h2>
@@ -276,15 +271,15 @@ export default function AgentJobs() {
return (
<div className="page page--wide">
<div className="page-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<h1 className="page-title">Agent Jobs</h1>
<p className="page-subtitle">Manage agent tasks and automated workflows</p>
</div>
<button className="btn btn-primary" onClick={() => navigate('/app/agent-jobs/tasks/new')}>
<i className="fas fa-plus" /> New Task
</button>
</div>
<PageHeader
title="Agent Jobs"
supporting="Manage agent tasks and automated workflows"
actions={
<button className="btn btn-primary" onClick={() => navigate('/app/agent-jobs/tasks/new')}>
<i className="fas fa-plus" /> New Task
</button>
}
/>
<div className="tabs">
<button className={`tab ${activeTab === 'tasks' ? 'tab-active' : ''}`} onClick={() => setActiveTab('tasks')}>

View File

@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback } from 'react'
import { useParams, useNavigate, useOutletContext, useSearchParams } from 'react-router-dom'
import { agentsApi } from '../utils/api'
import { apiUrl } from '../utils/basePath'
import PageHeader from '../components/PageHeader'
function ObservableSummary({ observable }) {
const creation = observable?.creation || {}
@@ -352,29 +353,26 @@ export default function AgentStatus() {
.as-status-value { font-size: 1rem; font-weight: 600; color: var(--color-text-primary); }
`}</style>
<div className="page-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<h1 className="page-title">
<i className="fas fa-chart-bar" style={{ marginRight: 'var(--spacing-xs)' }} />
{name} Status
</h1>
<p className="page-subtitle">Agent observables and activity history</p>
</div>
<div style={{ display: 'flex', gap: 'var(--spacing-sm)' }}>
<button className="btn btn-secondary" onClick={() => navigate(`/app/agents/${encodeURIComponent(name)}/chat${userId ? `?user_id=${encodeURIComponent(userId)}` : ''}`)}>
<i className="fas fa-comment" /> Chat
</button>
<button className="btn btn-secondary" onClick={() => navigate(`/app/agents/${encodeURIComponent(name)}/edit${userId ? `?user_id=${encodeURIComponent(userId)}` : ''}`)}>
<i className="fas fa-edit" /> Edit
</button>
<button className="btn btn-secondary" onClick={fetchData}>
<i className="fas fa-sync" /> Refresh
</button>
<button className="btn btn-danger" onClick={handleClear} disabled={observables.length === 0}>
<i className="fas fa-trash" /> Clear
</button>
</div>
</div>
<PageHeader
title={<><i className="fas fa-chart-bar" style={{ marginRight: 'var(--spacing-xs)' }} />{name} Status</>}
supporting="Agent observables and activity history"
actions={
<div style={{ display: 'flex', gap: 'var(--spacing-sm)' }}>
<button className="btn btn-secondary" onClick={() => navigate(`/app/agents/${encodeURIComponent(name)}/chat${userId ? `?user_id=${encodeURIComponent(userId)}` : ''}`)}>
<i className="fas fa-comment" /> Chat
</button>
<button className="btn btn-secondary" onClick={() => navigate(`/app/agents/${encodeURIComponent(name)}/edit${userId ? `?user_id=${encodeURIComponent(userId)}` : ''}`)}>
<i className="fas fa-edit" /> Edit
</button>
<button className="btn btn-secondary" onClick={fetchData}>
<i className="fas fa-sync" /> Refresh
</button>
<button className="btn btn-danger" onClick={handleClear} disabled={observables.length === 0}>
<i className="fas fa-trash" /> Clear
</button>
</div>
}
/>
{/* Status summary */}
{status && (

View File

@@ -3,6 +3,7 @@ import { useParams, useNavigate, useOutletContext, useLocation } from 'react-rou
import { agentJobsApi } from '../utils/api'
import { basePath } from '../utils/basePath'
import ModelSelector from '../components/ModelSelector'
import PageHeader from '../components/PageHeader'
import { CAP_CHAT } from '../utils/capabilities'
import LoadingSpinner from '../components/LoadingSpinner'
@@ -165,20 +166,20 @@ export default function AgentTaskDetails() {
if (!isNew && !isEdit) {
return (
<div className="page page--narrow">
<div className="page-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<h1 className="page-title">{task.name || 'Task Details'}</h1>
{task.description && <p className="page-subtitle">{task.description}</p>}
</div>
<div style={{ display: 'flex', gap: 'var(--spacing-sm)' }}>
<button className="btn btn-primary btn-sm" onClick={() => navigate(`/app/agent-jobs/tasks/${id}/edit`)}>
<i className="fas fa-edit" /> Edit
</button>
<button className="btn btn-secondary btn-sm" onClick={() => navigate('/app/agent-jobs')}>
<i className="fas fa-arrow-left" /> Back
</button>
</div>
</div>
<PageHeader
title={task.name || 'Task Details'}
supporting={task.description || undefined}
actions={
<div style={{ display: 'flex', gap: 'var(--spacing-sm)' }}>
<button className="btn btn-primary btn-sm" onClick={() => navigate(`/app/agent-jobs/tasks/${id}/edit`)}>
<i className="fas fa-edit" /> Edit
</button>
<button className="btn btn-secondary btn-sm" onClick={() => navigate('/app/agent-jobs')}>
<i className="fas fa-arrow-left" /> Back
</button>
</div>
}
/>
{/* Task Info */}
<div className="card" style={{ marginBottom: 'var(--spacing-md)' }}>
@@ -307,12 +308,14 @@ export default function AgentTaskDetails() {
// Edit/Create form
return (
<div className="page page--narrow">
<div className="page-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h1 className="page-title">{isNew ? 'Create Task' : 'Edit Task'}</h1>
<button className="btn btn-secondary btn-sm" onClick={() => navigate('/app/agent-jobs')}>
<i className="fas fa-arrow-left" /> Back
</button>
</div>
<PageHeader
title={isNew ? 'Create Task' : 'Edit Task'}
actions={
<button className="btn btn-secondary btn-sm" onClick={() => navigate('/app/agent-jobs')}>
<i className="fas fa-arrow-left" /> Back
</button>
}
/>
<form onSubmit={handleSave}>
{/* Basic Info */}

View File

@@ -5,6 +5,7 @@ import { agentsApi } from '../utils/api'
import { useAuth } from '../context/AuthContext'
import { useUserMap } from '../hooks/useUserMap'
import UserGroupSection from '../components/UserGroupSection'
import PageHeader from '../components/PageHeader'
import ConfirmDialog from '../components/ConfirmDialog'
export default function Agents() {
@@ -181,26 +182,26 @@ export default function Agents() {
}
`}</style>
<div className="page-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<h1 className="page-title">{t('title')}</h1>
<p className="page-subtitle">{t('subtitle')}</p>
</div>
<div style={{ display: 'flex', gap: 'var(--spacing-sm)', alignItems: 'center' }}>
{agentHubURL && (
<a className="btn btn-secondary" href={agentHubURL} target="_blank" rel="noopener noreferrer">
<i className="fas fa-store" /> {t('actions.agentHub')}
</a>
)}
<label className="btn btn-secondary">
<i className="fas fa-file-import" /> {t('actions.import')}
<input type="file" accept=".json" className="agents-import-input" onChange={handleImport} />
</label>
<button className="btn btn-primary" onClick={() => navigate('/app/agents/new')}>
<i className="fas fa-plus" /> {t('actions.createAgent')}
</button>
</div>
</div>
<PageHeader
title={t('title')}
supporting={t('subtitle')}
actions={
<div style={{ display: 'flex', gap: 'var(--spacing-sm)', alignItems: 'center' }}>
{agentHubURL && (
<a className="btn btn-secondary" href={agentHubURL} target="_blank" rel="noopener noreferrer">
<i className="fas fa-store" /> {t('actions.agentHub')}
</a>
)}
<label className="btn btn-secondary">
<i className="fas fa-file-import" /> {t('actions.import')}
<input type="file" accept=".json" className="agents-import-input" onChange={handleImport} />
</label>
<button className="btn btn-primary" onClick={() => navigate('/app/agents/new')}>
<i className="fas fa-plus" /> {t('actions.createAgent')}
</button>
</div>
}
/>
{loading ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: 'var(--spacing-xl)' }}>

View File

@@ -1,6 +1,7 @@
import { useState, useEffect, useRef } from 'react'
import { useParams, useOutletContext } from 'react-router-dom'
import ModelSelector from '../components/ModelSelector'
import PageHeader from '../components/PageHeader'
import { CAP_AUDIO_TRANSFORM } from '../utils/capabilities'
import LoadingSpinner from '../components/LoadingSpinner'
import ErrorWithTraceLink from '../components/ErrorWithTraceLink'
@@ -166,9 +167,7 @@ export default function AudioTransform() {
return (
<div className="media-layout">
<div className="media-controls">
<div className="page-header">
<h1 className="page-title"><i className="fas fa-wave-square" /> Audio Transform</h1>
</div>
<PageHeader title={<><i className="fas fa-wave-square" /> Audio Transform</>} />
<form onSubmit={handleProcess}>
<div className="form-group">

View File

@@ -4,6 +4,7 @@ import { backendLogsApi, nodesApi } from '../utils/api'
import { formatTimestamp } from '../utils/format'
import { apiUrl } from '../utils/basePath'
import LoadingSpinner from '../components/LoadingSpinner'
import PageHeader from '../components/PageHeader'
import { useDistributedMode } from '../hooks/useDistributedMode'
function wsUrl(path) {
@@ -151,15 +152,10 @@ function BackendLogsDetail({ modelId }) {
return (
<div className="page page--wide">
<div className="page-header">
<div>
<h1 className="page-title" style={{ marginBottom: 0 }}>
<i className="fas fa-terminal" style={{ fontSize: '0.8em', marginRight: 'var(--spacing-sm)' }} />
{modelId}
</h1>
<p className="page-subtitle" style={{ marginTop: 'var(--spacing-xs)' }}>Backend process output</p>
</div>
</div>
<PageHeader
title={<><i className="fas fa-terminal" style={{ fontSize: '0.8em', marginRight: 'var(--spacing-sm)' }} />{modelId}</>}
supporting="Backend process output"
/>
{/* Toolbar */}
<div style={{ display: 'flex', gap: 'var(--spacing-sm)', marginBottom: 'var(--spacing-md)', alignItems: 'center', flexWrap: 'wrap' }}>
@@ -361,17 +357,10 @@ function DistributedBackendLogsResolver({ modelId, fromTimestamp }) {
// Multiple workers host this model — let the operator pick.
return (
<div className="page page--wide">
<div className="page-header">
<div>
<h1 className="page-title" style={{ marginBottom: 0 }}>
<i className="fas fa-terminal" style={{ fontSize: '0.8em', marginRight: 'var(--spacing-sm)' }} />
{modelId}
</h1>
<p className="page-subtitle" style={{ marginTop: 'var(--spacing-xs)' }}>
Hosted on {hits.length} workers — pick one to view its logs.
</p>
</div>
</div>
<PageHeader
title={<><i className="fas fa-terminal" style={{ fontSize: '0.8em', marginRight: 'var(--spacing-sm)' }} />{modelId}</>}
supporting={`Hosted on ${hits.length} workers — pick one to view its logs.`}
/>
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-xs)' }}>
{hits.map(({ node, model }) => (
<Link

View File

@@ -7,6 +7,7 @@ import React from 'react'
import { useOperations } from '../hooks/useOperations'
import { useDistributedMode } from '../hooks/useDistributedMode'
import LoadingSpinner from '../components/LoadingSpinner'
import PageHeader from '../components/PageHeader'
import { renderMarkdown } from '../utils/markdown'
import { safeHref } from '../utils/url'
import ConfirmDialog from '../components/ConfirmDialog'
@@ -348,11 +349,10 @@ export default function Backends() {
)}
{/* Header */}
<div className="page-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div>
<h1 className="page-title">{t('backends.title')}</h1>
<p className="page-subtitle">{t('backends.subtitle')}</p>
</div>
<PageHeader
title={t('backends.title')}
supporting={t('backends.subtitle')}
actions={
<div style={{ display: 'flex', gap: 'var(--spacing-md)', alignItems: 'center' }}>
<div style={{ display: 'flex', gap: 'var(--spacing-md)', fontSize: '0.8125rem' }}>
<div style={{ textAlign: 'center' }}>
@@ -378,7 +378,8 @@ export default function Backends() {
<i className="fas fa-book" /> Docs
</a>
</div>
</div>
}
/>
{/* Upgrade Banner */}
{Object.keys(upgrades).length > 0 && (

View File

@@ -4,9 +4,10 @@ import { useTranslation } from 'react-i18next'
import { fromState } from '../utils/editorNav'
import { useChat } from '../hooks/useChat'
import ModelSelector from '../components/ModelSelector'
import { renderMarkdown, highlightAll } from '../utils/markdown'
import { renderMarkdown, highlightAll, enhanceCodeBlocks } from '../utils/markdown'
import { extractCodeArtifacts, renderMarkdownWithArtifacts } from '../utils/artifacts'
import CanvasPanel from '../components/CanvasPanel'
import Toggle from '../components/Toggle'
import { fileToBase64, modelsApi, mcpApi } from '../utils/api'
import { CAP_CHAT } from '../utils/capabilities'
import { useMCPClient } from '../hooks/useMCPClient'
@@ -335,6 +336,7 @@ export default function Chat() {
const messagesRef = useRef(null)
const textareaRef = useRef(null)
const stickToBottomRef = useRef(true)
const [scrolledUp, setScrolledUp] = useState(false)
const chatsMenuRef = useRef(null)
// Focus mode: once a conversation has at least one message we slim the
@@ -600,6 +602,7 @@ export default function Chat() {
const onScroll = () => {
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight
stickToBottomRef.current = distanceFromBottom < 80
setScrolledUp(distanceFromBottom > 160)
}
el.addEventListener('scroll', onScroll, { passive: true })
return () => el.removeEventListener('scroll', onScroll)
@@ -615,6 +618,7 @@ export default function Chat() {
// user's focus-mode override — each chat starts fresh.
useEffect(() => {
stickToBottomRef.current = true
setScrolledUp(false)
messagesEndRef.current?.scrollIntoView({ behavior: 'auto' })
setFocusOverride(false)
}, [activeChat?.id])
@@ -657,12 +661,25 @@ export default function Chat() {
return () => window.removeEventListener('keydown', onKey)
}, [focusActive])
// Highlight code blocks
// Highlight code blocks + add per-block copy buttons. A MutationObserver on
// the messages container is more reliable than render-keyed effects: it fires
// for loaded/switched chats AND for streaming token updates, regardless of
// render timing. The observer is disconnected while we mutate so our own
// highlight/enhance edits don't retrigger it.
useEffect(() => {
if (messagesRef.current) {
highlightAll(messagesRef.current)
const el = messagesRef.current
if (!el) return
let obs
const run = () => {
obs?.disconnect()
highlightAll(el)
enhanceCodeBlocks(el)
obs?.observe(el, { childList: true, subtree: true })
}
}, [activeChat?.history, streamingContent])
obs = new MutationObserver(run)
run()
return () => obs.disconnect()
}, [activeChat?.id])
// Auto-grow textarea
const autoGrowTextarea = useCallback(() => {
@@ -966,14 +983,10 @@ export default function Chat() {
{t('settings.manageModeDesc')}
</span>
</div>
<label className="toggle">
<input
type="checkbox"
checked={!!activeChat.localaiAssistant}
onChange={(e) => updateChatSettings(activeChat.id, { localaiAssistant: e.target.checked })}
/>
<span className="toggle-slider" />
</label>
<Toggle
checked={!!activeChat.localaiAssistant}
onChange={(next) => updateChatSettings(activeChat.id, { localaiAssistant: next })}
/>
</div>
)}
<div className="form-group">
@@ -1225,6 +1238,19 @@ export default function Chat() {
</div>
)}
<div ref={messagesEndRef} />
{scrolledUp && (
<button
type="button"
className="chat-jump-latest"
onClick={() => {
stickToBottomRef.current = true
setScrolledUp(false)
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}}
>
<i className="fas fa-arrow-down" aria-hidden="true" /> {t('actions.jumpToLatest')}
</button>
)}
</div>
{/* Token info bar */}
@@ -1247,15 +1273,22 @@ export default function Chat() {
{/* File badges */}
{files.length > 0 && (
<div className="chat-files">
{files.map((f, i) => (
<span key={i} className="chat-file-badge">
<i className={`fas ${f.type?.startsWith('image/') ? 'fa-image' : f.type?.startsWith('audio/') ? 'fa-headphones' : f.type?.startsWith('video/') ? 'fa-film' : 'fa-file'}`} />
{f.name}
<button onClick={() => setFiles(prev => prev.filter((_, idx) => idx !== i))}>
<i className="fas fa-xmark" />
</button>
</span>
))}
{files.map((f, i) => {
const isImage = f.type?.startsWith('image/') && f.base64
return (
<span key={i} className={`chat-file-badge${isImage ? ' chat-file-badge--image' : ''}`}>
{isImage ? (
<img src={`data:${f.type};base64,${f.base64}`} alt={f.name} className="chat-file-thumb" />
) : (
<i className={`fas ${f.type?.startsWith('audio/') ? 'fa-headphones' : f.type?.startsWith('video/') ? 'fa-film' : 'fa-file'}`} />
)}
<span className="chat-file-name">{f.name}</span>
<button onClick={() => setFiles(prev => prev.filter((_, idx) => idx !== i))} aria-label={`Remove ${f.name}`}>
<i className="fas fa-xmark" />
</button>
</span>
)
})}
</div>
)}

View File

@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback } from 'react'
import { useParams, useOutletContext, useSearchParams } from 'react-router-dom'
import { agentCollectionsApi } from '../utils/api'
import ConfirmDialog from '../components/ConfirmDialog'
import PageHeader from '../components/PageHeader'
export default function CollectionDetails() {
const { name } = useParams()
@@ -292,10 +293,7 @@ export default function CollectionDetails() {
}
`}</style>
<div className="page-header">
<h1 className="page-title">{name}</h1>
<p className="page-subtitle">Collection details and management</p>
</div>
<PageHeader title={name} supporting="Collection details and management" />
<div className="tabs">
<button className={`tab ${activeTab === 'entries' ? 'tab-active' : ''}`} onClick={() => setActiveTab('entries')}>

View File

@@ -5,6 +5,7 @@ import { agentCollectionsApi } from '../utils/api'
import { useAuth } from '../context/AuthContext'
import { useUserMap } from '../hooks/useUserMap'
import UserGroupSection from '../components/UserGroupSection'
import PageHeader from '../components/PageHeader'
import ConfirmDialog from '../components/ConfirmDialog'
export default function Collections() {
@@ -119,10 +120,7 @@ export default function Collections() {
}
`}</style>
<div className="page-header">
<h1 className="page-title">{t('title')}</h1>
<p className="page-subtitle">{t('subtitle')}</p>
</div>
<PageHeader title={t('title')} supporting={t('subtitle')} />
<div className="collections-create-bar">
<input

View File

@@ -1,6 +1,7 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { fineTuneApi } from '../utils/api'
import LoadingSpinner from '../components/LoadingSpinner'
import PageHeader from '../components/PageHeader'
const TRAINING_METHODS = ['sft', 'dpo', 'grpo', 'rloo', 'reward', 'kto', 'orpo']
const TRAINING_TYPES = ['lora', 'loha', 'lokr', 'full']
@@ -1058,21 +1059,21 @@ export default function FineTune() {
return (
<div className="page page--wide">
<div className="page-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div>
<h1 className="page-title">Fine-Tuning <span className="badge badge-warning" style={{ fontSize: '0.45em', verticalAlign: 'middle' }}>Experimental</span></h1>
<p className="page-subtitle">Create and manage fine-tuning jobs</p>
</div>
<div style={{ display: 'flex', gap: 'var(--spacing-sm)' }}>
<button className="btn" onClick={handleImportConfig}>
<i className="fas fa-upload" style={{ marginRight: 'var(--spacing-xs)' }} /> Import Config
</button>
<button className="btn btn-primary" onClick={() => setShowForm(!showForm)}>
<i className={`fas fa-${showForm ? 'times' : 'plus'}`} style={{ marginRight: 'var(--spacing-xs)' }} />
{showForm ? 'Cancel' : 'New Job'}
</button>
</div>
</div>
<PageHeader
title={<>Fine-Tuning <span className="badge badge-warning" style={{ fontSize: '0.45em', verticalAlign: 'middle' }}>Experimental</span></>}
supporting="Create and manage fine-tuning jobs"
actions={
<div style={{ display: 'flex', gap: 'var(--spacing-sm)' }}>
<button className="btn" onClick={handleImportConfig}>
<i className="fas fa-upload" style={{ marginRight: 'var(--spacing-xs)' }} /> Import Config
</button>
<button className="btn btn-primary" onClick={() => setShowForm(!showForm)}>
<i className={`fas fa-${showForm ? 'times' : 'plus'}`} style={{ marginRight: 'var(--spacing-xs)' }} />
{showForm ? 'Cancel' : 'New Job'}
</button>
</div>
}
/>
{error && (
<div className="card" style={{ background: 'var(--color-error-light)', borderColor: 'var(--color-error-border)', color: 'var(--color-error)', marginBottom: 'var(--spacing-md)', padding: 'var(--spacing-md)' }}>

View File

@@ -12,12 +12,12 @@ import HomeConnect from '../components/HomeConnect'
import { useResources } from '../hooks/useResources'
import { fileToBase64, backendControlApi, systemApi, modelsApi, mcpApi, nodesApi } from '../utils/api'
import { API_CONFIG } from '../utils/config'
function formatBytes(bytes) {
if (!bytes || bytes === 0) return null
const gb = bytes / (1024 * 1024 * 1024)
return gb >= 1 ? `${gb.toFixed(1)} GB` : `${(bytes / (1024 * 1024)).toFixed(0)} MB`
}
import { greetingKey } from '../utils/greeting'
import StatusPill from '../components/StatusPill'
import Skeleton from '../components/Skeleton'
import SectionHeading from '../components/SectionHeading'
import EmptyState from '../components/EmptyState'
import { staggerStyle } from '../hooks/useStagger'
export default function Home() {
const navigate = useNavigate()
@@ -287,62 +287,39 @@ export default function Home() {
const hasModels = modelsLoading || configuredModels.length > 0
const loadedCount = loadedModels.length
// Resource display
// Resource display - folded into the editorial status line.
const resType = resources?.type
const usagePct = resources?.aggregate?.usage_percent ?? resources?.ram?.usage_percent ?? 0
const pctColor = usagePct > 90 ? 'var(--color-error)' : usagePct > 70 ? 'var(--color-warning)' : 'var(--color-success)'
// Cluster resource display (distributed mode)
const clusterUsagePct = clusterData?.totalMem > 0 ? ((clusterData.usedMem / clusterData.totalMem) * 100) : 0
const clusterPctColor = clusterUsagePct > 90 ? 'var(--color-error)' : clusterUsagePct > 70 ? 'var(--color-warning)' : 'var(--color-success)'
return (
<div className="home-page">
{hasModels ? (
<>
{/* Hero with logo */}
<div className="home-hero">
<img src={apiUrl(branding.logoUrl)} alt={branding.instanceName} className="home-logo" />
</div>
{/* Resource monitor - prominent placement */}
{distributedMode && clusterData && clusterData.totalMem > 0 ? (
<div className="home-resource-bar">
<div className="home-resource-bar-header">
<i className={`fas ${clusterData.isGPU ? 'fa-microchip' : 'fa-memory'}`} />
<span className="home-resource-label">{clusterData.isGPU ? t('cluster.vram') : t('cluster.ram')}</span>
<span className="home-resource-pct" style={{ color: clusterPctColor }}>
{formatBytes(clusterData.usedMem)} / {formatBytes(clusterData.totalMem)}
</span>
</div>
<div className="home-resource-track">
<div
className="home-resource-fill"
style={{ width: `${clusterUsagePct}%`, background: clusterPctColor }}
/>
</div>
<div className="home-cluster-status">
<span className="home-cluster-dot" style={clusterData.healthyCount === 0 ? { background: 'var(--color-error)' } : undefined} />
<span>{t('cluster.nodesOnline', { healthy: clusterData.healthyCount, total: clusterData.totalCount })}</span>
</div>
{/* Editorial header */}
<header className="home-header reveal-stagger">
<div style={staggerStyle(0)}>
<span className="home-eyebrow">{branding.instanceName}</span>
<h1 className="home-greeting">{t(`greeting.${greetingKey()}`)}</h1>
</div>
) : !distributedMode && resources ? (
<div className="home-resource-bar">
<div className="home-resource-bar-header">
<i className={`fas ${resType === 'gpu' ? 'fa-microchip' : 'fa-memory'}`} />
<span className="home-resource-label">{resType === 'gpu' ? t('resourceGpu') : t('resourceRam')}</span>
<span className="home-resource-pct" style={{ color: pctColor }}>
{usagePct.toFixed(0)}%
</span>
</div>
<div className="home-resource-track">
<div
className="home-resource-fill"
style={{ width: `${usagePct}%`, background: pctColor }}
<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')}
/>
{distributedMode && clusterData && (
<StatusPill
status={clusterData.healthyCount > 0 ? 'healthy' : 'error'}
label={t('statusLine.nodes', { count: clusterData.totalCount })}
/>
</div>
)}
{!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>
)}
</div>
) : null}
</header>
{/* LocalAI Assistant — prominent CTA on first run. Once the
admin has used it, the big card collapses to a small entry in
@@ -463,53 +440,64 @@ export default function Home() {
<i className="fas fa-user-shield" /> {t('quickLinks.manageByChat')}
</button>
)}
<button className="home-link-btn" onClick={() => navigate('/app/manage')}>
<i className="fas fa-desktop" /> {t('quickLinks.installedModels')}
<button className="btn btn-primary" onClick={() => navigate('/app/models')}>
<i className="fas fa-download" aria-hidden="true" /> {t('quickLinks.browseGallery')}
</button>
<button className="home-link-btn" onClick={() => navigate('/app/models')}>
<i className="fas fa-download" /> {t('quickLinks.browseGallery')}
<button className="home-link-btn" onClick={() => navigate('/app/manage')}>
<i className="fas fa-desktop" aria-hidden="true" /> {t('quickLinks.installedModels')}
</button>
<button className="home-link-btn" onClick={() => navigate('/app/import-model')}>
<i className="fas fa-upload" /> {t('quickLinks.importModel')}
<i className="fas fa-upload" aria-hidden="true" /> {t('quickLinks.importModel')}
</button>
</>
)}
<a className="home-link-btn" href="https://localai.io" target="_blank" rel="noopener noreferrer">
<i className="fas fa-book" /> {t('quickLinks.documentation')}
<a className="home-link-btn home-link-btn--quiet" href="https://localai.io" target="_blank" rel="noopener noreferrer">
<i className="fas fa-book" aria-hidden="true" /> {t('quickLinks.documentation')}
</a>
</div>
{/* Loaded models status */}
{loadedCount > 0 && (
<div className="home-loaded-models">
<span className="home-loaded-dot" />
<span className="home-loaded-text">{t('loadedModels.count', { count: loadedCount })}</span>
<div className="home-loaded-list">
{[...loadedModels].sort((a, b) => a.id.localeCompare(b.id)).map(m => (
<span key={m.id} className="home-loaded-item">
{m.id}
<button onClick={() => handleStopModel(m.id)} title={t('loadedModels.stop')}>
<i className="fas fa-times" />
</button>
</span>
))}
</div>
{loadedCount > 1 && (
<button className="home-stop-all" onClick={handleStopAll}>
{t('loadedModels.stopAll')}
</button>
)}
</div>
)}
<section className="home-loaded">
<SectionHeading>{t('loadedModels.heading')}</SectionHeading>
{modelsLoading ? (
<Skeleton variant="line" count={2} />
) : loadedCount > 0 ? (
<>
<ul className="home-loaded-list 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} />
<button
type="button"
onClick={() => handleStopModel(m.id)}
title={t('loadedModels.stop')}
aria-label={t('loadedModels.stop')}
>
<i className="fas fa-times" aria-hidden="true" />
</button>
</li>
))}
</ul>
{loadedCount > 1 && (
<button className="btn btn-secondary btn-sm home-stop-all" onClick={handleStopAll}>
{t('loadedModels.stopAll')}
</button>
)}
</>
) : (
<p className="home-loaded-empty">{t('statusLine.noModelsLoaded')}</p>
)}
</section>
</>
) : isAdmin ? (
/* No models installed - compact getting started */
<div className="home-wizard">
<div className="home-wizard-hero">
<img src={apiUrl(branding.logoUrl)} alt={branding.instanceName} className="home-logo" />
<h1>{t('wizard.getStarted', { name: branding.instanceName })}</h1>
<p>{t('wizard.intro')}</p>
</div>
<EmptyState
eyebrow={branding.instanceName}
icon="fa-rocket"
title={t('wizard.getStarted', { name: branding.instanceName })}
body={t('wizard.intro')}
/>
<div className="home-wizard-steps card">
<div className="home-wizard-step">

View File

@@ -2,10 +2,13 @@ import { useState, useRef } from 'react'
import { useParams, useOutletContext } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import ModelSelector from '../components/ModelSelector'
import PageHeader from '../components/PageHeader'
import { CAP_IMAGE } from '../utils/capabilities'
import LoadingSpinner from '../components/LoadingSpinner'
import ErrorWithTraceLink from '../components/ErrorWithTraceLink'
import MediaHistory from '../components/MediaHistory'
import Lightbox from '../components/Lightbox'
import GenerationProgress from '../components/GenerationProgress'
import { imageApi, fileToBase64 } from '../utils/api'
import { useMediaHistory } from '../hooks/useMediaHistory'
@@ -32,6 +35,12 @@ export default function ImageGen() {
const sourceRef = useRef(null)
const refRef = useRef(null)
const { addEntry, selectEntry, selectedEntry, historyProps } = useMediaHistory('image')
const [lightboxIdx, setLightboxIdx] = useState(null)
// The images currently on screen (a picked history entry, else the latest run).
const displayImages = selectedEntry
? selectedEntry.results.map(r => ({ url: r.url, alt: selectedEntry.prompt }))
: images.map(img => ({ url: img.url || `data:image/png;base64,${img.b64_json}`, alt: prompt }))
const handleGenerate = async (e) => {
e.preventDefault()
@@ -84,9 +93,7 @@ export default function ImageGen() {
return (
<div className="media-layout">
<div className="media-controls">
<div className="page-header">
<h1 className="page-title"><i className="fas fa-image" /> {t('image.title')}</h1>
</div>
<PageHeader title={<><i className="fas fa-image" /> {t('image.title')}</>} />
<form onSubmit={handleGenerate}>
<div className="form-group">
@@ -149,23 +156,16 @@ export default function ImageGen() {
<div className="media-preview">
<div className="media-result">
{loading ? (
<LoadingSpinner size="lg" />
<GenerationProgress count={count} label={t('image.actions.generating')} />
) : error ? (
<ErrorWithTraceLink message={error} />
) : selectedEntry ? (
) : displayImages.length > 0 ? (
<div className="media-result-grid">
{selectedEntry.results.map((r, i) => (
<div key={i}>
<img src={r.url} alt={selectedEntry.prompt} style={{ width: '100%', borderRadius: 'var(--radius-md)' }} />
</div>
))}
</div>
) : images.length > 0 ? (
<div className="media-result-grid">
{images.map((img, i) => (
<div key={i}>
<img src={img.url || `data:image/png;base64,${img.b64_json}`} alt={prompt} style={{ width: '100%', borderRadius: 'var(--radius-md)' }} />
</div>
{displayImages.map((im, i) => (
<button type="button" key={i} className="media-result-thumb" onClick={() => setLightboxIdx(i)} title={t('image.actions.view')} aria-label={t('image.actions.view')}>
<img src={im.url} alt={im.alt} />
<span className="media-result-thumb__zoom" aria-hidden="true"><i className="fas fa-expand" /></span>
</button>
))}
</div>
) : (
@@ -175,6 +175,9 @@ export default function ImageGen() {
</div>
)}
</div>
{lightboxIdx !== null && (
<Lightbox images={displayImages} index={lightboxIdx} onIndex={setLightboxIdx} onClose={() => setLightboxIdx(null)} />
)}
</div>
</div>
)

View File

@@ -3,6 +3,7 @@ import { useNavigate, useOutletContext } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { modelsApi, backendsApi } from '../utils/api'
import LoadingSpinner from '../components/LoadingSpinner'
import PageHeader from '../components/PageHeader'
import CodeEditor from '../components/CodeEditor'
import SearchableSelect from '../components/SearchableSelect'
import AmbiguityAlert from '../components/AmbiguityAlert'
@@ -798,24 +799,24 @@ export default function ImportModel() {
return (
<div className="page page--narrow">
<div className="page-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 'var(--spacing-sm)' }}>
<div>
<h1 className="page-title">{t('title')}</h1>
<p className="page-subtitle">{subtitle}</p>
</div>
<div style={{ display: 'flex', gap: 'var(--spacing-sm)', flexWrap: 'wrap', alignItems: 'center' }}>
<SimplePowerSwitch value={mode} onChange={requestModeSwitch} disabled={isSubmitting} />
{isPowerYaml ? (
<button className="btn btn-primary" onClick={handleAdvancedImport} disabled={isSubmitting}>
{isSubmitting ? <><LoadingSpinner size="sm" /> {t('actions.saving')}</> : <><i className="fas fa-save" aria-hidden="true" /> {t('actions.create')}</>}
</button>
) : (
<button className="btn btn-primary" onClick={() => handleSimpleImport()} disabled={isSubmitting || !importUri.trim()}>
{isSubmitting ? <><LoadingSpinner size="sm" /> {t('actions.importing')}</> : <><i className="fas fa-upload" aria-hidden="true" /> {t('actions.import')}</>}
</button>
)}
</div>
</div>
<PageHeader
title={t('title')}
supporting={subtitle}
actions={
<div style={{ display: 'flex', gap: 'var(--spacing-sm)', flexWrap: 'wrap', alignItems: 'center' }}>
<SimplePowerSwitch value={mode} onChange={requestModeSwitch} disabled={isSubmitting} />
{isPowerYaml ? (
<button className="btn btn-primary" onClick={handleAdvancedImport} disabled={isSubmitting}>
{isSubmitting ? <><LoadingSpinner size="sm" /> {t('actions.saving')}</> : <><i className="fas fa-save" aria-hidden="true" /> {t('actions.create')}</>}
</button>
) : (
<button className="btn btn-primary" onClick={() => handleSimpleImport()} disabled={isSubmitting || !importUri.trim()}>
{isSubmitting ? <><LoadingSpinner size="sm" /> {t('actions.importing')}</> : <><i className="fas fa-upload" aria-hidden="true" /> {t('actions.import')}</>}
</button>
)}
</div>
}
/>
{/* Estimate banner */}
{!isPowerYaml && estimate && (

View File

@@ -3,6 +3,7 @@ import { useNavigate, useOutletContext, useSearchParams, useLocation } from 'rea
import { useTranslation } from 'react-i18next'
import { fromState } from '../utils/editorNav'
import ResourceMonitor from '../components/ResourceMonitor'
import PageHeader from '../components/PageHeader'
import ConfirmDialog from '../components/ConfirmDialog'
import NodeDistributionChip from '../components/NodeDistributionChip'
import FilterBar from '../components/FilterBar'
@@ -448,10 +449,7 @@ export default function Manage() {
return (
<div className="page page--wide">
<div className="page-header">
<h1 className="page-title">{t('manage.title')}</h1>
<p className="page-subtitle">{t('manage.subtitle')}</p>
</div>
<PageHeader title={t('manage.title')} supporting={t('manage.subtitle')} />
{/* Resource Monitor */}
<ResourceMonitor />

View File

@@ -5,6 +5,7 @@ import { fromState } from '../utils/editorNav'
import { settingsApi, modelsApi } from '../utils/api'
import LoadingSpinner from '../components/LoadingSpinner'
import Toggle from '../components/Toggle'
import PageHeader from '../components/PageHeader'
// Middleware admin page. Three tabs:
// - Filtering: per-model resolved PII state + per-model detector list
@@ -129,12 +130,10 @@ export default function Middleware() {
return (
<div className="page page--wide">
<div className="page-header" style={{ marginBottom: 'var(--spacing-sm)' }}>
<h1 className="page-title">Middleware</h1>
<p className="page-subtitle">
Inspect and configure routing-module middleware: PII filtering and intelligent routing.
</p>
</div>
<PageHeader
title="Middleware"
supporting="Inspect and configure routing-module middleware: PII filtering and intelligent routing."
/>
{/* Tab bar */}
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-xs)', marginBottom: 'var(--spacing-md)', flexWrap: 'wrap' }}>

View File

@@ -8,6 +8,7 @@ import { useDebouncedCallback } from '../hooks/useDebounce'
import { useOperations } from '../hooks/useOperations'
import { useResources } from '../hooks/useResources'
import SearchableSelect from '../components/SearchableSelect'
import PageHeader from '../components/PageHeader'
import ConfirmDialog from '../components/ConfirmDialog'
import GalleryLoader from '../components/GalleryLoader'
import Toggle from '../components/Toggle'
@@ -271,32 +272,32 @@ export default function Models() {
return (
<div className="page page--wide">
<div className="page-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div>
<h1 className="page-title">{t('title')}</h1>
<p className="page-subtitle">{t('subtitle')}</p>
</div>
<div style={{ display: 'flex', gap: 'var(--spacing-md)', alignItems: 'center' }}>
<div style={{ display: 'flex', gap: 'var(--spacing-md)', fontSize: '0.8125rem' }}>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: '1.25rem', fontWeight: 700, color: 'var(--color-primary)' }}>{stats.total}</div>
<div style={{ color: 'var(--color-text-muted)' }}>{t('stats.available')}</div>
</div>
<div style={{ textAlign: 'center' }}>
<a onClick={() => navigate('/app/manage')} style={{ cursor: 'pointer' }}>
<div style={{ fontSize: '1.25rem', fontWeight: 700, color: 'var(--color-success)' }}>{stats.installed}</div>
<div style={{ color: 'var(--color-text-muted)' }}>{t('stats.installed')}</div>
</a>
<PageHeader
title={t('title')}
supporting={t('subtitle')}
actions={
<div style={{ display: 'flex', gap: 'var(--spacing-md)', alignItems: 'center' }}>
<div style={{ display: 'flex', gap: 'var(--spacing-md)', fontSize: '0.8125rem' }}>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: '1.25rem', fontWeight: 700, color: 'var(--color-primary)' }}>{stats.total}</div>
<div style={{ color: 'var(--color-text-muted)' }}>{t('stats.available')}</div>
</div>
<div style={{ textAlign: 'center' }}>
<a onClick={() => navigate('/app/manage')} style={{ cursor: 'pointer' }}>
<div style={{ fontSize: '1.25rem', fontWeight: 700, color: 'var(--color-success)' }}>{stats.installed}</div>
<div style={{ color: 'var(--color-text-muted)' }}>{t('stats.installed')}</div>
</a>
</div>
</div>
<button className="btn btn-primary btn-sm" onClick={() => navigate('/app/model-editor', { state: fromState(location, t('models')) })}>
<i className="fas fa-plus" /> {t('actions.addModel')}
</button>
<button className="btn btn-secondary btn-sm" onClick={() => navigate('/app/import-model')}>
<i className="fas fa-upload" /> {t('actions.importModel')}
</button>
</div>
<button className="btn btn-primary btn-sm" onClick={() => navigate('/app/model-editor', { state: fromState(location, t('models')) })}>
<i className="fas fa-plus" /> {t('actions.addModel')}
</button>
<button className="btn btn-secondary btn-sm" onClick={() => navigate('/app/import-model')}>
<i className="fas fa-upload" /> {t('actions.importModel')}
</button>
</div>
</div>
}
/>
{/* Search */}
<div className="search-bar" style={{ marginBottom: 'var(--spacing-md)' }}>

View File

@@ -4,6 +4,7 @@ import { nodesApi } from '../utils/api'
import { formatTimestamp } from '../utils/format'
import { apiUrl } from '../utils/basePath'
import LoadingSpinner from '../components/LoadingSpinner'
import PageHeader from '../components/PageHeader'
function wsUrl(path) {
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
@@ -176,9 +177,9 @@ export default function NodeBackendLogs() {
return (
<div className="page page--wide">
<div className="page-header">
<div>
<h1 className="page-title" style={{ marginBottom: 0 }}>
<PageHeader
title={
<>
<i className="fas fa-terminal" style={{ fontSize: '0.8em', marginRight: 'var(--spacing-sm)' }} />
{baseModelName}
{!isMerged && (
@@ -217,13 +218,15 @@ export default function NodeBackendLogs() {
merged · {replicas.length} replicas
</span>
)}
</h1>
<p className="page-subtitle" style={{ marginTop: 'var(--spacing-xs)' }}>
</>
}
supporting={
<>
Backend logs from node <strong>{nodeName || nodeId}</strong>
{' '}<Link to="/app/nodes" style={{ color: 'var(--color-primary)', fontSize: '0.8125rem' }}>(back to nodes)</Link>
</p>
</div>
</div>
</>
}
/>
{showReplicaToggle && (
<div role="radiogroup" aria-label="Replica scope" className="segmented" style={{ marginBottom: 'var(--spacing-sm)' }}>

View File

@@ -3,6 +3,7 @@ import { useOutletContext, useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { nodesApi } from '../utils/api'
import LoadingSpinner from '../components/LoadingSpinner'
import PageHeader from '../components/PageHeader'
import ConfirmDialog from '../components/ConfirmDialog'
import ActionMenu from '../components/ActionMenu'
import SearchableModelSelect from '../components/SearchableModelSelect'
@@ -995,15 +996,15 @@ export default function Nodes() {
return (
<div className="page page--wide">
<div className="page-header">
<h1 className="page-title">
<i className="fas fa-network-wired" style={{ marginRight: 'var(--spacing-sm)' }} />
{t('nodes.title')}
</h1>
<p className="page-subtitle">
{t('nodes.subtitle')}
</p>
</div>
<PageHeader
title={
<>
<i className="fas fa-network-wired" style={{ marginRight: 'var(--spacing-sm)' }} />
{t('nodes.title')}
</>
}
supporting={t('nodes.subtitle')}
/>
{/* Tabs */}
<div className="tabs" style={{ marginBottom: 'var(--spacing-lg)' }}>

View File

@@ -3,6 +3,7 @@ import { useOutletContext } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { p2pApi } from '../utils/api'
import LoadingSpinner from '../components/LoadingSpinner'
import PageHeader from '../components/PageHeader'
import ImageSelector, { useImageSelector, dockerImage, dockerFlags } from '../components/ImageSelector'
function NodeCard({ node, label, iconColor, iconBg }) {
@@ -295,20 +296,24 @@ export default function P2P() {
return (
<div className="page page--narrow">
<div className="page-header">
<h1 className="page-title">
<i className="fas fa-circle-nodes" style={{ marginRight: 'var(--spacing-sm)' }} />
{t('p2p.title')}
</h1>
<p className="page-subtitle">
{t('p2p.subtitle')}
{' '}
<a href="https://localai.io/features/distribute/" target="_blank" rel="noopener noreferrer"
style={{ color: 'var(--color-primary)' }}>
<i className="fas fa-circle-info" />
</a>
</p>
</div>
<PageHeader
title={
<>
<i className="fas fa-circle-nodes" style={{ marginRight: 'var(--spacing-sm)' }} />
{t('p2p.title')}
</>
}
supporting={
<>
{t('p2p.subtitle')}
{' '}
<a href="https://localai.io/features/distribute/" target="_blank" rel="noopener noreferrer"
style={{ color: 'var(--color-primary)' }}>
<i className="fas fa-circle-info" />
</a>
</>
}
/>
{/* Network Token */}
<div style={{

View File

@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'
import { settingsApi, resourcesApi, brandingApi } from '../utils/api'
import { useBranding } from '../contexts/BrandingContext'
import LoadingSpinner from '../components/LoadingSpinner'
import PageHeader from '../components/PageHeader'
import SearchableModelSelect from '../components/SearchableModelSelect'
import { CAP_CHAT } from '../utils/capabilities'
import Toggle from '../components/Toggle'
@@ -159,17 +160,16 @@ export default function Settings() {
return (
<div className="page page--medium" style={{ padding: 0 }}>
{/* Header */}
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: 'var(--spacing-lg) var(--spacing-lg) var(--spacing-md)',
}}>
<div>
<h1 className="page-title">{t('settings.title')}</h1>
<p className="page-subtitle">{t('settings.subtitle')}</p>
</div>
<button className={`btn ${isDirty ? 'btn-primary' : 'btn-secondary'}`} onClick={handleSave} disabled={saving || !isDirty}>
{saving ? <><LoadingSpinner size="sm" /> Saving...</> : <><i className="fas fa-save" /> {isDirty ? 'Save Changes' : 'Saved'}</>}
</button>
<div style={{ padding: 'var(--spacing-lg) var(--spacing-lg) 0' }}>
<PageHeader
title={t('settings.title')}
supporting={t('settings.subtitle')}
actions={
<button className={`btn ${isDirty ? 'btn-primary' : 'btn-secondary'}`} onClick={handleSave} disabled={saving || !isDirty}>
{saving ? <><LoadingSpinner size="sm" /> Saving...</> : <><i className="fas fa-save" /> {isDirty ? 'Save Changes' : 'Saved'}</>}
</button>
}
/>
</div>
{/* Two-column layout */}

View File

@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react'
import { useParams, useNavigate, useLocation, useOutletContext, useSearchParams } from 'react-router-dom'
import { skillsApi } from '../utils/api'
import PageHeader from '../components/PageHeader'
const RESOURCE_PREFIXES = ['scripts/', 'references/', 'assets/']
function isValidResourcePath(path) {
@@ -498,11 +499,13 @@ export default function SkillEdit() {
<a className="skilledit-back-link" onClick={() => navigate('/app/skills')}>
<i className="fas fa-arrow-left" /> Back to skills
</a>
<div className="page-header">
<h1 className="page-title">
<i className="fas fa-book" style={{ marginRight: 'var(--spacing-xs)' }} /> {isNew ? 'New skill' : `Edit: ${name}`}
</h1>
</div>
<PageHeader
title={
<>
<i className="fas fa-book" style={{ marginRight: 'var(--spacing-xs)' }} /> {isNew ? 'New skill' : `Edit: ${name}`}
</>
}
/>
<div className="card" style={{ marginTop: 'var(--spacing-md)' }}>
<div className="skilledit-layout">

View File

@@ -5,6 +5,7 @@ import { skillsApi } from '../utils/api'
import { useAuth } from '../context/AuthContext'
import { useUserMap } from '../hooks/useUserMap'
import UserGroupSection from '../components/UserGroupSection'
import PageHeader from '../components/PageHeader'
import ConfirmDialog from '../components/ConfirmDialog'
export default function Skills() {
@@ -207,10 +208,7 @@ export default function Skills() {
if (unavailable) {
return (
<div className="page page--wide">
<div className="page-header">
<h1 className="page-title">{t('title')}</h1>
<p className="page-subtitle">{t('unavailable.subtitle')}</p>
</div>
<PageHeader title={t('title')} supporting={t('unavailable.subtitle')} />
<div style={{ display: 'flex', justifyContent: 'center', padding: 'var(--spacing-xl)' }}>
<button className="btn btn-primary" onClick={() => { setUnavailable(false); fetchSkills() }}>
<i className="fas fa-redo" /> {t('unavailable.retry')}
@@ -312,41 +310,41 @@ export default function Skills() {
}
`}</style>
<div className="page-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div>
<h1 className="page-title">{t('title')}</h1>
<p className="page-subtitle">{t('subtitle')}</p>
</div>
<div className="skills-header-actions">
<input
type="text"
className="input"
placeholder={t('search.placeholder')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
style={{ width: '200px' }}
/>
<button className="btn btn-primary" onClick={() => navigate('/app/skills/new')}>
<i className="fas fa-plus" /> {t('actions.newSkill')}
</button>
<label className="btn btn-secondary" style={{ cursor: 'pointer' }}>
<i className="fas fa-file-import" /> {importing ? t('actions.importing') : t('actions.import')}
<PageHeader
title={t('title')}
supporting={t('subtitle')}
actions={
<div className="skills-header-actions">
<input
type="file"
accept=".tar.gz"
className="skills-import-input"
onChange={handleImport}
disabled={importing}
type="text"
className="input"
placeholder={t('search.placeholder')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
style={{ width: '200px' }}
/>
</label>
<button
className={`btn ${showGitRepos ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => setShowGitRepos((v) => !v)}
>
<i className="fas fa-code-branch" /> {t('actions.gitRepos')}
</button>
</div>
</div>
<button className="btn btn-primary" onClick={() => navigate('/app/skills/new')}>
<i className="fas fa-plus" /> {t('actions.newSkill')}
</button>
<label className="btn btn-secondary" style={{ cursor: 'pointer' }}>
<i className="fas fa-file-import" /> {importing ? t('actions.importing') : t('actions.import')}
<input
type="file"
accept=".tar.gz"
className="skills-import-input"
onChange={handleImport}
disabled={importing}
/>
</label>
<button
className={`btn ${showGitRepos ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => setShowGitRepos((v) => !v)}
>
<i className="fas fa-code-branch" /> {t('actions.gitRepos')}
</button>
</div>
}
/>
{showGitRepos && (
<div className="skills-git-section">

View File

@@ -1,8 +1,10 @@
import { useState } from 'react'
import { useParams, useOutletContext } from 'react-router-dom'
import ModelSelector from '../components/ModelSelector'
import PageHeader from '../components/PageHeader'
import { CAP_SOUND_GENERATION } from '../utils/capabilities'
import LoadingSpinner from '../components/LoadingSpinner'
import GenerationProgress from '../components/GenerationProgress'
import ErrorWithTraceLink from '../components/ErrorWithTraceLink'
import MediaHistory from '../components/MediaHistory'
import WaveformPlayer from '../components/audio/WaveformPlayer'
@@ -77,9 +79,7 @@ export default function Sound() {
return (
<div className="media-layout">
<div className="media-controls">
<div className="page-header">
<h1 className="page-title"><i className="fas fa-music" /> Sound Generation</h1>
</div>
<PageHeader title={<><i className="fas fa-music" /> Sound Generation</>} />
<form onSubmit={handleGenerate}>
<div className="form-group">
@@ -144,7 +144,7 @@ export default function Sound() {
<div className="media-preview">
<div className="media-result">
{loading ? (
<LoadingSpinner size="lg" />
<GenerationProgress label="Generating..." />
) : error ? (
<ErrorWithTraceLink message={error} />
) : selectedEntry ? (

View File

@@ -2,8 +2,10 @@ import { useState } from 'react'
import { useParams, useOutletContext } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import ModelSelector from '../components/ModelSelector'
import PageHeader from '../components/PageHeader'
import { CAP_TTS } from '../utils/capabilities'
import LoadingSpinner from '../components/LoadingSpinner'
import GenerationProgress from '../components/GenerationProgress'
import ErrorWithTraceLink from '../components/ErrorWithTraceLink'
import MediaHistory from '../components/MediaHistory'
import WaveformPlayer from '../components/audio/WaveformPlayer'
@@ -49,9 +51,7 @@ export default function TTS() {
return (
<div className="media-layout">
<div className="media-controls">
<div className="page-header">
<h1 className="page-title"><i className="fas fa-headphones" /> {t('tts.title')}</h1>
</div>
<PageHeader title={<><i className="fas fa-headphones" /> {t('tts.title')}</>} />
<form onSubmit={handleGenerate}>
<div className="form-group">
@@ -78,7 +78,7 @@ export default function TTS() {
<div className="media-preview">
<div className="media-result">
{loading ? (
<LoadingSpinner size="lg" />
<GenerationProgress label={t('tts.actions.generating')} />
) : error ? (
<ErrorWithTraceLink message={error} />
) : selectedEntry ? (

View File

@@ -3,6 +3,7 @@ import { useOutletContext, useNavigate, useLocation } from 'react-router-dom'
import { realtimeApi } from '../utils/api'
import { fromState } from '../utils/editorNav'
import ModelSelector from '../components/ModelSelector'
import VoiceVisualizer from '../components/VoiceVisualizer'
import ClientMCPDropdown from '../components/ClientMCPDropdown'
import { useMCPClient } from '../hooks/useMCPClient'
import { loadClientMCPServers } from '../utils/mcpClientStorage'
@@ -598,6 +599,9 @@ export default function Talk() {
</div>
<div className="card" style={{ padding: 'var(--spacing-lg)', marginBottom: 'var(--spacing-md)' }}>
{/* Voice visualizer (hero) */}
<VoiceVisualizer audioRef={audioRef} micStreamRef={localStreamRef} status={status} active={isConnected} />
{/* Connection status */}
<div style={{
display: 'flex', alignItems: 'center', gap: 'var(--spacing-sm)',

View File

@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'
import { tracesApi, settingsApi } from '../utils/api'
import { formatTimestamp } from '../utils/format'
import LoadingSpinner from '../components/LoadingSpinner'
import PageHeader from '../components/PageHeader'
import Toggle from '../components/Toggle'
import SettingRow from '../components/SettingRow'
import WaveformPlayer from '../components/audio/WaveformPlayer'
@@ -407,10 +408,7 @@ export default function Traces() {
return (
<div className="page page--wide">
<div className="page-header">
<h1 className="page-title">{t('traces.title')}</h1>
<p className="page-subtitle">{t('traces.subtitle')}</p>
</div>
<PageHeader title={t('traces.title')} supporting={t('traces.subtitle')} />
<div className="tabs">
<button className={`tab ${activeTab === 'api' ? 'tab-active' : ''}`} onClick={() => setActiveTab('api')}>

View File

@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'
import { useAuth } from '../context/AuthContext'
import { apiUrl } from '../utils/basePath'
import LoadingSpinner from '../components/LoadingSpinner'
import PageHeader from '../components/PageHeader'
import SourcesTab from './Usage/SourcesTab'
const PERIODS = [
@@ -707,10 +708,7 @@ export default function Usage() {
return (
<div className="page page--wide">
<div className="page-header" style={{ marginBottom: 'var(--spacing-sm)' }}>
<h1 className="page-title">{t('usage.title')}</h1>
<p className="page-subtitle">{t('usage.subtitle')}</p>
</div>
<PageHeader title={t('usage.title')} supporting={t('usage.subtitle')} />
{/* Period selector + tabs */}
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-xs)', marginBottom: 'var(--spacing-md)', flexWrap: 'wrap' }}>

View File

@@ -4,8 +4,10 @@ import { useTranslation } from 'react-i18next'
import { useAuth } from '../context/AuthContext'
import { adminUsersApi, adminInvitesApi } from '../utils/api'
import LoadingSpinner from '../components/LoadingSpinner'
import PageHeader from '../components/PageHeader'
import Modal from '../components/Modal'
import ConfirmDialog from '../components/ConfirmDialog'
import Toggle from '../components/Toggle'
import './auth.css'
function RoleBadge({ role }) {
@@ -289,14 +291,10 @@ function PermissionsModal({ user, featureMeta, availableModels, onClose, onSave,
</div>
<div style={{ marginBottom: 'var(--spacing-sm)' }}>
<label className="perm-toggle-label">
<label className="toggle" style={{ flexShrink: 0 }}>
<input
type="checkbox"
checked={allowedModels.enabled}
onChange={() => setAllowedModels(prev => ({ ...prev, enabled: !prev.enabled }))}
/>
<span className="toggle-slider" />
</label>
<Toggle
checked={allowedModels.enabled}
onChange={next => setAllowedModels(prev => ({ ...prev, enabled: next }))}
/>
Restrict to specific models
</label>
</div>
@@ -807,10 +805,7 @@ export default function Users() {
return (
<div className="page page--wide">
<div className="page-header">
<h1 className="page-title">{t('users.title')}</h1>
<p className="page-subtitle">{t('users.subtitle')}</p>
</div>
<PageHeader title={t('users.title')} supporting={t('users.subtitle')} />
{/* Tab bar */}
<div className="auth-tab-bar">

View File

@@ -2,8 +2,10 @@ import { useState } from 'react'
import { useParams, useOutletContext } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import ModelSelector from '../components/ModelSelector'
import PageHeader from '../components/PageHeader'
import { CAP_VIDEO } from '../utils/capabilities'
import LoadingSpinner from '../components/LoadingSpinner'
import GenerationProgress from '../components/GenerationProgress'
import ErrorWithTraceLink from '../components/ErrorWithTraceLink'
import MediaHistory from '../components/MediaHistory'
import { videoApi, fileToBase64 } from '../utils/api'
@@ -81,9 +83,7 @@ export default function VideoGen() {
return (
<div className="media-layout">
<div className="media-controls">
<div className="page-header">
<h1 className="page-title"><i className="fas fa-video" /> {t('video.title')}</h1>
</div>
<PageHeader title={<><i className="fas fa-video" /> {t('video.title')}</>} />
<form onSubmit={handleGenerate}>
<div className="form-group">
@@ -147,7 +147,7 @@ export default function VideoGen() {
<div className="media-preview">
<div className="media-result">
{loading ? (
<LoadingSpinner size="lg" />
<GenerationProgress label={t('video.actions.generating')} />
) : error ? (
<ErrorWithTraceLink message={error} />
) : selectedEntry ? (

View File

@@ -64,7 +64,8 @@
--color-modal-backdrop: rgba(8, 11, 17, 0.68);
--color-focus-ring: rgba(136, 192, 208, 0.34);
--color-focus-ring: rgba(136, 192, 208, 0.7); /* was 0.34 - AA-visible */
--color-eyebrow: #d8b48c; /* muted Nord-aurora brass for editorial eyebrows */
/* Data viz — full aurora + frost palette */
--color-data-1: #88c0d0; /* frost cyan */
@@ -99,6 +100,8 @@
--duration-slow: 260ms;
--ease-default: cubic-bezier(0.22, 1, 0.36, 1);
--ease-spring: cubic-bezier(0.22, 1, 0.36, 1);
--duration-reveal: 420ms;
--ease-reveal: cubic-bezier(0.16, 1, 0.3, 1); /* ease-out-expo-ish */
/* Spacing */
--spacing-xs: 0.25rem;
@@ -108,6 +111,7 @@
--spacing-xl: 2rem;
--spacing-2xl: 3rem;
--spacing-3xl: 4rem;
--space-section: clamp(2rem, 1.2rem + 3vw, 3.5rem);
/* Radii — sharp, editorial */
--radius-sm: 3px;
@@ -218,7 +222,8 @@
--color-modal-backdrop: rgba(46, 52, 64, 0.38);
--color-focus-ring: rgba(94, 129, 172, 0.34);
--color-focus-ring: rgba(94, 129, 172, 0.6); /* was 0.34 */
--color-eyebrow: #9a6b3f; /* darker brass for contrast on snow storm */
/* Data viz — muted aurora for light mode */
--color-data-1: #5e81ac;

View File

@@ -100,6 +100,18 @@ function guessTitle(lang, index) {
return index > 0 && extMap[lang] ? base.replace('.', `-${index}.`) : base
}
const LANG_EXT = {
html: 'html', javascript: 'js', js: 'js', typescript: 'ts', ts: 'ts',
jsx: 'jsx', tsx: 'tsx', python: 'py', py: 'py', css: 'css', svg: 'svg',
json: 'json', yaml: 'yaml', yml: 'yaml', go: 'go', rust: 'rs', java: 'java',
markdown: 'md', md: 'md', bash: 'sh', sh: 'sh', sql: 'sql',
}
// File extension for a code artifact's language, for download filenames.
export function extensionForLanguage(lang) {
return LANG_EXT[(lang || '').toLowerCase()] || 'txt'
}
export function getArtifactIcon(type, language) {
if (type === 'image') return 'fa-image'
if (type === 'pdf') return 'fa-file-pdf'

View File

@@ -0,0 +1,9 @@
// Pure time-of-day bucket for the editorial home greeting.
// Returns an i18n key suffix; caller resolves t(`greeting.${key}`).
export function greetingKey(date = new Date()) {
const h = date.getHours()
if (h < 5) return 'night'
if (h < 12) return 'morning'
if (h < 18) return 'afternoon'
return 'evening'
}

View File

@@ -1,6 +1,7 @@
import { marked } from 'marked'
import DOMPurify from 'dompurify'
import hljs from './hljs'
import { copyToClipboard } from './clipboard'
marked.setOptions({
highlight(code, lang) {
@@ -25,3 +26,53 @@ export function highlightAll(element) {
hljs.highlightElement(block)
})
}
// Decorate each (not-yet-enhanced) <pre> code block in `element` with a header
// bar carrying the language label and a copy button. Idempotent: re-running on
// the same DOM (e.g. while streaming) only touches new blocks. Copy clicks are
// handled by a single delegated document listener (registered below).
export function enhanceCodeBlocks(element) {
if (!element) return
element.querySelectorAll('pre:not([data-enhanced])').forEach((pre) => {
pre.setAttribute('data-enhanced', '1')
const code = pre.querySelector('code')
const langMatch = code && code.className.match(/language-(\w+)/)
const lang = langMatch ? langMatch[1] : 'text'
const wrap = document.createElement('div')
wrap.className = 'code-block'
const head = document.createElement('div')
head.className = 'code-block__head'
const label = document.createElement('span')
label.className = 'code-block__lang'
label.textContent = lang
const btn = document.createElement('button')
btn.type = 'button'
btn.className = 'code-copy-btn'
btn.setAttribute('aria-label', 'Copy code')
btn.innerHTML = '<i class="fas fa-copy" aria-hidden="true"></i>'
head.appendChild(label)
head.appendChild(btn)
pre.parentNode.insertBefore(wrap, pre)
wrap.appendChild(head)
wrap.appendChild(pre)
})
}
// One delegated handler for every code-copy button, anywhere in the app.
if (typeof document !== 'undefined' && !window.__codeCopyDelegate) {
window.__codeCopyDelegate = true
document.addEventListener('click', async (e) => {
const btn = e.target.closest?.('.code-copy-btn')
if (!btn) return
const code = btn.closest('.code-block')?.querySelector('pre code')
if (!code) return
const ok = await copyToClipboard(code.innerText)
if (!ok) return
btn.innerHTML = '<i class="fas fa-check" aria-hidden="true"></i>'
btn.classList.add('code-copy-btn--ok')
setTimeout(() => {
btn.innerHTML = '<i class="fas fa-copy" aria-hidden="true"></i>'
btn.classList.remove('code-copy-btn--ok')
}, 2000)
})
}

18
core/http/react-ui/src/utils/section.js vendored Normal file
View File

@@ -0,0 +1,18 @@
import { consoles, consolePaths } from '../components/console/consoleConfig'
// Inline "Create" group from the sidebar (these pages live outside a console).
const CREATE_PATHS = ['/app/chat', '/app/studio', '/app/talk']
// The section/console an app page belongs to, returned as a `nav` i18n key for
// use as the PageHeader eyebrow. Console pages map to their console title
// (Build / Operate); the inline Create group maps to sections.create; any other
// top-level page (Home, Install Models, Account, ...) has no eyebrow.
export function sectionKeyForPath(pathname) {
for (const c of consoles) {
if (consolePaths(c).some(p => pathname === p || pathname.startsWith(p + '/'))) {
return c.titleKey
}
}
if (CREATE_PATHS.some(p => pathname === p || pathname.startsWith(p + '/'))) return 'sections.create'
return null
}

View File

@@ -22,6 +22,7 @@ import (
func main() {
mockBackend := flag.String("mock-backend", "", "path to mock-backend binary")
port := flag.Int("port", 8089, "port to listen on")
host := flag.String("host", "127.0.0.1", "host/address to bind on (use 0.0.0.0 to expose on the network)")
// piiYAML lets a test inject a per-model `pii:` block into the
// auto-generated mock-model.yaml. Used by the middleware end-to-end
// verification (and any future test that wants to exercise per-model
@@ -153,7 +154,7 @@ func main() {
}
// Start server
addr := fmt.Sprintf("127.0.0.1:%d", *port)
addr := fmt.Sprintf("%s:%d", *host, *port)
go func() {
if err := e.Start(addr); err != nil && err != http.ErrServerClosed {
xlog.Error("server error", "error", err)