feat(ui): move to React for frontend (#8772)

* feat(ui): move to React

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* Add import model

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* syntax highlight

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* Minor fixups

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
Ettore Di Giacinto
2026-03-05 21:47:12 +01:00
committed by GitHub
parent 61c139fa7d
commit 09ddaf94b2
66 changed files with 11947 additions and 420 deletions

View File

@@ -94,6 +94,12 @@ jobs:
run: |
sudo apt-get update
sudo apt-get install curl ffmpeg
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Build React UI
run: make react-ui
- name: Build backends
run: |
make backends/transformers
@@ -191,6 +197,12 @@ jobs:
run: |
brew install protobuf grpc make protoc-gen-go protoc-gen-go-grpc libomp llvm
pip install --user --no-cache-dir grpcio-tools grpcio
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Build React UI
run: make react-ui
- name: Build llama-cpp-darwin
run: |
make protogen-go

View File

@@ -44,6 +44,12 @@ jobs:
run: |
sudo apt-get update
sudo apt-get install -y build-essential
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Build React UI
run: make react-ui
- name: Test Backend E2E
run: |
PATH="$PATH:$HOME/go/bin" make build-mock-backend test-e2e

4
.gitignore vendored
View File

@@ -65,3 +65,7 @@ docs/static/gallery.html
# per-developer customization files for the development container
.devcontainer/customization/*
# React UI build artifacts (keep placeholder dist/index.html)
core/http/react-ui/node_modules/
core/http/react-ui/dist

View File

@@ -2,6 +2,7 @@ version: 2
before:
hooks:
- make protogen-go
- make react-ui
- go mod tidy
dist: release
source:

View File

@@ -291,6 +291,17 @@ EOT
###################################
###################################
# Build React UI
FROM node:22-slim AS react-ui-builder
WORKDIR /app
COPY core/http/react-ui/package*.json ./
RUN npm install
COPY core/http/react-ui/ ./
RUN npm run build
###################################
###################################
# Compile backends first in a separate stage
FROM builder-base AS builder-backends
ARG TARGETARCH
@@ -320,6 +331,9 @@ WORKDIR /build
COPY . .
# Copy pre-built React UI
COPY --from=react-ui-builder /app/dist ./core/http/react-ui/dist
## Build the binary
## If we're on arm64 AND using cublas/hipblas, skip some of the llama-compat backends to save space
## Otherwise just run the normal build

View File

@@ -91,8 +91,22 @@ install-go-tools:
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@1958fcbe2ca8bd93af633f11e97d44e567e945af
go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.34.2
## React UI:
react-ui:
ifneq ($(wildcard core/http/react-ui/dist),)
@echo "react-ui dist already exists, skipping build"
else
cd core/http/react-ui && npm install && npm run build
endif
react-ui-docker:
docker run --entrypoint /bin/bash -v $(CURDIR):/app:z oven/bun:1 \
-c "cd /app/core/http/react-ui && bun install && bun run build"
core/http/react-ui/dist: react-ui
## Build:
build: protogen-go install-go-tools ## Build the project
build: protogen-go install-go-tools core/http/react-ui/dist ## Build the project
$(info ${GREEN}I local-ai build info:${RESET})
$(info ${GREEN}I BUILD_TYPE: ${YELLOW}$(BUILD_TYPE)${RESET})
$(info ${GREEN}I GO_TAGS: ${YELLOW}$(GO_TAGS)${RESET})
@@ -559,6 +573,7 @@ clean-mock-backend:
swagger:
swag init -g core/http/app.go --output swagger
# DEPRECATED: gen-assets is for the legacy Alpine.js UI. Remove when legacy UI is removed.
.PHONY: gen-assets
gen-assets:
$(GOCMD) run core/dependencies_manager/manager.go webui_static.yaml core/http/static/assets

View File

@@ -354,6 +354,23 @@ func loadRuntimeSettingsFromFile(options *config.ApplicationConfig) {
}
}
// P2P settings
if settings.P2PToken != nil {
if options.P2PToken == "" {
options.P2PToken = *settings.P2PToken
}
}
if settings.P2PNetworkID != nil {
if options.P2PNetworkID == "" {
options.P2PNetworkID = *settings.P2PNetworkID
}
}
if settings.Federated != nil {
if !options.Federated {
options.Federated = *settings.Federated
}
}
xlog.Debug("Runtime settings loaded from runtime_settings.json")
}

View File

@@ -300,7 +300,8 @@ func (r *RunCMD) Run(ctx *cliContext.Context) error {
xlog.Info("LocalAI is started and running", "address", r.Address)
if token != "" {
// Start P2P if token was provided via CLI/env or loaded from runtime_settings.json
if token != "" || app.ApplicationConfig().P2PToken != "" {
if err := app.StartP2P(); err != nil {
return err
}

View File

@@ -1,3 +1,6 @@
// DEPRECATED: This tool downloads static assets for the legacy Alpine.js UI.
// The new React UI (core/http/react-ui/) bundles all dependencies via npm.
// Remove this file when the legacy UI (core/http/views/) is removed.
package main
import (

View File

@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"io/fs"
"mime"
"net/http"
"os"
"path/filepath"
@@ -29,6 +30,11 @@ import (
//go:embed static/*
var embedDirStatic embed.FS
// Embed React UI build output
//
//go:embed react-ui/dist/*
var reactUI embed.FS
var quietPaths = []string{"/api/operations", "/api/resources", "/healthz", "/readyz"}
// @title LocalAI API
@@ -229,6 +235,49 @@ func API(application *application.Application) (*echo.Echo, error) {
if !application.ApplicationConfig().DisableWebUI {
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
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)
} else {
serveIndex := func(c echo.Context) error {
indexHTML, err := reactUI.ReadFile("react-ui/dist/index.html")
if err != nil {
return c.String(http.StatusNotFound, "React UI not built")
}
// Inject <base href> for reverse-proxy support
baseURL := httpMiddleware.BaseURL(c)
if baseURL != "" {
baseTag := `<base href="` + baseURL + `" />`
indexHTML = []byte(strings.Replace(string(indexHTML), "<head>", "<head>\n "+baseTag, 1))
}
return c.HTMLBlob(http.StatusOK, indexHTML)
}
e.GET("/", serveIndex)
e.GET("/app", serveIndex)
e.GET("/app/*", func(c echo.Context) error {
p := c.Param("*")
// Try to serve static file from embedded FS
f, err := reactFS.Open(p)
if err == nil {
defer f.Close()
stat, statErr := f.Stat()
if statErr == nil && !stat.IsDir() {
contentType := mime.TypeByExtension(filepath.Ext(p))
if contentType == "" {
contentType = echo.MIMEOctetStream
}
return c.Stream(http.StatusOK, contentType, f)
}
}
// SPA fallback: serve index.html for client-side routing
return serveIndex(c)
})
}
}
routes.RegisterJINARoutes(e, requestExtractor, application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig())

View File

@@ -96,6 +96,12 @@ func UpdateSettingsEndpoint(app *application.Application) echo.HandlerFunc {
}
}
// Generate P2P token before saving so the real token is persisted (not "0")
if settings.P2PToken != nil && *settings.P2PToken == "0" {
token := p2p.GenerateToken(60, 60)
settings.P2PToken = &token
}
// Save to file
if appConfig.DynamicConfigsDir == "" {
return c.JSON(http.StatusBadRequest, schema.SettingsResponse{
@@ -218,11 +224,6 @@ func UpdateSettingsEndpoint(app *application.Application) echo.HandlerFunc {
})
}
} else {
if settings.P2PToken != nil && *settings.P2PToken == "0" {
token := p2p.GenerateToken(60, 60)
settings.P2PToken = &token
appConfig.P2PToken = token
}
if err := app.RestartP2P(); err != nil {
xlog.Error("Failed to restart P2P", "error", err)
return c.JSON(http.StatusInternalServerError, schema.SettingsResponse{

441
core/http/react-ui/bun.lock Normal file
View File

@@ -0,0 +1,441 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "localai-react-ui",
"dependencies": {
"dompurify": "^3.2.5",
"highlight.js": "^11.11.1",
"marked": "^15.0.7",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-router-dom": "^7.6.1",
},
"devDependencies": {
"@eslint/js": "^9.27.0",
"@vitejs/plugin-react": "^4.5.2",
"eslint": "^9.27.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.1.0",
"vite": "^6.3.5",
},
},
},
"packages": {
"@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
"@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="],
"@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="],
"@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="],
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="],
"@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="],
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="],
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="],
"@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="],
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="],
"@babel/helpers": ["@babel/helpers@7.28.6", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw=="],
"@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="],
"@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw=="],
"@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="],
"@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="],
"@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="],
"@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
"@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="],
"@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="],
"@eslint/config-array": ["@eslint/config-array@0.21.1", "", { "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.2" } }, "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA=="],
"@eslint/config-helpers": ["@eslint/config-helpers@0.4.2", "", { "dependencies": { "@eslint/core": "^0.17.0" } }, "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw=="],
"@eslint/core": ["@eslint/core@0.17.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ=="],
"@eslint/eslintrc": ["@eslint/eslintrc@3.3.4", "", { "dependencies": { "ajv": "^6.14.0", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.1", "minimatch": "^3.1.3", "strip-json-comments": "^3.1.1" } }, "sha512-4h4MVF8pmBsncB60r0wSJiIeUKTSD4m7FmTFThG8RHlsg9ajqckLm9OraguFGZE4vVdpiI1Q4+hFnisopmG6gQ=="],
"@eslint/js": ["@eslint/js@9.39.3", "", {}, "sha512-1B1VkCq6FuUNlQvlBYb+1jDu/gV297TIs/OeiaSR9l1H27SVW55ONE1e1Vp16NqP683+xEGzxYtv4XCiDPaQiw=="],
"@eslint/object-schema": ["@eslint/object-schema@2.1.7", "", {}, "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA=="],
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="],
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
"@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="],
"@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="],
"@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.59.0", "", { "os": "android", "cpu": "arm" }, "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.59.0", "", { "os": "android", "cpu": "arm64" }, "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q=="],
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.59.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg=="],
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.59.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w=="],
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.59.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA=="],
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.59.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg=="],
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw=="],
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA=="],
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA=="],
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA=="],
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg=="],
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q=="],
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA=="],
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA=="],
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg=="],
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg=="],
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.59.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w=="],
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg=="],
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg=="],
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.59.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ=="],
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.59.0", "", { "os": "none", "cpu": "arm64" }, "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.59.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A=="],
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.59.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA=="],
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA=="],
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA=="],
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
"@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
"@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="],
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
"ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="],
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.0", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA=="],
"brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
"browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
"caniuse-lite": ["caniuse-lite@1.0.30001776", "", {}, "sha512-sg01JDPzZ9jGshqKSckOQthXnYwOEP50jeVFhaSFbZcOy05TiuuaffDOfcwtCisJ9kNQuLBFibYywv2Bgm9osw=="],
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
"dompurify": ["dompurify@3.3.1", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q=="],
"electron-to-chromium": ["electron-to-chromium@1.5.307", "", {}, "sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg=="],
"esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
"eslint": ["eslint@9.39.3", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.1", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.39.3", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg=="],
"eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@5.2.0", "", { "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg=="],
"eslint-plugin-react-refresh": ["eslint-plugin-react-refresh@0.4.26", "", { "peerDependencies": { "eslint": ">=8.40" } }, "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ=="],
"eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="],
"eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="],
"espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="],
"esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="],
"esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="],
"estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
"find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
"flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="],
"flatted": ["flatted@3.3.4", "", {}, "sha512-3+mMldrTAPdta5kjX2G2J7iX4zxtnwpdA8Tr2ZSjkyPSanvbZAcy6flmtnXbEybHrDcU9641lxrMfFuUxVz9vA=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
"globals": ["globals@16.5.0", "", {}, "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ=="],
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
"highlight.js": ["highlight.js@11.11.1", "", {}, "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w=="],
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
"json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
"json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="],
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
"lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="],
"minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
"node-releases": ["node-releases@2.0.36", "", {}, "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA=="],
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
"p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
"p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
"parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
"postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
"react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="],
"react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
"react-router": ["react-router@7.13.1", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-td+xP4X2/6BJvZoX6xw++A2DdEi++YypA69bJUV5oVvqf6/9/9nNlD70YO1e9d3MyamJEBQFEzk6mbfDYbqrSA=="],
"react-router-dom": ["react-router-dom@7.13.1", "", { "dependencies": { "react-router": "7.13.1" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-UJnV3Rxc5TgUPJt2KJpo1Jpy0OKQr0AjgbZzBFjaPJcFOb2Y8jA5H3LT8HUJAiRLlWrEXWHbF1Z4SCZaQjWDHw=="],
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
"rollup": ["rollup@4.59.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.59.0", "@rollup/rollup-android-arm64": "4.59.0", "@rollup/rollup-darwin-arm64": "4.59.0", "@rollup/rollup-darwin-x64": "4.59.0", "@rollup/rollup-freebsd-arm64": "4.59.0", "@rollup/rollup-freebsd-x64": "4.59.0", "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", "@rollup/rollup-linux-arm-musleabihf": "4.59.0", "@rollup/rollup-linux-arm64-gnu": "4.59.0", "@rollup/rollup-linux-arm64-musl": "4.59.0", "@rollup/rollup-linux-loong64-gnu": "4.59.0", "@rollup/rollup-linux-loong64-musl": "4.59.0", "@rollup/rollup-linux-ppc64-gnu": "4.59.0", "@rollup/rollup-linux-ppc64-musl": "4.59.0", "@rollup/rollup-linux-riscv64-gnu": "4.59.0", "@rollup/rollup-linux-riscv64-musl": "4.59.0", "@rollup/rollup-linux-s390x-gnu": "4.59.0", "@rollup/rollup-linux-x64-gnu": "4.59.0", "@rollup/rollup-linux-x64-musl": "4.59.0", "@rollup/rollup-openbsd-x64": "4.59.0", "@rollup/rollup-openharmony-arm64": "4.59.0", "@rollup/rollup-win32-arm64-msvc": "4.59.0", "@rollup/rollup-win32-ia32-msvc": "4.59.0", "@rollup/rollup-win32-x64-gnu": "4.59.0", "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg=="],
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="],
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
"vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
"@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="],
}
}

View File

@@ -0,0 +1,29 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
export default [
{ ignores: ['dist'] },
{
files: ['**/*.{js,jsx}'],
languageOptions: {
ecmaVersion: 2024,
globals: globals.browser,
parserOptions: {
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
sourceType: 'module',
},
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
'no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
},
},
]

View File

@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>LocalAI</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Playfair+Display:wght@400;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

View File

@@ -0,0 +1,30 @@
{
"name": "localai-react-ui",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "eslint ."
},
"dependencies": {
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-router-dom": "^7.6.1",
"highlight.js": "^11.11.1",
"marked": "^15.0.7",
"dompurify": "^3.2.5",
"@fortawesome/fontawesome-free": "^6.7.2"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.5.2",
"vite": "^6.3.5",
"eslint": "^9.27.0",
"@eslint/js": "^9.27.0",
"globals": "^16.1.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20"
}
}

View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,68 @@
import { useState, useEffect } from 'react'
import { Outlet, useLocation } from 'react-router-dom'
import Sidebar from './components/Sidebar'
import OperationsBar from './components/OperationsBar'
import { ToastContainer, useToast } from './components/Toast'
import { systemApi } from './utils/api'
export default function App() {
const [sidebarOpen, setSidebarOpen] = useState(false)
const { toasts, addToast, removeToast } = useToast()
const [version, setVersion] = useState('')
const location = useLocation()
const isChatRoute = location.pathname.startsWith('/chat')
useEffect(() => {
systemApi.version()
.then(data => setVersion(typeof data === 'string' ? data : (data?.version || '')))
.catch(() => {})
}, [])
return (
<div className={`app-layout${isChatRoute ? ' app-layout-chat' : ''}`}>
<Sidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} />
<main className="main-content">
<OperationsBar />
{/* Mobile header */}
<header className="mobile-header">
<button
className="hamburger-btn"
onClick={() => setSidebarOpen(true)}
>
<i className="fas fa-bars" />
</button>
<span className="mobile-title">LocalAI</span>
</header>
<div className="main-content-inner">
<Outlet context={{ addToast }} />
</div>
{!isChatRoute && (
<footer className="app-footer">
<div className="app-footer-inner">
{version && (
<span className="app-footer-version">
LocalAI <span style={{ color: 'var(--color-primary)', fontWeight: 500 }}>{version}</span>
</span>
)}
<div className="app-footer-links">
<a href="https://github.com/mudler/LocalAI" target="_blank" rel="noopener noreferrer">
<i className="fab fa-github" /> GitHub
</a>
<a href="https://localai.io" target="_blank" rel="noopener noreferrer">
<i className="fas fa-book" /> Documentation
</a>
<a href="https://mudler.pm" target="_blank" rel="noopener noreferrer">
<i className="fas fa-user" /> Author
</a>
</div>
<span className="app-footer-copyright">
&copy; 2023-2026 <a href="https://mudler.pm" target="_blank" rel="noopener noreferrer">Ettore Di Giacinto</a>
</span>
</div>
</footer>
)}
</main>
<ToastContainer toasts={toasts} removeToast={removeToast} />
</div>
)
}

View File

@@ -0,0 +1,111 @@
import { useRef, useEffect, useCallback } from 'react'
import hljs from 'highlight.js/lib/core'
import yaml from 'highlight.js/lib/languages/yaml'
hljs.registerLanguage('yaml', yaml)
export default function CodeEditor({ value, onChange, disabled, minHeight = '500px' }) {
const codeRef = useRef(null)
const textareaRef = useRef(null)
const preRef = useRef(null)
const highlight = useCallback(() => {
if (!codeRef.current) return
const result = hljs.highlight(value + '\n', { language: 'yaml', ignoreIllegals: true })
codeRef.current.innerHTML = result.value
}, [value])
useEffect(() => {
highlight()
}, [highlight])
const handleScroll = () => {
if (preRef.current && textareaRef.current) {
preRef.current.scrollTop = textareaRef.current.scrollTop
preRef.current.scrollLeft = textareaRef.current.scrollLeft
}
}
const handleKeyDown = (e) => {
if (e.key === 'Tab') {
e.preventDefault()
const ta = e.target
const start = ta.selectionStart
const end = ta.selectionEnd
const newValue = value.substring(0, start) + ' ' + value.substring(end)
onChange(newValue)
requestAnimationFrame(() => {
ta.selectionStart = ta.selectionEnd = start + 2
})
}
}
return (
<div className="code-editor-wrapper" style={{ position: 'relative', minHeight, fontSize: '0.8125rem' }}>
<pre
ref={preRef}
className="code-editor-highlight"
aria-hidden="true"
style={{
position: 'absolute',
top: 0, left: 0, right: 0, bottom: 0,
margin: 0,
padding: 'var(--spacing-sm)',
overflow: 'auto',
pointerEvents: 'none',
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
fontSize: 'inherit',
lineHeight: 1.5,
tabSize: 2,
whiteSpace: 'pre-wrap',
wordWrap: 'break-word',
background: 'var(--color-bg-tertiary)',
borderRadius: 'var(--radius-md)',
border: '1px solid var(--color-border-default)',
}}
>
<code
ref={codeRef}
className="language-yaml"
style={{
fontFamily: 'inherit',
fontSize: 'inherit',
lineHeight: 'inherit',
padding: 0,
background: 'transparent',
}}
/>
</pre>
<textarea
ref={textareaRef}
value={value}
onChange={(e) => onChange(e.target.value)}
onScroll={handleScroll}
onKeyDown={handleKeyDown}
disabled={disabled}
spellCheck={false}
style={{
position: 'relative',
width: '100%',
minHeight,
margin: 0,
padding: 'var(--spacing-sm)',
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
fontSize: 'inherit',
lineHeight: 1.5,
tabSize: 2,
whiteSpace: 'pre-wrap',
wordWrap: 'break-word',
color: 'transparent',
caretColor: 'var(--color-text-primary)',
background: 'transparent',
border: '1px solid var(--color-border-default)',
borderRadius: 'var(--radius-md)',
outline: 'none',
resize: 'vertical',
overflow: 'auto',
}}
/>
</div>
)
}

View File

@@ -0,0 +1,8 @@
export default function LoadingSpinner({ size = 'md', className = '' }) {
const sizeClass = size === 'sm' ? 'spinner-sm' : size === 'lg' ? 'spinner-lg' : 'spinner-md'
return (
<div className={`spinner ${sizeClass} ${className}`}>
<div className="spinner-ring" />
</div>
)
}

View File

@@ -0,0 +1,27 @@
import { useEffect } from 'react'
import { useModels } from '../hooks/useModels'
export default function ModelSelector({ value, onChange, capability, className = '' }) {
const { models, loading } = useModels(capability)
useEffect(() => {
if (!value && models.length > 0) {
onChange(models[0].id)
}
}, [models, value, onChange])
return (
<select
className={`model-selector ${className}`}
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={loading}
>
{loading && <option>Loading models...</option>}
{!loading && models.length === 0 && <option>No models available</option>}
{models.map(model => (
<option key={model.id} value={model.id}>{model.id}</option>
))}
</select>
)
}

View File

@@ -0,0 +1,61 @@
import { useOperations } from '../hooks/useOperations'
export default function OperationsBar() {
const { operations, cancelOperation } = useOperations()
if (operations.length === 0) return null
return (
<div className="operations-bar">
{operations.map(op => (
<div key={op.jobID || op.id} className="operation-item">
<div className="operation-info">
{op.isCancelled ? (
<i className="fas fa-ban" style={{ color: 'var(--color-warning)', marginRight: 'var(--spacing-xs)' }} />
) : op.isDeletion ? (
<i className="fas fa-trash" style={{ color: 'var(--color-error)', marginRight: 'var(--spacing-xs)' }} />
) : (
<div className="operation-spinner" />
)}
<span className="operation-text">
{op.isDeletion ? 'Removing' : 'Installing'}{' '}
{op.isBackend ? 'backend' : 'model'}: {op.name || op.id}
</span>
{op.isQueued && (
<span style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)', marginLeft: 'var(--spacing-xs)' }}>
(Queued)
</span>
)}
{op.isCancelled && (
<span style={{ fontSize: '0.75rem', color: 'var(--color-warning)', marginLeft: 'var(--spacing-xs)' }}>
Cancelling...
</span>
)}
{op.message && !op.isQueued && !op.isCancelled && (
<span style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)', marginLeft: 'var(--spacing-xs)' }}>
{op.message}
</span>
)}
{op.progress !== undefined && op.progress > 0 && (
<span className="operation-progress">{Math.round(op.progress)}%</span>
)}
</div>
{op.progress !== undefined && op.progress > 0 && (
<div className="operation-bar-container">
<div className="operation-bar" style={{ width: `${op.progress}%` }} />
</div>
)}
{op.cancellable && !op.isCancelled && (
<button
className="operation-cancel"
onClick={() => cancelOperation(op.jobID)}
title="Cancel"
>
<i className="fas fa-xmark" />
</button>
)}
</div>
))}
</div>
)
}

View File

@@ -0,0 +1,111 @@
import { useResources } from '../hooks/useResources'
import { formatBytes, percentColor, vendorColor } from '../utils/format'
export default function ResourceMonitor() {
const { resources, loading } = useResources()
if (loading || !resources) {
return <div className="resource-monitor" style={{ color: 'var(--color-text-muted)', fontSize: '0.8125rem' }}>Loading resources...</div>
}
const gpus = resources.gpus || []
const ram = resources.ram || {}
const aggregate = resources.aggregate || {}
const isGpu = resources.type === 'gpu' && gpus.length > 0
return (
<div className="resource-monitor">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 'var(--spacing-sm)' }}>
<h3 className="resource-monitor-title" style={{ margin: 0 }}>
<i className="fas fa-chart-bar" /> System Resources
</h3>
<div style={{ display: 'flex', gap: 'var(--spacing-xs)', alignItems: 'center' }}>
{isGpu && gpus.length > 1 && (
<span className="badge badge-info">{gpus.length} GPUs</span>
)}
{resources.reclaimer_enabled && (
<span className="badge badge-success">Reclaimer Active</span>
)}
</div>
</div>
{isGpu ? (
<div className="resource-gpu-list">
{gpus.map((gpu, i) => {
const pct = gpu.usage_percent || 0
const color = percentColor(pct)
const vColor = vendorColor(gpu.vendor)
return (
<div key={i} className="resource-gpu-card">
<div className="resource-gpu-header">
<span className="resource-gpu-name" style={{ maxWidth: '200px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{gpu.name || `GPU ${i}`}
</span>
{gpu.vendor && (
<span className="resource-gpu-vendor" style={{ background: `${vColor}20`, color: vColor }}>
{gpu.vendor}
</span>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-sm)', marginBottom: 'var(--spacing-xs)' }}>
<div className="resource-bar-container" style={{ flex: 1 }}>
<div className="resource-bar" style={{ width: `${pct}%`, background: color }} />
</div>
<span style={{ fontSize: '0.8125rem', fontWeight: 600, fontFamily: "'JetBrains Mono', monospace", color, minWidth: '3em', textAlign: 'right' }}>
{pct.toFixed(0)}%
</span>
</div>
<div className="resource-gpu-stats">
<span>Used: {formatBytes(gpu.used_vram)}</span>
<span>Total: {formatBytes(gpu.total_vram)}</span>
</div>
</div>
)
})}
</div>
) : (
/* RAM display */
<div className="resource-gpu-card">
<div className="resource-gpu-header">
<span className="resource-gpu-name">System RAM</span>
<span className="resource-gpu-vendor" style={{ background: 'var(--color-accent-light)', color: 'var(--color-accent)' }}>
Memory
</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-sm)', marginBottom: 'var(--spacing-xs)' }}>
<div className="resource-bar-container" style={{ flex: 1 }}>
<div className="resource-bar" style={{ width: `${ram.usage_percent || 0}%`, background: percentColor(ram.usage_percent || 0) }} />
</div>
<span style={{ fontSize: '0.8125rem', fontWeight: 600, fontFamily: "'JetBrains Mono', monospace", color: percentColor(ram.usage_percent || 0), minWidth: '3em', textAlign: 'right' }}>
{(ram.usage_percent || 0).toFixed(0)}%
</span>
</div>
<div className="resource-gpu-stats">
<span>Used: {formatBytes(ram.used || 0)}</span>
<span>Total: {formatBytes(ram.total || 0)}</span>
</div>
</div>
)}
{/* Aggregate for multi-GPU */}
{isGpu && aggregate.gpu_count > 1 && (
<div style={{ fontSize: '0.75rem', color: 'var(--color-text-secondary)', marginTop: 'var(--spacing-sm)', display: 'flex', justifyContent: 'space-between' }}>
<span>Total VRAM</span>
<span style={{ fontFamily: "'JetBrains Mono', monospace" }}>
{formatBytes(aggregate.used_memory)} / {formatBytes(aggregate.total_memory)} ({aggregate.usage_percent?.toFixed(1)}%)
</span>
</div>
)}
{/* Storage */}
{resources.storage_size != null && (
<div style={{ fontSize: '0.75rem', color: 'var(--color-text-secondary)', marginTop: 'var(--spacing-sm)', display: 'flex', justifyContent: 'space-between' }}>
<span>Models storage</span>
<span style={{ fontFamily: "'JetBrains Mono', monospace", color: 'var(--color-text-primary)' }}>
{formatBytes(resources.storage_size)}
</span>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,102 @@
import { NavLink } from 'react-router-dom'
import ThemeToggle from './ThemeToggle'
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' },
]
const toolItems = [
{ path: '/agent-jobs', icon: 'fas fa-tasks', label: 'Agent Jobs' },
{ path: '/traces', icon: 'fas fa-chart-line', label: 'Traces' },
]
const systemItems = [
{ path: '/backends', icon: 'fas fa-server', label: 'Backends' },
{ 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' },
]
function NavItem({ item, onClose }) {
return (
<NavLink
to={item.path}
end={item.path === '/'}
className={({ isActive }) =>
`nav-item ${isActive ? 'active' : ''}`
}
onClick={onClose}
>
<i className={`${item.icon} nav-icon`} />
<span className="nav-label">{item.label}</span>
</NavLink>
)
}
export default function Sidebar({ isOpen, onClose }) {
return (
<>
{isOpen && <div className="sidebar-overlay" onClick={onClose} />}
<aside className={`sidebar ${isOpen ? 'open' : ''}`}>
{/* Logo */}
<div className="sidebar-header">
<a href="./" className="sidebar-logo-link">
<img src="/static/logo_horizontal.png" alt="LocalAI" className="sidebar-logo-img" />
</a>
<button className="sidebar-close-btn" onClick={onClose} aria-label="Close menu">
<i className="fas fa-times" />
</button>
</div>
{/* Navigation */}
<nav className="sidebar-nav">
{/* Main section */}
<div className="sidebar-section">
{mainItems.map(item => (
<NavItem key={item.path} item={item} onClose={onClose} />
))}
</div>
{/* Tools section */}
<div className="sidebar-section">
<div className="sidebar-section-title">Tools</div>
{toolItems.map(item => (
<NavItem key={item.path} item={item} onClose={onClose} />
))}
</div>
{/* System section */}
<div className="sidebar-section">
<div className="sidebar-section-title">System</div>
<a
href="/swagger/index.html"
target="_blank"
rel="noopener noreferrer"
className="nav-item"
>
<i className="fas fa-code nav-icon" />
<span className="nav-label">API</span>
<i className="fas fa-external-link-alt" style={{ fontSize: '0.6rem', marginLeft: 'auto', opacity: 0.5 }} />
</a>
{systemItems.map(item => (
<NavItem key={item.path} item={item} onClose={onClose} />
))}
</div>
</nav>
{/* Footer */}
<div className="sidebar-footer">
<ThemeToggle />
</div>
</aside>
</>
)
}

View File

@@ -0,0 +1,15 @@
import { useTheme } from '../contexts/ThemeContext'
export default function ThemeToggle() {
const { theme, toggleTheme } = useTheme()
return (
<button
onClick={toggleTheme}
className="theme-toggle"
title={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
>
<i className={`fas ${theme === 'dark' ? 'fa-sun' : 'fa-moon'}`} />
</button>
)
}

View File

@@ -0,0 +1,71 @@
import { useState, useCallback, useRef, useEffect } from 'react'
let toastId = 0
export function useToast() {
const [toasts, setToasts] = useState([])
const addToast = useCallback((message, type = 'info', duration = 5000) => {
const id = ++toastId
setToasts(prev => [...prev, { id, message, type }])
if (duration > 0) {
setTimeout(() => {
setToasts(prev => prev.filter(t => t.id !== id))
}, duration)
}
return id
}, [])
const removeToast = useCallback((id) => {
setToasts(prev => prev.filter(t => t.id !== id))
}, [])
return { toasts, addToast, removeToast }
}
const iconMap = {
success: 'fa-circle-check',
error: 'fa-circle-exclamation',
warning: 'fa-triangle-exclamation',
info: 'fa-circle-info',
}
const colorMap = {
success: 'toast-success',
error: 'toast-error',
warning: 'toast-warning',
info: 'toast-info',
}
export function ToastContainer({ toasts, removeToast }) {
return (
<div className="toast-container">
{toasts.map(toast => (
<ToastItem key={toast.id} toast={toast} onRemove={removeToast} />
))}
</div>
)
}
function ToastItem({ toast, onRemove }) {
const ref = useRef(null)
useEffect(() => {
if (ref.current) {
ref.current.classList.add('toast-enter')
requestAnimationFrame(() => {
ref.current?.classList.remove('toast-enter')
})
}
}, [])
return (
<div ref={ref} className={`toast ${colorMap[toast.type] || 'toast-info'}`}>
<i className={`fas ${iconMap[toast.type] || 'fa-circle-info'}`} />
<span>{toast.message}</span>
<button onClick={() => onRemove(toast.id)} className="toast-close">
<i className="fas fa-xmark" />
</button>
</div>
)
}

View File

@@ -0,0 +1,32 @@
import { createContext, useContext, useState, useEffect } from 'react'
const ThemeContext = createContext()
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState(() => {
return localStorage.getItem('localai-theme') || 'dark'
})
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme)
localStorage.setItem('localai-theme', theme)
}, [theme])
const toggleTheme = () => {
setTheme(prev => prev === 'dark' ? 'light' : 'dark')
}
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
)
}
export function useTheme() {
const context = useContext(ThemeContext)
if (!context) {
throw new Error('useTheme must be used within a ThemeProvider')
}
return context
}

601
core/http/react-ui/src/hooks/useChat.js vendored Normal file
View File

@@ -0,0 +1,601 @@
import { useState, useCallback, useRef, useEffect } from 'react'
import { API_CONFIG } from '../utils/config'
const thinkingTagRegex = /<thinking>([\s\S]*?)<\/thinking>|<think>([\s\S]*?)<\/think>/g
const openThinkTagRegex = /<thinking>|<think>/
const closeThinkTagRegex = /<\/thinking>|<\/think>/
function extractThinking(text) {
let regularContent = ''
let thinkingContent = ''
let lastIdx = 0
let match
thinkingTagRegex.lastIndex = 0
while ((match = thinkingTagRegex.exec(text)) !== null) {
regularContent += text.slice(lastIdx, match.index)
thinkingContent += match[1] || match[2] || ''
lastIdx = match.index + match[0].length
}
regularContent += text.slice(lastIdx)
return { regularContent, thinkingContent }
}
const CHATS_STORAGE_KEY = 'localai_chats_data'
const SAVE_DEBOUNCE_MS = 500
function generateId() {
return Date.now().toString(36) + Math.random().toString(36).slice(2)
}
function loadChats() {
try {
const stored = localStorage.getItem(CHATS_STORAGE_KEY)
if (stored) {
const data = JSON.parse(stored)
if (data && Array.isArray(data.chats)) {
return data
}
}
} catch (_e) {
localStorage.removeItem(CHATS_STORAGE_KEY)
}
return null
}
function saveChats(chats, activeChatId) {
try {
const data = {
chats: chats.map(chat => ({
id: chat.id,
name: chat.name,
model: chat.model,
history: chat.history,
systemPrompt: chat.systemPrompt,
mcpMode: chat.mcpMode,
temperature: chat.temperature,
topP: chat.topP,
topK: chat.topK,
tokenUsage: chat.tokenUsage,
contextSize: chat.contextSize,
createdAt: chat.createdAt,
updatedAt: chat.updatedAt,
})),
activeChatId,
lastSaved: Date.now(),
}
localStorage.setItem(CHATS_STORAGE_KEY, JSON.stringify(data))
} catch (err) {
if (err.name === 'QuotaExceededError' || err.code === 22) {
console.warn('localStorage quota exceeded')
}
}
}
function createNewChat(model = '', systemPrompt = '', mcpMode = false) {
return {
id: generateId(),
name: 'New Chat',
model,
history: [],
systemPrompt,
mcpMode,
temperature: null,
topP: null,
topK: null,
tokenUsage: { prompt: 0, completion: 0, total: 0 },
contextSize: null,
createdAt: Date.now(),
updatedAt: Date.now(),
}
}
export function useChat(initialModel = '') {
const [chats, setChats] = useState(() => {
const stored = loadChats()
if (stored && stored.chats.length > 0) return stored.chats
return [createNewChat(initialModel)]
})
const [activeChatId, setActiveChatId] = useState(() => {
const stored = loadChats()
if (stored && stored.activeChatId) return stored.activeChatId
return chats[0]?.id
})
const [isStreaming, setIsStreaming] = useState(false)
const [streamingContent, setStreamingContent] = useState('')
const [streamingReasoning, setStreamingReasoning] = useState('')
const [streamingToolCalls, setStreamingToolCalls] = useState([])
const [tokensPerSecond, setTokensPerSecond] = useState(null)
const [maxTokensPerSecond, setMaxTokensPerSecond] = useState(null)
const abortControllerRef = useRef(null)
const saveTimerRef = useRef(null)
const startTimeRef = useRef(null)
const tokenCountRef = useRef(0)
const maxTpsRef = useRef(0)
const activeChat = chats.find(c => c.id === activeChatId) || chats[0]
// Debounced save
const debouncedSave = useCallback(() => {
if (saveTimerRef.current) clearTimeout(saveTimerRef.current)
saveTimerRef.current = setTimeout(() => {
saveChats(chats, activeChatId)
}, SAVE_DEBOUNCE_MS)
}, [chats, activeChatId])
useEffect(() => {
debouncedSave()
}, [chats, activeChatId, debouncedSave])
const addChat = useCallback((model = '', systemPrompt = '', mcpMode = false) => {
const chat = createNewChat(model, systemPrompt, mcpMode)
setChats(prev => [chat, ...prev])
setActiveChatId(chat.id)
return chat
}, [])
const switchChat = useCallback((chatId) => {
setActiveChatId(chatId)
setStreamingContent('')
setStreamingReasoning('')
setStreamingToolCalls([])
setTokensPerSecond(null)
setMaxTokensPerSecond(null)
}, [])
const deleteChat = useCallback((chatId) => {
setChats(prev => {
if (prev.length <= 1) return prev
const filtered = prev.filter(c => c.id !== chatId)
if (chatId === activeChatId && filtered.length > 0) {
setActiveChatId(filtered[0].id)
}
return filtered
})
}, [activeChatId])
const deleteAllChats = useCallback(() => {
const chat = createNewChat(activeChat?.model || '')
setChats([chat])
setActiveChatId(chat.id)
setStreamingContent('')
setStreamingReasoning('')
setStreamingToolCalls([])
setTokensPerSecond(null)
setMaxTokensPerSecond(null)
}, [activeChat?.model])
const renameChat = useCallback((chatId, name) => {
setChats(prev => prev.map(c =>
c.id === chatId ? { ...c, name, updatedAt: Date.now() } : c
))
}, [])
const updateChatSettings = useCallback((chatId, settings) => {
setChats(prev => prev.map(c =>
c.id === chatId ? { ...c, ...settings, updatedAt: Date.now() } : c
))
}, [])
const getContextUsagePercent = useCallback(() => {
if (!activeChat || !activeChat.contextSize) return null
return Math.min(100, (activeChat.tokenUsage.total / activeChat.contextSize) * 100)
}, [activeChat])
const sendMessage = useCallback(async (content, files = [], options = {}) => {
if (!activeChat) return
const chatId = activeChat.id
const model = options.model || activeChat.model
const temperature = activeChat.temperature
const topP = activeChat.topP
const topK = activeChat.topK
const contextSize = activeChat.contextSize
// Build user message content
let messageContent
const userFiles = []
if (files.length > 0) {
messageContent = [{ type: 'text', text: content }]
for (const file of files) {
if (file.type?.startsWith('image/')) {
messageContent.push({
type: 'image_url',
image_url: { url: `data:${file.type};base64,${file.base64}` },
})
userFiles.push({ name: file.name, type: 'image' })
} else if (file.type?.startsWith('audio/')) {
messageContent.push({
type: 'audio_url',
audio_url: { url: `data:${file.type};base64,${file.base64}` },
})
userFiles.push({ name: file.name, type: 'audio' })
} else {
// Text/PDF files - append to content
userFiles.push({ name: file.name, type: 'file', content: file.textContent || '' })
}
}
} else {
messageContent = content
}
const userMessage = { role: 'user', content: messageContent, files: userFiles.length > 0 ? userFiles : undefined }
// Update chat with user message
setChats(prev => prev.map(c => {
if (c.id !== chatId) return c
const updated = {
...c,
model,
history: [...c.history, userMessage],
updatedAt: Date.now(),
}
if (c.history.length === 0 && typeof content === 'string') {
updated.name = content.slice(0, 40) + (content.length > 40 ? '...' : '')
}
return updated
}))
// Build messages array for API
const chat = chats.find(c => c.id === chatId)
const messages = []
if (chat?.systemPrompt) {
messages.push({ role: 'system', content: chat.systemPrompt })
}
// Filter out thinking/reasoning/tool_call/tool_result messages
const historyForApi = (chat?.history || []).filter(m =>
m.role !== 'thinking' && m.role !== 'reasoning' && m.role !== 'tool_call' && m.role !== 'tool_result'
)
messages.push(...historyForApi, { role: 'user', content: messageContent })
const requestBody = { model, messages, stream: true }
if (temperature !== null && temperature !== undefined) requestBody.temperature = temperature
if (topP !== null && topP !== undefined) requestBody.top_p = topP
if (topK !== null && topK !== undefined) requestBody.top_k = topK
if (contextSize) requestBody.max_tokens = contextSize
// Choose endpoint
const endpoint = activeChat.mcpMode
? API_CONFIG.endpoints.mcpChatCompletions
: API_CONFIG.endpoints.chatCompletions
const controller = new AbortController()
abortControllerRef.current = controller
setIsStreaming(true)
setStreamingContent('')
setStreamingReasoning('')
setStreamingToolCalls([])
setTokensPerSecond(null)
setMaxTokensPerSecond(null)
startTimeRef.current = Date.now()
tokenCountRef.current = 0
maxTpsRef.current = 0
let usage = {}
const newMessages = [] // Accumulate messages to add to history
if (activeChat.mcpMode) {
// MCP SSE streaming
try {
const timeoutId = setTimeout(() => controller.abort(), 300000) // 5 min timeout
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody),
signal: controller.signal,
})
clearTimeout(timeoutId)
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader()
let buffer = ''
let assistantContent = ''
let reasoningContent = ''
let hasReasoningFromAPI = false
let currentToolCalls = []
while (true) {
const { value, done } = await reader.read()
if (done) break
buffer += value
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
if (!line.trim() || line.startsWith(':')) continue
if (line === 'data: [DONE]') continue
if (!line.startsWith('data: ')) continue
try {
const eventData = JSON.parse(line.slice(6))
switch (eventData.type) {
case 'reasoning':
hasReasoningFromAPI = true
if (eventData.content) {
reasoningContent += eventData.content
tokenCountRef.current += Math.ceil(eventData.content.length / 4)
setStreamingReasoning(reasoningContent)
updateTps()
}
break
case 'tool_call':
if (eventData.name) {
const tc = {
type: 'tool_call',
name: eventData.name,
arguments: eventData.arguments || {},
reasoning: eventData.reasoning || '',
}
currentToolCalls.push(tc)
setStreamingToolCalls([...currentToolCalls])
newMessages.push({ role: 'tool_call', content: JSON.stringify(tc, null, 2), expanded: false })
}
break
case 'tool_result':
if (eventData.name) {
const tr = {
type: 'tool_result',
name: eventData.name,
result: eventData.result || '',
}
currentToolCalls.push(tr)
setStreamingToolCalls([...currentToolCalls])
newMessages.push({ role: 'tool_result', content: JSON.stringify(tr, null, 2), expanded: false })
}
break
case 'status':
// Logged but not displayed
break
case 'assistant':
if (eventData.content) {
assistantContent += eventData.content
tokenCountRef.current += Math.ceil(eventData.content.length / 4)
// Handle thinking tags if no API reasoning
if (!hasReasoningFromAPI) {
const { regularContent, thinkingContent } = extractThinking(assistantContent)
if (thinkingContent) {
reasoningContent = thinkingContent
setStreamingReasoning(reasoningContent)
}
setStreamingContent(regularContent)
} else {
setStreamingContent(assistantContent)
}
updateTps()
}
break
case 'error':
newMessages.push({ role: 'assistant', content: `Error: ${eventData.message}` })
break
}
} catch (_e) {
// skip malformed JSON
}
}
}
// Final: add accumulated messages
let finalContent = assistantContent
if (!hasReasoningFromAPI) {
const { regularContent, thinkingContent } = extractThinking(assistantContent)
finalContent = regularContent
if (thinkingContent && !reasoningContent) reasoningContent = thinkingContent
}
if (reasoningContent) {
newMessages.unshift({ role: 'thinking', content: reasoningContent, expanded: true })
}
if (finalContent) {
newMessages.push({ role: 'assistant', content: finalContent })
}
} catch (err) {
if (err.name !== 'AbortError') {
newMessages.push({ role: 'assistant', content: `Error: ${err.message}` })
}
}
} else {
// Regular SSE streaming
let rawContent = ''
let reasoningContent = ''
let hasReasoningFromAPI = false
let insideThinkTag = false
try {
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody),
signal: controller.signal,
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
const trimmed = line.trim()
if (!trimmed || !trimmed.startsWith('data: ')) continue
const data = trimmed.slice(6)
if (data === '[DONE]') continue
try {
const parsed = JSON.parse(data)
const delta = parsed?.choices?.[0]?.delta
// Handle reasoning field from API
if (delta?.reasoning) {
hasReasoningFromAPI = true
reasoningContent += delta.reasoning
tokenCountRef.current++
setStreamingReasoning(reasoningContent)
updateTps()
}
if (delta?.content) {
rawContent += delta.content
tokenCountRef.current++
if (!hasReasoningFromAPI) {
// Check thinking tags
if (openThinkTagRegex.test(rawContent) && !closeThinkTagRegex.test(rawContent)) {
insideThinkTag = true
}
if (insideThinkTag && closeThinkTagRegex.test(rawContent)) {
insideThinkTag = false
}
const { regularContent, thinkingContent } = extractThinking(rawContent)
if (thinkingContent) {
reasoningContent = thinkingContent
}
if (insideThinkTag) {
const lastOpen = Math.max(rawContent.lastIndexOf('<thinking>'), rawContent.lastIndexOf('<think>'))
if (lastOpen >= 0) {
const partial = rawContent.slice(lastOpen).replace(/<thinking>|<think>/, '')
setStreamingReasoning(partial)
// Only show content before the unclosed think tag (with prior complete pairs removed)
const beforeThink = rawContent.slice(0, lastOpen)
const { regularContent: contentBeforeThink } = extractThinking(beforeThink)
setStreamingContent(contentBeforeThink)
} else {
setStreamingContent(regularContent)
}
} else {
setStreamingReasoning(reasoningContent)
setStreamingContent(regularContent)
}
} else {
setStreamingContent(rawContent)
}
updateTps()
}
if (parsed?.usage) {
usage = parsed.usage
}
} catch (_e) {
// skip malformed JSON
}
}
}
} catch (err) {
if (err.name !== 'AbortError') {
rawContent += `\n\nError: ${err.message}`
}
}
// Determine final content
let finalContent = rawContent
if (!hasReasoningFromAPI) {
const { regularContent, thinkingContent } = extractThinking(rawContent)
finalContent = regularContent
if (thinkingContent && !reasoningContent) reasoningContent = thinkingContent
}
if (reasoningContent) {
newMessages.push({ role: 'thinking', content: reasoningContent, expanded: true })
}
if (finalContent) {
newMessages.push({ role: 'assistant', content: finalContent })
}
}
// Finalize
setIsStreaming(false)
abortControllerRef.current = null
setStreamingContent('')
setStreamingReasoning('')
setStreamingToolCalls([])
// Set max tokens/sec badge
if (maxTpsRef.current > 0) {
setMaxTokensPerSecond(Math.round(maxTpsRef.current * 10) / 10)
}
// Add messages to history
if (newMessages.length > 0) {
setChats(prev => prev.map(c => {
if (c.id !== chatId) return c
return {
...c,
history: [...c.history, ...newMessages],
tokenUsage: {
prompt: usage.prompt_tokens || c.tokenUsage.prompt,
completion: usage.completion_tokens || c.tokenUsage.completion,
total: usage.total_tokens || c.tokenUsage.total,
},
updatedAt: Date.now(),
}
}))
}
}, [activeChat, chats])
function updateTps() {
const elapsed = (Date.now() - startTimeRef.current) / 1000
if (elapsed > 0) {
const tps = tokenCountRef.current / elapsed
setTokensPerSecond(Math.round(tps * 10) / 10)
if (tps > maxTpsRef.current) {
maxTpsRef.current = tps
}
}
}
const stopGeneration = useCallback(() => {
if (abortControllerRef.current) {
abortControllerRef.current.abort()
}
}, [])
const clearHistory = useCallback((chatId) => {
setChats(prev => prev.map(c =>
c.id === chatId ? { ...c, history: [], tokenUsage: { prompt: 0, completion: 0, total: 0 }, updatedAt: Date.now() } : c
))
}, [])
return {
chats,
activeChat,
activeChatId,
isStreaming,
streamingContent,
streamingReasoning,
streamingToolCalls,
tokensPerSecond,
maxTokensPerSecond,
addChat,
switchChat,
deleteChat,
deleteAllChats,
renameChat,
updateChatSettings,
sendMessage,
stopGeneration,
clearHistory,
getContextUsagePercent,
}
}

View File

@@ -0,0 +1,69 @@
import { useState, useEffect, useCallback } from 'react'
import { modelsApi } from '../utils/api'
export function useModels(capability) {
const [models, setModels] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const fetchModels = useCallback(async () => {
try {
setLoading(true)
const data = await modelsApi.listCapabilities()
let items = data?.data || []
if (capability) {
items = items.filter(m =>
m.capabilities?.includes(capability) ||
// Models without config (loose files) have no capabilities — show them only when no filter
false
)
}
setModels(items)
setError(null)
} catch {
// Fallback to /v1/models if capabilities endpoint unavailable
try {
const data = await modelsApi.listV1()
setModels((data?.data || []).map(m => ({ id: m.id, capabilities: [] })))
setError(null)
} catch (err) {
setError(err.message)
}
} finally {
setLoading(false)
}
}, [capability])
useEffect(() => {
fetchModels()
}, [fetchModels])
return { models, loading, error, refetch: fetchModels }
}
export function useGalleryModels(params = {}) {
const [models, setModels] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [totalPages, setTotalPages] = useState(1)
const fetchModels = useCallback(async (fetchParams) => {
try {
setLoading(true)
const data = await modelsApi.list(fetchParams || params)
setModels(data?.models || [])
setTotalPages(data?.total_pages || 1)
setError(null)
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchModels(params)
}, [params.page, params.search, params.filter, params.sort, params.order])
return { models, loading, error, totalPages, refetch: fetchModels }
}

View File

@@ -0,0 +1,48 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { operationsApi } from '../utils/api'
export function useOperations(pollInterval = 1000) {
const [operations, setOperations] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const intervalRef = useRef(null)
const previousCountRef = useRef(0)
const fetchOperations = useCallback(async () => {
try {
const data = await operationsApi.list()
const ops = data?.operations || (Array.isArray(data) ? data : [])
setOperations(ops)
// Auto-refresh the page when all operations complete (mirrors original behavior)
if (previousCountRef.current > 0 && ops.length === 0) {
setTimeout(() => window.location.reload(), 1000)
}
previousCountRef.current = ops.length
setError(null)
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}, [])
const cancelOperation = useCallback(async (jobID) => {
try {
await operationsApi.cancel(jobID)
await fetchOperations()
} catch (err) {
setError(err.message)
}
}, [fetchOperations])
useEffect(() => {
fetchOperations()
intervalRef.current = setInterval(fetchOperations, pollInterval)
return () => {
if (intervalRef.current) clearInterval(intervalRef.current)
}
}, [fetchOperations, pollInterval])
return { operations, loading, error, cancelOperation, refetch: fetchOperations }
}

View File

@@ -0,0 +1,31 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { resourcesApi } from '../utils/api'
export function useResources(pollInterval = 5000) {
const [resources, setResources] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const intervalRef = useRef(null)
const fetchResources = useCallback(async () => {
try {
const data = await resourcesApi.get()
setResources(data)
setError(null)
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchResources()
intervalRef.current = setInterval(fetchResources, pollInterval)
return () => {
if (intervalRef.current) clearInterval(intervalRef.current)
}
}, [fetchResources, pollInterval])
return { resources, loading, error, refetch: fetchResources }
}

View File

@@ -0,0 +1,59 @@
/* Reset */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
height: 100%;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
font-family: 'Space Grotesk', -apple-system, BlinkMacSystemFont, sans-serif;
min-height: 100%;
background-color: var(--color-bg-primary);
color: var(--color-text-primary);
transition: background-color 200ms ease, color 200ms ease;
}
#root {
min-height: 100vh;
min-height: 100dvh;
}
/* Scrollbar */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: var(--color-bg-primary); }
::-webkit-scrollbar-thumb { background: var(--color-bg-secondary); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--color-primary); }
* { scrollbar-width: thin; scrollbar-color: var(--color-bg-secondary) var(--color-bg-primary); }
/* Typography */
h1, h2, h3, h4, h5, h6 {
font-family: 'Space Grotesk', sans-serif;
color: var(--color-text-primary);
line-height: 1.3;
}
code, pre {
font-family: 'JetBrains Mono', monospace;
}
a {
color: var(--color-primary);
text-decoration: none;
}
a:hover {
color: var(--color-primary-hover);
}
/* Utility classes */
.text-gradient {
background: var(--gradient-text);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}

View File

@@ -0,0 +1,17 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { RouterProvider } from 'react-router-dom'
import { ThemeProvider } from './contexts/ThemeContext'
import { router } from './router'
import '@fortawesome/fontawesome-free/css/all.min.css'
import './index.css'
import './theme.css'
import './App.css'
createRoot(document.getElementById('root')).render(
<StrictMode>
<ThemeProvider>
<RouterProvider router={router} />
</ThemeProvider>
</StrictMode>,
)

View File

@@ -0,0 +1,398 @@
import { useState, useEffect, useRef } from 'react'
import { useParams, useNavigate, useOutletContext } from 'react-router-dom'
import { agentJobsApi } from '../utils/api'
import LoadingSpinner from '../components/LoadingSpinner'
const traceColors = {
reasoning: { bg: 'rgba(99,102,241,0.1)', border: 'rgba(99,102,241,0.3)', icon: 'fa-brain', color: 'var(--color-primary)' },
tool_call: { bg: 'rgba(139,92,246,0.1)', border: 'rgba(139,92,246,0.3)', icon: 'fa-wrench', color: 'var(--color-accent)' },
tool_result: { bg: 'rgba(34,197,94,0.1)', border: 'rgba(34,197,94,0.3)', icon: 'fa-check', color: 'var(--color-success)' },
status: { bg: 'rgba(245,158,11,0.1)', border: 'rgba(245,158,11,0.3)', icon: 'fa-info-circle', color: 'var(--color-warning)' },
}
function TraceCard({ trace, index }) {
const [expanded, setExpanded] = useState(true)
const style = traceColors[trace.type] || traceColors.status
return (
<div style={{
background: style.bg, border: `1px solid ${style.border}`,
borderRadius: 'var(--radius-md)', marginBottom: 'var(--spacing-sm)', overflow: 'hidden',
}}>
<button
onClick={() => setExpanded(!expanded)}
style={{
width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: 'var(--spacing-sm) var(--spacing-md)', background: 'none', border: 'none',
cursor: 'pointer', color: 'var(--color-text-primary)', textAlign: 'left',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-sm)' }}>
<span style={{
fontSize: '0.6875rem', fontWeight: 700, color: 'var(--color-text-muted)',
background: 'var(--color-bg-secondary)', borderRadius: 'var(--radius-sm)',
padding: '2px 6px', minWidth: 24, textAlign: 'center',
}}>
{index + 1}
</span>
<i className={`fas ${style.icon}`} style={{ color: style.color, fontSize: '0.875rem' }} />
<span className="badge" style={{ background: style.border, color: style.color, fontSize: '0.6875rem' }}>
{trace.type || 'unknown'}
</span>
{trace.tool_name && (
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.75rem', color: 'var(--color-text-secondary)' }}>
{trace.tool_name}
</span>
)}
{trace.timestamp && (
<span style={{ fontSize: '0.6875rem', color: 'var(--color-text-muted)' }}>
{new Date(trace.timestamp).toLocaleTimeString()}
</span>
)}
</div>
<i className={`fas fa-chevron-${expanded ? 'up' : 'down'}`} style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)' }} />
</button>
{expanded && (
<div style={{ padding: '0 var(--spacing-md) var(--spacing-sm)', fontSize: '0.8125rem' }}>
{trace.content && (
<pre style={{
whiteSpace: 'pre-wrap', wordBreak: 'break-word', margin: 0,
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.75rem',
color: 'var(--color-text-secondary)', lineHeight: 1.6,
}}>
{trace.content}
</pre>
)}
{trace.arguments && (
<div style={{ marginTop: 'var(--spacing-xs)' }}>
<span style={{ fontSize: '0.6875rem', fontWeight: 600, color: 'var(--color-text-muted)' }}>Arguments:</span>
<pre style={{
whiteSpace: 'pre-wrap', wordBreak: 'break-word', margin: '4px 0 0',
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.75rem',
color: 'var(--color-text-secondary)', lineHeight: 1.5,
}}>
{typeof trace.arguments === 'string' ? trace.arguments : JSON.stringify(trace.arguments, null, 2)}
</pre>
</div>
)}
</div>
)}
</div>
)
}
export default function AgentJobDetails() {
const { id } = useParams()
const navigate = useNavigate()
const { addToast } = useOutletContext()
const [job, setJob] = useState(null)
const [task, setTask] = useState(null)
const [loading, setLoading] = useState(true)
const intervalRef = useRef(null)
useEffect(() => {
if (!id) return
const fetchJob = async () => {
try {
const data = await agentJobsApi.getJob(id)
setJob(data)
// Fetch associated task data
if (data?.task_id && !task) {
agentJobsApi.getTask(data.task_id).then(setTask).catch(() => {})
}
// Stop polling when job is done
if (data && data.status !== 'running' && data.status !== 'pending') {
if (intervalRef.current) {
clearInterval(intervalRef.current)
intervalRef.current = null
}
}
} catch (err) {
addToast(`Failed to load job: ${err.message}`, 'error')
} finally {
setLoading(false)
}
}
fetchJob()
intervalRef.current = setInterval(fetchJob, 2000)
return () => { if (intervalRef.current) clearInterval(intervalRef.current) }
}, [id, addToast])
const handleCancel = async () => {
try {
await agentJobsApi.cancelJob(id)
addToast('Job cancelled', 'success')
} catch (err) {
addToast(`Cancel failed: ${err.message}`, 'error')
}
}
const formatDate = (d) => d ? new Date(d).toLocaleString() : '-'
const statusBadge = (status) => {
const map = {
pending: { cls: 'badge-warning', icon: 'fa-clock' },
running: { cls: 'badge-info', icon: 'fa-spinner fa-spin' },
completed: { cls: 'badge-success', icon: 'fa-check' },
failed: { cls: 'badge-error', icon: 'fa-xmark' },
cancelled: { cls: '', icon: 'fa-ban' },
}
const m = map[status] || { cls: '', icon: 'fa-question' }
return (
<span className={`badge ${m.cls}`} style={{ fontSize: '0.875rem', padding: '4px 12px' }}>
<i className={`fas ${m.icon}`} style={{ marginRight: 4 }} /> {status || 'unknown'}
</span>
)
}
// Render the prompt with parameters substituted
const renderPrompt = () => {
if (!task?.prompt || !job?.parameters) return null
let rendered = task.prompt
Object.entries(job.parameters).forEach(([key, value]) => {
rendered = rendered.replace(new RegExp(`\\{\\{\\s*\\.${key}\\s*\\}\\}`, 'g'), value)
})
return rendered
}
if (loading) return <div className="page" style={{ display: 'flex', justifyContent: 'center', padding: 'var(--spacing-xl)' }}><LoadingSpinner size="lg" /></div>
if (!job) return (
<div className="page">
<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>
</div>
</div>
)
const renderedPrompt = renderPrompt()
const traces = Array.isArray(job.traces) ? job.traces : []
return (
<div className="page" style={{ maxWidth: 900 }}>
<div className="page-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<h1 className="page-title">Job Details</h1>
<p className="page-subtitle">Live status and reasoning traces</p>
</div>
<div style={{ display: 'flex', gap: 'var(--spacing-sm)' }}>
{(job.status === 'running' || job.status === 'pending') && (
<button className="btn btn-danger" onClick={handleCancel}>
<i className="fas fa-stop" /> Cancel
</button>
)}
<button className="btn btn-secondary" onClick={() => navigate('/agent-jobs')}>
<i className="fas fa-arrow-left" /> Back
</button>
</div>
</div>
{/* Status Card */}
<div className="card" style={{ marginBottom: 'var(--spacing-md)' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 'var(--spacing-md)' }}>
<h3 style={{ fontWeight: 600 }}>
<i className="fas fa-circle-info" style={{ color: 'var(--color-primary)', marginRight: 'var(--spacing-xs)' }} />
Job Status
</h3>
{statusBadge(job.status)}
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 'var(--spacing-md)' }}>
<div>
<span className="form-label">Job ID</span>
<p style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.8125rem', wordBreak: 'break-all' }}>{job.id}</p>
</div>
<div>
<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)' }}>
{job.task_id}
</a>
) : '-'}
</p>
</div>
<div>
<span className="form-label">Triggered By</span>
<p style={{ fontSize: '0.875rem' }}>{job.triggered_by || 'manual'}</p>
</div>
<div>
<span className="form-label">Created</span>
<p style={{ fontSize: '0.8125rem', color: 'var(--color-text-secondary)' }}>{formatDate(job.created_at)}</p>
</div>
<div>
<span className="form-label">Started</span>
<p style={{ fontSize: '0.8125rem', color: 'var(--color-text-secondary)' }}>{formatDate(job.started_at)}</p>
</div>
<div>
<span className="form-label">Completed</span>
<p style={{ fontSize: '0.8125rem', color: 'var(--color-text-secondary)' }}>{formatDate(job.completed_at)}</p>
</div>
</div>
</div>
{/* Prompt Template */}
{task?.prompt && (
<div className="card" style={{ marginBottom: 'var(--spacing-md)' }}>
<h3 style={{ fontWeight: 600, marginBottom: 'var(--spacing-sm)' }}>
<i className="fas fa-file-lines" style={{ color: 'var(--color-accent)', marginRight: 'var(--spacing-xs)' }} />
Agent Prompt Template
</h3>
<pre style={{
background: 'var(--color-bg-primary)', padding: 'var(--spacing-sm)',
borderRadius: 'var(--radius-md)', fontSize: '0.8125rem',
whiteSpace: 'pre-wrap', overflow: 'auto', maxHeight: 200,
}}>
{task.prompt}
</pre>
</div>
)}
{/* Cron Parameters */}
{job.triggered_by === 'cron' && job.cron_parameters && Object.keys(job.cron_parameters).length > 0 && (
<div className="card" style={{ marginBottom: 'var(--spacing-md)' }}>
<h3 style={{ fontWeight: 600, marginBottom: 'var(--spacing-sm)' }}>
<i className="fas fa-clock" style={{ color: 'var(--color-warning)', marginRight: 'var(--spacing-xs)' }} />
Cron Parameters
</h3>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 'var(--spacing-xs)' }}>
{Object.entries(job.cron_parameters).map(([k, v]) => (
<span key={k} className="badge badge-info" style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.75rem' }}>
{k}={v}
</span>
))}
</div>
</div>
)}
{/* Job Parameters */}
{job.parameters && Object.keys(job.parameters).length > 0 && (
<div className="card" style={{ marginBottom: 'var(--spacing-md)' }}>
<h3 style={{ fontWeight: 600, marginBottom: 'var(--spacing-sm)' }}>
<i className="fas fa-sliders-h" style={{ color: 'var(--color-primary)', marginRight: 'var(--spacing-xs)' }} />
Job Parameters
</h3>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 'var(--spacing-xs)' }}>
{Object.entries(job.parameters).map(([k, v]) => (
<span key={k} className="badge badge-info" style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.75rem' }}>
{k}={v}
</span>
))}
</div>
</div>
)}
{/* Rendered Prompt */}
{renderedPrompt && renderedPrompt !== task?.prompt && (
<div className="card" style={{ marginBottom: 'var(--spacing-md)' }}>
<h3 style={{ fontWeight: 600, marginBottom: 'var(--spacing-sm)' }}>
<i className="fas fa-spell-check" style={{ color: 'var(--color-success)', marginRight: 'var(--spacing-xs)' }} />
Rendered Prompt
</h3>
<pre style={{
background: 'var(--color-bg-primary)', padding: 'var(--spacing-sm)',
borderRadius: 'var(--radius-md)', fontSize: '0.8125rem',
whiteSpace: 'pre-wrap', overflow: 'auto', maxHeight: 300,
}}>
{renderedPrompt}
</pre>
</div>
)}
{/* Result */}
{job.result && (
<div className="card" style={{ marginBottom: 'var(--spacing-md)' }}>
<h3 style={{ fontWeight: 600, marginBottom: 'var(--spacing-sm)' }}>
<i className="fas fa-check-circle" style={{ color: 'var(--color-success)', marginRight: 'var(--spacing-xs)' }} />
Result
</h3>
<pre style={{
background: 'var(--color-bg-primary)', padding: 'var(--spacing-sm)',
borderRadius: 'var(--radius-md)', fontSize: '0.8125rem',
whiteSpace: 'pre-wrap', overflow: 'auto', maxHeight: 500,
}}>
{typeof job.result === 'string' ? job.result : JSON.stringify(job.result, null, 2)}
</pre>
</div>
)}
{/* Error */}
{job.error && (
<div className="card" style={{ marginBottom: 'var(--spacing-md)', borderColor: 'var(--color-error)' }}>
<h3 style={{ fontWeight: 600, marginBottom: 'var(--spacing-sm)', color: 'var(--color-error)' }}>
<i className="fas fa-exclamation-triangle" style={{ marginRight: 'var(--spacing-xs)' }} />
Error
</h3>
<pre style={{
background: 'rgba(239,68,68,0.05)', padding: 'var(--spacing-sm)',
borderRadius: 'var(--radius-md)', fontSize: '0.8125rem',
whiteSpace: 'pre-wrap', overflow: 'auto', color: 'var(--color-error)',
}}>
{typeof job.error === 'string' ? job.error : JSON.stringify(job.error, null, 2)}
</pre>
</div>
)}
{/* Execution Traces */}
{traces.length > 0 && (
<div className="card" style={{ marginBottom: 'var(--spacing-md)' }}>
<h3 style={{ fontWeight: 600, marginBottom: 'var(--spacing-md)' }}>
<i className="fas fa-wave-square" style={{ color: 'var(--color-accent)', marginRight: 'var(--spacing-xs)' }} />
Execution Traces ({traces.length} steps)
</h3>
{traces.map((trace, i) => (
<TraceCard key={i} trace={trace} index={i} />
))}
</div>
)}
{/* Running indicator */}
{(job.status === 'running' || job.status === 'pending') && (
<div style={{
textAlign: 'center', padding: 'var(--spacing-md)',
color: 'var(--color-text-muted)', fontSize: '0.8125rem',
}}>
<i className="fas fa-spinner fa-spin" style={{ marginRight: 'var(--spacing-xs)' }} />
Polling for updates every 2 seconds...
</div>
)}
{/* Webhook Status */}
{(job.webhook_sent !== undefined || job.webhook_error) && (
<div className="card">
<h3 style={{ fontWeight: 600, marginBottom: 'var(--spacing-md)' }}>
<i className="fas fa-globe" style={{ color: 'var(--color-primary)', marginRight: 'var(--spacing-xs)' }} />
Webhook Status
</h3>
<div style={{
display: 'flex', alignItems: 'center', gap: 'var(--spacing-sm)',
background: 'var(--color-bg-primary)', borderRadius: 'var(--radius-md)',
padding: 'var(--spacing-sm)',
}}>
{job.webhook_sent ? (
<>
<span className="badge badge-success"><i className="fas fa-check" /> Delivered</span>
{job.webhook_sent_at && (
<span style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)' }}>
at {formatDate(job.webhook_sent_at)}
</span>
)}
</>
) : job.webhook_error ? (
<>
<span className="badge badge-error"><i className="fas fa-xmark" /> Failed</span>
<span style={{ fontSize: '0.75rem', color: 'var(--color-error)' }}>{job.webhook_error}</span>
</>
) : (
<span className="badge badge-warning"><i className="fas fa-clock" /> Pending</span>
)}
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,496 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { useNavigate, useOutletContext } from 'react-router-dom'
import { agentJobsApi, modelsApi } from '../utils/api'
import { useModels } from '../hooks/useModels'
import LoadingSpinner from '../components/LoadingSpinner'
import { fileToBase64 } from '../utils/api'
export default function AgentJobs() {
const { addToast } = useOutletContext()
const navigate = useNavigate()
const { models } = useModels()
const [activeTab, setActiveTab] = useState('tasks')
const [tasks, setTasks] = useState([])
const [jobs, setJobs] = useState([])
const [loading, setLoading] = useState(true)
const [jobFilter, setJobFilter] = useState('all')
const [hasMCPModels, setHasMCPModels] = useState(false)
// Execute modal state
const [executeModal, setExecuteModal] = useState(null)
const [executeTab, setExecuteTab] = useState('parameters')
const [executeParams, setExecuteParams] = useState('')
const [executeMultimedia, setExecuteMultimedia] = useState({ images: [], videos: [], audios: [], files: [] })
const [executing, setExecuting] = useState(false)
const fileInputRef = useRef(null)
const fileTypeRef = useRef('images')
const fetchData = useCallback(async () => {
try {
const [t, j] = await Promise.allSettled([
agentJobsApi.listTasks(),
agentJobsApi.listJobs(),
])
if (t.status === 'fulfilled') setTasks(Array.isArray(t.value) ? t.value : [])
if (j.status === 'fulfilled') setJobs(Array.isArray(j.value) ? j.value : [])
} catch (err) {
addToast(`Failed to load: ${err.message}`, 'error')
} finally {
setLoading(false)
}
}, [addToast])
useEffect(() => {
fetchData()
const interval = setInterval(fetchData, 5000)
return () => clearInterval(interval)
}, [fetchData])
// Check for MCP-enabled models
useEffect(() => {
if (models.length === 0) { setHasMCPModels(false); return }
let cancelled = false
Promise.all(
models.map(m => modelsApi.getConfigJson(m.id).catch(() => null))
).then(configs => {
if (cancelled) return
const hasMcp = configs.some(cfg => cfg && (cfg.mcp?.remote || cfg.mcp?.stdio))
setHasMCPModels(hasMcp)
})
return () => { cancelled = true }
}, [models])
const handleDeleteTask = async (id) => {
if (!confirm('Delete this task?')) return
try {
await agentJobsApi.deleteTask(id)
addToast('Task deleted', 'success')
fetchData()
} catch (err) {
addToast(`Failed to delete: ${err.message}`, 'error')
}
}
const handleCancelJob = async (id) => {
try {
await agentJobsApi.cancelJob(id)
addToast('Job cancelled', 'success')
fetchData()
} catch (err) {
addToast(`Failed to cancel: ${err.message}`, 'error')
}
}
const handleClearHistory = async () => {
if (!confirm('Clear all job history?')) return
try {
// Cancel all running jobs first, then refetch
const running = jobs.filter(j => j.status === 'running' || j.status === 'pending')
await Promise.all(running.map(j => agentJobsApi.cancelJob(j.id).catch(() => {})))
addToast('Job history cleared', 'success')
fetchData()
} catch (err) {
addToast(`Failed to clear: ${err.message}`, 'error')
}
}
const openExecuteModal = (task) => {
setExecuteModal(task)
setExecuteTab('parameters')
setExecuteParams('')
setExecuteMultimedia({ images: [], videos: [], audios: [], files: [] })
}
const handleExecute = async () => {
if (!executeModal) return
setExecuting(true)
try {
const body = { name: executeModal.name || executeModal.id }
// Parse parameters
if (executeParams.trim()) {
const params = {}
executeParams.split('\n').forEach(line => {
const [key, ...rest] = line.split('=')
if (key?.trim() && rest.length > 0) {
params[key.trim()] = rest.join('=').trim()
}
})
body.parameters = params
}
// Add multimedia
const mm = {}
if (executeMultimedia.images.length > 0) mm.images = executeMultimedia.images
if (executeMultimedia.videos.length > 0) mm.videos = executeMultimedia.videos
if (executeMultimedia.audios.length > 0) mm.audios = executeMultimedia.audios
if (executeMultimedia.files.length > 0) mm.files = executeMultimedia.files
if (Object.keys(mm).length > 0) body.multimedia = mm
await agentJobsApi.executeTask(executeModal.name || executeModal.id)
addToast(`Task "${executeModal.name}" started`, 'success')
setExecuteModal(null)
fetchData()
} catch (err) {
addToast(`Failed to execute: ${err.message}`, 'error')
} finally {
setExecuting(false)
}
}
const handleFileUpload = async (e, type) => {
for (const file of e.target.files) {
const base64 = await fileToBase64(file)
const url = `data:${file.type};base64,${base64}`
setExecuteMultimedia(prev => ({
...prev,
[type]: [...prev[type], { url, name: file.name }]
}))
}
e.target.value = ''
}
const removeMultimedia = (type, index) => {
setExecuteMultimedia(prev => ({
...prev,
[type]: prev[type].filter((_, i) => i !== index)
}))
}
const filteredJobs = jobFilter === 'all' ? jobs : jobs.filter(j => j.status === jobFilter)
const statusBadge = (status) => {
const cls = status === 'completed' ? 'badge-success' : status === 'failed' ? 'badge-error' : status === 'running' ? 'badge-info' : status === 'cancelled' ? '' : 'badge-warning'
return <span className={`badge ${cls}`}>{status || 'unknown'}</span>
}
const formatDate = (d) => {
if (!d) return '-'
return new Date(d).toLocaleString()
}
// Wizard: no models installed
if (!loading && models.length === 0) {
return (
<div className="page">
<div className="page-header">
<h1 className="page-title">Agent Jobs</h1>
<p className="page-subtitle">Manage agent tasks and automated workflows</p>
</div>
<div className="card" style={{ textAlign: 'center', padding: 'var(--spacing-xl)' }}>
<i className="fas fa-exclamation-triangle" style={{ fontSize: '3rem', color: 'var(--color-warning)', marginBottom: 'var(--spacing-md)' }} />
<h2 style={{ marginBottom: 'var(--spacing-sm)' }}>No Models Installed</h2>
<p style={{ color: 'var(--color-text-secondary)', marginBottom: 'var(--spacing-md)', maxWidth: 500, margin: '0 auto var(--spacing-md)' }}>
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')}>
<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">
<i className="fas fa-book" /> Documentation
</a>
</div>
</div>
</div>
)
}
// Wizard: models but no MCP
if (!loading && models.length > 0 && !hasMCPModels && tasks.length === 0) {
return (
<div className="page">
<div className="page-header">
<h1 className="page-title">Agent Jobs</h1>
<p className="page-subtitle">Manage agent tasks and automated workflows</p>
</div>
<div className="card" style={{ textAlign: 'center', padding: 'var(--spacing-xl)' }}>
<i className="fas fa-plug" style={{ fontSize: '3rem', color: 'var(--color-primary)', marginBottom: 'var(--spacing-md)' }} />
<h2 style={{ marginBottom: 'var(--spacing-sm)' }}>MCP Not Configured</h2>
<p style={{ color: 'var(--color-text-secondary)', marginBottom: 'var(--spacing-md)', maxWidth: 600, margin: '0 auto var(--spacing-md)' }}>
You have models installed, but none have MCP (Model Context Protocol) enabled. Agent Jobs require MCP to interact with tools and external services. Edit a model configuration to add MCP servers.
</p>
<div style={{ background: 'var(--color-bg-primary)', borderRadius: 'var(--radius-md)', padding: 'var(--spacing-md)', maxWidth: 500, margin: '0 auto var(--spacing-md)', textAlign: 'left' }}>
<p style={{ fontSize: '0.8125rem', fontWeight: 600, marginBottom: 'var(--spacing-xs)' }}>Example MCP configuration (YAML):</p>
<pre style={{ fontSize: '0.75rem', fontFamily: "'JetBrains Mono', monospace", color: 'var(--color-text-secondary)', whiteSpace: 'pre-wrap' }}>{`mcp:
stdio:
- name: my-tool
command: /path/to/tool
args: ["--flag"]`}</pre>
</div>
<div style={{ display: 'flex', gap: 'var(--spacing-sm)', justifyContent: 'center' }}>
<button className="btn btn-primary" onClick={() => navigate('/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">
<i className="fas fa-book" /> Documentation
</a>
</div>
</div>
</div>
)
}
return (
<div className="page">
<div className="page-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<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')}>
<i className="fas fa-plus" /> New Task
</button>
</div>
<div className="tabs">
<button className={`tab ${activeTab === 'tasks' ? 'tab-active' : ''}`} onClick={() => setActiveTab('tasks')}>
<i className="fas fa-list-check" /> Tasks ({tasks.length})
</button>
<button className={`tab ${activeTab === 'jobs' ? 'tab-active' : ''}`} onClick={() => setActiveTab('jobs')}>
<i className="fas fa-clock-rotate-left" /> Job History ({jobs.length})
</button>
</div>
{loading ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: 'var(--spacing-xl)' }}><LoadingSpinner size="lg" /></div>
) : activeTab === 'tasks' ? (
tasks.length === 0 ? (
<div className="empty-state">
<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')}>
<i className="fas fa-plus" /> Create Task
</button>
</div>
) : (
<div className="table-container">
<table className="table">
<thead>
<tr>
<th>Name</th>
<th>Description</th>
<th>Model</th>
<th>Cron</th>
<th>Status</th>
<th style={{ textAlign: 'right' }}>Actions</th>
</tr>
</thead>
<tbody>
{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 }}>
{task.name || task.id}
</a>
</td>
<td>
<span style={{ fontSize: '0.8125rem', color: 'var(--color-text-secondary)', maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'inline-block' }}>
{task.description || '-'}
</span>
</td>
<td>
{task.model ? (
<a onClick={() => navigate(`/model-editor/${encodeURIComponent(task.model)}`)} style={{ cursor: 'pointer', color: 'var(--color-primary)', fontSize: '0.8125rem' }}>
{task.model}
</a>
) : '-'}
</td>
<td>
{task.cron ? (
<span className="badge badge-info" style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.6875rem' }}>
{task.cron}
</span>
) : '-'}
</td>
<td>
{task.enabled === false ? (
<span className="badge" style={{ background: 'var(--color-bg-tertiary)', color: 'var(--color-text-muted)' }}>Disabled</span>
) : (
<span className="badge badge-success">Enabled</span>
)}
</td>
<td>
<div style={{ display: 'flex', gap: 'var(--spacing-xs)', justifyContent: 'flex-end' }}>
<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">
<i className="fas fa-edit" />
</button>
<button className="btn btn-danger btn-sm" onClick={() => handleDeleteTask(task.id || task.name)} title="Delete">
<i className="fas fa-trash" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)
) : (
<>
{/* Job History Controls */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 'var(--spacing-md)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-sm)' }}>
<select className="input" value={jobFilter} onChange={(e) => setJobFilter(e.target.value)} style={{ width: 'auto', minWidth: 140 }}>
<option value="all">All Status</option>
<option value="pending">Pending</option>
<option value="running">Running</option>
<option value="completed">Completed</option>
<option value="failed">Failed</option>
<option value="cancelled">Cancelled</option>
</select>
<span style={{ fontSize: '0.8125rem', color: 'var(--color-text-muted)' }}>
{filteredJobs.length} job{filteredJobs.length !== 1 ? 's' : ''}
</span>
</div>
{jobs.length > 0 && (
<button className="btn btn-secondary btn-sm" onClick={handleClearHistory}>
<i className="fas fa-broom" /> Clear History
</button>
)}
</div>
{filteredJobs.length === 0 ? (
<div className="empty-state">
<div className="empty-state-icon"><i className="fas fa-list-check" /></div>
<h2 className="empty-state-title">No jobs {jobFilter !== 'all' ? `with status "${jobFilter}"` : ''}</h2>
<p className="empty-state-text">Execute a task to create a job.</p>
</div>
) : (
<div className="table-container">
<table className="table">
<thead>
<tr>
<th>Job ID</th>
<th>Task</th>
<th>Status</th>
<th>Created</th>
<th style={{ textAlign: 'right' }}>Actions</th>
</tr>
</thead>
<tbody>
{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' }}>
{job.id?.slice(0, 12)}...
</a>
</td>
<td>{job.task_id || '-'}</td>
<td>{statusBadge(job.status)}</td>
<td style={{ fontSize: '0.8125rem', color: 'var(--color-text-secondary)' }}>
{formatDate(job.created_at)}
</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">
<i className="fas fa-eye" />
</button>
{(job.status === 'running' || job.status === 'pending') && (
<button className="btn btn-danger btn-sm" onClick={() => handleCancelJob(job.id)} title="Cancel">
<i className="fas fa-stop" />
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</>
)}
{/* Execute Task Modal */}
{executeModal && (
<div style={{
position: 'fixed', inset: 0, zIndex: 1000,
background: 'rgba(0,0,0,0.6)', display: 'flex', alignItems: 'center', justifyContent: 'center',
}} onClick={() => setExecuteModal(null)}>
<div className="card" style={{ maxWidth: 600, width: '90%', maxHeight: '80vh', overflow: 'auto' }} onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 'var(--spacing-md)' }}>
<h3 style={{ fontWeight: 600 }}>
<i className="fas fa-play" style={{ color: 'var(--color-primary)', marginRight: 'var(--spacing-xs)' }} />
Execute: {executeModal.name}
</h3>
<button className="btn btn-secondary btn-sm" onClick={() => setExecuteModal(null)}>
<i className="fas fa-xmark" />
</button>
</div>
{/* Tabs */}
<div className="tabs" style={{ marginBottom: 'var(--spacing-md)' }}>
<button className={`tab ${executeTab === 'parameters' ? 'tab-active' : ''}`} onClick={() => setExecuteTab('parameters')}>
<i className="fas fa-sliders-h" /> Parameters
</button>
<button className={`tab ${executeTab === 'multimedia' ? 'tab-active' : ''}`} onClick={() => setExecuteTab('multimedia')}>
<i className="fas fa-photo-film" /> Multimedia
</button>
</div>
{executeTab === 'parameters' ? (
<div>
<label className="form-label">Parameters (key=value, one per line)</label>
<textarea
className="textarea"
value={executeParams}
onChange={(e) => setExecuteParams(e.target.value)}
rows={5}
placeholder={`topic=AI trends\nformat=markdown`}
style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.8125rem' }}
/>
<p style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)', marginTop: 'var(--spacing-xs)' }}>
These will be available as {'{{.parameter_name}}'} in the prompt template.
</p>
</div>
) : (
<div>
{['images', 'videos', 'audios', 'files'].map(type => (
<div key={type} style={{ marginBottom: 'var(--spacing-md)' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 'var(--spacing-xs)' }}>
<label className="form-label" style={{ marginBottom: 0, textTransform: 'capitalize' }}>
<i className={`fas ${type === 'images' ? 'fa-image' : type === 'videos' ? 'fa-video' : type === 'audios' ? 'fa-headphones' : 'fa-file'}`} style={{ marginRight: 4 }} />
{type} ({executeMultimedia[type].length})
</label>
<button className="btn btn-secondary btn-sm" onClick={() => { fileTypeRef.current = type; fileInputRef.current?.click() }}>
<i className="fas fa-plus" /> Add
</button>
</div>
{executeMultimedia[type].length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{executeMultimedia[type].map((item, i) => (
<div key={i} style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
background: 'var(--color-bg-primary)', borderRadius: 'var(--radius-sm)', padding: '4px 8px', fontSize: '0.75rem',
}}>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{item.name || item.url?.slice(0, 40)}</span>
<button onClick={() => removeMultimedia(type, i)} style={{ background: 'none', border: 'none', color: 'var(--color-error)', cursor: 'pointer', padding: '2px 4px' }}>
<i className="fas fa-xmark" />
</button>
</div>
))}
</div>
)}
</div>
))}
<input ref={fileInputRef} type="file" multiple style={{ display: 'none' }} onChange={(e) => handleFileUpload(e, fileTypeRef.current)} />
</div>
)}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 'var(--spacing-sm)', marginTop: 'var(--spacing-md)' }}>
<button className="btn btn-secondary" onClick={() => setExecuteModal(null)}>Cancel</button>
<button className="btn btn-primary" onClick={handleExecute} disabled={executing}>
{executing ? <><i className="fas fa-spinner fa-spin" /> Running...</> : <><i className="fas fa-play" /> Execute</>}
</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,508 @@
import { useState, useEffect, useCallback } from 'react'
import { useParams, useNavigate, useOutletContext, useLocation } from 'react-router-dom'
import { agentJobsApi } from '../utils/api'
import ModelSelector from '../components/ModelSelector'
import LoadingSpinner from '../components/LoadingSpinner'
export default function AgentTaskDetails() {
const { id } = useParams()
const navigate = useNavigate()
const location = useLocation()
const { addToast } = useOutletContext()
const isNew = !id || location.pathname.endsWith('/new')
const isEdit = location.pathname.endsWith('/edit')
const [task, setTask] = useState({
name: '', description: '', model: '', prompt: '', context: '',
enabled: true, cron: '', cron_parameters: '',
webhooks: [], multimedia_sources: [],
})
const [loading, setLoading] = useState(!isNew)
const [saving, setSaving] = useState(false)
const [jobHistory, setJobHistory] = useState([])
const [cronError, setCronError] = useState('')
useEffect(() => {
if (!isNew && id) {
agentJobsApi.getTask(id).then(data => {
if (data) {
setTask({
name: data.name || '',
description: data.description || '',
model: data.model || '',
prompt: data.prompt || '',
context: data.context || '',
enabled: data.enabled !== false,
cron: data.cron || '',
cron_parameters: typeof data.cron_parameters === 'object'
? Object.entries(data.cron_parameters).map(([k, v]) => `${k}=${v}`).join('\n')
: (data.cron_parameters || ''),
webhooks: Array.isArray(data.webhooks) ? data.webhooks : [],
multimedia_sources: Array.isArray(data.multimedia_sources) ? data.multimedia_sources : [],
})
}
setLoading(false)
}).catch(err => {
addToast(`Failed to load task: ${err.message}`, 'error')
setLoading(false)
})
// Fetch job history for this task
agentJobsApi.listJobs().then(jobs => {
if (Array.isArray(jobs)) {
setJobHistory(jobs.filter(j => j.task_id === id).slice(0, 20))
}
}).catch(() => {})
}
}, [id, isNew, addToast])
const updateField = (field, value) => {
setTask(prev => ({ ...prev, [field]: value }))
}
const validateCron = (expr) => {
if (!expr) { setCronError(''); return }
const parts = expr.trim().split(/\s+/)
if (parts.length < 5 || parts.length > 6) {
setCronError('Cron must have 5 or 6 fields (min hour day month weekday [year])')
} else {
setCronError('')
}
}
// Webhook management
const addWebhook = () => {
updateField('webhooks', [...task.webhooks, { url: '', method: 'POST', headers: '{}', payload_template: '' }])
}
const updateWebhook = (i, field, value) => {
const wh = [...task.webhooks]
wh[i] = { ...wh[i], [field]: value }
updateField('webhooks', wh)
}
const removeWebhook = (i) => {
updateField('webhooks', task.webhooks.filter((_, idx) => idx !== i))
}
// Multimedia source management
const addMultimediaSource = () => {
updateField('multimedia_sources', [...task.multimedia_sources, { type: 'image', url: '', headers: '{}' }])
}
const updateMultimediaSource = (i, field, value) => {
const ms = [...task.multimedia_sources]
ms[i] = { ...ms[i], [field]: value }
updateField('multimedia_sources', ms)
}
const removeMultimediaSource = (i) => {
updateField('multimedia_sources', task.multimedia_sources.filter((_, idx) => idx !== i))
}
const handleSave = async (e) => {
e.preventDefault()
if (!task.name?.trim()) { addToast('Task name is required', 'warning'); return }
if (cronError) { addToast('Fix cron expression errors first', 'warning'); return }
setSaving(true)
try {
const body = { ...task }
// Parse cron_parameters from key=value lines to object
if (body.cron_parameters && typeof body.cron_parameters === 'string') {
const params = {}
body.cron_parameters.split('\n').forEach(line => {
const [key, ...rest] = line.split('=')
if (key?.trim() && rest.length > 0) {
params[key.trim()] = rest.join('=').trim()
}
})
body.cron_parameters = params
}
// Parse webhook headers from JSON strings
if (body.webhooks) {
body.webhooks = body.webhooks.map(wh => ({
...wh,
headers: typeof wh.headers === 'string' ? JSON.parse(wh.headers || '{}') : wh.headers,
}))
}
// Parse multimedia source headers
if (body.multimedia_sources) {
body.multimedia_sources = body.multimedia_sources.map(ms => ({
...ms,
headers: typeof ms.headers === 'string' ? JSON.parse(ms.headers || '{}') : ms.headers,
}))
}
if (isNew) {
await agentJobsApi.createTask(body)
addToast('Task created', 'success')
} else {
await agentJobsApi.updateTask(id, body)
addToast('Task updated', 'success')
}
navigate('/agent-jobs')
} catch (err) {
addToast(`Save failed: ${err.message}`, 'error')
} finally {
setSaving(false)
}
}
const statusBadge = (status) => {
const cls = status === 'completed' ? 'badge-success' : status === 'failed' ? 'badge-error' : status === 'running' ? 'badge-info' : status === 'cancelled' ? '' : 'badge-warning'
return <span className={`badge ${cls}`}>{status || 'unknown'}</span>
}
const formatDate = (d) => d ? new Date(d).toLocaleString() : '-'
if (loading) return <div className="page" style={{ display: 'flex', justifyContent: 'center', padding: 'var(--spacing-xl)' }}><LoadingSpinner size="lg" /></div>
// View mode
if (!isNew && !isEdit) {
return (
<div className="page" style={{ maxWidth: 900 }}>
<div className="page-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<h1 className="page-title">{task.name || 'Task Details'}</h1>
{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`)}>
<i className="fas fa-edit" /> Edit
</button>
<button className="btn btn-secondary btn-sm" onClick={() => navigate('/agent-jobs')}>
<i className="fas fa-arrow-left" /> Back
</button>
</div>
</div>
{/* Task Info */}
<div className="card" style={{ marginBottom: 'var(--spacing-md)' }}>
<h3 style={{ fontWeight: 600, marginBottom: 'var(--spacing-md)' }}>
<i className="fas fa-info-circle" style={{ color: 'var(--color-primary)', marginRight: 'var(--spacing-xs)' }} />
Task Information
</h3>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--spacing-md)' }}>
<div>
<span className="form-label">Model</span>
<p style={{ fontSize: '0.875rem' }}>{task.model || '-'}</p>
</div>
<div>
<span className="form-label">Status</span>
<p>{task.enabled !== false ? <span className="badge badge-success">Enabled</span> : <span className="badge">Disabled</span>}</p>
</div>
{task.cron && (
<div>
<span className="form-label">Cron Schedule</span>
<p style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.8125rem' }}>{task.cron}</p>
</div>
)}
</div>
{task.prompt && (
<div style={{ marginTop: 'var(--spacing-md)' }}>
<span className="form-label">Prompt Template</span>
<pre style={{ background: 'var(--color-bg-primary)', padding: 'var(--spacing-sm)', borderRadius: 'var(--radius-md)', fontSize: '0.8125rem', whiteSpace: 'pre-wrap', overflow: 'auto', maxHeight: 300 }}>
{task.prompt}
</pre>
</div>
)}
{task.context && (
<div style={{ marginTop: 'var(--spacing-md)' }}>
<span className="form-label">Context</span>
<pre style={{ background: 'var(--color-bg-primary)', padding: 'var(--spacing-sm)', borderRadius: 'var(--radius-md)', fontSize: '0.8125rem', whiteSpace: 'pre-wrap' }}>
{task.context}
</pre>
</div>
)}
</div>
{/* API Usage Examples */}
<div className="card" style={{ marginBottom: 'var(--spacing-md)' }}>
<h3 style={{ fontWeight: 600, marginBottom: 'var(--spacing-md)' }}>
<i className="fas fa-code" style={{ color: 'var(--color-accent)', marginRight: 'var(--spacing-xs)' }} />
API Usage
</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-md)' }}>
<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`}
</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 \\
-H "Content-Type: application/json" \\
-d '{"multimedia": {"images": [{"url": "https://example.com/image.jpg"}]}}'`}
</pre>
</div>
<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>`}
</pre>
</div>
</div>
</div>
{/* Webhooks */}
{task.webhooks && task.webhooks.length > 0 && (
<div className="card" style={{ marginBottom: 'var(--spacing-md)' }}>
<h3 style={{ fontWeight: 600, marginBottom: 'var(--spacing-md)' }}>
<i className="fas fa-globe" style={{ color: 'var(--color-success)', marginRight: 'var(--spacing-xs)' }} />
Webhooks ({task.webhooks.length})
</h3>
{task.webhooks.map((wh, i) => (
<div key={i} style={{ background: 'var(--color-bg-primary)', borderRadius: 'var(--radius-md)', padding: 'var(--spacing-sm)', marginBottom: 'var(--spacing-sm)' }}>
<div style={{ display: 'flex', gap: 'var(--spacing-sm)', fontSize: '0.8125rem' }}>
<span className="badge badge-info">{wh.method || 'POST'}</span>
<span style={{ fontFamily: "'JetBrains Mono', monospace" }}>{wh.url}</span>
</div>
</div>
))}
</div>
)}
{/* Job History */}
{jobHistory.length > 0 && (
<div className="card">
<h3 style={{ fontWeight: 600, marginBottom: 'var(--spacing-md)' }}>
<i className="fas fa-clock-rotate-left" style={{ color: 'var(--color-warning)', marginRight: 'var(--spacing-xs)' }} />
Recent Jobs ({jobHistory.length})
</h3>
<div className="table-container">
<table className="table">
<thead>
<tr><th>Job ID</th><th>Status</th><th>Created</th><th>Actions</th></tr>
</thead>
<tbody>
{jobHistory.map(job => (
<tr key={job.id}>
<td style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.8125rem' }}>
{job.id?.slice(0, 12)}...
</td>
<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}`)}>
<i className="fas fa-eye" /> View
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
)
}
// Edit/Create form
return (
<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')}>
<i className="fas fa-arrow-left" /> Back
</button>
</div>
<form onSubmit={handleSave}>
{/* Basic Info */}
<div className="card" style={{ marginBottom: 'var(--spacing-md)' }}>
<h3 style={{ fontWeight: 600, marginBottom: 'var(--spacing-md)' }}>Basic Information</h3>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--spacing-md)' }}>
<div className="form-group">
<label className="form-label">Task Name *</label>
<input className="input" value={task.name} onChange={(e) => updateField('name', e.target.value)} placeholder="my-task" required />
</div>
<div className="form-group">
<label className="form-label">Model</label>
<ModelSelector value={task.model} onChange={(model) => updateField('model', model)} capability="FLAG_CHAT" />
</div>
</div>
<div className="form-group">
<label className="form-label">Description</label>
<input className="input" value={task.description} onChange={(e) => updateField('description', e.target.value)} placeholder="Brief description of what this task does" />
</div>
<div className="form-group">
<label style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-sm)', cursor: 'pointer' }}>
<input type="checkbox" checked={task.enabled} onChange={(e) => updateField('enabled', e.target.checked)} />
<span className="form-label" style={{ marginBottom: 0 }}>Enabled</span>
</label>
</div>
</div>
{/* Prompt Template */}
<div className="card" style={{ marginBottom: 'var(--spacing-md)' }}>
<h3 style={{ fontWeight: 600, marginBottom: 'var(--spacing-md)' }}>Prompt Template</h3>
<div className="form-group">
<label className="form-label">Prompt</label>
<textarea
className="textarea"
value={task.prompt}
onChange={(e) => updateField('prompt', e.target.value)}
rows={8}
placeholder={`Write a summary about {{.topic}} in {{.format}} format.`}
style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.8125rem' }}
/>
<p style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)', marginTop: 'var(--spacing-xs)' }}>
Use {'{{.parameter_name}}'} for dynamic parameters. Parameters are provided when executing the task.
</p>
</div>
<div className="form-group">
<label className="form-label">Context (optional)</label>
<textarea className="textarea" value={task.context} onChange={(e) => updateField('context', e.target.value)} rows={3} placeholder="Additional context for the agent..." />
</div>
</div>
{/* Cron Schedule */}
<div className="card" style={{ marginBottom: 'var(--spacing-md)' }}>
<h3 style={{ fontWeight: 600, marginBottom: 'var(--spacing-md)' }}>
<i className="fas fa-clock" style={{ marginRight: 'var(--spacing-xs)' }} />
Cron Schedule (optional)
</h3>
<div className="form-group">
<label className="form-label">Cron Expression</label>
<input
className="input"
value={task.cron}
onChange={(e) => { updateField('cron', e.target.value); validateCron(e.target.value) }}
placeholder="0 */6 * * *"
style={{ fontFamily: "'JetBrains Mono', monospace" }}
/>
{cronError && <p style={{ color: 'var(--color-error)', fontSize: '0.75rem', marginTop: 4 }}>{cronError}</p>}
<p style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)', marginTop: 'var(--spacing-xs)' }}>
Format: minute hour day month weekday (e.g., "0 */6 * * *" = every 6 hours)
</p>
</div>
{task.cron && (
<div className="form-group">
<label className="form-label">Cron Parameters (key=value, one per line)</label>
<textarea
className="textarea"
value={task.cron_parameters}
onChange={(e) => updateField('cron_parameters', e.target.value)}
rows={3}
placeholder={`topic=daily news\nformat=bullet points`}
style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.8125rem' }}
/>
<p style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)', marginTop: 'var(--spacing-xs)' }}>
Default parameters used when the cron triggers the task.
</p>
</div>
)}
</div>
{/* Multimedia Sources */}
<div className="card" style={{ marginBottom: 'var(--spacing-md)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 'var(--spacing-md)' }}>
<h3 style={{ fontWeight: 600 }}>
<i className="fas fa-photo-film" style={{ marginRight: 'var(--spacing-xs)' }} />
Multimedia Sources (optional)
</h3>
<button type="button" className="btn btn-secondary btn-sm" onClick={addMultimediaSource}>
<i className="fas fa-plus" /> Add Source
</button>
</div>
{task.multimedia_sources.length === 0 ? (
<p style={{ color: 'var(--color-text-muted)', fontSize: '0.8125rem' }}>No multimedia sources configured.</p>
) : (
task.multimedia_sources.map((ms, i) => (
<div key={i} style={{ background: 'var(--color-bg-primary)', borderRadius: 'var(--radius-md)', padding: 'var(--spacing-sm)', marginBottom: 'var(--spacing-sm)' }}>
<div style={{ display: 'flex', gap: 'var(--spacing-sm)', alignItems: 'flex-start' }}>
<div className="form-group" style={{ minWidth: 120 }}>
<label className="form-label">Type</label>
<select className="input" value={ms.type} onChange={(e) => updateMultimediaSource(i, 'type', e.target.value)}>
<option value="image">Image</option>
<option value="video">Video</option>
<option value="audio">Audio</option>
<option value="file">File</option>
</select>
</div>
<div className="form-group" style={{ flex: 1 }}>
<label className="form-label">URL</label>
<input className="input" value={ms.url} onChange={(e) => updateMultimediaSource(i, 'url', e.target.value)} placeholder="https://example.com/media.jpg" />
</div>
<button type="button" className="btn btn-danger btn-sm" onClick={() => removeMultimediaSource(i)} style={{ marginTop: 24 }}>
<i className="fas fa-trash" />
</button>
</div>
<div className="form-group" style={{ marginTop: 'var(--spacing-xs)' }}>
<label className="form-label">Headers (JSON)</label>
<input className="input" value={ms.headers} onChange={(e) => updateMultimediaSource(i, 'headers', e.target.value)} placeholder='{"Authorization": "Bearer ..."}' style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.8125rem' }} />
</div>
</div>
))
)}
</div>
{/* Webhooks */}
<div className="card" style={{ marginBottom: 'var(--spacing-md)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 'var(--spacing-md)' }}>
<h3 style={{ fontWeight: 600 }}>
<i className="fas fa-globe" style={{ marginRight: 'var(--spacing-xs)' }} />
Webhooks (optional)
</h3>
<button type="button" className="btn btn-secondary btn-sm" onClick={addWebhook}>
<i className="fas fa-plus" /> Add Webhook
</button>
</div>
{task.webhooks.length === 0 ? (
<p style={{ color: 'var(--color-text-muted)', fontSize: '0.8125rem' }}>No webhooks configured.</p>
) : (
task.webhooks.map((wh, i) => (
<div key={i} style={{ background: 'var(--color-bg-primary)', borderRadius: 'var(--radius-md)', padding: 'var(--spacing-sm)', marginBottom: 'var(--spacing-sm)' }}>
<div style={{ display: 'flex', gap: 'var(--spacing-sm)', alignItems: 'flex-start' }}>
<div className="form-group" style={{ minWidth: 100 }}>
<label className="form-label">Method</label>
<select className="input" value={wh.method} onChange={(e) => updateWebhook(i, 'method', e.target.value)}>
<option value="POST">POST</option>
<option value="PUT">PUT</option>
<option value="PATCH">PATCH</option>
</select>
</div>
<div className="form-group" style={{ flex: 1 }}>
<label className="form-label">URL</label>
<input className="input" value={wh.url} onChange={(e) => updateWebhook(i, 'url', e.target.value)} placeholder="https://hooks.slack.com/..." />
</div>
<button type="button" className="btn btn-danger btn-sm" onClick={() => removeWebhook(i)} style={{ marginTop: 24 }}>
<i className="fas fa-trash" />
</button>
</div>
<div className="form-group" style={{ marginTop: 'var(--spacing-xs)' }}>
<label className="form-label">Headers (JSON)</label>
<input className="input" value={wh.headers} onChange={(e) => updateWebhook(i, 'headers', e.target.value)} placeholder='{"Content-Type": "application/json"}' style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.8125rem' }} />
</div>
<div className="form-group" style={{ marginTop: 'var(--spacing-xs)' }}>
<label className="form-label">Payload Template (Go template syntax)</label>
<textarea
className="textarea"
value={wh.payload_template}
onChange={(e) => updateWebhook(i, 'payload_template', e.target.value)}
rows={3}
placeholder={`{"text": "Job {{.Status}}: {{if .Error}}Error: {{.Error}}{{else}}{{.Result}}{{end}}"}`}
style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.8125rem' }}
/>
<p style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)', marginTop: 2 }}>
Available: {'{{.Job}}'} {'{{.Task}}'} {'{{.Result}}'} {'{{.Error}}'} {'{{.Status}}'}
</p>
</div>
</div>
))
)}
</div>
<div style={{ display: 'flex', gap: 'var(--spacing-sm)' }}>
<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>
</div>
</form>
</div>
)
}

View File

@@ -0,0 +1,498 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { useNavigate, useOutletContext } from 'react-router-dom'
import { backendsApi } from '../utils/api'
import { useOperations } from '../hooks/useOperations'
import LoadingSpinner from '../components/LoadingSpinner'
import { renderMarkdown } from '../utils/markdown'
export default function Backends() {
const { addToast } = useOutletContext()
const navigate = useNavigate()
const { operations } = useOperations()
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState('')
const [filter, setFilter] = useState('')
const [sortBy, setSortBy] = useState('name')
const [sortOrder, setSortOrder] = useState('asc')
const [page, setPage] = useState(1)
const [installedCount, setInstalledCount] = useState(0)
const [showManualInstall, setShowManualInstall] = useState(false)
const [manualUri, setManualUri] = useState('')
const [manualName, setManualName] = useState('')
const [manualAlias, setManualAlias] = useState('')
const [selectedBackend, setSelectedBackend] = useState(null)
const debounceRef = useRef(null)
const [allBackends, setAllBackends] = useState([])
const fetchBackends = useCallback(async () => {
try {
setLoading(true)
const params = { page: 1, items: 9999, sort: sortBy, order: sortOrder }
if (search) params.term = search
const data = await backendsApi.list(params)
const list = Array.isArray(data?.backends) ? data.backends : Array.isArray(data) ? data : []
setAllBackends(list)
setInstalledCount(list.filter(b => b.installed).length)
} catch (err) {
addToast(`Failed to load backends: ${err.message}`, 'error')
} finally {
setLoading(false)
}
}, [search, sortBy, sortOrder, addToast])
useEffect(() => {
fetchBackends()
}, [sortBy, sortOrder])
// Re-fetch when operations change (install/delete completion)
useEffect(() => {
if (!loading) fetchBackends()
}, [operations.length])
// Client-side filtering by tag
const filteredBackends = filter
? allBackends.filter(b => {
const tags = (b.tags || []).map(t => t.toLowerCase())
const name = (b.name || '').toLowerCase()
const desc = (b.description || '').toLowerCase()
const f = filter.toLowerCase()
// Match against tags, or name/description containing the filter keyword
return tags.some(t => t.includes(f)) || name.includes(f) || desc.includes(f)
})
: allBackends
// Client-side pagination
const ITEMS_PER_PAGE = 21
const totalPages = Math.max(1, Math.ceil(filteredBackends.length / ITEMS_PER_PAGE))
const backends = filteredBackends.slice((page - 1) * ITEMS_PER_PAGE, page * ITEMS_PER_PAGE)
const handleSearch = (value) => {
setSearch(value)
setPage(1)
if (debounceRef.current) clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(() => fetchBackends(), 500)
}
const handleSort = (col) => {
if (sortBy === col) {
setSortOrder(prev => prev === 'asc' ? 'desc' : 'asc')
} else {
setSortBy(col)
setSortOrder('asc')
}
setPage(1)
}
const handleInstall = async (id) => {
try {
await backendsApi.install(id)
addToast(`Installing backend ${id}...`, 'info')
} catch (err) {
addToast(`Install failed: ${err.message}`, 'error')
}
}
const handleDelete = async (id) => {
if (!confirm(`Delete backend ${id}?`)) return
try {
await backendsApi.delete(id)
addToast(`Deleting ${id}...`, 'info')
setTimeout(fetchBackends, 1000)
} catch (err) {
addToast(`Delete failed: ${err.message}`, 'error')
}
}
const handleManualInstall = async (e) => {
e.preventDefault()
if (!manualUri.trim()) { addToast('Please enter a URI', 'warning'); return }
try {
const body = { uri: manualUri.trim() }
if (manualName.trim()) body.name = manualName.trim()
if (manualAlias.trim()) body.alias = manualAlias.trim()
await backendsApi.installExternal(body)
addToast('Installing backend...', 'info')
setManualUri('')
setManualName('')
setManualAlias('')
setShowManualInstall(false)
} catch (err) {
addToast(`Install failed: ${err.message}`, 'error')
}
}
// Check if a backend has an active operation
const getBackendOp = (backend) => {
if (!operations.length) return null
return operations.find(op => op.name === backend.name || op.name === backend.id) || null
}
const FILTERS = [
{ key: '', label: 'All', icon: 'fa-layer-group' },
{ key: 'llm', label: 'LLM', icon: 'fa-brain' },
{ key: 'image', label: 'Image', icon: 'fa-image' },
{ key: 'video', label: 'Video', icon: 'fa-video' },
{ key: 'tts', label: 'TTS', icon: 'fa-microphone' },
{ key: 'stt', label: 'STT', icon: 'fa-headphones' },
{ key: 'vision', label: 'Vision', icon: 'fa-eye' },
]
const SortHeader = ({ col, children }) => (
<th
onClick={() => handleSort(col)}
style={{ cursor: 'pointer', userSelect: 'none', whiteSpace: 'nowrap' }}
>
{children}
{sortBy === col && (
<i className={`fas fa-sort-${sortOrder === 'asc' ? 'up' : 'down'}`} style={{ marginLeft: 4, fontSize: '0.6875rem', color: 'var(--color-primary)' }} />
)}
</th>
)
return (
<div className="page">
{/* Header */}
<div className="page-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div>
<h1 className="page-title">Backend Management</h1>
<p className="page-subtitle">Discover and install AI backends to power your models</p>
</div>
<div style={{ display: 'flex', gap: 'var(--spacing-md)', alignItems: 'center' }}>
<div style={{ display: 'flex', gap: 'var(--spacing-md)', fontSize: '0.8125rem' }}>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: '1.25rem', fontWeight: 700, color: 'var(--color-primary)' }}>{filteredBackends.length}</div>
<div style={{ color: 'var(--color-text-muted)' }}>Available</div>
</div>
<div style={{ textAlign: 'center' }}>
<a onClick={() => navigate('/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>
</div>
</div>
<a className="btn btn-secondary btn-sm" href="https://localai.io/docs/getting-started/manual/" target="_blank" rel="noopener noreferrer">
<i className="fas fa-book" /> Docs
</a>
</div>
</div>
{/* Manual Install */}
<div style={{ marginBottom: 'var(--spacing-md)' }}>
<button className="btn btn-secondary btn-sm" onClick={() => setShowManualInstall(!showManualInstall)}>
<i className={`fas ${showManualInstall ? 'fa-chevron-up' : 'fa-plus'}`} /> Manual Install
</button>
</div>
{showManualInstall && (
<form onSubmit={handleManualInstall} className="card" style={{ marginBottom: 'var(--spacing-md)' }}>
<h3 style={{ fontSize: '0.9375rem', fontWeight: 600, marginBottom: 'var(--spacing-sm)' }}>
<i className="fas fa-download" style={{ color: 'var(--color-primary)', marginRight: 'var(--spacing-xs)' }} />
Install External Backend
</h3>
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr 1fr auto', gap: 'var(--spacing-sm)', alignItems: 'end' }}>
<div className="form-group" style={{ margin: 0 }}>
<label className="form-label">OCI Image / URL / Path *</label>
<input className="input" value={manualUri} onChange={(e) => setManualUri(e.target.value)} placeholder="oci://quay.io/example/backend:latest" />
</div>
<div className="form-group" style={{ margin: 0 }}>
<label className="form-label">Name (required for OCI)</label>
<input className="input" value={manualName} onChange={(e) => setManualName(e.target.value)} placeholder="my-backend" />
</div>
<div className="form-group" style={{ margin: 0 }}>
<label className="form-label">Alias (optional)</label>
<input className="input" value={manualAlias} onChange={(e) => setManualAlias(e.target.value)} placeholder="alias" />
</div>
<button type="submit" className="btn btn-primary">
<i className="fas fa-download" /> Install
</button>
</div>
</form>
)}
{/* Search + Filters */}
<div style={{ display: 'flex', gap: 'var(--spacing-sm)', marginBottom: 'var(--spacing-md)', flexWrap: 'wrap', alignItems: 'center' }}>
<div className="search-bar" style={{ flex: 1, minWidth: 200 }}>
<i className="fas fa-search search-icon" />
<input className="input" placeholder="Search backends by name, description, or type..." value={search} onChange={(e) => handleSearch(e.target.value)} />
</div>
</div>
<div className="filter-bar" style={{ marginBottom: 'var(--spacing-md)' }}>
{FILTERS.map(f => (
<button
key={f.key}
className={`filter-btn ${filter === f.key ? 'active' : ''}`}
onClick={() => { setFilter(f.key); setPage(1) }}
>
<i className={`fas ${f.icon}`} style={{ marginRight: 4 }} />
{f.label}
</button>
))}
</div>
{/* Table */}
{loading ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: 'var(--spacing-xl)' }}><LoadingSpinner size="lg" /></div>
) : backends.length === 0 ? (
<div className="empty-state">
<div className="empty-state-icon"><i className="fas fa-server" /></div>
<h2 className="empty-state-title">No backends found</h2>
<p className="empty-state-text">
{search || filter ? 'Try adjusting your search or filters.' : 'No backends available in the gallery.'}
</p>
</div>
) : (
<div className="table-container">
<table className="table">
<thead>
<tr>
<th style={{ width: 40 }}></th>
<SortHeader col="name">Backend</SortHeader>
<th>Description</th>
<SortHeader col="repository">Repository</SortHeader>
<SortHeader col="license">License</SortHeader>
<SortHeader col="status">Status</SortHeader>
<th style={{ textAlign: 'right' }}>Actions</th>
</tr>
</thead>
<tbody>
{backends.map(b => {
const op = getBackendOp(b)
const isProcessing = !!op
return (
<tr key={b.name || b.id}>
{/* Icon */}
<td>
{b.icon ? (
<img src={b.icon} alt="" style={{ width: 28, height: 28, borderRadius: 'var(--radius-sm)', objectFit: 'cover' }} />
) : (
<div style={{
width: 28, height: 28, borderRadius: 'var(--radius-sm)',
background: 'var(--color-bg-tertiary)', display: 'flex',
alignItems: 'center', justifyContent: 'center',
}}>
<i className="fas fa-cog" style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)' }} />
</div>
)}
</td>
{/* Name */}
<td>
<span
style={{ fontWeight: 500, cursor: 'pointer', color: 'var(--color-primary)' }}
onClick={() => setSelectedBackend(b)}
>
{b.name || b.id}
</span>
</td>
{/* Description */}
<td>
<span style={{
fontSize: '0.8125rem', color: 'var(--color-text-secondary)',
display: 'inline-block', maxWidth: 300, overflow: 'hidden',
textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}} title={b.description}>
{b.description || '-'}
</span>
</td>
{/* Repository */}
<td>
{b.gallery ? (
<span className="badge badge-info" style={{ fontSize: '0.6875rem' }}>{typeof b.gallery === 'string' ? b.gallery : b.gallery.name || '-'}</span>
) : '-'}
</td>
{/* License */}
<td>
{b.license ? (
<span className="badge" style={{ fontSize: '0.6875rem', background: 'var(--color-bg-tertiary)' }}>{b.license}</span>
) : '-'}
</td>
{/* Status */}
<td>
{isProcessing ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-xs)' }}>
<div style={{
width: 80, height: 6, background: 'var(--color-bg-tertiary)',
borderRadius: 3, overflow: 'hidden',
}}>
<div style={{
width: `${op.progress || 0}%`, height: '100%',
background: 'var(--color-primary)',
borderRadius: 3, transition: 'width 300ms',
}} />
</div>
<span style={{ fontSize: '0.6875rem', color: 'var(--color-text-muted)' }}>
{op.isDeletion ? 'Deleting...' : op.isQueued ? 'Queued' : 'Installing...'}
</span>
</div>
) : b.installed ? (
<span className="badge badge-success">
<i className="fas fa-check" style={{ fontSize: '0.5rem', marginRight: 2 }} /> Installed
</span>
) : (
<span className="badge" style={{ background: 'var(--color-bg-tertiary)', color: 'var(--color-text-muted)' }}>
<i className="fas fa-circle" style={{ fontSize: '0.5rem', marginRight: 2 }} /> Not Installed
</span>
)}
</td>
{/* Actions */}
<td>
<div style={{ display: 'flex', gap: 'var(--spacing-xs)', justifyContent: 'flex-end' }}>
<button className="btn btn-secondary btn-sm" onClick={() => setSelectedBackend(b)} title="Details">
<i className="fas fa-info-circle" />
</button>
{b.installed ? (
<>
<button className="btn btn-secondary btn-sm" onClick={() => handleInstall(b.name || b.id)} title="Reinstall" disabled={isProcessing}>
<i className={`fas ${isProcessing ? 'fa-spinner fa-spin' : 'fa-rotate'}`} />
</button>
<button className="btn btn-danger btn-sm" onClick={() => handleDelete(b.name || b.id)} title="Delete" disabled={isProcessing}>
<i className="fas fa-trash" />
</button>
</>
) : (
<button className="btn btn-primary btn-sm" onClick={() => handleInstall(b.name || b.id)} title="Install" disabled={isProcessing}>
<i className={`fas ${isProcessing ? 'fa-spinner fa-spin' : 'fa-download'}`} />
</button>
)}
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
{/* Pagination */}
{totalPages > 1 && (
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
gap: 'var(--spacing-sm)', marginTop: 'var(--spacing-md)',
}}>
<button className="btn btn-secondary btn-sm" onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page <= 1}>
<i className="fas fa-chevron-left" /> Previous
</button>
<span style={{ fontSize: '0.8125rem', color: 'var(--color-text-secondary)' }}>
Page {page} of {totalPages}
</span>
<button className="btn btn-secondary btn-sm" onClick={() => setPage(p => Math.min(totalPages, p + 1))} disabled={page >= totalPages}>
Next <i className="fas fa-chevron-right" />
</button>
</div>
)}
{/* Detail Modal */}
{selectedBackend && (
<div style={{
position: 'fixed', inset: 0, zIndex: 1000,
background: 'rgba(0,0,0,0.6)', display: 'flex', alignItems: 'center', justifyContent: 'center',
}} onClick={() => setSelectedBackend(null)}>
<div className="card" style={{ maxWidth: 600, width: '90%', maxHeight: '80vh', overflow: 'auto' }} onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 'var(--spacing-md)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-sm)' }}>
{selectedBackend.icon ? (
<img src={selectedBackend.icon} alt="" style={{ width: 48, height: 48, borderRadius: 'var(--radius-md)' }} />
) : (
<div style={{
width: 48, height: 48, borderRadius: 'var(--radius-md)',
background: 'var(--color-bg-tertiary)', display: 'flex',
alignItems: 'center', justifyContent: 'center',
}}>
<i className="fas fa-cog" style={{ fontSize: '1.25rem', color: 'var(--color-text-muted)' }} />
</div>
)}
<div>
<h3 style={{ fontWeight: 600, fontSize: '1.125rem' }}>{selectedBackend.name || selectedBackend.id}</h3>
{selectedBackend.installed && <span className="badge badge-success">Installed</span>}
</div>
</div>
<button className="btn btn-secondary btn-sm" onClick={() => setSelectedBackend(null)}>
<i className="fas fa-xmark" />
</button>
</div>
{/* Description */}
{selectedBackend.description && (
<div style={{ marginBottom: 'var(--spacing-md)' }}>
<div
style={{ fontSize: '0.875rem', color: 'var(--color-text-secondary)', lineHeight: 1.6 }}
dangerouslySetInnerHTML={{ __html: renderMarkdown(selectedBackend.description) }}
/>
</div>
)}
{/* Tags */}
{selectedBackend.tags && selectedBackend.tags.length > 0 && (
<div style={{ marginBottom: 'var(--spacing-md)' }}>
<span className="form-label">Tags</span>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{selectedBackend.tags.map(tag => (
<span key={tag} className="badge badge-info" style={{ fontSize: '0.6875rem' }}>{tag}</span>
))}
</div>
</div>
)}
{/* URLs */}
{selectedBackend.urls && selectedBackend.urls.length > 0 && (
<div style={{ marginBottom: 'var(--spacing-md)' }}>
<span className="form-label">Links</span>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{selectedBackend.urls.map((url, i) => (
<a key={i} href={url} target="_blank" rel="noopener noreferrer" style={{ fontSize: '0.8125rem', color: 'var(--color-primary)', wordBreak: 'break-all' }}>
<i className="fas fa-external-link-alt" style={{ marginRight: 4 }} />{url}
</a>
))}
</div>
</div>
)}
{/* Repository / License */}
<div style={{ display: 'flex', gap: 'var(--spacing-md)', marginBottom: 'var(--spacing-md)' }}>
{selectedBackend.gallery && (
<div>
<span className="form-label">Repository</span>
<p style={{ fontSize: '0.8125rem' }}>{typeof selectedBackend.gallery === 'string' ? selectedBackend.gallery : selectedBackend.gallery.name || '-'}</p>
</div>
)}
{selectedBackend.license && (
<div>
<span className="form-label">License</span>
<p style={{ fontSize: '0.8125rem' }}>{selectedBackend.license}</p>
</div>
)}
</div>
{/* Actions */}
<div style={{ display: 'flex', gap: 'var(--spacing-sm)', justifyContent: 'flex-end', borderTop: '1px solid var(--color-border-subtle)', paddingTop: 'var(--spacing-md)' }}>
{selectedBackend.installed ? (
<>
<button className="btn btn-secondary btn-sm" onClick={() => { handleInstall(selectedBackend.name || selectedBackend.id); setSelectedBackend(null) }}>
<i className="fas fa-rotate" /> Reinstall
</button>
<button className="btn btn-danger btn-sm" onClick={() => { handleDelete(selectedBackend.name || selectedBackend.id); setSelectedBackend(null) }}>
<i className="fas fa-trash" /> Delete
</button>
</>
) : (
<button className="btn btn-primary btn-sm" onClick={() => { handleInstall(selectedBackend.name || selectedBackend.id); setSelectedBackend(null) }}>
<i className="fas fa-download" /> Install
</button>
)}
<button className="btn btn-secondary btn-sm" onClick={() => setSelectedBackend(null)}>Close</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,852 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { useParams, useOutletContext, useNavigate } from 'react-router-dom'
import { useChat } from '../hooks/useChat'
import ModelSelector from '../components/ModelSelector'
import { renderMarkdown, highlightAll } from '../utils/markdown'
import { fileToBase64, modelsApi } from '../utils/api'
function relativeTime(ts) {
if (!ts) return ''
const diff = Date.now() - ts
const seconds = Math.floor(diff / 1000)
if (seconds < 60) return 'Just now'
const minutes = Math.floor(seconds / 60)
if (minutes < 60) return `${minutes}m ago`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours}h ago`
const days = Math.floor(hours / 24)
if (days < 7) return `${days}d ago`
return new Date(ts).toLocaleDateString()
}
function getLastMessagePreview(chat) {
if (!chat.history || chat.history.length === 0) return ''
for (let i = chat.history.length - 1; i >= 0; i--) {
const msg = chat.history[i]
if (msg.role === 'user' || msg.role === 'assistant') {
const text = typeof msg.content === 'string' ? msg.content : msg.content?.[0]?.text || ''
return text.slice(0, 40).replace(/\n/g, ' ')
}
}
return ''
}
function exportChatAsMarkdown(chat) {
let md = `# ${chat.name}\n\n`
md += `Model: ${chat.model || 'Unknown'}\n`
md += `Date: ${new Date(chat.createdAt).toLocaleString()}\n\n---\n\n`
for (const msg of chat.history) {
if (msg.role === 'user') {
const text = typeof msg.content === 'string' ? msg.content : msg.content?.[0]?.text || ''
md += `## User\n\n${text}\n\n`
} else if (msg.role === 'assistant') {
md += `## Assistant\n\n${msg.content}\n\n`
} else if (msg.role === 'thinking' || msg.role === 'reasoning') {
md += `<details><summary>Thinking</summary>\n\n${msg.content}\n\n</details>\n\n`
}
}
const blob = new Blob([md], { type: 'text/markdown' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${chat.name.replace(/[^a-zA-Z0-9]/g, '_')}.md`
a.click()
URL.revokeObjectURL(url)
}
function ThinkingMessage({ msg, onToggle }) {
const contentRef = useRef(null)
useEffect(() => {
if (msg.expanded && contentRef.current) {
highlightAll(contentRef.current)
}
}, [msg.expanded, msg.content])
return (
<div className="chat-thinking-box">
<button className="chat-thinking-header" onClick={onToggle}>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-xs)' }}>
<i className="fas fa-brain" style={{ color: 'var(--color-primary)' }} />
<span className="chat-thinking-label">Thinking</span>
<span style={{ fontSize: '0.7rem', color: 'var(--color-text-muted)' }}>
({(msg.content || '').split('\n').length} lines)
</span>
</div>
<i className={`fas fa-chevron-${msg.expanded ? 'up' : 'down'}`} style={{ color: 'var(--color-primary)', fontSize: '0.75rem' }} />
</button>
{msg.expanded && (
<div
ref={contentRef}
className="chat-thinking-content"
dangerouslySetInnerHTML={{ __html: renderMarkdown(msg.content || '') }}
/>
)}
</div>
)
}
function ToolCallMessage({ msg, onToggle }) {
let parsed = null
try { parsed = JSON.parse(msg.content) } catch (_e) { /* ignore */ }
const isCall = msg.role === 'tool_call'
return (
<div className={`chat-tool-box chat-tool-box-${isCall ? 'call' : 'result'}`}>
<button className="chat-tool-header" onClick={onToggle}>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-xs)' }}>
<i className={`fas ${isCall ? 'fa-wrench' : 'fa-check-circle'}`}
style={{ color: isCall ? 'var(--color-accent)' : 'var(--color-success)' }} />
<span className="chat-tool-label">
{isCall ? 'Tool Call' : 'Tool Result'}: {parsed?.name || 'unknown'}
</span>
</div>
<i className={`fas fa-chevron-${msg.expanded ? 'up' : 'down'}`}
style={{ color: 'var(--color-text-muted)', fontSize: '0.75rem' }} />
</button>
{msg.expanded && (
<div className="chat-tool-content">
<pre><code>{msg.content}</code></pre>
</div>
)}
</div>
)
}
function StreamingToolCalls({ toolCalls }) {
if (!toolCalls || toolCalls.length === 0) return null
return toolCalls.map((tc, i) => {
const isCall = tc.type === 'tool_call'
return (
<div key={i} className={`chat-tool-box chat-tool-box-${isCall ? 'call' : 'result'}`}>
<div className="chat-tool-header" style={{ cursor: 'default' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-xs)' }}>
<i className={`fas ${isCall ? 'fa-wrench' : 'fa-check-circle'}`}
style={{ color: isCall ? 'var(--color-accent)' : 'var(--color-success)' }} />
<span className="chat-tool-label">
{isCall ? 'Tool Call' : 'Tool Result'}: {tc.name}
</span>
<span className="chat-streaming-cursor" />
</div>
</div>
<div className="chat-tool-content">
<pre><code>{JSON.stringify(isCall ? tc.arguments : tc.result, null, 2)}</code></pre>
</div>
</div>
)
})
}
function UserMessageContent({ content, files }) {
const text = typeof content === 'string' ? content : content?.[0]?.text || ''
return (
<>
<div dangerouslySetInnerHTML={{ __html: text.replace(/\n/g, '<br>') }} />
{files && files.length > 0 && (
<div className="chat-message-files">
{files.map((f, i) => (
<span key={i} className="chat-file-inline">
<i className={`fas ${f.type === 'image' ? 'fa-image' : f.type === 'audio' ? 'fa-headphones' : 'fa-file'}`} />
{f.name}
</span>
))}
</div>
)}
{Array.isArray(content) && content.filter(c => c.type === 'image_url').map((img, i) => (
<img key={i} src={img.image_url.url} alt="attached" className="chat-inline-image" />
))}
</>
)
}
export default function Chat() {
const { model: urlModel } = useParams()
const { addToast } = useOutletContext()
const navigate = useNavigate()
const {
chats, activeChat, activeChatId, isStreaming, streamingContent, streamingReasoning,
streamingToolCalls, tokensPerSecond, maxTokensPerSecond,
addChat, switchChat, deleteChat, deleteAllChats, renameChat, updateChatSettings,
sendMessage, stopGeneration, clearHistory, getContextUsagePercent,
} = useChat(urlModel || '')
const [input, setInput] = useState('')
const [files, setFiles] = useState([])
const [showSettings, setShowSettings] = useState(false)
const [editingName, setEditingName] = useState(null)
const [editName, setEditName] = useState('')
const [mcpAvailable, setMcpAvailable] = useState(false)
const [chatSearch, setChatSearch] = useState('')
const [modelInfo, setModelInfo] = useState(null)
const [showModelInfo, setShowModelInfo] = useState(false)
const [sidebarOpen, setSidebarOpen] = useState(true)
const messagesEndRef = useRef(null)
const fileInputRef = useRef(null)
const messagesRef = useRef(null)
const thinkingBoxRef = useRef(null)
const textareaRef = useRef(null)
// Check MCP availability and fetch model config
useEffect(() => {
const model = activeChat?.model
if (!model) { setMcpAvailable(false); setModelInfo(null); return }
let cancelled = false
modelsApi.getConfigJson(model).then(cfg => {
if (cancelled) return
setModelInfo(cfg)
const hasMcp = !!(cfg?.mcp?.remote || cfg?.mcp?.stdio)
setMcpAvailable(hasMcp)
if (!hasMcp && activeChat?.mcpMode) {
updateChatSettings(activeChat.id, { mcpMode: false })
}
}).catch(() => { if (!cancelled) { setMcpAvailable(false); setModelInfo(null) } })
return () => { cancelled = true }
}, [activeChat?.model])
// Load initial message from home page
const homeDataProcessed = useRef(false)
useEffect(() => {
if (homeDataProcessed.current) return
const stored = localStorage.getItem('localai_index_chat_data')
if (stored) {
homeDataProcessed.current = true
try {
const data = JSON.parse(stored)
localStorage.removeItem('localai_index_chat_data')
if (data.message) {
// Create a new chat when coming from home
let targetChat = activeChat
if (data.newChat) {
targetChat = addChat(data.model || '', '', data.mcpMode || false)
} else {
if (data.model && activeChat) {
updateChatSettings(activeChat.id, { model: data.model })
}
if (data.mcpMode && activeChat) {
updateChatSettings(activeChat.id, { mcpMode: true })
}
}
setInput(data.message)
if (data.files) setFiles(data.files)
setTimeout(() => {
const submitBtn = document.getElementById('chat-submit-btn')
submitBtn?.click()
}, 100)
}
} catch (_e) { /* ignore */ }
}
}, [])
// Auto-scroll
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [activeChat?.history, streamingContent, streamingReasoning, streamingToolCalls])
// Scroll streaming thinking box
useEffect(() => {
if (thinkingBoxRef.current) {
thinkingBoxRef.current.scrollTop = thinkingBoxRef.current.scrollHeight
}
}, [streamingReasoning])
// Highlight code blocks
useEffect(() => {
if (messagesRef.current) {
highlightAll(messagesRef.current)
}
}, [activeChat?.history, streamingContent])
// Auto-grow textarea
const autoGrowTextarea = useCallback(() => {
const el = textareaRef.current
if (!el) return
el.style.height = 'auto'
el.style.height = Math.min(el.scrollHeight, 200) + 'px'
}, [])
useEffect(() => {
autoGrowTextarea()
}, [input, autoGrowTextarea])
const handleFileChange = useCallback(async (e) => {
const newFiles = []
for (const file of e.target.files) {
const base64 = await fileToBase64(file)
const entry = { name: file.name, type: file.type, base64 }
if (!file.type.startsWith('image/') && !file.type.startsWith('audio/')) {
entry.textContent = await file.text().catch(() => '')
}
newFiles.push(entry)
}
setFiles(prev => [...prev, ...newFiles])
e.target.value = ''
}, [])
const handleSend = useCallback(async () => {
const msg = input.trim()
if (!msg && files.length === 0) return
if (!activeChat?.model) {
addToast('Please select a model', 'warning')
return
}
setInput('')
setFiles([])
await sendMessage(msg, files)
}, [input, files, activeChat, sendMessage, addToast])
const handleRegenerate = useCallback(async () => {
if (!activeChat || isStreaming) return
const history = activeChat.history
let lastUserMsg = null
let lastUserFiles = null
for (let i = history.length - 1; i >= 0; i--) {
if (history[i].role === 'user') {
lastUserMsg = typeof history[i].content === 'string' ? history[i].content : history[i].content?.[0]?.text || ''
lastUserFiles = history[i].files || []
break
}
}
if (!lastUserMsg) return
// Remove everything after and including the last user message
const newHistory = []
let foundLastUser = false
for (let i = history.length - 1; i >= 0; i--) {
if (!foundLastUser && history[i].role === 'user') {
foundLastUser = true
continue
}
if (foundLastUser) {
newHistory.unshift(history[i])
}
}
updateChatSettings(activeChat.id, { history: newHistory })
await sendMessage(lastUserMsg, lastUserFiles)
}, [activeChat, isStreaming, sendMessage, updateChatSettings])
const handleKeyDown = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
}
}
const startRename = (chatId, currentName) => {
setEditingName(chatId)
setEditName(currentName)
}
const finishRename = () => {
if (editingName && editName.trim()) {
renameChat(editingName, editName.trim())
}
setEditingName(null)
}
const copyMessage = (content) => {
const text = typeof content === 'string' ? content : content?.[0]?.text || ''
navigator.clipboard.writeText(text)
addToast('Copied to clipboard', 'success', 2000)
}
// Filter chats by search
const filteredChats = chatSearch.trim()
? chats.filter(c => {
const q = chatSearch.toLowerCase()
if ((c.name || '').toLowerCase().includes(q)) return true
return c.history?.some(m => {
const t = typeof m.content === 'string' ? m.content : m.content?.[0]?.text || ''
return t.toLowerCase().includes(q)
})
})
: chats
const contextPercent = getContextUsagePercent()
if (!activeChat) return null
return (
<div className={`chat-layout${sidebarOpen ? '' : ' chat-sidebar-collapsed'}`}>
{/* Chat sidebar */}
<div className={`chat-sidebar${sidebarOpen ? '' : ' hidden'}`}>
<div className="chat-sidebar-header">
<button className="btn btn-primary btn-sm" style={{ flex: 1 }} onClick={() => addChat(activeChat.model)}>
<i className="fas fa-plus" /> New Chat
</button>
<button
className="btn btn-secondary btn-sm"
onClick={() => {
if (confirm('Delete all chats? This cannot be undone.')) deleteAllChats()
}}
title="Delete all chats"
style={{ padding: '6px 8px' }}
>
<i className="fas fa-trash" />
</button>
</div>
{/* Chat search */}
<div style={{ padding: '0 var(--spacing-sm)' }}>
<div className="chat-search-wrapper">
<i className="fas fa-search chat-search-icon" />
<input
className="chat-search-input"
type="text"
value={chatSearch}
onChange={(e) => setChatSearch(e.target.value)}
placeholder="Search conversations..."
/>
{chatSearch && (
<button className="chat-search-clear" onClick={() => setChatSearch('')}>
<i className="fas fa-times" />
</button>
)}
</div>
</div>
<div className="chat-list">
{filteredChats.map(chat => (
<div
key={chat.id}
className={`chat-list-item ${chat.id === activeChatId ? 'active' : ''}`}
onClick={() => switchChat(chat.id)}
>
<i className="fas fa-message" style={{ fontSize: '0.7rem', flexShrink: 0, marginTop: '2px' }} />
{editingName === chat.id ? (
<input
className="input"
value={editName}
onChange={(e) => setEditName(e.target.value)}
onBlur={finishRename}
onKeyDown={(e) => e.key === 'Enter' && finishRename()}
autoFocus
onClick={(e) => e.stopPropagation()}
style={{ padding: '2px 4px', fontSize: '0.8125rem' }}
/>
) : (
<div className="chat-list-item-info">
<div className="chat-list-item-top">
<span
className="chat-list-item-name"
onDoubleClick={() => startRename(chat.id, chat.name)}
>
{chat.name}
</span>
<span className="chat-list-item-time">{relativeTime(chat.updatedAt)}</span>
</div>
<span className="chat-list-item-preview">
{getLastMessagePreview(chat) || 'No messages yet'}
</span>
</div>
)}
<div className="chat-list-item-actions">
<button
onClick={(e) => { e.stopPropagation(); startRename(chat.id, chat.name) }}
title="Rename"
>
<i className="fas fa-edit" />
</button>
{chats.length > 1 && (
<button
className="chat-list-item-delete"
onClick={(e) => { e.stopPropagation(); deleteChat(chat.id) }}
title="Delete chat"
>
<i className="fas fa-trash" />
</button>
)}
</div>
</div>
))}
{filteredChats.length === 0 && chatSearch && (
<div style={{ padding: 'var(--spacing-sm)', textAlign: 'center', color: 'var(--color-text-muted)', fontSize: '0.8rem' }}>
No conversations match your search
</div>
)}
</div>
</div>
{/* Chat main area */}
<div className="chat-main">
{/* Header */}
<div className="chat-header">
<button
className="btn btn-secondary btn-sm"
onClick={() => setSidebarOpen(prev => !prev)}
title={sidebarOpen ? 'Hide chat list' : 'Show chat list'}
style={{ flexShrink: 0 }}
>
<i className={`fas fa-${sidebarOpen ? 'angles-left' : 'angles-right'}`} />
</button>
<span className="chat-header-title">{activeChat.name}</span>
<ModelSelector
value={activeChat.model}
onChange={(model) => updateChatSettings(activeChat.id, { model })}
capability="FLAG_CHAT"
/>
{activeChat.model && (
<>
<button
className="btn btn-secondary btn-sm"
onClick={() => setShowModelInfo(!showModelInfo)}
title="Model info"
>
<i className="fas fa-info-circle" />
</button>
<button
className="btn btn-secondary btn-sm"
onClick={() => navigate(`/model-editor/${encodeURIComponent(activeChat.model)}`)}
title="Edit model config"
>
<i className="fas fa-edit" />
</button>
</>
)}
{mcpAvailable && (
<label className="chat-mcp-switch" title="Toggle MCP mode">
<span className="chat-mcp-switch-label">MCP</span>
<span className="toggle">
<input
type="checkbox"
checked={activeChat.mcpMode || false}
onChange={(e) => updateChatSettings(activeChat.id, { mcpMode: e.target.checked })}
/>
<span className="toggle-slider" />
</span>
</label>
)}
<div className="chat-header-actions">
<button
className="btn btn-secondary btn-sm"
onClick={() => exportChatAsMarkdown(activeChat)}
title="Export chat as Markdown"
>
<i className="fas fa-download" />
</button>
<button
className="btn btn-secondary btn-sm"
onClick={() => clearHistory(activeChat.id)}
title="Clear chat history"
>
<i className="fas fa-eraser" />
</button>
<button
className={`btn btn-secondary btn-sm${showSettings ? ' active' : ''}`}
onClick={() => setShowSettings(!showSettings)}
title="Settings"
>
<i className="fas fa-sliders-h" />
</button>
</div>
</div>
{/* Model info panel */}
{showModelInfo && modelInfo && (
<div className="chat-model-info-panel">
<div className="chat-model-info-header">
<span>Model Info: {activeChat.model}</span>
<button className="btn btn-secondary btn-sm" onClick={() => setShowModelInfo(false)}>
<i className="fas fa-times" />
</button>
</div>
<div className="chat-model-info-body">
{modelInfo.backend && <div className="chat-model-info-row"><span>Backend</span><span>{modelInfo.backend}</span></div>}
{modelInfo.parameters?.model && <div className="chat-model-info-row"><span>Model file</span><span>{modelInfo.parameters.model}</span></div>}
{modelInfo.context_size > 0 && <div className="chat-model-info-row"><span>Context size</span><span>{modelInfo.context_size}</span></div>}
{modelInfo.threads > 0 && <div className="chat-model-info-row"><span>Threads</span><span>{modelInfo.threads}</span></div>}
{(modelInfo.mcp?.remote || modelInfo.mcp?.stdio) && <div className="chat-model-info-row"><span>MCP</span><span className="badge badge-success">Configured</span></div>}
{modelInfo.template?.chat_message && <div className="chat-model-info-row"><span>Chat template</span><span>Yes</span></div>}
{modelInfo.gpu_layers > 0 && <div className="chat-model-info-row"><span>GPU layers</span><span>{modelInfo.gpu_layers}</span></div>}
</div>
</div>
)}
{/* Context window progress bar */}
{contextPercent !== null && (
<div className="chat-context-bar">
<div className="chat-context-progress"
style={{
width: `${contextPercent}%`,
background: contextPercent > 90 ? 'var(--color-error)' : contextPercent > 70 ? 'var(--color-warning)' : 'var(--color-primary)',
}}
/>
<span className="chat-context-label">
Context: {Math.round(contextPercent)}%
{activeChat.tokenUsage.total > 0 && ` (${activeChat.tokenUsage.total} tokens)`}
</span>
</div>
)}
{/* Settings slide-out panel */}
<div className={`chat-settings-overlay${showSettings ? ' open' : ''}`} onClick={() => setShowSettings(false)} />
<div className={`chat-settings-drawer${showSettings ? ' open' : ''}`}>
<div className="chat-settings-drawer-header">
<span>Chat Settings</span>
<button className="btn btn-secondary btn-sm" onClick={() => setShowSettings(false)}>
<i className="fas fa-times" />
</button>
</div>
<div className="chat-settings-drawer-body">
<div className="form-group">
<label className="form-label">System Prompt</label>
<textarea
className="textarea"
value={activeChat.systemPrompt || ''}
onChange={(e) => updateChatSettings(activeChat.id, { systemPrompt: e.target.value })}
rows={3}
placeholder="You are a helpful assistant..."
/>
</div>
<div className="form-group">
<label className="form-label">
Temperature {activeChat.temperature !== null ? `(${activeChat.temperature})` : ''}
</label>
<input
type="range" min="0" max="2" step="0.1"
value={activeChat.temperature ?? 0.7}
onChange={(e) => updateChatSettings(activeChat.id, { temperature: parseFloat(e.target.value) })}
className="chat-slider"
/>
<div className="chat-slider-labels"><span>0</span><span>2</span></div>
</div>
<div className="form-group">
<label className="form-label">
Top P {activeChat.topP !== null ? `(${activeChat.topP})` : ''}
</label>
<input
type="range" min="0" max="1" step="0.05"
value={activeChat.topP ?? 0.9}
onChange={(e) => updateChatSettings(activeChat.id, { topP: parseFloat(e.target.value) })}
className="chat-slider"
/>
<div className="chat-slider-labels"><span>0</span><span>1</span></div>
</div>
<div className="form-group">
<label className="form-label">
Top K {activeChat.topK !== null ? `(${activeChat.topK})` : ''}
</label>
<input
type="range" min="1" max="100" step="1"
value={activeChat.topK ?? 40}
onChange={(e) => updateChatSettings(activeChat.id, { topK: parseInt(e.target.value) })}
className="chat-slider"
/>
<div className="chat-slider-labels"><span>1</span><span>100</span></div>
</div>
<div className="form-group">
<label className="form-label">Context Size</label>
<input
type="number"
className="input"
value={activeChat.contextSize || ''}
onChange={(e) => updateChatSettings(activeChat.id, { contextSize: parseInt(e.target.value) || null })}
placeholder="2048"
/>
</div>
</div>
</div>
{/* Messages */}
<div className="chat-messages" ref={messagesRef}>
{activeChat.history.length === 0 && !isStreaming && (
<div className="chat-empty-state">
<div className="chat-empty-icon">
<i className="fas fa-comments" />
</div>
<h2 className="chat-empty-title">Start a conversation</h2>
<p className="chat-empty-text">Type a message below to begin chatting{activeChat.model ? ` with ${activeChat.model}` : ''}.</p>
<div className="chat-empty-hints">
<span><i className="fas fa-keyboard" /> Enter to send</span>
<span><i className="fas fa-level-down-alt" /> Shift+Enter for newline</span>
<span><i className="fas fa-paperclip" /> Attach files</span>
</div>
</div>
)}
{activeChat.history.map((msg, i) => {
if (msg.role === 'thinking' || msg.role === 'reasoning') {
return (
<ThinkingMessage key={i} msg={msg} onToggle={() => {
const newHistory = [...activeChat.history]
newHistory[i] = { ...newHistory[i], expanded: !newHistory[i].expanded }
updateChatSettings(activeChat.id, { history: newHistory })
}} />
)
}
if (msg.role === 'tool_call' || msg.role === 'tool_result') {
return (
<ToolCallMessage key={i} msg={msg} onToggle={() => {
const newHistory = [...activeChat.history]
newHistory[i] = { ...newHistory[i], expanded: !newHistory[i].expanded }
updateChatSettings(activeChat.id, { history: newHistory })
}} />
)
}
return (
<div key={i} className={`chat-message chat-message-${msg.role}`}>
<div className="chat-message-avatar">
<i className={`fas ${msg.role === 'user' ? 'fa-user' : 'fa-robot'}`} />
</div>
<div className="chat-message-bubble">
{msg.role === 'assistant' && activeChat.model && (
<span className="chat-message-model">{activeChat.model}</span>
)}
<div className="chat-message-content">
{msg.role === 'user' ? (
<UserMessageContent content={msg.content} files={msg.files} />
) : (
<div dangerouslySetInnerHTML={{
__html: renderMarkdown(typeof msg.content === 'string' ? msg.content : '')
}} />
)}
</div>
<div className="chat-message-actions">
<button onClick={() => copyMessage(msg.content)} title="Copy">
<i className="fas fa-copy" />
</button>
{msg.role === 'assistant' && i === activeChat.history.length - 1 && !isStreaming && (
<button onClick={handleRegenerate} title="Regenerate">
<i className="fas fa-rotate" />
</button>
)}
</div>
</div>
</div>
)
})}
{/* Streaming reasoning box */}
{isStreaming && streamingReasoning && (
<div className="chat-thinking-box chat-thinking-box-streaming">
<div className="chat-thinking-header" style={{ cursor: 'default' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-xs)' }}>
<i className="fas fa-brain" style={{ color: 'var(--color-primary)' }} />
<span className="chat-thinking-label">Thinking</span>
<span className="chat-streaming-cursor" />
</div>
</div>
<div
ref={thinkingBoxRef}
className="chat-thinking-content"
style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}
>
{streamingReasoning}
</div>
</div>
)}
{/* Streaming tool calls */}
{isStreaming && <StreamingToolCalls toolCalls={streamingToolCalls} />}
{/* Streaming message */}
{isStreaming && streamingContent && (
<div className="chat-message chat-message-assistant">
<div className="chat-message-avatar">
<i className="fas fa-robot" />
</div>
<div className="chat-message-bubble">
{activeChat.model && (
<span className="chat-message-model">{activeChat.model}</span>
)}
<div className="chat-message-content">
<span dangerouslySetInnerHTML={{ __html: renderMarkdown(streamingContent) }} />
<span className="chat-streaming-cursor" />
</div>
</div>
</div>
)}
{isStreaming && !streamingContent && !streamingReasoning && streamingToolCalls.length === 0 && (
<div className="chat-message chat-message-assistant">
<div className="chat-message-avatar">
<i className="fas fa-robot" />
</div>
<div className="chat-message-bubble">
<div className="chat-message-content" style={{ color: 'var(--color-text-muted)' }}>
<i className="fas fa-circle-notch fa-spin" /> Thinking...
</div>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Token info bar */}
{(tokensPerSecond || maxTokensPerSecond || activeChat.tokenUsage?.total > 0) && (
<div className="chat-token-info">
{tokensPerSecond !== null && <span><i className="fas fa-tachometer-alt" /> {tokensPerSecond} tok/s</span>}
{maxTokensPerSecond !== null && !isStreaming && (
<span className="chat-max-tps-badge">
<i className="fas fa-bolt" /> Peak: {maxTokensPerSecond} tok/s
</span>
)}
{activeChat.tokenUsage?.total > 0 && (
<span>
<i className="fas fa-coins" /> {activeChat.tokenUsage.prompt}p + {activeChat.tokenUsage.completion}c = {activeChat.tokenUsage.total}
</span>
)}
</div>
)}
{/* File badges */}
{files.length > 0 && (
<div className="chat-files">
{files.map((f, i) => (
<span key={i} className="chat-file-badge">
<i className={`fas ${f.type?.startsWith('image/') ? 'fa-image' : f.type?.startsWith('audio/') ? 'fa-headphones' : 'fa-file'}`} />
{f.name}
<button onClick={() => setFiles(prev => prev.filter((_, idx) => idx !== i))}>
<i className="fas fa-xmark" />
</button>
</span>
))}
</div>
)}
{/* Input area */}
<div className="chat-input-area">
<div className="chat-input-wrapper">
<button
type="button"
className="btn btn-secondary btn-sm chat-attach-btn"
onClick={() => fileInputRef.current?.click()}
title="Attach file"
>
<i className="fas fa-paperclip" />
</button>
<input
ref={fileInputRef}
type="file"
multiple
accept="image/*,audio/*,application/pdf,.txt,.md,.csv,.json"
style={{ display: 'none' }}
onChange={handleFileChange}
/>
<textarea
ref={textareaRef}
className="chat-input"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type a message..."
rows={1}
disabled={isStreaming}
/>
{isStreaming ? (
<button className="chat-stop-btn" onClick={stopGeneration} title="Stop generating">
<i className="fas fa-stop" />
</button>
) : (
<button
id="chat-submit-btn"
className="chat-send-btn"
onClick={handleSend}
disabled={!input.trim() && files.length === 0}
>
<i className="fas fa-paper-plane" />
</button>
)}
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,26 @@
import { useEffect, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
export default function Explorer() {
const navigate = useNavigate()
return (
<div style={{ minHeight: '100vh', background: 'var(--color-bg-primary)', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: 'var(--spacing-xl)' }}>
<h1 style={{ fontSize: '2rem', fontWeight: 700, marginBottom: 'var(--spacing-md)' }}>
<span className="text-gradient">LocalAI Explorer</span>
</h1>
<p style={{ color: 'var(--color-text-secondary)', marginBottom: 'var(--spacing-xl)', textAlign: 'center' }}>
Network visualization and node explorer
</p>
<div className="card" style={{ width: '100%', maxWidth: '800px', minHeight: '400px', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{ textAlign: 'center', color: 'var(--color-text-muted)' }}>
<i className="fas fa-network-wired" style={{ fontSize: '3rem', marginBottom: 'var(--spacing-md)' }} />
<p>Explorer visualization</p>
</div>
</div>
<button className="btn btn-secondary" onClick={() => navigate('/')} style={{ marginTop: 'var(--spacing-lg)' }}>
<i className="fas fa-arrow-left" /> Back to Home
</button>
</div>
)
}

View File

@@ -0,0 +1,770 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { useNavigate, useOutletContext } from 'react-router-dom'
import ModelSelector from '../components/ModelSelector'
import { useResources } from '../hooks/useResources'
import { fileToBase64, backendControlApi, systemApi, modelsApi } from '../utils/api'
import { API_CONFIG } from '../utils/config'
const placeholderMessages = [
'What is the meaning of life?',
'Write a poem about AI',
'Explain quantum computing simply',
'Help me debug my code',
'Tell me a creative story',
'How do neural networks work?',
'Write a haiku about programming',
'Explain blockchain in simple terms',
'What are the best practices for REST APIs?',
'Help me write a cover letter',
'What is the Fibonacci sequence?',
'Explain the theory of relativity',
]
export default function Home() {
const navigate = useNavigate()
const { addToast } = useOutletContext()
const { resources } = useResources()
const [configuredModels, setConfiguredModels] = useState([])
const [loadedModels, setLoadedModels] = useState([])
const [initialLoaded, setInitialLoaded] = useState(false)
const [selectedModel, setSelectedModel] = useState('')
const [message, setMessage] = useState('')
const [imageFiles, setImageFiles] = useState([])
const [audioFiles, setAudioFiles] = useState([])
const [textFiles, setTextFiles] = useState([])
const [mcpMode, setMcpMode] = useState(false)
const [mcpAvailable, setMcpAvailable] = useState(false)
const [placeholderIdx, setPlaceholderIdx] = useState(0)
const [placeholderText, setPlaceholderText] = useState('')
const imageInputRef = useRef(null)
const audioInputRef = useRef(null)
const fileInputRef = useRef(null)
// Fetch configured models (to know if any exist) and loaded models (currently running)
const fetchSystemInfo = useCallback(async () => {
try {
const [sysInfo, v1Models] = await Promise.all([
systemApi.info().catch(() => null),
modelsApi.listV1().catch(() => null),
])
if (sysInfo?.loaded_models) {
setLoadedModels(sysInfo.loaded_models)
}
if (v1Models?.data) {
setConfiguredModels(v1Models.data)
}
setInitialLoaded(true)
} catch (_e) { setInitialLoaded(true) }
}, [])
useEffect(() => {
fetchSystemInfo()
const interval = setInterval(fetchSystemInfo, 5000)
return () => clearInterval(interval)
}, [fetchSystemInfo])
// Check MCP availability when selected model changes
useEffect(() => {
if (!selectedModel) {
setMcpAvailable(false)
setMcpMode(false)
return
}
let cancelled = false
modelsApi.getConfigJson(selectedModel).then(cfg => {
if (cancelled) return
const hasMcp = !!(cfg?.mcp?.remote || cfg?.mcp?.stdio)
setMcpAvailable(hasMcp)
if (!hasMcp) setMcpMode(false)
}).catch(() => {
if (!cancelled) {
setMcpAvailable(false)
setMcpMode(false)
}
})
return () => { cancelled = true }
}, [selectedModel])
const allFiles = [...imageFiles, ...audioFiles, ...textFiles]
// Animated typewriter placeholder
useEffect(() => {
const target = placeholderMessages[placeholderIdx]
let charIdx = 0
setPlaceholderText('')
const interval = setInterval(() => {
if (charIdx <= target.length) {
setPlaceholderText(target.slice(0, charIdx))
charIdx++
} else {
clearInterval(interval)
setTimeout(() => {
setPlaceholderIdx(prev => (prev + 1) % placeholderMessages.length)
}, 2000)
}
}, 50)
return () => clearInterval(interval)
}, [placeholderIdx])
const addFiles = useCallback(async (fileList, setter) => {
const newFiles = []
for (const file of fileList) {
const base64 = await fileToBase64(file)
newFiles.push({ name: file.name, type: file.type, base64 })
}
setter(prev => [...prev, ...newFiles])
}, [])
const removeFile = useCallback((file) => {
const removeFn = (prev) => prev.filter(f => f !== file)
if (file.type?.startsWith('image/')) setImageFiles(removeFn)
else if (file.type?.startsWith('audio/')) setAudioFiles(removeFn)
else setTextFiles(removeFn)
}, [])
const doSubmit = useCallback(() => {
const text = message.trim() || placeholderText
if (!text && allFiles.length === 0) return
if (!selectedModel) {
addToast('Please select a model first', 'warning')
return
}
const chatData = {
message: text,
model: selectedModel,
files: allFiles,
mcpMode,
newChat: true,
}
localStorage.setItem('localai_index_chat_data', JSON.stringify(chatData))
navigate(`/chat/${encodeURIComponent(selectedModel)}`)
}, [message, placeholderText, allFiles, selectedModel, mcpMode, addToast, navigate])
const handleSubmit = (e) => {
if (e) e.preventDefault()
doSubmit()
}
const handleStopModel = async (modelName) => {
if (!confirm(`Stop model ${modelName}?`)) return
try {
await backendControlApi.shutdown({ model: modelName })
addToast(`Stopped ${modelName}`, 'success')
// Refresh loaded models list after a short delay
setTimeout(fetchSystemInfo, 500)
} catch (err) {
addToast(`Failed to stop: ${err.message}`, 'error')
}
}
const handleStopAll = async () => {
if (!confirm('Stop all loaded models?')) return
try {
await Promise.all(loadedModels.map(m => backendControlApi.shutdown({ model: m.id })))
addToast('All models stopped', 'success')
setTimeout(fetchSystemInfo, 1000)
} catch (err) {
addToast(`Failed to stop: ${err.message}`, 'error')
}
}
const hasModels = configuredModels.length > 0
const loadedCount = loadedModels.length
// Resource display
const resType = resources?.type
const usagePct = resources?.aggregate?.usage_percent ?? resources?.ram?.usage_percent ?? 0
const pctColor = usagePct > 90 ? 'var(--color-error)' : usagePct > 70 ? 'var(--color-warning)' : 'var(--color-success)'
if (!initialLoaded) {
return <div className="home-page" />
}
return (
<div className="home-page">
{hasModels ? (
<>
{/* Hero with logo */}
<div className="home-hero">
<img src="/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>
{/* Chat input form */}
<div className="home-chat-card">
<form onSubmit={handleSubmit}>
{/* Model selector + MCP toggle */}
<div className="home-model-row">
<ModelSelector value={selectedModel} onChange={setSelectedModel} capability="FLAG_CHAT" />
{mcpAvailable && (
<label className="home-mcp-toggle">
<span className="home-mcp-label">MCP</span>
<span className="toggle">
<input
type="checkbox"
checked={mcpMode}
onChange={(e) => setMcpMode(e.target.checked)}
/>
<span className="toggle-slider" />
</span>
</label>
)}
</div>
{mcpMode && (
<div className="home-mcp-info">
<i className="fas fa-info-circle" /> Non-streaming mode active.
</div>
)}
{/* File attachment tags */}
{allFiles.length > 0 && (
<div className="home-file-tags">
{allFiles.map((f, i) => (
<span key={i} className="home-file-tag">
<i className={`fas ${f.type?.startsWith('image/') ? 'fa-image' : f.type?.startsWith('audio/') ? 'fa-microphone' : 'fa-file'}`} />
{f.name}
<button type="button" onClick={() => removeFile(f)}>
<i className="fas fa-times" />
</button>
</span>
))}
</div>
)}
{/* Textarea with attach buttons */}
<div className="home-input-area">
<textarea
className="home-textarea"
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder={placeholderText}
rows={3}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
doSubmit()
}
}}
/>
<div className="home-attach-buttons">
<button type="button" className="home-attach-btn" onClick={() => imageInputRef.current?.click()} title="Attach image">
<i className="fas fa-image" />
</button>
<button type="button" className="home-attach-btn" onClick={() => audioInputRef.current?.click()} title="Attach audio">
<i className="fas fa-microphone" />
</button>
<button type="button" className="home-attach-btn" onClick={() => fileInputRef.current?.click()} title="Attach file">
<i className="fas fa-file" />
</button>
</div>
<input ref={imageInputRef} type="file" multiple accept="image/*" style={{ display: 'none' }} onChange={(e) => addFiles(e.target.files, setImageFiles)} />
<input ref={audioInputRef} type="file" multiple accept="audio/*" style={{ display: 'none' }} onChange={(e) => addFiles(e.target.files, setAudioFiles)} />
<input ref={fileInputRef} type="file" multiple accept=".txt,.md,.pdf" style={{ display: 'none' }} onChange={(e) => addFiles(e.target.files, setTextFiles)} />
</div>
<button
type="submit"
className="home-send-btn"
disabled={!selectedModel}
>
<i className="fas fa-paper-plane" /> Send
</button>
</form>
</div>
{/* Quick links */}
<div className="home-quick-links">
<button className="home-link-btn" onClick={() => navigate('/manage')}>
<i className="fas fa-desktop" /> Installed Models and Backends
</button>
<button className="home-link-btn" onClick={() => navigate('/browse')}>
<i className="fas fa-download" /> Browse Gallery
</button>
<button className="home-link-btn" onClick={() => navigate('/import-model')}>
<i className="fas fa-upload" /> Import Model
</button>
<a className="home-link-btn" href="https://localai.io" target="_blank" rel="noopener noreferrer">
<i className="fas fa-book" /> Documentation
</a>
</div>
{/* Compact resource indicator */}
{resources && (
<div className="home-resource-pill">
<i className={`fas ${resType === 'gpu' ? 'fa-microchip' : 'fa-memory'}`} />
<span className="home-resource-label">{resType === 'gpu' ? 'GPU' : 'RAM'}</span>
<span className="home-resource-pct" style={{ color: pctColor }}>
{usagePct.toFixed(0)}%
</span>
<div className="home-resource-bar-track">
<div
className="home-resource-bar-fill"
style={{ width: `${usagePct}%`, background: pctColor }}
/>
</div>
</div>
)}
{/* Loaded models status */}
{loadedCount > 0 && (
<div className="home-loaded-models">
<span className="home-loaded-dot" />
<span className="home-loaded-text">{loadedCount} model{loadedCount !== 1 ? 's' : ''} loaded</span>
<div className="home-loaded-list">
{loadedModels.map(m => (
<span key={m.id} className="home-loaded-item">
{m.id}
<button onClick={() => handleStopModel(m.id)} title="Stop model">
<i className="fas fa-times" />
</button>
</span>
))}
</div>
{loadedCount > 1 && (
<button className="home-stop-all" onClick={handleStopAll}>
Stop all
</button>
)}
</div>
)}
</>
) : (
/* No models installed wizard */
<div className="home-wizard">
<div className="home-wizard-hero">
<h1>No Models Installed</h1>
<p>Get started with LocalAI by installing your first model. Browse our gallery of open-source AI models.</p>
</div>
{/* Feature preview cards */}
<div className="home-wizard-features">
<div className="home-wizard-feature">
<div className="home-wizard-feature-icon" style={{ background: 'var(--color-primary-light)' }}>
<i className="fas fa-images" style={{ color: 'var(--color-primary)' }} />
</div>
<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-icon" style={{ background: 'var(--color-accent-light)' }}>
<i className="fas fa-upload" style={{ color: 'var(--color-accent)' }} />
</div>
<h3>Import Models</h3>
<p>Import your own models from HuggingFace or local files</p>
</div>
<div className="home-wizard-feature">
<div className="home-wizard-feature-icon" style={{ background: 'var(--color-success-light)' }}>
<i className="fas fa-code" style={{ color: 'var(--color-success)' }} />
</div>
<h3>API Download</h3>
<p>Use the API to download and configure models programmatically</p>
</div>
</div>
{/* Setup steps */}
<div className="home-wizard-steps card">
<h2>How to Get Started</h2>
<div className="home-wizard-step">
<div className="home-wizard-step-num">1</div>
<div>
<strong>Browse the Model Gallery</strong>
<p>Visit the model gallery to find the right model for your needs.</p>
</div>
</div>
<div className="home-wizard-step">
<div className="home-wizard-step-num">2</div>
<div>
<strong>Install a Model</strong>
<p>Click install on any model to download and configure it automatically.</p>
</div>
</div>
<div className="home-wizard-step">
<div className="home-wizard-step-num">3</div>
<div>
<strong>Start Chatting</strong>
<p>Once installed, you can chat with your model right from the browser.</p>
</div>
</div>
</div>
{/* Action buttons */}
<div className="home-wizard-actions">
<button className="btn btn-primary" onClick={() => navigate('/browse')}>
<i className="fas fa-store" /> Browse Model Gallery
</button>
<button className="btn btn-secondary" onClick={() => navigate('/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">
<i className="fas fa-book" /> Getting Started
</a>
</div>
</div>
)}
<style>{`
.home-page {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
max-width: 48rem;
margin: 0 auto;
padding: var(--spacing-xl);
width: 100%;
}
.home-hero {
text-align: center;
padding: var(--spacing-lg) 0;
}
.home-logo {
width: 80px;
height: auto;
margin: 0 auto var(--spacing-md);
display: block;
}
.home-heading {
font-size: 1.5rem;
font-weight: 600;
margin-bottom: var(--spacing-xs);
}
.home-subheading {
font-size: 0.875rem;
color: var(--color-text-secondary);
}
/* Chat card */
.home-chat-card {
width: 100%;
background: var(--color-bg-secondary);
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-lg);
padding: var(--spacing-md);
margin-bottom: var(--spacing-md);
}
.home-model-row {
display: flex;
align-items: center;
gap: var(--spacing-sm);
margin-bottom: var(--spacing-sm);
}
.home-mcp-toggle {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
user-select: none;
}
.home-mcp-info {
font-size: 0.75rem;
color: var(--color-accent);
padding: var(--spacing-xs) var(--spacing-sm);
background: var(--color-accent-light);
border-radius: var(--radius-md);
margin-bottom: var(--spacing-sm);
}
.home-file-tags {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-xs);
margin-bottom: var(--spacing-sm);
}
.home-file-tag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
background: var(--color-bg-tertiary);
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-full);
font-size: 0.75rem;
color: var(--color-text-secondary);
}
.home-file-tag button {
background: none;
border: none;
color: var(--color-text-muted);
cursor: pointer;
padding: 0;
font-size: 0.625rem;
}
.home-input-area {
position: relative;
margin-bottom: var(--spacing-sm);
}
.home-textarea {
width: 100%;
background: var(--color-bg-tertiary);
color: var(--color-text-primary);
border: 1px solid var(--color-border-default);
border-radius: var(--radius-md);
padding: var(--spacing-sm) var(--spacing-md);
padding-right: 7rem;
font-size: 0.875rem;
font-family: inherit;
outline: none;
resize: none;
min-height: 80px;
transition: border-color var(--duration-fast);
}
.home-textarea:focus { border-color: var(--color-border-strong); }
.home-attach-buttons {
position: absolute;
right: var(--spacing-sm);
bottom: var(--spacing-sm);
display: flex;
gap: 4px;
}
.home-attach-btn {
background: none;
border: none;
color: var(--color-text-muted);
cursor: pointer;
padding: 4px 6px;
font-size: 0.875rem;
border-radius: var(--radius-sm);
transition: color var(--duration-fast);
}
.home-attach-btn:hover { color: var(--color-primary); }
.home-send-btn {
display: flex;
align-items: center;
gap: var(--spacing-xs);
padding: var(--spacing-sm) var(--spacing-lg);
background: var(--color-primary);
color: var(--color-primary-text);
border: none;
border-radius: var(--radius-md);
font-size: 0.875rem;
font-family: inherit;
cursor: pointer;
margin-left: auto;
transition: background var(--duration-fast);
}
.home-send-btn:hover:not(:disabled) { background: var(--color-primary-hover); }
.home-send-btn:disabled { opacity: 0.5; cursor: not-allowed; }
/* Quick links */
.home-quick-links {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-sm);
justify-content: center;
margin: var(--spacing-md) 0;
}
.home-link-btn {
display: inline-flex;
align-items: center;
gap: var(--spacing-xs);
padding: var(--spacing-xs) var(--spacing-md);
background: var(--color-bg-tertiary);
color: var(--color-text-secondary);
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-full);
font-size: 0.8125rem;
font-family: inherit;
cursor: pointer;
text-decoration: none;
transition: all var(--duration-fast);
}
.home-link-btn:hover {
border-color: var(--color-primary-border);
color: var(--color-primary);
}
/* Resource pill */
.home-resource-pill {
display: flex;
align-items: center;
gap: var(--spacing-xs);
padding: var(--spacing-xs) var(--spacing-sm);
background: var(--color-bg-secondary);
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-full);
font-size: 0.75rem;
color: var(--color-text-secondary);
margin: var(--spacing-sm) 0;
}
.home-resource-label {
font-weight: 500;
}
.home-resource-pct {
font-family: 'JetBrains Mono', monospace;
font-weight: 500;
}
.home-resource-bar-track {
width: 16px;
height: 6px;
background: var(--color-bg-tertiary);
border-radius: 3px;
overflow: hidden;
}
.home-resource-bar-fill {
height: 100%;
border-radius: 3px;
transition: width 500ms ease;
}
/* Loaded models */
.home-loaded-models {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-xs);
padding: var(--spacing-sm);
background: var(--color-bg-secondary);
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-lg);
font-size: 0.8125rem;
color: var(--color-text-secondary);
width: 100%;
}
.home-loaded-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--color-success);
}
.home-loaded-text {
font-weight: 500;
margin-right: var(--spacing-xs);
}
.home-loaded-list {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-xs);
}
.home-loaded-item {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
background: var(--color-bg-tertiary);
border-radius: var(--radius-full);
font-size: 0.75rem;
}
.home-loaded-item button {
background: none;
border: none;
color: var(--color-error);
cursor: pointer;
padding: 0;
font-size: 0.625rem;
}
.home-stop-all {
margin-left: auto;
background: none;
border: 1px solid var(--color-error);
color: var(--color-error);
padding: 2px 8px;
border-radius: var(--radius-full);
font-size: 0.75rem;
cursor: pointer;
font-family: inherit;
}
/* No models wizard */
.home-wizard {
max-width: 48rem;
width: 100%;
}
.home-wizard-hero {
text-align: center;
padding: var(--spacing-xl) 0;
}
.home-wizard-hero h1 {
font-size: 1.5rem;
font-weight: 600;
margin-bottom: var(--spacing-sm);
}
.home-wizard-hero p {
color: var(--color-text-secondary);
font-size: 0.9375rem;
}
.home-wizard-features {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--spacing-md);
margin-bottom: var(--spacing-xl);
}
.home-wizard-feature {
text-align: center;
padding: var(--spacing-md);
background: var(--color-bg-secondary);
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-lg);
}
.home-wizard-feature-icon {
width: 48px;
height: 48px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto var(--spacing-sm);
font-size: 1.25rem;
}
.home-wizard-feature h3 {
font-size: 0.9375rem;
font-weight: 600;
margin-bottom: var(--spacing-xs);
}
.home-wizard-feature p {
font-size: 0.8125rem;
color: var(--color-text-secondary);
line-height: 1.4;
}
.home-wizard-steps {
margin-bottom: var(--spacing-xl);
}
.home-wizard-steps h2 {
font-size: 1.125rem;
font-weight: 600;
margin-bottom: var(--spacing-md);
}
.home-wizard-step {
display: flex;
gap: var(--spacing-md);
align-items: flex-start;
padding: var(--spacing-sm) 0;
}
.home-wizard-step-num {
width: 28px;
height: 28px;
border-radius: 50%;
background: var(--color-primary);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.8125rem;
font-weight: 600;
flex-shrink: 0;
}
.home-wizard-step strong {
display: block;
margin-bottom: 2px;
}
.home-wizard-step p {
font-size: 0.8125rem;
color: var(--color-text-secondary);
margin: 0;
}
.home-wizard-actions {
display: flex;
gap: var(--spacing-sm);
justify-content: center;
}
@media (max-width: 640px) {
.home-wizard-features {
grid-template-columns: 1fr;
}
}
`}</style>
</div>
)
}

View File

@@ -0,0 +1,152 @@
import { useState, useRef } from 'react'
import { useParams, useOutletContext } from 'react-router-dom'
import ModelSelector from '../components/ModelSelector'
import LoadingSpinner from '../components/LoadingSpinner'
import { imageApi, fileToBase64 } from '../utils/api'
const SIZES = ['256x256', '512x512', '768x768', '1024x1024']
export default function ImageGen() {
const { model: urlModel } = useParams()
const { addToast } = useOutletContext()
const [model, setModel] = useState(urlModel || '')
const [prompt, setPrompt] = useState('')
const [negativePrompt, setNegativePrompt] = useState('')
const [size, setSize] = useState('512x512')
const [count, setCount] = useState(1)
const [steps, setSteps] = useState('')
const [seed, setSeed] = useState('')
const [loading, setLoading] = useState(false)
const [images, setImages] = useState([])
const [showAdvanced, setShowAdvanced] = useState(false)
const [showImageInputs, setShowImageInputs] = useState(false)
const [sourceImage, setSourceImage] = useState(null)
const [refImages, setRefImages] = useState([])
const sourceRef = useRef(null)
const refRef = useRef(null)
const handleGenerate = async (e) => {
e.preventDefault()
if (!prompt.trim()) { addToast('Please enter a prompt', 'warning'); return }
if (!model) { addToast('Please select a model', 'warning'); return }
setLoading(true)
setImages([])
let combinedPrompt = prompt.trim()
if (negativePrompt.trim()) combinedPrompt += '|' + negativePrompt.trim()
const body = { model, prompt: combinedPrompt, n: count, size }
if (steps) body.step = parseInt(steps)
if (seed) body.seed = parseInt(seed)
if (sourceImage) body.file = sourceImage
if (refImages.length > 0) body.ref_images = refImages
try {
const data = await imageApi.generate(body)
setImages(data?.data || [])
if (!data?.data?.length) addToast('No images generated', 'warning')
} catch (err) {
addToast(`Error: ${err.message}`, 'error')
} finally {
setLoading(false)
}
}
const handleSourceImage = async (e) => {
if (e.target.files[0]) setSourceImage(await fileToBase64(e.target.files[0]))
}
const handleRefImages = async (e) => {
const arr = []
for (const f of e.target.files) arr.push(await fileToBase64(f))
setRefImages(prev => [...prev, ...arr])
}
return (
<div className="media-layout">
<div className="media-controls">
<div className="page-header">
<h1 className="page-title"><i className="fas fa-image" style={{ marginRight: 8, color: 'var(--color-accent)' }} />Image Generation</h1>
</div>
<form onSubmit={handleGenerate}>
<div className="form-group">
<label className="form-label">Model</label>
<ModelSelector value={model} onChange={setModel} capability="FLAG_IMAGE" />
</div>
<div className="form-group">
<label className="form-label">Prompt</label>
<textarea className="textarea" value={prompt} onChange={(e) => setPrompt(e.target.value)} placeholder="Describe the image you want to generate..." rows={3} onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleGenerate(e) } }} />
</div>
<div className="form-group">
<label className="form-label">Negative Prompt</label>
<textarea className="textarea" value={negativePrompt} onChange={(e) => setNegativePrompt(e.target.value)} placeholder="What to avoid..." rows={2} />
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--spacing-sm)' }}>
<div className="form-group">
<label className="form-label">Size</label>
<select className="model-selector" value={size} onChange={(e) => setSize(e.target.value)} style={{ width: '100%' }}>
{SIZES.map(s => <option key={s} value={s}>{s}</option>)}
</select>
</div>
<div className="form-group">
<label className="form-label">Count (1-4)</label>
<input className="input" type="number" min="1" max="4" value={count} onChange={(e) => setCount(parseInt(e.target.value) || 1)} />
</div>
</div>
<div className={`collapsible-header ${showAdvanced ? 'open' : ''}`} onClick={() => setShowAdvanced(!showAdvanced)}>
<i className="fas fa-chevron-right" /> Advanced Settings
</div>
{showAdvanced && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--spacing-sm)', marginBottom: 'var(--spacing-md)' }}>
<div className="form-group"><label className="form-label">Steps</label><input className="input" type="number" value={steps} onChange={(e) => setSteps(e.target.value)} placeholder="20" /></div>
<div className="form-group"><label className="form-label">Seed</label><input className="input" type="number" value={seed} onChange={(e) => setSeed(e.target.value)} placeholder="Random" /></div>
</div>
)}
<div className={`collapsible-header ${showImageInputs ? 'open' : ''}`} onClick={() => setShowImageInputs(!showImageInputs)}>
<i className="fas fa-chevron-right" /> Image Inputs
</div>
{showImageInputs && (
<div style={{ marginBottom: 'var(--spacing-md)' }}>
<div className="form-group"><label className="form-label">Source Image (img2img)</label><input ref={sourceRef} type="file" accept="image/*" onChange={handleSourceImage} className="input" /></div>
<div className="form-group">
<label className="form-label">Reference Images</label>
<input ref={refRef} type="file" accept="image/*" multiple onChange={handleRefImages} className="input" />
{refImages.length > 0 && <span style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)' }}>{refImages.length} image(s) added</span>}
</div>
</div>
)}
<button type="submit" className="btn btn-primary" disabled={loading} style={{ width: '100%' }}>
{loading ? <><LoadingSpinner size="sm" /> Generating...</> : <><i className="fas fa-wand-magic-sparkles" /> Generate</>}
</button>
</form>
</div>
<div className="media-preview">
<div className="media-result">
{loading ? (
<LoadingSpinner size="lg" />
) : images.length > 0 ? (
<div className="media-result-grid">
{images.map((img, i) => (
<div key={i}>
<img src={img.url || `data:image/png;base64,${img.b64_json}`} alt={prompt} style={{ width: '100%', borderRadius: 'var(--radius-md)' }} />
</div>
))}
</div>
) : (
<div style={{ textAlign: 'center', color: 'var(--color-text-muted)' }}>
<i className="fas fa-image" style={{ fontSize: '3rem', marginBottom: 'var(--spacing-md)', opacity: 0.4 }} />
<p>Generated images will appear here</p>
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,445 @@
import { useState, useRef, useCallback, useEffect } from 'react'
import { useNavigate, useOutletContext } from 'react-router-dom'
import { modelsApi } from '../utils/api'
import LoadingSpinner from '../components/LoadingSpinner'
import CodeEditor from '../components/CodeEditor'
const BACKENDS = [
{ value: '', label: 'Auto-detect (based on URI)' },
{ value: 'llama-cpp', label: 'llama-cpp' },
{ value: 'mlx', label: 'mlx' },
{ value: 'mlx-vlm', label: 'mlx-vlm' },
{ value: 'transformers', label: 'transformers' },
{ value: 'vllm', label: 'vllm' },
{ value: 'diffusers', label: 'diffusers' },
]
const URI_FORMATS = [
{
icon: 'fab fa-hubspot', color: 'var(--color-accent)', title: 'HuggingFace',
examples: [
{ prefix: 'huggingface://', suffix: 'TheBloke/Llama-2-7B-Chat-GGUF', desc: 'Standard HuggingFace format' },
{ prefix: 'hf://', suffix: 'TheBloke/Llama-2-7B-Chat-GGUF', desc: 'Short HuggingFace format' },
{ prefix: 'https://huggingface.co/', suffix: 'TheBloke/Llama-2-7B-Chat-GGUF', desc: 'Full HuggingFace URL' },
],
},
{
icon: 'fas fa-globe', color: 'var(--color-primary)', title: 'HTTP/HTTPS URLs',
examples: [
{ prefix: 'https://', suffix: 'example.com/model.gguf', desc: 'Direct download from any HTTPS URL' },
],
},
{
icon: 'fas fa-file', color: 'var(--color-warning)', title: 'Local Files',
examples: [
{ prefix: 'file://', suffix: '/path/to/model.gguf', desc: 'Local file path (absolute)' },
{ prefix: '', suffix: '/path/to/model.yaml', desc: 'Direct local YAML config file' },
],
},
{
icon: 'fas fa-box', color: '#22d3ee', title: 'OCI Registry',
examples: [
{ prefix: 'oci://', suffix: 'registry.example.com/model:tag', desc: 'OCI container registry' },
{ prefix: 'ocifile://', suffix: '/path/to/image.tar', desc: 'Local OCI tarball file' },
],
},
{
icon: 'fas fa-cube', color: '#818cf8', title: 'Ollama',
examples: [
{ prefix: 'ollama://', suffix: 'llama2:7b', desc: 'Ollama model format' },
],
},
{
icon: 'fas fa-code', color: '#f472b6', title: 'YAML Configuration Files',
examples: [
{ prefix: '', suffix: 'https://example.com/model.yaml', desc: 'Remote YAML config file' },
{ prefix: 'file://', suffix: '/path/to/config.yaml', desc: 'Local YAML config file' },
],
},
]
const DEFAULT_YAML = `name: my-model
backend: llama-cpp
parameters:
model: /path/to/model.gguf
`
const hintStyle = { marginTop: '4px', fontSize: '0.75rem', color: 'var(--color-text-muted)' }
export default function ImportModel() {
const navigate = useNavigate()
const { addToast } = useOutletContext()
const [isAdvancedMode, setIsAdvancedMode] = useState(false)
const [importUri, setImportUri] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const [showGuide, setShowGuide] = useState(false)
const [yamlContent, setYamlContent] = useState(DEFAULT_YAML)
const [estimate, setEstimate] = useState(null)
const [jobProgress, setJobProgress] = useState(null)
const [prefs, setPrefs] = useState({
backend: '', name: '', description: '', quantizations: '',
mmproj_quantizations: '', embeddings: false, type: '',
pipeline_type: '', scheduler_type: '', enable_parameters: '', cuda: false,
})
const [customPrefs, setCustomPrefs] = useState([])
const pollRef = useRef(null)
useEffect(() => {
return () => { if (pollRef.current) clearInterval(pollRef.current) }
}, [])
const updatePref = (key, value) => setPrefs(p => ({ ...p, [key]: value }))
const addCustomPref = () => setCustomPrefs(p => [...p, { key: '', value: '' }])
const removeCustomPref = (i) => setCustomPrefs(p => p.filter((_, idx) => idx !== i))
const updateCustomPref = (i, field, value) => {
setCustomPrefs(p => p.map((item, idx) => idx === i ? { ...item, [field]: value } : item))
}
const startJobPolling = useCallback((jobId) => {
if (pollRef.current) clearInterval(pollRef.current)
pollRef.current = setInterval(async () => {
try {
const data = await modelsApi.getJobStatus(jobId)
if (data.processed || data.progress) {
setJobProgress(data.message || data.progress || 'Processing...')
}
if (data.completed) {
clearInterval(pollRef.current)
pollRef.current = null
setIsSubmitting(false)
setJobProgress(null)
addToast('Model imported successfully!', 'success')
navigate('/manage')
} else if (data.error || (data.message && data.message.startsWith('error:'))) {
clearInterval(pollRef.current)
pollRef.current = null
setIsSubmitting(false)
setJobProgress(null)
let msg = 'Unknown error'
if (typeof data.error === 'string') msg = data.error
else if (data.error?.message) msg = data.error.message
else if (data.message) msg = data.message
if (msg.startsWith('error: ')) msg = msg.substring(7)
addToast(`Import failed: ${msg}`, 'error')
}
} catch (err) {
console.error('Error polling job status:', err)
}
}, 1000)
}, [addToast, navigate])
const handleSimpleImport = async () => {
if (!importUri.trim()) { addToast('Please enter a model URI', 'error'); return }
setIsSubmitting(true)
setEstimate(null)
try {
const prefsObj = {}
if (prefs.backend) prefsObj.backend = prefs.backend
if (prefs.name.trim()) prefsObj.name = prefs.name.trim()
if (prefs.description.trim()) prefsObj.description = prefs.description.trim()
if (prefs.quantizations.trim()) prefsObj.quantizations = prefs.quantizations.trim()
if (prefs.mmproj_quantizations.trim()) prefsObj.mmproj_quantizations = prefs.mmproj_quantizations.trim()
if (prefs.embeddings) prefsObj.embeddings = 'true'
if (prefs.type.trim()) prefsObj.type = prefs.type.trim()
if (prefs.pipeline_type.trim()) prefsObj.pipeline_type = prefs.pipeline_type.trim()
if (prefs.scheduler_type.trim()) prefsObj.scheduler_type = prefs.scheduler_type.trim()
if (prefs.enable_parameters.trim()) prefsObj.enable_parameters = prefs.enable_parameters.trim()
if (prefs.cuda) prefsObj.cuda = true
customPrefs.forEach(cp => {
if (cp.key.trim() && cp.value.trim()) prefsObj[cp.key.trim()] = cp.value.trim()
})
const result = await modelsApi.importUri({
uri: importUri.trim(),
preferences: Object.keys(prefsObj).length > 0 ? prefsObj : null,
})
const hasSize = result.estimated_size_display && result.estimated_size_display !== '0 B'
const hasVram = result.estimated_vram_display && result.estimated_vram_display !== '0 B'
if (hasSize || hasVram) {
setEstimate({ sizeDisplay: result.estimated_size_display || '', vramDisplay: result.estimated_vram_display || '' })
}
const jobId = result.uuid || result.ID
if (!jobId) throw new Error('No job ID returned from server')
let msg = 'Import started! Tracking progress...'
const parts = []
if (hasSize) parts.push(`Size: ${result.estimated_size_display}`)
if (hasVram) parts.push(`VRAM: ${result.estimated_vram_display}`)
if (parts.length) msg += ` (${parts.join(' \u00b7 ')})`
addToast(msg, 'success')
startJobPolling(jobId)
} catch (err) {
addToast(`Failed to start import: ${err.message}`, 'error')
setIsSubmitting(false)
}
}
const handleAdvancedImport = async () => {
if (!yamlContent.trim()) { addToast('Please enter YAML configuration', 'error'); return }
setIsSubmitting(true)
try {
await modelsApi.importConfig(yamlContent, 'application/x-yaml')
addToast('Model configuration imported successfully!', 'success')
navigate('/manage')
} catch (err) {
addToast(`Import failed: ${err.message}`, 'error')
} finally {
setIsSubmitting(false)
}
}
return (
<div className="page" style={{ maxWidth: '900px' }}>
<div className="page-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 'var(--spacing-sm)' }}>
<div>
<h1 className="page-title">Import New Model</h1>
<p className="page-subtitle">
{isAdvancedMode ? 'Configure your model settings using YAML' : 'Import a model from URI with preferences'}
</p>
</div>
<div style={{ display: 'flex', gap: 'var(--spacing-sm)' }}>
<button className="btn btn-secondary" onClick={() => setIsAdvancedMode(!isAdvancedMode)}>
<i className={`fas ${isAdvancedMode ? 'fa-magic' : 'fa-code'}`} />
{isAdvancedMode ? ' Simple Mode' : ' Advanced Mode'}
</button>
{!isAdvancedMode ? (
<button className="btn btn-primary" onClick={handleSimpleImport} disabled={isSubmitting || !importUri.trim()}>
{isSubmitting ? <><LoadingSpinner size="sm" /> Importing...</> : <><i className="fas fa-upload" /> Import Model</>}
</button>
) : (
<button className="btn btn-primary" onClick={handleAdvancedImport} disabled={isSubmitting}>
{isSubmitting ? <><LoadingSpinner size="sm" /> Saving...</> : <><i className="fas fa-save" /> Create</>}
</button>
)}
</div>
</div>
{/* Estimate banner */}
{!isAdvancedMode && estimate && (
<div className="card" style={{ marginBottom: 'var(--spacing-md)', padding: 'var(--spacing-md)', borderColor: 'var(--color-primary)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-sm)', fontSize: '0.875rem', flexWrap: 'wrap' }}>
<i className="fas fa-memory" style={{ color: 'var(--color-primary)' }} />
<strong>Estimated requirements</strong>
{estimate.sizeDisplay && estimate.sizeDisplay !== '0 B' && (
<span><i className="fas fa-download" style={{ color: 'var(--color-primary)', marginRight: '4px' }} />Download: {estimate.sizeDisplay}</span>
)}
{estimate.vramDisplay && estimate.vramDisplay !== '0 B' && (
<span><i className="fas fa-microchip" style={{ color: 'var(--color-primary)', marginRight: '4px' }} />VRAM: {estimate.vramDisplay}</span>
)}
</div>
</div>
)}
{/* Job progress */}
{jobProgress && (
<div className="card" style={{ marginBottom: 'var(--spacing-md)', padding: 'var(--spacing-md)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-sm)', fontSize: '0.875rem' }}>
<LoadingSpinner size="sm" />
<span>{jobProgress}</span>
</div>
</div>
)}
{/* Simple Import Mode */}
{!isAdvancedMode && (
<div className="card" style={{ padding: 'var(--spacing-lg)' }}>
<h2 style={{ fontSize: '1.25rem', fontWeight: 600, marginBottom: 'var(--spacing-md)', display: 'flex', alignItems: 'center', gap: 'var(--spacing-sm)' }}>
<i className="fas fa-link" style={{ color: 'var(--color-success)' }} />
Import from URI
</h2>
{/* URI Input */}
<div className="form-group">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '4px' }}>
<label className="form-label" style={{ marginBottom: 0 }}>
<i className="fas fa-link" style={{ marginRight: '6px' }} />Model URI
</label>
<a href="https://huggingface.co/models?search=gguf&sort=trending" target="_blank" rel="noreferrer"
className="btn btn-secondary" style={{ fontSize: '0.7rem', padding: '3px 8px' }}>
Search GGUF on HF <i className="fas fa-external-link-alt" style={{ marginLeft: '4px' }} />
</a>
</div>
<input
className="input"
type="text"
value={importUri}
onChange={(e) => setImportUri(e.target.value)}
placeholder="huggingface://TheBloke/Llama-2-7B-Chat-GGUF or https://example.com/model.gguf"
disabled={isSubmitting}
/>
<p style={hintStyle}>Enter the URI or path to the model file you want to import</p>
{/* URI format guide */}
<button
onClick={() => setShowGuide(!showGuide)}
style={{ marginTop: 'var(--spacing-sm)', background: 'none', border: 'none', color: 'var(--color-text-secondary)', cursor: 'pointer', fontSize: '0.8125rem', display: 'flex', alignItems: 'center', gap: '6px', padding: 0 }}
>
<i className={`fas ${showGuide ? 'fa-chevron-down' : 'fa-chevron-right'}`} />
<i className="fas fa-info-circle" />
Supported URI Formats
</button>
{showGuide && (
<div style={{ marginTop: 'var(--spacing-sm)', padding: 'var(--spacing-md)', background: 'var(--color-bg-primary)', border: '1px solid var(--color-border-default)', borderRadius: 'var(--radius-md)' }}>
{URI_FORMATS.map((fmt, i) => (
<div key={i} style={{ marginBottom: i < URI_FORMATS.length - 1 ? 'var(--spacing-md)' : 0 }}>
<h4 style={{ fontSize: '0.8125rem', fontWeight: 600, marginBottom: '6px', display: 'flex', alignItems: 'center', gap: '6px' }}>
<i className={fmt.icon} style={{ color: fmt.color }} />
{fmt.title}
</h4>
<div style={{ paddingLeft: '20px', fontSize: '0.75rem', fontFamily: 'monospace' }}>
{fmt.examples.map((ex, j) => (
<div key={j} style={{ marginBottom: '4px' }}>
<code style={{ color: 'var(--color-success)' }}>{ex.prefix}</code>
<span style={{ color: 'var(--color-text-secondary)' }}>{ex.suffix}</span>
<p style={{ color: 'var(--color-text-muted)', marginTop: '1px', fontFamily: 'inherit' }}>{ex.desc}</p>
</div>
))}
</div>
</div>
))}
</div>
)}
</div>
{/* Preferences */}
<div style={{ marginTop: 'var(--spacing-lg)' }}>
<div style={{ fontSize: '0.875rem', fontWeight: 500, color: 'var(--color-text-secondary)', marginBottom: 'var(--spacing-sm)' }}>
<i className="fas fa-cog" style={{ marginRight: '6px' }} />Preferences (Optional)
</div>
<div style={{ padding: 'var(--spacing-md)', background: 'var(--color-bg-primary)', border: '1px solid var(--color-border-default)', borderRadius: 'var(--radius-md)' }}>
<h3 style={{ fontSize: '0.8125rem', fontWeight: 600, color: 'var(--color-text-secondary)', marginBottom: 'var(--spacing-md)', display: 'flex', alignItems: 'center', gap: '6px' }}>
<i className="fas fa-star" style={{ color: 'var(--color-warning)' }} />
Common Preferences
</h3>
<div style={{ display: 'grid', gap: 'var(--spacing-md)' }}>
<div className="form-group" style={{ marginBottom: 0 }}>
<label className="form-label"><i className="fas fa-server" style={{ marginRight: '6px' }} />Backend</label>
<select className="input" value={prefs.backend} onChange={e => updatePref('backend', e.target.value)} disabled={isSubmitting}>
{BACKENDS.map(b => <option key={b.value} value={b.value}>{b.label}</option>)}
</select>
<p style={hintStyle}>Force a specific backend. Leave empty to auto-detect from URI.</p>
</div>
<div className="form-group" style={{ marginBottom: 0 }}>
<label className="form-label"><i className="fas fa-tag" style={{ marginRight: '6px' }} />Model Name</label>
<input className="input" type="text" value={prefs.name} onChange={e => updatePref('name', e.target.value)} placeholder="Leave empty to use filename" disabled={isSubmitting} />
<p style={hintStyle}>Custom name for the model. If empty, the filename will be used.</p>
</div>
<div className="form-group" style={{ marginBottom: 0 }}>
<label className="form-label"><i className="fas fa-align-left" style={{ marginRight: '6px' }} />Description</label>
<textarea className="textarea" rows={3} value={prefs.description} onChange={e => updatePref('description', e.target.value)} placeholder="Leave empty to use default description" disabled={isSubmitting} />
<p style={hintStyle}>Custom description for the model.</p>
</div>
<div className="form-group" style={{ marginBottom: 0 }}>
<label className="form-label"><i className="fas fa-layer-group" style={{ marginRight: '6px' }} />Quantizations</label>
<input className="input" type="text" value={prefs.quantizations} onChange={e => updatePref('quantizations', e.target.value)} placeholder="q4_k_m,q4_k_s,q3_k_m (comma-separated)" disabled={isSubmitting} />
<p style={hintStyle}>Preferred quantizations (comma-separated). Leave empty for default (q4_k_m).</p>
</div>
<div className="form-group" style={{ marginBottom: 0 }}>
<label className="form-label"><i className="fas fa-image" style={{ marginRight: '6px' }} />MMProj Quantizations</label>
<input className="input" type="text" value={prefs.mmproj_quantizations} onChange={e => updatePref('mmproj_quantizations', e.target.value)} placeholder="fp16,fp32 (comma-separated)" disabled={isSubmitting} />
<p style={hintStyle}>Preferred MMProj quantizations. Leave empty for default (fp16).</p>
</div>
<div>
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}>
<input type="checkbox" checked={prefs.embeddings} onChange={e => updatePref('embeddings', e.target.checked)} disabled={isSubmitting} />
<span style={{ fontSize: '0.875rem', fontWeight: 500, color: 'var(--color-text-secondary)' }}>
<i className="fas fa-vector-square" style={{ marginRight: '6px' }} />Embeddings
</span>
</label>
<p style={{ ...hintStyle, marginLeft: '28px' }}>Enable embeddings support for this model.</p>
</div>
<div className="form-group" style={{ marginBottom: 0 }}>
<label className="form-label"><i className="fas fa-tag" style={{ marginRight: '6px' }} />Model Type</label>
<input className="input" type="text" value={prefs.type} onChange={e => updatePref('type', e.target.value)} placeholder="AutoModelForCausalLM (for transformers backend)" disabled={isSubmitting} />
<p style={hintStyle}>Model type for transformers backend. Examples: AutoModelForCausalLM, SentenceTransformer, Mamba.</p>
</div>
{/* Diffusers-specific fields */}
{prefs.backend === 'diffusers' && (
<>
<div className="form-group" style={{ marginBottom: 0 }}>
<label className="form-label"><i className="fas fa-stream" style={{ marginRight: '6px' }} />Pipeline Type</label>
<input className="input" type="text" value={prefs.pipeline_type} onChange={e => updatePref('pipeline_type', e.target.value)} placeholder="StableDiffusionPipeline" disabled={isSubmitting} />
<p style={hintStyle}>Pipeline type for diffusers backend.</p>
</div>
<div className="form-group" style={{ marginBottom: 0 }}>
<label className="form-label"><i className="fas fa-clock" style={{ marginRight: '6px' }} />Scheduler Type</label>
<input className="input" type="text" value={prefs.scheduler_type} onChange={e => updatePref('scheduler_type', e.target.value)} placeholder="k_dpmpp_2m (optional)" disabled={isSubmitting} />
<p style={hintStyle}>Scheduler type for diffusers backend. Examples: k_dpmpp_2m, euler_a, ddim.</p>
</div>
<div className="form-group" style={{ marginBottom: 0 }}>
<label className="form-label"><i className="fas fa-cogs" style={{ marginRight: '6px' }} />Enable Parameters</label>
<input className="input" type="text" value={prefs.enable_parameters} onChange={e => updatePref('enable_parameters', e.target.value)} placeholder="negative_prompt,num_inference_steps (comma-separated)" disabled={isSubmitting} />
<p style={hintStyle}>Enabled parameters for diffusers backend (comma-separated).</p>
</div>
<div>
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}>
<input type="checkbox" checked={prefs.cuda} onChange={e => updatePref('cuda', e.target.checked)} disabled={isSubmitting} />
<span style={{ fontSize: '0.875rem', fontWeight: 500, color: 'var(--color-text-secondary)' }}>
<i className="fas fa-microchip" style={{ marginRight: '6px' }} />CUDA
</span>
</label>
<p style={{ ...hintStyle, marginLeft: '28px' }}>Enable CUDA support for GPU acceleration.</p>
</div>
</>
)}
</div>
</div>
{/* Custom Preferences */}
<div style={{ marginTop: 'var(--spacing-md)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 'var(--spacing-sm)' }}>
<span style={{ fontSize: '0.875rem', fontWeight: 500, color: 'var(--color-text-secondary)' }}>
<i className="fas fa-sliders-h" style={{ marginRight: '6px' }} />Custom Preferences
</span>
<button className="btn btn-secondary" onClick={addCustomPref} disabled={isSubmitting} style={{ fontSize: '0.75rem' }}>
<i className="fas fa-plus" /> Add Custom
</button>
</div>
{customPrefs.map((cp, i) => (
<div key={i} style={{ display: 'flex', gap: 'var(--spacing-sm)', alignItems: 'center', marginBottom: 'var(--spacing-xs)' }}>
<input className="input" type="text" value={cp.key} onChange={e => updateCustomPref(i, 'key', e.target.value)} placeholder="Key" disabled={isSubmitting} style={{ flex: 1 }} />
<span style={{ color: 'var(--color-text-secondary)' }}>:</span>
<input className="input" type="text" value={cp.value} onChange={e => updateCustomPref(i, 'value', e.target.value)} placeholder="Value" disabled={isSubmitting} style={{ flex: 1 }} />
<button className="btn btn-secondary" onClick={() => removeCustomPref(i)} disabled={isSubmitting} style={{ color: 'var(--color-error)' }}>
<i className="fas fa-trash" />
</button>
</div>
))}
<p style={hintStyle}>Add custom key-value pairs for advanced configuration.</p>
</div>
</div>
</div>
)}
{/* Advanced YAML Editor Mode */}
{isAdvancedMode && (
<div className="card" style={{ padding: 0, overflow: 'hidden' }}>
<div style={{ padding: 'var(--spacing-md)', borderBottom: '1px solid var(--color-border-default)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h2 style={{ fontSize: '1.125rem', fontWeight: 600, display: 'flex', alignItems: 'center', gap: '8px' }}>
<i className="fas fa-code" style={{ color: '#d946ef' }} />
YAML Configuration Editor
</h2>
<button className="btn btn-secondary" style={{ fontSize: '0.75rem' }} onClick={() => { navigator.clipboard.writeText(yamlContent); addToast('Copied to clipboard', 'success') }}>
<i className="fas fa-copy" /> Copy
</button>
</div>
<CodeEditor value={yamlContent} onChange={setYamlContent} disabled={isSubmitting} minHeight="calc(100vh - 400px)" />
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,58 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
export default function Login() {
const navigate = useNavigate()
const [token, setToken] = useState('')
const [error, setError] = useState('')
const handleSubmit = (e) => {
e.preventDefault()
if (!token.trim()) {
setError('Please enter a token')
return
}
// Set token as cookie
document.cookie = `token=${encodeURIComponent(token.trim())}; path=/; SameSite=Strict`
navigate('/')
}
return (
<div style={{
minHeight: '100vh',
background: 'var(--color-bg-primary)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 'var(--spacing-xl)',
}}>
<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)' }} />
<h1 style={{ fontSize: '1.5rem', fontWeight: 700, marginBottom: 'var(--spacing-xs)' }}>
<span className="text-gradient">LocalAI</span>
</h1>
<p style={{ color: 'var(--color-text-secondary)', fontSize: '0.875rem' }}>Enter your API token to continue</p>
</div>
<form onSubmit={handleSubmit}>
<div className="form-group">
<label className="form-label">API Token</label>
<input
className="input"
type="password"
value={token}
onChange={(e) => { setToken(e.target.value); setError('') }}
placeholder="Enter token..."
autoFocus
/>
{error && <p style={{ color: 'var(--color-error)', fontSize: '0.8125rem', marginTop: 'var(--spacing-xs)' }}>{error}</p>}
</div>
<button type="submit" className="btn btn-primary" style={{ width: '100%' }}>
<i className="fas fa-sign-in-alt" /> Login
</button>
</form>
</div>
</div>
)
}

View File

@@ -0,0 +1,350 @@
import { useState, useEffect, useCallback } from 'react'
import { useNavigate, useOutletContext } from 'react-router-dom'
import ResourceMonitor from '../components/ResourceMonitor'
import { useModels } from '../hooks/useModels'
import { backendControlApi, modelsApi, backendsApi, systemApi } from '../utils/api'
export default function Manage() {
const { addToast } = useOutletContext()
const navigate = useNavigate()
const { models, loading: modelsLoading, refetch: refetchModels } = useModels()
const [loadedModelIds, setLoadedModelIds] = useState(new Set())
const [backends, setBackends] = useState([])
const [backendsLoading, setBackendsLoading] = useState(true)
const [reloading, setReloading] = useState(false)
const [reinstallingBackends, setReinstallingBackends] = useState(new Set())
const fetchLoadedModels = useCallback(async () => {
try {
const info = await systemApi.info()
const loaded = Array.isArray(info?.loaded_models) ? info.loaded_models : []
setLoadedModelIds(new Set(loaded.map(m => m.id)))
} catch {
setLoadedModelIds(new Set())
}
}, [])
const fetchBackends = useCallback(async () => {
try {
setBackendsLoading(true)
const data = await backendsApi.listInstalled()
setBackends(Array.isArray(data) ? data : [])
} catch {
setBackends([])
} finally {
setBackendsLoading(false)
}
}, [])
useEffect(() => {
fetchLoadedModels()
fetchBackends()
}, [fetchLoadedModels, fetchBackends])
const handleStopModel = async (modelName) => {
if (!confirm(`Stop model ${modelName}?`)) return
try {
await backendControlApi.shutdown({ model: modelName })
addToast(`Stopped ${modelName}`, 'success')
setTimeout(fetchLoadedModels, 500)
} catch (err) {
addToast(`Failed to stop: ${err.message}`, 'error')
}
}
const handleDeleteModel = async (modelName) => {
if (!confirm(`Delete model ${modelName}? This cannot be undone.`)) return
try {
await modelsApi.deleteByName(modelName)
addToast(`Deleted ${modelName}`, 'success')
refetchModels()
fetchLoadedModels()
} catch (err) {
addToast(`Failed to delete: ${err.message}`, 'error')
}
}
const handleReload = async () => {
setReloading(true)
try {
await modelsApi.reload()
addToast('Models reloaded', 'success')
setTimeout(() => { refetchModels(); fetchLoadedModels(); setReloading(false) }, 1000)
} catch (err) {
addToast(`Reload failed: ${err.message}`, 'error')
setReloading(false)
}
}
const handleReinstallBackend = async (name) => {
try {
setReinstallingBackends(prev => new Set(prev).add(name))
await backendsApi.install(name)
addToast(`Reinstalling ${name}...`, 'info')
} catch (err) {
addToast(`Failed to reinstall: ${err.message}`, 'error')
} finally {
setReinstallingBackends(prev => {
const next = new Set(prev)
next.delete(name)
return next
})
}
}
const handleDeleteBackend = async (name) => {
if (!confirm(`Delete backend ${name}?`)) return
try {
await backendsApi.deleteInstalled(name)
addToast(`Deleted backend ${name}`, 'success')
fetchBackends()
} catch (err) {
addToast(`Failed to delete backend: ${err.message}`, 'error')
}
}
return (
<div className="page">
<div className="page-header">
<h1 className="page-title">Model & Backend Management</h1>
</div>
{/* Resource Monitor */}
<ResourceMonitor />
{/* Models Section */}
<div style={{ marginTop: 'var(--spacing-xl)' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 'var(--spacing-md)' }}>
<h2 style={{ fontSize: '1.125rem', fontWeight: 600 }}>
Models ({models.length})
</h2>
<button className="btn btn-secondary btn-sm" onClick={handleReload} disabled={reloading}>
<i className={`fas ${reloading ? 'fa-spinner fa-spin' : 'fa-rotate'}`} />
{reloading ? 'Updating...' : 'Update'}
</button>
</div>
{modelsLoading ? (
<div className="card" style={{ padding: 'var(--spacing-xl)', textAlign: 'center', color: 'var(--color-text-muted)' }}>
<i className="fas fa-circle-notch fa-spin" /> Loading models...
</div>
) : models.length === 0 ? (
<div className="card" style={{ padding: 'var(--spacing-xl)', textAlign: 'center' }}>
<i className="fas fa-exclamation-triangle" style={{ fontSize: '2rem', color: 'var(--color-warning)', marginBottom: 'var(--spacing-md)' }} />
<h3 style={{ marginBottom: 'var(--spacing-sm)' }}>No models installed yet</h3>
<p style={{ color: 'var(--color-text-secondary)', fontSize: '0.875rem', marginBottom: 'var(--spacing-md)' }}>
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')}>
<i className="fas fa-store" /> Browse Gallery
</button>
<button className="btn btn-secondary btn-sm" onClick={() => navigate('/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">
<i className="fas fa-book" /> Documentation
</a>
</div>
</div>
) : (
<div className="table-container">
<table className="table">
<thead>
<tr>
<th>Name</th>
<th>Status</th>
<th>Backend</th>
<th>Use Cases</th>
<th style={{ textAlign: 'right' }}>Actions</th>
</tr>
</thead>
<tbody>
{models.map(model => (
<tr key={model.id}>
<td>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-sm)' }}>
<i className="fas fa-brain" style={{ color: 'var(--color-accent)' }} />
<span className="badge badge-success" style={{ width: 6, height: 6, padding: 0, borderRadius: '50%', minWidth: 'auto' }} />
<span style={{ fontWeight: 500 }}>{model.id}</span>
<a
href="#"
onClick={(e) => { e.preventDefault(); navigate(`/model-editor/${encodeURIComponent(model.id)}`) }}
style={{ fontSize: '0.75rem', color: 'var(--color-primary)' }}
title="Edit config"
>
<i className="fas fa-pen-to-square" />
</a>
</div>
</td>
<td>
{loadedModelIds.has(model.id) ? (
<span className="badge badge-success">
<i className="fas fa-circle" style={{ fontSize: '6px' }} /> Running
</span>
) : (
<span className="badge" style={{ background: 'var(--color-bg-tertiary)', color: 'var(--color-text-muted)' }}>
<i className="fas fa-circle" style={{ fontSize: '6px' }} /> Idle
</span>
)}
</td>
<td>
<span className="badge badge-info">Auto</span>
</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>
</div>
</td>
<td>
<div style={{ display: 'flex', gap: 'var(--spacing-xs)', justifyContent: 'flex-end' }}>
{loadedModelIds.has(model.id) && (
<button
className="btn btn-secondary btn-sm"
onClick={() => handleStopModel(model.id)}
title="Stop model"
>
<i className="fas fa-stop" />
</button>
)}
<button
className="btn btn-danger btn-sm"
onClick={() => handleDeleteModel(model.id)}
title="Delete model"
>
<i className="fas fa-trash" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* Backends Section */}
<div style={{ marginTop: 'var(--spacing-xl)' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 'var(--spacing-md)' }}>
<h2 style={{ fontSize: '1.125rem', fontWeight: 600 }}>
Backends ({backends.length})
</h2>
</div>
{backendsLoading ? (
<div style={{ textAlign: 'center', padding: 'var(--spacing-md)', color: 'var(--color-text-muted)', fontSize: '0.875rem' }}>
Loading backends...
</div>
) : backends.length === 0 ? (
<div className="card" style={{ padding: 'var(--spacing-xl)', textAlign: 'center' }}>
<i className="fas fa-server" style={{ fontSize: '2rem', color: 'var(--color-text-muted)', marginBottom: 'var(--spacing-md)' }} />
<h3 style={{ marginBottom: 'var(--spacing-sm)' }}>No backends installed yet</h3>
<p style={{ color: 'var(--color-text-secondary)', fontSize: '0.875rem', marginBottom: 'var(--spacing-md)' }}>
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')}>
<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">
<i className="fas fa-book" /> Documentation
</a>
</div>
</div>
) : (
<div className="table-container">
<table className="table">
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th>Metadata</th>
<th style={{ textAlign: 'right' }}>Actions</th>
</tr>
</thead>
<tbody>
{backends.map((backend, i) => (
<tr key={backend.Name || i}>
<td>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-sm)' }}>
<i className="fas fa-cog" style={{ color: 'var(--color-accent)', fontSize: '0.75rem' }} />
<span style={{ fontWeight: 500 }}>{backend.Name}</span>
</div>
</td>
<td>
<div style={{ display: 'flex', gap: '4px', flexWrap: 'wrap' }}>
{backend.IsSystem ? (
<span className="badge badge-info" style={{ fontSize: '0.625rem' }}>
<i className="fas fa-shield-alt" style={{ fontSize: '0.5rem', marginRight: 2 }} />System
</span>
) : (
<span className="badge badge-success" style={{ fontSize: '0.625rem' }}>
<i className="fas fa-download" style={{ fontSize: '0.5rem', marginRight: 2 }} />User
</span>
)}
{backend.IsMeta && (
<span className="badge" style={{ background: 'var(--color-accent-light)', color: 'var(--color-accent)', fontSize: '0.625rem' }}>
<i className="fas fa-layer-group" style={{ fontSize: '0.5rem', marginRight: 2 }} />Meta
</span>
)}
</div>
</td>
<td>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, fontSize: '0.75rem', color: 'var(--color-text-secondary)' }}>
{backend.Metadata?.alias && (
<span>
<i className="fas fa-tag" style={{ fontSize: '0.5rem', marginRight: 4 }} />
Alias: <span style={{ color: 'var(--color-text-primary)' }}>{backend.Metadata.alias}</span>
</span>
)}
{backend.Metadata?.meta_backend_for && (
<span>
<i className="fas fa-link" style={{ fontSize: '0.5rem', marginRight: 4 }} />
For: <span style={{ color: 'var(--color-accent)' }}>{backend.Metadata.meta_backend_for}</span>
</span>
)}
{backend.Metadata?.installed_at && (
<span>
<i className="fas fa-calendar" style={{ fontSize: '0.5rem', marginRight: 4 }} />
{backend.Metadata.installed_at}
</span>
)}
{!backend.Metadata?.alias && !backend.Metadata?.meta_backend_for && !backend.Metadata?.installed_at && '—'}
</div>
</td>
<td>
<div style={{ display: 'flex', gap: 'var(--spacing-xs)', justifyContent: 'flex-end' }}>
{!backend.IsSystem ? (
<>
<button
className="btn btn-secondary btn-sm"
onClick={() => handleReinstallBackend(backend.Name)}
disabled={reinstallingBackends.has(backend.Name)}
title="Reinstall"
>
<i className={`fas ${reinstallingBackends.has(backend.Name) ? 'fa-spinner fa-spin' : 'fa-rotate'}`} />
</button>
<button
className="btn btn-danger btn-sm"
onClick={() => handleDeleteBackend(backend.Name)}
title="Delete"
>
<i className="fas fa-trash" />
</button>
</>
) : (
<span style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)' }}></span>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,70 @@
import { useState, useEffect } from 'react'
import { useParams, useNavigate, useOutletContext } from 'react-router-dom'
import { modelsApi } from '../utils/api'
import LoadingSpinner from '../components/LoadingSpinner'
import CodeEditor from '../components/CodeEditor'
export default function ModelEditor() {
const { name } = useParams()
const navigate = useNavigate()
const { addToast } = useOutletContext()
const [config, setConfig] = useState('')
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
useEffect(() => {
if (!name) return
modelsApi.getEditConfig(name).then(data => {
setConfig(data?.config || '')
setLoading(false)
}).catch(err => {
addToast(`Failed to load config: ${err.message}`, 'error')
setLoading(false)
})
}, [name, addToast])
const handleSave = async () => {
setSaving(true)
try {
// Send raw YAML/text to the edit endpoint (not JSON-encoded)
const response = await fetch(`/models/edit/${encodeURIComponent(name)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-yaml' },
body: config,
})
const data = await response.json()
if (!response.ok || data.success === false) {
throw new Error(data.error || `HTTP ${response.status}`)
}
addToast('Config saved', 'success')
} catch (err) {
addToast(`Save failed: ${err.message}`, 'error')
} finally {
setSaving(false)
}
}
if (loading) return <div className="page"><LoadingSpinner size="lg" /></div>
return (
<div className="page" style={{ maxWidth: '900px' }}>
<div className="page-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<h1 className="page-title">Model Editor</h1>
<p className="page-subtitle">{decodeURIComponent(name)}</p>
</div>
<button className="btn btn-secondary" onClick={() => navigate('/manage')}>
<i className="fas fa-arrow-left" /> Back
</button>
</div>
<CodeEditor value={config} onChange={setConfig} minHeight="500px" />
<div style={{ marginTop: 'var(--spacing-md)', display: 'flex', gap: 'var(--spacing-sm)' }}>
<button className="btn btn-primary" onClick={handleSave} disabled={saving}>
{saving ? <><LoadingSpinner size="sm" /> Saving...</> : <><i className="fas fa-save" /> Save</>}
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,554 @@
import { useState, useCallback, useEffect, useRef } from 'react'
import { useNavigate, useOutletContext } from 'react-router-dom'
import { modelsApi } from '../utils/api'
import { useOperations } from '../hooks/useOperations'
import { useResources } from '../hooks/useResources'
import { formatBytes } from '../utils/format'
const LOADING_PHRASES = [
{ text: 'Rounding up the neural networks...', icon: 'fa-brain' },
{ text: 'Asking the models to line up nicely...', icon: 'fa-people-line' },
{ text: 'Convincing transformers to transform...', icon: 'fa-wand-magic-sparkles' },
{ text: 'Herding digital llamas...', icon: 'fa-horse' },
{ text: 'Downloading more RAM... just kidding', icon: 'fa-memory' },
{ text: 'Counting parameters... lost count at a billion', icon: 'fa-calculator' },
{ text: 'Untangling attention heads...', icon: 'fa-diagram-project' },
{ text: 'Warming up the GPUs...', icon: 'fa-fire' },
{ text: 'Teaching AI to sit and stay...', icon: 'fa-graduation-cap' },
{ text: 'Polishing the weights and biases...', icon: 'fa-gem' },
{ text: 'Stacking layers like pancakes...', icon: 'fa-layer-group' },
{ text: 'Negotiating with the token budget...', icon: 'fa-coins' },
{ text: 'Fetching models from the cloud mines...', icon: 'fa-cloud-arrow-down' },
{ text: 'Calibrating the vibe check algorithm...', icon: 'fa-gauge-high' },
{ text: 'Optimizing inference with good intentions...', icon: 'fa-bolt' },
{ text: 'Measuring GPU with a ruler...', icon: 'fa-ruler' },
{ text: 'Will it fit? Asking the VRAM oracle...', icon: 'fa-microchip' },
{ text: 'Playing Tetris with model layers...', icon: 'fa-cubes' },
{ text: 'Checking if we need more RGB...', icon: 'fa-rainbow' },
{ text: 'Squeezing tensors into memory...', icon: 'fa-compress' },
{ text: 'Whispering sweet nothings to CUDA cores...', icon: 'fa-heart' },
{ text: 'Asking the electrons to scoot over...', icon: 'fa-atom' },
{ text: 'Defragmenting the flux capacitor...', icon: 'fa-clock-rotate-left' },
{ text: 'Consulting the tensor gods...', icon: 'fa-hands-praying' },
{ text: 'Checking under the GPU\'s hood...', icon: 'fa-car' },
{ text: 'Seeing if the hamsters can run faster...', icon: 'fa-fan' },
{ text: 'Running very important math... carry the 1...', icon: 'fa-square-root-variable' },
{ text: 'Poking the memory bus gently...', icon: 'fa-bus' },
{ text: 'Bribing the scheduler with clock cycles...', icon: 'fa-stopwatch' },
{ text: 'Asking models to share their VRAM nicely...', icon: 'fa-handshake' },
]
function GalleryLoader() {
const [idx, setIdx] = useState(() => Math.floor(Math.random() * LOADING_PHRASES.length))
const [fade, setFade] = useState(true)
useEffect(() => {
const interval = setInterval(() => {
setFade(false)
setTimeout(() => {
setIdx(prev => (prev + 1) % LOADING_PHRASES.length)
setFade(true)
}, 300)
}, 2800)
return () => clearInterval(interval)
}, [])
const phrase = LOADING_PHRASES[idx]
return (
<div style={{
display: 'flex', flexDirection: 'column', alignItems: 'center',
justifyContent: 'center', padding: 'var(--spacing-xl) var(--spacing-md)',
minHeight: '280px', gap: 'var(--spacing-lg)',
}}>
{/* Animated dots */}
<div style={{ display: 'flex', gap: '8px' }}>
{[0, 1, 2, 3, 4].map(i => (
<div key={i} style={{
width: 10, height: 10, borderRadius: '50%',
background: 'var(--color-primary)',
animation: `galleryDot 1.4s ease-in-out ${i * 0.15}s infinite`,
}} />
))}
</div>
{/* Rotating phrase */}
<div style={{
display: 'flex', alignItems: 'center', gap: 'var(--spacing-sm)',
opacity: fade ? 1 : 0,
transition: 'opacity 300ms ease',
color: 'var(--color-text-secondary)',
fontSize: '0.9375rem',
fontWeight: 500,
}}>
<i className={`fas ${phrase.icon}`} style={{ color: 'var(--color-accent)', fontSize: '1.125rem' }} />
{phrase.text}
</div>
{/* Skeleton rows */}
<div style={{ width: '100%', maxWidth: '700px', display: 'flex', flexDirection: 'column', gap: '12px' }}>
{[0.9, 0.7, 0.5].map((opacity, i) => (
<div key={i} style={{
height: '48px', borderRadius: 'var(--radius-md)',
background: 'var(--color-bg-tertiary)', opacity,
animation: `galleryShimmer 1.8s ease-in-out ${i * 0.2}s infinite`,
}} />
))}
</div>
<style>{`
@keyframes galleryDot {
0%, 80%, 100% { transform: scale(0.4); opacity: 0.3; }
40% { transform: scale(1); opacity: 1; }
}
@keyframes galleryShimmer {
0%, 100% { opacity: var(--shimmer-base, 0.15); }
50% { opacity: var(--shimmer-peak, 0.3); }
}
`}</style>
</div>
)
}
const FILTERS = [
{ key: '', label: 'All', icon: 'fa-layer-group' },
{ key: 'llm', label: 'LLM', icon: 'fa-brain' },
{ key: 'sd', label: 'Image', icon: 'fa-image' },
{ key: 'multimodal', label: 'Multimodal', icon: 'fa-shapes' },
{ key: 'vision', label: 'Vision', icon: 'fa-eye' },
{ key: 'tts', label: 'TTS', icon: 'fa-microphone' },
{ key: 'stt', label: 'STT', icon: 'fa-headphones' },
{ key: 'embedding', label: 'Embedding', icon: 'fa-vector-square' },
{ key: 'reranker', label: 'Rerank', icon: 'fa-sort' },
]
export default function Models() {
const { addToast } = useOutletContext()
const navigate = useNavigate()
const { operations } = useOperations()
const { resources } = useResources()
const [models, setModels] = useState([])
const [loading, setLoading] = useState(true)
const [page, setPage] = useState(1)
const [totalPages, setTotalPages] = useState(1)
const [search, setSearch] = useState('')
const [filter, setFilter] = useState('')
const [sort, setSort] = useState('')
const [order, setOrder] = useState('asc')
const [installing, setInstalling] = useState(new Set())
const [selectedModel, setSelectedModel] = useState(null)
const [stats, setStats] = useState({ total: 0, installed: 0, repositories: 0 })
const debounceRef = useRef(null)
// Total GPU memory for "fits" check
const totalGpuMemory = resources?.aggregate?.total_memory || 0
const fetchModels = useCallback(async (params = {}) => {
try {
setLoading(true)
const searchVal = params.search !== undefined ? params.search : search
const filterVal = params.filter !== undefined ? params.filter : filter
const sortVal = params.sort !== undefined ? params.sort : sort
// Combine search text and filter into 'term' param
const term = searchVal || filterVal || ''
const queryParams = {
page: params.page || page,
items: 21,
}
if (term) queryParams.term = term
if (sortVal) {
queryParams.sort = sortVal
queryParams.order = params.order || order
}
const data = await modelsApi.list(queryParams)
setModels(data?.models || [])
setTotalPages(data?.totalPages || data?.total_pages || 1)
setStats({
total: data?.availableModels || 0,
installed: data?.installedModels || 0,
})
} catch (err) {
addToast(`Failed to load models: ${err.message}`, 'error')
} finally {
setLoading(false)
}
}, [page, search, filter, sort, order, addToast])
useEffect(() => {
fetchModels()
}, [page, filter, sort, order])
const handleSearch = (value) => {
setSearch(value)
if (debounceRef.current) clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(() => {
setPage(1)
fetchModels({ search: value, page: 1 })
}, 500)
}
const handleSort = (col) => {
if (sort === col) {
setOrder(o => o === 'asc' ? 'desc' : 'asc')
} else {
setSort(col)
setOrder('asc')
}
}
const handleInstall = async (modelId) => {
try {
setInstalling(prev => new Set(prev).add(modelId))
await modelsApi.install(modelId)
addToast(`Installing ${modelId}...`, 'info')
} catch (err) {
addToast(`Failed to install: ${err.message}`, 'error')
}
}
const handleDelete = async (modelId) => {
if (!confirm(`Delete model ${modelId}?`)) return
try {
await modelsApi.delete(modelId)
addToast(`Deleting ${modelId}...`, 'info')
fetchModels()
} catch (err) {
addToast(`Failed to delete: ${err.message}`, 'error')
}
}
const isInstalling = (modelId) => {
return installing.has(modelId) || operations.some(op =>
op.name === modelId && !op.completed && !op.error
)
}
const getOperationProgress = (modelId) => {
const op = operations.find(o => o.name === modelId && !o.completed && !o.error)
return op?.progress ?? 0
}
const fitsGpu = (vramBytes) => {
if (!vramBytes || !totalGpuMemory) return null
return vramBytes <= totalGpuMemory * 0.95
}
return (
<div className="page">
<div className="page-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div>
<h1 className="page-title">Model Gallery</h1>
<p className="page-subtitle">Discover and install AI models for your workflows</p>
</div>
<div style={{ display: 'flex', gap: 'var(--spacing-md)', alignItems: 'center' }}>
<div style={{ display: 'flex', gap: 'var(--spacing-md)', fontSize: '0.8125rem' }}>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: '1.25rem', fontWeight: 700, color: 'var(--color-primary)' }}>{stats.total}</div>
<div style={{ color: 'var(--color-text-muted)' }}>Available</div>
</div>
<div style={{ textAlign: 'center' }}>
<a onClick={() => navigate('/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')}>
<i className="fas fa-upload" /> Import Model
</button>
</div>
</div>
{/* Search */}
<div className="search-bar" style={{ marginBottom: 'var(--spacing-md)' }}>
<i className="fas fa-search search-icon" />
<input
className="input"
type="text"
placeholder="Search models..."
value={search}
onChange={(e) => handleSearch(e.target.value)}
/>
</div>
{/* Filter buttons */}
<div className="filter-bar">
{FILTERS.map(f => (
<button
key={f.key}
className={`filter-btn ${filter === f.key ? 'active' : ''}`}
onClick={() => { setFilter(f.key); setPage(1) }}
>
<i className={`fas ${f.icon}`} style={{ marginRight: 4 }} />
{f.label}
</button>
))}
</div>
{/* Table */}
{loading ? (
<GalleryLoader />
) : models.length === 0 ? (
<div className="empty-state">
<div className="empty-state-icon"><i className="fas fa-search" /></div>
<h2 className="empty-state-title">No models found</h2>
<p className="empty-state-text">Try adjusting your search or filters</p>
</div>
) : (
<div className="table-container" style={{ background: 'var(--color-bg-secondary)', borderRadius: 'var(--radius-lg)', overflow: 'hidden' }}>
<div style={{ overflowX: 'auto' }}>
<table className="table" style={{ minWidth: '800px' }}>
<thead>
<tr>
<th style={{ width: '60px' }}></th>
<th style={{ cursor: 'pointer' }} onClick={() => handleSort('name')}>
Model Name {sort === 'name' && <i className={`fas fa-arrow-${order === 'asc' ? 'up' : 'down'}`} style={{ fontSize: '0.625rem' }} />}
</th>
<th>Description</th>
<th>Size / VRAM</th>
<th style={{ cursor: 'pointer' }} onClick={() => handleSort('status')}>
Status {sort === 'status' && <i className={`fas fa-arrow-${order === 'asc' ? 'up' : 'down'}`} style={{ fontSize: '0.625rem' }} />}
</th>
<th style={{ textAlign: 'right' }}>Actions</th>
</tr>
</thead>
<tbody>
{models.map(model => {
const name = model.name || model.id
const installing = isInstalling(name)
const progress = getOperationProgress(name)
const fit = fitsGpu(model.estimated_vram_bytes)
return (
<tr key={name}>
{/* Icon */}
<td>
<div style={{
width: 48, height: 48, borderRadius: 'var(--radius-md)',
border: '1px solid var(--color-border-subtle)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
background: 'var(--color-bg-primary)', overflow: 'hidden',
}}>
{model.icon ? (
<img src={model.icon} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} loading="lazy" />
) : (
<i className="fas fa-brain" style={{ fontSize: '1.25rem', color: 'var(--color-accent)' }} />
)}
</div>
</td>
{/* Name */}
<td>
<div>
<span style={{ fontSize: '0.875rem', fontWeight: 600 }}>{name}</span>
{model.trustRemoteCode && (
<div style={{ marginTop: '2px' }}>
<span className="badge badge-error" style={{ fontSize: '0.625rem' }}>
<i className="fas fa-circle-exclamation" /> Trust Remote Code
</span>
</div>
)}
</div>
</td>
{/* Description */}
<td>
<div style={{
fontSize: '0.8125rem', color: 'var(--color-text-secondary)',
maxWidth: '200px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}} title={model.description}>
{model.description || '—'}
</div>
</td>
{/* Size / VRAM */}
<td>
<div style={{ display: 'flex', flexDirection: 'column', gap: '2px' }}>
{(model.estimated_size_display || model.estimated_vram_display) ? (
<>
<span style={{ fontSize: '0.75rem', color: 'var(--color-text-secondary)' }}>
{model.estimated_size_display && model.estimated_size_display !== '0 B' && (
<span>Size: {model.estimated_size_display}</span>
)}
{model.estimated_size_display && model.estimated_size_display !== '0 B' && model.estimated_vram_display && model.estimated_vram_display !== '0 B' && ' · '}
{model.estimated_vram_display && model.estimated_vram_display !== '0 B' && (
<span>VRAM: {model.estimated_vram_display}</span>
)}
</span>
{fit !== null && (
<span style={{ fontSize: '0.6875rem', display: 'flex', alignItems: 'center', gap: '4px' }}>
<i className="fas fa-microchip" style={{ color: fit ? 'var(--color-success)' : 'var(--color-error)' }} />
<span style={{ color: fit ? 'var(--color-success)' : 'var(--color-error)' }}>
{fit ? 'Fits' : 'May not fit'}
</span>
</span>
)}
</>
) : (
<span style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)' }}></span>
)}
</div>
</td>
{/* Status */}
<td>
{installing ? (
<div>
<span style={{ fontSize: '0.75rem', color: 'var(--color-primary)' }}>
<i className="fas fa-spinner fa-spin" /> Installing...
</span>
{progress > 0 && (
<div style={{ marginTop: '4px', width: '100%', maxWidth: '120px' }}>
<div style={{ height: 3, background: 'var(--color-bg-tertiary)', borderRadius: 2, overflow: 'hidden' }}>
<div style={{ height: '100%', width: `${progress}%`, background: 'var(--color-primary)', borderRadius: 2, transition: 'width 300ms' }} />
</div>
</div>
)}
</div>
) : model.installed ? (
<span className="badge badge-success">
<i className="fas fa-check-circle" /> Installed
</span>
) : (
<span className="badge" style={{ background: 'var(--color-bg-tertiary)', color: 'var(--color-text-muted)' }}>
<i className="fas fa-circle" /> Not Installed
</span>
)}
</td>
{/* Actions */}
<td>
<div style={{ display: 'flex', gap: 'var(--spacing-xs)', justifyContent: 'flex-end' }}>
<button
className="btn btn-secondary btn-sm"
onClick={() => setSelectedModel(model)}
title="Details"
>
<i className="fas fa-info-circle" />
</button>
{model.installed ? (
<>
<button className="btn btn-secondary btn-sm" onClick={() => handleInstall(name)} title="Reinstall">
<i className="fas fa-rotate" />
</button>
<button className="btn btn-danger btn-sm" onClick={() => handleDelete(name)} title="Delete">
<i className="fas fa-trash" />
</button>
</>
) : (
<button
className="btn btn-primary btn-sm"
onClick={() => handleInstall(name)}
disabled={installing}
title="Install"
>
<i className="fas fa-download" />
</button>
)}
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
)}
{/* Pagination */}
{totalPages > 1 && (
<div className="pagination">
<button className="pagination-btn" onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page === 1}>
<i className="fas fa-chevron-left" />
</button>
<span style={{ fontSize: '0.8125rem', color: 'var(--color-text-secondary)', padding: '0 var(--spacing-sm)' }}>
{page} / {totalPages}
</span>
<button className="pagination-btn" onClick={() => setPage(p => Math.min(totalPages, p + 1))} disabled={page === totalPages}>
<i className="fas fa-chevron-right" />
</button>
</div>
)}
{/* Detail Modal */}
{selectedModel && (
<div style={{
position: 'fixed', inset: 0, zIndex: 100,
display: 'flex', alignItems: 'center', justifyContent: 'center',
background: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(4px)',
}} onClick={() => setSelectedModel(null)}>
<div style={{
background: 'var(--color-bg-secondary)',
border: '1px solid var(--color-border-subtle)',
borderRadius: 'var(--radius-lg)',
maxWidth: '600px', width: '90%', maxHeight: '80vh',
display: 'flex', flexDirection: 'column',
}} onClick={e => e.stopPropagation()}>
{/* Modal header */}
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: 'var(--spacing-md)', borderBottom: '1px solid var(--color-border-subtle)',
}}>
<h3 style={{ fontSize: '1rem', fontWeight: 600 }}>{selectedModel.name}</h3>
<button className="btn btn-secondary btn-sm" onClick={() => setSelectedModel(null)}>
<i className="fas fa-times" />
</button>
</div>
{/* Modal body */}
<div style={{ padding: 'var(--spacing-md)', overflowY: 'auto', flex: 1 }}>
{/* Icon */}
{selectedModel.icon && (
<div style={{
width: 48, height: 48, borderRadius: 'var(--radius-md)',
border: '1px solid var(--color-border-subtle)', overflow: 'hidden',
marginBottom: 'var(--spacing-md)',
}}>
<img src={selectedModel.icon} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
</div>
)}
{/* Description */}
{selectedModel.description && (
<p style={{ fontSize: '0.875rem', color: 'var(--color-text-secondary)', lineHeight: 1.6, marginBottom: 'var(--spacing-md)' }}>
{selectedModel.description}
</p>
)}
{/* Size/VRAM */}
{(selectedModel.estimated_size_display || selectedModel.estimated_vram_display) && (
<div style={{ fontSize: '0.8125rem', color: 'var(--color-text-secondary)', marginBottom: 'var(--spacing-md)' }}>
{selectedModel.estimated_size_display && <div>Size: {selectedModel.estimated_size_display}</div>}
{selectedModel.estimated_vram_display && <div>VRAM: {selectedModel.estimated_vram_display}</div>}
</div>
)}
{/* Tags */}
{selectedModel.tags?.length > 0 && (
<div style={{ display: 'flex', gap: '4px', flexWrap: 'wrap', marginBottom: 'var(--spacing-md)' }}>
{selectedModel.tags.map(tag => (
<span key={tag} className="badge badge-info">{tag}</span>
))}
</div>
)}
{/* Links */}
{selectedModel.urls?.length > 0 && (
<div style={{ marginBottom: 'var(--spacing-md)' }}>
<h4 style={{ fontSize: '0.8125rem', fontWeight: 600, marginBottom: 'var(--spacing-xs)' }}>Links</h4>
{selectedModel.urls.map((url, i) => (
<a key={i} href={url} target="_blank" rel="noopener noreferrer" style={{ display: 'block', fontSize: '0.8125rem', color: 'var(--color-primary)', marginBottom: '2px' }}>
{url}
</a>
))}
</div>
)}
</div>
{/* Modal footer */}
<div style={{
padding: 'var(--spacing-sm) var(--spacing-md)',
borderTop: '1px solid var(--color-border-subtle)',
display: 'flex', justifyContent: 'flex-end',
}}>
<button className="btn btn-secondary btn-sm" onClick={() => setSelectedModel(null)}>Close</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,19 @@
import { useNavigate } from 'react-router-dom'
export default function NotFound() {
const navigate = useNavigate()
return (
<div className="page">
<div className="empty-state">
<div className="empty-state-icon"><i className="fas fa-compass" /></div>
<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('/')}>
<i className="fas fa-home" /> Go Home
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,684 @@
import { useState, useEffect, useCallback } from 'react'
import { useOutletContext } from 'react-router-dom'
import { p2pApi } from '../utils/api'
import LoadingSpinner from '../components/LoadingSpinner'
function NodeCard({ node, label, iconColor, iconBg }) {
return (
<div style={{
background: 'var(--color-bg-primary)',
border: `1px solid ${node.isOnline ? 'rgba(34,197,94,0.5)' : 'rgba(239,68,68,0.5)'}`,
borderRadius: 'var(--radius-md)',
padding: 'var(--spacing-md)',
transition: 'border-color 200ms',
}}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 'var(--spacing-sm)' }}>
<div style={{ display: 'flex', alignItems: 'center' }}>
<div style={{
width: 40, height: 40, borderRadius: 'var(--radius-md)',
background: iconBg, display: 'flex', alignItems: 'center', justifyContent: 'center',
marginRight: 'var(--spacing-sm)',
}}>
<i className="fas fa-server" style={{ color: iconColor, fontSize: '1rem' }} />
</div>
<div>
<h4 style={{ fontSize: '0.875rem', fontWeight: 600 }}>{label}</h4>
<p style={{ fontSize: '0.75rem', fontFamily: "'JetBrains Mono', monospace", color: 'var(--color-text-secondary)', wordBreak: 'break-all' }}>
{node.id}
</p>
</div>
</div>
<div style={{
display: 'flex', alignItems: 'center', gap: '6px',
background: 'var(--color-bg-primary)', borderRadius: 'var(--radius-md)',
padding: '4px 10px', border: '1px solid var(--color-border-subtle)',
}}>
<i className="fas fa-circle" style={{
fontSize: '0.5rem',
color: node.isOnline ? 'var(--color-success)' : 'var(--color-error)',
}} />
<span style={{
fontSize: '0.75rem', fontWeight: 500,
color: node.isOnline ? 'var(--color-success)' : 'var(--color-error)',
}}>
{node.isOnline ? 'Online' : 'Offline'}
</span>
</div>
</div>
<div style={{
fontSize: '0.75rem', color: 'var(--color-text-muted)',
paddingTop: 'var(--spacing-sm)', borderTop: '1px solid var(--color-border-subtle)',
display: 'flex', alignItems: 'center', gap: '6px',
}}>
<i className="fas fa-clock" />
<span>Updated: {new Date().toLocaleTimeString()}</span>
</div>
</div>
)
}
function CommandBlock({ command, addToast }) {
const copy = () => {
navigator.clipboard.writeText(command)
addToast('Copied to clipboard', 'success', 2000)
}
return (
<div style={{ position: 'relative' }}>
<pre style={{
background: 'var(--color-bg-primary)', padding: 'var(--spacing-md)',
paddingRight: 'var(--spacing-xl)', borderRadius: 'var(--radius-md)',
fontSize: '0.8125rem', fontFamily: "'JetBrains Mono', monospace",
whiteSpace: 'pre-wrap', wordBreak: 'break-all',
color: 'var(--color-warning)', overflow: 'auto',
border: '1px solid var(--color-border-subtle)',
}}>
{command}
</pre>
<button
onClick={copy}
style={{
position: 'absolute', top: 8, right: 8,
background: 'var(--color-bg-secondary)', border: '1px solid var(--color-border-subtle)',
borderRadius: 'var(--radius-sm)', padding: '4px 8px', cursor: 'pointer',
color: 'var(--color-text-secondary)', fontSize: '0.75rem',
}}
title="Copy"
>
<i className="fas fa-copy" />
</button>
</div>
)
}
function StepNumber({ n, bg, color }) {
return (
<span style={{
width: 28, height: 28, borderRadius: '50%', background: bg,
color, display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: '0.8125rem', fontWeight: 700, flexShrink: 0,
}}>{n}</span>
)
}
export default function P2P() {
const { addToast } = useOutletContext()
const [workers, setWorkers] = useState([])
const [federation, setFederation] = useState([])
const [stats, setStats] = useState({ workers: { online: 0, total: 0 }, federated: { online: 0, total: 0 } })
const [loading, setLoading] = useState(true)
const [enabled, setEnabled] = useState(false)
const [token, setToken] = useState('')
const [activeTab, setActiveTab] = useState('federation')
const fetchData = useCallback(async () => {
try {
const [wRes, fRes, sRes, tRes] = await Promise.allSettled([
p2pApi.getWorkers(),
p2pApi.getFederation(),
p2pApi.getStats(),
p2pApi.getToken(),
])
let p2pToken = ''
if (tRes.status === 'fulfilled') {
p2pToken = (typeof tRes.value === 'string' ? tRes.value : (tRes.value?.token || '')).trim()
}
setToken(p2pToken)
setEnabled(!!p2pToken)
if (p2pToken) {
if (wRes.status === 'fulfilled') {
const data = wRes.value
setWorkers(data?.nodes || (Array.isArray(data) ? data : []))
}
if (fRes.status === 'fulfilled') {
const data = fRes.value
setFederation(data?.nodes || (Array.isArray(data) ? data : []))
}
if (sRes.status === 'fulfilled') {
setStats(sRes.value)
}
}
} catch {
setEnabled(false)
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchData()
const interval = setInterval(fetchData, 3000)
return () => clearInterval(interval)
}, [fetchData])
const copyToken = () => {
if (token) {
navigator.clipboard.writeText(token)
addToast('Token copied to clipboard', 'success', 2000)
}
}
if (loading) {
return (
<div className="page" style={{ display: 'flex', justifyContent: 'center', padding: 'var(--spacing-xl)' }}>
<LoadingSpinner size="lg" />
</div>
)
}
// ── P2P Disabled ──
if (!enabled) {
return (
<div className="page">
<div style={{ textAlign: 'center', padding: 'var(--spacing-xl) 0' }}>
<i className="fas fa-network-wired" style={{ fontSize: '3rem', color: 'var(--color-primary)', marginBottom: 'var(--spacing-md)' }} />
<h1 style={{ fontSize: '1.5rem', fontWeight: 600, marginBottom: 'var(--spacing-sm)' }}>
P2P Distribution Not Enabled
</h1>
<p style={{ color: 'var(--color-text-secondary)', maxWidth: 600, margin: '0 auto var(--spacing-xl)' }}>
Enable peer-to-peer distribution to scale your AI workloads across multiple devices. Share instances, shard models, and pool computational resources across your network.
</p>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 'var(--spacing-md)', marginBottom: 'var(--spacing-xl)' }}>
<div className="card" style={{ textAlign: 'center', padding: 'var(--spacing-md)' }}>
<div style={{
width: 40, height: 40, borderRadius: 'var(--radius-md)', margin: '0 auto var(--spacing-sm)',
background: 'var(--color-primary-light)', display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<i className="fas fa-network-wired" style={{ color: 'var(--color-primary)', fontSize: '1.25rem' }} />
</div>
<h3 style={{ fontSize: '0.9375rem', fontWeight: 600, marginBottom: 'var(--spacing-xs)' }}>Instance Federation</h3>
<p style={{ fontSize: '0.8125rem', color: 'var(--color-text-secondary)' }}>Load balance across multiple instances</p>
</div>
<div className="card" style={{ textAlign: 'center', padding: 'var(--spacing-md)' }}>
<div style={{
width: 40, height: 40, borderRadius: 'var(--radius-md)', margin: '0 auto var(--spacing-sm)',
background: 'var(--color-accent-light)', display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<i className="fas fa-puzzle-piece" style={{ color: 'var(--color-accent)', fontSize: '1.25rem' }} />
</div>
<h3 style={{ fontSize: '0.9375rem', fontWeight: 600, marginBottom: 'var(--spacing-xs)' }}>Model Sharding</h3>
<p style={{ fontSize: '0.8125rem', color: 'var(--color-text-secondary)' }}>Split large models across workers</p>
</div>
<div className="card" style={{ textAlign: 'center', padding: 'var(--spacing-md)' }}>
<div style={{
width: 40, height: 40, borderRadius: 'var(--radius-md)', margin: '0 auto var(--spacing-sm)',
background: 'rgba(34,197,94,0.1)', display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<i className="fas fa-share-alt" style={{ color: 'var(--color-success)', fontSize: '1.25rem' }} />
</div>
<h3 style={{ fontSize: '0.9375rem', fontWeight: 600, marginBottom: 'var(--spacing-xs)' }}>Resource Sharing</h3>
<p style={{ fontSize: '0.8125rem', color: 'var(--color-text-secondary)' }}>Pool resources from multiple devices</p>
</div>
</div>
</div>
{/* How to Enable */}
<div className="card" style={{ maxWidth: 700, margin: '0 auto var(--spacing-xl)', padding: 'var(--spacing-lg)', textAlign: 'left' }}>
<h3 style={{ fontSize: '1.125rem', fontWeight: 700, marginBottom: 'var(--spacing-md)', display: 'flex', alignItems: 'center' }}>
<i className="fas fa-rocket" style={{ color: 'var(--color-accent)', marginRight: 'var(--spacing-sm)' }} />
How to Enable P2P
</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-md)' }}>
<div style={{ display: 'flex', gap: 'var(--spacing-sm)' }}>
<StepNumber n={1} bg="var(--color-accent-light)" color="var(--color-accent)" />
<div style={{ flex: 1 }}>
<p style={{ fontWeight: 500, marginBottom: 'var(--spacing-xs)' }}>Start LocalAI with P2P enabled</p>
<CommandBlock
command={`docker run -ti --net host --name local-ai \\\n localai/localai:latest-cpu run --p2p`}
addToast={addToast}
/>
<p style={{ color: 'var(--color-text-secondary)', fontSize: '0.8125rem', marginTop: 'var(--spacing-xs)' }}>
This will automatically generate a network token for you.
</p>
</div>
</div>
<div style={{ display: 'flex', gap: 'var(--spacing-sm)' }}>
<StepNumber n={2} bg="var(--color-accent-light)" color="var(--color-accent)" />
<div style={{ flex: 1 }}>
<p style={{ fontWeight: 500, marginBottom: 'var(--spacing-xs)' }}>Or use an existing token</p>
<CommandBlock
command={`docker run -ti --net host \\\n -e TOKEN="your-token-here" \\\n --name local-ai \\\n localai/localai:latest-cpu run --p2p`}
addToast={addToast}
/>
<p style={{ color: 'var(--color-text-secondary)', fontSize: '0.8125rem', marginTop: 'var(--spacing-xs)' }}>
If you already have a token from another instance, you can reuse it.
</p>
</div>
</div>
<div style={{ display: 'flex', gap: 'var(--spacing-sm)' }}>
<StepNumber n={3} bg="var(--color-accent-light)" color="var(--color-accent)" />
<div style={{ flex: 1 }}>
<p style={{ fontWeight: 500 }}>Access the P2P dashboard</p>
<p style={{ color: 'var(--color-text-secondary)', fontSize: '0.8125rem', marginTop: 'var(--spacing-xs)' }}>
Once enabled, refresh this page to see your network token and start connecting nodes.
</p>
</div>
</div>
</div>
</div>
<div style={{ textAlign: 'center', display: 'flex', gap: 'var(--spacing-md)', justifyContent: 'center', flexWrap: 'wrap' }}>
<a className="btn btn-primary" href="https://localai.io/features/distribute/" target="_blank" rel="noopener noreferrer">
<i className="fas fa-book" /> Documentation <i className="fas fa-external-link-alt" style={{ fontSize: '0.75rem', marginLeft: 4 }} />
</a>
<a className="btn btn-secondary" href="https://localai.io/basics/getting_started/" target="_blank" rel="noopener noreferrer">
<i className="fas fa-graduation-cap" /> Getting Started <i className="fas fa-external-link-alt" style={{ fontSize: '0.75rem', marginLeft: 4 }} />
</a>
</div>
</div>
)
}
// ── P2P Enabled ──
const fedOnline = stats.federated?.online ?? 0
const fedTotal = stats.federated?.total ?? 0
const wrkOnline = stats.workers?.online ?? 0
const wrkTotal = stats.workers?.total ?? 0
return (
<div className="page">
<div className="page-header">
<h1 className="page-title">
<i className="fas fa-circle-nodes" style={{ marginRight: 'var(--spacing-sm)' }} />
Distributed AI Computing
</h1>
<p className="page-subtitle">
Scale your AI workloads across multiple devices with peer-to-peer distribution
{' '}
<a href="https://localai.io/features/distribute/" target="_blank" rel="noopener noreferrer"
style={{ color: 'var(--color-primary)' }}>
<i className="fas fa-circle-info" />
</a>
</p>
</div>
{/* Network Token */}
<div style={{
background: 'var(--color-bg-secondary)', border: '1px solid rgba(139,92,246,0.2)',
borderRadius: 'var(--radius-lg)', padding: 'var(--spacing-lg)', marginBottom: 'var(--spacing-xl)',
}}>
<div style={{ display: 'flex', alignItems: 'center', marginBottom: 'var(--spacing-md)' }}>
<i className="fas fa-key" style={{ color: 'var(--color-warning)', fontSize: '1.25rem', marginRight: 'var(--spacing-sm)' }} />
<h3 style={{ fontSize: '1.125rem', fontWeight: 700, flex: 1 }}>Network Token</h3>
<button className="btn btn-secondary btn-sm" onClick={copyToken} title="Copy token">
<i className="fas fa-copy" />
</button>
</div>
<pre
onClick={copyToken}
style={{
background: 'var(--color-bg-primary)', color: 'var(--color-warning)',
padding: 'var(--spacing-md)', borderRadius: 'var(--radius-md)',
wordBreak: 'break-all', whiteSpace: 'pre-wrap',
border: '1px solid var(--color-border-subtle)', cursor: 'pointer',
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.8125rem',
}}
>
{token || 'Loading...'}
</pre>
<p style={{ color: 'var(--color-text-secondary)', fontSize: '0.8125rem', marginTop: 'var(--spacing-sm)' }}>
All nodes (federated servers, instances, and workers) use the same token to join the network. Pass it via the <code>TOKEN</code> environment variable.
</p>
</div>
{/* Tab bar */}
<div style={{
display: 'flex', borderBottom: '2px solid var(--color-border-subtle)',
marginBottom: 'var(--spacing-xl)', gap: '2px',
}}>
<button
onClick={() => setActiveTab('federation')}
style={{
flex: 1, padding: 'var(--spacing-md)',
background: activeTab === 'federation' ? 'var(--color-bg-secondary)' : 'transparent',
border: 'none', cursor: 'pointer',
borderBottom: activeTab === 'federation' ? '2px solid var(--color-primary)' : '2px solid transparent',
marginBottom: '-2px',
borderRadius: 'var(--radius-md) var(--radius-md) 0 0',
transition: 'all 150ms',
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 'var(--spacing-sm)' }}>
<div style={{
width: 36, height: 36, borderRadius: 'var(--radius-md)',
background: activeTab === 'federation' ? 'var(--color-primary-light)' : 'var(--color-bg-tertiary)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<i className="fas fa-circle-nodes" style={{
color: activeTab === 'federation' ? 'var(--color-primary)' : 'var(--color-text-muted)',
fontSize: '1rem',
}} />
</div>
<div style={{ textAlign: 'left' }}>
<div style={{
fontSize: '0.9375rem', fontWeight: 600,
color: activeTab === 'federation' ? 'var(--color-text-primary)' : 'var(--color-text-secondary)',
}}>
Federation
</div>
<div style={{
fontSize: '0.75rem',
color: activeTab === 'federation' ? 'var(--color-primary)' : 'var(--color-text-muted)',
}}>
{fedOnline}/{fedTotal} instances
</div>
</div>
</div>
</button>
<button
onClick={() => setActiveTab('sharding')}
style={{
flex: 1, padding: 'var(--spacing-md)',
background: activeTab === 'sharding' ? 'var(--color-bg-secondary)' : 'transparent',
border: 'none', cursor: 'pointer',
borderBottom: activeTab === 'sharding' ? '2px solid var(--color-accent)' : '2px solid transparent',
marginBottom: '-2px',
borderRadius: 'var(--radius-md) var(--radius-md) 0 0',
transition: 'all 150ms',
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 'var(--spacing-sm)' }}>
<div style={{
width: 36, height: 36, borderRadius: 'var(--radius-md)',
background: activeTab === 'sharding' ? 'var(--color-accent-light)' : 'var(--color-bg-tertiary)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<i className="fas fa-puzzle-piece" style={{
color: activeTab === 'sharding' ? 'var(--color-accent)' : 'var(--color-text-muted)',
fontSize: '1rem',
}} />
</div>
<div style={{ textAlign: 'left' }}>
<div style={{
fontSize: '0.9375rem', fontWeight: 600,
color: activeTab === 'sharding' ? 'var(--color-text-primary)' : 'var(--color-text-secondary)',
}}>
Model Sharding
</div>
<div style={{
fontSize: '0.75rem',
color: activeTab === 'sharding' ? 'var(--color-accent)' : 'var(--color-text-muted)',
}}>
{wrkOnline}/{wrkTotal} workers
</div>
</div>
</div>
</button>
</div>
{/* ── Federation Tab ── */}
{activeTab === 'federation' && (
<div style={{
background: 'var(--color-bg-secondary)', border: '1px solid rgba(99,102,241,0.2)',
borderRadius: 'var(--radius-lg)', overflow: 'hidden',
}}>
<div style={{ padding: 'var(--spacing-lg)', borderBottom: '1px solid var(--color-border-subtle)' }}>
{/* Architecture diagram */}
<div style={{
background: 'var(--color-bg-primary)', border: '1px solid var(--color-border-subtle)',
borderRadius: 'var(--radius-lg)', padding: 'var(--spacing-md)', marginBottom: 'var(--spacing-md)',
}}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 'var(--spacing-md)', flexWrap: 'wrap' }}>
<div style={{ textAlign: 'center' }}>
<div style={{
width: 48, height: 48, borderRadius: 'var(--radius-md)',
background: 'rgba(245,158,11,0.1)', display: 'flex', alignItems: 'center', justifyContent: 'center',
margin: '0 auto var(--spacing-xs)',
}}>
<i className="fas fa-user" style={{ color: 'var(--color-warning)', fontSize: '1rem' }} />
</div>
<div style={{ fontSize: '0.75rem', fontWeight: 600 }}>API Client</div>
</div>
<i className="fas fa-arrow-right" style={{ color: 'var(--color-text-muted)', fontSize: '1rem' }} />
<div style={{ textAlign: 'center' }}>
<div style={{
width: 48, height: 48, borderRadius: 'var(--radius-md)',
background: 'rgba(34,197,94,0.1)', display: 'flex', alignItems: 'center', justifyContent: 'center',
margin: '0 auto var(--spacing-xs)', border: '2px solid var(--color-success)',
}}>
<i className="fas fa-scale-balanced" style={{ color: 'var(--color-success)', fontSize: '1rem' }} />
</div>
<div style={{ fontSize: '0.75rem', fontWeight: 600 }}>Federated Server</div>
<div style={{ fontSize: '0.625rem', color: 'var(--color-text-muted)' }}>Load balancer</div>
</div>
<i className="fas fa-arrow-right" style={{ color: 'var(--color-text-muted)', fontSize: '1rem' }} />
<div style={{ textAlign: 'center' }}>
<div style={{ display: 'flex', gap: '4px', marginBottom: 'var(--spacing-xs)' }}>
{[1, 2, 3].map(n => (
<div key={n} style={{
width: 36, height: 36, borderRadius: 'var(--radius-sm)',
background: 'var(--color-primary-light)', display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<i className="fas fa-server" style={{ color: 'var(--color-primary)', fontSize: '0.75rem' }} />
</div>
))}
</div>
<div style={{ fontSize: '0.75rem', fontWeight: 600 }}>Federated Instances</div>
<div style={{ fontSize: '0.625rem', color: 'var(--color-text-muted)' }}>Workers</div>
</div>
</div>
<p style={{ fontSize: '0.8125rem', color: 'var(--color-text-secondary)', textAlign: 'center', marginTop: 'var(--spacing-sm)', lineHeight: 1.5 }}>
The <strong>Federated Server</strong> acts as a load balancer it receives API requests and distributes them across <strong>Federated Instances</strong> (workers running your models).
</p>
</div>
{/* Status + nodes */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 'var(--spacing-md)' }}>
<h3 style={{ fontSize: '1rem', fontWeight: 700 }}>Connected Instances</h3>
<div style={{ fontSize: '1.25rem', fontWeight: 700 }}>
<span style={{ color: fedOnline > 0 ? 'var(--color-success)' : 'var(--color-error)' }}>{fedOnline}</span>
<span style={{ color: 'var(--color-text-secondary)', fontSize: '1rem' }}>/{fedTotal}</span>
</div>
</div>
{federation.length === 0 ? (
<div style={{
textAlign: 'center', padding: 'var(--spacing-lg)',
background: 'var(--color-bg-primary)', border: '1px solid var(--color-border-subtle)',
borderRadius: 'var(--radius-lg)',
}}>
<i className="fas fa-server" style={{ fontSize: '2rem', color: 'var(--color-text-muted)', marginBottom: 'var(--spacing-sm)' }} />
<p style={{ fontWeight: 500, color: 'var(--color-text-secondary)' }}>No federated instances connected</p>
<p style={{ fontSize: '0.8125rem', color: 'var(--color-text-muted)', marginTop: 'var(--spacing-xs)' }}>Follow the setup steps below</p>
</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))', gap: 'var(--spacing-md)' }}>
{federation.map((node, i) => (
<NodeCard key={node.id || i} node={node} label="Instance" iconColor="var(--color-primary)" iconBg="var(--color-primary-light)" />
))}
</div>
)}
</div>
{/* Setup Guide */}
<div style={{ padding: 'var(--spacing-lg)' }}>
<h3 style={{ fontSize: '1.125rem', fontWeight: 700, marginBottom: 'var(--spacing-md)' }}>
<i className="fas fa-book" style={{ color: 'var(--color-primary)', marginRight: 'var(--spacing-sm)' }} />
Setup Guide
</h3>
<div style={{
background: 'var(--color-bg-primary)', borderRadius: 'var(--radius-lg)',
border: '1px solid var(--color-border-subtle)', padding: 'var(--spacing-lg)',
}}>
{/* Step 1 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-sm)', marginBottom: 'var(--spacing-sm)' }}>
<StepNumber n={1} bg="rgba(34,197,94,0.15)" color="var(--color-success)" />
<h4 style={{ fontSize: '1rem', fontWeight: 700 }}>
Start the Federated Server <span style={{ fontSize: '0.8125rem', fontWeight: 400, color: 'var(--color-text-muted)' }}>(load balancer)</span>
</h4>
</div>
<p style={{ color: 'var(--color-text-secondary)', fontSize: '0.875rem', marginBottom: 'var(--spacing-sm)' }}>
This is the entry point for your API clients. It receives requests and distributes them to federated instances.
</p>
<CommandBlock
command={`docker run -ti --net host \\\n -e TOKEN="${token}" \\\n --name local-ai-federated \\\n localai/localai:latest-cpu federated`}
addToast={addToast}
/>
<p style={{ color: 'var(--color-text-muted)', fontSize: '0.8125rem', marginTop: 'var(--spacing-sm)' }}>
Listens on port <code>8080</code> by default. To change it, add <code>-e ADDRESS=:9090</code>.
</p>
{/* Step 2 */}
<div style={{
display: 'flex', alignItems: 'center', gap: 'var(--spacing-sm)',
marginTop: 'var(--spacing-xl)', marginBottom: 'var(--spacing-sm)',
}}>
<StepNumber n={2} bg="var(--color-primary-light)" color="var(--color-primary)" />
<h4 style={{ fontSize: '1rem', fontWeight: 700 }}>
Start Federated Instances <span style={{ fontSize: '0.8125rem', fontWeight: 400, color: 'var(--color-text-muted)' }}>(workers)</span>
</h4>
</div>
<p style={{ color: 'var(--color-text-secondary)', fontSize: '0.875rem', marginBottom: 'var(--spacing-sm)' }}>
Run this on each machine you want to add as a worker. Each instance runs your models and receives tasks from the federated server.
</p>
<CommandBlock
command={`docker run -ti --net host \\\n -e TOKEN="${token}" \\\n --name local-ai \\\n localai/localai:latest-cpu run --federated --p2p`}
addToast={addToast}
/>
<p style={{ color: 'var(--color-text-muted)', fontSize: '0.8125rem', marginTop: 'var(--spacing-sm)' }}>
Listens on port <code>8080</code> by default. To change it, add <code>-e ADDRESS=:9090</code>.
</p>
<p style={{ color: 'var(--color-text-secondary)', fontSize: '0.875rem', marginTop: 'var(--spacing-lg)' }}>
For GPU images and all available options, see the{' '}
<a href="https://localai.io/basics/container/" target="_blank" rel="noopener noreferrer"
style={{ color: 'var(--color-primary)' }}>Container images</a>
{' '}and{' '}
<a href="https://localai.io/features/distribute/" target="_blank" rel="noopener noreferrer"
style={{ color: 'var(--color-primary)' }}>Distribution</a> docs.
</p>
</div>
</div>
</div>
)}
{/* ── Model Sharding Tab ── */}
{activeTab === 'sharding' && (
<div style={{
background: 'var(--color-bg-secondary)', border: '1px solid rgba(139,92,246,0.2)',
borderRadius: 'var(--radius-lg)', overflow: 'hidden',
}}>
<div style={{ padding: 'var(--spacing-lg)', borderBottom: '1px solid var(--color-border-subtle)' }}>
{/* Architecture diagram */}
<div style={{
background: 'var(--color-bg-primary)', border: '1px solid var(--color-border-subtle)',
borderRadius: 'var(--radius-lg)', padding: 'var(--spacing-md)', marginBottom: 'var(--spacing-md)',
}}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 'var(--spacing-md)', flexWrap: 'wrap' }}>
<div style={{ textAlign: 'center' }}>
<div style={{
width: 48, height: 48, borderRadius: 'var(--radius-md)',
background: 'var(--color-primary-light)', display: 'flex', alignItems: 'center', justifyContent: 'center',
margin: '0 auto var(--spacing-xs)', border: '2px solid var(--color-primary)',
}}>
<i className="fas fa-server" style={{ color: 'var(--color-primary)', fontSize: '1rem' }} />
</div>
<div style={{ fontSize: '0.75rem', fontWeight: 600 }}>LocalAI Instance</div>
<div style={{ fontSize: '0.625rem', color: 'var(--color-text-muted)' }}>Orchestrator</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '2px' }}>
<i className="fas fa-arrow-right" style={{ color: 'var(--color-text-muted)', fontSize: '0.875rem' }} />
<span style={{ fontSize: '0.625rem', color: 'var(--color-text-muted)' }}>RPC</span>
</div>
<div style={{ textAlign: 'center' }}>
<div style={{ display: 'flex', gap: '4px', marginBottom: 'var(--spacing-xs)' }}>
{['Layer 1-10', 'Layer 11-20', 'Layer 21-30'].map((label, i) => (
<div key={i} style={{ textAlign: 'center' }}>
<div style={{
width: 56, height: 36, borderRadius: 'var(--radius-sm)',
background: 'var(--color-accent-light)', display: 'flex', alignItems: 'center', justifyContent: 'center',
border: '1px solid rgba(139,92,246,0.3)',
}}>
<i className="fas fa-microchip" style={{ color: 'var(--color-accent)', fontSize: '0.75rem' }} />
</div>
<div style={{ fontSize: '0.5625rem', color: 'var(--color-text-muted)', marginTop: 2 }}>{label}</div>
</div>
))}
</div>
<div style={{ fontSize: '0.75rem', fontWeight: 600 }}>RPC Workers</div>
<div style={{ fontSize: '0.625rem', color: 'var(--color-text-muted)' }}>Distributed memory</div>
</div>
</div>
<p style={{ fontSize: '0.8125rem', color: 'var(--color-text-secondary)', textAlign: 'center', marginTop: 'var(--spacing-sm)', lineHeight: 1.5 }}>
Model weights are <strong>split across RPC workers</strong>. Each worker holds a portion of the model layers in its memory (GPU or CPU).
The LocalAI instance orchestrates inference by communicating with all workers via RPC.
</p>
</div>
<div style={{
background: 'var(--color-accent-light)', border: '1px solid rgba(139,92,246,0.3)',
borderRadius: 'var(--radius-md)', padding: 'var(--spacing-sm) var(--spacing-md)',
fontSize: '0.8125rem', color: 'var(--color-text-secondary)', marginBottom: 'var(--spacing-md)',
}}>
<i className="fas fa-info-circle" style={{ color: 'var(--color-accent)', marginRight: 6 }} />
<strong>Different from federation:</strong> Federation distributes whole requests across instances. Model sharding splits a single model's weights across machines for joint inference. Currently only supported with <strong>llama.cpp</strong> based models.
</div>
{/* Status + nodes */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 'var(--spacing-md)' }}>
<h3 style={{ fontSize: '1rem', fontWeight: 700 }}>Connected Workers</h3>
<div style={{ fontSize: '1.25rem', fontWeight: 700 }}>
<span style={{ color: wrkOnline > 0 ? 'var(--color-success)' : 'var(--color-error)' }}>{wrkOnline}</span>
<span style={{ color: 'var(--color-text-secondary)', fontSize: '1rem' }}>/{wrkTotal}</span>
</div>
</div>
{workers.length === 0 ? (
<div style={{
textAlign: 'center', padding: 'var(--spacing-lg)',
background: 'var(--color-bg-primary)', border: '1px solid var(--color-border-subtle)',
borderRadius: 'var(--radius-lg)',
}}>
<i className="fas fa-puzzle-piece" style={{ fontSize: '2rem', color: 'var(--color-text-muted)', marginBottom: 'var(--spacing-sm)' }} />
<p style={{ fontWeight: 500, color: 'var(--color-text-secondary)' }}>No workers available</p>
<p style={{ fontSize: '0.8125rem', color: 'var(--color-text-muted)', marginTop: 'var(--spacing-xs)' }}>Start workers to see them here</p>
</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))', gap: 'var(--spacing-md)' }}>
{workers.map((node, i) => (
<NodeCard key={node.id || i} node={node} label="Worker" iconColor="var(--color-accent)" iconBg="var(--color-accent-light)" />
))}
</div>
)}
</div>
{/* Setup Guide */}
<div style={{ padding: 'var(--spacing-lg)' }}>
<h3 style={{ fontSize: '1.125rem', fontWeight: 700, marginBottom: 'var(--spacing-md)' }}>
<i className="fas fa-book" style={{ color: 'var(--color-accent)', marginRight: 'var(--spacing-sm)' }} />
Start a llama.cpp RPC Worker
</h3>
<div style={{
background: 'var(--color-bg-primary)', borderRadius: 'var(--radius-lg)',
border: '1px solid var(--color-border-subtle)', padding: 'var(--spacing-lg)',
}}>
<p style={{ color: 'var(--color-text-secondary)', fontSize: '0.875rem', marginBottom: 'var(--spacing-sm)' }}>
Each worker exposes its GPU/CPU memory as a shard for distributed model inference.
</p>
<CommandBlock
command={`docker run -ti --net host \\\n -e TOKEN="${token}" \\\n --name local-ai-worker \\\n localai/localai:latest-cpu worker p2p-llama-cpp-rpc`}
addToast={addToast}
/>
<p style={{ color: 'var(--color-text-muted)', fontSize: '0.8125rem', marginTop: 'var(--spacing-sm)' }}>
Run this on each machine you want to contribute as a shard. The worker will automatically join the network and advertise its resources.
</p>
<p style={{ color: 'var(--color-text-secondary)', fontSize: '0.875rem', marginTop: 'var(--spacing-lg)' }}>
For GPU images and all available options, see the{' '}
<a href="https://localai.io/basics/container/" target="_blank" rel="noopener noreferrer"
style={{ color: 'var(--color-accent)' }}>Container images</a>
{' '}and{' '}
<a href="https://localai.io/features/distribute/#starting-workers" target="_blank" rel="noopener noreferrer"
style={{ color: 'var(--color-accent)' }}>Worker</a> docs.
</p>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,468 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { useOutletContext } from 'react-router-dom'
import { settingsApi, resourcesApi } from '../utils/api'
import LoadingSpinner from '../components/LoadingSpinner'
import { formatBytes, percentColor } from '../utils/format'
function Toggle({ checked, onChange, disabled }) {
return (
<label style={{
position: 'relative', display: 'inline-block', width: 40, height: 22, cursor: disabled ? 'not-allowed' : 'pointer',
opacity: disabled ? 0.5 : 1,
}}>
<input
type="checkbox"
checked={checked || false}
onChange={(e) => onChange(e.target.checked)}
disabled={disabled}
style={{ display: 'none' }}
/>
<span style={{
position: 'absolute', inset: 0, borderRadius: 22,
background: checked ? 'var(--color-primary)' : 'var(--color-toggle-off)',
transition: 'background 200ms',
}}>
<span style={{
position: 'absolute', top: 2, left: checked ? 20 : 2,
width: 18, height: 18, borderRadius: '50%',
background: '#fff', transition: 'left 200ms',
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.2)',
}} />
</span>
</label>
)
}
function SettingRow({ label, description, children }) {
return (
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: 'var(--spacing-sm) 0',
borderBottom: '1px solid var(--color-border-subtle)',
}}>
<div style={{ flex: 1, marginRight: 'var(--spacing-md)' }}>
<div style={{ fontSize: '0.875rem', fontWeight: 500 }}>{label}</div>
{description && <div style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)', marginTop: 2 }}>{description}</div>}
</div>
<div style={{ flexShrink: 0 }}>{children}</div>
</div>
)
}
const SECTIONS = [
{ id: 'watchdog', icon: 'fa-shield-halved', color: 'var(--color-primary)', label: 'Watchdog' },
{ id: 'memory', icon: 'fa-memory', color: 'var(--color-accent)', label: 'Memory' },
{ id: 'backends', icon: 'fa-cogs', color: 'var(--color-accent)', label: 'Backends' },
{ id: 'performance', icon: 'fa-gauge-high', color: 'var(--color-success)', label: 'Performance' },
{ id: 'api', icon: 'fa-globe', color: 'var(--color-warning)', label: 'API & CORS' },
{ id: 'p2p', icon: 'fa-network-wired', color: 'var(--color-accent)', label: 'P2P' },
{ id: 'galleries', icon: 'fa-images', color: 'var(--color-accent)', label: 'Galleries' },
{ id: 'apikeys', icon: 'fa-key', color: 'var(--color-error)', label: 'API Keys' },
{ id: 'agents', icon: 'fa-tasks', color: 'var(--color-primary)', label: 'Agent Jobs' },
{ id: 'responses', icon: 'fa-database', color: 'var(--color-accent)', label: 'Responses' },
]
export default function Settings() {
const { addToast } = useOutletContext()
const [settings, setSettings] = useState(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [resources, setResources] = useState(null)
const [activeSection, setActiveSection] = useState('watchdog')
const contentRef = useRef(null)
const sectionRefs = useRef({})
useEffect(() => { fetchSettings() }, [])
const fetchSettings = async () => {
try {
const data = await settingsApi.get()
setSettings(data)
} catch (err) {
addToast(`Failed to load settings: ${err.message}`, 'error')
} finally {
setLoading(false)
}
}
const fetchResources = useCallback(async () => {
try {
const data = await resourcesApi.get()
setResources(data)
} catch (_e) { /* ignore */ }
}, [])
const handleSave = async () => {
setSaving(true)
try {
await settingsApi.save(settings)
addToast('Settings saved successfully', 'success')
} catch (err) {
addToast(`Save failed: ${err.message}`, 'error')
} finally {
setSaving(false)
}
}
const update = (key, value) => {
setSettings(prev => ({ ...prev, [key]: value }))
}
const scrollTo = (id) => {
setActiveSection(id)
sectionRefs.current[id]?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
// Track which section is visible on scroll
useEffect(() => {
const container = contentRef.current
if (!container) return
const onScroll = () => {
const containerTop = container.getBoundingClientRect().top
let closest = SECTIONS[0].id
let closestDist = Infinity
for (const s of SECTIONS) {
const el = sectionRefs.current[s.id]
if (el) {
const dist = Math.abs(el.getBoundingClientRect().top - containerTop - 8)
if (dist < closestDist) { closestDist = dist; closest = s.id }
}
}
setActiveSection(closest)
}
container.addEventListener('scroll', onScroll, { passive: true })
return () => container.removeEventListener('scroll', onScroll)
}, [loading])
if (loading) return <div className="page" style={{ display: 'flex', justifyContent: 'center', padding: 'var(--spacing-xl)' }}><LoadingSpinner size="lg" /></div>
if (!settings) return <div className="page"><div className="empty-state"><p className="empty-state-text">Settings not available</p></div></div>
const watchdogEnabled = settings.watchdog_idle || settings.watchdog_busy
return (
<div className="page" style={{ maxWidth: 1000, padding: 0 }}>
{/* Header */}
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: 'var(--spacing-lg) var(--spacing-lg) var(--spacing-md)',
}}>
<div>
<h1 className="page-title">Settings</h1>
<p className="page-subtitle">Configure LocalAI runtime settings</p>
</div>
<button className="btn btn-primary" onClick={handleSave} disabled={saving}>
{saving ? <><LoadingSpinner size="sm" /> Saving...</> : <><i className="fas fa-save" /> Save</>}
</button>
</div>
{/* Two-column layout */}
<div style={{ display: 'flex', gap: 0, minHeight: 'calc(100vh - 180px)' }}>
{/* Sidebar nav */}
<nav style={{
width: 180, flexShrink: 0, padding: '0 var(--spacing-sm)',
position: 'sticky', top: 0, alignSelf: 'flex-start',
}}>
{SECTIONS.map(s => (
<button
key={s.id}
onClick={() => scrollTo(s.id)}
style={{
display: 'flex', alignItems: 'center', gap: 'var(--spacing-sm)',
width: '100%', padding: '8px 12px',
background: activeSection === s.id ? 'var(--color-primary-light)' : 'transparent',
border: 'none', borderRadius: 'var(--radius-md)', cursor: 'pointer',
color: activeSection === s.id ? 'var(--color-primary)' : 'var(--color-text-secondary)',
fontSize: '0.8125rem', fontWeight: activeSection === s.id ? 600 : 400,
textAlign: 'left', transition: 'all 150ms',
marginBottom: 2,
borderLeft: activeSection === s.id ? '2px solid var(--color-primary)' : '2px solid transparent',
}}
>
<i className={`fas ${s.icon}`} style={{
width: 16, textAlign: 'center', fontSize: '0.75rem',
color: activeSection === s.id ? s.color : 'var(--color-text-muted)',
}} />
{s.label}
</button>
))}
</nav>
{/* Content area */}
<div
ref={contentRef}
style={{
flex: 1, overflow: 'auto', padding: '0 var(--spacing-lg) var(--spacing-xl) var(--spacing-md)',
maxHeight: 'calc(100vh - 180px)',
}}
>
{/* Watchdog */}
<div ref={el => sectionRefs.current.watchdog = el} style={{ marginBottom: 'var(--spacing-xl)' }}>
<h3 style={{ fontSize: '1rem', fontWeight: 700, display: 'flex', alignItems: 'center', gap: 'var(--spacing-sm)', marginBottom: 'var(--spacing-md)' }}>
<i className="fas fa-shield-halved" style={{ color: 'var(--color-primary)' }} /> Watchdog
</h3>
<div className="card">
<SettingRow label="Enable Watchdog" description="Automatically monitor and manage backend processes">
<Toggle checked={settings.watchdog_idle || settings.watchdog_busy} onChange={(v) => { update('watchdog_idle', v); update('watchdog_busy', v) }} />
</SettingRow>
<SettingRow label="Enable Idle Check" description="Automatically stop backends that have been idle too long">
<Toggle checked={settings.watchdog_idle} onChange={(v) => update('watchdog_idle', v)} disabled={!watchdogEnabled} />
</SettingRow>
<SettingRow label="Idle Timeout" description="Time before an idle backend is stopped (e.g. 15m, 1h)">
<input className="input" style={{ width: 120 }} value={settings.watchdog_idle_timeout || ''} onChange={(e) => update('watchdog_idle_timeout', e.target.value)} placeholder="15m" disabled={!settings.watchdog_idle} />
</SettingRow>
<SettingRow label="Enable Busy Check" description="Stop stuck/busy processes that exceed timeout">
<Toggle checked={settings.watchdog_busy} onChange={(v) => update('watchdog_busy', v)} disabled={!watchdogEnabled} />
</SettingRow>
<SettingRow label="Busy Timeout" description="Time before a busy backend is stopped (e.g. 5m)">
<input className="input" style={{ width: 120 }} value={settings.watchdog_busy_timeout || ''} onChange={(e) => update('watchdog_busy_timeout', e.target.value)} placeholder="5m" disabled={!settings.watchdog_busy} />
</SettingRow>
<SettingRow label="Check Interval" description="How often the watchdog checks backends (e.g. 2s)">
<input className="input" style={{ width: 120 }} value={settings.watchdog_check_interval || ''} onChange={(e) => update('watchdog_check_interval', e.target.value)} placeholder="2s" />
</SettingRow>
<SettingRow label="Force Eviction When Busy" description="Allow model eviction even during active API calls">
<Toggle checked={settings.force_eviction} onChange={(v) => update('force_eviction', v)} />
</SettingRow>
<SettingRow label="LRU Eviction Max Retries" description="Maximum retries waiting for busy models before eviction">
<input className="input" type="number" style={{ width: 120 }} value={settings.lru_retries ?? ''} onChange={(e) => update('lru_retries', parseInt(e.target.value) || 0)} placeholder="30" />
</SettingRow>
<SettingRow label="LRU Eviction Retry Interval" description="Wait between eviction retries (e.g. 1s)">
<input className="input" style={{ width: 120 }} value={settings.lru_retry_interval || ''} onChange={(e) => update('lru_retry_interval', e.target.value)} placeholder="1s" />
</SettingRow>
</div>
</div>
{/* Memory Reclaimer */}
<div ref={el => sectionRefs.current.memory = el} style={{ marginBottom: 'var(--spacing-xl)' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 'var(--spacing-md)' }}>
<h3 style={{ fontSize: '1rem', fontWeight: 700, display: 'flex', alignItems: 'center', gap: 'var(--spacing-sm)' }}>
<i className="fas fa-memory" style={{ color: 'var(--color-accent)' }} /> Memory Reclaimer
</h3>
<button className="btn btn-secondary btn-sm" onClick={fetchResources} title="Refresh resource status">
<i className="fas fa-sync-alt" />
</button>
</div>
<div className="card">
{resources && (
<div style={{
background: 'var(--color-bg-tertiary)', borderRadius: 'var(--radius-md)',
padding: 'var(--spacing-sm)', marginBottom: 'var(--spacing-sm)', fontSize: '0.75rem',
}}>
{resources.gpus?.length > 0 ? resources.gpus.map((gpu, i) => {
const usedPct = gpu.total > 0 ? Math.round((gpu.used / gpu.total) * 100) : 0
return (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-xs)', marginBottom: i < resources.gpus.length - 1 ? 4 : 0 }}>
<span style={{ color: 'var(--color-text-muted)', minWidth: 60 }}>GPU {i}</span>
<div style={{ flex: 1, height: 6, background: 'var(--color-bg-primary)', borderRadius: 3, overflow: 'hidden' }}>
<div style={{ width: `${usedPct}%`, height: '100%', background: percentColor(usedPct), borderRadius: 3 }} />
</div>
<span style={{ color: percentColor(usedPct), minWidth: 40, textAlign: 'right' }}>{usedPct}%</span>
<span style={{ color: 'var(--color-text-muted)' }}>{formatBytes(gpu.used)} / {formatBytes(gpu.total)}</span>
</div>
)
}) : (
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-xs)' }}>
<span style={{ color: 'var(--color-text-muted)', minWidth: 60 }}>RAM</span>
{resources.ram && (() => {
const usedPct = resources.ram.total > 0 ? Math.round((resources.ram.used / resources.ram.total) * 100) : 0
return (
<>
<div style={{ flex: 1, height: 6, background: 'var(--color-bg-primary)', borderRadius: 3, overflow: 'hidden' }}>
<div style={{ width: `${usedPct}%`, height: '100%', background: percentColor(usedPct), borderRadius: 3 }} />
</div>
<span style={{ color: percentColor(usedPct), minWidth: 40, textAlign: 'right' }}>{usedPct}%</span>
<span style={{ color: 'var(--color-text-muted)' }}>{formatBytes(resources.ram.used)} / {formatBytes(resources.ram.total)}</span>
</>
)
})()}
</div>
)}
</div>
)}
<SettingRow label="Enable Memory Reclaimer" description="Evict backends when memory usage exceeds threshold">
<Toggle checked={settings.memory_reclaimer} onChange={(v) => update('memory_reclaimer', v)} />
</SettingRow>
<SettingRow label="Memory Threshold (%)" description="Eviction triggers when usage exceeds this percentage">
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-sm)' }}>
<input type="range" min="50" max="100" value={settings.memory_threshold || 80} onChange={(e) => update('memory_threshold', parseInt(e.target.value))} disabled={!settings.memory_reclaimer} style={{ width: 120 }} />
<span style={{ fontSize: '0.875rem', fontWeight: 600, minWidth: 40, textAlign: 'right', color: percentColor(settings.memory_threshold || 80) }}>
{settings.memory_threshold || 80}%
</span>
</div>
</SettingRow>
</div>
</div>
{/* Backends */}
<div ref={el => sectionRefs.current.backends = el} style={{ marginBottom: 'var(--spacing-xl)' }}>
<h3 style={{ fontSize: '1rem', fontWeight: 700, display: 'flex', alignItems: 'center', gap: 'var(--spacing-sm)', marginBottom: 'var(--spacing-md)' }}>
<i className="fas fa-cogs" style={{ color: 'var(--color-accent)' }} /> Backend Management
</h3>
<div className="card">
<SettingRow label="Max Active Backends" description="Maximum models to keep loaded simultaneously (0 = unlimited)">
<input className="input" type="number" style={{ width: 120 }} value={settings.max_active_backends ?? ''} onChange={(e) => update('max_active_backends', parseInt(e.target.value) || 0)} placeholder="0" />
</SettingRow>
<SettingRow label="Parallel Backend Requests" description="Enable parallel request handling per backend">
<Toggle checked={settings.parallel_backend_requests} onChange={(v) => update('parallel_backend_requests', v)} />
</SettingRow>
</div>
</div>
{/* Performance */}
<div ref={el => sectionRefs.current.performance = el} style={{ marginBottom: 'var(--spacing-xl)' }}>
<h3 style={{ fontSize: '1rem', fontWeight: 700, display: 'flex', alignItems: 'center', gap: 'var(--spacing-sm)', marginBottom: 'var(--spacing-md)' }}>
<i className="fas fa-gauge-high" style={{ color: 'var(--color-success)' }} /> Performance
</h3>
<div className="card">
<SettingRow label="Default Threads" description="CPU threads for inference (0 = auto-detect)">
<input className="input" type="number" style={{ width: 120 }} value={settings.threads ?? ''} onChange={(e) => update('threads', parseInt(e.target.value) || 0)} placeholder="0" />
</SettingRow>
<SettingRow label="Default Context Size" description="Default context window size for models">
<input className="input" type="number" style={{ width: 120 }} value={settings.context_size ?? ''} onChange={(e) => update('context_size', parseInt(e.target.value) || 0)} placeholder="2048" />
</SettingRow>
<SettingRow label="F16 Precision" description="Use 16-bit floating point for reduced memory usage">
<Toggle checked={settings.f16} onChange={(v) => update('f16', v)} />
</SettingRow>
<SettingRow label="Debug Mode" description="Enable verbose debug logging">
<Toggle checked={settings.debug} onChange={(v) => update('debug', v)} />
</SettingRow>
<SettingRow label="Enable Tracing" description="Enable request/response tracing for debugging">
<Toggle checked={settings.enable_tracing} onChange={(v) => update('enable_tracing', v)} />
</SettingRow>
<SettingRow label="Tracing Max Items" description="Maximum number of trace items to retain">
<input className="input" type="number" style={{ width: 120 }} value={settings.tracing_max_items ?? ''} onChange={(e) => update('tracing_max_items', parseInt(e.target.value) || 0)} placeholder="100" disabled={!settings.enable_tracing} />
</SettingRow>
</div>
</div>
{/* API & CORS */}
<div ref={el => sectionRefs.current.api = el} style={{ marginBottom: 'var(--spacing-xl)' }}>
<h3 style={{ fontSize: '1rem', fontWeight: 700, display: 'flex', alignItems: 'center', gap: 'var(--spacing-sm)', marginBottom: 'var(--spacing-md)' }}>
<i className="fas fa-globe" style={{ color: 'var(--color-warning)' }} /> API & CORS
</h3>
<div className="card">
<SettingRow label="Enable CORS" description="Enable Cross-Origin Resource Sharing">
<Toggle checked={settings.cors} onChange={(v) => update('cors', v)} />
</SettingRow>
<SettingRow label="CORS Allow Origins" description="Comma-separated list of allowed origins">
<input className="input" style={{ width: 200 }} value={settings.cors_allow_origins || ''} onChange={(e) => update('cors_allow_origins', e.target.value)} placeholder="*" disabled={!settings.cors} />
</SettingRow>
<SettingRow label="Enable CSRF Protection" description="Enable Cross-Site Request Forgery protection">
<Toggle checked={settings.csrf} onChange={(v) => update('csrf', v)} />
</SettingRow>
</div>
</div>
{/* P2P */}
<div ref={el => sectionRefs.current.p2p = el} style={{ marginBottom: 'var(--spacing-xl)' }}>
<h3 style={{ fontSize: '1rem', fontWeight: 700, display: 'flex', alignItems: 'center', gap: 'var(--spacing-sm)', marginBottom: 'var(--spacing-md)' }}>
<i className="fas fa-network-wired" style={{ color: 'var(--color-accent)' }} /> P2P Network
</h3>
<div className="card">
<SettingRow label="P2P Token" description="Generate a new token or paste an existing one to join a network">
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-xs)' }}>
<input className="input" style={{ width: 200 }} value={settings.p2p_token || ''} onChange={(e) => update('p2p_token', e.target.value)} placeholder="No token set" />
<button className="btn btn-primary btn-sm" onClick={() => update('p2p_token', '0')} title="Generate a new P2P token (applied on save)">
<i className="fas fa-rotate" /> Generate
</button>
{settings.p2p_token && (
<button className="btn btn-secondary btn-sm" onClick={() => update('p2p_token', '')} title="Clear token (disables P2P on save)" style={{ color: 'var(--color-error)' }}>
<i className="fas fa-times" />
</button>
)}
</div>
</SettingRow>
<SettingRow label="P2P Network ID" description="Network identifier for grouping instances">
<input className="input" style={{ width: 200 }} value={settings.p2p_network_id || ''} onChange={(e) => update('p2p_network_id', e.target.value)} placeholder="Network ID" />
</SettingRow>
<SettingRow label="Federated Mode" description="Enable federated instance mode for load balancing">
<Toggle checked={settings.federated} onChange={(v) => update('federated', v)} />
</SettingRow>
</div>
</div>
{/* Galleries */}
<div ref={el => sectionRefs.current.galleries = el} style={{ marginBottom: 'var(--spacing-xl)' }}>
<h3 style={{ fontSize: '1rem', fontWeight: 700, display: 'flex', alignItems: 'center', gap: 'var(--spacing-sm)', marginBottom: 'var(--spacing-md)' }}>
<i className="fas fa-images" style={{ color: 'var(--color-accent)' }} /> Galleries
</h3>
<div className="card">
<SettingRow label="Autoload Galleries" description="Automatically load model galleries on startup">
<Toggle checked={settings.autoload_galleries} onChange={(v) => update('autoload_galleries', v)} />
</SettingRow>
<SettingRow label="Autoload Backend Galleries" description="Automatically load backend galleries on startup">
<Toggle checked={settings.autoload_backend_galleries} onChange={(v) => update('autoload_backend_galleries', v)} />
</SettingRow>
<div style={{ marginTop: 'var(--spacing-sm)' }}>
<label className="form-label">Model Galleries (JSON)</label>
<textarea
className="textarea"
value={settings.galleries_json || (settings.galleries ? JSON.stringify(settings.galleries, null, 2) : '')}
onChange={(e) => update('galleries_json', e.target.value)}
rows={4}
placeholder={'[\n { "url": "https://...", "name": "my-gallery" }\n]'}
style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.8125rem' }}
/>
</div>
<div style={{ marginTop: 'var(--spacing-sm)' }}>
<label className="form-label">Backend Galleries (JSON)</label>
<textarea
className="textarea"
value={settings.backend_galleries_json || (settings.backend_galleries ? JSON.stringify(settings.backend_galleries, null, 2) : '')}
onChange={(e) => update('backend_galleries_json', e.target.value)}
rows={4}
placeholder={'[\n { "url": "https://...", "name": "my-backends" }\n]'}
style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.8125rem' }}
/>
</div>
</div>
</div>
{/* API Keys */}
<div ref={el => sectionRefs.current.apikeys = el} style={{ marginBottom: 'var(--spacing-xl)' }}>
<h3 style={{ fontSize: '1rem', fontWeight: 700, display: 'flex', alignItems: 'center', gap: 'var(--spacing-sm)', marginBottom: 'var(--spacing-md)' }}>
<i className="fas fa-key" style={{ color: 'var(--color-error)' }} /> API Keys
</h3>
<div className="card">
<div style={{ fontSize: '0.8125rem', color: 'var(--color-text-muted)', marginBottom: 'var(--spacing-sm)' }}>
<i className="fas fa-exclamation-triangle" style={{ color: 'var(--color-warning)', marginRight: 'var(--spacing-xs)' }} />
API keys are sensitive. One key per line or comma-separated.
</div>
<textarea
className="textarea"
value={settings.api_keys?.join('\n') || (typeof settings.api_keys_text === 'string' ? settings.api_keys_text : '')}
onChange={(e) => update('api_keys_text', e.target.value)}
rows={4}
placeholder="sk-key-1&#10;sk-key-2"
style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.8125rem' }}
/>
</div>
</div>
{/* Agent Jobs */}
<div ref={el => sectionRefs.current.agents = el} style={{ marginBottom: 'var(--spacing-xl)' }}>
<h3 style={{ fontSize: '1rem', fontWeight: 700, display: 'flex', alignItems: 'center', gap: 'var(--spacing-sm)', marginBottom: 'var(--spacing-md)' }}>
<i className="fas fa-tasks" style={{ color: 'var(--color-primary)' }} /> Agent Jobs
</h3>
<div className="card">
<SettingRow label="Job Retention Days" description="Number of days to keep job history">
<input className="input" type="number" style={{ width: 120 }} value={settings.agent_job_retention_days ?? ''} onChange={(e) => update('agent_job_retention_days', parseInt(e.target.value) || 0)} placeholder="30" />
</SettingRow>
</div>
</div>
{/* Open Responses */}
<div ref={el => sectionRefs.current.responses = el} style={{ marginBottom: 'var(--spacing-xl)' }}>
<h3 style={{ fontSize: '1rem', fontWeight: 700, display: 'flex', alignItems: 'center', gap: 'var(--spacing-sm)', marginBottom: 'var(--spacing-md)' }}>
<i className="fas fa-database" style={{ color: 'var(--color-accent)' }} /> Open Responses
</h3>
<div className="card">
<SettingRow label="Response Store TTL" description="Time-to-live for stored responses (e.g. 1h, 30m, 0 = no expiration)">
<input className="input" style={{ width: 120 }} value={settings.open_responses_store_ttl || ''} onChange={(e) => update('open_responses_store_ttl', e.target.value)} placeholder="1h" />
</SettingRow>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,152 @@
import { useState, useRef } from 'react'
import { useParams, useOutletContext } from 'react-router-dom'
import ModelSelector from '../components/ModelSelector'
import LoadingSpinner from '../components/LoadingSpinner'
import { soundApi } from '../utils/api'
export default function Sound() {
const { model: urlModel } = useParams()
const { addToast } = useOutletContext()
const [model, setModel] = useState(urlModel || '')
const [mode, setMode] = useState('simple')
const [text, setText] = useState('')
const [instrumental, setInstrumental] = useState(false)
const [vocalLanguage, setVocalLanguage] = useState('')
const [caption, setCaption] = useState('')
const [lyrics, setLyrics] = useState('')
const [think, setThink] = useState(false)
const [bpm, setBpm] = useState('')
const [duration, setDuration] = useState('')
const [keyscale, setKeyscale] = useState('')
const [language, setLanguage] = useState('')
const [timesignature, setTimesignature] = useState('')
const [loading, setLoading] = useState(false)
const [audioUrl, setAudioUrl] = useState(null)
const audioRef = useRef(null)
const handleGenerate = async (e) => {
e.preventDefault()
if (!model) { addToast('Please select a model', 'warning'); return }
const body = { model_id: model }
if (mode === 'simple') {
if (!text.trim()) { addToast('Please enter a description', 'warning'); return }
body.text = text.trim()
body.instrumental = instrumental
if (vocalLanguage.trim()) body.vocal_language = vocalLanguage.trim()
} else {
if (!caption.trim() && !lyrics.trim()) { addToast('Please enter caption or lyrics', 'warning'); return }
if (caption.trim()) body.caption = caption.trim()
if (lyrics.trim()) body.lyrics = lyrics.trim()
body.think = think
if (bpm) body.bpm = parseInt(bpm)
if (duration) body.duration_seconds = parseFloat(duration)
if (keyscale.trim()) body.keyscale = keyscale.trim()
if (language.trim()) body.language = language.trim()
if (timesignature.trim()) body.timesignature = timesignature.trim()
}
setLoading(true)
setAudioUrl(null)
try {
const blob = await soundApi.generate(body)
const url = URL.createObjectURL(blob)
setAudioUrl(url)
addToast('Sound generated', 'success')
setTimeout(() => audioRef.current?.play().catch(() => {}), 100)
} catch (err) {
addToast(`Error: ${err.message}`, 'error')
} finally {
setLoading(false)
}
}
return (
<div className="media-layout">
<div className="media-controls">
<div className="page-header">
<h1 className="page-title"><i className="fas fa-music" style={{ marginRight: 8, color: 'var(--color-accent)' }} />Sound Generation</h1>
</div>
<form onSubmit={handleGenerate}>
<div className="form-group">
<label className="form-label">Model</label>
<ModelSelector value={model} onChange={setModel} capability="FLAG_SOUND_GENERATION" />
</div>
{/* Mode toggle */}
<div className="form-group">
<div style={{ display: 'flex', gap: 'var(--spacing-sm)' }}>
<button type="button" className={`filter-btn ${mode === 'simple' ? 'active' : ''}`} onClick={() => setMode('simple')}>Simple</button>
<button type="button" className={`filter-btn ${mode === 'advanced' ? 'active' : ''}`} onClick={() => setMode('advanced')}>Advanced</button>
</div>
</div>
{mode === 'simple' ? (
<>
<div className="form-group">
<label className="form-label">Description</label>
<textarea className="textarea" value={text} onChange={(e) => setText(e.target.value)} placeholder="Describe the sound..." rows={3} />
</div>
<div style={{ display: 'flex', gap: 'var(--spacing-md)', alignItems: 'center', marginBottom: 'var(--spacing-md)' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '0.875rem', color: 'var(--color-text-secondary)' }}>
<input type="checkbox" checked={instrumental} onChange={(e) => setInstrumental(e.target.checked)} /> Instrumental
</label>
<div className="form-group" style={{ flex: 1, margin: 0 }}>
<input className="input" value={vocalLanguage} onChange={(e) => setVocalLanguage(e.target.value)} placeholder="Vocal language" />
</div>
</div>
</>
) : (
<>
<div className="form-group">
<label className="form-label">Caption</label>
<textarea className="textarea" value={caption} onChange={(e) => setCaption(e.target.value)} rows={2} />
</div>
<div className="form-group">
<label className="form-label">Lyrics</label>
<textarea className="textarea" value={lyrics} onChange={(e) => setLyrics(e.target.value)} rows={3} />
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--spacing-sm)' }}>
<div className="form-group"><label className="form-label">BPM</label><input className="input" type="number" value={bpm} onChange={(e) => setBpm(e.target.value)} /></div>
<div className="form-group"><label className="form-label">Duration (s)</label><input className="input" type="number" step="0.1" value={duration} onChange={(e) => setDuration(e.target.value)} /></div>
<div className="form-group"><label className="form-label">Key/Scale</label><input className="input" value={keyscale} onChange={(e) => setKeyscale(e.target.value)} /></div>
<div className="form-group"><label className="form-label">Language</label><input className="input" value={language} onChange={(e) => setLanguage(e.target.value)} /></div>
<div className="form-group"><label className="form-label">Time Signature</label><input className="input" value={timesignature} onChange={(e) => setTimesignature(e.target.value)} /></div>
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '0.875rem', color: 'var(--color-text-secondary)', marginBottom: 'var(--spacing-md)' }}>
<input type="checkbox" checked={think} onChange={(e) => setThink(e.target.checked)} /> Think mode
</label>
</>
)}
<button type="submit" className="btn btn-primary" disabled={loading} style={{ width: '100%' }}>
{loading ? <><LoadingSpinner size="sm" /> Generating...</> : <><i className="fas fa-music" /> Generate Sound</>}
</button>
</form>
</div>
<div className="media-preview">
<div className="media-result">
{loading ? (
<LoadingSpinner size="lg" />
) : audioUrl ? (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 'var(--spacing-md)', width: '100%' }}>
<audio ref={audioRef} controls src={audioUrl} style={{ width: '100%', maxWidth: '400px' }} />
<a href={audioUrl} download={`sound-${new Date().toISOString().slice(0, 10)}.wav`} className="btn btn-primary btn-sm">
<i className="fas fa-download" /> Download
</a>
</div>
) : (
<div style={{ textAlign: 'center', color: 'var(--color-text-muted)' }}>
<i className="fas fa-music" style={{ fontSize: '3rem', marginBottom: 'var(--spacing-md)', opacity: 0.4 }} />
<p>Generated sound will appear here</p>
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,95 @@
import { useState, useRef } from 'react'
import { useParams, useOutletContext } from 'react-router-dom'
import ModelSelector from '../components/ModelSelector'
import LoadingSpinner from '../components/LoadingSpinner'
import { ttsApi } from '../utils/api'
export default function TTS() {
const { model: urlModel } = useParams()
const { addToast } = useOutletContext()
const [model, setModel] = useState(urlModel || '')
const [text, setText] = useState('')
const [loading, setLoading] = useState(false)
const [audioUrl, setAudioUrl] = useState(null)
const audioRef = useRef(null)
const handleGenerate = async (e) => {
e.preventDefault()
if (!text.trim()) { addToast('Please enter text', 'warning'); return }
if (!model) { addToast('Please select a model', 'warning'); return }
setLoading(true)
setAudioUrl(null)
try {
const blob = await ttsApi.generate({ model, input: text.trim() })
const url = URL.createObjectURL(blob)
setAudioUrl(url)
addToast('Audio generated', 'success')
// Auto-play
setTimeout(() => audioRef.current?.play(), 100)
} catch (err) {
addToast(`Error: ${err.message}`, 'error')
} finally {
setLoading(false)
}
}
return (
<div className="media-layout">
<div className="media-controls">
<div className="page-header">
<h1 className="page-title"><i className="fas fa-headphones" style={{ marginRight: 8, color: 'var(--color-accent)' }} />Text to Speech</h1>
</div>
<form onSubmit={handleGenerate}>
<div className="form-group">
<label className="form-label">Model</label>
<ModelSelector value={model} onChange={setModel} capability="FLAG_TTS" />
</div>
<div className="form-group">
<label className="form-label">Text</label>
<textarea
className="textarea"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Enter text to convert to speech..."
rows={5}
/>
</div>
<button type="submit" className="btn btn-primary" disabled={loading} style={{ width: '100%' }}>
{loading ? <><LoadingSpinner size="sm" /> Generating...</> : <><i className="fas fa-headphones" /> Generate Audio</>}
</button>
</form>
</div>
<div className="media-preview">
<div className="media-result">
{loading ? (
<LoadingSpinner size="lg" />
) : audioUrl ? (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 'var(--spacing-md)', width: '100%' }}>
<audio ref={audioRef} controls src={audioUrl} style={{ width: '100%' }} />
<div style={{ display: 'flex', gap: 'var(--spacing-sm)' }}>
<a href={audioUrl} download={`tts-${model}-${new Date().toISOString().slice(0, 10)}.mp3`} className="btn btn-primary btn-sm">
<i className="fas fa-download" /> Download
</a>
<button className="btn btn-secondary btn-sm" onClick={() => audioRef.current?.play()}>
<i className="fas fa-rotate-right" /> Replay
</button>
</div>
<div style={{ padding: 'var(--spacing-sm)', background: 'var(--color-bg-tertiary)', borderRadius: 'var(--radius-md)', color: 'var(--color-text-secondary)', fontStyle: 'italic', textAlign: 'center' }}>
"{text}"
</div>
</div>
) : (
<div style={{ textAlign: 'center', color: 'var(--color-text-muted)' }}>
<i className="fas fa-headphones" style={{ fontSize: '3rem', marginBottom: 'var(--spacing-md)', opacity: 0.4 }} />
<p>Generated audio will appear here</p>
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,200 @@
import { useState, useRef, useCallback } from 'react'
import { useOutletContext } from 'react-router-dom'
import ModelSelector from '../components/ModelSelector'
import LoadingSpinner from '../components/LoadingSpinner'
import { chatApi, ttsApi, audioApi } from '../utils/api'
export default function Talk() {
const { addToast } = useOutletContext()
const [llmModel, setLlmModel] = useState('')
const [whisperModel, setWhisperModel] = useState('')
const [ttsModel, setTtsModel] = useState('')
const [isRecording, setIsRecording] = useState(false)
const [loading, setLoading] = useState(false)
const [status, setStatus] = useState('Press the record button to start talking.')
const [audioUrl, setAudioUrl] = useState(null)
const [conversationHistory, setConversationHistory] = useState([])
const mediaRecorderRef = useRef(null)
const chunksRef = useRef([])
const audioRef = useRef(null)
const startRecording = async () => {
if (!navigator.mediaDevices) {
addToast('MediaDevices API not supported', 'error')
return
}
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
const recorder = new MediaRecorder(stream)
chunksRef.current = []
recorder.ondataavailable = (e) => chunksRef.current.push(e.data)
recorder.start()
mediaRecorderRef.current = recorder
setIsRecording(true)
setStatus('Recording... Click to stop.')
} catch (err) {
addToast(`Microphone error: ${err.message}`, 'error')
}
}
const stopRecording = useCallback(() => {
if (!mediaRecorderRef.current) return
mediaRecorderRef.current.onstop = async () => {
setIsRecording(false)
setLoading(true)
const audioBlob = new Blob(chunksRef.current, { type: 'audio/webm' })
try {
// 1. Transcribe
setStatus('Transcribing audio...')
const formData = new FormData()
formData.append('file', audioBlob)
formData.append('model', whisperModel)
const transcription = await audioApi.transcribe(formData)
const userText = transcription.text
setStatus(`You said: "${userText}". Generating response...`)
// 2. Chat completion
const newHistory = [...conversationHistory, { role: 'user', content: userText }]
const chatResponse = await chatApi.complete({
model: llmModel,
messages: newHistory,
})
const assistantText = chatResponse?.choices?.[0]?.message?.content || ''
const updatedHistory = [...newHistory, { role: 'assistant', content: assistantText }]
setConversationHistory(updatedHistory)
setStatus(`Response: "${assistantText}". Generating speech...`)
// 3. TTS
const ttsBlob = await ttsApi.generateV1({ input: assistantText, model: ttsModel })
const url = URL.createObjectURL(ttsBlob)
setAudioUrl(url)
setStatus('Press the record button to continue.')
// Auto-play
setTimeout(() => audioRef.current?.play(), 100)
} catch (err) {
addToast(`Error: ${err.message}`, 'error')
setStatus('Error occurred. Try again.')
} finally {
setLoading(false)
}
}
mediaRecorderRef.current.stop()
mediaRecorderRef.current.stream?.getTracks().forEach(t => t.stop())
}, [whisperModel, llmModel, ttsModel, conversationHistory])
const resetConversation = () => {
setConversationHistory([])
setAudioUrl(null)
setStatus('Conversation reset. Press record to start.')
addToast('Conversation reset', 'info')
}
const allModelsSet = llmModel && whisperModel && ttsModel
return (
<div className="page" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<div style={{ width: '100%', maxWidth: '40rem' }}>
<div style={{ textAlign: 'center', marginBottom: 'var(--spacing-lg)' }}>
<h1 className="page-title">Talk</h1>
<p className="page-subtitle">Voice conversation with AI</p>
</div>
{/* Main interaction area */}
<div className="card" style={{ padding: 'var(--spacing-lg)', textAlign: 'center', marginBottom: 'var(--spacing-md)' }}>
{/* Big record button */}
<button
onClick={isRecording ? stopRecording : startRecording}
disabled={loading || !allModelsSet}
style={{
width: 96, height: 96, borderRadius: '50%', border: 'none', cursor: loading || !allModelsSet ? 'not-allowed' : 'pointer',
background: isRecording ? 'var(--color-error)' : 'var(--color-primary)',
color: '#fff', fontSize: '2rem', display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
boxShadow: isRecording ? '0 0 0 8px rgba(239,68,68,0.2)' : '0 0 0 8px var(--color-primary-light)',
transition: 'all 200ms', opacity: loading || !allModelsSet ? 0.5 : 1,
margin: '0 auto var(--spacing-md)',
}}
>
<i className={`fas ${isRecording ? 'fa-stop' : 'fa-microphone'}`} />
</button>
{/* Status */}
<p style={{ color: 'var(--color-text-secondary)', fontSize: '0.875rem', marginBottom: 'var(--spacing-md)' }}>
{loading ? <LoadingSpinner size="sm" /> : null}
{' '}{status}
</p>
{/* Recording indicator */}
{isRecording && (
<div style={{
background: 'rgba(239, 68, 68, 0.1)', border: '1px solid rgba(239, 68, 68, 0.3)',
borderRadius: 'var(--radius-md)', padding: 'var(--spacing-xs) var(--spacing-sm)',
display: 'inline-flex', alignItems: 'center', gap: 'var(--spacing-xs)',
color: 'var(--color-error)', fontSize: '0.8125rem', marginBottom: 'var(--spacing-md)',
}}>
<i className="fas fa-circle" style={{ fontSize: '0.5rem', animation: 'pulse 1s infinite' }} />
Recording...
</div>
)}
{/* Audio playback */}
{audioUrl && (
<div style={{ marginTop: 'var(--spacing-sm)' }}>
<audio ref={audioRef} controls src={audioUrl} style={{ width: '100%' }} />
</div>
)}
</div>
{/* Model selectors */}
<div className="card" style={{ padding: 'var(--spacing-md)' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 'var(--spacing-md)' }}>
<h3 style={{ fontSize: '0.875rem', fontWeight: 600, color: 'var(--color-text-secondary)' }}>
<i className="fas fa-sliders-h" style={{ marginRight: 'var(--spacing-xs)' }} /> Models
</h3>
<button className="btn btn-secondary btn-sm" onClick={resetConversation} style={{ fontSize: '0.75rem' }}>
<i className="fas fa-rotate-right" /> Reset
</button>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-sm)' }}>
<div className="form-group" style={{ margin: 0 }}>
<label className="form-label" style={{ fontSize: '0.75rem' }}>
<i className="fas fa-brain" style={{ color: 'var(--color-primary)', marginRight: 4 }} /> LLM
</label>
<ModelSelector value={llmModel} onChange={setLlmModel} capability="FLAG_CHAT" />
</div>
<div className="form-group" style={{ margin: 0 }}>
<label className="form-label" style={{ fontSize: '0.75rem' }}>
<i className="fas fa-ear-listen" style={{ color: 'var(--color-accent)', marginRight: 4 }} /> Speech-to-Text
</label>
<ModelSelector value={whisperModel} onChange={setWhisperModel} capability="FLAG_TRANSCRIPT" />
</div>
<div className="form-group" style={{ margin: 0 }}>
<label className="form-label" style={{ fontSize: '0.75rem' }}>
<i className="fas fa-volume-high" style={{ color: 'var(--color-success)', marginRight: 4 }} /> Text-to-Speech
</label>
<ModelSelector value={ttsModel} onChange={setTtsModel} capability="FLAG_TTS" />
</div>
</div>
{!allModelsSet && (
<div style={{
background: 'var(--color-info-light)', border: '1px solid rgba(56, 189, 248, 0.2)',
borderRadius: 'var(--radius-md)', padding: 'var(--spacing-xs) var(--spacing-sm)',
marginTop: 'var(--spacing-sm)', fontSize: '0.75rem', color: 'var(--color-text-secondary)',
}}>
<i className="fas fa-info-circle" style={{ color: 'var(--color-info)', marginRight: 4 }} />
Select all three models to start talking.
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,167 @@
import React, { useState, useEffect, useCallback } from 'react'
import { useOutletContext } from 'react-router-dom'
import { tracesApi } from '../utils/api'
import LoadingSpinner from '../components/LoadingSpinner'
function formatDuration(ns) {
if (!ns && ns !== 0) return '-'
if (ns < 1000) return `${ns}ns`
if (ns < 1_000_000) return `${(ns / 1000).toFixed(1)}µs`
if (ns < 1_000_000_000) return `${(ns / 1_000_000).toFixed(1)}ms`
return `${(ns / 1_000_000_000).toFixed(2)}s`
}
export default function Traces() {
const { addToast } = useOutletContext()
const [activeTab, setActiveTab] = useState('api')
const [traces, setTraces] = useState([])
const [loading, setLoading] = useState(true)
const [expandedRow, setExpandedRow] = useState(null)
const fetchTraces = useCallback(async () => {
try {
setLoading(true)
const data = activeTab === 'api'
? await tracesApi.get()
: await tracesApi.getBackend()
setTraces(Array.isArray(data) ? data : [])
} catch (err) {
addToast(`Failed to load traces: ${err.message}`, 'error')
} finally {
setLoading(false)
}
}, [activeTab, addToast])
useEffect(() => {
fetchTraces()
}, [fetchTraces])
const handleClear = async () => {
try {
if (activeTab === 'api') await tracesApi.clear()
else await tracesApi.clearBackend()
setTraces([])
addToast('Traces cleared', 'success')
} catch (err) {
addToast(`Failed to clear: ${err.message}`, 'error')
}
}
const handleExport = () => {
const blob = new Blob([JSON.stringify(traces, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `traces-${activeTab}-${new Date().toISOString().slice(0, 10)}.json`
a.click()
URL.revokeObjectURL(url)
}
return (
<div className="page">
<div className="page-header">
<h1 className="page-title">Traces</h1>
<p className="page-subtitle">Debug API and backend traces</p>
</div>
<div className="tabs">
<button className={`tab ${activeTab === 'api' ? 'tab-active' : ''}`} onClick={() => setActiveTab('api')}>API Traces</button>
<button className={`tab ${activeTab === 'backend' ? 'tab-active' : ''}`} onClick={() => setActiveTab('backend')}>Backend Traces</button>
</div>
<div style={{ display: 'flex', gap: 'var(--spacing-sm)', marginBottom: 'var(--spacing-md)' }}>
<button className="btn btn-secondary btn-sm" onClick={fetchTraces}><i className="fas fa-rotate" /> Refresh</button>
<button className="btn btn-danger btn-sm" onClick={handleClear}><i className="fas fa-trash" /> Clear</button>
<button className="btn btn-secondary btn-sm" onClick={handleExport} disabled={traces.length === 0}><i className="fas fa-download" /> Export</button>
</div>
{loading ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: 'var(--spacing-xl)' }}><LoadingSpinner size="lg" /></div>
) : traces.length === 0 ? (
<div className="empty-state">
<div className="empty-state-icon"><i className="fas fa-wave-square" /></div>
<h2 className="empty-state-title">No traces</h2>
<p className="empty-state-text">Traces will appear here as requests are made.</p>
</div>
) : activeTab === 'api' ? (
<div className="table-container">
<table className="table">
<thead>
<tr>
<th style={{ width: '30px' }}></th>
<th>Time</th>
<th>Method</th>
<th>Path</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{traces.map((trace, i) => (
<React.Fragment key={i}>
<tr onClick={() => setExpandedRow(expandedRow === i ? null : i)} style={{ cursor: 'pointer' }}>
<td><i className={`fas fa-chevron-${expandedRow === i ? 'down' : 'right'}`} style={{ fontSize: '0.7rem' }} /></td>
<td>{trace.timestamp ? new Date(trace.timestamp).toLocaleTimeString() : '-'}</td>
<td><span className="badge badge-info">{trace.request?.method || '-'}</span></td>
<td style={{ fontFamily: 'JetBrains Mono, monospace', fontSize: '0.8125rem' }}>{trace.request?.path || '-'}</td>
<td><span className={`badge ${(trace.response?.status || 0) < 400 ? 'badge-success' : 'badge-error'}`}>{trace.response?.status || '-'}</span></td>
</tr>
{expandedRow === i && (
<tr>
<td colSpan="5">
<pre style={{ background: 'var(--color-bg-primary)', padding: 'var(--spacing-sm)', borderRadius: 'var(--radius-md)', fontSize: '0.75rem', overflow: 'auto', maxHeight: '300px' }}>
{JSON.stringify(trace, null, 2)}
</pre>
</td>
</tr>
)}
</React.Fragment>
))}
</tbody>
</table>
</div>
) : (
<div className="table-container">
<table className="table">
<thead>
<tr>
<th style={{ width: '30px' }}></th>
<th>Time</th>
<th>Type</th>
<th>Model</th>
<th>Backend</th>
<th>Duration</th>
<th>Summary</th>
</tr>
</thead>
<tbody>
{traces.map((trace, i) => (
<React.Fragment key={i}>
<tr onClick={() => setExpandedRow(expandedRow === i ? null : i)} style={{ cursor: 'pointer' }}>
<td><i className={`fas fa-chevron-${expandedRow === i ? 'down' : 'right'}`} style={{ fontSize: '0.7rem' }} /></td>
<td>{trace.timestamp ? new Date(trace.timestamp).toLocaleTimeString() : '-'}</td>
<td><span className="badge badge-info">{trace.type || '-'}</span></td>
<td style={{ fontFamily: 'JetBrains Mono, monospace', fontSize: '0.8125rem' }}>{trace.model_name || '-'}</td>
<td>{trace.backend || '-'}</td>
<td>{formatDuration(trace.duration)}</td>
<td style={{ maxWidth: '300px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{trace.error ? <span style={{ color: 'var(--color-error)' }}>{trace.error}</span> : (trace.summary || '-')}
</td>
</tr>
{expandedRow === i && (
<tr>
<td colSpan="7">
<pre style={{ background: 'var(--color-bg-primary)', padding: 'var(--spacing-sm)', borderRadius: 'var(--radius-md)', fontSize: '0.75rem', overflow: 'auto', maxHeight: '300px' }}>
{JSON.stringify(trace, null, 2)}
</pre>
</td>
</tr>
)}
</React.Fragment>
))}
</tbody>
</table>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,148 @@
import { useState } from 'react'
import { useParams, useOutletContext } from 'react-router-dom'
import ModelSelector from '../components/ModelSelector'
import LoadingSpinner from '../components/LoadingSpinner'
import { videoApi, fileToBase64 } from '../utils/api'
const SIZES = ['256x256', '512x512', '768x768', '1024x1024']
export default function VideoGen() {
const { model: urlModel } = useParams()
const { addToast } = useOutletContext()
const [model, setModel] = useState(urlModel || '')
const [prompt, setPrompt] = useState('')
const [negativePrompt, setNegativePrompt] = useState('')
const [size, setSize] = useState('512x512')
const [seconds, setSeconds] = useState('')
const [fps, setFps] = useState('16')
const [frames, setFrames] = useState('')
const [steps, setSteps] = useState('')
const [seed, setSeed] = useState('')
const [cfgScale, setCfgScale] = useState('')
const [loading, setLoading] = useState(false)
const [videos, setVideos] = useState([])
const [showAdvanced, setShowAdvanced] = useState(false)
const [showImageInputs, setShowImageInputs] = useState(false)
const [startImage, setStartImage] = useState(null)
const [endImage, setEndImage] = useState(null)
const handleGenerate = async (e) => {
e.preventDefault()
if (!prompt.trim()) { addToast('Please enter a prompt', 'warning'); return }
if (!model) { addToast('Please select a model', 'warning'); return }
setLoading(true)
setVideos([])
const [w, h] = size.split('x').map(Number)
const body = { model, prompt: prompt.trim(), width: w, height: h, fps: parseInt(fps) || 16 }
if (negativePrompt.trim()) body.negative_prompt = negativePrompt.trim()
if (seconds) body.seconds = seconds
if (frames) body.num_frames = parseInt(frames)
if (steps) body.step = parseInt(steps)
if (seed) body.seed = parseInt(seed)
if (cfgScale) body.cfg_scale = parseFloat(cfgScale)
if (startImage) body.start_image = startImage
if (endImage) body.end_image = endImage
try {
const data = await videoApi.generate(body)
setVideos(data?.data || [])
if (!data?.data?.length) addToast('No videos generated', 'warning')
} catch (err) {
addToast(`Error: ${err.message}`, 'error')
} finally {
setLoading(false)
}
}
const handleImageUpload = async (e, setter) => {
if (e.target.files[0]) setter(await fileToBase64(e.target.files[0]))
}
return (
<div className="media-layout">
<div className="media-controls">
<div className="page-header">
<h1 className="page-title"><i className="fas fa-video" style={{ marginRight: 8, color: 'var(--color-accent)' }} />Video Generation</h1>
</div>
<form onSubmit={handleGenerate}>
<div className="form-group">
<label className="form-label">Model</label>
<ModelSelector value={model} onChange={setModel} capability="FLAG_VIDEO" />
</div>
<div className="form-group">
<label className="form-label">Prompt</label>
<textarea className="textarea" value={prompt} onChange={(e) => setPrompt(e.target.value)} placeholder="Describe the video..." rows={3} />
</div>
<div className="form-group">
<label className="form-label">Negative Prompt</label>
<textarea className="textarea" value={negativePrompt} onChange={(e) => setNegativePrompt(e.target.value)} rows={2} />
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 'var(--spacing-sm)' }}>
<div className="form-group">
<label className="form-label">Size</label>
<select className="model-selector" value={size} onChange={(e) => setSize(e.target.value)} style={{ width: '100%' }}>
{SIZES.map(s => <option key={s} value={s}>{s}</option>)}
</select>
</div>
<div className="form-group">
<label className="form-label">Duration (s)</label>
<input className="input" type="text" value={seconds} onChange={(e) => setSeconds(e.target.value)} placeholder="Auto" />
</div>
<div className="form-group">
<label className="form-label">FPS</label>
<input className="input" type="number" value={fps} onChange={(e) => setFps(e.target.value)} />
</div>
</div>
<div className={`collapsible-header ${showAdvanced ? 'open' : ''}`} onClick={() => setShowAdvanced(!showAdvanced)}>
<i className="fas fa-chevron-right" /> Advanced
</div>
{showAdvanced && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 'var(--spacing-sm)', marginBottom: 'var(--spacing-md)' }}>
<div className="form-group"><label className="form-label">Steps</label><input className="input" type="number" value={steps} onChange={(e) => setSteps(e.target.value)} /></div>
<div className="form-group"><label className="form-label">Seed</label><input className="input" type="number" value={seed} onChange={(e) => setSeed(e.target.value)} /></div>
<div className="form-group"><label className="form-label">CFG Scale</label><input className="input" type="number" step="0.1" value={cfgScale} onChange={(e) => setCfgScale(e.target.value)} /></div>
</div>
)}
<div className={`collapsible-header ${showImageInputs ? 'open' : ''}`} onClick={() => setShowImageInputs(!showImageInputs)}>
<i className="fas fa-chevron-right" /> Image Inputs
</div>
{showImageInputs && (
<div style={{ marginBottom: 'var(--spacing-md)' }}>
<div className="form-group"><label className="form-label">Start Image</label><input type="file" accept="image/*" onChange={(e) => handleImageUpload(e, setStartImage)} className="input" /></div>
<div className="form-group"><label className="form-label">End Image</label><input type="file" accept="image/*" onChange={(e) => handleImageUpload(e, setEndImage)} className="input" /></div>
</div>
)}
<button type="submit" className="btn btn-primary" disabled={loading} style={{ width: '100%' }}>
{loading ? <><LoadingSpinner size="sm" /> Generating...</> : <><i className="fas fa-video" /> Generate Video</>}
</button>
</form>
</div>
<div className="media-preview">
<div className="media-result">
{loading ? (
<LoadingSpinner size="lg" />
) : videos.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-md)', width: '100%' }}>
{videos.map((v, i) => (
<video key={i} controls className="media-result" style={{ minHeight: 0 }} src={v.url || `data:video/mp4;base64,${v.b64_json}`} />
))}
</div>
) : (
<div style={{ textAlign: 'center', color: 'var(--color-text-muted)' }}>
<i className="fas fa-video" style={{ fontSize: '3rem', marginBottom: 'var(--spacing-md)', opacity: 0.4 }} />
<p>Generated videos will appear here</p>
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,66 @@
import { createBrowserRouter } from 'react-router-dom'
import App from './App'
import Home from './pages/Home'
import Chat from './pages/Chat'
import Models from './pages/Models'
import Manage from './pages/Manage'
import ImageGen from './pages/ImageGen'
import VideoGen from './pages/VideoGen'
import TTS from './pages/TTS'
import Sound from './pages/Sound'
import Talk from './pages/Talk'
import Backends from './pages/Backends'
import Settings from './pages/Settings'
import Traces from './pages/Traces'
import P2P from './pages/P2P'
import AgentJobs from './pages/AgentJobs'
import AgentTaskDetails from './pages/AgentTaskDetails'
import AgentJobDetails from './pages/AgentJobDetails'
import ModelEditor from './pages/ModelEditor'
import ImportModel from './pages/ImportModel'
import Explorer from './pages/Explorer'
import Login from './pages/Login'
import NotFound from './pages/NotFound'
export const router = createBrowserRouter([
{
path: '/login',
element: <Login />,
},
{
path: '/explorer',
element: <Explorer />,
},
{
path: '/',
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: '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 /> },
],
},
], { basename: '/app' })

View File

@@ -0,0 +1,138 @@
/* LocalAI Theme - CSS Variables System */
:root,
[data-theme="dark"] {
--color-bg-primary: #121212;
--color-bg-secondary: #1A1A1A;
--color-bg-tertiary: #222222;
--color-bg-overlay: rgba(18, 18, 18, 0.95);
--color-primary: #38BDF8;
--color-primary-hover: #0EA5E9;
--color-primary-active: #0284C7;
--color-primary-text: #FFFFFF;
--color-primary-light: rgba(56, 189, 248, 0.08);
--color-primary-border: rgba(56, 189, 248, 0.15);
--color-secondary: #14B8A6;
--color-secondary-hover: #0D9488;
--color-secondary-light: rgba(20, 184, 166, 0.1);
--color-accent: #8B5CF6;
--color-accent-hover: #7C3AED;
--color-accent-light: rgba(139, 92, 246, 0.1);
--color-accent-purple: #A78BFA;
--color-accent-teal: #2DD4BF;
--color-text-primary: #E5E7EB;
--color-text-secondary: #94A3B8;
--color-text-muted: #64748B;
--color-text-disabled: #475569;
--color-text-inverse: #FFFFFF;
--color-border-subtle: rgba(255, 255, 255, 0.08);
--color-border-default: rgba(255, 255, 255, 0.12);
--color-border-strong: rgba(56, 189, 248, 0.3);
--color-border-divider: rgba(255, 255, 255, 0.05);
--color-border-primary: rgba(56, 189, 248, 0.2);
--color-border-focus: rgba(56, 189, 248, 0.4);
--color-success: #14B8A6;
--color-success-light: rgba(20, 184, 166, 0.1);
--color-warning: #F59E0B;
--color-warning-light: rgba(245, 158, 11, 0.1);
--color-error: #EF4444;
--color-error-light: rgba(239, 68, 68, 0.1);
--color-info: #38BDF8;
--color-info-light: rgba(56, 189, 248, 0.1);
--gradient-primary: linear-gradient(135deg, #38BDF8 0%, #8B5CF6 50%, #14B8A6 100%);
--gradient-hero: linear-gradient(135deg, #121212 0%, #1A1A1A 50%, #121212 100%);
--gradient-card: linear-gradient(135deg, rgba(56, 189, 248, 0.04) 0%, rgba(139, 92, 246, 0.04) 100%);
--gradient-text: linear-gradient(135deg, #38BDF8 0%, #8B5CF6 50%, #14B8A6 100%);
--shadow-subtle: 0 1px 2px rgba(0, 0, 0, 0.2);
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.25);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.35);
--shadow-glow: 0 0 0 1px rgba(56, 189, 248, 0.15), 0 0 12px rgba(56, 189, 248, 0.2);
--shadow-sidebar: 1px 0 3px rgba(0, 0, 0, 0.25);
--duration-fast: 150ms;
--duration-normal: 200ms;
--duration-slow: 300ms;
--ease-default: cubic-bezier(0.4, 0, 0.2, 1);
--spacing-xs: 0.25rem;
--spacing-sm: 0.5rem;
--spacing-md: 1rem;
--spacing-lg: 1.5rem;
--spacing-xl: 2rem;
--radius-sm: 4px;
--radius-md: 6px;
--radius-lg: 8px;
--radius-xl: 12px;
--radius-full: 9999px;
--sidebar-width: 220px;
--color-toggle-off: #475569;
}
[data-theme="light"] {
--color-bg-primary: #F8FAFC;
--color-bg-secondary: #FFFFFF;
--color-bg-tertiary: #FFFFFF;
--color-bg-overlay: rgba(248, 250, 252, 0.9);
--color-primary: #0EA5E9;
--color-primary-hover: #0284C7;
--color-primary-active: #0369A1;
--color-primary-text: #FFFFFF;
--color-primary-light: rgba(14, 165, 233, 0.08);
--color-primary-border: rgba(14, 165, 233, 0.2);
--color-secondary: #0D9488;
--color-secondary-hover: #0F766E;
--color-secondary-light: rgba(13, 148, 136, 0.1);
--color-accent: #7C3AED;
--color-accent-hover: #6D28D9;
--color-accent-light: rgba(124, 58, 237, 0.1);
--color-accent-purple: #A78BFA;
--color-accent-teal: #2DD4BF;
--color-text-primary: #1E293B;
--color-text-secondary: #64748B;
--color-text-muted: #94A3B8;
--color-text-disabled: #CBD5E1;
--color-text-inverse: #FFFFFF;
--color-border-subtle: rgba(15, 23, 42, 0.06);
--color-border-default: rgba(15, 23, 42, 0.1);
--color-border-strong: rgba(14, 165, 233, 0.3);
--color-border-divider: rgba(15, 23, 42, 0.04);
--color-border-primary: rgba(14, 165, 233, 0.2);
--color-border-focus: rgba(14, 165, 233, 0.4);
--color-success: #0D9488;
--color-success-light: rgba(13, 148, 136, 0.1);
--color-warning: #D97706;
--color-warning-light: rgba(217, 119, 6, 0.1);
--color-error: #DC2626;
--color-error-light: rgba(220, 38, 38, 0.1);
--color-info: #0EA5E9;
--color-info-light: rgba(14, 165, 233, 0.1);
--gradient-primary: linear-gradient(135deg, #0EA5E9 0%, #7C3AED 50%, #0D9488 100%);
--gradient-hero: linear-gradient(135deg, #F8FAFC 0%, #FFFFFF 50%, #F8FAFC 100%);
--gradient-card: linear-gradient(135deg, rgba(14, 165, 233, 0.03) 0%, rgba(124, 58, 237, 0.03) 100%);
--gradient-text: linear-gradient(135deg, #0EA5E9 0%, #7C3AED 50%, #0D9488 100%);
--shadow-subtle: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.08);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.07);
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.08);
--shadow-glow: 0 0 0 1px rgba(14, 165, 233, 0.15), 0 0 8px rgba(14, 165, 233, 0.2);
--shadow-sidebar: 1px 0 3px rgba(0, 0, 0, 0.08);
--color-toggle-off: #CBD5E1;
}

253
core/http/react-ui/src/utils/api.js vendored Normal file
View File

@@ -0,0 +1,253 @@
import { API_CONFIG } from './config'
async function handleResponse(response) {
if (!response.ok) {
let errorMessage = `HTTP ${response.status}`
try {
const data = await response.json()
if (data?.error?.message) errorMessage = data.error.message
else if (data?.error) errorMessage = data.error
} catch (_e) {
// response wasn't JSON
}
throw new Error(errorMessage)
}
const contentType = response.headers.get('content-type')
if (contentType && contentType.includes('application/json')) {
return response.json()
}
return response
}
function buildUrl(endpoint, params) {
const url = new URL(endpoint, window.location.origin)
if (params) {
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') {
url.searchParams.set(key, value)
}
})
}
return url.toString()
}
async function fetchJSON(endpoint, options = {}) {
const response = await fetch(endpoint, {
headers: { 'Content-Type': 'application/json', ...options.headers },
...options,
})
return handleResponse(response)
}
async function postJSON(endpoint, body, options = {}) {
return fetchJSON(endpoint, {
method: 'POST',
body: JSON.stringify(body),
...options,
})
}
// SSE streaming for chat completions
export async function streamChat(body, signal) {
const response = await fetch(API_CONFIG.endpoints.chatCompletions, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...body, stream: true }),
signal,
})
if (!response.ok) {
let errorMessage = `HTTP ${response.status}`
try {
const data = await response.json()
if (data?.error?.message) errorMessage = data.error.message
} catch (_e) { /* not JSON */ }
throw new Error(errorMessage)
}
return response.body
}
// Models API
export const modelsApi = {
list: (params) => fetchJSON(buildUrl(API_CONFIG.endpoints.models, params)),
listV1: () => fetchJSON(API_CONFIG.endpoints.modelsList),
listCapabilities: () => fetchJSON(API_CONFIG.endpoints.modelsCapabilities),
install: (id) => postJSON(API_CONFIG.endpoints.installModel(id), {}),
delete: (id) => postJSON(API_CONFIG.endpoints.deleteModel(id), {}),
getConfig: (id) => postJSON(API_CONFIG.endpoints.modelConfig(id), {}),
getConfigJson: (name) => fetchJSON(API_CONFIG.endpoints.modelConfigJson(name)),
getJob: (uid) => fetchJSON(API_CONFIG.endpoints.modelJob(uid)),
apply: (body) => postJSON(API_CONFIG.endpoints.modelsApply, body),
deleteByName: (name) => postJSON(API_CONFIG.endpoints.modelsDelete(name), {}),
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, {
method: 'POST',
headers: { 'Content-Type': contentType },
body: content,
})
return handleResponse(response)
},
getJobStatus: (uid) => fetchJSON(API_CONFIG.endpoints.modelsJobStatus(uid)),
getEditConfig: (name) => fetchJSON(API_CONFIG.endpoints.modelEditGet(name)),
editConfig: (name, body) => postJSON(API_CONFIG.endpoints.modelEdit(name), body),
}
// Backends API
export const backendsApi = {
list: (params) => fetchJSON(buildUrl(API_CONFIG.endpoints.backends, params)),
listInstalled: () => fetchJSON(API_CONFIG.endpoints.backendsInstalled),
install: (id) => postJSON(API_CONFIG.endpoints.installBackend(id), {}),
delete: (id) => postJSON(API_CONFIG.endpoints.deleteBackend(id), {}),
installExternal: (body) => postJSON(API_CONFIG.endpoints.installExternalBackend, body),
getJob: (uid) => fetchJSON(API_CONFIG.endpoints.backendJob(uid)),
deleteInstalled: (name) => postJSON(API_CONFIG.endpoints.deleteInstalledBackend(name), {}),
}
// Chat API (non-streaming)
export const chatApi = {
complete: (body) => postJSON(API_CONFIG.endpoints.chatCompletions, body),
mcpComplete: (body) => postJSON(API_CONFIG.endpoints.mcpChatCompletions, body),
}
// Resources API
export const resourcesApi = {
get: () => fetchJSON(API_CONFIG.endpoints.resources),
}
// Operations API
export const operationsApi = {
list: () => fetchJSON(API_CONFIG.endpoints.operations),
cancel: (jobID) => postJSON(API_CONFIG.endpoints.cancelOperation(jobID), {}),
}
// Settings API
export const settingsApi = {
get: () => fetchJSON(API_CONFIG.endpoints.settings),
save: (body) => postJSON(API_CONFIG.endpoints.settings, body),
}
// Traces API
export const tracesApi = {
get: () => fetchJSON(API_CONFIG.endpoints.traces),
clear: () => postJSON(API_CONFIG.endpoints.clearTraces, {}),
getBackend: () => fetchJSON(API_CONFIG.endpoints.backendTraces),
clearBackend: () => postJSON(API_CONFIG.endpoints.clearBackendTraces, {}),
}
// P2P API
export const p2pApi = {
getWorkers: () => fetchJSON(API_CONFIG.endpoints.p2pWorkers),
getFederation: () => fetchJSON(API_CONFIG.endpoints.p2pFederation),
getStats: () => fetchJSON(API_CONFIG.endpoints.p2pStats),
getToken: async () => {
const response = await fetch(API_CONFIG.endpoints.p2pToken)
if (!response.ok) throw new Error(`HTTP ${response.status}`)
return response.text()
},
}
// Agent Jobs API
export const agentJobsApi = {
listTasks: () => fetchJSON(API_CONFIG.endpoints.agentTasks),
getTask: (id) => fetchJSON(API_CONFIG.endpoints.agentTask(id)),
createTask: (body) => postJSON(API_CONFIG.endpoints.agentTasks, body),
updateTask: (id, body) => fetchJSON(API_CONFIG.endpoints.agentTask(id), { method: 'PUT', body: JSON.stringify(body), headers: { 'Content-Type': 'application/json' } }),
deleteTask: (id) => fetchJSON(API_CONFIG.endpoints.agentTask(id), { method: 'DELETE' }),
executeTask: (name) => postJSON(API_CONFIG.endpoints.executeAgentTask(name), {}),
listJobs: () => fetchJSON(API_CONFIG.endpoints.agentJobs),
getJob: (id) => fetchJSON(API_CONFIG.endpoints.agentJob(id)),
cancelJob: (id) => postJSON(API_CONFIG.endpoints.cancelAgentJob(id), {}),
executeJob: (body) => postJSON(API_CONFIG.endpoints.executeAgentJob, body),
}
// Image generation
export const imageApi = {
generate: (body) => postJSON(API_CONFIG.endpoints.imageGenerations, body),
}
// Video generation
export const videoApi = {
generate: (body) => postJSON(API_CONFIG.endpoints.video, body),
}
// TTS
export const ttsApi = {
generate: async (body) => {
const response = await fetch(API_CONFIG.endpoints.tts, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (!response.ok) {
const data = await response.json().catch(() => ({}))
throw new Error(data?.error?.message || `HTTP ${response.status}`)
}
return response.blob()
},
generateV1: async (body) => {
const response = await fetch(API_CONFIG.endpoints.audioSpeech, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (!response.ok) {
const data = await response.json().catch(() => ({}))
throw new Error(data?.error?.message || `HTTP ${response.status}`)
}
return response.blob()
},
}
// Sound generation
export const soundApi = {
generate: async (body) => {
const response = await fetch(API_CONFIG.endpoints.soundGeneration, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (!response.ok) {
const data = await response.json().catch(() => ({}))
throw new Error(data?.error?.message || `HTTP ${response.status}`)
}
return response.blob()
},
}
// Audio transcription
export const audioApi = {
transcribe: async (formData) => {
const response = await fetch(API_CONFIG.endpoints.audioTranscriptions, {
method: 'POST',
body: formData,
})
return handleResponse(response)
},
}
// Backend control
export const backendControlApi = {
shutdown: (body) => postJSON(API_CONFIG.endpoints.backendShutdown, body),
}
// System info
export const systemApi = {
version: () => fetchJSON(API_CONFIG.endpoints.version),
info: () => fetchJSON(API_CONFIG.endpoints.system),
}
// File to base64 helper
export function fileToBase64(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => {
const base64 = reader.result.split(',')[1] || reader.result
resolve(base64)
}
reader.onerror = reject
reader.readAsDataURL(file)
})
}

82
core/http/react-ui/src/utils/config.js vendored Normal file
View File

@@ -0,0 +1,82 @@
export const API_CONFIG = {
endpoints: {
// Operations
operations: '/api/operations',
cancelOperation: (jobID) => `/api/operations/${jobID}/cancel`,
// Models gallery
models: '/api/models',
installModel: (id) => `/api/models/install/${id}`,
deleteModel: (id) => `/api/models/delete/${id}`,
modelConfig: (id) => `/api/models/config/${id}`,
modelConfigJson: (name) => `/api/models/config-json/${name}`,
modelJob: (uid) => `/api/models/job/${uid}`,
// Backends gallery
backends: '/api/backends',
installBackend: (id) => `/api/backends/install/${id}`,
deleteBackend: (id) => `/api/backends/delete/${id}`,
installExternalBackend: '/api/backends/install-external',
backendJob: (uid) => `/api/backends/job/${uid}`,
deleteInstalledBackend: (name) => `/api/backends/system/delete/${name}`,
// Resources
resources: '/api/resources',
// Settings
settings: '/api/settings',
// Traces
traces: '/api/traces',
clearTraces: '/api/traces/clear',
backendTraces: '/api/backend-traces',
clearBackendTraces: '/api/backend-traces/clear',
// P2P
p2pWorkers: '/api/p2p/workers',
p2pFederation: '/api/p2p/federation',
p2pStats: '/api/p2p/stats',
p2pToken: '/api/p2p/token',
// Agent jobs
agentTasks: '/api/agent/tasks',
agentTask: (id) => `/api/agent/tasks/${id}`,
executeAgentTask: (name) => `/api/agent/tasks/${name}/execute`,
agentJobs: '/api/agent/jobs',
agentJob: (id) => `/api/agent/jobs/${id}`,
cancelAgentJob: (id) => `/api/agent/jobs/${id}/cancel`,
executeAgentJob: '/api/agent/jobs/execute',
// OpenAI-compatible endpoints
chatCompletions: '/v1/chat/completions',
mcpChatCompletions: '/v1/mcp/chat/completions',
completions: '/v1/completions',
imageGenerations: '/v1/images/generations',
audioSpeech: '/v1/audio/speech',
audioTranscriptions: '/v1/audio/transcriptions',
soundGeneration: '/v1/sound-generation',
embeddings: '/v1/embeddings',
modelsList: '/v1/models',
modelsCapabilities: '/api/models/capabilities',
// LocalAI-specific
tts: '/tts',
video: '/video',
backendMonitor: '/backend/monitor',
backendShutdown: '/backend/shutdown',
modelsApply: '/models/apply',
modelsDelete: (name) => `/models/delete/${name}`,
modelsAvailable: '/models/available',
modelsGalleries: '/models/galleries',
modelsReload: '/models/reload',
modelsImportUri: '/models/import-uri',
modelsImport: '/models/import',
modelsJobStatus: (uid) => `/models/jobs/${uid}`,
modelEditGet: (name) => `/api/models/edit/${name}`,
modelEdit: (name) => `/models/edit/${name}`,
backendsAvailable: '/backends/available',
backendsInstalled: '/backends',
version: '/version',
system: '/system',
},
}

22
core/http/react-ui/src/utils/format.js vendored Normal file
View File

@@ -0,0 +1,22 @@
export function formatBytes(bytes) {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
}
export function percentColor(pct) {
if (pct > 90) return 'var(--color-error)'
if (pct > 70) return 'var(--color-warning)'
return 'var(--color-success)'
}
export function vendorColor(vendor) {
if (!vendor) return 'var(--color-accent)'
const v = vendor.toLowerCase()
if (v.includes('nvidia')) return '#76b900'
if (v.includes('amd')) return '#ed1c24'
if (v.includes('intel')) return '#0071c5'
return 'var(--color-accent)'
}

View File

@@ -0,0 +1,27 @@
import { marked } from 'marked'
import DOMPurify from 'dompurify'
import hljs from 'highlight.js'
marked.setOptions({
highlight(code, lang) {
if (lang && hljs.getLanguage(lang)) {
return hljs.highlight(code, { language: lang }).value
}
return hljs.highlightAuto(code).value
},
breaks: true,
gfm: true,
})
export function renderMarkdown(text) {
if (!text) return ''
const html = marked.parse(text)
return DOMPurify.sanitize(html)
}
export function highlightAll(element) {
if (!element) return
element.querySelectorAll('pre code').forEach((block) => {
hljs.highlightElement(block)
})
}

View File

@@ -0,0 +1,32 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
const backendUrl = process.env.LOCALAI_URL || 'http://localhost:8080'
export default defineConfig({
plugins: [react()],
base: '/app',
server: {
port: 3000,
proxy: {
'/api': backendUrl,
'/v1': backendUrl,
'/tts': backendUrl,
'/video': backendUrl,
'/backend': backendUrl,
'/models': backendUrl,
'/backends': backendUrl,
'/swagger': backendUrl,
'/static': backendUrl,
'/generated-audio': backendUrl,
'/generated-images': backendUrl,
'/generated-videos': backendUrl,
'/version': backendUrl,
'/system': backendUrl,
},
},
build: {
outDir: 'dist',
assetsDir: 'assets',
},
})

View File

@@ -3,12 +3,9 @@ package routes
import (
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/gallery"
"github.com/mudler/LocalAI/core/http/endpoints/localai"
"github.com/mudler/LocalAI/core/http/middleware"
"github.com/mudler/LocalAI/core/services"
"github.com/mudler/LocalAI/core/trace"
"github.com/mudler/LocalAI/internal"
"github.com/mudler/LocalAI/pkg/model"
)
@@ -18,409 +15,68 @@ func RegisterUIRoutes(app *echo.Echo,
appConfig *config.ApplicationConfig,
galleryService *services.GalleryService) {
// keeps the state of ops that are started from the UI
var processingOps = services.NewOpCache(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)
}
}
app.GET("/", localai.WelcomeEndpoint(appConfig, cl, ml, processingOps))
app.GET("/manage", localai.WelcomeEndpoint(appConfig, cl, ml, processingOps))
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 {
// Settings page
app.GET("/settings", func(c echo.Context) error {
summary := map[string]interface{}{
"Title": "LocalAI - Settings",
"BaseURL": middleware.BaseURL(c),
}
return c.Render(200, "views/settings", summary)
})
app.GET("/settings", redirectToApp("/settings"))
}
// Agent Jobs pages
app.GET("/agent-jobs", func(c echo.Context) error {
modelConfigs := cl.GetAllModelsConfigs()
summary := map[string]interface{}{
"Title": "LocalAI - Agent Jobs",
"BaseURL": middleware.BaseURL(c),
"Version": internal.PrintableVersion(),
"ModelsConfig": modelConfigs,
}
return c.Render(200, "views/agent-jobs", summary)
})
app.GET("/agent-jobs/tasks/new", func(c echo.Context) error {
modelConfigs := cl.GetAllModelsConfigs()
summary := map[string]interface{}{
"Title": "LocalAI - Create Task",
"BaseURL": middleware.BaseURL(c),
"Version": internal.PrintableVersion(),
"ModelsConfig": modelConfigs,
}
return c.Render(200, "views/agent-task-details", summary)
})
// More specific route must come first
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 {
modelConfigs := cl.GetAllModelsConfigs()
summary := map[string]interface{}{
"Title": "LocalAI - Edit Task",
"BaseURL": middleware.BaseURL(c),
"Version": internal.PrintableVersion(),
"ModelsConfig": modelConfigs,
}
return c.Render(200, "views/agent-task-details", summary)
return c.Redirect(302, "/app/agent-jobs/tasks/"+c.Param("id")+"/edit")
})
// Task details page (less specific, comes after edit route)
app.GET("/agent-jobs/tasks/:id", func(c echo.Context) error {
summary := map[string]interface{}{
"Title": "LocalAI - Task Details",
"BaseURL": middleware.BaseURL(c),
"Version": internal.PrintableVersion(),
}
return c.Render(200, "views/agent-task-details", summary)
return c.Redirect(302, "/app/agent-jobs/tasks/"+c.Param("id"))
})
app.GET("/agent-jobs/jobs/:id", func(c echo.Context) error {
summary := map[string]interface{}{
"Title": "LocalAI - Job Details",
"BaseURL": middleware.BaseURL(c),
"Version": internal.PrintableVersion(),
}
return c.Render(200, "views/agent-job-details", summary)
return c.Redirect(302, "/app/agent-jobs/jobs/"+c.Param("id"))
})
// P2P
app.GET("/p2p", func(c echo.Context) error {
summary := map[string]interface{}{
"Title": "LocalAI - P2P dashboard",
"BaseURL": middleware.BaseURL(c),
"Version": internal.PrintableVersion(),
//"Nodes": p2p.GetAvailableNodes(""),
//"FederatedNodes": p2p.GetAvailableNodes(p2p.FederatedID),
"P2PToken": appConfig.P2PToken,
"NetworkID": appConfig.P2PNetworkID,
}
// Render index
return c.Render(200, "views/p2p", summary)
})
// Note: P2P UI fragment routes (/p2p/ui/*) were removed
// P2P nodes are now fetched via JSON API at /api/p2p/workers and /api/p2p/federation
// End P2P
app.GET("/p2p", redirectToApp("/p2p"))
if !appConfig.DisableGalleryEndpoint {
registerGalleryRoutes(app, cl, appConfig, galleryService, processingOps)
registerBackendGalleryRoutes(app, appConfig, galleryService, processingOps)
app.GET("/browse", redirectToApp("/browse"))
app.GET("/browse/backends", redirectToApp("/backends"))
}
app.GET("/talk", func(c echo.Context) error {
modelConfigs, _ := services.ListModels(cl, ml, config.NoFilterFn, services.SKIP_IF_CONFIGURED)
if len(modelConfigs) == 0 {
// If no model is available redirect to the index which suggests how to install models
return c.Redirect(302, middleware.BaseURL(c))
}
summary := map[string]interface{}{
"Title": "LocalAI - Talk",
"BaseURL": middleware.BaseURL(c),
"ModelsConfig": modelConfigs,
"Model": modelConfigs[0],
"Version": internal.PrintableVersion(),
}
// Render index
return c.Render(200, "views/talk", summary)
})
app.GET("/chat", func(c echo.Context) error {
modelConfigs := cl.GetAllModelsConfigs()
modelsWithoutConfig, _ := services.ListModels(cl, ml, config.NoFilterFn, services.LOOSE_ONLY)
if len(modelConfigs)+len(modelsWithoutConfig) == 0 {
// If no model is available redirect to the index which suggests how to install models
return c.Redirect(302, middleware.BaseURL(c))
}
modelThatCanBeUsed := ""
galleryConfigs := map[string]*gallery.ModelConfig{}
for _, m := range modelConfigs {
cfg, err := gallery.GetLocalModelConfiguration(ml.ModelPath, m.Name)
if err != nil {
continue
}
galleryConfigs[m.Name] = cfg
}
title := "LocalAI - Chat"
var modelContextSize *int
for _, b := range modelConfigs {
if b.HasUsecases(config.FLAG_CHAT) {
modelThatCanBeUsed = b.Name
title = "LocalAI - Chat with " + modelThatCanBeUsed
if b.LLMConfig.ContextSize != nil {
modelContextSize = b.LLMConfig.ContextSize
}
break
}
}
summary := map[string]interface{}{
"Title": title,
"BaseURL": middleware.BaseURL(c),
"ModelsWithoutConfig": modelsWithoutConfig,
"GalleryConfig": galleryConfigs,
"ModelsConfig": modelConfigs,
"Model": modelThatCanBeUsed,
"ContextSize": modelContextSize,
"Version": internal.PrintableVersion(),
}
// Render index
return c.Render(200, "views/chat", summary)
})
// Show the Chat page
app.GET("/chat/:model", func(c echo.Context) error {
modelConfigs := cl.GetAllModelsConfigs()
modelsWithoutConfig, _ := services.ListModels(cl, ml, config.NoFilterFn, services.LOOSE_ONLY)
galleryConfigs := map[string]*gallery.ModelConfig{}
modelName := c.Param("model")
var modelContextSize *int
for _, m := range modelConfigs {
cfg, err := gallery.GetLocalModelConfiguration(ml.ModelPath, m.Name)
if err != nil {
continue
}
galleryConfigs[m.Name] = cfg
if m.Name == modelName && m.LLMConfig.ContextSize != nil {
modelContextSize = m.LLMConfig.ContextSize
}
}
summary := map[string]interface{}{
"Title": "LocalAI - Chat with " + modelName,
"BaseURL": middleware.BaseURL(c),
"ModelsConfig": modelConfigs,
"GalleryConfig": galleryConfigs,
"ModelsWithoutConfig": modelsWithoutConfig,
"Model": modelName,
"ContextSize": modelContextSize,
"Version": internal.PrintableVersion(),
}
// Render index
return c.Render(200, "views/chat", summary)
})
app.GET("/image/:model", func(c echo.Context) error {
modelConfigs := cl.GetAllModelsConfigs()
modelsWithoutConfig, _ := services.ListModels(cl, ml, config.NoFilterFn, services.LOOSE_ONLY)
summary := map[string]interface{}{
"Title": "LocalAI - Generate images with " + c.Param("model"),
"BaseURL": middleware.BaseURL(c),
"ModelsConfig": modelConfigs,
"ModelsWithoutConfig": modelsWithoutConfig,
"Model": c.Param("model"),
"Version": internal.PrintableVersion(),
}
// Render index
return c.Render(200, "views/image", summary)
})
app.GET("/image", func(c echo.Context) error {
modelConfigs := cl.GetAllModelsConfigs()
modelsWithoutConfig, _ := services.ListModels(cl, ml, config.NoFilterFn, services.LOOSE_ONLY)
if len(modelConfigs)+len(modelsWithoutConfig) == 0 {
// If no model is available redirect to the index which suggests how to install models
return c.Redirect(302, middleware.BaseURL(c))
}
modelThatCanBeUsed := ""
title := "LocalAI - Generate images"
for _, b := range modelConfigs {
if b.HasUsecases(config.FLAG_IMAGE) {
modelThatCanBeUsed = b.Name
title = "LocalAI - Generate images with " + modelThatCanBeUsed
break
}
}
summary := map[string]interface{}{
"Title": title,
"BaseURL": middleware.BaseURL(c),
"ModelsConfig": modelConfigs,
"ModelsWithoutConfig": modelsWithoutConfig,
"Model": modelThatCanBeUsed,
"Version": internal.PrintableVersion(),
}
// Render index
return c.Render(200, "views/image", summary)
})
app.GET("/tts/:model", func(c echo.Context) error {
modelConfigs := cl.GetAllModelsConfigs()
modelsWithoutConfig, _ := services.ListModels(cl, ml, config.NoFilterFn, services.LOOSE_ONLY)
summary := map[string]interface{}{
"Title": "LocalAI - Generate images with " + c.Param("model"),
"BaseURL": middleware.BaseURL(c),
"ModelsConfig": modelConfigs,
"ModelsWithoutConfig": modelsWithoutConfig,
"Model": c.Param("model"),
"Version": internal.PrintableVersion(),
}
// Render index
return c.Render(200, "views/tts", summary)
})
app.GET("/tts", func(c echo.Context) error {
modelConfigs := cl.GetAllModelsConfigs()
modelsWithoutConfig, _ := services.ListModels(cl, ml, config.NoFilterFn, services.LOOSE_ONLY)
if len(modelConfigs)+len(modelsWithoutConfig) == 0 {
// If no model is available redirect to the index which suggests how to install models
return c.Redirect(302, middleware.BaseURL(c))
}
modelThatCanBeUsed := ""
title := "LocalAI - Generate audio"
for _, b := range modelConfigs {
if b.HasUsecases(config.FLAG_TTS) {
modelThatCanBeUsed = b.Name
title = "LocalAI - Generate audio with " + modelThatCanBeUsed
break
}
}
summary := map[string]interface{}{
"Title": title,
"BaseURL": middleware.BaseURL(c),
"ModelsConfig": modelConfigs,
"ModelsWithoutConfig": modelsWithoutConfig,
"Model": modelThatCanBeUsed,
"Version": internal.PrintableVersion(),
}
// Render index
return c.Render(200, "views/tts", summary)
})
app.GET("/sound/:model", func(c echo.Context) error {
modelConfigs := cl.GetAllModelsConfigs()
modelsWithoutConfig, _ := services.ListModels(cl, ml, config.NoFilterFn, services.LOOSE_ONLY)
summary := map[string]interface{}{
"Title": "LocalAI - Generate sound with " + c.Param("model"),
"BaseURL": middleware.BaseURL(c),
"ModelsConfig": modelConfigs,
"ModelsWithoutConfig": modelsWithoutConfig,
"Model": c.Param("model"),
"Version": internal.PrintableVersion(),
}
return c.Render(200, "views/sound", summary)
})
app.GET("/sound", func(c echo.Context) error {
modelConfigs := cl.GetAllModelsConfigs()
modelsWithoutConfig, _ := services.ListModels(cl, ml, config.NoFilterFn, services.LOOSE_ONLY)
if len(modelConfigs)+len(modelsWithoutConfig) == 0 {
return c.Redirect(302, middleware.BaseURL(c))
}
modelThatCanBeUsed := ""
title := "LocalAI - Generate sound"
for _, b := range modelConfigs {
if b.HasUsecases(config.FLAG_SOUND_GENERATION) {
modelThatCanBeUsed = b.Name
title = "LocalAI - Generate sound with " + modelThatCanBeUsed
break
}
}
summary := map[string]interface{}{
"Title": title,
"BaseURL": middleware.BaseURL(c),
"ModelsConfig": modelConfigs,
"ModelsWithoutConfig": modelsWithoutConfig,
"Model": modelThatCanBeUsed,
"Version": internal.PrintableVersion(),
}
return c.Render(200, "views/sound", summary)
})
app.GET("/video/:model", func(c echo.Context) error {
modelConfigs := cl.GetAllModelsConfigs()
modelsWithoutConfig, _ := services.ListModels(cl, ml, config.NoFilterFn, services.LOOSE_ONLY)
summary := map[string]interface{}{
"Title": "LocalAI - Generate videos with " + c.Param("model"),
"BaseURL": middleware.BaseURL(c),
"ModelsConfig": modelConfigs,
"ModelsWithoutConfig": modelsWithoutConfig,
"Model": c.Param("model"),
"Version": internal.PrintableVersion(),
}
// Render index
return c.Render(200, "views/video", summary)
})
app.GET("/video", func(c echo.Context) error {
modelConfigs := cl.GetAllModelsConfigs()
modelsWithoutConfig, _ := services.ListModels(cl, ml, config.NoFilterFn, services.LOOSE_ONLY)
if len(modelConfigs)+len(modelsWithoutConfig) == 0 {
// If no model is available redirect to the index which suggests how to install models
return c.Redirect(302, middleware.BaseURL(c))
}
modelThatCanBeUsed := ""
title := "LocalAI - Generate videos"
for _, b := range modelConfigs {
if b.HasUsecases(config.FLAG_VIDEO) {
modelThatCanBeUsed = b.Name
title = "LocalAI - Generate videos with " + modelThatCanBeUsed
break
}
}
summary := map[string]interface{}{
"Title": title,
"BaseURL": middleware.BaseURL(c),
"ModelsConfig": modelConfigs,
"ModelsWithoutConfig": modelsWithoutConfig,
"Model": modelThatCanBeUsed,
"Version": internal.PrintableVersion(),
}
// Render index
return c.Render(200, "views/video", summary)
})
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", func(c echo.Context) error {
summary := map[string]interface{}{
"Title": "LocalAI - Traces",
"BaseURL": middleware.BaseURL(c),
"Version": internal.PrintableVersion(),
}
return c.Render(200, "views/traces", summary)
})
app.GET("/traces", redirectToApp("/traces"))
app.GET("/api/traces", func(c echo.Context) error {
return c.JSON(200, middleware.GetTraces())

View File

@@ -397,6 +397,35 @@ func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model
})
})
// Returns installed models with their capability flags for UI filtering
app.GET("/api/models/capabilities", func(c echo.Context) error {
modelConfigs := cl.GetAllModelsConfigs()
modelsWithoutConfig, _ := services.ListModels(cl, ml, config.NoFilterFn, services.LOOSE_ONLY)
type modelCapability struct {
ID string `json:"id"`
Capabilities []string `json:"capabilities"`
}
result := make([]modelCapability, 0, len(modelConfigs)+len(modelsWithoutConfig))
for _, cfg := range modelConfigs {
result = append(result, modelCapability{
ID: cfg.Name,
Capabilities: cfg.KnownUsecaseStrings,
})
}
for _, name := range modelsWithoutConfig {
result = append(result, modelCapability{
ID: name,
Capabilities: []string{},
})
}
return c.JSON(200, map[string]any{
"data": result,
})
})
app.POST("/api/models/install/:id", func(c echo.Context) error {
galleryID := c.Param("id")
// URL decode the gallery ID (e.g., "localai%40model" -> "localai@model")
@@ -533,6 +562,61 @@ func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model
})
})
// Get installed model config as JSON (used by frontend for MCP detection, etc.)
app.GET("/api/models/config-json/:name", func(c echo.Context) error {
modelName := c.Param("name")
if modelName == "" {
return c.JSON(http.StatusBadRequest, map[string]interface{}{
"error": "model name is required",
})
}
modelConfig, exists := cl.GetModelConfig(modelName)
if !exists {
return c.JSON(http.StatusNotFound, map[string]interface{}{
"error": "model configuration not found",
})
}
return c.JSON(http.StatusOK, modelConfig)
})
// Get installed model YAML config for the React model editor
app.GET("/api/models/edit/:name", func(c echo.Context) error {
modelName := c.Param("name")
if modelName == "" {
return c.JSON(http.StatusBadRequest, map[string]interface{}{
"error": "model name is required",
})
}
modelConfig, exists := cl.GetModelConfig(modelName)
if !exists {
return c.JSON(http.StatusNotFound, map[string]interface{}{
"error": "model configuration not found",
})
}
modelConfigFile := modelConfig.GetModelConfigFile()
if modelConfigFile == "" {
return c.JSON(http.StatusNotFound, map[string]interface{}{
"error": "model configuration file not found",
})
}
configData, err := os.ReadFile(modelConfigFile)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]interface{}{
"error": "failed to read configuration file: " + err.Error(),
})
}
return c.JSON(http.StatusOK, map[string]interface{}{
"config": string(configData),
"name": modelName,
})
})
app.GET("/api/models/job/:uid", func(c echo.Context) error {
jobUID := c.Param("uid")

View File

@@ -3,22 +3,11 @@ package routes
import (
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/http/middleware"
"github.com/mudler/LocalAI/core/services"
"github.com/mudler/LocalAI/internal"
)
func registerBackendGalleryRoutes(app *echo.Echo, appConfig *config.ApplicationConfig, galleryService *services.GalleryService, opcache *services.OpCache) {
// Show the Backends page (all backends are loaded client-side via Alpine.js)
app.GET("/browse/backends", func(c echo.Context) error {
summary := map[string]interface{}{
"Title": "LocalAI - Backends",
"BaseURL": middleware.BaseURL(c),
"Version": internal.PrintableVersion(),
"Repositories": appConfig.BackendGalleries,
}
// Render index - backends are now loaded via Alpine.js from /api/backends
return c.Render(200, "views/backends", summary)
})
// Backend gallery routes are now handled by the React SPA at /app/backends
// This function is kept for backward compatibility but no longer registers routes
// (routes are registered directly in RegisterUIRoutes)
}

View File

@@ -3,21 +3,11 @@ package routes
import (
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/http/middleware"
"github.com/mudler/LocalAI/core/services"
"github.com/mudler/LocalAI/internal"
)
func registerGalleryRoutes(app *echo.Echo, cl *config.ModelConfigLoader, appConfig *config.ApplicationConfig, galleryService *services.GalleryService, opcache *services.OpCache) {
app.GET("/browse", func(c echo.Context) error {
summary := map[string]interface{}{
"Title": "LocalAI - Models",
"BaseURL": middleware.BaseURL(c),
"Version": internal.PrintableVersion(),
"Repositories": appConfig.Galleries,
}
// Render index - models are now loaded via Alpine.js from /api/models
return c.Render(200, "views/models", summary)
})
// Gallery routes are now handled by the React SPA at /app/browse
// This function is kept for backward compatibility but no longer registers routes
// (routes are registered directly in RegisterUIRoutes)
}

View File

@@ -1,3 +1,4 @@
package functions_test
import (

View File

@@ -1,3 +1,9 @@
# DEPRECATED: This file is used by the legacy Alpine.js UI (core/http/views/).
# The new React UI (core/http/react-ui/) bundles all its dependencies via npm.
# When the legacy UI is removed, delete this file along with:
# - core/dependencies_manager/manager.go
# - core/http/static/assets/ (downloaded artifacts)
# - The "gen-assets" Makefile target
- filename: "highlightjs.css"
url: "https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.8.0/build/styles/default.min.css"
sha: "fbde0ac0921d86c356c41532e7319c887a23bd1b8ff00060cab447249f03c7cf"