mirror of
https://github.com/mudler/LocalAI.git
synced 2026-03-31 21:25:59 -04:00
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:
committed by
GitHub
parent
f9a850c02a
commit
ed2c6da4bf
@@ -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"`
|
||||
|
||||
@@ -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())
|
||||
|
||||
228
core/http/middleware/auth_test.go
Normal file
228
core/http/middleware/auth_test.go
Normal 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))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
@@ -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]) => {
|
||||
|
||||
@@ -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"
|
||||
|
||||
5
core/http/react-ui/src/hooks/useChat.js
vendored
5
core/http/react-ui/src/hooks/useChat.js
vendored
@@ -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),
|
||||
|
||||
3
core/http/react-ui/src/hooks/useMCPClient.js
vendored
3
core/http/react-ui/src/hooks/useMCPClient.js
vendored
@@ -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() {
|
||||
|
||||
13
core/http/react-ui/src/hooks/useOperations.js
vendored
13
core/http/react-ui/src/hooks/useOperations.js
vendored
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
) : '-'}
|
||||
|
||||
@@ -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') && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
|
||||
29
core/http/react-ui/src/utils/api.js
vendored
29
core/http/react-ui/src/utils/api.js
vendored
@@ -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'),
|
||||
|
||||
3
core/http/react-ui/src/utils/artifacts.js
vendored
3
core/http/react-ui/src/utils/artifacts.js
vendored
@@ -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
|
||||
|
||||
16
core/http/react-ui/src/utils/basePath.js
vendored
Normal file
16
core/http/react-ui/src/utils/basePath.js
vendored
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user