mirror of
https://github.com/mudler/LocalAI.git
synced 2026-06-20 06:39:01 -04:00
* 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>
187 lines
7.8 KiB
JavaScript
187 lines
7.8 KiB
JavaScript
import { useState, useEffect, useRef, Suspense } from 'react'
|
|
import { Outlet, useLocation, useNavigate } from 'react-router-dom'
|
|
import { useTranslation } from 'react-i18next'
|
|
import Sidebar from './components/Sidebar'
|
|
import OperationsBar from './components/OperationsBar'
|
|
import { ToastContainer, useToast } from './components/Toast'
|
|
import { systemApi } from './utils/api'
|
|
import { useTheme } from './contexts/ThemeContext'
|
|
import { useBranding } from './contexts/BrandingContext'
|
|
import { useAuth } from './context/AuthContext'
|
|
import RouteFallback from './components/RouteFallback'
|
|
import { consoles, consolePaths } from './components/console/consoleConfig'
|
|
|
|
const COLLAPSED_KEY = 'localai_sidebar_collapsed'
|
|
|
|
// The page wrapper is keyed so its transition replays on navigation. Within a
|
|
// console, collapse the key to the console id so the layout (and its rail)
|
|
// persists across item-to-item nav instead of remounting and flashing — only
|
|
// the inner page swaps. Normal routes keep their per-path key.
|
|
function pageTransitionKey(pathname) {
|
|
for (const c of consoles) {
|
|
if (consolePaths(c).some(p => pathname.startsWith(p))) return `console:${c.id}`
|
|
}
|
|
return pathname
|
|
}
|
|
|
|
export default function App() {
|
|
const [sidebarOpen, setSidebarOpen] = useState(false)
|
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => {
|
|
try { return localStorage.getItem(COLLAPSED_KEY) === 'true' } catch (_) { return false }
|
|
})
|
|
const { toasts, addToast, removeToast } = useToast()
|
|
const [version, setVersion] = useState('')
|
|
const location = useLocation()
|
|
const navigate = useNavigate()
|
|
const { theme, toggleTheme } = useTheme()
|
|
const { authEnabled, user } = useAuth()
|
|
const branding = useBranding()
|
|
const { t } = useTranslation('nav')
|
|
const hamburgerRef = useRef(null)
|
|
const isChatRoute = location.pathname.match(/\/chat(\/|$)/) || location.pathname.match(/\/agents\/[^/]+\/chat/)
|
|
|
|
useEffect(() => {
|
|
systemApi.version()
|
|
.then(data => setVersion(typeof data === 'string' ? data : (data?.version || '')))
|
|
.catch(() => {})
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
const handler = (e) => setSidebarCollapsed(e.detail.collapsed)
|
|
window.addEventListener('sidebar-collapse', handler)
|
|
return () => window.removeEventListener('sidebar-collapse', handler)
|
|
}, [])
|
|
|
|
// Scroll to top on route change
|
|
useEffect(() => {
|
|
window.scrollTo(0, 0)
|
|
}, [location.pathname])
|
|
|
|
// Drawer polish: lock body scroll, close on Escape, return focus to the
|
|
// hamburger when the drawer closes. Only engages when the drawer is open;
|
|
// desktop and tablet rail mode are unaffected.
|
|
useEffect(() => {
|
|
if (!sidebarOpen) return
|
|
const prevOverflow = document.body.style.overflow
|
|
document.body.style.overflow = 'hidden'
|
|
const onKey = (e) => { if (e.key === 'Escape') setSidebarOpen(false) }
|
|
window.addEventListener('keydown', onKey)
|
|
return () => {
|
|
document.body.style.overflow = prevOverflow
|
|
window.removeEventListener('keydown', onKey)
|
|
// Restore focus to the trigger so keyboard users land back where
|
|
// they invoked the drawer from.
|
|
hamburgerRef.current?.focus()
|
|
}
|
|
}, [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' : '',
|
|
sidebarCollapsed ? 'sidebar-is-collapsed' : '',
|
|
].filter(Boolean).join(' ')
|
|
|
|
const showAvatar = authEnabled && user
|
|
const accountLabel = user?.name || user?.email || t('account')
|
|
const themeToggleLabel = theme === 'dark' ? t('switchToLightMode') : t('switchToDarkMode')
|
|
|
|
return (
|
|
<div className={layoutClasses}>
|
|
<Sidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} />
|
|
<main className="main-content" {...(sidebarOpen ? { 'aria-hidden': 'true', inert: '' } : {})}>
|
|
<OperationsBar />
|
|
{/* Mobile header — primary actions reachable without opening the
|
|
drawer. Hamburger is the only way to expand the nav on phones;
|
|
theme toggle and account avatar are mirrored from the sidebar
|
|
footer so they remain one tap away. */}
|
|
<header className="mobile-header">
|
|
<button
|
|
ref={hamburgerRef}
|
|
className="hamburger-btn"
|
|
onClick={() => setSidebarOpen(true)}
|
|
aria-label={t('openMenu')}
|
|
aria-expanded={sidebarOpen}
|
|
aria-controls="app-sidebar"
|
|
>
|
|
<i className="fas fa-bars" aria-hidden="true" />
|
|
</button>
|
|
<span className="mobile-title">{branding.instanceName}</span>
|
|
<div className="mobile-header-actions">
|
|
<button
|
|
type="button"
|
|
className="mobile-header-btn"
|
|
onClick={toggleTheme}
|
|
aria-label={themeToggleLabel}
|
|
title={themeToggleLabel}
|
|
>
|
|
<i className={`fas ${theme === 'dark' ? 'fa-sun' : 'fa-moon'}`} aria-hidden="true" />
|
|
</button>
|
|
{showAvatar && (
|
|
<button
|
|
type="button"
|
|
className="mobile-header-btn mobile-header-avatar"
|
|
onClick={() => navigate('/app/account')}
|
|
aria-label={t('accountFor', { name: accountLabel })}
|
|
title={accountLabel}
|
|
>
|
|
{user.avatarUrl ? (
|
|
<img src={user.avatarUrl} alt="" />
|
|
) : (
|
|
<i className="fas fa-user-circle" aria-hidden="true" />
|
|
)}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</header>
|
|
<div className="main-content-inner">
|
|
<div className="page-transition" key={pageTransitionKey(location.pathname)}>
|
|
{/* Per-route Suspense catches React.lazy chunk loads (router.jsx)
|
|
here, inside the App layout. Without it, suspension would bubble
|
|
up to main.jsx's outer boundary and unmount the sidebar/header
|
|
on every navigation. RouteFallback shows a delayed loader so the
|
|
content area isn't blank while a chunk arrives (console pages get
|
|
their own boundary in ConsoleLayout so the rail stays put). */}
|
|
<Suspense fallback={<RouteFallback />}>
|
|
<Outlet context={{ addToast }} />
|
|
</Suspense>
|
|
</div>
|
|
</div>
|
|
{!isChatRoute && (
|
|
<footer className="app-footer">
|
|
<div className="app-footer-inner">
|
|
{version && (
|
|
<span className="app-footer-version">
|
|
{branding.instanceName} <span style={{ fontWeight: 500 }}>{version}</span>
|
|
</span>
|
|
)}
|
|
<div className="app-footer-links">
|
|
<a href="https://github.com/mudler/LocalAI" target="_blank" rel="noopener noreferrer">
|
|
<i className="fab fa-github" /> {t('footer.github')}
|
|
</a>
|
|
<a href="https://localai.io" target="_blank" rel="noopener noreferrer">
|
|
<i className="fas fa-book" /> {t('footer.documentation')}
|
|
</a>
|
|
<a href="https://mudler.pm" target="_blank" rel="noopener noreferrer">
|
|
<i className="fas fa-user" /> {t('footer.author')}
|
|
</a>
|
|
</div>
|
|
<span className="app-footer-copyright">
|
|
© 2023-2026 <a href="https://mudler.pm" target="_blank" rel="noopener noreferrer">Ettore Di Giacinto</a>
|
|
</span>
|
|
</div>
|
|
</footer>
|
|
)}
|
|
</main>
|
|
<ToastContainer toasts={toasts} removeToast={removeToast} />
|
|
</div>
|
|
)
|
|
}
|