mirror of
https://github.com/mudler/LocalAI.git
synced 2026-06-22 15:49:12 -04:00
feat(ui): role and deployment-mode adaptive UI (landing, sidebar, top navbar) (#10449)
* 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>
This commit is contained in:
100
core/http/react-ui/e2e/role-mode-adaptive.spec.js
Normal file
100
core/http/react-ui/e2e/role-mode-adaptive.spec.js
Normal file
@@ -0,0 +1,100 @@
|
||||
import { test, expect } from './coverage-fixtures.js'
|
||||
|
||||
// These specs stub /api/features and /api/auth/status per cell. The test server
|
||||
// disables auth (isAdmin=true) and reports its own features, so we intercept
|
||||
// before navigation to simulate each role x mode cell.
|
||||
|
||||
function stubFeatures(page, features) {
|
||||
return page.route('**/api/features', route =>
|
||||
route.fulfill({ contentType: 'application/json', body: JSON.stringify(features) }))
|
||||
}
|
||||
|
||||
function stubNoP2P(page) {
|
||||
// P2P token endpoint returns empty -> p2pEnabled=false.
|
||||
return page.route('**/api/p2p/token', route =>
|
||||
route.fulfill({ contentType: 'text/plain', body: '' }))
|
||||
}
|
||||
|
||||
test.describe('Adaptive landing (HomeRoute)', () => {
|
||||
test('admin + distributed redirects /app to Nodes', async ({ page }) => {
|
||||
await stubFeatures(page, { distributed: true })
|
||||
await stubNoP2P(page)
|
||||
await page.goto('/app')
|
||||
await expect(page).toHaveURL(/\/app\/nodes$/)
|
||||
await expect(page.locator('.page-title').first()).toBeVisible({ timeout: 15_000 })
|
||||
})
|
||||
|
||||
test('admin + single-node stays on Home', async ({ page }) => {
|
||||
await stubFeatures(page, { distributed: false })
|
||||
await stubNoP2P(page)
|
||||
await page.goto('/app')
|
||||
await expect(page).toHaveURL(/\/app$/)
|
||||
await expect(page.locator('.home-greeting')).toBeVisible({ timeout: 15_000 })
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Adaptive sidebar', () => {
|
||||
test('distributed pins the Cluster group with Nodes at the top', async ({ page }) => {
|
||||
await stubFeatures(page, { distributed: true })
|
||||
await stubNoP2P(page)
|
||||
await page.goto('/app/chat') // any in-app page so the sidebar is mounted
|
||||
const pinned = page.locator('.sidebar-nav .sidebar-section-items').first()
|
||||
await expect(pinned.getByText('Nodes', { exact: false })).toBeVisible({ timeout: 15_000 })
|
||||
})
|
||||
|
||||
test('single-node does not pin a Cluster group', async ({ page }) => {
|
||||
await stubFeatures(page, { distributed: false })
|
||||
await stubNoP2P(page)
|
||||
await page.goto('/app/chat')
|
||||
// Nodes is reachable only via the Operate rail, not pinned at the top.
|
||||
await expect(page.locator('.sidebar-nav')).toBeVisible({ timeout: 15_000 })
|
||||
await expect(page.locator('.sidebar-nav .sidebar-section-items').first()
|
||||
.getByText('Nodes', { exact: false })).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Top navbar', () => {
|
||||
test('admin sees the mode pill and settings cog', async ({ page }) => {
|
||||
await stubFeatures(page, { distributed: true })
|
||||
await stubNoP2P(page)
|
||||
await page.goto('/app/chat')
|
||||
await expect(page.locator('.top-navbar__mode')).toBeVisible({ timeout: 15_000 })
|
||||
await expect(page.locator('.top-navbar__icon[aria-label]')).not.toHaveCount(0)
|
||||
})
|
||||
|
||||
test('admin-via-chat jump shows when localai_assistant is enabled', async ({ page }) => {
|
||||
await stubFeatures(page, { distributed: false, localai_assistant: true })
|
||||
await stubNoP2P(page)
|
||||
await page.goto('/app/chat')
|
||||
await expect(page.locator('.top-navbar__assistant')).toBeVisible({ timeout: 15_000 })
|
||||
})
|
||||
|
||||
test('admin-via-chat jump hidden when localai_assistant is off', async ({ page }) => {
|
||||
await stubFeatures(page, { distributed: false, localai_assistant: false })
|
||||
await stubNoP2P(page)
|
||||
await page.goto('/app/chat')
|
||||
await expect(page.locator('.top-navbar__assistant')).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Token usage meter', () => {
|
||||
test('renders when admin usage has data', async ({ page }) => {
|
||||
await stubFeatures(page, { distributed: false })
|
||||
await stubNoP2P(page)
|
||||
await page.route('**/api/auth/admin/usage**', route =>
|
||||
route.fulfill({ contentType: 'application/json',
|
||||
body: JSON.stringify({ buckets: [{ total_tokens: 1234 }] }) }))
|
||||
await page.goto('/app/chat')
|
||||
await expect(page.locator('.top-navbar__meter')).toBeVisible({ timeout: 15_000 })
|
||||
})
|
||||
|
||||
test('hidden when admin usage is empty (graceful degrade)', async ({ page }) => {
|
||||
await stubFeatures(page, { distributed: false })
|
||||
await stubNoP2P(page)
|
||||
await page.route('**/api/auth/admin/usage**', route =>
|
||||
route.fulfill({ contentType: 'application/json', body: JSON.stringify({ buckets: [] }) }))
|
||||
await page.goto('/app/chat')
|
||||
await expect(page.locator('.top-navbar')).toBeVisible({ timeout: 15_000 })
|
||||
await expect(page.locator('.top-navbar__meter')).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
@@ -12,6 +12,16 @@
|
||||
"accountSettings": "Account settings",
|
||||
"account": "Account",
|
||||
"accountFor": "Account: {{name}}",
|
||||
"topbar": {
|
||||
"label": "Top bar",
|
||||
"modeDistributed": "Distributed",
|
||||
"modeSwarm": "Swarm",
|
||||
"modeSingle": "Single-node",
|
||||
"pickModel": "Models",
|
||||
"adminViaChat": "Admin via chat",
|
||||
"tokensToday": "Tokens today",
|
||||
"usageDetail": "View usage detail"
|
||||
},
|
||||
"sections": {
|
||||
"create": "Create",
|
||||
"recognition": "Recognition",
|
||||
|
||||
@@ -184,6 +184,50 @@
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
/* Desktop top bar: deployment + admin affordances on wide screens. Hidden on
|
||||
mobile, where .mobile-header carries the equivalent actions. */
|
||||
.top-navbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-sm) var(--spacing-lg);
|
||||
border-bottom: 1px solid var(--color-border-default);
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
.top-navbar__right { display: flex; align-items: center; gap: var(--spacing-sm); }
|
||||
.top-navbar__mode {
|
||||
font-size: 0.75rem;
|
||||
padding: 2px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--color-border-default);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.top-navbar__mode.is-active { color: var(--color-success); border-color: var(--color-success); }
|
||||
.top-navbar__btn {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
font-size: 0.8125rem; padding: 5px 10px; border-radius: 8px;
|
||||
border: 1px solid var(--color-border-default); background: var(--color-bg-tertiary);
|
||||
color: var(--color-text-primary); cursor: pointer;
|
||||
}
|
||||
.top-navbar__icon {
|
||||
width: 32px; height: 32px; display: inline-flex; align-items: center;
|
||||
justify-content: center; border-radius: 8px; border: 1px solid var(--color-border-default);
|
||||
background: var(--color-bg-tertiary); color: var(--color-text-secondary); cursor: pointer;
|
||||
}
|
||||
.top-navbar__avatar img { width: 100%; height: 100%; border-radius: 50%; object-fit: cover; }
|
||||
.top-navbar__meter {
|
||||
display: inline-flex; flex-direction: column; gap: 3px; align-items: flex-start;
|
||||
padding: 4px 10px; border-radius: 8px; border: 1px solid var(--color-border-default);
|
||||
background: var(--color-bg-tertiary); cursor: pointer; min-width: 150px;
|
||||
}
|
||||
.top-navbar__meter-label { font-size: 0.6875rem; color: var(--color-text-secondary); }
|
||||
.top-navbar__meter-bar { width: 100%; height: 5px; border-radius: 3px; background: var(--color-bg-secondary); overflow: hidden; }
|
||||
.top-navbar__meter-bar i { display: block; height: 100%; background: var(--color-primary); }
|
||||
@media (max-width: 639px) {
|
||||
.top-navbar { display: none; }
|
||||
}
|
||||
|
||||
/* Sidebar */
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
|
||||
@@ -3,6 +3,7 @@ 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'
|
||||
@@ -98,6 +99,7 @@ export default function App() {
|
||||
<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
|
||||
|
||||
28
core/http/react-ui/src/components/HomeRoute.jsx
Normal file
28
core/http/react-ui/src/components/HomeRoute.jsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { lazy, Suspense } from 'react'
|
||||
import { Navigate } from 'react-router-dom'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { useDeployment } from '../contexts/DeploymentContext'
|
||||
import { resolveHome } from '../utils/resolveHome'
|
||||
import RouteFallback from './RouteFallback'
|
||||
|
||||
const Home = lazy(() => import('../pages/Home'))
|
||||
|
||||
// Index-route element. Waits for auth + deployment signals to load (so we never
|
||||
// flash the wrong landing), then either renders Home or redirects to the cell's
|
||||
// landing page. Redirecting (rather than rendering Nodes/Chat inline at /app)
|
||||
// keeps each target's own route guard, active-nav state, and deep-linkability.
|
||||
export default function HomeRoute() {
|
||||
const { isAdmin, loading: authLoading } = useAuth()
|
||||
const { distributed, p2pEnabled, loading: deployLoading } = useDeployment()
|
||||
|
||||
if (authLoading || deployLoading) return <RouteFallback />
|
||||
|
||||
const target = resolveHome({ isAdmin, distributed, p2pEnabled })
|
||||
if (target) return <Navigate to={target} replace />
|
||||
|
||||
return (
|
||||
<Suspense fallback={<RouteFallback />}>
|
||||
<Home />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -5,9 +5,11 @@ import ThemeToggle from './ThemeToggle'
|
||||
import LanguageSwitcher from './LanguageSwitcher'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { useBranding } from '../contexts/BrandingContext'
|
||||
import { useDeployment } from '../contexts/DeploymentContext'
|
||||
import { apiUrl } from '../utils/basePath'
|
||||
import { preloadRoute } from '../router'
|
||||
import { consoles, firstVisiblePath, consolePaths } from './console/consoleConfig'
|
||||
import { clusterPinItems, shouldCollapseCreate } from '../utils/sidebarPolicy'
|
||||
|
||||
const COLLAPSED_KEY = 'localai_sidebar_collapsed'
|
||||
const SECTIONS_KEY = 'localai_sidebar_sections'
|
||||
@@ -58,11 +60,13 @@ function NavItem({ item, onClose, collapsed }) {
|
||||
)
|
||||
}
|
||||
|
||||
function loadSectionState() {
|
||||
// Tiers render expanded by default (the redesign favours showing the few
|
||||
// intent groups up front); users can still collapse any tier and the choice
|
||||
// is persisted. Stored values override the defaults so a saved collapse wins.
|
||||
function loadSectionState(collapseCreate = false) {
|
||||
// Tiers render expanded by default; users can collapse any tier and the
|
||||
// choice persists (stored values override defaults). In cluster cells we
|
||||
// start Create collapsed so the pinned cluster group leads - but only when
|
||||
// the user has not already expressed a preference.
|
||||
const defaults = Object.fromEntries(sections.map(s => [s.id, true]))
|
||||
if (collapseCreate) defaults.create = false
|
||||
try {
|
||||
const stored = localStorage.getItem(SECTIONS_KEY)
|
||||
return stored ? { ...defaults, ...JSON.parse(stored) } : defaults
|
||||
@@ -77,20 +81,34 @@ function saveSectionState(state) {
|
||||
|
||||
export default function Sidebar({ isOpen, onClose }) {
|
||||
const { t } = useTranslation('nav')
|
||||
const [features, setFeatures] = useState({})
|
||||
const { isAdmin, authEnabled, user, logout, hasFeature } = useAuth()
|
||||
// Deployment shape (server features + p2p) drives the adaptive sidebar; the
|
||||
// shared context replaces the sidebar's own /api/features fetch so the
|
||||
// landing resolver, navbar, and this policy agree on one snapshot.
|
||||
const deployment = useDeployment()
|
||||
const features = deployment.features
|
||||
// Shared shape for the console gating helpers (consoleConfig.js); in scope for
|
||||
// both the pinned cluster group and the console-tier rendering below.
|
||||
const auth = { isAdmin, authEnabled, hasFeature, features }
|
||||
const collapseCreate = shouldCollapseCreate(auth, deployment)
|
||||
const [collapsed, setCollapsed] = useState(() => {
|
||||
try { return localStorage.getItem(COLLAPSED_KEY) === 'true' } catch (_) { return false }
|
||||
})
|
||||
const [openSections, setOpenSections] = useState(loadSectionState)
|
||||
const { isAdmin, authEnabled, user, logout, hasFeature } = useAuth()
|
||||
const branding = useBranding()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const closeBtnRef = useRef(null)
|
||||
|
||||
// Apply the cluster-cell Create-collapse default once, only when the user has
|
||||
// no stored section preference (so we never override an explicit choice).
|
||||
useEffect(() => {
|
||||
fetch(apiUrl('/api/features')).then(r => r.json()).then(setFeatures).catch(() => {})
|
||||
}, [])
|
||||
if (deployment.loading) return
|
||||
let hasStored = false
|
||||
try { hasStored = !!localStorage.getItem(SECTIONS_KEY) } catch { hasStored = false }
|
||||
if (hasStored || !collapseCreate) return
|
||||
setOpenSections(prev => (prev.create === false ? prev : { ...prev, create: false }))
|
||||
}, [deployment.loading, collapseCreate])
|
||||
|
||||
// Stay in sync with external collapse dispatches (e.g. the chat
|
||||
// page's focus mode). The collapse-toggle button still owns the
|
||||
@@ -157,8 +175,6 @@ export default function Sidebar({ isOpen, onClose }) {
|
||||
}
|
||||
|
||||
const visibleTopItems = topItems.filter(filterItem)
|
||||
// Shared shape for the console gating helpers (consoleConfig.js).
|
||||
const auth = { isAdmin, authEnabled, hasFeature, features }
|
||||
|
||||
// Inline sections (Create) carry no gating; a plain filterItem pass suffices.
|
||||
const getVisibleSectionItems = (section) => section.items.filter(filterItem)
|
||||
@@ -199,6 +215,28 @@ export default function Sidebar({ isOpen, onClose }) {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pinned Cluster quick-access (admin + distributed/p2p). Same gate
|
||||
as the Operate rail; surfaced at the top for cluster operators. */}
|
||||
{(() => {
|
||||
const pinned = clusterPinItems(auth, deployment)
|
||||
if (pinned.length === 0) return null
|
||||
return (
|
||||
<div className="sidebar-section">
|
||||
<div className="sidebar-section-title">{t('operate.cluster')}</div>
|
||||
<div className="sidebar-section-items">
|
||||
{pinned.map(item => (
|
||||
<NavItem
|
||||
key={item.path}
|
||||
item={{ path: item.path, icon: item.icon, labelKey: item.labelKey }}
|
||||
onClose={onClose}
|
||||
collapsed={collapsed}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Collapsible sections */}
|
||||
{sections.map(section => {
|
||||
const visibleItems = getVisibleSectionItems(section)
|
||||
|
||||
96
core/http/react-ui/src/components/TopNavbar.jsx
Normal file
96
core/http/react-ui/src/components/TopNavbar.jsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { useDeployment } from '../contexts/DeploymentContext'
|
||||
import { useTheme } from '../contexts/ThemeContext'
|
||||
import { launchAssistantChat } from '../utils/launchAssistantChat'
|
||||
import TokenUsageMeter from './navbar/TokenUsageMeter'
|
||||
|
||||
// Desktop top bar. Complementary to the mobile-only header in App.jsx: this is
|
||||
// hidden on small screens (see .top-navbar CSS) and shows deployment/admin
|
||||
// affordances on wide screens where the sidebar footer is far from the content.
|
||||
export default function TopNavbar() {
|
||||
const { t } = useTranslation('nav')
|
||||
const navigate = useNavigate()
|
||||
const { isAdmin, authEnabled, user } = useAuth()
|
||||
const { features, distributed, p2pEnabled } = useDeployment()
|
||||
const { theme, toggleTheme } = useTheme()
|
||||
|
||||
const modeLabel = distributed
|
||||
? t('topbar.modeDistributed')
|
||||
: p2pEnabled
|
||||
? t('topbar.modeSwarm')
|
||||
: t('topbar.modeSingle')
|
||||
|
||||
const showAssistantJump = isAdmin && !!features.localai_assistant
|
||||
const showAvatar = authEnabled && user
|
||||
const themeLabel = theme === 'dark' ? t('switchToLightMode') : t('switchToDarkMode')
|
||||
|
||||
return (
|
||||
<div className="top-navbar" role="navigation" aria-label={t('topbar.label')}>
|
||||
<div className="top-navbar__left">
|
||||
{isAdmin && (
|
||||
<span className={`top-navbar__mode ${distributed || p2pEnabled ? 'is-active' : ''}`}>
|
||||
<i className="fas fa-circle-nodes" aria-hidden="true" /> {modeLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="top-navbar__right">
|
||||
{!isAdmin && (
|
||||
<button
|
||||
type="button"
|
||||
className="top-navbar__btn"
|
||||
onClick={() => navigate('/app/chat')}
|
||||
title={t('topbar.pickModel')}
|
||||
>
|
||||
<i className="fas fa-cube" aria-hidden="true" /> {t('topbar.pickModel')}
|
||||
</button>
|
||||
)}
|
||||
{showAssistantJump && (
|
||||
<button
|
||||
type="button"
|
||||
className="top-navbar__btn top-navbar__assistant"
|
||||
onClick={() => launchAssistantChat(navigate)}
|
||||
title={t('topbar.adminViaChat')}
|
||||
>
|
||||
<i className="fas fa-user-shield" aria-hidden="true" /> {t('topbar.adminViaChat')}
|
||||
</button>
|
||||
)}
|
||||
{isAdmin && <TokenUsageMeter />}
|
||||
{isAdmin && (
|
||||
<button
|
||||
type="button"
|
||||
className="top-navbar__icon"
|
||||
onClick={() => navigate('/app/settings')}
|
||||
aria-label={t('items.settings')}
|
||||
title={t('items.settings')}
|
||||
>
|
||||
<i className="fas fa-cog" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="top-navbar__icon"
|
||||
onClick={toggleTheme}
|
||||
aria-label={themeLabel}
|
||||
title={themeLabel}
|
||||
>
|
||||
<i className={`fas ${theme === 'dark' ? 'fa-sun' : 'fa-moon'}`} aria-hidden="true" />
|
||||
</button>
|
||||
{showAvatar && (
|
||||
<button
|
||||
type="button"
|
||||
className="top-navbar__icon top-navbar__avatar"
|
||||
onClick={() => navigate('/app/account')}
|
||||
aria-label={user.name || user.email}
|
||||
title={user.name || user.email}
|
||||
>
|
||||
{user.avatarUrl
|
||||
? <img src={user.avatarUrl} alt="" />
|
||||
: <i className="fas fa-user-circle" aria-hidden="true" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
52
core/http/react-ui/src/components/navbar/TokenUsageMeter.jsx
Normal file
52
core/http/react-ui/src/components/navbar/TokenUsageMeter.jsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { usageApi } from '../../utils/api'
|
||||
|
||||
// Compact admin-only usage glance: today's total tokens, optionally against a
|
||||
// quota cap, linking to the full /app/usage page. Self-contained data fetch so
|
||||
// a usage-API failure cannot break the navbar - it just renders nothing.
|
||||
function sumTotalTokens(res) {
|
||||
const buckets = res?.buckets || res?.usage || (Array.isArray(res) ? res : [])
|
||||
if (!Array.isArray(buckets) || buckets.length === 0) return null
|
||||
return buckets.reduce((s, b) => s + (b.total_tokens || 0), 0)
|
||||
}
|
||||
|
||||
export default function TokenUsageMeter() {
|
||||
const { t } = useTranslation('nav')
|
||||
const navigate = useNavigate()
|
||||
const [tokens, setTokens] = useState(null)
|
||||
const [cap, setCap] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
usageApi.getAdminUsage('day')
|
||||
.then(res => { if (!cancelled) setTokens(sumTotalTokens(res)) })
|
||||
.catch(() => { if (!cancelled) setTokens(null) })
|
||||
usageApi.getMyQuotas()
|
||||
.then(q => { if (!cancelled) setCap(q?.token_limit || q?.tokens?.limit || null) })
|
||||
.catch(() => { if (!cancelled) setCap(null) })
|
||||
return () => { cancelled = true }
|
||||
}, [])
|
||||
|
||||
if (tokens === null) return null
|
||||
|
||||
const pct = cap ? Math.min(100, Math.round((tokens / cap) * 100)) : null
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="top-navbar__meter"
|
||||
onClick={() => navigate('/app/usage')}
|
||||
title={t('topbar.usageDetail')}
|
||||
>
|
||||
<span className="top-navbar__meter-label">
|
||||
{t('topbar.tokensToday')}: {Intl.NumberFormat().format(tokens)}
|
||||
{cap ? ` / ${Intl.NumberFormat().format(cap)}` : ''}
|
||||
</span>
|
||||
{pct !== null && (
|
||||
<span className="top-navbar__meter-bar"><i style={{ width: `${pct}%` }} /></span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
55
core/http/react-ui/src/contexts/DeploymentContext.jsx
Normal file
55
core/http/react-ui/src/contexts/DeploymentContext.jsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { createContext, useContext, useState, useEffect } from 'react'
|
||||
import { apiUrl } from '../utils/basePath'
|
||||
import { p2pApi } from '../utils/api'
|
||||
|
||||
const DeploymentContext = createContext(null)
|
||||
|
||||
// One shared fetch of the deployment-shape signals the adaptive UI keys off:
|
||||
// server features (/api/features) and whether a P2P network token exists.
|
||||
// Components used to fetch /api/features independently (Sidebar, Home); this
|
||||
// centralises it so the landing resolver, sidebar policy, and navbar agree on
|
||||
// one snapshot and we issue a single request.
|
||||
export function DeploymentProvider({ children }) {
|
||||
const [features, setFeatures] = useState({})
|
||||
const [p2pEnabled, setP2pEnabled] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const featuresP = fetch(apiUrl('/api/features'))
|
||||
.then(r => r.json())
|
||||
.catch(() => ({}))
|
||||
// P2P has no /api/features flag: it is "enabled" when a network token
|
||||
// exists (mirrors pages/P2P.jsx). A 404/disabled endpoint throws and we
|
||||
// treat that as not-enabled.
|
||||
const p2pP = p2pApi.getToken()
|
||||
.then(tok => (typeof tok === 'string' ? tok : (tok?.token || '')).trim())
|
||||
.catch(() => '')
|
||||
Promise.all([featuresP, p2pP]).then(([f, tok]) => {
|
||||
if (cancelled) return
|
||||
setFeatures(f || {})
|
||||
setP2pEnabled(!!tok)
|
||||
setLoading(false)
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [])
|
||||
|
||||
const value = {
|
||||
features,
|
||||
distributed: !!features.distributed,
|
||||
p2pEnabled,
|
||||
loading,
|
||||
}
|
||||
|
||||
return (
|
||||
<DeploymentContext.Provider value={value}>
|
||||
{children}
|
||||
</DeploymentContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useDeployment() {
|
||||
const ctx = useContext(DeploymentContext)
|
||||
if (!ctx) throw new Error('useDeployment must be used within DeploymentProvider')
|
||||
return ctx
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { RouterProvider } from 'react-router-dom'
|
||||
import { ThemeProvider } from './contexts/ThemeContext'
|
||||
import { BrandingProvider } from './contexts/BrandingContext'
|
||||
import { AuthProvider } from './context/AuthContext'
|
||||
import { DeploymentProvider } from './contexts/DeploymentContext'
|
||||
import { OperationsProvider } from './contexts/OperationsContext'
|
||||
import { router } from './router'
|
||||
import './i18n'
|
||||
@@ -32,9 +33,11 @@ createRoot(document.getElementById('root')).render(
|
||||
<ThemeProvider>
|
||||
<BrandingProvider>
|
||||
<AuthProvider>
|
||||
<OperationsProvider>
|
||||
<RouterProvider router={router} />
|
||||
</OperationsProvider>
|
||||
<DeploymentProvider>
|
||||
<OperationsProvider>
|
||||
<RouterProvider router={router} />
|
||||
</OperationsProvider>
|
||||
</DeploymentProvider>
|
||||
</AuthProvider>
|
||||
</BrandingProvider>
|
||||
</ThemeProvider>
|
||||
|
||||
@@ -541,58 +541,73 @@ export default function Chat() {
|
||||
updateChatSettings(activeChat.id, { clientMCPServers: next })
|
||||
}, [activeChat, updateChatSettings])
|
||||
|
||||
// Load initial message from home page
|
||||
// Load initial message / assistant launch from the Home page or the navbar
|
||||
// quick-jump. Factored into a callback so both the mount-time reader and the
|
||||
// navbar re-trigger event below consume the same payload through one path.
|
||||
const homeDataProcessed = useRef(false)
|
||||
useEffect(() => {
|
||||
if (homeDataProcessed.current) return
|
||||
const consumeHomeChatData = useCallback(() => {
|
||||
const stored = localStorage.getItem('localai_index_chat_data')
|
||||
if (stored) {
|
||||
homeDataProcessed.current = true
|
||||
try {
|
||||
const data = JSON.parse(stored)
|
||||
localStorage.removeItem('localai_index_chat_data')
|
||||
if (!stored) return
|
||||
try {
|
||||
const data = JSON.parse(stored)
|
||||
localStorage.removeItem('localai_index_chat_data')
|
||||
|
||||
// Two entry shapes from Home:
|
||||
// - "compose-and-send": data.message present → open new chat,
|
||||
// prefill the composer, click submit.
|
||||
// - "open-assistant": no message, just data.localaiAssistant → open
|
||||
// a fresh chat already in admin mode so the wizard can fire.
|
||||
const hasMessage = !!data.message
|
||||
const wantsAssistant = !!data.localaiAssistant
|
||||
// Two entry shapes from Home:
|
||||
// - "compose-and-send": data.message present → open new chat,
|
||||
// prefill the composer, click submit.
|
||||
// - "open-assistant": no message, just data.localaiAssistant → open
|
||||
// a fresh chat already in admin mode so the wizard can fire.
|
||||
const hasMessage = !!data.message
|
||||
const wantsAssistant = !!data.localaiAssistant
|
||||
|
||||
if (hasMessage || wantsAssistant) {
|
||||
let targetChat = activeChat
|
||||
if (data.newChat) {
|
||||
targetChat = addChat(data.model || '', '', data.mcpMode || false)
|
||||
} else {
|
||||
if (data.model && activeChat) {
|
||||
updateChatSettings(activeChat.id, { model: data.model })
|
||||
}
|
||||
if (data.mcpMode && activeChat) {
|
||||
updateChatSettings(activeChat.id, { mcpMode: true })
|
||||
}
|
||||
if (hasMessage || wantsAssistant) {
|
||||
let targetChat = activeChat
|
||||
if (data.newChat) {
|
||||
targetChat = addChat(data.model || '', '', data.mcpMode || false)
|
||||
} else {
|
||||
if (data.model && activeChat) {
|
||||
updateChatSettings(activeChat.id, { model: data.model })
|
||||
}
|
||||
if (data.mcpServers?.length > 0 && targetChat) {
|
||||
updateChatSettings(targetChat.id, { mcpServers: data.mcpServers })
|
||||
}
|
||||
if (data.clientMCPServers?.length > 0 && targetChat) {
|
||||
updateChatSettings(targetChat.id, { clientMCPServers: data.clientMCPServers })
|
||||
}
|
||||
if (wantsAssistant && targetChat) {
|
||||
updateChatSettings(targetChat.id, { localaiAssistant: true })
|
||||
}
|
||||
if (hasMessage) {
|
||||
setInput(data.message)
|
||||
if (data.files) setFiles(data.files)
|
||||
setTimeout(() => {
|
||||
const submitBtn = document.getElementById('chat-submit-btn')
|
||||
submitBtn?.click()
|
||||
}, 100)
|
||||
if (data.mcpMode && activeChat) {
|
||||
updateChatSettings(activeChat.id, { mcpMode: true })
|
||||
}
|
||||
}
|
||||
} catch (_e) { /* ignore */ }
|
||||
}
|
||||
}, [])
|
||||
if (data.mcpServers?.length > 0 && targetChat) {
|
||||
updateChatSettings(targetChat.id, { mcpServers: data.mcpServers })
|
||||
}
|
||||
if (data.clientMCPServers?.length > 0 && targetChat) {
|
||||
updateChatSettings(targetChat.id, { clientMCPServers: data.clientMCPServers })
|
||||
}
|
||||
if (wantsAssistant && targetChat) {
|
||||
updateChatSettings(targetChat.id, { localaiAssistant: true })
|
||||
}
|
||||
if (hasMessage) {
|
||||
setInput(data.message)
|
||||
if (data.files) setFiles(data.files)
|
||||
setTimeout(() => {
|
||||
const submitBtn = document.getElementById('chat-submit-btn')
|
||||
submitBtn?.click()
|
||||
}, 100)
|
||||
}
|
||||
}
|
||||
} catch (_e) { /* ignore */ }
|
||||
}, [activeChat, addChat, updateChatSettings])
|
||||
|
||||
useEffect(() => {
|
||||
if (homeDataProcessed.current) return
|
||||
homeDataProcessed.current = true
|
||||
consumeHomeChatData()
|
||||
}, [consumeHomeChatData])
|
||||
|
||||
// Admins can re-trigger the assistant jump from the navbar while already on
|
||||
// the chat page; navigate('/app/chat') does not remount Chat, so the
|
||||
// mount-time reader above never fires. The launcher dispatches this event
|
||||
// after writing the payload so we re-consume it and open a fresh assistant.
|
||||
useEffect(() => {
|
||||
const onOpenAssistant = () => consumeHomeChatData()
|
||||
window.addEventListener('localai-open-assistant', onOpenAssistant)
|
||||
return () => window.removeEventListener('localai-open-assistant', onOpenAssistant)
|
||||
}, [consumeHomeChatData])
|
||||
|
||||
// Track whether the user is pinned to the bottom. If they scroll up
|
||||
// while a response is streaming, stop forcing them back down.
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useResources } from '../hooks/useResources'
|
||||
import { fileToBase64, backendControlApi, systemApi, modelsApi, mcpApi, nodesApi } from '../utils/api'
|
||||
import { API_CONFIG } from '../utils/config'
|
||||
import { greetingKey } from '../utils/greeting'
|
||||
import { launchAssistantChat } from '../utils/launchAssistantChat'
|
||||
import StatusPill from '../components/StatusPill'
|
||||
import Skeleton from '../components/Skeleton'
|
||||
import SectionHeading from '../components/SectionHeading'
|
||||
@@ -228,16 +229,8 @@ export default function Home() {
|
||||
// requiring an initial message or model selection. Useful when an admin
|
||||
// wants to start the assistant from a cold home page.
|
||||
const openAssistantChat = useCallback(() => {
|
||||
const chatData = {
|
||||
model: selectedModel || '',
|
||||
mcpMode: false,
|
||||
localaiAssistant: true,
|
||||
newChat: true,
|
||||
}
|
||||
localStorage.setItem('localai_index_chat_data', JSON.stringify(chatData))
|
||||
try { localStorage.setItem('localai_assistant_used', '1') } catch { /* ignore */ }
|
||||
launchAssistantChat(navigate, selectedModel)
|
||||
setAssistantUsed(true)
|
||||
navigate('/app/chat')
|
||||
}, [navigate, selectedModel])
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import RequireAdmin from './components/RequireAdmin'
|
||||
import RequireAuth from './components/RequireAuth'
|
||||
import RequireAuthEnabled from './components/RequireAuthEnabled'
|
||||
import RequireFeature from './components/RequireFeature'
|
||||
import HomeRoute from './components/HomeRoute'
|
||||
|
||||
// Pages are code-split: each becomes its own chunk loaded on demand, so a route
|
||||
// no longer drags every other page (and its heavy deps — CodeMirror, the MCP
|
||||
@@ -32,7 +33,7 @@ export function preloadRoute(path) {
|
||||
preloaders[m[1] ?? '']?.().catch(() => { /* network blip — real click will retry */ })
|
||||
}
|
||||
|
||||
const Home = page('', () => import('./pages/Home'))
|
||||
page('', () => import('./pages/Home'))
|
||||
const Chat = page('chat', () => import('./pages/Chat'))
|
||||
const Models = page('models', () => import('./pages/Models'))
|
||||
const Manage = page('manage', () => import('./pages/Manage'))
|
||||
@@ -96,7 +97,7 @@ function Feature({ feature, children }) {
|
||||
}
|
||||
|
||||
const appChildren = [
|
||||
{ index: true, element: <Home /> },
|
||||
{ index: true, element: <HomeRoute /> },
|
||||
{ path: 'chat', element: <Chat /> },
|
||||
{ path: 'chat/:model', element: <Chat /> },
|
||||
{ path: 'image', element: <ImageGen /> },
|
||||
|
||||
19
core/http/react-ui/src/utils/launchAssistantChat.js
vendored
Normal file
19
core/http/react-ui/src/utils/launchAssistantChat.js
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
// Opens a fresh chat already in LocalAI Assistant ("manage") mode. Chat.jsx
|
||||
// reads localai_index_chat_data on mount and enables localaiAssistant for the
|
||||
// new chat. Shared by the Home CTA and the top navbar quick-jump so there is
|
||||
// one definition of how the assistant is launched.
|
||||
export function launchAssistantChat(navigate, model = '') {
|
||||
const chatData = {
|
||||
model: model || '',
|
||||
mcpMode: false,
|
||||
localaiAssistant: true,
|
||||
newChat: true,
|
||||
}
|
||||
try { localStorage.setItem('localai_index_chat_data', JSON.stringify(chatData)) } catch { /* ignore */ }
|
||||
try { localStorage.setItem('localai_assistant_used', '1') } catch { /* ignore */ }
|
||||
navigate('/app/chat')
|
||||
// When already on /app/chat, navigate() does not remount Chat, so its
|
||||
// mount-time reader would never see the payload above. Signal the mounted
|
||||
// Chat to re-consume it; harmless elsewhere since Chat reads on mount anyway.
|
||||
try { window.dispatchEvent(new CustomEvent('localai-open-assistant')) } catch { /* ignore */ }
|
||||
}
|
||||
11
core/http/react-ui/src/utils/resolveHome.js
vendored
Normal file
11
core/http/react-ui/src/utils/resolveHome.js
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
// Pure landing-page resolver for the index route. Returns a target path, or ''
|
||||
// meaning "render the default Home". Admin precedence is distributed > p2p >
|
||||
// plain; non-admins always go to Chat (distributed/p2p are admin-only and
|
||||
// invisible to them). Visibility gates are enforced elsewhere - this only
|
||||
// chooses where /app lands.
|
||||
export function resolveHome({ isAdmin, distributed, p2pEnabled }) {
|
||||
if (!isAdmin) return '/app/chat'
|
||||
if (distributed) return '/app/nodes'
|
||||
if (p2pEnabled) return '/app/p2p'
|
||||
return ''
|
||||
}
|
||||
20
core/http/react-ui/src/utils/sidebarPolicy.js
vendored
Normal file
20
core/http/react-ui/src/utils/sidebarPolicy.js
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
import { operateConsole, isConsoleItemVisible } from '../components/console/consoleConfig'
|
||||
|
||||
// The Operate > Cluster group, surfaced as a pinned top-of-sidebar quick-access
|
||||
// group when the admin is running a cluster (NATS-distributed) or a P2P swarm.
|
||||
// Items are filtered through the SAME gate as everywhere else, so e.g. in a
|
||||
// p2p-only deployment Nodes/Scheduling (feature: 'distributed') drop out and
|
||||
// only Swarm remains. Returns [] when the pin does not apply.
|
||||
export function clusterPinItems(auth, deployment) {
|
||||
if (!auth.isAdmin) return []
|
||||
if (!deployment.distributed && !deployment.p2pEnabled) return []
|
||||
const group = operateConsole.groups.find(g => g.titleKey === 'operate.cluster')
|
||||
if (!group) return []
|
||||
return group.items.filter(item => isConsoleItemVisible(item, auth))
|
||||
}
|
||||
|
||||
// In the cluster cells the Create group defaults collapsed so the pinned
|
||||
// cluster group leads. Users can still expand it; their stored choice wins.
|
||||
export function shouldCollapseCreate(auth, deployment) {
|
||||
return !!auth.isAdmin && (!!deployment.distributed || !!deployment.p2pEnabled)
|
||||
}
|
||||
Reference in New Issue
Block a user