feat(ui): console-based navigation + drop-in API endpoint section (#10377)

* feat(ui): restructure sidebar into Create/Recognition/Build tiers

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(ui): preserve exact sidebar gating for agent items and fine-tune/quantize

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* i18n(ui): add nav tier + console keys to all locales

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(ui): add grouped admin console via pathless layout route

Wrap the existing admin pages in a pathless AdminConsoleLayout route so
they keep their exact flat URLs while gaining a grouped left rail
(Inference / Cluster / Observability / Access / System). Rail item gating
mirrors the sidebar (adminOnly / authOnly / feature + /api/features). The
layout forwards the App-level outlet context (addToast) to the wrapped
pages, which read it via useOutletContext().

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(ui): fold Audio Transform into Studio as a tab

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(ui): update e2e specs for tiered nav + admin console

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(ui): gate embedded Studio transform view on audio_transform feature

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(ui): visual polish + console-ize Build/Recognition tiers

Generalize the one-off admin console into a reusable ConsoleLayout driven by
a shared consoleConfig (single source of truth for the rail, its gating, and
the sidebar entry that opens it — removes the prior rail/sidebar drift).

- Promote Install Models to the top menu next to Home.
- Build and Operate are now console tiers (secondary rail); Create stays inline.
- Fold Recognition (Faces/Voices) into the Build console as a group alongside
  Automation and Training so it no longer feels split off.
- Style the console rail as a panel (header, grouped dividers, rounded active
  pills) with a hover nudge; sidebar items become inset rounded pills. The rail
  slide-in plays only when entering a console, not on item-to-item sub-nav
  (which remounts the layout), so switching no longer flashes the menu. All
  token-based (light + dark), respects reduced-motion.
- Add a delayed RouteFallback loader so lazy routes no longer flash blank;
  scoped inside ConsoleLayout so the rail stays put while the body loads.
- Update e2e specs for the new structure (.console-* classes, console entries).

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(ui): persist console layout across sub-nav + add drop-in endpoint section

- Keep the page-transition key stable within a console (derived from the
  shared console config) so the ConsoleLayout and its rail persist across
  item-to-item navigation instead of remounting — fixes the submenu flash.
  Cache /api/features across mounts and play the rail entrance animation only
  when actually entering a console.
- Add a "One endpoint, every API" section to Home: leads with LocalAI's own
  native API (images, video, realtime voice over WebRTC/WS, depth, object
  detection, rerank, audio/TTS, face & voice recognition) plus a Full API
  reference link, then the drop-in compatibility layer (OpenAI, Anthropic,
  Ollama, OpenAI Responses) with the live copyable base URL. All 7 locales.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(ui): revert Middleware nav label rename (keep Middleware in all locales)

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
LocalAI [bot]
2026-06-18 00:09:17 +02:00
committed by GitHub
parent 9b57dcb721
commit 5ac864dbed
38 changed files with 990 additions and 195 deletions

View File

@@ -0,0 +1,28 @@
import { test, expect } from './coverage-fixtures.js'
test.describe('Admin console', () => {
test('admin pages render inside the grouped console rail', async ({ page }) => {
await page.goto('/app/backends')
const rail = page.locator('.console-rail')
await expect(rail).toBeVisible()
for (const group of ['Inference', 'Cluster', 'Observability', 'Access', 'System']) {
await expect(rail.locator('.console-group-title', { hasText: group })).toBeVisible()
}
})
test('console rail cross-navigates between admin pages', async ({ page }) => {
await page.goto('/app/backends')
const settings = page.locator('.console-rail a.nav-item[href="/app/settings"]')
await expect(settings).toBeVisible()
await settings.click()
await expect(page).toHaveURL(/\/app\/settings/)
// Rail persists across admin navigation (layout route, not per-page chrome)
await expect(page.locator('.console-rail')).toBeVisible()
})
test('rail links to external API docs', async ({ page }) => {
await page.goto('/app/settings')
const api = page.locator('.console-rail a[href$="/swagger/index.html"]')
await expect(api).toBeVisible()
})
})

View File

@@ -10,7 +10,7 @@ test.describe('Collections page', () => {
await expect(page).toHaveURL(/\/app\/collections$/)
await expect(page.getByRole('heading', { name: 'Knowledge Base' })).toBeVisible()
await expect(page.getByText(/No collections yet/i)).toBeVisible()
await expect(page.getByRole('button').filter({ hasText: 'Create' })).toBeVisible()
await expect(page.locator('button.btn-primary').filter({ hasText: 'Create' })).toBeVisible()
})
test('new-collection name field accepts input', async ({ page }) => {

View File

@@ -130,7 +130,7 @@ test.describe('Import form UX — Batch B3 (Power mode tabs)', () => {
await page.locator('[data-testid="mode-power"]').click()
await page.locator('[data-testid="power-tab-yaml"]').click()
await expect(page.locator('input[placeholder*="q4_k_m"]')).toHaveCount(0)
await expect(page.locator('button', { hasText: /^\s*Create$/ })).toBeVisible()
await expect(page.locator('button.btn-primary', { hasText: /^\s*Create$/ })).toBeVisible()
})
test('B3 — powerTab persists across reload', async ({ page }) => {

View File

@@ -6,21 +6,51 @@ test.describe('Navigation', () => {
await expect(page).toHaveURL(/\/app/)
})
test('/app shows home page with LocalAI title', async ({ page }) => {
test('/app shows the home page', async ({ page }) => {
await page.goto('/app')
await expect(page.locator('.sidebar')).toBeVisible()
await expect(page.locator('.home-page')).toBeVisible()
})
test('sidebar traces link navigates to /app/traces', async ({ page }) => {
test('top menu exposes Home and Install Models', async ({ page }) => {
await page.goto('/app')
// Expand the "System" collapsible section so the traces link is visible
const systemSection = page.locator('button.sidebar-section-toggle', { hasText: 'System' })
await systemSection.click()
const tracesLink = page.locator('a.nav-item[href="/app/traces"]')
await expect(tracesLink).toBeVisible()
await tracesLink.click()
await expect(page).toHaveURL(/\/app\/traces/)
await expect(page.getByRole('heading', { name: 'Traces', exact: true })).toBeVisible()
await expect(page.locator('.sidebar-nav a.nav-item[href="/app"]')).toBeVisible()
await expect(page.locator('.sidebar-nav a.nav-item[href="/app/models"]')).toBeVisible()
})
test('Create stays an inline tier with Chat, Studio and Talk', async ({ page }) => {
await page.goto('/app')
await expect(page.locator('.sidebar-section-title', { hasText: 'Create' })).toBeVisible()
await expect(page.locator('.sidebar-nav a.nav-item[href="/app/chat"]')).toBeVisible()
await expect(page.locator('.sidebar-nav a.nav-item[href="/app/studio"]')).toBeVisible()
await expect(page.locator('.sidebar-nav a.nav-item[href="/app/talk"]')).toBeVisible()
})
test('Build is a single entry that opens the Build console', async ({ page }) => {
await page.goto('/app')
const build = page.locator('.sidebar-nav a.nav-item', { hasText: 'Build' })
await expect(build).toBeVisible()
await build.click()
await expect(page.locator('.console-rail .console-rail-header', { hasText: 'Build' })).toBeVisible()
})
test('Operate is a single entry that opens the admin console', async ({ page }) => {
await page.goto('/app')
const operate = page.locator('.sidebar-nav a.nav-item', { hasText: 'Operate' })
await expect(operate).toBeVisible()
await operate.click()
await expect(page.locator('.console-rail .console-rail-header', { hasText: 'Operate' })).toBeVisible()
})
test('Build console groups Automation, Training and Recognition', async ({ page }) => {
await page.goto('/app/agents')
const rail = page.locator('.console-rail')
await expect(rail).toBeVisible()
for (const group of ['Automation', 'Training', 'Recognition']) {
await expect(rail.locator('.console-group-title', { hasText: group })).toBeVisible()
}
// Recognition (Faces/Voices) and Training (Fine-tune/Quantize) live here now.
await expect(rail.locator('a.nav-item[href="/app/fine-tune"]')).toBeVisible()
await expect(rail.locator('a.nav-item[href="/app/face"]')).toBeVisible()
})
})

View File

@@ -0,0 +1,9 @@
import { test, expect } from './coverage-fixtures.js'
test.describe('Studio - Transform', () => {
test('Studio exposes a Transform tab that renders Audio Transform', async ({ page }) => {
await page.goto('/app/studio?tab=transform')
await expect(page.locator('.studio-tab', { hasText: 'Transform' })).toBeVisible()
await expect(page.locator('h1.page-title', { hasText: 'Audio Transform' })).toBeVisible({ timeout: 15_000 })
})
})

View File

@@ -73,12 +73,12 @@ test.describe('Usage page — single-user no-auth mode', () => {
page.usageHits = () => usageHits
})
test('Usage entry is visible in sidebar without auth', async ({ page }) => {
await page.goto('/app')
const systemSection = page.locator('button.sidebar-section-toggle', { hasText: 'System' })
await systemSection.click()
const usageLink = page.locator('a.nav-item[href="/app/usage"]')
await expect(usageLink).toBeVisible()
test('Usage entry is visible in the console without auth', async ({ page }) => {
// Usage lives in the Operate admin-console rail under Observability. In
// no-auth (single-user) mode isAdmin is true, so the console renders and
// the Usage rail link is reachable.
await page.goto('/app/usage')
await expect(page.locator('.console-rail a.nav-item[href="/app/usage"]')).toBeVisible()
})
test('navigating to /app/usage renders the dashboard with local-user data', async ({ page }) => {

View File

@@ -28,13 +28,12 @@ test.describe('Users tab — single-user no-auth mode', () => {
)
})
test('sidebar does not list Users entry', async ({ page }) => {
await page.goto('/app')
const systemSection = page.locator('button.sidebar-section-toggle', { hasText: 'System' })
await systemSection.click()
// The Users page link uses /app/users; if Sidebar's authOnly gate
// regresses (or someone removes the flag), this assertion fails.
const usersLink = page.locator('a.nav-item[href="/app/users"]')
test('console does not list Users entry without auth', async ({ page }) => {
// Users lives in the Operate console rail (authOnly gate). With auth off
// the rail must not list it. /app/backends is an admin console page,
// reachable because no-auth ⇒ isAdmin.
await page.goto('/app/backends')
const usersLink = page.locator('.console-rail a.nav-item[href="/app/users"]')
await expect(usersLink).toHaveCount(0)
})
@@ -64,11 +63,10 @@ test.describe('Users tab — auth on', () => {
)
})
test('sidebar lists Users entry when auth is on', async ({ page }) => {
await page.goto('/app')
const systemSection = page.locator('button.sidebar-section-toggle', { hasText: 'System' })
await systemSection.click()
const usersLink = page.locator('a.nav-item[href="/app/users"]')
test('console lists Users entry when auth is on', async ({ page }) => {
// With auth on and an admin viewer the console rail lists Users.
await page.goto('/app/backends')
const usersLink = page.locator('.console-rail a.nav-item[href="/app/users"]')
await expect(usersLink).toBeVisible()
})
})

View File

@@ -62,5 +62,14 @@
"docs": "Dokumentation",
"noModelsTitle": "Keine Modelle verfügbar",
"noModelsBody": "Es sind noch keine Modelle installiert. Bitten Sie Ihren Administrator, Modelle einzurichten, damit Sie mit dem Chatten beginnen können."
},
"connect": {
"title": "Ein Endpunkt, jede API",
"subtitle": "LocalAI bietet eine eigene, vollständige API — Bild- und Videoerzeugung, Tiefe, Objekterkennung, Reranking, Audio, Gesichts- und Spracherkennung sowie Echtzeit-Sprache über WebRTC und WebSocket. Darüber hinaus sorgt eine Drop-in-Kompatibilitätsschicht dafür, dass jede für OpenAI, Anthropic, Ollama oder OpenAI Responses gebaute App unverändert funktioniert.",
"nativeTitle": "Native API",
"compatTitle": "Drop-in-Kompatibilität",
"apiReference": "Vollständige API-Referenz",
"copy": "Kopieren",
"copied": "Kopiert"
}
}

View File

@@ -4,7 +4,8 @@
"images": "Bilder",
"video": "Video",
"tts": "TTS",
"sound": "Audio"
"sound": "Audio",
"transform": "Transform"
}
},
"image": {

View File

@@ -13,10 +13,16 @@
"account": "Konto",
"accountFor": "Konto: {{name}}",
"sections": {
"tools": "Werkzeuge",
"enhance": "Verbessern",
"biometrics": "Biometrie",
"agents": "Agenten",
"create": "Erstellen",
"recognition": "Erkennung",
"build": "Entwickeln",
"operate": "Betrieb"
},
"operate": {
"inference": "Inference",
"cluster": "Cluster",
"observability": "Observability",
"access": "Access",
"system": "System"
},
"items": {
@@ -27,6 +33,11 @@
"talk": "Sprechen",
"fineTune": "Fine-Tuning (Experimentell)",
"quantize": "Quantisierung (Experimentell)",
"faces": "Gesichter",
"voices": "Stimmen",
"jobs": "Aufgaben",
"operate": "Verwaltung",
"host": "Host",
"audioTransform": "Audio transformieren",
"faceRecognition": "Gesichtserkennung",
"voiceRecognition": "Spracherkennung",
@@ -42,12 +53,17 @@
"swarm": "Swarm",
"system": "System",
"settings": "Einstellungen",
"api": "API"
"api": "API",
"middleware": "Middleware"
},
"footer": {
"github": "GitHub",
"documentation": "Dokumentation",
"author": "Autor",
"copyright": "© 2023-{{year}} {{author}}"
},
"console": {
"automation": "Automatisierung",
"training": "Training"
}
}

View File

@@ -62,5 +62,14 @@
"docs": "Docs",
"noModelsTitle": "No Models Available",
"noModelsBody": "There are no models installed yet. Ask your administrator to set up models so you can start chatting."
},
"connect": {
"title": "One endpoint, every API",
"subtitle": "LocalAI serves its own full API — image & video generation, depth, object detection, reranking, audio, face & voice recognition, and realtime voice over WebRTC and WebSocket. On top of that, a drop-in compatibility layer lets any app built for OpenAI, Anthropic, Ollama or OpenAI Responses talk to it unchanged.",
"nativeTitle": "Native API",
"compatTitle": "Drop-in compatibility",
"apiReference": "Full API reference",
"copy": "Copy",
"copied": "Copied"
}
}

View File

@@ -4,7 +4,8 @@
"images": "Images",
"video": "Video",
"tts": "TTS",
"sound": "Sound"
"sound": "Sound",
"transform": "Transform"
}
},
"image": {

View File

@@ -13,10 +13,16 @@
"account": "Account",
"accountFor": "Account: {{name}}",
"sections": {
"tools": "Tools",
"enhance": "Enhance",
"biometrics": "Biometrics",
"agents": "Agents",
"create": "Create",
"recognition": "Recognition",
"build": "Build",
"operate": "Operate"
},
"operate": {
"inference": "Inference",
"cluster": "Cluster",
"observability": "Observability",
"access": "Access",
"system": "System"
},
"items": {
@@ -25,8 +31,13 @@
"chat": "Chat",
"studio": "Studio",
"talk": "Talk",
"fineTune": "Fine-Tune (Experimental)",
"quantize": "Quantize (Experimental)",
"fineTune": "Fine-Tune",
"quantize": "Quantize",
"faces": "Faces",
"voices": "Voices",
"jobs": "Jobs",
"operate": "Admin",
"host": "Host",
"audioTransform": "Audio Transform",
"faceRecognition": "Face Recognition",
"voiceRecognition": "Voice Recognition",
@@ -50,5 +61,9 @@
"documentation": "Documentation",
"author": "Author",
"copyright": "© 2023-{{year}} {{author}}"
},
"console": {
"automation": "Automation",
"training": "Training"
}
}

View File

@@ -62,5 +62,14 @@
"docs": "Documentación",
"noModelsTitle": "No hay modelos disponibles",
"noModelsBody": "No hay modelos instalados aún. Pídele a tu administrador que configure modelos para que puedas empezar a chatear."
},
"connect": {
"title": "Un endpoint, todas las APIs",
"subtitle": "LocalAI ofrece su propia API completa: generación de imágenes y vídeo, profundidad, detección de objetos, reranking, audio, reconocimiento facial y de voz, y voz en tiempo real por WebRTC y WebSocket. Además, una capa de compatibilidad directa permite que cualquier app creada para OpenAI, Anthropic, Ollama u OpenAI Responses funcione sin cambios.",
"nativeTitle": "API nativa",
"compatTitle": "Compatibilidad directa",
"apiReference": "Referencia completa de la API",
"copy": "Copiar",
"copied": "Copiado"
}
}

View File

@@ -4,7 +4,8 @@
"images": "Imágenes",
"video": "Video",
"tts": "TTS",
"sound": "Sonido"
"sound": "Sonido",
"transform": "Transform"
}
},
"image": {

View File

@@ -13,11 +13,17 @@
"account": "Cuenta",
"accountFor": "Cuenta: {{name}}",
"sections": {
"tools": "Herramientas",
"enhance": "Mejorar",
"biometrics": "Biometría",
"agents": "Agentes",
"system": "Sistema"
"create": "Crear",
"recognition": "Reconocimiento",
"build": "Construir",
"operate": "Operar"
},
"operate": {
"inference": "Inference",
"cluster": "Cluster",
"observability": "Observability",
"access": "Access",
"system": "System"
},
"items": {
"home": "Inicio",
@@ -27,6 +33,11 @@
"talk": "Hablar",
"fineTune": "Ajuste fino (Experimental)",
"quantize": "Cuantización (Experimental)",
"faces": "Rostros",
"voices": "Voces",
"jobs": "Trabajos",
"operate": "Administración",
"host": "Host",
"audioTransform": "Transformar audio",
"faceRecognition": "Reconocimiento facial",
"voiceRecognition": "Reconocimiento de voz",
@@ -42,12 +53,17 @@
"swarm": "Swarm",
"system": "Sistema",
"settings": "Configuración",
"api": "API"
"api": "API",
"middleware": "Middleware"
},
"footer": {
"github": "GitHub",
"documentation": "Documentación",
"author": "Autor",
"copyright": "© 2023-{{year}} {{author}}"
},
"console": {
"automation": "Automatización",
"training": "Entrenamiento"
}
}

View File

@@ -62,5 +62,14 @@
"docs": "Dokumentasi",
"noModelsTitle": "Tidak Ada Model yang Tersedia",
"noModelsBody": "Belum ada model yang terinstal. Hubungi administrator Anda untuk menyiapkan model agar Anda dapat mulai mengobrol."
},
"connect": {
"title": "Satu endpoint, semua API",
"subtitle": "LocalAI menyediakan API miliknya sendiri yang lengkap — pembuatan gambar & video, depth, deteksi objek, reranking, audio, pengenalan wajah & suara, serta suara realtime melalui WebRTC dan WebSocket. Di atas itu, lapisan kompatibilitas drop-in membuat aplikasi apa pun yang dibuat untuk OpenAI, Anthropic, Ollama, atau OpenAI Responses bekerja tanpa perubahan.",
"nativeTitle": "API native",
"compatTitle": "Kompatibilitas drop-in",
"apiReference": "Referensi API lengkap",
"copy": "Salin",
"copied": "Disalin"
}
}
}

View File

@@ -4,7 +4,8 @@
"images": "Gambar",
"video": "Video",
"tts": "TTS",
"sound": "Suara"
"sound": "Suara",
"transform": "Transform"
}
},
"image": {

View File

@@ -13,11 +13,17 @@
"account": "Akun",
"accountFor": "Akun: {{name}}",
"sections": {
"tools": "Peralatan",
"enhance": "Peningkatan",
"biometrics": "Biometrik",
"agents": "Agen",
"system": "Sistem"
"create": "Buat",
"recognition": "Pengenalan",
"build": "Bangun",
"operate": "Operasikan"
},
"operate": {
"inference": "Inference",
"cluster": "Cluster",
"observability": "Observability",
"access": "Access",
"system": "System"
},
"items": {
"home": "Beranda",
@@ -27,6 +33,11 @@
"talk": "Bicara",
"fineTune": "Fine-Tune (Eksperimental)",
"quantize": "Kuantisasi (Eksperimental)",
"faces": "Wajah",
"voices": "Suara",
"jobs": "Pekerjaan",
"operate": "Admin",
"host": "Host",
"audioTransform": "Transformasi Audio",
"faceRecognition": "Pengenalan Wajah",
"voiceRecognition": "Pengenalan Suara",
@@ -50,5 +61,9 @@
"documentation": "Dokumentasi",
"author": "Penulis",
"copyright": "© 2023-{{year}} {{author}}"
},
"console": {
"automation": "Otomasi",
"training": "Pelatihan"
}
}
}

View File

@@ -62,5 +62,14 @@
"docs": "Documentazione",
"noModelsTitle": "Nessun modello disponibile",
"noModelsBody": "Non ci sono ancora modelli installati. Chiedi al tuo amministratore di configurare i modelli per iniziare a chattare."
},
"connect": {
"title": "Un endpoint, ogni API",
"subtitle": "LocalAI offre una propria API completa: generazione di immagini e video, profondità, rilevamento oggetti, reranking, audio, riconoscimento del volto e della voce e voce in tempo reale via WebRTC e WebSocket. In più, un livello di compatibilità drop-in fa funzionare senza modifiche qualsiasi app creata per OpenAI, Anthropic, Ollama o OpenAI Responses.",
"nativeTitle": "API nativa",
"compatTitle": "Compatibilità drop-in",
"apiReference": "Riferimento API completo",
"copy": "Copia",
"copied": "Copiato"
}
}

View File

@@ -4,7 +4,8 @@
"images": "Immagini",
"video": "Video",
"tts": "TTS",
"sound": "Audio"
"sound": "Audio",
"transform": "Transform"
}
},
"image": {

View File

@@ -13,11 +13,17 @@
"account": "Account",
"accountFor": "Account: {{name}}",
"sections": {
"tools": "Strumenti",
"enhance": "Migliora",
"biometrics": "Biometria",
"agents": "Agenti",
"system": "Sistema"
"create": "Crea",
"recognition": "Riconoscimento",
"build": "Costruisci",
"operate": "Gestione"
},
"operate": {
"inference": "Inference",
"cluster": "Cluster",
"observability": "Observability",
"access": "Access",
"system": "System"
},
"items": {
"home": "Home",
@@ -27,6 +33,11 @@
"talk": "Conversazione",
"fineTune": "Fine-Tuning (Sperimentale)",
"quantize": "Quantizzazione (Sperimentale)",
"faces": "Volti",
"voices": "Voci",
"jobs": "Lavori",
"operate": "Amministrazione",
"host": "Host",
"audioTransform": "Trasforma audio",
"faceRecognition": "Riconoscimento volti",
"voiceRecognition": "Riconoscimento vocale",
@@ -42,12 +53,17 @@
"swarm": "Swarm",
"system": "Sistema",
"settings": "Impostazioni",
"api": "API"
"api": "API",
"middleware": "Middleware"
},
"footer": {
"github": "GitHub",
"documentation": "Documentazione",
"author": "Autore",
"copyright": "© 2023-{{year}} {{author}}"
},
"console": {
"automation": "Automazione",
"training": "Addestramento"
}
}

View File

@@ -62,5 +62,14 @@
"docs": "문서",
"noModelsTitle": "사용 가능한 모델 없음",
"noModelsBody": "아직 설치된 모델이 없습니다. 채팅을 시작할 수 있도록 관리자에게 모델 설정을 요청하세요."
},
"connect": {
"title": "하나의 엔드포인트, 모든 API",
"subtitle": "LocalAI는 이미지 및 비디오 생성, 깊이, 객체 탐지, 리랭킹, 오디오, 얼굴 및 음성 인식, 그리고 WebRTC와 WebSocket을 통한 실시간 음성 등 자체 전체 API를 제공합니다. 여기에 드롭인 호환 계층을 통해 OpenAI, Anthropic, Ollama 또는 OpenAI Responses용으로 만들어진 모든 앱이 수정 없이 작동합니다.",
"nativeTitle": "네이티브 API",
"compatTitle": "드롭인 호환성",
"apiReference": "전체 API 레퍼런스",
"copy": "복사",
"copied": "복사됨"
}
}

View File

@@ -4,7 +4,8 @@
"images": "이미지",
"video": "비디오",
"tts": "TTS",
"sound": "사운드"
"sound": "사운드",
"transform": "Transform"
}
},
"image": {

View File

@@ -13,11 +13,17 @@
"account": "계정",
"accountFor": "계정: {{name}}",
"sections": {
"tools": "도구",
"enhance": "향상",
"biometrics": "생체 인식",
"agents": "에이전트",
"system": "시스템"
"create": "만들기",
"recognition": "인식",
"build": "구축",
"operate": "운영"
},
"operate": {
"inference": "Inference",
"cluster": "Cluster",
"observability": "Observability",
"access": "Access",
"system": "System"
},
"items": {
"home": "홈",
@@ -27,6 +33,11 @@
"talk": "대화",
"fineTune": "파인튜닝 (실험적)",
"quantize": "양자화 (실험적)",
"faces": "얼굴",
"voices": "음성",
"jobs": "작업",
"operate": "관리",
"host": "호스트",
"audioTransform": "오디오 변환",
"faceRecognition": "얼굴 인식",
"voiceRecognition": "음성 인식",
@@ -50,5 +61,9 @@
"documentation": "문서",
"author": "작성자",
"copyright": "© 2023-{{year}} {{author}}"
},
"console": {
"automation": "자동화",
"training": "학습"
}
}

View File

@@ -62,5 +62,14 @@
"docs": "文档",
"noModelsTitle": "暂无可用模型",
"noModelsBody": "尚未安装模型。请联系您的管理员设置模型,以便开始聊天。"
},
"connect": {
"title": "一个端点,所有 API",
"subtitle": "LocalAI 提供自己的完整 API——图像与视频生成、深度、目标检测、重排序、音频、人脸与声音识别以及通过 WebRTC 和 WebSocket 的实时语音。在此之上,即插即用的兼容层让任何为 OpenAI、Anthropic、Ollama 或 OpenAI Responses 构建的应用无需改动即可使用。",
"nativeTitle": "原生 API",
"compatTitle": "即插即用兼容",
"apiReference": "完整 API 参考",
"copy": "复制",
"copied": "已复制"
}
}

View File

@@ -4,7 +4,8 @@
"images": "图像",
"video": "视频",
"tts": "TTS",
"sound": "声音"
"sound": "声音",
"transform": "Transform"
}
},
"image": {

View File

@@ -13,11 +13,17 @@
"account": "账户",
"accountFor": "账户:{{name}}",
"sections": {
"tools": "工具",
"enhance": "增强",
"biometrics": "生物识别",
"agents": "智能体",
"system": "系统"
"create": "创建",
"recognition": "识别",
"build": "构建",
"operate": "运维"
},
"operate": {
"inference": "Inference",
"cluster": "Cluster",
"observability": "Observability",
"access": "Access",
"system": "System"
},
"items": {
"home": "首页",
@@ -27,6 +33,11 @@
"talk": "通话",
"fineTune": "微调(实验性)",
"quantize": "量化(实验性)",
"faces": "人脸",
"voices": "声音",
"jobs": "任务",
"operate": "管理",
"host": "主机",
"audioTransform": "音频变换",
"faceRecognition": "人脸识别",
"voiceRecognition": "语音识别",
@@ -42,12 +53,17 @@
"swarm": "Swarm",
"system": "系统",
"settings": "设置",
"api": "API"
"api": "API",
"middleware": "Middleware"
},
"footer": {
"github": "GitHub",
"documentation": "文档",
"author": "作者",
"copyright": "© 2023-{{year}} {{author}}"
},
"console": {
"automation": "自动化",
"training": "训练"
}
}

View File

@@ -341,6 +341,28 @@
transition: opacity 150ms ease;
}
/* Sidebar nav polish: inset, rounded "pill" items give the nav a lighter,
more intentional rhythm than full-bleed bars. Scoped to .sidebar-nav so the
shared .nav-item class (reused by the admin console rail) keeps its own
treatment. */
.sidebar-nav .nav-item {
margin: 1px var(--spacing-sm);
padding-left: var(--spacing-sm);
padding-right: var(--spacing-sm);
border-radius: var(--radius-lg);
}
.sidebar-nav .nav-item.active {
background: var(--color-primary-light);
color: var(--color-primary);
box-shadow: none;
}
.sidebar-nav .nav-item.active .nav-icon { color: var(--color-primary); }
/* Align tier labels with the inset item text (item margin + icon padding). */
.sidebar-nav .sidebar-section-title {
padding-left: var(--spacing-md);
padding-right: var(--spacing-md);
}
.nav-external {
font-size: 0.55rem;
margin-left: auto;
@@ -5994,6 +6016,113 @@ select.input {
justify-content: center;
}
/* ──────────────────── Home: drop-in endpoint / API compatibility ──────────────────── */
.home-connect {
max-width: 760px;
margin: var(--spacing-xl) auto 0;
padding: var(--spacing-lg);
}
.home-connect-head {
display: flex;
align-items: flex-start;
gap: var(--spacing-md);
}
.home-connect-icon {
flex-shrink: 0;
display: grid;
place-items: center;
width: 38px;
height: 38px;
border-radius: var(--radius-lg);
background: var(--color-primary-light);
color: var(--color-primary);
}
.home-connect-title {
margin: 0;
font-size: var(--text-lg);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
}
.home-connect-sub {
margin: 2px 0 0;
font-size: var(--text-sm);
color: var(--color-text-muted);
line-height: 1.5;
}
.home-connect-url {
display: flex;
align-items: center;
gap: var(--spacing-sm);
margin-top: var(--spacing-md);
padding: var(--spacing-sm) var(--spacing-sm) var(--spacing-sm) var(--spacing-md);
background: var(--color-surface-sunken);
border: 1px solid var(--color-border-default);
border-radius: var(--radius-lg);
}
.home-connect-url code {
flex: 1;
min-width: 0;
overflow-x: auto;
white-space: nowrap;
font-size: var(--text-sm);
color: var(--color-primary);
background: none;
padding: 0;
}
.home-connect-url .btn { flex-shrink: 0; display: inline-flex; align-items: center; gap: 6px; }
.home-connect-block { margin-top: var(--spacing-md); }
.home-connect-block-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--spacing-sm);
margin-bottom: var(--spacing-sm);
}
.home-connect-block-title {
font-size: var(--text-xs);
text-transform: uppercase;
letter-spacing: 0.08em;
font-weight: var(--font-weight-semibold);
color: var(--color-text-tertiary);
}
.home-connect-apis {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--spacing-sm);
}
.home-connect-api {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--spacing-sm);
padding: var(--spacing-sm) var(--spacing-md);
background: var(--color-bg-tertiary);
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-md);
}
.home-connect-api-name { font-weight: var(--font-weight-medium); color: var(--color-text-secondary); }
.home-connect-api-path { font-size: var(--text-xs); color: var(--color-text-muted); background: none; padding: 0; }
.home-connect-docs {
display: inline-flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
font-size: var(--text-sm);
font-weight: var(--font-weight-medium);
color: var(--color-primary);
text-decoration: none;
}
.home-connect-docs:hover { color: var(--color-primary-hover); }
.home-connect-docs i { font-size: 0.75rem; transition: transform var(--duration-fast) var(--ease-out, ease-out); }
.home-connect-docs:hover i { transform: translateX(3px); }
@media (max-width: 560px) {
.home-connect-apis { grid-template-columns: 1fr; }
}
/* ──────────────────── Biometrics (face + voice recognition) ──────────────────── */
.biometrics-page {
@@ -7855,3 +7984,96 @@ select.input {
transition-duration: 0.01ms !important;
}
}
/* Admin console layout (pathless route) - structural only */
/* Lazy-route loading fallback. Delayed ~150ms so fast chunk loads don't flash. */
.route-fallback {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
min-height: 40vh;
opacity: 0;
animation: route-fallback-in var(--duration-normal) var(--ease-out, ease-out) 150ms forwards;
}
@keyframes route-fallback-in { to { opacity: 1; } }
@media (prefers-reduced-motion: reduce) {
.route-fallback { opacity: 1; animation: none; }
}
/* Console layout: a secondary rail panel (Build, Operate) beside the page body. */
.console-layout {
display: flex;
gap: var(--spacing-lg);
align-items: flex-start;
padding: var(--spacing-md);
}
.console-rail {
flex: 0 0 220px;
position: sticky;
top: var(--spacing-md);
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
padding: var(--spacing-sm);
background: var(--color-bg-secondary);
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-subtle);
}
/* Entrance only when entering the console (not on item-to-item sub-nav). */
.console-rail.console-rail--enter {
animation: console-rail-in var(--duration-normal) var(--ease-out, ease-out) both;
}
@keyframes console-rail-in {
from { opacity: 0; transform: translateX(-8px); }
to { opacity: 1; transform: translateX(0); }
}
.console-rail-header {
display: flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-sm) var(--spacing-sm) var(--spacing-xs);
font-size: var(--text-sm);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
}
.console-rail-header i { color: var(--color-primary); font-size: 0.9rem; }
.console-group { display: flex; flex-direction: column; gap: 1px; }
.console-group + .console-group {
margin-top: var(--spacing-xs);
padding-top: var(--spacing-xs);
border-top: 1px solid var(--color-border-divider);
}
.console-group-title {
font-size: var(--text-xs);
text-transform: uppercase;
letter-spacing: 0.08em;
font-weight: var(--font-weight-semibold);
color: var(--color-text-tertiary);
padding: var(--spacing-xs) var(--spacing-sm);
}
.console-rail .nav-item {
margin: 0;
padding: 7px var(--spacing-sm);
border-radius: var(--radius-md);
font-size: var(--text-sm);
transition: color var(--duration-fast) var(--ease-out, ease-out),
background var(--duration-fast) var(--ease-out, ease-out),
transform var(--duration-fast) var(--ease-out, ease-out);
}
.console-rail .nav-item:hover:not(.active) { transform: translateX(2px); }
.console-rail .nav-item.active { box-shadow: none; }
.console-rail .nav-item.active .nav-icon { color: var(--color-primary); }
.console-body {
flex: 1 1 auto;
min-width: 0;
}
@media (max-width: 768px) {
.console-layout { flex-direction: column; padding: var(--spacing-sm); }
.console-rail { position: static; flex-basis: auto; width: 100%; }
}
@media (prefers-reduced-motion: reduce) {
.console-rail.console-rail--enter { animation: none; }
.console-rail .nav-item:hover:not(.active) { transform: none; }
}

View File

@@ -8,9 +8,22 @@ 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(() => {
@@ -121,13 +134,14 @@ export default function App() {
</div>
</header>
<div className="main-content-inner">
<div className="page-transition" key={location.pathname}>
<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. fallback={null} keeps the shell stable; the
page-content area briefly blanks while the chunk arrives. */}
<Suspense fallback={null}>
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>

View File

@@ -0,0 +1,92 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { apiUrl } from '../utils/basePath'
// LocalAI's own API goes well beyond chat — a sample of capability endpoints
// that have no OpenAI equivalent (see core/http/routes/*.go). The pitch leads
// from this breadth, then presents drop-in compatibility as a bonus on top.
const NATIVE = [
{ name: 'Images', path: '/v1/images/generations' },
{ name: 'Video', path: '/video' },
{ name: 'Realtime voice', path: '/v1/realtime · WebRTC, WS' },
{ name: 'Depth', path: '/v1/depth' },
{ name: 'Object detection', path: '/v1/detection' },
{ name: 'Rerank', path: '/v1/rerank' },
{ name: 'Audio & TTS', path: '/v1/audio/speech' },
{ name: 'Face & voice', path: '/v1/face · /v1/voice' },
]
// Wire-compatible API dialects: any client built for these works unchanged.
const COMPAT = [
{ name: 'OpenAI', path: '/v1' },
{ name: 'Anthropic', path: '/v1/messages' },
{ name: 'Ollama', path: '/api' },
{ name: 'OpenAI Responses', path: '/v1/responses' },
]
export default function HomeConnect() {
const { t } = useTranslation('home')
const [copied, setCopied] = useState(false)
// Absolute base for this instance, honouring any sub-path mount.
const base = new URL(apiUrl('/'), window.location.origin).href.replace(/\/$/, '')
const copy = async () => {
try {
await navigator.clipboard.writeText(base)
setCopied(true)
setTimeout(() => setCopied(false), 1500)
} catch (_) { /* clipboard blocked — the URL is selectable anyway */ }
}
return (
<section className="home-connect card" aria-labelledby="home-connect-title">
<div className="home-connect-head">
<span className="home-connect-icon"><i className="fas fa-plug" aria-hidden="true" /></span>
<div>
<h2 id="home-connect-title" className="home-connect-title">{t('connect.title')}</h2>
<p className="home-connect-sub">{t('connect.subtitle')}</p>
</div>
</div>
<div className="home-connect-url">
<code>{base}</code>
<button type="button" className="btn btn-secondary btn-sm" onClick={copy} aria-label={t('connect.copy')}>
<i className={`fas ${copied ? 'fa-check' : 'fa-copy'}`} aria-hidden="true" />
<span>{copied ? t('connect.copied') : t('connect.copy')}</span>
</button>
</div>
<div className="home-connect-block">
<div className="home-connect-block-head">
<span className="home-connect-block-title">{t('connect.nativeTitle')}</span>
<a className="home-connect-docs" href={apiUrl('/swagger/index.html')} target="_blank" rel="noopener noreferrer">
{t('connect.apiReference')} <i className="fas fa-arrow-right" aria-hidden="true" />
</a>
</div>
<ul className="home-connect-apis">
{NATIVE.map(api => (
<li key={api.name} className="home-connect-api">
<span className="home-connect-api-name">{api.name}</span>
<code className="home-connect-api-path">{api.path}</code>
</li>
))}
</ul>
</div>
<div className="home-connect-block">
<div className="home-connect-block-head">
<span className="home-connect-block-title">{t('connect.compatTitle')}</span>
</div>
<ul className="home-connect-apis">
{COMPAT.map(api => (
<li key={api.name} className="home-connect-api">
<span className="home-connect-api-name">{api.name}</span>
<code className="home-connect-api-path">{api.path}</code>
</li>
))}
</ul>
</div>
</section>
)
}

View File

@@ -0,0 +1,11 @@
import LoadingSpinner from './LoadingSpinner'
// Suspense fallback for lazy-loaded routes. Centered in the content area; the
// CSS delays its appearance ~150ms so fast chunk loads don't flash a spinner.
export default function RouteFallback() {
return (
<div className="route-fallback" role="status" aria-live="polite">
<LoadingSpinner size="lg" />
</div>
)
}

View File

@@ -7,6 +7,7 @@ import { useAuth } from '../context/AuthContext'
import { useBranding } from '../contexts/BrandingContext'
import { apiUrl } from '../utils/basePath'
import { preloadRoute } from '../router'
import { consoles, firstVisiblePath, consolePaths } from './console/consoleConfig'
const COLLAPSED_KEY = 'localai_sidebar_collapsed'
const SECTIONS_KEY = 'localai_sidebar_sections'
@@ -14,71 +15,19 @@ const SECTIONS_KEY = 'localai_sidebar_sections'
const topItems = [
{ path: '/app', icon: 'fas fa-home', labelKey: 'items.home' },
{ path: '/app/models', icon: 'fas fa-download', labelKey: 'items.installModels', adminOnly: true },
{ path: '/app/chat', icon: 'fas fa-comments', labelKey: 'items.chat' },
{ path: '/app/studio', icon: 'fas fa-palette', labelKey: 'items.studio' },
{ path: '/app/talk', icon: 'fas fa-phone', labelKey: 'items.talk' },
]
// Create stays inline (frequent, one-click creative destinations). The Build
// and Operate tiers are single entries that open a secondary console rail —
// their items live in console/consoleConfig.js (shared with ConsoleLayout).
const sections = [
{
id: 'tools',
titleKey: 'sections.tools',
id: 'create',
titleKey: 'sections.create',
items: [
{ path: '/app/fine-tune', icon: 'fas fa-graduation-cap', labelKey: 'items.fineTune', feature: 'fine_tuning' },
{ path: '/app/quantize', icon: 'fas fa-compress', labelKey: 'items.quantize', feature: 'quantization' },
],
},
{
id: 'enhance',
titleKey: 'sections.enhance',
featureMap: {
'/app/transform': 'audio_transform',
},
items: [
{ path: '/app/transform', icon: 'fas fa-wave-square', labelKey: 'items.audioTransform', feature: 'audio_transform' },
],
},
{
id: 'biometrics',
titleKey: 'sections.biometrics',
featureMap: {
'/app/face': 'face_recognition',
'/app/voice': 'voice_recognition',
},
items: [
{ path: '/app/face', icon: 'fas fa-face-smile', labelKey: 'items.faceRecognition', feature: 'face_recognition' },
{ path: '/app/voice', icon: 'fas fa-microphone-lines', labelKey: 'items.voiceRecognition', feature: 'voice_recognition' },
],
},
{
id: 'agents',
titleKey: 'sections.agents',
featureMap: {
'/app/agents': 'agents',
'/app/skills': 'skills',
'/app/collections': 'collections',
'/app/agent-jobs': 'mcp_jobs',
},
items: [
{ path: '/app/agents', icon: 'fas fa-robot', labelKey: 'items.agents' },
{ path: '/app/skills', icon: 'fas fa-wand-magic-sparkles', labelKey: 'items.skills' },
{ path: '/app/collections', icon: 'fas fa-database', labelKey: 'items.memory' },
{ path: '/app/agent-jobs', icon: 'fas fa-tasks', labelKey: 'items.mcpJobs', feature: 'mcp' },
],
},
{
id: 'system',
titleKey: 'sections.system',
items: [
{ path: '/app/usage', icon: 'fas fa-chart-bar', labelKey: 'items.usage' },
{ path: '/app/users', icon: 'fas fa-users', labelKey: 'items.users', adminOnly: true, authOnly: true },
{ path: '/app/middleware', icon: 'fas fa-shield-halved', labelKey: 'items.middleware', adminOnly: true },
{ path: '/app/backends', icon: 'fas fa-server', labelKey: 'items.backends', adminOnly: true },
{ path: '/app/traces', icon: 'fas fa-chart-line', labelKey: 'items.traces', adminOnly: true },
{ path: '/app/nodes', icon: 'fas fa-network-wired', labelKey: 'items.nodes', adminOnly: true, feature: 'distributed' },
{ path: '/app/p2p', icon: 'fas fa-circle-nodes', labelKey: 'items.swarm', adminOnly: true },
{ path: '/app/manage', icon: 'fas fa-desktop', labelKey: 'items.system', adminOnly: true },
{ path: '/app/settings', icon: 'fas fa-cog', labelKey: 'items.settings', adminOnly: true },
{ path: '/app/chat', icon: 'fas fa-comments', labelKey: 'items.chat' },
{ path: '/app/studio', icon: 'fas fa-palette', labelKey: 'items.studio' },
{ path: '/app/talk', icon: 'fas fa-phone', labelKey: 'items.talk' },
],
},
]
@@ -110,11 +59,15 @@ 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.
const defaults = Object.fromEntries(sections.map(s => [s.id, true]))
try {
const stored = localStorage.getItem(SECTIONS_KEY)
return stored ? JSON.parse(stored) : {}
return stored ? { ...defaults, ...JSON.parse(stored) } : defaults
} catch (_) {
return {}
return defaults
}
}
@@ -202,17 +155,11 @@ 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 }
const getVisibleSectionItems = (section) => {
return section.items.filter(item => {
if (!filterItem(item)) return false
if (section.featureMap) {
const featureName = section.featureMap[item.path]
return featureName ? hasFeature(featureName) : isAdmin
}
return true
})
}
// Inline sections (Create) carry no gating; a plain filterItem pass suffices.
const getVisibleSectionItems = (section) => section.items.filter(filterItem)
return (
<>
@@ -252,9 +199,6 @@ export default function Sidebar({ isOpen, onClose }) {
{/* Collapsible sections */}
{sections.map(section => {
// For agents section, check global feature flag
if (section.id === 'agents' && features.agents === false) return null
const visibleItems = getVisibleSectionItems(section)
if (visibleItems.length === 0) return null
@@ -274,19 +218,6 @@ export default function Sidebar({ isOpen, onClose }) {
</button>
{showItems && (
<div className="sidebar-section-items">
{section.id === 'system' && (
<a
href={apiUrl('/swagger/index.html')}
target="_blank"
rel="noopener noreferrer"
className="nav-item"
title={collapsed ? t('items.api') : undefined}
>
<i className="fas fa-code nav-icon" />
<span className="nav-label">{t('items.api')}</span>
<i className="fas fa-external-link-alt nav-external" />
</a>
)}
{visibleItems.map(item => (
<NavItem key={item.path} item={item} onClose={onClose} collapsed={collapsed} />
))}
@@ -295,6 +226,31 @@ export default function Sidebar({ isOpen, onClose }) {
</div>
)
})}
{/* Console tiers (Build, Operate): a single entry that opens a
secondary rail. Hidden when the viewer can see none of its items. */}
{consoles.map(config => {
const target = firstVisiblePath(config, auth)
if (!target) return null
const active = consolePaths(config).some(p => location.pathname.startsWith(p))
const label = t(config.titleKey)
return (
<div key={config.id} className="sidebar-section">
<NavLink
to={target}
className={() => `nav-item ${active ? 'active' : ''}`}
onClick={onClose}
onMouseEnter={() => preloadRoute(target)}
onFocus={() => preloadRoute(target)}
onTouchStart={() => preloadRoute(target)}
title={collapsed ? label : undefined}
>
<i className={`${config.icon} nav-icon`} />
<span className="nav-label">{label}</span>
</NavLink>
</div>
)
})}
</nav>
{/* Footer */}

View File

@@ -0,0 +1,102 @@
import { useState, useEffect, Suspense } from 'react'
import { NavLink, Outlet, useOutletContext, useLocation } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { useAuth } from '../../context/AuthContext'
import { apiUrl } from '../../utils/basePath'
import { preloadRoute } from '../../router'
import RouteFallback from '../RouteFallback'
import { isConsoleItemVisible } from './consoleConfig'
// The App wraps the outlet in key={pathname}, so this layout remounts on every
// sub-navigation. Tracking the last-entered console id across mounts lets us
// play the rail's entrance animation only when actually entering a console
// (from outside), not when switching items within it — otherwise it flashes.
let lastConsoleId = null
// /api/features rarely changes; cache it across remounts so the rail renders
// the correct (gated) item set immediately instead of flashing the wrong set
// while a fresh fetch resolves on every sub-navigation.
let featuresCache = {}
// Generic secondary-rail layout shared by the Build and Operate consoles.
// Driven entirely by a config from consoleConfig.js, so the rail, its gating,
// and the sidebar entry that opens it stay in sync. Mounted as a PATHLESS
// route in router.jsx — wrapped pages keep their existing flat URLs.
function RailItem({ item, label }) {
if (item.external) {
return (
<a className="nav-item" href={apiUrl(item.href)} target="_blank" rel="noopener noreferrer">
<i className={`${item.icon} nav-icon`} />
<span className="nav-label">{label}</span>
<i className="fas fa-external-link-alt nav-external" />
</a>
)
}
return (
<NavLink
to={item.path}
className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}
onMouseEnter={() => preloadRoute(item.path)}
onFocus={() => preloadRoute(item.path)}
>
<i className={`${item.icon} nav-icon`} />
<span className="nav-label">{label}</span>
</NavLink>
)
}
export default function ConsoleLayout({ config }) {
const { t } = useTranslation('nav')
const { isAdmin, authEnabled, hasFeature } = useAuth()
const [features, setFeatures] = useState(featuresCache)
const location = useLocation()
// Forward the App-level outlet context (e.g. addToast) — a nested bare
// <Outlet/> would otherwise shadow it with undefined and crash pages.
const outletContext = useOutletContext()
// True only when entering this console fresh; false on item-to-item nav.
const [entering] = useState(() => {
const fresh = lastConsoleId !== config.id
lastConsoleId = config.id
return fresh
})
useEffect(() => {
fetch(apiUrl('/api/features'))
.then(r => r.json())
.then(f => { featuresCache = f; setFeatures(f) })
.catch(() => {})
}, [])
const auth = { isAdmin, authEnabled, hasFeature, features }
return (
<div className="console-layout">
<nav className={`console-rail${entering ? ' console-rail--enter' : ''}`} aria-label={t(config.titleKey)}>
<div className="console-rail-header">
<i className={config.icon} aria-hidden="true" />
<span>{t(config.titleKey)}</span>
</div>
{config.groups.map((group, gi) => {
const items = group.items.filter(item => isConsoleItemVisible(item, auth))
if (items.length === 0) return null
return (
<div key={group.titleKey || gi} className="console-group">
{group.titleKey && <div className="console-group-title">{t(group.titleKey)}</div>}
{items.map(item => (
<RailItem key={item.path || item.href} item={item} label={t(item.labelKey)} />
))}
</div>
)
})}
</nav>
<div className="console-body" key={location.pathname}>
{/* Own Suspense so a lazy page shows the loader in the body while the
rail stays put (instead of bubbling to App's boundary). */}
<Suspense fallback={<RouteFallback />}>
<Outlet context={outletContext} />
</Suspense>
</div>
</div>
)
}

View File

@@ -0,0 +1,117 @@
// Console configuration shared by ConsoleLayout (renders the rail) and Sidebar
// (renders the single tier entry + computes its active state). Keeping the tier
// definitions in one place means the sidebar entry, its active-path matching,
// and the console rail can never drift out of sync.
//
// A console is a tier that opens a secondary rail instead of expanding inline.
// Each item carries its own gating (adminOnly / authOnly / feature /
// requiresAgentPool) so visibility matches the legacy sidebar exactly. Note
// `/api/features` only emits: agents, mcp, fine_tuning, quantization,
// distributed, localai_assistant — capability-style flags (face_recognition,
// skills, …) come from hasFeature(), not the features map.
// Recognition (Faces/Voices) lives as a GROUP inside the Build console rather
// than its own tier — it's an AI capability you build with, so keeping it next
// to Automation/Training avoids it feeling split off on its own.
export const buildConsole = {
id: 'build',
titleKey: 'sections.build',
icon: 'fas fa-screwdriver-wrench',
groups: [
{
titleKey: 'console.automation',
items: [
{ path: '/app/agents', icon: 'fas fa-robot', labelKey: 'items.agents', feature: 'agents', requiresAgentPool: true },
{ path: '/app/skills', icon: 'fas fa-wand-magic-sparkles', labelKey: 'items.skills', feature: 'skills', requiresAgentPool: true },
{ path: '/app/collections', icon: 'fas fa-database', labelKey: 'items.memory', feature: 'collections', requiresAgentPool: true },
{ path: '/app/agent-jobs', icon: 'fas fa-tasks', labelKey: 'items.jobs', feature: 'mcp', requiresAgentPool: true },
],
},
{
titleKey: 'console.training',
items: [
{ path: '/app/fine-tune', icon: 'fas fa-graduation-cap', labelKey: 'items.fineTune', feature: 'fine_tuning' },
{ path: '/app/quantize', icon: 'fas fa-compress', labelKey: 'items.quantize', feature: 'quantization' },
],
},
{
titleKey: 'sections.recognition',
items: [
{ path: '/app/face', icon: 'fas fa-face-smile', labelKey: 'items.faces', feature: 'face_recognition' },
{ path: '/app/voice', icon: 'fas fa-microphone-lines', labelKey: 'items.voices', feature: 'voice_recognition' },
],
},
],
}
export const operateConsole = {
id: 'operate',
titleKey: 'sections.operate',
icon: 'fas fa-sliders',
groups: [
{
titleKey: 'operate.inference',
items: [
{ path: '/app/backends', icon: 'fas fa-server', labelKey: 'items.backends', adminOnly: true },
],
},
{
titleKey: 'operate.cluster',
items: [
{ path: '/app/nodes', icon: 'fas fa-network-wired', labelKey: 'items.nodes', adminOnly: true, feature: 'distributed' },
{ path: '/app/p2p', icon: 'fas fa-circle-nodes', labelKey: 'items.swarm', adminOnly: true },
],
},
{
titleKey: 'operate.observability',
items: [
{ path: '/app/usage', icon: 'fas fa-chart-bar', labelKey: 'items.usage', adminOnly: true },
{ path: '/app/traces', icon: 'fas fa-chart-line', labelKey: 'items.traces', adminOnly: true },
],
},
{
titleKey: 'operate.access',
items: [
{ path: '/app/users', icon: 'fas fa-users', labelKey: 'items.users', adminOnly: true, authOnly: true },
{ path: '/app/middleware', icon: 'fas fa-shield-halved', labelKey: 'items.middleware', adminOnly: true },
],
},
{
titleKey: 'operate.system',
items: [
{ path: '/app/manage', icon: 'fas fa-desktop', labelKey: 'items.host', adminOnly: true },
{ path: '/app/settings', icon: 'fas fa-cog', labelKey: 'items.settings', adminOnly: true },
{ href: '/swagger/index.html', icon: 'fas fa-code', labelKey: 'items.api', external: true, adminOnly: true },
],
},
],
}
export const consoles = [buildConsole, operateConsole]
// Single source of truth for item visibility — mirrors the legacy sidebar
// filterItem + section gates.
export function isConsoleItemVisible(item, { isAdmin, authEnabled, hasFeature, features }) {
if (item.adminOnly && !isAdmin) return false
if (item.authOnly && !authEnabled) return false
if (item.requiresAgentPool && features.agents === false) return false
if (item.feature && features[item.feature] === false) return false
if (item.feature && !hasFeature(item.feature)) return false
return true
}
// All nav paths a console owns (for the sidebar entry's active-state match).
export function consolePaths(config) {
return config.groups.flatMap(g => g.items.filter(i => i.path).map(i => i.path))
}
// The page the tier's single sidebar entry links to: its first visible item.
// Returns null when nothing is visible (so the entry can be hidden entirely).
export function firstVisiblePath(config, auth) {
for (const group of config.groups) {
for (const item of group.items) {
if (item.path && isConsoleItemVisible(item, auth)) return item.path
}
}
return null
}

View File

@@ -8,6 +8,7 @@ import ModelSelector from '../components/ModelSelector'
import { CAP_CHAT } from '../utils/capabilities'
import UnifiedMCPDropdown from '../components/UnifiedMCPDropdown'
import ConfirmDialog from '../components/ConfirmDialog'
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'
@@ -562,6 +563,8 @@ export default function Home() {
</div>
)}
<HomeConnect />
<ConfirmDialog
open={!!confirmDialog}
title={confirmDialog?.title}

View File

@@ -4,36 +4,48 @@ import ImageGen from './ImageGen'
import VideoGen from './VideoGen'
import TTS from './TTS'
import Sound from './Sound'
import AudioTransform from './AudioTransform'
import { useAuth } from '../context/AuthContext'
const TABS = [
const BASE_TABS = [
{ key: 'images', labelKey: 'studio.tabs.images', icon: 'fas fa-image' },
{ key: 'video', labelKey: 'studio.tabs.video', icon: 'fas fa-video' },
{ key: 'tts', labelKey: 'studio.tabs.tts', icon: 'fas fa-headphones' },
{ key: 'sound', labelKey: 'studio.tabs.sound', icon: 'fas fa-music' },
]
const TRANSFORM_TAB = { key: 'transform', labelKey: 'studio.tabs.transform', icon: 'fas fa-wave-square' }
const TAB_COMPONENTS = {
images: ImageGen,
video: VideoGen,
tts: TTS,
sound: Sound,
transform: AudioTransform,
}
export default function Studio() {
const { t } = useTranslation('media')
const { hasFeature } = useAuth()
const [searchParams, setSearchParams] = useSearchParams()
const activeTab = searchParams.get('tab') || 'images'
// Transform is a distinct capability; only show its tab when enabled.
const tabs = hasFeature('audio_transform') ? [...BASE_TABS, TRANSFORM_TAB] : BASE_TABS
const setTab = (key) => {
setSearchParams({ tab: key }, { replace: true })
}
const ActiveComponent = TAB_COMPONENTS[activeTab] || ImageGen
const ActiveComponent =
(activeTab === 'transform' && !hasFeature('audio_transform'))
? ImageGen
: (TAB_COMPONENTS[activeTab] || ImageGen)
return (
<div>
<div className="studio-tabs">
{TABS.map(tab => (
{tabs.map(tab => (
<button
key={tab.key}
className={`studio-tab${activeTab === tab.key ? ' studio-tab-active' : ''}`}

View File

@@ -76,6 +76,9 @@ const Users = page('users', () => import('./pages/Users'))
const Middleware = page('middleware', () => import('./pages/Middleware'))
const Account = page('account', () => import('./pages/Account'))
import ConsoleLayout from './components/console/ConsoleLayout'
import { buildConsole, operateConsole } from './components/console/consoleConfig'
function BrowseRedirect() {
const { '*': splat } = useParams()
return <Navigate to={`/app/${splat || ''}`} replace />
@@ -92,7 +95,6 @@ function Feature({ feature, children }) {
const appChildren = [
{ index: true, element: <Home /> },
{ path: 'models', element: <Admin><Models /></Admin> },
{ path: 'chat', element: <Chat /> },
{ path: 'chat/:model', element: <Chat /> },
{ path: 'image', element: <ImageGen /> },
@@ -107,39 +109,59 @@ const appChildren = [
{ path: 'transform/:model', element: <Feature feature="audio_transform"><AudioTransform /></Feature> },
{ path: 'studio', element: <Studio /> },
{ path: 'talk', element: <Talk /> },
{ path: 'face', element: <Feature feature="face_recognition"><FaceRecognition /></Feature> },
{ path: 'face/:model', element: <Feature feature="face_recognition"><FaceRecognition /></Feature> },
{ path: 'voice', element: <Feature feature="voice_recognition"><VoiceRecognition /></Feature> },
{ path: 'voice/:model', element: <Feature feature="voice_recognition"><VoiceRecognition /></Feature> },
{ path: 'usage', element: <Usage /> },
{ path: 'account', element: <Account /> },
{ path: 'users', element: <RequireAuthEnabled><Admin><Users /></Admin></RequireAuthEnabled> },
{ path: 'middleware', element: <Admin><Middleware /></Admin> },
{ path: 'manage', element: <Admin><Manage /></Admin> },
{ path: 'backends', element: <Admin><Backends /></Admin> },
{ path: 'settings', element: <Admin><Settings /></Admin> },
{ path: 'traces', element: <Admin><Traces /></Admin> },
{ path: 'backend-logs/:modelId', element: <Admin><BackendLogs /></Admin> },
{ path: 'p2p', element: <Admin><P2P /></Admin> },
{ path: 'nodes', element: <Admin><Nodes /></Admin> },
{ path: 'node-backend-logs/:nodeId/:modelId', element: <Admin><NodeBackendLogs /></Admin> },
{ path: 'agents', element: <Feature feature="agents"><Agents /></Feature> },
// Build console — Automation, Training, and Recognition groups share one rail.
// Only the section landing pages live under the rail; deep create/edit/chat
// flows below render full-width.
{
element: <ConsoleLayout config={buildConsole} />,
children: [
{ path: 'agents', element: <Feature feature="agents"><Agents /></Feature> },
{ path: 'skills', element: <Feature feature="skills"><Skills /></Feature> },
{ path: 'collections', element: <Feature feature="collections"><Collections /></Feature> },
{ path: 'agent-jobs', element: <Feature feature="mcp_jobs"><AgentJobs /></Feature> },
{ path: 'fine-tune', element: <Feature feature="fine_tuning"><FineTune /></Feature> },
{ path: 'quantize', element: <Feature feature="quantization"><Quantize /></Feature> },
{ path: 'face', element: <Feature feature="face_recognition"><FaceRecognition /></Feature> },
{ path: 'face/:model', element: <Feature feature="face_recognition"><FaceRecognition /></Feature> },
{ path: 'voice', element: <Feature feature="voice_recognition"><VoiceRecognition /></Feature> },
{ path: 'voice/:model', element: <Feature feature="voice_recognition"><VoiceRecognition /></Feature> },
],
},
// Build deep flows — full-width, no rail.
{ path: 'agents/new', element: <Feature feature="agents"><AgentCreate /></Feature> },
{ path: 'agents/:name/edit', element: <Feature feature="agents"><AgentCreate /></Feature> },
{ path: 'agents/:name/chat', element: <Feature feature="agents"><AgentChat /></Feature> },
{ path: 'agents/:name/status', element: <Feature feature="agents"><AgentStatus /></Feature> },
{ path: 'collections', element: <Feature feature="collections"><Collections /></Feature> },
{ path: 'collections/:name', element: <Feature feature="collections"><CollectionDetails /></Feature> },
{ path: 'skills', element: <Feature feature="skills"><Skills /></Feature> },
{ path: 'skills/new', element: <Feature feature="skills"><SkillEdit /></Feature> },
{ path: 'skills/edit/:name', element: <Feature feature="skills"><SkillEdit /></Feature> },
{ path: 'agent-jobs', element: <Feature feature="mcp_jobs"><AgentJobs /></Feature> },
{ path: 'agent-jobs/tasks/new', element: <Feature feature="mcp_jobs"><AgentTaskDetails /></Feature> },
{ path: 'agent-jobs/tasks/:id', element: <Feature feature="mcp_jobs"><AgentTaskDetails /></Feature> },
{ path: 'agent-jobs/tasks/:id/edit', element: <Feature feature="mcp_jobs"><AgentTaskDetails /></Feature> },
{ path: 'agent-jobs/jobs/:id', element: <Feature feature="mcp_jobs"><AgentJobDetails /></Feature> },
{ path: 'fine-tune', element: <Feature feature="fine_tuning"><FineTune /></Feature> },
{ path: 'quantize', element: <Feature feature="quantization"><Quantize /></Feature> },
// Operate console (admin).
{
element: <ConsoleLayout config={operateConsole} />,
children: [
{ path: 'backends', element: <Admin><Backends /></Admin> },
{ path: 'settings', element: <Admin><Settings /></Admin> },
{ path: 'traces', element: <Admin><Traces /></Admin> },
{ path: 'backend-logs/:modelId', element: <Admin><BackendLogs /></Admin> },
{ path: 'p2p', element: <Admin><P2P /></Admin> },
{ path: 'nodes', element: <Admin><Nodes /></Admin> },
{ path: 'node-backend-logs/:nodeId/:modelId', element: <Admin><NodeBackendLogs /></Admin> },
{ path: 'usage', element: <Usage /> },
{ path: 'users', element: <RequireAuthEnabled><Admin><Users /></Admin></RequireAuthEnabled> },
{ path: 'middleware', element: <Admin><Middleware /></Admin> },
{ path: 'manage', element: <Admin><Manage /></Admin> },
],
},
// Models management (Install Models) — top-level destination, full-width.
{ path: 'models', element: <Admin><Models /></Admin> },
{ path: 'model-editor', element: <Admin><ModelEditor /></Admin> },
{ path: 'model-editor/:name', element: <Admin><ModelEditor /></Admin> },
{ path: 'import-model', element: <Admin><ImportModel /></Admin> },