mirror of
https://github.com/mudler/LocalAI.git
synced 2026-06-18 13:49:09 -04:00
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:
28
core/http/react-ui/e2e/admin-console.spec.js
Normal file
28
core/http/react-ui/e2e/admin-console.spec.js
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
9
core/http/react-ui/e2e/studio-transform.spec.js
Normal file
9
core/http/react-ui/e2e/studio-transform.spec.js
Normal 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 })
|
||||
})
|
||||
})
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
"images": "Bilder",
|
||||
"video": "Video",
|
||||
"tts": "TTS",
|
||||
"sound": "Audio"
|
||||
"sound": "Audio",
|
||||
"transform": "Transform"
|
||||
}
|
||||
},
|
||||
"image": {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
"images": "Images",
|
||||
"video": "Video",
|
||||
"tts": "TTS",
|
||||
"sound": "Sound"
|
||||
"sound": "Sound",
|
||||
"transform": "Transform"
|
||||
}
|
||||
},
|
||||
"image": {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
"images": "Imágenes",
|
||||
"video": "Video",
|
||||
"tts": "TTS",
|
||||
"sound": "Sonido"
|
||||
"sound": "Sonido",
|
||||
"transform": "Transform"
|
||||
}
|
||||
},
|
||||
"image": {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
"images": "Gambar",
|
||||
"video": "Video",
|
||||
"tts": "TTS",
|
||||
"sound": "Suara"
|
||||
"sound": "Suara",
|
||||
"transform": "Transform"
|
||||
}
|
||||
},
|
||||
"image": {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
"images": "Immagini",
|
||||
"video": "Video",
|
||||
"tts": "TTS",
|
||||
"sound": "Audio"
|
||||
"sound": "Audio",
|
||||
"transform": "Transform"
|
||||
}
|
||||
},
|
||||
"image": {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "복사됨"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
"images": "이미지",
|
||||
"video": "비디오",
|
||||
"tts": "TTS",
|
||||
"sound": "사운드"
|
||||
"sound": "사운드",
|
||||
"transform": "Transform"
|
||||
}
|
||||
},
|
||||
"image": {
|
||||
|
||||
@@ -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": "학습"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "已复制"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
"images": "图像",
|
||||
"video": "视频",
|
||||
"tts": "TTS",
|
||||
"sound": "声音"
|
||||
"sound": "声音",
|
||||
"transform": "Transform"
|
||||
}
|
||||
},
|
||||
"image": {
|
||||
|
||||
@@ -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": "训练"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
92
core/http/react-ui/src/components/HomeConnect.jsx
Normal file
92
core/http/react-ui/src/components/HomeConnect.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
11
core/http/react-ui/src/components/RouteFallback.jsx
Normal file
11
core/http/react-ui/src/components/RouteFallback.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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 */}
|
||||
|
||||
102
core/http/react-ui/src/components/console/ConsoleLayout.jsx
Normal file
102
core/http/react-ui/src/components/console/ConsoleLayout.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
117
core/http/react-ui/src/components/console/consoleConfig.js
vendored
Normal file
117
core/http/react-ui/src/components/console/consoleConfig.js
vendored
Normal 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
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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' : ''}`}
|
||||
|
||||
@@ -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> },
|
||||
|
||||
Reference in New Issue
Block a user