From 58d2a17c5d7e4f7a64b06abad8e1f9ef2fdecc2a Mon Sep 17 00:00:00 2001 From: Richard Palethorpe Date: Thu, 12 Mar 2026 16:46:12 +0000 Subject: [PATCH] fix(ui): Move routes to /app to avoid conflict with API endpoints Also test for regressions in HTTP GET API key exempted endpoints because this list can get out of sync with the UI routes. Also fix support for proxying on a different prefix both server and client side. Signed-off-by: Richard Palethorpe --- core/cli/run.go | 2 +- core/http/app.go | 36 ++- core/http/middleware/auth_test.go | 228 ++++++++++++++++++ core/http/react-ui/src/App.jsx | 2 +- .../react-ui/src/components/ResourceCards.jsx | 3 +- core/http/react-ui/src/components/Sidebar.jsx | 45 ++-- core/http/react-ui/src/hooks/useChat.js | 5 +- core/http/react-ui/src/hooks/useMCPClient.js | 3 +- core/http/react-ui/src/hooks/useOperations.js | 13 +- core/http/react-ui/src/pages/AgentChat.jsx | 5 +- core/http/react-ui/src/pages/AgentCreate.jsx | 6 +- .../react-ui/src/pages/AgentJobDetails.jsx | 6 +- core/http/react-ui/src/pages/AgentJobs.jsx | 18 +- core/http/react-ui/src/pages/AgentStatus.jsx | 9 +- .../react-ui/src/pages/AgentTaskDetails.jsx | 19 +- core/http/react-ui/src/pages/Agents.jsx | 14 +- core/http/react-ui/src/pages/Backends.jsx | 2 +- core/http/react-ui/src/pages/Chat.jsx | 2 +- core/http/react-ui/src/pages/Collections.jsx | 4 +- core/http/react-ui/src/pages/Explorer.jsx | 2 +- core/http/react-ui/src/pages/Home.jsx | 17 +- core/http/react-ui/src/pages/ImportModel.jsx | 4 +- core/http/react-ui/src/pages/Login.jsx | 5 +- core/http/react-ui/src/pages/Manage.jsx | 10 +- core/http/react-ui/src/pages/ModelEditor.jsx | 5 +- core/http/react-ui/src/pages/Models.jsx | 9 +- core/http/react-ui/src/pages/NotFound.jsx | 2 +- core/http/react-ui/src/pages/SkillEdit.jsx | 8 +- core/http/react-ui/src/pages/Skills.jsx | 6 +- core/http/react-ui/src/router.jsx | 99 ++++---- core/http/react-ui/src/utils/api.js | 29 +-- core/http/react-ui/src/utils/artifacts.js | 3 +- core/http/react-ui/src/utils/basePath.js | 16 ++ docs/content/reference/cli-reference.md | 2 +- 34 files changed, 468 insertions(+), 171 deletions(-) create mode 100644 core/http/middleware/auth_test.go create mode 100644 core/http/react-ui/src/utils/basePath.js diff --git a/core/cli/run.go b/core/cli/run.go index 2aa39985960f..163797ac08aa 100644 --- a/core/cli/run.go +++ b/core/cli/run.go @@ -68,7 +68,7 @@ type RunCMD struct { UseSubtleKeyComparison bool `env:"LOCALAI_SUBTLE_KEY_COMPARISON" default:"false" help:"If true, API Key validation comparisons will be performed using constant-time comparisons rather than simple equality. This trades off performance on each request for resiliancy against timing attacks." group:"hardening"` DisableApiKeyRequirementForHttpGet bool `env:"LOCALAI_DISABLE_API_KEY_REQUIREMENT_FOR_HTTP_GET" default:"false" help:"If true, a valid API key is not required to issue GET requests to portions of the web ui. This should only be enabled in secure testing environments" group:"hardening"` DisableMetricsEndpoint bool `env:"LOCALAI_DISABLE_METRICS_ENDPOINT,DISABLE_METRICS_ENDPOINT" default:"false" help:"Disable the /metrics endpoint" group:"api"` - HttpGetExemptedEndpoints []string `env:"LOCALAI_HTTP_GET_EXEMPTED_ENDPOINTS" default:"^/$,^/browse/?$,^/talk/?$,^/p2p/?$,^/chat/?$,^/image/?$,^/text2image/?$,^/tts/?$,^/static/.*$,^/swagger.*$" help:"If LOCALAI_DISABLE_API_KEY_REQUIREMENT_FOR_HTTP_GET is overriden to true, this is the list of endpoints to exempt. Only adjust this in case of a security incident or as a result of a personal security posture review" group:"hardening"` + HttpGetExemptedEndpoints []string `env:"LOCALAI_HTTP_GET_EXEMPTED_ENDPOINTS" default:"^/$,^/app(/.*)?$,^/browse(/.*)?$,^/login/?$,^/explorer/?$,^/assets/.*$,^/static/.*$,^/swagger.*$" help:"If LOCALAI_DISABLE_API_KEY_REQUIREMENT_FOR_HTTP_GET is overriden to true, this is the list of endpoints to exempt. Only adjust this in case of a security incident or as a result of a personal security posture review" group:"hardening"` Peer2Peer bool `env:"LOCALAI_P2P,P2P" name:"p2p" default:"false" help:"Enable P2P mode" group:"p2p"` Peer2PeerDHTInterval int `env:"LOCALAI_P2P_DHT_INTERVAL,P2P_DHT_INTERVAL" default:"360" name:"p2p-dht-interval" help:"Interval for DHT refresh (used during token generation)" group:"p2p"` Peer2PeerOTPInterval int `env:"LOCALAI_P2P_OTP_INTERVAL,P2P_OTP_INTERVAL" default:"9000" name:"p2p-otp-interval" help:"Interval for OTP refresh (used during token generation)" group:"p2p"` diff --git a/core/http/app.go b/core/http/app.go index faf343a385b2..138515fb7edc 100644 --- a/core/http/app.go +++ b/core/http/app.go @@ -270,8 +270,31 @@ func API(application *application.Application) (*echo.Echo, error) { // Enable SPA fallback in the 404 handler for client-side routing spaFallback = serveIndex - // Serve React SPA at / - e.GET("/", serveIndex) + // Serve React SPA at /app + e.GET("/app", serveIndex) + e.GET("/app/*", serveIndex) + + // prefixRedirect performs a redirect that preserves X-Forwarded-Prefix for reverse-proxy support. + prefixRedirect := func(c echo.Context, target string) error { + if prefix := c.Request().Header.Get("X-Forwarded-Prefix"); prefix != "" { + target = strings.TrimSuffix(prefix, "/") + target + } + return c.Redirect(http.StatusMovedPermanently, target) + } + + // Redirect / to /app + e.GET("/", func(c echo.Context) error { + return prefixRedirect(c, "/app") + }) + + // Backward compatibility: redirect /browse/* to /app/* + e.GET("/browse", func(c echo.Context) error { + return prefixRedirect(c, "/app") + }) + e.GET("/browse/*", func(c echo.Context) error { + p := c.Param("*") + return prefixRedirect(c, "/app/"+p) + }) // Serve React static assets (JS, CSS, etc.) serveReactAsset := func(c echo.Context) error { @@ -291,15 +314,6 @@ func API(application *application.Application) (*echo.Echo, error) { return echo.NewHTTPError(http.StatusNotFound) } e.GET("/assets/*", serveReactAsset) - - // Backward compatibility: redirect /app/* to /* - e.GET("/app", func(c echo.Context) error { - return c.Redirect(http.StatusMovedPermanently, "/") - }) - e.GET("/app/*", func(c echo.Context) error { - p := c.Param("*") - return c.Redirect(http.StatusMovedPermanently, "/"+p) - }) } } routes.RegisterJINARoutes(e, requestExtractor, application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig()) diff --git a/core/http/middleware/auth_test.go b/core/http/middleware/auth_test.go new file mode 100644 index 000000000000..6e75b80a0580 --- /dev/null +++ b/core/http/middleware/auth_test.go @@ -0,0 +1,228 @@ +package middleware_test + +import ( + "net/http" + "net/http/httptest" + + "github.com/labstack/echo/v4" + "github.com/mudler/LocalAI/core/config" + . "github.com/mudler/LocalAI/core/http/middleware" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// ok is a simple handler that returns 200 OK. +func ok(c echo.Context) error { + return c.String(http.StatusOK, "ok") +} + +// newAuthApp creates a minimal Echo app with auth middleware applied. +// Requests that fail auth with Content-Type: application/json get a JSON 401 +// (no template renderer needed). +func newAuthApp(appConfig *config.ApplicationConfig) *echo.Echo { + e := echo.New() + + mw, err := GetKeyAuthConfig(appConfig) + Expect(err).ToNot(HaveOccurred()) + e.Use(mw) + + // Sensitive API routes + e.GET("/v1/models", ok) + e.POST("/v1/chat/completions", ok) + + // UI routes + e.GET("/app", ok) + e.GET("/app/*", ok) + e.GET("/browse", ok) + e.GET("/browse/*", ok) + e.GET("/login", ok) + e.GET("/explorer", ok) + e.GET("/assets/*", ok) + e.POST("/app", ok) + + return e +} + +// doRequest performs an HTTP request against the given Echo app and returns the recorder. +func doRequest(e *echo.Echo, method, path string, opts ...func(*http.Request)) *httptest.ResponseRecorder { + req := httptest.NewRequest(method, path, nil) + req.Header.Set("Content-Type", "application/json") + for _, opt := range opts { + opt(req) + } + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + return rec +} + +func withBearerToken(token string) func(*http.Request) { + return func(req *http.Request) { + req.Header.Set("Authorization", "Bearer "+token) + } +} + +func withXApiKey(key string) func(*http.Request) { + return func(req *http.Request) { + req.Header.Set("x-api-key", key) + } +} + +func withXiApiKey(key string) func(*http.Request) { + return func(req *http.Request) { + req.Header.Set("xi-api-key", key) + } +} + +func withTokenCookie(token string) func(*http.Request) { + return func(req *http.Request) { + req.AddCookie(&http.Cookie{Name: "token", Value: token}) + } +} + +var _ = Describe("Auth Middleware", func() { + + Context("when API keys are configured", func() { + var app *echo.Echo + const validKey = "sk-test-key-123" + + BeforeEach(func() { + appConfig := config.NewApplicationConfig() + appConfig.ApiKeys = []string{validKey} + app = newAuthApp(appConfig) + }) + + It("returns 401 for GET request without a key", func() { + rec := doRequest(app, http.MethodGet, "/v1/models") + Expect(rec.Code).To(Equal(http.StatusUnauthorized)) + }) + + It("returns 401 for POST request without a key", func() { + rec := doRequest(app, http.MethodPost, "/v1/chat/completions") + Expect(rec.Code).To(Equal(http.StatusUnauthorized)) + }) + + It("returns 401 for request with an invalid key", func() { + rec := doRequest(app, http.MethodGet, "/v1/models", withBearerToken("wrong-key")) + Expect(rec.Code).To(Equal(http.StatusUnauthorized)) + }) + + It("passes through with valid Bearer token in Authorization header", func() { + rec := doRequest(app, http.MethodGet, "/v1/models", withBearerToken(validKey)) + Expect(rec.Code).To(Equal(http.StatusOK)) + }) + + It("passes through with valid x-api-key header", func() { + rec := doRequest(app, http.MethodGet, "/v1/models", withXApiKey(validKey)) + Expect(rec.Code).To(Equal(http.StatusOK)) + }) + + It("passes through with valid xi-api-key header", func() { + rec := doRequest(app, http.MethodGet, "/v1/models", withXiApiKey(validKey)) + Expect(rec.Code).To(Equal(http.StatusOK)) + }) + + It("passes through with valid token cookie", func() { + rec := doRequest(app, http.MethodGet, "/v1/models", withTokenCookie(validKey)) + Expect(rec.Code).To(Equal(http.StatusOK)) + }) + }) + + Context("when no API keys are configured", func() { + var app *echo.Echo + + BeforeEach(func() { + appConfig := config.NewApplicationConfig() + app = newAuthApp(appConfig) + }) + + It("passes through without any key", func() { + rec := doRequest(app, http.MethodGet, "/v1/models") + Expect(rec.Code).To(Equal(http.StatusOK)) + }) + }) + + Context("GET exempted endpoints (feature enabled)", func() { + var app *echo.Echo + const validKey = "sk-test-key-456" + + BeforeEach(func() { + appConfig := config.NewApplicationConfig( + config.WithApiKeys([]string{validKey}), + config.WithDisableApiKeyRequirementForHttpGet(true), + config.WithHttpGetExemptedEndpoints([]string{ + "^/$", + "^/app(/.*)?$", + "^/browse(/.*)?$", + "^/login/?$", + "^/explorer/?$", + "^/assets/.*$", + "^/static/.*$", + "^/swagger.*$", + }), + ) + app = newAuthApp(appConfig) + }) + + It("allows GET to /app without a key", func() { + rec := doRequest(app, http.MethodGet, "/app") + Expect(rec.Code).To(Equal(http.StatusOK)) + }) + + It("allows GET to /app/chat/model sub-route without a key", func() { + rec := doRequest(app, http.MethodGet, "/app/chat/llama3") + Expect(rec.Code).To(Equal(http.StatusOK)) + }) + + It("allows GET to /browse/models without a key", func() { + rec := doRequest(app, http.MethodGet, "/browse/models") + Expect(rec.Code).To(Equal(http.StatusOK)) + }) + + It("allows GET to /login without a key", func() { + rec := doRequest(app, http.MethodGet, "/login") + Expect(rec.Code).To(Equal(http.StatusOK)) + }) + + It("allows GET to /explorer without a key", func() { + rec := doRequest(app, http.MethodGet, "/explorer") + Expect(rec.Code).To(Equal(http.StatusOK)) + }) + + It("allows GET to /assets/main.js without a key", func() { + rec := doRequest(app, http.MethodGet, "/assets/main.js") + Expect(rec.Code).To(Equal(http.StatusOK)) + }) + + It("rejects POST to /app without a key", func() { + rec := doRequest(app, http.MethodPost, "/app") + Expect(rec.Code).To(Equal(http.StatusUnauthorized)) + }) + + It("rejects GET to /v1/models without a key", func() { + rec := doRequest(app, http.MethodGet, "/v1/models") + Expect(rec.Code).To(Equal(http.StatusUnauthorized)) + }) + }) + + Context("GET exempted endpoints (feature disabled)", func() { + var app *echo.Echo + const validKey = "sk-test-key-789" + + BeforeEach(func() { + appConfig := config.NewApplicationConfig( + config.WithApiKeys([]string{validKey}), + // DisableApiKeyRequirementForHttpGet defaults to false + config.WithHttpGetExemptedEndpoints([]string{ + "^/$", + "^/app(/.*)?$", + }), + ) + app = newAuthApp(appConfig) + }) + + It("requires auth for GET to /app even though it matches exempted pattern", func() { + rec := doRequest(app, http.MethodGet, "/app") + Expect(rec.Code).To(Equal(http.StatusUnauthorized)) + }) + }) +}) diff --git a/core/http/react-ui/src/App.jsx b/core/http/react-ui/src/App.jsx index 6320cd780cc0..421441071c96 100644 --- a/core/http/react-ui/src/App.jsx +++ b/core/http/react-ui/src/App.jsx @@ -15,7 +15,7 @@ export default function App() { const { toasts, addToast, removeToast } = useToast() const [version, setVersion] = useState('') const location = useLocation() - const isChatRoute = location.pathname.startsWith('/chat') || location.pathname.match(/^\/agents\/[^/]+\/chat/) + const isChatRoute = location.pathname.match(/\/chat(\/|$)/) || location.pathname.match(/\/agents\/[^/]+\/chat/) useEffect(() => { systemApi.version() diff --git a/core/http/react-ui/src/components/ResourceCards.jsx b/core/http/react-ui/src/components/ResourceCards.jsx index 7f111b51d337..2b3c45410207 100644 --- a/core/http/react-ui/src/components/ResourceCards.jsx +++ b/core/http/react-ui/src/components/ResourceCards.jsx @@ -1,5 +1,6 @@ import { useState } from 'react' import { getArtifactIcon, inferMetadataType } from '../utils/artifacts' +import { apiUrl } from '../utils/basePath' export default function ResourceCards({ metadata, onOpenArtifact, messageIndex, agentName }) { const [expanded, setExpanded] = useState(false) @@ -9,7 +10,7 @@ export default function ResourceCards({ metadata, onOpenArtifact, messageIndex, const items = [] const fileUrl = (absPath) => { if (!agentName) return absPath - return `/api/agents/${encodeURIComponent(agentName)}/files?path=${encodeURIComponent(absPath)}` + return apiUrl(`/api/agents/${encodeURIComponent(agentName)}/files?path=${encodeURIComponent(absPath)}`) } Object.entries(metadata).forEach(([key, values]) => { diff --git a/core/http/react-ui/src/components/Sidebar.jsx b/core/http/react-ui/src/components/Sidebar.jsx index cd2bda8700e1..c526481284d4 100644 --- a/core/http/react-ui/src/components/Sidebar.jsx +++ b/core/http/react-ui/src/components/Sidebar.jsx @@ -1,40 +1,41 @@ import { useState, useEffect } from 'react' import { NavLink } from 'react-router-dom' import ThemeToggle from './ThemeToggle' +import { apiUrl } from '../utils/basePath' const COLLAPSED_KEY = 'localai_sidebar_collapsed' const mainItems = [ - { path: '/', icon: 'fas fa-home', label: 'Home' }, - { path: '/browse', icon: 'fas fa-download', label: 'Install Models' }, - { path: '/chat', icon: 'fas fa-comments', label: 'Chat' }, - { path: '/image', icon: 'fas fa-image', label: 'Images' }, - { path: '/video', icon: 'fas fa-video', label: 'Video' }, - { path: '/tts', icon: 'fas fa-music', label: 'TTS' }, - { path: '/sound', icon: 'fas fa-volume-high', label: 'Sound' }, - { path: '/talk', icon: 'fas fa-phone', label: 'Talk' }, + { path: '/app', icon: 'fas fa-home', label: 'Home' }, + { path: '/app/models', icon: 'fas fa-download', label: 'Install Models' }, + { path: '/app/chat', icon: 'fas fa-comments', label: 'Chat' }, + { path: '/app/image', icon: 'fas fa-image', label: 'Images' }, + { path: '/app/video', icon: 'fas fa-video', label: 'Video' }, + { path: '/app/tts', icon: 'fas fa-music', label: 'TTS' }, + { path: '/app/sound', icon: 'fas fa-volume-high', label: 'Sound' }, + { path: '/app/talk', icon: 'fas fa-phone', label: 'Talk' }, ] const agentItems = [ - { path: '/agents', icon: 'fas fa-robot', label: 'Agents' }, - { path: '/skills', icon: 'fas fa-wand-magic-sparkles', label: 'Skills' }, - { path: '/collections', icon: 'fas fa-database', label: 'Memory' }, - { path: '/agent-jobs', icon: 'fas fa-tasks', label: 'MCP CI Jobs', feature: 'mcp' }, + { path: '/app/agents', icon: 'fas fa-robot', label: 'Agents' }, + { path: '/app/skills', icon: 'fas fa-wand-magic-sparkles', label: 'Skills' }, + { path: '/app/collections', icon: 'fas fa-database', label: 'Memory' }, + { path: '/app/agent-jobs', icon: 'fas fa-tasks', label: 'MCP CI Jobs', feature: 'mcp' }, ] const systemItems = [ - { path: '/backends', icon: 'fas fa-server', label: 'Backends' }, - { path: '/traces', icon: 'fas fa-chart-line', label: 'Traces' }, - { path: '/p2p', icon: 'fas fa-circle-nodes', label: 'Swarm' }, - { path: '/manage', icon: 'fas fa-desktop', label: 'System' }, - { path: '/settings', icon: 'fas fa-cog', label: 'Settings' }, + { path: '/app/backends', icon: 'fas fa-server', label: 'Backends' }, + { path: '/app/traces', icon: 'fas fa-chart-line', label: 'Traces' }, + { path: '/app/p2p', icon: 'fas fa-circle-nodes', label: 'Swarm' }, + { path: '/app/manage', icon: 'fas fa-desktop', label: 'System' }, + { path: '/app/settings', icon: 'fas fa-cog', label: 'Settings' }, ] function NavItem({ item, onClose, collapsed }) { return ( `nav-item ${isActive ? 'active' : ''}` } @@ -54,7 +55,7 @@ export default function Sidebar({ isOpen, onClose }) { }) useEffect(() => { - fetch('/api/features').then(r => r.json()).then(setFeatures).catch(() => {}) + fetch(apiUrl('/api/features')).then(r => r.json()).then(setFeatures).catch(() => {}) }, []) const toggleCollapse = () => { @@ -74,10 +75,10 @@ export default function Sidebar({ isOpen, onClose }) { {/* Logo */}
- LocalAI + LocalAI - LocalAI + LocalAI )} -
@@ -831,7 +831,7 @@ export default function AgentCreate() {
- +
) @@ -186,7 +186,7 @@ export default function AgentJobDetails() { Cancel )} - @@ -210,7 +210,7 @@ export default function AgentJobDetails() { Task

{job.task_id ? ( - navigate(`/agent-jobs/tasks/${job.task_id}`)} style={{ cursor: 'pointer', color: 'var(--color-primary)' }}> + navigate(`/app/agent-jobs/tasks/${job.task_id}`)} style={{ cursor: 'pointer', color: 'var(--color-primary)' }}> {job.task_id} ) : '-'} diff --git a/core/http/react-ui/src/pages/AgentJobs.jsx b/core/http/react-ui/src/pages/AgentJobs.jsx index de20a82d7991..cdf6a845f012 100644 --- a/core/http/react-ui/src/pages/AgentJobs.jsx +++ b/core/http/react-ui/src/pages/AgentJobs.jsx @@ -184,7 +184,7 @@ export default function AgentJobs() { Agent Jobs require at least one model with MCP (Model Context Protocol) support. Install a model first, then configure MCP in the model settings.

- @@ -219,7 +219,7 @@ export default function AgentJobs() { args: ["--flag"]`}
- @@ -260,7 +260,7 @@ export default function AgentJobs() {

No tasks defined

Create a task to get started with agent workflows.

- @@ -281,7 +281,7 @@ export default function AgentJobs() { {tasks.map(task => ( -
navigate(`/agent-jobs/tasks/${task.id || task.name}`)} style={{ cursor: 'pointer', color: 'var(--color-primary)', fontWeight: 500 }}> + navigate(`/app/agent-jobs/tasks/${task.id || task.name}`)} style={{ cursor: 'pointer', color: 'var(--color-primary)', fontWeight: 500 }}> {task.name || task.id} @@ -292,7 +292,7 @@ export default function AgentJobs() { {task.model ? ( - navigate(`/model-editor/${encodeURIComponent(task.model)}`)} style={{ cursor: 'pointer', color: 'var(--color-primary)', fontSize: '0.8125rem' }}> + navigate(`/app/model-editor/${encodeURIComponent(task.model)}`)} style={{ cursor: 'pointer', color: 'var(--color-primary)', fontSize: '0.8125rem' }}> {task.model} ) : '-'} @@ -316,7 +316,7 @@ export default function AgentJobs() { - {(job.status === 'running' || job.status === 'pending') && ( diff --git a/core/http/react-ui/src/pages/AgentStatus.jsx b/core/http/react-ui/src/pages/AgentStatus.jsx index ad45556ae9e1..b16b6675a105 100644 --- a/core/http/react-ui/src/pages/AgentStatus.jsx +++ b/core/http/react-ui/src/pages/AgentStatus.jsx @@ -1,6 +1,7 @@ import { useState, useEffect, useCallback } from 'react' import { useParams, useNavigate, useOutletContext } from 'react-router-dom' import { agentsApi } from '../utils/api' +import { apiUrl } from '../utils/basePath' function ObservableSummary({ observable }) { const creation = observable?.creation || {} @@ -215,7 +216,7 @@ export default function AgentStatus() { // SSE for real-time observable updates useEffect(() => { - const url = `/api/agents/${encodeURIComponent(name)}/sse` + const url = apiUrl(`/api/agents/${encodeURIComponent(name)}/sse`) const es = new EventSource(url) es.addEventListener('observable_update', (e) => { @@ -358,10 +359,10 @@ export default function AgentStatus() {

Agent observables and activity history

- -
diff --git a/core/http/react-ui/src/pages/AgentTaskDetails.jsx b/core/http/react-ui/src/pages/AgentTaskDetails.jsx index 4a8bc6c02a65..6e497782f01c 100644 --- a/core/http/react-ui/src/pages/AgentTaskDetails.jsx +++ b/core/http/react-ui/src/pages/AgentTaskDetails.jsx @@ -1,6 +1,7 @@ import { useState, useEffect, useCallback } from 'react' import { useParams, useNavigate, useOutletContext, useLocation } from 'react-router-dom' import { agentJobsApi } from '../utils/api' +import { basePath } from '../utils/basePath' import ModelSelector from '../components/ModelSelector' import LoadingSpinner from '../components/LoadingSpinner' @@ -140,7 +141,7 @@ export default function AgentTaskDetails() { await agentJobsApi.updateTask(id, body) addToast('Task updated', 'success') } - navigate('/agent-jobs') + navigate('/app/agent-jobs') } catch (err) { addToast(`Save failed: ${err.message}`, 'error') } finally { @@ -167,10 +168,10 @@ export default function AgentTaskDetails() { {task.description &&

{task.description}

}
- -
@@ -226,13 +227,13 @@ export default function AgentTaskDetails() {
Execute by name
-{`curl -X POST ${window.location.origin}/api/agent/tasks/${encodeURIComponent(task.name)}/execute`}
+{`curl -X POST ${window.location.origin}${basePath}/api/agent/tasks/${encodeURIComponent(task.name)}/execute`}
               
Execute with multimedia
-{`curl -X POST ${window.location.origin}/api/agent/tasks/${encodeURIComponent(task.name)}/execute \\
+{`curl -X POST ${window.location.origin}${basePath}/api/agent/tasks/${encodeURIComponent(task.name)}/execute \\
   -H "Content-Type: application/json" \\
   -d '{"multimedia": {"images": [{"url": "https://example.com/image.jpg"}]}}'`}
               
@@ -240,7 +241,7 @@ export default function AgentTaskDetails() {
Check job status
-{`curl ${window.location.origin}/api/agent/jobs/`}
+{`curl ${window.location.origin}${basePath}/api/agent/jobs/`}
               
@@ -285,7 +286,7 @@ export default function AgentTaskDetails() { {statusBadge(job.status)} {formatDate(job.created_at)} - @@ -305,7 +306,7 @@ export default function AgentTaskDetails() {

{isNew ? 'Create Task' : 'Edit Task'}

-
@@ -500,7 +501,7 @@ export default function AgentTaskDetails() { - +
diff --git a/core/http/react-ui/src/pages/Agents.jsx b/core/http/react-ui/src/pages/Agents.jsx index 6232bcdcf32e..73b25c5a4625 100644 --- a/core/http/react-ui/src/pages/Agents.jsx +++ b/core/http/react-ui/src/pages/Agents.jsx @@ -106,7 +106,7 @@ export default function Agents() { try { const text = await file.text() const config = JSON.parse(text) - navigate('/agents/new', { state: { importedConfig: config } }) + navigate('/app/agents/new', { state: { importedConfig: config } }) } catch (err) { addToast(`Failed to parse agent file: ${err.message}`, 'error') } @@ -177,7 +177,7 @@ export default function Agents() { Import - @@ -198,7 +198,7 @@ export default function Agents() {

)}
-
- navigate('/manage')} style={{ cursor: 'pointer' }}> + navigate('/app/manage')} style={{ cursor: 'pointer' }}>
{installedCount}
Installed
diff --git a/core/http/react-ui/src/pages/Chat.jsx b/core/http/react-ui/src/pages/Chat.jsx index ef149832d618..006dff0ffe72 100644 --- a/core/http/react-ui/src/pages/Chat.jsx +++ b/core/http/react-ui/src/pages/Chat.jsx @@ -889,7 +889,7 @@ export default function Chat() {
- diff --git a/core/http/react-ui/src/pages/Home.jsx b/core/http/react-ui/src/pages/Home.jsx index 0f50ce0ef261..a69a82751429 100644 --- a/core/http/react-ui/src/pages/Home.jsx +++ b/core/http/react-ui/src/pages/Home.jsx @@ -1,5 +1,6 @@ import { useState, useEffect, useRef, useCallback } from 'react' import { useNavigate, useOutletContext } from 'react-router-dom' +import { apiUrl } from '../utils/basePath' import ModelSelector from '../components/ModelSelector' import ClientMCPDropdown from '../components/ClientMCPDropdown' import { useResources } from '../hooks/useResources' @@ -193,7 +194,7 @@ export default function Home() { newChat: true, } localStorage.setItem('localai_index_chat_data', JSON.stringify(chatData)) - navigate(`/chat/${encodeURIComponent(selectedModel)}`) + navigate(`/app/chat/${encodeURIComponent(selectedModel)}`) }, [message, placeholderText, allFiles, selectedModel, mcpMode, mcpSelectedServers, clientMCPSelectedIds, addToast, navigate]) const handleSubmit = (e) => { @@ -239,7 +240,7 @@ export default function Home() { <> {/* Hero with logo */}
- LocalAI + LocalAI

How can I help you today?

Ask me anything, and I'll do my best to assist you.

@@ -372,13 +373,13 @@ export default function Home() { {/* Quick links */}
- - - @@ -443,7 +444,7 @@ export default function Home() {

Model Gallery

Browse and install from a curated collection of open-source AI models

-
navigate('/import-model')} style={{ cursor: 'pointer' }}> +
navigate('/app/import-model')} style={{ cursor: 'pointer' }}>
@@ -487,10 +488,10 @@ export default function Home() { {/* Action buttons */}
- - diff --git a/core/http/react-ui/src/pages/ImportModel.jsx b/core/http/react-ui/src/pages/ImportModel.jsx index 2b0e391a5f63..278ecd093b90 100644 --- a/core/http/react-ui/src/pages/ImportModel.jsx +++ b/core/http/react-ui/src/pages/ImportModel.jsx @@ -112,7 +112,7 @@ export default function ImportModel() { setIsSubmitting(false) setJobProgress(null) addToast('Model imported successfully!', 'success') - navigate('/manage') + navigate('/app/manage') } else if (data.error || (data.message && data.message.startsWith('error:'))) { clearInterval(pollRef.current) pollRef.current = null @@ -185,7 +185,7 @@ export default function ImportModel() { try { await modelsApi.importConfig(yamlContent, 'application/x-yaml') addToast('Model configuration imported successfully!', 'success') - navigate('/manage') + navigate('/app/manage') } catch (err) { addToast(`Import failed: ${err.message}`, 'error') } finally { diff --git a/core/http/react-ui/src/pages/Login.jsx b/core/http/react-ui/src/pages/Login.jsx index ffaf680c4ba1..62eef6bfa950 100644 --- a/core/http/react-ui/src/pages/Login.jsx +++ b/core/http/react-ui/src/pages/Login.jsx @@ -1,5 +1,6 @@ import { useState } from 'react' import { useNavigate } from 'react-router-dom' +import { apiUrl } from '../utils/basePath' export default function Login() { const navigate = useNavigate() @@ -14,7 +15,7 @@ export default function Login() { } // Set token as cookie document.cookie = `token=${encodeURIComponent(token.trim())}; path=/; SameSite=Strict` - navigate('/') + navigate('/app') } return ( @@ -28,7 +29,7 @@ export default function Login() { }}>
- LocalAI + LocalAI

LocalAI

diff --git a/core/http/react-ui/src/pages/Manage.jsx b/core/http/react-ui/src/pages/Manage.jsx index 56ab73f233c6..2d04f5fb272d 100644 --- a/core/http/react-ui/src/pages/Manage.jsx +++ b/core/http/react-ui/src/pages/Manage.jsx @@ -168,10 +168,10 @@ export default function Manage() { Install a model from the gallery to get started.

- - @@ -201,7 +201,7 @@ export default function Manage() { {model.id} { e.preventDefault(); navigate(`/model-editor/${encodeURIComponent(model.id)}`) }} + onClick={(e) => { e.preventDefault(); navigate(`/app/model-editor/${encodeURIComponent(model.id)}`) }} style={{ fontSize: '0.75rem', color: 'var(--color-primary)' }} title="Edit config" > @@ -225,7 +225,7 @@ export default function Manage() { @@ -272,7 +272,7 @@ export default function Manage() { Install backends from the gallery to extend functionality.

- diff --git a/core/http/react-ui/src/pages/ModelEditor.jsx b/core/http/react-ui/src/pages/ModelEditor.jsx index 939bad301ec0..3bfedb7d32b2 100644 --- a/core/http/react-ui/src/pages/ModelEditor.jsx +++ b/core/http/react-ui/src/pages/ModelEditor.jsx @@ -1,6 +1,7 @@ import { useState, useEffect } from 'react' import { useParams, useNavigate, useOutletContext } from 'react-router-dom' import { modelsApi } from '../utils/api' +import { apiUrl } from '../utils/basePath' import LoadingSpinner from '../components/LoadingSpinner' import CodeEditor from '../components/CodeEditor' @@ -27,7 +28,7 @@ export default function ModelEditor() { setSaving(true) try { // Send raw YAML/text to the edit endpoint (not JSON-encoded) - const response = await fetch(`/models/edit/${encodeURIComponent(name)}`, { + const response = await fetch(apiUrl(`/models/edit/${encodeURIComponent(name)}`), { method: 'POST', headers: { 'Content-Type': 'application/x-yaml' }, body: config, @@ -53,7 +54,7 @@ export default function ModelEditor() {

Model Editor

{decodeURIComponent(name)}

-
diff --git a/core/http/react-ui/src/pages/Models.jsx b/core/http/react-ui/src/pages/Models.jsx index dd112cc3262a..355c97ce6a33 100644 --- a/core/http/react-ui/src/pages/Models.jsx +++ b/core/http/react-ui/src/pages/Models.jsx @@ -176,6 +176,11 @@ export default function Models() { fetchModels() }, [page, filter, sort, order]) + // Re-fetch when operations change (install/delete completion) + useEffect(() => { + if (!loading) fetchModels() + }, [operations.length]) + const handleSearch = (value) => { setSearch(value) if (debounceRef.current) clearTimeout(debounceRef.current) @@ -263,13 +268,13 @@ export default function Models() {
Available
-
diff --git a/core/http/react-ui/src/pages/NotFound.jsx b/core/http/react-ui/src/pages/NotFound.jsx index c5d1a480fe33..f3342f70ee96 100644 --- a/core/http/react-ui/src/pages/NotFound.jsx +++ b/core/http/react-ui/src/pages/NotFound.jsx @@ -10,7 +10,7 @@ export default function NotFound() {

404

Page Not Found

The page you're looking for doesn't exist.

-
diff --git a/core/http/react-ui/src/pages/SkillEdit.jsx b/core/http/react-ui/src/pages/SkillEdit.jsx index 6ba3b38406cd..744d7c0078c7 100644 --- a/core/http/react-ui/src/pages/SkillEdit.jsx +++ b/core/http/react-ui/src/pages/SkillEdit.jsx @@ -298,7 +298,7 @@ export default function SkillEdit() { }) .catch((err) => { addToast(err.message || 'Failed to load skill', 'error') - navigate('/skills') + navigate('/app/skills') }) .finally(() => setLoading(false)) } @@ -332,7 +332,7 @@ export default function SkillEdit() { await skillsApi.update(name, { ...payload, name: undefined }) addToast('Skill updated', 'success') } - navigate('/skills') + navigate('/app/skills') } catch (err) { addToast(err.message || 'Save failed', 'error') } finally { @@ -493,7 +493,7 @@ export default function SkillEdit() { } `} - navigate('/skills')}> + navigate('/app/skills')}> Back to skills
@@ -613,7 +613,7 @@ export default function SkillEdit() { )}
-