fix(ui): Move routes to /app to avoid conflict with API endpoints (#8978)

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 <io@richiejp.com>
This commit is contained in:
Richard Palethorpe
2026-03-13 20:38:18 +00:00
committed by GitHub
parent f9a850c02a
commit ed2c6da4bf
34 changed files with 468 additions and 171 deletions

View File

@@ -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"`

View File

@@ -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())

View File

@@ -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))
})
})
})

View File

@@ -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()

View File

@@ -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]) => {

View File

@@ -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 (
<NavLink
to={item.path}
end={item.path === '/'}
end={item.path === '/app'}
className={({ isActive }) =>
`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 */}
<div className="sidebar-header">
<a href="./" className="sidebar-logo-link">
<img src="/static/logo_horizontal.png" alt="LocalAI" className="sidebar-logo-img" />
<img src={apiUrl('/static/logo_horizontal.png')} alt="LocalAI" className="sidebar-logo-img" />
</a>
<a href="./" className="sidebar-logo-icon" title="LocalAI">
<img src="/static/logo.png" alt="LocalAI" className="sidebar-logo-icon-img" />
<img src={apiUrl('/static/logo.png')} alt="LocalAI" className="sidebar-logo-icon-img" />
</a>
<button className="sidebar-close-btn" onClick={onClose} aria-label="Close menu">
<i className="fas fa-times" />
@@ -107,7 +108,7 @@ export default function Sidebar({ isOpen, onClose }) {
<div className="sidebar-section">
<div className="sidebar-section-title">System</div>
<a
href="/swagger/index.html"
href={apiUrl('/swagger/index.html')}
target="_blank"
rel="noopener noreferrer"
className="nav-item"

View File

@@ -1,5 +1,6 @@
import { useState, useCallback, useRef, useEffect } from 'react'
import { API_CONFIG } from '../utils/config'
import { apiUrl } from '../utils/basePath'
const thinkingTagRegex = /<thinking>([\s\S]*?)<\/thinking>|<think>([\s\S]*?)<\/think>/g
const openThinkTagRegex = /<thinking>|<think>/
@@ -306,7 +307,7 @@ export function useChat(initialModel = '') {
// Legacy MCP SSE streaming (custom event types from /v1/mcp/chat/completions)
try {
const timeoutId = setTimeout(() => controller.abort(), 300000) // 5 min timeout
const response = await fetch(endpoint, {
const response = await fetch(apiUrl(endpoint), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody),
@@ -452,7 +453,7 @@ export function useChat(initialModel = '') {
let fullToolCalls = [] // Tool calls with id for agentic loop
try {
const response = await fetch(endpoint, {
const response = await fetch(apiUrl(endpoint), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(loopBody),

View File

@@ -4,11 +4,12 @@ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'
import { getToolUiResourceUri, isToolVisibilityAppOnly } from '@modelcontextprotocol/ext-apps/app-bridge'
import { API_CONFIG } from '../utils/config'
import { apiUrl } from '../utils/basePath'
function buildProxyUrl(targetUrl, useProxy = true) {
if (!useProxy) return new URL(targetUrl)
const base = window.location.origin
return new URL(`${base}${API_CONFIG.endpoints.corsProxy}?url=${encodeURIComponent(targetUrl)}`)
return new URL(`${base}${apiUrl(API_CONFIG.endpoints.corsProxy)}?url=${encodeURIComponent(targetUrl)}`)
}
export function useMCPClient() {

View File

@@ -8,6 +8,7 @@ export function useOperations(pollInterval = 1000) {
const intervalRef = useRef(null)
const previousCountRef = useRef(0)
const onAllCompleteRef = useRef(null)
const fetchOperations = useCallback(async () => {
try {
@@ -19,10 +20,9 @@ export function useOperations(pollInterval = 1000) {
const activeOps = ops.filter(op => !op.error)
const failedOps = ops.filter(op => op.error)
// Auto-refresh the page when all active operations complete (mirrors original behavior)
// but not when there are still failed operations being shown
// Notify when all operations complete (no active or failed remaining)
if (previousCountRef.current > 0 && activeOps.length === 0 && failedOps.length === 0) {
setTimeout(() => window.location.reload(), 1000)
onAllCompleteRef.current?.()
}
previousCountRef.current = activeOps.length
@@ -64,5 +64,10 @@ export function useOperations(pollInterval = 1000) {
}
}, [fetchOperations, pollInterval])
return { operations, loading, error, cancelOperation, dismissFailedOp, refetch: fetchOperations }
// Allow callers to register a callback for when all operations finish
const onAllComplete = useCallback((cb) => {
onAllCompleteRef.current = cb
}, [])
return { operations, loading, error, cancelOperation, dismissFailedOp, refetch: fetchOperations, onAllComplete }
}

View File

@@ -1,6 +1,7 @@
import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
import { useParams, useNavigate, useOutletContext } from 'react-router-dom'
import { agentsApi } from '../utils/api'
import { apiUrl } from '../utils/basePath'
import { renderMarkdown, highlightAll } from '../utils/markdown'
import { extractCodeArtifacts, extractMetadataArtifacts, renderMarkdownWithArtifacts } from '../utils/artifacts'
import CanvasPanel from '../components/CanvasPanel'
@@ -122,7 +123,7 @@ export default function AgentChat() {
// Connect to SSE endpoint — only reconnect when agent name changes
useEffect(() => {
const url = `/api/agents/${encodeURIComponent(name)}/sse`
const url = apiUrl(`/api/agents/${encodeURIComponent(name)}/sse`)
const es = new EventSource(url)
eventSourceRef.current = es
@@ -456,7 +457,7 @@ export default function AgentChat() {
<i className="fas fa-layer-group" /> {artifacts.length}
</button>
)}
<button className="btn btn-secondary btn-sm" onClick={() => navigate(`/agents/${encodeURIComponent(name)}/status`)} title="View status & observables">
<button className="btn btn-secondary btn-sm" onClick={() => navigate(`/app/agents/${encodeURIComponent(name)}/status`)} title="View status & observables">
<i className="fas fa-chart-bar" /> Status
</button>
<button className="btn btn-secondary btn-sm" onClick={() => clearMessages()} disabled={messages.length === 0} title="Clear chat history">

View File

@@ -435,7 +435,7 @@ export default function AgentCreate() {
await agentsApi.create(payload)
addToast(`Agent "${form.name}" created`, 'success')
}
navigate('/agents')
navigate('/app/agents')
} catch (err) {
addToast(`Save failed: ${err.message}`, 'error')
} finally {
@@ -789,7 +789,7 @@ export default function AgentCreate() {
<div className="page-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h1 className="page-title">{isEdit ? `Edit Agent: ${name}` : importedConfig ? 'Import Agent' : 'Create Agent'}</h1>
<button className="btn btn-secondary btn-sm" onClick={() => navigate('/agents')}>
<button className="btn btn-secondary btn-sm" onClick={() => navigate('/app/agents')}>
<i className="fas fa-arrow-left" /> Back
</button>
</div>
@@ -831,7 +831,7 @@ export default function AgentCreate() {
</div>
<div style={{ display: 'flex', gap: 'var(--spacing-sm)', justifyContent: 'flex-end', marginTop: 'var(--spacing-md)' }}>
<button type="button" className="btn btn-secondary" onClick={() => navigate('/agents')}>
<button type="button" className="btn btn-secondary" onClick={() => navigate('/app/agents')}>
<i className="fas fa-times" /> Cancel
</button>
<button type="submit" className="btn btn-primary" disabled={saving}>

View File

@@ -165,7 +165,7 @@ export default function AgentJobDetails() {
<div className="empty-state">
<div className="empty-state-icon"><i className="fas fa-search" /></div>
<h2 className="empty-state-title">Job not found</h2>
<button className="btn btn-secondary" onClick={() => navigate('/agent-jobs')}><i className="fas fa-arrow-left" /> Back</button>
<button className="btn btn-secondary" onClick={() => navigate('/app/agent-jobs')}><i className="fas fa-arrow-left" /> Back</button>
</div>
</div>
)
@@ -186,7 +186,7 @@ export default function AgentJobDetails() {
<i className="fas fa-stop" /> Cancel
</button>
)}
<button className="btn btn-secondary" onClick={() => navigate('/agent-jobs')}>
<button className="btn btn-secondary" onClick={() => navigate('/app/agent-jobs')}>
<i className="fas fa-arrow-left" /> Back
</button>
</div>
@@ -210,7 +210,7 @@ export default function AgentJobDetails() {
<span className="form-label">Task</span>
<p>
{job.task_id ? (
<a onClick={() => navigate(`/agent-jobs/tasks/${job.task_id}`)} style={{ cursor: 'pointer', color: 'var(--color-primary)' }}>
<a onClick={() => navigate(`/app/agent-jobs/tasks/${job.task_id}`)} style={{ cursor: 'pointer', color: 'var(--color-primary)' }}>
{job.task_id}
</a>
) : '-'}

View File

@@ -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.
</p>
<div style={{ display: 'flex', gap: 'var(--spacing-sm)', justifyContent: 'center' }}>
<button className="btn btn-primary" onClick={() => navigate('/browse')}>
<button className="btn btn-primary" onClick={() => navigate('/app/models')}>
<i className="fas fa-store" /> Browse Models
</button>
<a className="btn btn-secondary" href="https://localai.io/features/agent-jobs/" target="_blank" rel="noopener noreferrer">
@@ -219,7 +219,7 @@ export default function AgentJobs() {
args: ["--flag"]`}</pre>
</div>
<div style={{ display: 'flex', gap: 'var(--spacing-sm)', justifyContent: 'center' }}>
<button className="btn btn-primary" onClick={() => navigate('/manage')}>
<button className="btn btn-primary" onClick={() => navigate('/app/manage')}>
<i className="fas fa-cog" /> Manage Models
</button>
<a className="btn btn-secondary" href="https://localai.io/features/agent-jobs/" target="_blank" rel="noopener noreferrer">
@@ -238,7 +238,7 @@ export default function AgentJobs() {
<h1 className="page-title">Agent Jobs</h1>
<p className="page-subtitle">Manage agent tasks and automated workflows</p>
</div>
<button className="btn btn-primary" onClick={() => navigate('/agent-jobs/tasks/new')}>
<button className="btn btn-primary" onClick={() => navigate('/app/agent-jobs/tasks/new')}>
<i className="fas fa-plus" /> New Task
</button>
</div>
@@ -260,7 +260,7 @@ export default function AgentJobs() {
<div className="empty-state-icon"><i className="fas fa-robot" /></div>
<h2 className="empty-state-title">No tasks defined</h2>
<p className="empty-state-text">Create a task to get started with agent workflows.</p>
<button className="btn btn-primary" onClick={() => navigate('/agent-jobs/tasks/new')}>
<button className="btn btn-primary" onClick={() => navigate('/app/agent-jobs/tasks/new')}>
<i className="fas fa-plus" /> Create Task
</button>
</div>
@@ -281,7 +281,7 @@ export default function AgentJobs() {
{tasks.map(task => (
<tr key={task.id || task.name}>
<td>
<a onClick={() => navigate(`/agent-jobs/tasks/${task.id || task.name}`)} style={{ cursor: 'pointer', color: 'var(--color-primary)', fontWeight: 500 }}>
<a onClick={() => navigate(`/app/agent-jobs/tasks/${task.id || task.name}`)} style={{ cursor: 'pointer', color: 'var(--color-primary)', fontWeight: 500 }}>
{task.name || task.id}
</a>
</td>
@@ -292,7 +292,7 @@ export default function AgentJobs() {
</td>
<td>
{task.model ? (
<a onClick={() => navigate(`/model-editor/${encodeURIComponent(task.model)}`)} style={{ cursor: 'pointer', color: 'var(--color-primary)', fontSize: '0.8125rem' }}>
<a onClick={() => navigate(`/app/model-editor/${encodeURIComponent(task.model)}`)} style={{ cursor: 'pointer', color: 'var(--color-primary)', fontSize: '0.8125rem' }}>
{task.model}
</a>
) : '-'}
@@ -316,7 +316,7 @@ export default function AgentJobs() {
<button className="btn btn-primary btn-sm" onClick={() => openExecuteModal(task)} title="Execute">
<i className="fas fa-play" />
</button>
<button className="btn btn-secondary btn-sm" onClick={() => navigate(`/agent-jobs/tasks/${task.id || task.name}/edit`)} title="Edit">
<button className="btn btn-secondary btn-sm" onClick={() => navigate(`/app/agent-jobs/tasks/${task.id || task.name}/edit`)} title="Edit">
<i className="fas fa-edit" />
</button>
<button className="btn btn-danger btn-sm" onClick={() => handleDeleteTask(task.id || task.name)} title="Delete">
@@ -376,7 +376,7 @@ export default function AgentJobs() {
{filteredJobs.map(job => (
<tr key={job.id}>
<td>
<a onClick={() => navigate(`/agent-jobs/jobs/${job.id}`)} style={{ cursor: 'pointer', color: 'var(--color-primary)', fontFamily: "'JetBrains Mono', monospace", fontSize: '0.8125rem' }}>
<a onClick={() => navigate(`/app/agent-jobs/jobs/${job.id}`)} style={{ cursor: 'pointer', color: 'var(--color-primary)', fontFamily: "'JetBrains Mono', monospace", fontSize: '0.8125rem' }}>
{job.id?.slice(0, 12)}...
</a>
</td>
@@ -387,7 +387,7 @@ export default function AgentJobs() {
</td>
<td>
<div style={{ display: 'flex', gap: 'var(--spacing-xs)', justifyContent: 'flex-end' }}>
<button className="btn btn-secondary btn-sm" onClick={() => navigate(`/agent-jobs/jobs/${job.id}`)} title="View">
<button className="btn btn-secondary btn-sm" onClick={() => navigate(`/app/agent-jobs/jobs/${job.id}`)} title="View">
<i className="fas fa-eye" />
</button>
{(job.status === 'running' || job.status === 'pending') && (

View File

@@ -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() {
<p className="page-subtitle">Agent observables and activity history</p>
</div>
<div style={{ display: 'flex', gap: 'var(--spacing-sm)' }}>
<button className="btn btn-secondary" onClick={() => navigate(`/agents/${encodeURIComponent(name)}/chat`)}>
<button className="btn btn-secondary" onClick={() => navigate(`/app/agents/${encodeURIComponent(name)}/chat`)}>
<i className="fas fa-comment" /> Chat
</button>
<button className="btn btn-secondary" onClick={() => navigate(`/agents/${encodeURIComponent(name)}/edit`)}>
<button className="btn btn-secondary" onClick={() => navigate(`/app/agents/${encodeURIComponent(name)}/edit`)}>
<i className="fas fa-edit" /> Edit
</button>
<button className="btn btn-secondary" onClick={fetchData}>
@@ -404,7 +405,7 @@ export default function AgentStatus() {
<div className="empty-state-icon"><i className="fas fa-chart-bar" /></div>
<h2 className="empty-state-title">No observables yet</h2>
<p className="empty-state-text">Send a message to the agent to see its activity here.</p>
<button className="btn btn-primary" onClick={() => navigate(`/agents/${encodeURIComponent(name)}/chat`)}>
<button className="btn btn-primary" onClick={() => navigate(`/app/agents/${encodeURIComponent(name)}/chat`)}>
<i className="fas fa-comment" /> Chat with {name}
</button>
</div>

View File

@@ -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 && <p className="page-subtitle">{task.description}</p>}
</div>
<div style={{ display: 'flex', gap: 'var(--spacing-sm)' }}>
<button className="btn btn-primary btn-sm" onClick={() => navigate(`/agent-jobs/tasks/${id}/edit`)}>
<button className="btn btn-primary btn-sm" onClick={() => navigate(`/app/agent-jobs/tasks/${id}/edit`)}>
<i className="fas fa-edit" /> Edit
</button>
<button className="btn btn-secondary btn-sm" onClick={() => navigate('/agent-jobs')}>
<button className="btn btn-secondary btn-sm" onClick={() => navigate('/app/agent-jobs')}>
<i className="fas fa-arrow-left" /> Back
</button>
</div>
@@ -226,13 +227,13 @@ export default function AgentTaskDetails() {
<div>
<span className="form-label">Execute by name</span>
<pre style={{ background: 'var(--color-bg-primary)', padding: 'var(--spacing-sm)', borderRadius: 'var(--radius-md)', fontSize: '0.75rem', fontFamily: "'JetBrains Mono', monospace", whiteSpace: 'pre-wrap', overflow: 'auto' }}>
{`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`}
</pre>
</div>
<div>
<span className="form-label">Execute with multimedia</span>
<pre style={{ background: 'var(--color-bg-primary)', padding: 'var(--spacing-sm)', borderRadius: 'var(--radius-md)', fontSize: '0.75rem', fontFamily: "'JetBrains Mono', monospace", whiteSpace: 'pre-wrap', overflow: 'auto' }}>
{`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"}]}}'`}
</pre>
@@ -240,7 +241,7 @@ export default function AgentTaskDetails() {
<div>
<span className="form-label">Check job status</span>
<pre style={{ background: 'var(--color-bg-primary)', padding: 'var(--spacing-sm)', borderRadius: 'var(--radius-md)', fontSize: '0.75rem', fontFamily: "'JetBrains Mono', monospace", whiteSpace: 'pre-wrap', overflow: 'auto' }}>
{`curl ${window.location.origin}/api/agent/jobs/<job-id>`}
{`curl ${window.location.origin}${basePath}/api/agent/jobs/<job-id>`}
</pre>
</div>
</div>
@@ -285,7 +286,7 @@ export default function AgentTaskDetails() {
<td>{statusBadge(job.status)}</td>
<td style={{ fontSize: '0.8125rem', color: 'var(--color-text-secondary)' }}>{formatDate(job.created_at)}</td>
<td>
<button className="btn btn-secondary btn-sm" onClick={() => navigate(`/agent-jobs/jobs/${job.id}`)}>
<button className="btn btn-secondary btn-sm" onClick={() => navigate(`/app/agent-jobs/jobs/${job.id}`)}>
<i className="fas fa-eye" /> View
</button>
</td>
@@ -305,7 +306,7 @@ export default function AgentTaskDetails() {
<div className="page" style={{ maxWidth: 900 }}>
<div className="page-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h1 className="page-title">{isNew ? 'Create Task' : 'Edit Task'}</h1>
<button className="btn btn-secondary btn-sm" onClick={() => navigate('/agent-jobs')}>
<button className="btn btn-secondary btn-sm" onClick={() => navigate('/app/agent-jobs')}>
<i className="fas fa-arrow-left" /> Back
</button>
</div>
@@ -500,7 +501,7 @@ export default function AgentTaskDetails() {
<button type="submit" className="btn btn-primary" disabled={saving}>
{saving ? <><i className="fas fa-spinner fa-spin" /> Saving...</> : <><i className="fas fa-save" /> {isNew ? 'Create Task' : 'Save Changes'}</>}
</button>
<button type="button" className="btn btn-secondary" onClick={() => navigate('/agent-jobs')}>Cancel</button>
<button type="button" className="btn btn-secondary" onClick={() => navigate('/app/agent-jobs')}>Cancel</button>
</div>
</form>
</div>

View File

@@ -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() {
<i className="fas fa-file-import" /> Import
<input type="file" accept=".json" className="agents-import-input" onChange={handleImport} />
</label>
<button className="btn btn-primary" onClick={() => navigate('/agents/new')}>
<button className="btn btn-primary" onClick={() => navigate('/app/agents/new')}>
<i className="fas fa-plus" /> Create Agent
</button>
</div>
@@ -198,7 +198,7 @@ export default function Agents() {
</p>
)}
<div style={{ display: 'flex', gap: 'var(--spacing-sm)', justifyContent: 'center', flexWrap: 'wrap' }}>
<button className="btn btn-primary" onClick={() => navigate('/agents/new')}>
<button className="btn btn-primary" onClick={() => navigate('/app/agents/new')}>
<i className="fas fa-plus" /> Create Agent
</button>
<label className="btn btn-secondary">
@@ -254,7 +254,7 @@ export default function Agents() {
return (
<tr key={name}>
<td>
<a className="agents-name" onClick={() => navigate(`/agents/${encodeURIComponent(name)}/chat`)}>
<a className="agents-name" onClick={() => navigate(`/app/agents/${encodeURIComponent(name)}/chat`)}>
{name}
</a>
</td>
@@ -262,7 +262,7 @@ export default function Agents() {
<td>
<a
className="agents-name"
onClick={() => navigate(`/agents/${encodeURIComponent(name)}/status`)}
onClick={() => navigate(`/app/agents/${encodeURIComponent(name)}/status`)}
title={`${agent.eventsCount} events - Click to view`}
>
{agent.eventsCount}
@@ -279,14 +279,14 @@ export default function Agents() {
</button>
<button
className="btn btn-secondary btn-sm"
onClick={() => navigate(`/agents/${encodeURIComponent(name)}/edit`)}
onClick={() => navigate(`/app/agents/${encodeURIComponent(name)}/edit`)}
title="Edit"
>
<i className="fas fa-edit" />
</button>
<button
className="btn btn-secondary btn-sm"
onClick={() => navigate(`/agents/${encodeURIComponent(name)}/chat`)}
onClick={() => navigate(`/app/agents/${encodeURIComponent(name)}/chat`)}
title="Chat"
>
<i className="fas fa-comment" />

View File

@@ -163,7 +163,7 @@ export default function Backends() {
<div style={{ color: 'var(--color-text-muted)' }}>Available</div>
</div>
<div style={{ textAlign: 'center' }}>
<a onClick={() => navigate('/manage')} style={{ cursor: 'pointer' }}>
<a onClick={() => navigate('/app/manage')} style={{ cursor: 'pointer' }}>
<div style={{ fontSize: '1.25rem', fontWeight: 700, color: 'var(--color-success)' }}>{installedCount}</div>
<div style={{ color: 'var(--color-text-muted)' }}>Installed</div>
</a>

View File

@@ -889,7 +889,7 @@ export default function Chat() {
</button>
<button
className="btn btn-secondary btn-sm"
onClick={() => navigate(`/model-editor/${encodeURIComponent(activeChat.model)}`)}
onClick={() => navigate(`/app/model-editor/${encodeURIComponent(activeChat.model)}`)}
title="Edit model config"
>
<i className="fas fa-edit" />

View File

@@ -126,13 +126,13 @@ export default function Collections() {
{collections.map((collection) => {
const name = typeof collection === 'string' ? collection : collection.name
return (
<div className="card" key={name} style={{ cursor: 'pointer' }} onClick={() => navigate(`/collections/${encodeURIComponent(name)}`)}>
<div className="card" key={name} style={{ cursor: 'pointer' }} onClick={() => navigate(`/app/collections/${encodeURIComponent(name)}`)}>
<div className="collections-card-name">
<i className="fas fa-folder" style={{ marginRight: 'var(--spacing-xs)', color: 'var(--color-primary)' }} />
{name}
</div>
<div className="collections-card-actions" onClick={(e) => e.stopPropagation()}>
<button className="btn btn-secondary btn-sm" onClick={() => navigate(`/collections/${encodeURIComponent(name)}`)} title="View details">
<button className="btn btn-secondary btn-sm" onClick={() => navigate(`/app/collections/${encodeURIComponent(name)}`)} title="View details">
<i className="fas fa-eye" /> Details
</button>
<button className="btn btn-secondary btn-sm" onClick={() => handleReset(name)} title="Reset collection">

View File

@@ -18,7 +18,7 @@ export default function Explorer() {
<p>Explorer visualization</p>
</div>
</div>
<button className="btn btn-secondary" onClick={() => navigate('/')} style={{ marginTop: 'var(--spacing-lg)' }}>
<button className="btn btn-secondary" onClick={() => navigate('/app')} style={{ marginTop: 'var(--spacing-lg)' }}>
<i className="fas fa-arrow-left" /> Back to Home
</button>
</div>

View File

@@ -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 */}
<div className="home-hero">
<img src="/static/logo.png" alt="LocalAI" className="home-logo" />
<img src={apiUrl('/static/logo.png')} alt="LocalAI" className="home-logo" />
<h1 className="home-heading">How can I help you today?</h1>
<p className="home-subheading">Ask me anything, and I'll do my best to assist you.</p>
</div>
@@ -372,13 +373,13 @@ export default function Home() {
{/* Quick links */}
<div className="home-quick-links">
<button className="home-link-btn" onClick={() => navigate('/manage')}>
<button className="home-link-btn" onClick={() => navigate('/app/manage')}>
<i className="fas fa-desktop" /> Installed Models and Backends
</button>
<button className="home-link-btn" onClick={() => navigate('/browse')}>
<button className="home-link-btn" onClick={() => navigate('/app/models')}>
<i className="fas fa-download" /> Browse Gallery
</button>
<button className="home-link-btn" onClick={() => navigate('/import-model')}>
<button className="home-link-btn" onClick={() => navigate('/app/import-model')}>
<i className="fas fa-upload" /> Import Model
</button>
<a className="home-link-btn" href="https://localai.io" target="_blank" rel="noopener noreferrer">
@@ -443,7 +444,7 @@ export default function Home() {
<h3>Model Gallery</h3>
<p>Browse and install from a curated collection of open-source AI models</p>
</div>
<div className="home-wizard-feature" onClick={() => navigate('/import-model')} style={{ cursor: 'pointer' }}>
<div className="home-wizard-feature" onClick={() => navigate('/app/import-model')} style={{ cursor: 'pointer' }}>
<div className="home-wizard-feature-icon" style={{ background: 'var(--color-accent-light)' }}>
<i className="fas fa-upload" style={{ color: 'var(--color-accent)' }} />
</div>
@@ -487,10 +488,10 @@ export default function Home() {
{/* Action buttons */}
<div className="home-wizard-actions">
<button className="btn btn-primary" onClick={() => navigate('/browse')}>
<button className="btn btn-primary" onClick={() => navigate('/app/models')}>
<i className="fas fa-store" /> Browse Model Gallery
</button>
<button className="btn btn-secondary" onClick={() => navigate('/import-model')}>
<button className="btn btn-secondary" onClick={() => navigate('/app/import-model')}>
<i className="fas fa-upload" /> Import Model
</button>
<a className="btn btn-secondary" href="https://localai.io/docs/getting-started" target="_blank" rel="noopener noreferrer">

View File

@@ -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 {

View File

@@ -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() {
}}>
<div className="card" style={{ width: '100%', maxWidth: '400px', padding: 'var(--spacing-xl)' }}>
<div style={{ textAlign: 'center', marginBottom: 'var(--spacing-xl)' }}>
<img src="/static/logo.png" alt="LocalAI" style={{ width: 64, height: 64, marginBottom: 'var(--spacing-md)' }} />
<img src={apiUrl('/static/logo.png')} alt="LocalAI" style={{ width: 64, height: 64, marginBottom: 'var(--spacing-md)' }} />
<h1 style={{ fontSize: '1.5rem', fontWeight: 700, marginBottom: 'var(--spacing-xs)' }}>
<span className="text-gradient">LocalAI</span>
</h1>

View File

@@ -168,10 +168,10 @@ export default function Manage() {
Install a model from the gallery to get started.
</p>
<div style={{ display: 'flex', gap: 'var(--spacing-sm)', justifyContent: 'center' }}>
<button className="btn btn-primary btn-sm" onClick={() => navigate('/browse')}>
<button className="btn btn-primary btn-sm" onClick={() => navigate('/app/models')}>
<i className="fas fa-store" /> Browse Gallery
</button>
<button className="btn btn-secondary btn-sm" onClick={() => navigate('/import-model')}>
<button className="btn btn-secondary btn-sm" onClick={() => navigate('/app/import-model')}>
<i className="fas fa-upload" /> Import Model
</button>
<a className="btn btn-secondary btn-sm" href="https://localai.io" target="_blank" rel="noopener noreferrer">
@@ -201,7 +201,7 @@ export default function Manage() {
<span style={{ fontWeight: 500 }}>{model.id}</span>
<a
href="#"
onClick={(e) => { 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() {
</td>
<td>
<div style={{ display: 'flex', gap: '4px', flexWrap: 'wrap' }}>
<a href="#" onClick={(e) => { e.preventDefault(); navigate(`/chat/${encodeURIComponent(model.id)}`) }} className="badge badge-info" style={{ textDecoration: 'none', cursor: 'pointer' }}>Chat</a>
<a href="#" onClick={(e) => { e.preventDefault(); navigate(`/app/chat/${encodeURIComponent(model.id)}`) }} className="badge badge-info" style={{ textDecoration: 'none', cursor: 'pointer' }}>Chat</a>
</div>
</td>
<td>
@@ -272,7 +272,7 @@ export default function Manage() {
Install backends from the gallery to extend functionality.
</p>
<div style={{ display: 'flex', gap: 'var(--spacing-sm)', justifyContent: 'center' }}>
<button className="btn btn-primary btn-sm" onClick={() => navigate('/backends')}>
<button className="btn btn-primary btn-sm" onClick={() => navigate('/app/backends')}>
<i className="fas fa-server" /> Browse Backend Gallery
</button>
<a className="btn btn-secondary btn-sm" href="https://localai.io/backends/" target="_blank" rel="noopener noreferrer">

View File

@@ -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() {
<h1 className="page-title">Model Editor</h1>
<p className="page-subtitle">{decodeURIComponent(name)}</p>
</div>
<button className="btn btn-secondary" onClick={() => navigate('/manage')}>
<button className="btn btn-secondary" onClick={() => navigate('/app/manage')}>
<i className="fas fa-arrow-left" /> Back
</button>
</div>

View File

@@ -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() {
<div style={{ color: 'var(--color-text-muted)' }}>Available</div>
</div>
<div style={{ textAlign: 'center' }}>
<a onClick={() => navigate('/manage')} style={{ cursor: 'pointer' }}>
<a onClick={() => navigate('/app/manage')} style={{ cursor: 'pointer' }}>
<div style={{ fontSize: '1.25rem', fontWeight: 700, color: 'var(--color-success)' }}>{stats.installed}</div>
<div style={{ color: 'var(--color-text-muted)' }}>Installed</div>
</a>
</div>
</div>
<button className="btn btn-secondary btn-sm" onClick={() => navigate('/import-model')}>
<button className="btn btn-secondary btn-sm" onClick={() => navigate('/app/import-model')}>
<i className="fas fa-upload" /> Import Model
</button>
</div>

View File

@@ -10,7 +10,7 @@ export default function NotFound() {
<h1 className="empty-state-title" style={{ fontSize: '3rem' }}>404</h1>
<h2 className="empty-state-title">Page Not Found</h2>
<p className="empty-state-text">The page you're looking for doesn't exist.</p>
<button className="btn btn-primary" onClick={() => navigate('/')}>
<button className="btn btn-primary" onClick={() => navigate('/app')}>
<i className="fas fa-home" /> Go Home
</button>
</div>

View File

@@ -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() {
}
`}</style>
<a className="skilledit-back-link" onClick={() => navigate('/skills')}>
<a className="skilledit-back-link" onClick={() => navigate('/app/skills')}>
<i className="fas fa-arrow-left" /> Back to skills
</a>
<div className="page-header">
@@ -613,7 +613,7 @@ export default function SkillEdit() {
)}
<div className="skilledit-form-actions">
<button type="button" className="btn btn-secondary" onClick={() => navigate('/skills')}>
<button type="button" className="btn btn-secondary" onClick={() => navigate('/app/skills')}>
<i className="fas fa-times" /> Cancel
</button>
<button type="submit" className="btn btn-primary" disabled={saving}>

View File

@@ -292,7 +292,7 @@ export default function Skills() {
onChange={(e) => setSearchQuery(e.target.value)}
style={{ width: '200px' }}
/>
<button className="btn btn-primary" onClick={() => navigate('/skills/new')}>
<button className="btn btn-primary" onClick={() => navigate('/app/skills/new')}>
<i className="fas fa-plus" /> New skill
</button>
<label className="btn btn-secondary" style={{ cursor: 'pointer' }}>
@@ -390,7 +390,7 @@ export default function Skills() {
<h2 className="empty-state-title">No skills found</h2>
<p className="empty-state-text">Create a skill or import one to get started.</p>
<div style={{ display: 'flex', gap: 'var(--spacing-sm)', justifyContent: 'center' }}>
<button className="btn btn-primary" onClick={() => navigate('/skills/new')}>
<button className="btn btn-primary" onClick={() => navigate('/app/skills/new')}>
<i className="fas fa-plus" /> Create skill
</button>
<label className="btn btn-secondary" style={{ cursor: 'pointer' }}>
@@ -420,7 +420,7 @@ export default function Skills() {
{!s.readOnly && (
<button
className="btn btn-secondary btn-sm"
onClick={() => navigate(`/skills/edit/${encodeURIComponent(s.name)}`)}
onClick={() => navigate(`/app/skills/edit/${encodeURIComponent(s.name)}`)}
title="Edit skill"
>
<i className="fas fa-edit" /> Edit

View File

@@ -1,4 +1,5 @@
import { createBrowserRouter } from 'react-router-dom'
import { createBrowserRouter, Navigate, useParams } from 'react-router-dom'
import { routerBasename } from './utils/basePath'
import App from './App'
import Home from './pages/Home'
import Chat from './pages/Chat'
@@ -30,6 +31,50 @@ import Explorer from './pages/Explorer'
import Login from './pages/Login'
import NotFound from './pages/NotFound'
function BrowseRedirect() {
const { '*': splat } = useParams()
return <Navigate to={`/app/${splat || ''}`} replace />
}
const appChildren = [
{ index: true, element: <Home /> },
{ path: 'models', element: <Models /> },
{ path: 'chat', element: <Chat /> },
{ path: 'chat/:model', element: <Chat /> },
{ path: 'image', element: <ImageGen /> },
{ path: 'image/:model', element: <ImageGen /> },
{ path: 'video', element: <VideoGen /> },
{ path: 'video/:model', element: <VideoGen /> },
{ path: 'tts', element: <TTS /> },
{ path: 'tts/:model', element: <TTS /> },
{ path: 'sound', element: <Sound /> },
{ path: 'sound/:model', element: <Sound /> },
{ path: 'talk', element: <Talk /> },
{ path: 'manage', element: <Manage /> },
{ path: 'backends', element: <Backends /> },
{ path: 'settings', element: <Settings /> },
{ path: 'traces', element: <Traces /> },
{ path: 'p2p', element: <P2P /> },
{ path: 'agents', element: <Agents /> },
{ path: 'agents/new', element: <AgentCreate /> },
{ path: 'agents/:name/edit', element: <AgentCreate /> },
{ path: 'agents/:name/chat', element: <AgentChat /> },
{ path: 'agents/:name/status', element: <AgentStatus /> },
{ path: 'collections', element: <Collections /> },
{ path: 'collections/:name', element: <CollectionDetails /> },
{ path: 'skills', element: <Skills /> },
{ path: 'skills/new', element: <SkillEdit /> },
{ path: 'skills/edit/:name', element: <SkillEdit /> },
{ path: 'agent-jobs', element: <AgentJobs /> },
{ path: 'agent-jobs/tasks/new', element: <AgentTaskDetails /> },
{ path: 'agent-jobs/tasks/:id', element: <AgentTaskDetails /> },
{ path: 'agent-jobs/tasks/:id/edit', element: <AgentTaskDetails /> },
{ path: 'agent-jobs/jobs/:id', element: <AgentJobDetails /> },
{ path: 'model-editor/:name', element: <ModelEditor /> },
{ path: 'import-model', element: <ImportModel /> },
{ path: '*', element: <NotFound /> },
]
export const router = createBrowserRouter([
{
path: '/login',
@@ -40,45 +85,17 @@ export const router = createBrowserRouter([
element: <Explorer />,
},
{
path: '/',
path: '/app',
element: <App />,
children: [
{ index: true, element: <Home /> },
{ path: 'browse', element: <Models /> },
{ path: 'chat', element: <Chat /> },
{ path: 'chat/:model', element: <Chat /> },
{ path: 'image', element: <ImageGen /> },
{ path: 'image/:model', element: <ImageGen /> },
{ path: 'video', element: <VideoGen /> },
{ path: 'video/:model', element: <VideoGen /> },
{ path: 'tts', element: <TTS /> },
{ path: 'tts/:model', element: <TTS /> },
{ path: 'sound', element: <Sound /> },
{ path: 'sound/:model', element: <Sound /> },
{ path: 'talk', element: <Talk /> },
{ path: 'manage', element: <Manage /> },
{ path: 'backends', element: <Backends /> },
{ path: 'settings', element: <Settings /> },
{ path: 'traces', element: <Traces /> },
{ path: 'p2p', element: <P2P /> },
{ path: 'agents', element: <Agents /> },
{ path: 'agents/new', element: <AgentCreate /> },
{ path: 'agents/:name/edit', element: <AgentCreate /> },
{ path: 'agents/:name/chat', element: <AgentChat /> },
{ path: 'agents/:name/status', element: <AgentStatus /> },
{ path: 'collections', element: <Collections /> },
{ path: 'collections/:name', element: <CollectionDetails /> },
{ path: 'skills', element: <Skills /> },
{ path: 'skills/new', element: <SkillEdit /> },
{ path: 'skills/edit/:name', element: <SkillEdit /> },
{ path: 'agent-jobs', element: <AgentJobs /> },
{ path: 'agent-jobs/tasks/new', element: <AgentTaskDetails /> },
{ path: 'agent-jobs/tasks/:id', element: <AgentTaskDetails /> },
{ path: 'agent-jobs/tasks/:id/edit', element: <AgentTaskDetails /> },
{ path: 'agent-jobs/jobs/:id', element: <AgentJobDetails /> },
{ path: 'model-editor/:name', element: <ModelEditor /> },
{ path: 'import-model', element: <ImportModel /> },
{ path: '*', element: <NotFound /> },
],
children: appChildren,
},
])
// Backward compatibility: redirect /browse/* to /app/*
{
path: '/browse/*',
element: <BrowseRedirect />,
},
{
path: '/',
element: <Navigate to="/app" replace />,
},
], { basename: routerBasename })

View File

@@ -1,4 +1,5 @@
import { API_CONFIG } from './config'
import { apiUrl } from './basePath'
async function handleResponse(response) {
if (!response.ok) {
@@ -20,7 +21,7 @@ async function handleResponse(response) {
}
function buildUrl(endpoint, params) {
const url = new URL(endpoint, window.location.origin)
const url = new URL(apiUrl(endpoint), window.location.origin)
if (params) {
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') {
@@ -32,7 +33,7 @@ function buildUrl(endpoint, params) {
}
async function fetchJSON(endpoint, options = {}) {
const response = await fetch(endpoint, {
const response = await fetch(apiUrl(endpoint), {
headers: { 'Content-Type': 'application/json', ...options.headers },
...options,
})
@@ -49,7 +50,7 @@ async function postJSON(endpoint, body, options = {}) {
// SSE streaming for chat completions
export async function streamChat(body, signal) {
const response = await fetch(API_CONFIG.endpoints.chatCompletions, {
const response = await fetch(apiUrl(API_CONFIG.endpoints.chatCompletions), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...body, stream: true }),
@@ -83,7 +84,7 @@ export const modelsApi = {
reload: () => postJSON(API_CONFIG.endpoints.modelsReload, {}),
importUri: (body) => postJSON(API_CONFIG.endpoints.modelsImportUri, body),
importConfig: async (content, contentType = 'application/x-yaml') => {
const response = await fetch(API_CONFIG.endpoints.modelsImport, {
const response = await fetch(apiUrl(API_CONFIG.endpoints.modelsImport), {
method: 'POST',
headers: { 'Content-Type': contentType },
body: content,
@@ -153,7 +154,7 @@ export const p2pApi = {
getFederation: () => fetchJSON(API_CONFIG.endpoints.p2pFederation),
getStats: () => fetchJSON(API_CONFIG.endpoints.p2pStats),
getToken: async () => {
const response = await fetch(API_CONFIG.endpoints.p2pToken)
const response = await fetch(apiUrl(API_CONFIG.endpoints.p2pToken))
if (!response.ok) throw new Error(`HTTP ${response.status}`)
return response.text()
},
@@ -186,7 +187,7 @@ export const videoApi = {
// TTS
export const ttsApi = {
generate: async (body) => {
const response = await fetch(API_CONFIG.endpoints.tts, {
const response = await fetch(apiUrl(API_CONFIG.endpoints.tts), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
@@ -198,7 +199,7 @@ export const ttsApi = {
return response.blob()
},
generateV1: async (body) => {
const response = await fetch(API_CONFIG.endpoints.audioSpeech, {
const response = await fetch(apiUrl(API_CONFIG.endpoints.audioSpeech), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
@@ -214,7 +215,7 @@ export const ttsApi = {
// Sound generation
export const soundApi = {
generate: async (body) => {
const response = await fetch(API_CONFIG.endpoints.soundGeneration, {
const response = await fetch(apiUrl(API_CONFIG.endpoints.soundGeneration), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
@@ -230,7 +231,7 @@ export const soundApi = {
// Audio transcription
export const audioApi = {
transcribe: async (formData) => {
const response = await fetch(API_CONFIG.endpoints.audioTranscriptions, {
const response = await fetch(apiUrl(API_CONFIG.endpoints.audioTranscriptions), {
method: 'POST',
body: formData,
})
@@ -269,14 +270,14 @@ export const agentsApi = {
clearObservables: (name) => fetchJSON(`/api/agents/${encodeURIComponent(name)}/observables`, { method: 'DELETE' }),
chat: (name, message) => postJSON(`/api/agents/${encodeURIComponent(name)}/chat`, { message }),
export: (name) => fetchJSON(`/api/agents/${encodeURIComponent(name)}/export`),
import: (formData) => fetch('/api/agents/import', { method: 'POST', body: formData }).then(handleResponse),
import: (formData) => fetch(apiUrl('/api/agents/import'), { method: 'POST', body: formData }).then(handleResponse),
configMeta: () => fetchJSON('/api/agents/config/metadata'),
}
export const agentCollectionsApi = {
list: () => fetchJSON('/api/agents/collections'),
create: (name) => postJSON('/api/agents/collections', { name }),
upload: (name, formData) => fetch(`/api/agents/collections/${encodeURIComponent(name)}/upload`, { method: 'POST', body: formData }).then(handleResponse),
upload: (name, formData) => fetch(apiUrl(`/api/agents/collections/${encodeURIComponent(name)}/upload`), { method: 'POST', body: formData }).then(handleResponse),
entries: (name) => fetchJSON(`/api/agents/collections/${encodeURIComponent(name)}/entries`),
entryContent: (name, entry) => fetchJSON(`/api/agents/collections/${encodeURIComponent(name)}/entries/${encodeURIComponent(entry)}`),
search: (name, query, maxResults) => postJSON(`/api/agents/collections/${encodeURIComponent(name)}/search`, { query, max_results: maxResults }),
@@ -295,11 +296,11 @@ export const skillsApi = {
create: (data) => postJSON('/api/agents/skills', data),
update: (name, data) => fetchJSON(`/api/agents/skills/${encodeURIComponent(name)}`, { method: 'PUT', body: JSON.stringify(data), headers: { 'Content-Type': 'application/json' } }),
delete: (name) => fetchJSON(`/api/agents/skills/${encodeURIComponent(name)}`, { method: 'DELETE' }),
import: (file) => { const fd = new FormData(); fd.append('file', file); return fetch('/api/agents/skills/import', { method: 'POST', body: fd }).then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); }); },
exportUrl: (name) => `/api/agents/skills/export/${encodeURIComponent(name)}`,
import: (file) => { const fd = new FormData(); fd.append('file', file); return fetch(apiUrl('/api/agents/skills/import'), { method: 'POST', body: fd }).then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); }); },
exportUrl: (name) => apiUrl(`/api/agents/skills/export/${encodeURIComponent(name)}`),
listResources: (name) => fetchJSON(`/api/agents/skills/${encodeURIComponent(name)}/resources`),
getResource: (name, path, opts) => fetchJSON(`/api/agents/skills/${encodeURIComponent(name)}/resources/${path}${opts?.json ? '?encoding=base64' : ''}`),
createResource: (name, path, file) => { const fd = new FormData(); fd.append('file', file); fd.append('path', path); return fetch(`/api/agents/skills/${encodeURIComponent(name)}/resources`, { method: 'POST', body: fd }).then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); }); },
createResource: (name, path, file) => { const fd = new FormData(); fd.append('file', file); fd.append('path', path); return fetch(apiUrl(`/api/agents/skills/${encodeURIComponent(name)}/resources`), { method: 'POST', body: fd }).then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); }); },
updateResource: (name, path, content) => postJSON(`/api/agents/skills/${encodeURIComponent(name)}/resources/${path}`, { content }),
deleteResource: (name, path) => fetchJSON(`/api/agents/skills/${encodeURIComponent(name)}/resources/${path}`, { method: 'DELETE' }),
listGitRepos: () => fetchJSON('/api/agents/git-repos'),

View File

@@ -1,6 +1,7 @@
import { Marked } from 'marked'
import DOMPurify from 'dompurify'
import hljs from 'highlight.js'
import { apiUrl } from './basePath'
const FENCE_REGEX = /```(\w*)\n([\s\S]*?)```/g
@@ -64,7 +65,7 @@ export function extractMetadataArtifacts(messages, agentName) {
if (!meta) return
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(meta).forEach(([key, values]) => {
if (!Array.isArray(values)) return

View File

@@ -0,0 +1,16 @@
function getBasePath() {
const el = document.querySelector('base[href]')
if (!el) return ''
try {
return new URL(el.getAttribute('href')).pathname.replace(/\/+$/, '')
} catch { return '' }
}
export const basePath = getBasePath()
export const routerBasename = basePath || '/'
export function apiUrl(path) {
if (!basePath) return path
if (path.startsWith('http://') || path.startsWith('https://')) return path
return basePath + path
}

View File

@@ -99,7 +99,7 @@ For more information on VRAM management, see [VRAM and Memory Management]({{%rel
| `--opaque-errors` | `false` | If true, all error responses are replaced with blank 500 errors. This is intended only for hardening against information leaks and is normally not recommended | `$LOCALAI_OPAQUE_ERRORS` |
| `--use-subtle-key-comparison` | `false` | 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 resilience against timing attacks | `$LOCALAI_SUBTLE_KEY_COMPARISON` |
| `--disable-api-key-requirement-for-http-get` | `false` | 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 | `$LOCALAI_DISABLE_API_KEY_REQUIREMENT_FOR_HTTP_GET` |
| `--http-get-exempted-endpoints` | `^/$,^/browse/?$,^/talk/?$,^/p2p/?$,^/chat/?$,^/image/?$,^/text2image/?$,^/tts/?$,^/static/.*$,^/swagger.*$` | If `--disable-api-key-requirement-for-http-get` is overridden 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 | `$LOCALAI_HTTP_GET_EXEMPTED_ENDPOINTS` |
| `--http-get-exempted-endpoints` | `^/$,^/app(/.*)?$,^/browse(/.*)?$,^/login/?$,^/explorer/?$,^/assets/.*$,^/static/.*$,^/swagger.*$` | If `--disable-api-key-requirement-for-http-get` is overridden 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 | `$LOCALAI_HTTP_GET_EXEMPTED_ENDPOINTS` |
## P2P Flags