mirror of
https://github.com/mudler/LocalAI.git
synced 2026-06-19 06:09:07 -04:00
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:
48
core/http/react-ui/e2e/design-system.spec.js
Normal file
48
core/http/react-ui/e2e/design-system.spec.js
Normal 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)
|
||||
})
|
||||
})
|
||||
31
core/http/react-ui/e2e/home-redesign.spec.js
Normal file
31
core/http/react-ui/e2e/home-redesign.spec.js
Normal 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)
|
||||
})
|
||||
})
|
||||
3630
core/http/react-ui/package-lock.json
generated
3630
core/http/react-ui/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -71,7 +71,8 @@
|
||||
},
|
||||
"actions": {
|
||||
"copy": "Kopieren",
|
||||
"regenerate": "Neu generieren"
|
||||
"regenerate": "Neu generieren",
|
||||
"jumpToLatest": "Jump to latest"
|
||||
},
|
||||
"streaming": {
|
||||
"transferring": "Modell wird übertragen...",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"refImagesAdded_other": "{{count}} Bilder hinzugefügt"
|
||||
},
|
||||
"actions": {
|
||||
"view": "View",
|
||||
"generate": "Generieren",
|
||||
"generating": "Generieren..."
|
||||
},
|
||||
|
||||
@@ -71,7 +71,8 @@
|
||||
},
|
||||
"actions": {
|
||||
"copy": "Copy",
|
||||
"regenerate": "Regenerate"
|
||||
"regenerate": "Regenerate",
|
||||
"jumpToLatest": "Jump to latest"
|
||||
},
|
||||
"streaming": {
|
||||
"transferring": "Transferring model...",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"refImagesAdded_other": "{{count}} images added"
|
||||
},
|
||||
"actions": {
|
||||
"view": "View",
|
||||
"generate": "Generate",
|
||||
"generating": "Generating..."
|
||||
},
|
||||
|
||||
@@ -71,7 +71,8 @@
|
||||
},
|
||||
"actions": {
|
||||
"copy": "Copiar",
|
||||
"regenerate": "Regenerar"
|
||||
"regenerate": "Regenerar",
|
||||
"jumpToLatest": "Jump to latest"
|
||||
},
|
||||
"streaming": {
|
||||
"transferring": "Transfiriendo modelo...",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"refImagesAdded_other": "{{count}} imágenes añadidas"
|
||||
},
|
||||
"actions": {
|
||||
"view": "View",
|
||||
"generate": "Generar",
|
||||
"generating": "Generando..."
|
||||
},
|
||||
|
||||
@@ -71,7 +71,8 @@
|
||||
},
|
||||
"actions": {
|
||||
"copy": "Salin",
|
||||
"regenerate": "Hasilkan ulang"
|
||||
"regenerate": "Hasilkan ulang",
|
||||
"jumpToLatest": "Jump to latest"
|
||||
},
|
||||
"streaming": {
|
||||
"transferring": "Mentransfer model...",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"refImagesAdded_other": "{{count}} gambar ditambahkan"
|
||||
},
|
||||
"actions": {
|
||||
"view": "View",
|
||||
"generate": "Hasilkan",
|
||||
"generating": "Menghasilkan..."
|
||||
},
|
||||
|
||||
@@ -71,7 +71,8 @@
|
||||
},
|
||||
"actions": {
|
||||
"copy": "Copia",
|
||||
"regenerate": "Rigenera"
|
||||
"regenerate": "Rigenera",
|
||||
"jumpToLatest": "Torna in fondo"
|
||||
},
|
||||
"streaming": {
|
||||
"transferring": "Trasferimento del modello...",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"refImagesAdded_other": "{{count}} immagini aggiunte"
|
||||
},
|
||||
"actions": {
|
||||
"view": "Visualizza",
|
||||
"generate": "Genera",
|
||||
"generating": "Generazione..."
|
||||
},
|
||||
|
||||
@@ -71,7 +71,8 @@
|
||||
},
|
||||
"actions": {
|
||||
"copy": "복사",
|
||||
"regenerate": "다시 생성"
|
||||
"regenerate": "다시 생성",
|
||||
"jumpToLatest": "Jump to latest"
|
||||
},
|
||||
"streaming": {
|
||||
"transferring": "모델 전송 중...",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"refImagesAdded_other": "이미지 {{count}}개 추가됨"
|
||||
},
|
||||
"actions": {
|
||||
"view": "View",
|
||||
"generate": "생성",
|
||||
"generating": "생성 중..."
|
||||
},
|
||||
|
||||
@@ -71,7 +71,8 @@
|
||||
},
|
||||
"actions": {
|
||||
"copy": "复制",
|
||||
"regenerate": "重新生成"
|
||||
"regenerate": "重新生成",
|
||||
"jumpToLatest": "Jump to latest"
|
||||
},
|
||||
"streaming": {
|
||||
"transferring": "正在传输模型...",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"refImagesAdded_other": "已添加 {{count}} 张图像"
|
||||
},
|
||||
"actions": {
|
||||
"view": "View",
|
||||
"generate": "生成",
|
||||
"generating": "生成中..."
|
||||
},
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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' : '',
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
13
core/http/react-ui/src/components/EmptyState.jsx
Normal file
13
core/http/react-ui/src/components/EmptyState.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
29
core/http/react-ui/src/components/GenerationProgress.jsx
Normal file
29
core/http/react-ui/src/components/GenerationProgress.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
55
core/http/react-ui/src/components/Lightbox.jsx
Normal file
55
core/http/react-ui/src/components/Lightbox.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
24
core/http/react-ui/src/components/PageHeader.jsx
Normal file
24
core/http/react-ui/src/components/PageHeader.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
3
core/http/react-ui/src/components/SectionHeading.jsx
Normal file
3
core/http/react-ui/src/components/SectionHeading.jsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function SectionHeading({ children, className = '' }) {
|
||||
return <h3 className={`section-heading ${className}`.trim()}>{children}</h3>
|
||||
}
|
||||
16
core/http/react-ui/src/components/Skeleton.jsx
Normal file
16
core/http/react-ui/src/components/Skeleton.jsx
Normal 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"
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
21
core/http/react-ui/src/components/StatusPill.jsx
Normal file
21
core/http/react-ui/src/components/StatusPill.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
106
core/http/react-ui/src/components/VoiceVisualizer.jsx
Normal file
106
core/http/react-ui/src/components/VoiceVisualizer.jsx
Normal 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" />
|
||||
}
|
||||
8
core/http/react-ui/src/hooks/useStagger.js
vendored
Normal file
8
core/http/react-ui/src/hooks/useStagger.js
vendored
Normal 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 }
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)' }}>
|
||||
|
||||
@@ -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')}>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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)' }}>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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')}>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)' }}>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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' }}>
|
||||
|
||||
@@ -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)' }}>
|
||||
|
||||
@@ -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)' }}>
|
||||
|
||||
@@ -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)' }}>
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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')}>
|
||||
|
||||
@@ -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' }}>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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;
|
||||
|
||||
12
core/http/react-ui/src/utils/artifacts.js
vendored
12
core/http/react-ui/src/utils/artifacts.js
vendored
@@ -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'
|
||||
|
||||
9
core/http/react-ui/src/utils/greeting.js
vendored
Normal file
9
core/http/react-ui/src/utils/greeting.js
vendored
Normal 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'
|
||||
}
|
||||
51
core/http/react-ui/src/utils/markdown.js
vendored
51
core/http/react-ui/src/utils/markdown.js
vendored
@@ -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
18
core/http/react-ui/src/utils/section.js
vendored
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user