From 5ac864dbed5d03032e8c7c75d0634ee86af1128d Mon Sep 17 00:00:00 2001 From: "LocalAI [bot]" <139863280+localai-bot@users.noreply.github.com> Date: Thu, 18 Jun 2026 00:09:17 +0200 Subject: [PATCH] feat(ui): console-based navigation + drop-in API endpoint section (#10377) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(ui): restructure sidebar into Create/Recognition/Build tiers Signed-off-by: Ettore Di Giacinto * fix(ui): preserve exact sidebar gating for agent items and fine-tune/quantize Signed-off-by: Ettore Di Giacinto * i18n(ui): add nav tier + console keys to all locales Signed-off-by: Ettore Di Giacinto * 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 * feat(ui): fold Audio Transform into Studio as a tab Signed-off-by: Ettore Di Giacinto * test(ui): update e2e specs for tiered nav + admin console Signed-off-by: Ettore Di Giacinto * fix(ui): gate embedded Studio transform view on audio_transform feature Signed-off-by: Ettore Di Giacinto * 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 * 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 * fix(ui): revert Middleware nav label rename (keep Middleware in all locales) Signed-off-by: Ettore Di Giacinto --------- Signed-off-by: Ettore Di Giacinto Co-authored-by: Ettore Di Giacinto --- core/http/react-ui/e2e/admin-console.spec.js | 28 +++ core/http/react-ui/e2e/collections.spec.js | 2 +- .../e2e/import-form-ux-batch-b.spec.js | 2 +- core/http/react-ui/e2e/navigation.spec.js | 50 +++- .../react-ui/e2e/studio-transform.spec.js | 9 + .../http/react-ui/e2e/usage-dashboard.spec.js | 12 +- .../react-ui/e2e/users-tab-gating.spec.js | 22 +- .../http/react-ui/public/locales/de/home.json | 9 + .../react-ui/public/locales/de/media.json | 3 +- core/http/react-ui/public/locales/de/nav.json | 26 +- .../http/react-ui/public/locales/en/home.json | 9 + .../react-ui/public/locales/en/media.json | 3 +- core/http/react-ui/public/locales/en/nav.json | 27 ++- .../http/react-ui/public/locales/es/home.json | 9 + .../react-ui/public/locales/es/media.json | 3 +- core/http/react-ui/public/locales/es/nav.json | 28 ++- .../http/react-ui/public/locales/id/home.json | 11 +- .../react-ui/public/locales/id/media.json | 3 +- core/http/react-ui/public/locales/id/nav.json | 27 ++- .../http/react-ui/public/locales/it/home.json | 9 + .../react-ui/public/locales/it/media.json | 3 +- core/http/react-ui/public/locales/it/nav.json | 28 ++- .../http/react-ui/public/locales/ko/home.json | 9 + .../react-ui/public/locales/ko/media.json | 3 +- core/http/react-ui/public/locales/ko/nav.json | 25 +- .../react-ui/public/locales/zh-CN/home.json | 9 + .../react-ui/public/locales/zh-CN/media.json | 3 +- .../react-ui/public/locales/zh-CN/nav.json | 28 ++- core/http/react-ui/src/App.css | 222 ++++++++++++++++++ core/http/react-ui/src/App.jsx | 22 +- .../react-ui/src/components/HomeConnect.jsx | 92 ++++++++ .../react-ui/src/components/RouteFallback.jsx | 11 + core/http/react-ui/src/components/Sidebar.jsx | 132 ++++------- .../src/components/console/ConsoleLayout.jsx | 102 ++++++++ .../src/components/console/consoleConfig.js | 117 +++++++++ core/http/react-ui/src/pages/Home.jsx | 3 + core/http/react-ui/src/pages/Studio.jsx | 18 +- core/http/react-ui/src/router.jsx | 66 ++++-- 38 files changed, 990 insertions(+), 195 deletions(-) create mode 100644 core/http/react-ui/e2e/admin-console.spec.js create mode 100644 core/http/react-ui/e2e/studio-transform.spec.js create mode 100644 core/http/react-ui/src/components/HomeConnect.jsx create mode 100644 core/http/react-ui/src/components/RouteFallback.jsx create mode 100644 core/http/react-ui/src/components/console/ConsoleLayout.jsx create mode 100644 core/http/react-ui/src/components/console/consoleConfig.js diff --git a/core/http/react-ui/e2e/admin-console.spec.js b/core/http/react-ui/e2e/admin-console.spec.js new file mode 100644 index 000000000..1a039eba3 --- /dev/null +++ b/core/http/react-ui/e2e/admin-console.spec.js @@ -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() + }) +}) diff --git a/core/http/react-ui/e2e/collections.spec.js b/core/http/react-ui/e2e/collections.spec.js index 9d5afaedf..4fa4168dd 100644 --- a/core/http/react-ui/e2e/collections.spec.js +++ b/core/http/react-ui/e2e/collections.spec.js @@ -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 }) => { diff --git a/core/http/react-ui/e2e/import-form-ux-batch-b.spec.js b/core/http/react-ui/e2e/import-form-ux-batch-b.spec.js index 339d35422..78b0a808c 100644 --- a/core/http/react-ui/e2e/import-form-ux-batch-b.spec.js +++ b/core/http/react-ui/e2e/import-form-ux-batch-b.spec.js @@ -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 }) => { diff --git a/core/http/react-ui/e2e/navigation.spec.js b/core/http/react-ui/e2e/navigation.spec.js index d22dbe0a6..233509b1a 100644 --- a/core/http/react-ui/e2e/navigation.spec.js +++ b/core/http/react-ui/e2e/navigation.spec.js @@ -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() }) }) diff --git a/core/http/react-ui/e2e/studio-transform.spec.js b/core/http/react-ui/e2e/studio-transform.spec.js new file mode 100644 index 000000000..394eec40d --- /dev/null +++ b/core/http/react-ui/e2e/studio-transform.spec.js @@ -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 }) + }) +}) diff --git a/core/http/react-ui/e2e/usage-dashboard.spec.js b/core/http/react-ui/e2e/usage-dashboard.spec.js index a27bf4006..5c61b8c29 100644 --- a/core/http/react-ui/e2e/usage-dashboard.spec.js +++ b/core/http/react-ui/e2e/usage-dashboard.spec.js @@ -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 }) => { diff --git a/core/http/react-ui/e2e/users-tab-gating.spec.js b/core/http/react-ui/e2e/users-tab-gating.spec.js index f683d215f..ca9acd036 100644 --- a/core/http/react-ui/e2e/users-tab-gating.spec.js +++ b/core/http/react-ui/e2e/users-tab-gating.spec.js @@ -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() }) }) diff --git a/core/http/react-ui/public/locales/de/home.json b/core/http/react-ui/public/locales/de/home.json index 534c4f49b..a79b388d1 100644 --- a/core/http/react-ui/public/locales/de/home.json +++ b/core/http/react-ui/public/locales/de/home.json @@ -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" } } diff --git a/core/http/react-ui/public/locales/de/media.json b/core/http/react-ui/public/locales/de/media.json index 57afebff6..0e7e456c4 100644 --- a/core/http/react-ui/public/locales/de/media.json +++ b/core/http/react-ui/public/locales/de/media.json @@ -4,7 +4,8 @@ "images": "Bilder", "video": "Video", "tts": "TTS", - "sound": "Audio" + "sound": "Audio", + "transform": "Transform" } }, "image": { diff --git a/core/http/react-ui/public/locales/de/nav.json b/core/http/react-ui/public/locales/de/nav.json index 891a15cae..29f5c65d6 100644 --- a/core/http/react-ui/public/locales/de/nav.json +++ b/core/http/react-ui/public/locales/de/nav.json @@ -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" } } diff --git a/core/http/react-ui/public/locales/en/home.json b/core/http/react-ui/public/locales/en/home.json index 22d5a1048..ddfdee071 100644 --- a/core/http/react-ui/public/locales/en/home.json +++ b/core/http/react-ui/public/locales/en/home.json @@ -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" } } diff --git a/core/http/react-ui/public/locales/en/media.json b/core/http/react-ui/public/locales/en/media.json index 4f5c755de..8d6b6dd70 100644 --- a/core/http/react-ui/public/locales/en/media.json +++ b/core/http/react-ui/public/locales/en/media.json @@ -4,7 +4,8 @@ "images": "Images", "video": "Video", "tts": "TTS", - "sound": "Sound" + "sound": "Sound", + "transform": "Transform" } }, "image": { diff --git a/core/http/react-ui/public/locales/en/nav.json b/core/http/react-ui/public/locales/en/nav.json index ac85d4979..20c8e1599 100644 --- a/core/http/react-ui/public/locales/en/nav.json +++ b/core/http/react-ui/public/locales/en/nav.json @@ -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" } } diff --git a/core/http/react-ui/public/locales/es/home.json b/core/http/react-ui/public/locales/es/home.json index ec71d8db2..00a651f5b 100644 --- a/core/http/react-ui/public/locales/es/home.json +++ b/core/http/react-ui/public/locales/es/home.json @@ -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" } } diff --git a/core/http/react-ui/public/locales/es/media.json b/core/http/react-ui/public/locales/es/media.json index b3f25695a..944fe7a8d 100644 --- a/core/http/react-ui/public/locales/es/media.json +++ b/core/http/react-ui/public/locales/es/media.json @@ -4,7 +4,8 @@ "images": "Imágenes", "video": "Video", "tts": "TTS", - "sound": "Sonido" + "sound": "Sonido", + "transform": "Transform" } }, "image": { diff --git a/core/http/react-ui/public/locales/es/nav.json b/core/http/react-ui/public/locales/es/nav.json index 0c831a599..bbb2084fe 100644 --- a/core/http/react-ui/public/locales/es/nav.json +++ b/core/http/react-ui/public/locales/es/nav.json @@ -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" } } diff --git a/core/http/react-ui/public/locales/id/home.json b/core/http/react-ui/public/locales/id/home.json index b01529c1b..87fbaeaab 100644 --- a/core/http/react-ui/public/locales/id/home.json +++ b/core/http/react-ui/public/locales/id/home.json @@ -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" } -} \ No newline at end of file +} diff --git a/core/http/react-ui/public/locales/id/media.json b/core/http/react-ui/public/locales/id/media.json index bf7b7bc13..19918876c 100644 --- a/core/http/react-ui/public/locales/id/media.json +++ b/core/http/react-ui/public/locales/id/media.json @@ -4,7 +4,8 @@ "images": "Gambar", "video": "Video", "tts": "TTS", - "sound": "Suara" + "sound": "Suara", + "transform": "Transform" } }, "image": { diff --git a/core/http/react-ui/public/locales/id/nav.json b/core/http/react-ui/public/locales/id/nav.json index f06ba0c28..34d025277 100644 --- a/core/http/react-ui/public/locales/id/nav.json +++ b/core/http/react-ui/public/locales/id/nav.json @@ -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" } -} \ No newline at end of file +} diff --git a/core/http/react-ui/public/locales/it/home.json b/core/http/react-ui/public/locales/it/home.json index 32adedf71..733709a10 100644 --- a/core/http/react-ui/public/locales/it/home.json +++ b/core/http/react-ui/public/locales/it/home.json @@ -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" } } diff --git a/core/http/react-ui/public/locales/it/media.json b/core/http/react-ui/public/locales/it/media.json index cc00e6d21..631c83b22 100644 --- a/core/http/react-ui/public/locales/it/media.json +++ b/core/http/react-ui/public/locales/it/media.json @@ -4,7 +4,8 @@ "images": "Immagini", "video": "Video", "tts": "TTS", - "sound": "Audio" + "sound": "Audio", + "transform": "Transform" } }, "image": { diff --git a/core/http/react-ui/public/locales/it/nav.json b/core/http/react-ui/public/locales/it/nav.json index e3d3ec434..492f4b8db 100644 --- a/core/http/react-ui/public/locales/it/nav.json +++ b/core/http/react-ui/public/locales/it/nav.json @@ -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" } } diff --git a/core/http/react-ui/public/locales/ko/home.json b/core/http/react-ui/public/locales/ko/home.json index 007885b2f..170343b2a 100644 --- a/core/http/react-ui/public/locales/ko/home.json +++ b/core/http/react-ui/public/locales/ko/home.json @@ -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": "복사됨" } } diff --git a/core/http/react-ui/public/locales/ko/media.json b/core/http/react-ui/public/locales/ko/media.json index b52bdc6d1..0a5b5e57f 100644 --- a/core/http/react-ui/public/locales/ko/media.json +++ b/core/http/react-ui/public/locales/ko/media.json @@ -4,7 +4,8 @@ "images": "이미지", "video": "비디오", "tts": "TTS", - "sound": "사운드" + "sound": "사운드", + "transform": "Transform" } }, "image": { diff --git a/core/http/react-ui/public/locales/ko/nav.json b/core/http/react-ui/public/locales/ko/nav.json index 4c152ca86..98902880d 100644 --- a/core/http/react-ui/public/locales/ko/nav.json +++ b/core/http/react-ui/public/locales/ko/nav.json @@ -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": "학습" } } diff --git a/core/http/react-ui/public/locales/zh-CN/home.json b/core/http/react-ui/public/locales/zh-CN/home.json index 392e817be..b17554a04 100644 --- a/core/http/react-ui/public/locales/zh-CN/home.json +++ b/core/http/react-ui/public/locales/zh-CN/home.json @@ -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": "已复制" } } diff --git a/core/http/react-ui/public/locales/zh-CN/media.json b/core/http/react-ui/public/locales/zh-CN/media.json index b23af26f7..9f87deaca 100644 --- a/core/http/react-ui/public/locales/zh-CN/media.json +++ b/core/http/react-ui/public/locales/zh-CN/media.json @@ -4,7 +4,8 @@ "images": "图像", "video": "视频", "tts": "TTS", - "sound": "声音" + "sound": "声音", + "transform": "Transform" } }, "image": { diff --git a/core/http/react-ui/public/locales/zh-CN/nav.json b/core/http/react-ui/public/locales/zh-CN/nav.json index 84fff7c91..58805eec1 100644 --- a/core/http/react-ui/public/locales/zh-CN/nav.json +++ b/core/http/react-ui/public/locales/zh-CN/nav.json @@ -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": "训练" } } diff --git a/core/http/react-ui/src/App.css b/core/http/react-ui/src/App.css index 649174b1d..837cb1dca 100644 --- a/core/http/react-ui/src/App.css +++ b/core/http/react-ui/src/App.css @@ -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; } +} diff --git a/core/http/react-ui/src/App.jsx b/core/http/react-ui/src/App.jsx index d9f170a39..e14035a3c 100644 --- a/core/http/react-ui/src/App.jsx +++ b/core/http/react-ui/src/App.jsx @@ -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() {
-
+
{/* 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. */} - + 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). */} + }>
diff --git a/core/http/react-ui/src/components/HomeConnect.jsx b/core/http/react-ui/src/components/HomeConnect.jsx new file mode 100644 index 000000000..46e211d41 --- /dev/null +++ b/core/http/react-ui/src/components/HomeConnect.jsx @@ -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 ( +
+
+