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() { )}
-