mirror of
https://github.com/mudler/LocalAI.git
synced 2026-03-31 13:15:51 -04:00
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:
committed by
GitHub
parent
61c139fa7d
commit
09ddaf94b2
12
.github/workflows/test.yml
vendored
12
.github/workflows/test.yml
vendored
@@ -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
|
||||
|
||||
6
.github/workflows/tests-e2e.yml
vendored
6
.github/workflows/tests-e2e.yml
vendored
@@ -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
4
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -2,6 +2,7 @@ version: 2
|
||||
before:
|
||||
hooks:
|
||||
- make protogen-go
|
||||
- make react-ui
|
||||
- go mod tidy
|
||||
dist: release
|
||||
source:
|
||||
|
||||
14
Dockerfile
14
Dockerfile
@@ -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
|
||||
|
||||
17
Makefile
17
Makefile
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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
441
core/http/react-ui/bun.lock
Normal 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=="],
|
||||
}
|
||||
}
|
||||
29
core/http/react-ui/eslint.config.js
Normal file
29
core/http/react-ui/eslint.config.js
Normal 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: '^_' }],
|
||||
},
|
||||
},
|
||||
]
|
||||
16
core/http/react-ui/index.html
Normal file
16
core/http/react-ui/index.html
Normal 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>
|
||||
30
core/http/react-ui/package.json
Normal file
30
core/http/react-ui/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
2001
core/http/react-ui/src/App.css
Normal file
2001
core/http/react-ui/src/App.css
Normal file
File diff suppressed because it is too large
Load Diff
68
core/http/react-ui/src/App.jsx
Normal file
68
core/http/react-ui/src/App.jsx
Normal 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">
|
||||
© 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>
|
||||
)
|
||||
}
|
||||
111
core/http/react-ui/src/components/CodeEditor.jsx
Normal file
111
core/http/react-ui/src/components/CodeEditor.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
8
core/http/react-ui/src/components/LoadingSpinner.jsx
Normal file
8
core/http/react-ui/src/components/LoadingSpinner.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
27
core/http/react-ui/src/components/ModelSelector.jsx
Normal file
27
core/http/react-ui/src/components/ModelSelector.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
61
core/http/react-ui/src/components/OperationsBar.jsx
Normal file
61
core/http/react-ui/src/components/OperationsBar.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
111
core/http/react-ui/src/components/ResourceMonitor.jsx
Normal file
111
core/http/react-ui/src/components/ResourceMonitor.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
102
core/http/react-ui/src/components/Sidebar.jsx
Normal file
102
core/http/react-ui/src/components/Sidebar.jsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
15
core/http/react-ui/src/components/ThemeToggle.jsx
Normal file
15
core/http/react-ui/src/components/ThemeToggle.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
71
core/http/react-ui/src/components/Toast.jsx
Normal file
71
core/http/react-ui/src/components/Toast.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
32
core/http/react-ui/src/contexts/ThemeContext.jsx
Normal file
32
core/http/react-ui/src/contexts/ThemeContext.jsx
Normal 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
601
core/http/react-ui/src/hooks/useChat.js
vendored
Normal 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,
|
||||
}
|
||||
}
|
||||
69
core/http/react-ui/src/hooks/useModels.js
vendored
Normal file
69
core/http/react-ui/src/hooks/useModels.js
vendored
Normal 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 }
|
||||
}
|
||||
48
core/http/react-ui/src/hooks/useOperations.js
vendored
Normal file
48
core/http/react-ui/src/hooks/useOperations.js
vendored
Normal 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 }
|
||||
}
|
||||
31
core/http/react-ui/src/hooks/useResources.js
vendored
Normal file
31
core/http/react-ui/src/hooks/useResources.js
vendored
Normal 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 }
|
||||
}
|
||||
59
core/http/react-ui/src/index.css
Normal file
59
core/http/react-ui/src/index.css
Normal 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;
|
||||
}
|
||||
17
core/http/react-ui/src/main.jsx
Normal file
17
core/http/react-ui/src/main.jsx
Normal 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>,
|
||||
)
|
||||
398
core/http/react-ui/src/pages/AgentJobDetails.jsx
Normal file
398
core/http/react-ui/src/pages/AgentJobDetails.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
496
core/http/react-ui/src/pages/AgentJobs.jsx
Normal file
496
core/http/react-ui/src/pages/AgentJobs.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
508
core/http/react-ui/src/pages/AgentTaskDetails.jsx
Normal file
508
core/http/react-ui/src/pages/AgentTaskDetails.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
498
core/http/react-ui/src/pages/Backends.jsx
Normal file
498
core/http/react-ui/src/pages/Backends.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
852
core/http/react-ui/src/pages/Chat.jsx
Normal file
852
core/http/react-ui/src/pages/Chat.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
26
core/http/react-ui/src/pages/Explorer.jsx
Normal file
26
core/http/react-ui/src/pages/Explorer.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
770
core/http/react-ui/src/pages/Home.jsx
Normal file
770
core/http/react-ui/src/pages/Home.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
152
core/http/react-ui/src/pages/ImageGen.jsx
Normal file
152
core/http/react-ui/src/pages/ImageGen.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
445
core/http/react-ui/src/pages/ImportModel.jsx
Normal file
445
core/http/react-ui/src/pages/ImportModel.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
58
core/http/react-ui/src/pages/Login.jsx
Normal file
58
core/http/react-ui/src/pages/Login.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
350
core/http/react-ui/src/pages/Manage.jsx
Normal file
350
core/http/react-ui/src/pages/Manage.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
70
core/http/react-ui/src/pages/ModelEditor.jsx
Normal file
70
core/http/react-ui/src/pages/ModelEditor.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
554
core/http/react-ui/src/pages/Models.jsx
Normal file
554
core/http/react-ui/src/pages/Models.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
19
core/http/react-ui/src/pages/NotFound.jsx
Normal file
19
core/http/react-ui/src/pages/NotFound.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
684
core/http/react-ui/src/pages/P2P.jsx
Normal file
684
core/http/react-ui/src/pages/P2P.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
468
core/http/react-ui/src/pages/Settings.jsx
Normal file
468
core/http/react-ui/src/pages/Settings.jsx
Normal 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 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>
|
||||
)
|
||||
}
|
||||
152
core/http/react-ui/src/pages/Sound.jsx
Normal file
152
core/http/react-ui/src/pages/Sound.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
95
core/http/react-ui/src/pages/TTS.jsx
Normal file
95
core/http/react-ui/src/pages/TTS.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
200
core/http/react-ui/src/pages/Talk.jsx
Normal file
200
core/http/react-ui/src/pages/Talk.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
167
core/http/react-ui/src/pages/Traces.jsx
Normal file
167
core/http/react-ui/src/pages/Traces.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
148
core/http/react-ui/src/pages/VideoGen.jsx
Normal file
148
core/http/react-ui/src/pages/VideoGen.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
66
core/http/react-ui/src/router.jsx
Normal file
66
core/http/react-ui/src/router.jsx
Normal 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' })
|
||||
138
core/http/react-ui/src/theme.css
Normal file
138
core/http/react-ui/src/theme.css
Normal 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
253
core/http/react-ui/src/utils/api.js
vendored
Normal 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
82
core/http/react-ui/src/utils/config.js
vendored
Normal 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
22
core/http/react-ui/src/utils/format.js
vendored
Normal 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)'
|
||||
}
|
||||
27
core/http/react-ui/src/utils/markdown.js
vendored
Normal file
27
core/http/react-ui/src/utils/markdown.js
vendored
Normal 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)
|
||||
})
|
||||
}
|
||||
32
core/http/react-ui/vite.config.js
Normal file
32
core/http/react-ui/vite.config.js
Normal 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',
|
||||
},
|
||||
})
|
||||
@@ -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())
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
|
||||
package functions_test
|
||||
|
||||
import (
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user