fix(ui): fix /app redirect

Do not handle redirect individually, but serve the app directly in /

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
Ettore Di Giacinto
2026-03-05 21:43:43 +00:00
parent 09ddaf94b2
commit 86680ff8bc
5 changed files with 2782 additions and 73 deletions

View File

@@ -58,6 +58,9 @@ func API(application *application.Application) (*echo.Echo, error) {
e.Use(middleware.BodyLimit(fmt.Sprintf("%dM", application.ApplicationConfig().UploadLimitMB)))
}
// SPA fallback handler, set later when React UI is available
var spaFallback func(echo.Context) error
// Set error handler
if !application.ApplicationConfig().OpaqueErrors {
e.HTTPErrorHandler = func(err error, c echo.Context) {
@@ -67,8 +70,16 @@ func API(application *application.Application) (*echo.Echo, error) {
code = he.Code
}
// Handle 404 errors with HTML rendering when appropriate
// Handle 404 errors: serve React SPA for HTML requests, JSON otherwise
if code == http.StatusNotFound {
if spaFallback != nil {
accept := c.Request().Header.Get("Accept")
contentType := c.Request().Header.Get("Content-Type")
if strings.Contains(accept, "text/html") && !strings.Contains(contentType, "application/json") {
spaFallback(c)
return
}
}
notFoundHandler(c)
return
}
@@ -236,7 +247,7 @@ func API(application *application.Application) (*echo.Echo, error) {
routes.RegisterUIAPIRoutes(e, application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig(), application.GalleryService(), opcache, application)
routes.RegisterUIRoutes(e, application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig(), application.GalleryService())
// Serve React SPA at /app with SPA fallback
// Serve React SPA from / with SPA fallback via 404 handler
reactFS, fsErr := fs.Sub(reactUI, "react-ui/dist")
if fsErr != nil {
xlog.Warn("React UI not available (build with 'make core/http/react-ui/dist')", "error", fsErr)
@@ -255,12 +266,15 @@ func API(application *application.Application) (*echo.Echo, error) {
return c.HTMLBlob(http.StatusOK, indexHTML)
}
e.GET("/", serveIndex)
e.GET("/app", serveIndex)
e.GET("/app/*", func(c echo.Context) error {
p := c.Param("*")
// Enable SPA fallback in the 404 handler for client-side routing
spaFallback = serveIndex
// Try to serve static file from embedded FS
// Serve React SPA at /
e.GET("/", serveIndex)
// Serve React static assets (JS, CSS, etc.)
serveReactAsset := func(c echo.Context) error {
p := "assets/" + c.Param("*")
f, err := reactFS.Open(p)
if err == nil {
defer f.Close()
@@ -273,9 +287,17 @@ func API(application *application.Application) (*echo.Echo, error) {
return c.Stream(http.StatusOK, contentType, f)
}
}
return echo.NewHTTPError(http.StatusNotFound)
}
e.GET("/assets/*", serveReactAsset)
// SPA fallback: serve index.html for client-side routing
return serveIndex(c)
// 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)
})
}
}

2747
core/http/react-ui/package-lock.json generated Normal file
View File

File diff suppressed because it is too large Load Diff

View File

@@ -63,4 +63,4 @@ export const router = createBrowserRouter([
{ path: '*', element: <NotFound /> },
],
},
], { basename: '/app' })
])

View File

@@ -5,7 +5,7 @@ const backendUrl = process.env.LOCALAI_URL || 'http://localhost:8080'
export default defineConfig({
plugins: [react()],
base: '/app',
base: '/',
server: {
port: 3000,
proxy: {

View File

@@ -15,68 +15,8 @@ func RegisterUIRoutes(app *echo.Echo,
appConfig *config.ApplicationConfig,
galleryService *services.GalleryService) {
// Redirect all old UI routes to React SPA at /app
redirectToApp := func(path string) echo.HandlerFunc {
return func(c echo.Context) error {
return c.Redirect(302, "/app"+path)
}
}
redirectToAppWithParam := func(prefix string) echo.HandlerFunc {
return func(c echo.Context) error {
param := c.Param("model")
if param == "" {
param = c.Param("id")
}
if param != "" {
return c.Redirect(302, "/app"+prefix+"/"+param)
}
return c.Redirect(302, "/app"+prefix)
}
}
// "/" is handled in app.go to serve React SPA directly (preserves reverse-proxy headers)
app.GET("/manage", redirectToApp("/manage"))
if !appConfig.DisableRuntimeSettings {
app.GET("/settings", redirectToApp("/settings"))
}
// Agent Jobs pages
app.GET("/agent-jobs", redirectToApp("/agent-jobs"))
app.GET("/agent-jobs/tasks/new", redirectToApp("/agent-jobs/tasks/new"))
app.GET("/agent-jobs/tasks/:id/edit", func(c echo.Context) error {
return c.Redirect(302, "/app/agent-jobs/tasks/"+c.Param("id")+"/edit")
})
app.GET("/agent-jobs/tasks/:id", func(c echo.Context) error {
return c.Redirect(302, "/app/agent-jobs/tasks/"+c.Param("id"))
})
app.GET("/agent-jobs/jobs/:id", func(c echo.Context) error {
return c.Redirect(302, "/app/agent-jobs/jobs/"+c.Param("id"))
})
// P2P
app.GET("/p2p", redirectToApp("/p2p"))
if !appConfig.DisableGalleryEndpoint {
app.GET("/browse", redirectToApp("/browse"))
app.GET("/browse/backends", redirectToApp("/backends"))
}
app.GET("/talk", redirectToApp("/talk"))
app.GET("/chat", redirectToApp("/chat"))
app.GET("/chat/:model", redirectToAppWithParam("/chat"))
app.GET("/image", redirectToApp("/image"))
app.GET("/image/:model", redirectToAppWithParam("/image"))
app.GET("/tts", redirectToApp("/tts"))
app.GET("/tts/:model", redirectToAppWithParam("/tts"))
app.GET("/sound", redirectToApp("/sound"))
app.GET("/sound/:model", redirectToAppWithParam("/sound"))
app.GET("/video", redirectToApp("/video"))
app.GET("/video/:model", redirectToAppWithParam("/video"))
// Traces UI
app.GET("/traces", redirectToApp("/traces"))
// SPA routes are handled by the 404 fallback in app.go which serves
// index.html for any unmatched HTML request, enabling client-side routing.
app.GET("/api/traces", func(c echo.Context) error {
return c.JSON(200, middleware.GetTraces())