mirror of
https://github.com/mudler/LocalAI.git
synced 2026-06-22 23:58:51 -04:00
* feat(ui): add shared DeploymentContext (features + p2p signal) Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * refactor(ui): extract launchAssistantChat shared helper Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(ui): role/mode-aware landing redirect at /app Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(ui): pin Cluster group and collapse Create for cluster admins Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(ui): desktop top navbar with mode pill and admin-via-chat jump Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(ui): admin token-usage meter in the top navbar Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(ui): top-navbar breakpoint handoff + assistant jump from chat page M1: the desktop .top-navbar was hidden at max-width 768px while the .mobile-header only appears at max-width 639px, leaving 640-768px with neither bar so admins lost the mode pill, token meter and admin-via-chat jump. Hide the top bar at 639px instead so it covers every width the rail sidebar is shown and hands off to the mobile-header exactly at 639px. M2: the navbar 'Admin via chat' button wrote localStorage and called navigate('/app/chat'), but when already on the chat page Chat does not remount so its mount-time payload reader never fired and the click was a no-op until reload. The payload consume logic is factored into a shared callback; the launcher now dispatches a localai-open-assistant event that the mounted Chat listens for to re-consume the payload. Mount behavior is unchanged. 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>
189 lines
7.9 KiB
JavaScript
189 lines
7.9 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 TopNavbar from './components/TopNavbar'
|
|
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 />
|
|
<TopNavbar />
|
|
{/* 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>
|
|
)
|
|
}
|