mirror of
https://github.com/mudler/LocalAI.git
synced 2026-06-25 00:59:28 -04:00
Compare commits
12 Commits
fix/parake
...
fix/https-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46b76cb4ac | ||
|
|
15c7ce059a | ||
|
|
975b54dfc5 | ||
|
|
2eec8bfeb9 | ||
|
|
d9feac54dc | ||
|
|
5c3d48ab50 | ||
|
|
764b0352b9 | ||
|
|
75ba2daba1 | ||
|
|
62b14fd635 | ||
|
|
193d0e6aef | ||
|
|
482314c623 | ||
|
|
e8ae88a2a0 |
7
.github/backend-matrix.yml
vendored
7
.github/backend-matrix.yml
vendored
@@ -4974,6 +4974,9 @@ includeDarwin:
|
|||||||
- backend: "kitten-tts"
|
- backend: "kitten-tts"
|
||||||
tag-suffix: "-metal-darwin-arm64-kitten-tts"
|
tag-suffix: "-metal-darwin-arm64-kitten-tts"
|
||||||
build-type: "mps"
|
build-type: "mps"
|
||||||
|
- backend: "liquid-audio"
|
||||||
|
tag-suffix: "-metal-darwin-arm64-liquid-audio"
|
||||||
|
build-type: "mps"
|
||||||
- backend: "piper"
|
- backend: "piper"
|
||||||
tag-suffix: "-metal-darwin-arm64-piper"
|
tag-suffix: "-metal-darwin-arm64-piper"
|
||||||
build-type: "metal"
|
build-type: "metal"
|
||||||
@@ -4990,6 +4993,10 @@ includeDarwin:
|
|||||||
tag-suffix: "-metal-darwin-arm64-sherpa-onnx"
|
tag-suffix: "-metal-darwin-arm64-sherpa-onnx"
|
||||||
build-type: "metal"
|
build-type: "metal"
|
||||||
lang: "go"
|
lang: "go"
|
||||||
|
- backend: "supertonic"
|
||||||
|
tag-suffix: "-metal-darwin-arm64-supertonic"
|
||||||
|
build-type: "metal"
|
||||||
|
lang: "go"
|
||||||
- backend: "local-store"
|
- backend: "local-store"
|
||||||
tag-suffix: "-metal-darwin-arm64-local-store"
|
tag-suffix: "-metal-darwin-arm64-local-store"
|
||||||
build-type: "metal"
|
build-type: "metal"
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
"unicode"
|
"unicode"
|
||||||
@@ -943,7 +944,13 @@ func InitializeONNXRuntime() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if libPath == "" {
|
if libPath == "" {
|
||||||
libPath = "/usr/local/lib/libonnxruntime.so"
|
// LocalAI: default to the platform-native shared library
|
||||||
|
// extension when nothing else is found (dyld vs ld.so).
|
||||||
|
if runtime.GOOS == "darwin" {
|
||||||
|
libPath = "/usr/local/lib/libonnxruntime.dylib"
|
||||||
|
} else {
|
||||||
|
libPath = "/usr/local/lib/libonnxruntime.so"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ort.SetSharedLibraryPath(libPath)
|
ort.SetSharedLibraryPath(libPath)
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ elif [ -f "/lib/ld-linux-aarch64.so.1" ]; then
|
|||||||
cp -arfLv /lib/aarch64-linux-gnu/libdl.so.2 $CURDIR/package/lib/libdl.so.2
|
cp -arfLv /lib/aarch64-linux-gnu/libdl.so.2 $CURDIR/package/lib/libdl.so.2
|
||||||
cp -arfLv /lib/aarch64-linux-gnu/librt.so.1 $CURDIR/package/lib/librt.so.1
|
cp -arfLv /lib/aarch64-linux-gnu/librt.so.1 $CURDIR/package/lib/librt.so.1
|
||||||
cp -arfLv /lib/aarch64-linux-gnu/libpthread.so.0 $CURDIR/package/lib/libpthread.so.0
|
cp -arfLv /lib/aarch64-linux-gnu/libpthread.so.0 $CURDIR/package/lib/libpthread.so.0
|
||||||
|
elif [ $(uname -s) = "Darwin" ]; then
|
||||||
|
# macOS: dyld resolves the bundled .dylib via DYLD_LIBRARY_PATH (set in
|
||||||
|
# run.sh); there is no ld.so loader nor glibc to bundle.
|
||||||
|
echo "Detected Darwin"
|
||||||
else
|
else
|
||||||
echo "Error: Could not detect architecture"
|
echo "Error: Could not detect architecture"
|
||||||
exit 1
|
exit 1
|
||||||
|
|||||||
@@ -3,12 +3,19 @@ set -ex
|
|||||||
|
|
||||||
CURDIR=$(dirname "$(realpath $0)")
|
CURDIR=$(dirname "$(realpath $0)")
|
||||||
|
|
||||||
export LD_LIBRARY_PATH=$CURDIR/lib:$LD_LIBRARY_PATH
|
if [ "$(uname)" = "Darwin" ]; then
|
||||||
export ONNXRUNTIME_LIB_PATH=$CURDIR/lib/libonnxruntime.so
|
# macOS uses dyld: there is no ld.so loader, and the search path env
|
||||||
|
# var is DYLD_LIBRARY_PATH. ONNX Runtime ships as a .dylib here.
|
||||||
|
export DYLD_LIBRARY_PATH=$CURDIR/lib:$DYLD_LIBRARY_PATH
|
||||||
|
export ONNXRUNTIME_LIB_PATH=$CURDIR/lib/libonnxruntime.dylib
|
||||||
|
else
|
||||||
|
export LD_LIBRARY_PATH=$CURDIR/lib:$LD_LIBRARY_PATH
|
||||||
|
export ONNXRUNTIME_LIB_PATH=$CURDIR/lib/libonnxruntime.so
|
||||||
|
|
||||||
if [ -f $CURDIR/lib/ld.so ]; then
|
if [ -f $CURDIR/lib/ld.so ]; then
|
||||||
echo "Using lib/ld.so"
|
echo "Using lib/ld.so"
|
||||||
exec $CURDIR/lib/ld.so $CURDIR/supertonic "$@"
|
exec $CURDIR/lib/ld.so $CURDIR/supertonic "$@"
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
exec $CURDIR/supertonic "$@"
|
exec $CURDIR/supertonic "$@"
|
||||||
|
|||||||
@@ -1284,6 +1284,7 @@
|
|||||||
nvidia-cuda-13: "cuda13-liquid-audio"
|
nvidia-cuda-13: "cuda13-liquid-audio"
|
||||||
nvidia-cuda-12: "cuda12-liquid-audio"
|
nvidia-cuda-12: "cuda12-liquid-audio"
|
||||||
nvidia-l4t-cuda-13: "cuda13-nvidia-l4t-arm64-liquid-audio"
|
nvidia-l4t-cuda-13: "cuda13-nvidia-l4t-arm64-liquid-audio"
|
||||||
|
metal: "metal-liquid-audio"
|
||||||
icon: https://cdn-avatars.huggingface.co/v1/production/uploads/61b8e2ba285851687028d395/7_6D7rWrLxp2hb6OHSV1p.png
|
icon: https://cdn-avatars.huggingface.co/v1/production/uploads/61b8e2ba285851687028d395/7_6D7rWrLxp2hb6OHSV1p.png
|
||||||
- &qwen-tts
|
- &qwen-tts
|
||||||
urls:
|
urls:
|
||||||
@@ -1569,6 +1570,7 @@
|
|||||||
- TTS
|
- TTS
|
||||||
capabilities:
|
capabilities:
|
||||||
default: "cpu-supertonic"
|
default: "cpu-supertonic"
|
||||||
|
metal: "metal-supertonic"
|
||||||
- !!merge <<: *neutts
|
- !!merge <<: *neutts
|
||||||
name: "neutts-development"
|
name: "neutts-development"
|
||||||
capabilities:
|
capabilities:
|
||||||
@@ -4612,6 +4614,7 @@
|
|||||||
nvidia-cuda-13: "cuda13-liquid-audio-development"
|
nvidia-cuda-13: "cuda13-liquid-audio-development"
|
||||||
nvidia-cuda-12: "cuda12-liquid-audio-development"
|
nvidia-cuda-12: "cuda12-liquid-audio-development"
|
||||||
nvidia-l4t-cuda-13: "cuda13-nvidia-l4t-arm64-liquid-audio-development"
|
nvidia-l4t-cuda-13: "cuda13-nvidia-l4t-arm64-liquid-audio-development"
|
||||||
|
metal: "metal-liquid-audio-development"
|
||||||
- !!merge <<: *liquid-audio
|
- !!merge <<: *liquid-audio
|
||||||
name: "cpu-liquid-audio"
|
name: "cpu-liquid-audio"
|
||||||
uri: "quay.io/go-skynet/local-ai-backends:latest-cpu-liquid-audio"
|
uri: "quay.io/go-skynet/local-ai-backends:latest-cpu-liquid-audio"
|
||||||
@@ -4622,6 +4625,16 @@
|
|||||||
uri: "quay.io/go-skynet/local-ai-backends:master-cpu-liquid-audio"
|
uri: "quay.io/go-skynet/local-ai-backends:master-cpu-liquid-audio"
|
||||||
mirrors:
|
mirrors:
|
||||||
- localai/localai-backends:master-cpu-liquid-audio
|
- localai/localai-backends:master-cpu-liquid-audio
|
||||||
|
- !!merge <<: *liquid-audio
|
||||||
|
name: "metal-liquid-audio"
|
||||||
|
uri: "quay.io/go-skynet/local-ai-backends:latest-metal-darwin-arm64-liquid-audio"
|
||||||
|
mirrors:
|
||||||
|
- localai/localai-backends:latest-metal-darwin-arm64-liquid-audio
|
||||||
|
- !!merge <<: *liquid-audio
|
||||||
|
name: "metal-liquid-audio-development"
|
||||||
|
uri: "quay.io/go-skynet/local-ai-backends:master-metal-darwin-arm64-liquid-audio"
|
||||||
|
mirrors:
|
||||||
|
- localai/localai-backends:master-metal-darwin-arm64-liquid-audio
|
||||||
- !!merge <<: *liquid-audio
|
- !!merge <<: *liquid-audio
|
||||||
name: "cuda12-liquid-audio"
|
name: "cuda12-liquid-audio"
|
||||||
uri: "quay.io/go-skynet/local-ai-backends:latest-gpu-nvidia-cuda-12-liquid-audio"
|
uri: "quay.io/go-skynet/local-ai-backends:latest-gpu-nvidia-cuda-12-liquid-audio"
|
||||||
@@ -5484,6 +5497,7 @@
|
|||||||
name: "supertonic-development"
|
name: "supertonic-development"
|
||||||
capabilities:
|
capabilities:
|
||||||
default: "cpu-supertonic-development"
|
default: "cpu-supertonic-development"
|
||||||
|
metal: "metal-supertonic-development"
|
||||||
- !!merge <<: *supertonic
|
- !!merge <<: *supertonic
|
||||||
name: "cpu-supertonic"
|
name: "cpu-supertonic"
|
||||||
uri: "quay.io/go-skynet/local-ai-backends:latest-cpu-supertonic"
|
uri: "quay.io/go-skynet/local-ai-backends:latest-cpu-supertonic"
|
||||||
@@ -5494,3 +5508,13 @@
|
|||||||
uri: "quay.io/go-skynet/local-ai-backends:master-cpu-supertonic"
|
uri: "quay.io/go-skynet/local-ai-backends:master-cpu-supertonic"
|
||||||
mirrors:
|
mirrors:
|
||||||
- localai/localai-backends:master-cpu-supertonic
|
- localai/localai-backends:master-cpu-supertonic
|
||||||
|
- !!merge <<: *supertonic
|
||||||
|
name: "metal-supertonic"
|
||||||
|
uri: "quay.io/go-skynet/local-ai-backends:latest-metal-darwin-arm64-supertonic"
|
||||||
|
mirrors:
|
||||||
|
- localai/localai-backends:latest-metal-darwin-arm64-supertonic
|
||||||
|
- !!merge <<: *supertonic
|
||||||
|
name: "metal-supertonic-development"
|
||||||
|
uri: "quay.io/go-skynet/local-ai-backends:master-metal-darwin-arm64-supertonic"
|
||||||
|
mirrors:
|
||||||
|
- localai/localai-backends:master-metal-darwin-arm64-supertonic
|
||||||
|
|||||||
@@ -14,5 +14,11 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# liquid-audio's torch wheels are large; allow upgrades to satisfy transitive pins
|
# liquid-audio's torch wheels are large; allow upgrades to satisfy transitive pins
|
||||||
EXTRA_PIP_INSTALL_FLAGS+=" --upgrade --index-strategy=unsafe-first-match"
|
EXTRA_PIP_INSTALL_FLAGS+=" --upgrade"
|
||||||
|
# --index-strategy is a uv-only flag. The darwin/MPS build installs with pip
|
||||||
|
# (USE_PIP=true in scripts/build/python-darwin.sh), which rejects it. Only add
|
||||||
|
# it on the uv path; Linux/CUDA resolution is unchanged.
|
||||||
|
if [ "x${USE_PIP:-}" != "xtrue" ]; then
|
||||||
|
EXTRA_PIP_INSTALL_FLAGS+=" --index-strategy=unsafe-first-match"
|
||||||
|
fi
|
||||||
installRequirements
|
installRequirements
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
# MPS (Apple Silicon / Metal) build profile - installed by the darwin CI job.
|
||||||
torch>=2.8.0
|
torch>=2.8.0
|
||||||
torchaudio>=2.8.0
|
torchaudio>=2.8.0
|
||||||
torchcodec>=0.9.1
|
torchcodec>=0.9.1
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ type RunCMD struct {
|
|||||||
OIDCIssuer string `env:"LOCALAI_OIDC_ISSUER" help:"OIDC issuer URL for auto-discovery" group:"auth"`
|
OIDCIssuer string `env:"LOCALAI_OIDC_ISSUER" help:"OIDC issuer URL for auto-discovery" group:"auth"`
|
||||||
OIDCClientID string `env:"LOCALAI_OIDC_CLIENT_ID" help:"OIDC Client ID (auto-enables auth)" group:"auth"`
|
OIDCClientID string `env:"LOCALAI_OIDC_CLIENT_ID" help:"OIDC Client ID (auto-enables auth)" group:"auth"`
|
||||||
OIDCClientSecret string `env:"LOCALAI_OIDC_CLIENT_SECRET" help:"OIDC Client Secret" group:"auth"`
|
OIDCClientSecret string `env:"LOCALAI_OIDC_CLIENT_SECRET" help:"OIDC Client Secret" group:"auth"`
|
||||||
AuthBaseURL string `env:"LOCALAI_BASE_URL" help:"Base URL for OAuth callbacks (e.g. http://localhost:8080)" group:"auth"`
|
ExternalBaseURL string `env:"LOCALAI_BASE_URL" help:"External base URL of this instance (e.g. https://localhost:8080). Used for OAuth callbacks and self-referential links (generated images/videos, job status). When unset, derived from X-Forwarded-Proto/Host or Forwarded headers." group:"api"`
|
||||||
AuthAdminEmail string `env:"LOCALAI_ADMIN_EMAIL" help:"Email address to auto-promote to admin role" group:"auth"`
|
AuthAdminEmail string `env:"LOCALAI_ADMIN_EMAIL" help:"Email address to auto-promote to admin role" group:"auth"`
|
||||||
AuthRegistrationMode string `env:"LOCALAI_REGISTRATION_MODE" default:"open" help:"Registration mode: 'open' (default), 'approval', or 'invite' (invite code required)" group:"auth"`
|
AuthRegistrationMode string `env:"LOCALAI_REGISTRATION_MODE" default:"open" help:"Registration mode: 'open' (default), 'approval', or 'invite' (invite code required)" group:"auth"`
|
||||||
DisableLocalAuth bool `env:"LOCALAI_DISABLE_LOCAL_AUTH" default:"false" help:"Disable local email/password registration and login (use with OAuth/OIDC-only setups)" group:"auth"`
|
DisableLocalAuth bool `env:"LOCALAI_DISABLE_LOCAL_AUTH" default:"false" help:"Disable local email/password registration and login (use with OAuth/OIDC-only setups)" group:"auth"`
|
||||||
@@ -503,9 +503,6 @@ func (r *RunCMD) Run(ctx *cliContext.Context) error {
|
|||||||
opts = append(opts, config.WithAuthOIDCClientID(r.OIDCClientID))
|
opts = append(opts, config.WithAuthOIDCClientID(r.OIDCClientID))
|
||||||
opts = append(opts, config.WithAuthOIDCClientSecret(r.OIDCClientSecret))
|
opts = append(opts, config.WithAuthOIDCClientSecret(r.OIDCClientSecret))
|
||||||
}
|
}
|
||||||
if r.AuthBaseURL != "" {
|
|
||||||
opts = append(opts, config.WithAuthBaseURL(r.AuthBaseURL))
|
|
||||||
}
|
|
||||||
if r.AuthAdminEmail != "" {
|
if r.AuthAdminEmail != "" {
|
||||||
opts = append(opts, config.WithAuthAdminEmail(r.AuthAdminEmail))
|
opts = append(opts, config.WithAuthAdminEmail(r.AuthAdminEmail))
|
||||||
}
|
}
|
||||||
@@ -523,6 +520,12 @@ func (r *RunCMD) Run(ctx *cliContext.Context) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Applied unconditionally: the external base URL governs all self-referential
|
||||||
|
// links (not just OAuth callbacks), so it must take effect even when auth is off.
|
||||||
|
if r.ExternalBaseURL != "" {
|
||||||
|
opts = append(opts, config.WithExternalBaseURL(r.ExternalBaseURL))
|
||||||
|
}
|
||||||
|
|
||||||
if idleWatchDog || busyWatchDog {
|
if idleWatchDog || busyWatchDog {
|
||||||
opts = append(opts, config.EnableWatchDog)
|
opts = append(opts, config.EnableWatchDog)
|
||||||
if idleWatchDog {
|
if idleWatchDog {
|
||||||
|
|||||||
@@ -49,6 +49,13 @@ type ApplicationConfig struct {
|
|||||||
P2PNetworkID string
|
P2PNetworkID string
|
||||||
Federated bool
|
Federated bool
|
||||||
|
|
||||||
|
// ExternalBaseURL is the externally visible base URL of this instance
|
||||||
|
// (scheme+host[:port]), set via LOCALAI_BASE_URL. When non-empty it is
|
||||||
|
// authoritative for every self-referential URL LocalAI emits (OAuth
|
||||||
|
// callbacks, generated image/video links, async job StatusURLs),
|
||||||
|
// overriding proxy-header detection. Empty = derive from request headers.
|
||||||
|
ExternalBaseURL string
|
||||||
|
|
||||||
// DisableStats turns off per-request token tracking. By default the
|
// DisableStats turns off per-request token tracking. By default the
|
||||||
// routing module's billing recorder runs in every mode (including
|
// routing module's billing recorder runs in every mode (including
|
||||||
// no-auth single-user) so dashboards and `/api/usage` are immediately
|
// no-auth single-user) so dashboards and `/api/usage` are immediately
|
||||||
@@ -196,7 +203,6 @@ type AuthConfig struct {
|
|||||||
OIDCIssuer string // OIDC issuer URL for auto-discovery (e.g. https://accounts.google.com)
|
OIDCIssuer string // OIDC issuer URL for auto-discovery (e.g. https://accounts.google.com)
|
||||||
OIDCClientID string
|
OIDCClientID string
|
||||||
OIDCClientSecret string
|
OIDCClientSecret string
|
||||||
BaseURL string // for OAuth callback URLs (e.g. "http://localhost:8080")
|
|
||||||
AdminEmail string // auto-promote to admin on login
|
AdminEmail string // auto-promote to admin on login
|
||||||
RegistrationMode string // "open", "approval" (default when empty), "invite"
|
RegistrationMode string // "open", "approval" (default when empty), "invite"
|
||||||
DisableLocalAuth bool // disable local email/password registration and login
|
DisableLocalAuth bool // disable local email/password registration and login
|
||||||
@@ -950,9 +956,9 @@ func WithAuthGitHubClientSecret(clientSecret string) AppOption {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func WithAuthBaseURL(baseURL string) AppOption {
|
func WithExternalBaseURL(url string) AppOption {
|
||||||
return func(o *ApplicationConfig) {
|
return func(o *ApplicationConfig) {
|
||||||
o.Auth.BaseURL = baseURL
|
o.ExternalBaseURL = url
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -149,6 +149,18 @@ func API(application *application.Application) (*echo.Echo, error) {
|
|||||||
// Middleware - StripPathPrefix must be registered early as it uses Rewrite which runs before routing
|
// Middleware - StripPathPrefix must be registered early as it uses Rewrite which runs before routing
|
||||||
e.Pre(httpMiddleware.StripPathPrefix())
|
e.Pre(httpMiddleware.StripPathPrefix())
|
||||||
|
|
||||||
|
// Stamp the configured external base URL into each request context so
|
||||||
|
// middleware.BaseURL can treat it as authoritative for self-referential
|
||||||
|
// links. Registered as Pre so it runs before routing and handlers.
|
||||||
|
if extBaseURL := application.ApplicationConfig().ExternalBaseURL; extBaseURL != "" {
|
||||||
|
e.Pre(func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
|
return func(c echo.Context) error {
|
||||||
|
c.Set("_external_base_url", extBaseURL)
|
||||||
|
return next(c)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
e.Pre(middleware.RemoveTrailingSlash())
|
e.Pre(middleware.RemoveTrailingSlash())
|
||||||
|
|
||||||
if application.ApplicationConfig().MachineTag != "" {
|
if application.ApplicationConfig().MachineTag != "" {
|
||||||
|
|||||||
@@ -432,7 +432,7 @@ func loadSoundDetectionConfig(pipeline *config.Pipeline, cl *config.ModelConfigL
|
|||||||
if pipeline.SoundDetection == "" {
|
if pipeline.SoundDetection == "" {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
cfg, err := cl.LoadModelConfigFileByName(pipeline.SoundDetection, ml.ModelPath)
|
cfg, err := loadPipelineSubModel(cl, pipeline.SoundDetection, ml.ModelPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to load sound detection config: %w", err)
|
return nil, fmt.Errorf("failed to load sound detection config: %w", err)
|
||||||
}
|
}
|
||||||
@@ -443,7 +443,7 @@ func loadSoundDetectionConfig(pipeline *config.Pipeline, cl *config.ModelConfigL
|
|||||||
}
|
}
|
||||||
|
|
||||||
func newTranscriptionOnlyModel(pipeline *config.Pipeline, cl *config.ModelConfigLoader, ml *model.ModelLoader, appConfig *config.ApplicationConfig) (Model, *config.ModelConfig, error) {
|
func newTranscriptionOnlyModel(pipeline *config.Pipeline, cl *config.ModelConfigLoader, ml *model.ModelLoader, appConfig *config.ApplicationConfig) (Model, *config.ModelConfig, error) {
|
||||||
cfgVAD, err := cl.LoadModelConfigFileByName(pipeline.VAD, ml.ModelPath)
|
cfgVAD, err := loadPipelineSubModel(cl, pipeline.VAD, ml.ModelPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
||||||
return nil, nil, fmt.Errorf("failed to load backend config: %w", err)
|
return nil, nil, fmt.Errorf("failed to load backend config: %w", err)
|
||||||
@@ -453,7 +453,7 @@ func newTranscriptionOnlyModel(pipeline *config.Pipeline, cl *config.ModelConfig
|
|||||||
return nil, nil, fmt.Errorf("failed to validate config: %w", err)
|
return nil, nil, fmt.Errorf("failed to validate config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cfgSST, err := cl.LoadModelConfigFileByName(pipeline.Transcription, ml.ModelPath)
|
cfgSST, err := loadPipelineSubModel(cl, pipeline.Transcription, ml.ModelPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
||||||
return nil, nil, fmt.Errorf("failed to load backend config: %w", err)
|
return nil, nil, fmt.Errorf("failed to load backend config: %w", err)
|
||||||
@@ -542,11 +542,30 @@ func buildRealtimeRoutingContext(a *application.Application, sessionID string) *
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// loadPipelineSubModel loads a pipeline sub-model config by name and follows a
|
||||||
|
// single alias hop, so a pipeline that references an alias (e.g. `llm: default`)
|
||||||
|
// gets the alias target's full config (Backend, Model, ...) rather than the
|
||||||
|
// alias stub with an empty Backend. Without this the alias survives unresolved
|
||||||
|
// into model loading and fails downstream — notably in distributed mode with
|
||||||
|
// "backend name is empty". Mirrors the top-level alias resolution in
|
||||||
|
// core/http/middleware/request.go.
|
||||||
|
func loadPipelineSubModel(cl *config.ModelConfigLoader, name, modelPath string) (*config.ModelConfig, error) {
|
||||||
|
cfg, err := cl.LoadModelConfigFileByName(name, modelPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resolved, _, err := cl.ResolveAlias(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return resolved, nil
|
||||||
|
}
|
||||||
|
|
||||||
// returns and loads either a wrapped model or a model that support audio-to-audio
|
// returns and loads either a wrapped model or a model that support audio-to-audio
|
||||||
func newModel(pipeline *config.Pipeline, cl *config.ModelConfigLoader, ml *model.ModelLoader, appConfig *config.ApplicationConfig, evaluator *templates.Evaluator, routing *RealtimeRoutingContext) (Model, error) {
|
func newModel(pipeline *config.Pipeline, cl *config.ModelConfigLoader, ml *model.ModelLoader, appConfig *config.ApplicationConfig, evaluator *templates.Evaluator, routing *RealtimeRoutingContext) (Model, error) {
|
||||||
xlog.Debug("Creating new model pipeline model", "pipeline", pipeline)
|
xlog.Debug("Creating new model pipeline model", "pipeline", pipeline)
|
||||||
|
|
||||||
cfgVAD, err := cl.LoadModelConfigFileByName(pipeline.VAD, ml.ModelPath)
|
cfgVAD, err := loadPipelineSubModel(cl, pipeline.VAD, ml.ModelPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to load backend config: %w", err)
|
return nil, fmt.Errorf("failed to load backend config: %w", err)
|
||||||
@@ -557,7 +576,7 @@ func newModel(pipeline *config.Pipeline, cl *config.ModelConfigLoader, ml *model
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Do we always need a transcription model? It can be disabled. Note that any-to-any instruction following models don't transcribe as such, so if transcription is required it is a separate process
|
// TODO: Do we always need a transcription model? It can be disabled. Note that any-to-any instruction following models don't transcribe as such, so if transcription is required it is a separate process
|
||||||
cfgSST, err := cl.LoadModelConfigFileByName(pipeline.Transcription, ml.ModelPath)
|
cfgSST, err := loadPipelineSubModel(cl, pipeline.Transcription, ml.ModelPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to load backend config: %w", err)
|
return nil, fmt.Errorf("failed to load backend config: %w", err)
|
||||||
@@ -589,7 +608,7 @@ func newModel(pipeline *config.Pipeline, cl *config.ModelConfigLoader, ml *model
|
|||||||
xlog.Debug("Loading a wrapped model")
|
xlog.Debug("Loading a wrapped model")
|
||||||
|
|
||||||
// Otherwise we want to return a wrapped model, which is a "virtual" model that re-uses other models to perform operations
|
// Otherwise we want to return a wrapped model, which is a "virtual" model that re-uses other models to perform operations
|
||||||
cfgLLM, err := cl.LoadModelConfigFileByName(pipeline.LLM, ml.ModelPath)
|
cfgLLM, err := loadPipelineSubModel(cl, pipeline.LLM, ml.ModelPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to load backend config: %w", err)
|
return nil, fmt.Errorf("failed to load backend config: %w", err)
|
||||||
@@ -604,7 +623,7 @@ func newModel(pipeline *config.Pipeline, cl *config.ModelConfigLoader, ml *model
|
|||||||
applyPipelineReasoning(cfgLLM, *pipeline)
|
applyPipelineReasoning(cfgLLM, *pipeline)
|
||||||
applyPipelineThinking(cfgLLM, *pipeline)
|
applyPipelineThinking(cfgLLM, *pipeline)
|
||||||
|
|
||||||
cfgTTS, err := cl.LoadModelConfigFileByName(pipeline.TTS, ml.ModelPath)
|
cfgTTS, err := loadPipelineSubModel(cl, pipeline.TTS, ml.ModelPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to load backend config: %w", err)
|
return nil, fmt.Errorf("failed to load backend config: %w", err)
|
||||||
|
|||||||
52
core/http/endpoints/openai/realtime_model_alias_test.go
Normal file
52
core/http/endpoints/openai/realtime_model_alias_test.go
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
package openai
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
. "github.com/onsi/ginkgo/v2"
|
||||||
|
. "github.com/onsi/gomega"
|
||||||
|
|
||||||
|
"github.com/mudler/LocalAI/core/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// loadPipelineSubModel must resolve a pipeline sub-model that references an
|
||||||
|
// alias (e.g. `llm: default`) one hop to the alias target's full config — so
|
||||||
|
// the effective backend is the target's backend, not the empty backend of the
|
||||||
|
// alias stub. This mirrors the top-level alias resolution done in
|
||||||
|
// core/http/middleware/request.go, which the realtime pipeline previously
|
||||||
|
// skipped (failing in distributed mode with "backend name is empty").
|
||||||
|
var _ = Describe("loadPipelineSubModel", func() {
|
||||||
|
It("resolves a sub-model alias one hop to the target's config", func() {
|
||||||
|
tmpDir := GinkgoT().TempDir()
|
||||||
|
|
||||||
|
// A real model config with a concrete backend.
|
||||||
|
realLLM := `name: real-llm
|
||||||
|
backend: llama-cpp
|
||||||
|
parameters:
|
||||||
|
model: real-llm.gguf
|
||||||
|
`
|
||||||
|
Expect(os.WriteFile(filepath.Join(tmpDir, "real-llm.yaml"), []byte(realLLM), 0644)).To(Succeed())
|
||||||
|
|
||||||
|
// An alias pointing at the real model.
|
||||||
|
aliasCfg := `name: default
|
||||||
|
alias: real-llm
|
||||||
|
`
|
||||||
|
Expect(os.WriteFile(filepath.Join(tmpDir, "default.yaml"), []byte(aliasCfg), 0644)).To(Succeed())
|
||||||
|
|
||||||
|
cl := config.NewModelConfigLoader(tmpDir)
|
||||||
|
Expect(cl.LoadModelConfigsFromPath(tmpDir)).To(Succeed())
|
||||||
|
|
||||||
|
// Resolving the alias must follow the hop to the target's full config.
|
||||||
|
resolved, err := loadPipelineSubModel(cl, "default", tmpDir)
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
Expect(resolved.IsAlias()).To(BeFalse())
|
||||||
|
Expect(resolved.Backend).To(Equal("llama-cpp"))
|
||||||
|
|
||||||
|
// A non-alias name must load unchanged.
|
||||||
|
direct, err := loadPipelineSubModel(cl, "real-llm", tmpDir)
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
Expect(direct.Backend).To(Equal("llama-cpp"))
|
||||||
|
Expect(direct.Name).To(Equal("real-llm"))
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -55,17 +55,70 @@ func BasePathPrefix(c echo.Context) string {
|
|||||||
// The returned URL is guaranteed to end with `/`.
|
// The returned URL is guaranteed to end with `/`.
|
||||||
// The method should be used in conjunction with the StripPathPrefix middleware.
|
// The method should be used in conjunction with the StripPathPrefix middleware.
|
||||||
func BaseURL(c echo.Context) string {
|
func BaseURL(c echo.Context) string {
|
||||||
|
// An explicit external base URL (LOCALAI_BASE_URL) is authoritative for
|
||||||
|
// the origin. The proxy-derived path prefix is still appended so a
|
||||||
|
// reverse-proxy mount point keeps working. Trailing slashes are
|
||||||
|
// normalized via BasePathPrefix, which always starts and ends with "/".
|
||||||
|
if ext, ok := c.Get("_external_base_url").(string); ok && ext != "" {
|
||||||
|
return strings.TrimRight(ext, "/") + BasePathPrefix(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
fwdProto, fwdHost := parseForwarded(c.Request().Header.Get("Forwarded"))
|
||||||
|
|
||||||
scheme := "http"
|
scheme := "http"
|
||||||
if c.Request().Header.Get("X-Forwarded-Proto") == "https" {
|
switch {
|
||||||
|
case c.Request().TLS != nil:
|
||||||
scheme = "https"
|
scheme = "https"
|
||||||
} else if c.Request().TLS != nil {
|
case strings.EqualFold(firstToken(c.Request().Header.Get("X-Forwarded-Proto")), "https"):
|
||||||
|
scheme = "https"
|
||||||
|
case strings.EqualFold(fwdProto, "https"):
|
||||||
scheme = "https"
|
scheme = "https"
|
||||||
}
|
}
|
||||||
|
|
||||||
host := c.Request().Host
|
host := c.Request().Host
|
||||||
if forwardedHost := c.Request().Header.Get("X-Forwarded-Host"); forwardedHost != "" {
|
if forwardedHost := c.Request().Header.Get("X-Forwarded-Host"); forwardedHost != "" {
|
||||||
host = forwardedHost
|
host = forwardedHost
|
||||||
|
} else if fwdHost != "" {
|
||||||
|
host = fwdHost
|
||||||
}
|
}
|
||||||
|
|
||||||
return scheme + "://" + host + BasePathPrefix(c)
|
return scheme + "://" + host + BasePathPrefix(c)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// firstToken returns the first comma-separated token of v, trimmed of spaces.
|
||||||
|
// Reverse-proxy chains can emit X-Forwarded-Proto as "https,http"; only the
|
||||||
|
// first hop (closest to the client) is meaningful for scheme detection.
|
||||||
|
func firstToken(v string) string {
|
||||||
|
if i := strings.IndexByte(v, ','); i >= 0 {
|
||||||
|
v = v[:i]
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseForwarded extracts the proto and host directives from the first element
|
||||||
|
// of an RFC 7239 Forwarded header (e.g. `for=x;proto=https;host=h, for=y`).
|
||||||
|
// Values may be quoted. Returns empty strings when absent or malformed so the
|
||||||
|
// caller can fall through to other signals.
|
||||||
|
func parseForwarded(header string) (proto, host string) {
|
||||||
|
if header == "" {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
// Only the first element (closest proxy to the client) matters here.
|
||||||
|
if i := strings.IndexByte(header, ','); i >= 0 {
|
||||||
|
header = header[:i]
|
||||||
|
}
|
||||||
|
for _, directive := range strings.Split(header, ";") {
|
||||||
|
key, value, ok := strings.Cut(strings.TrimSpace(directive), "=")
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
value = strings.Trim(strings.TrimSpace(value), `"`)
|
||||||
|
switch strings.ToLower(strings.TrimSpace(key)) {
|
||||||
|
case "proto":
|
||||||
|
proto = value
|
||||||
|
case "host":
|
||||||
|
host = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return proto, host
|
||||||
|
}
|
||||||
|
|||||||
@@ -135,4 +135,138 @@ var _ = Describe("BaseURL", func() {
|
|||||||
Entry("missing leading slash", "evil"),
|
Entry("missing leading slash", "evil"),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
Context("scheme detection hardening", func() {
|
||||||
|
It("treats comma-separated X-Forwarded-Proto as https when first token is https", func() {
|
||||||
|
app := echo.New()
|
||||||
|
actualURL := ""
|
||||||
|
app.GET("/x", func(c echo.Context) error {
|
||||||
|
actualURL = BaseURL(c)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
req := httptest.NewRequest("GET", "/x", nil)
|
||||||
|
req.Header.Set("X-Forwarded-Proto", "https,http")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
app.ServeHTTP(rec, req)
|
||||||
|
Expect(actualURL).To(Equal("https://example.com/"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("derives https from the RFC 7239 Forwarded proto directive", func() {
|
||||||
|
app := echo.New()
|
||||||
|
actualURL := ""
|
||||||
|
app.GET("/x", func(c echo.Context) error {
|
||||||
|
actualURL = BaseURL(c)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
req := httptest.NewRequest("GET", "/x", nil)
|
||||||
|
req.Header.Set("Forwarded", "for=192.0.2.1;proto=https;host=proxy.example")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
app.ServeHTTP(rec, req)
|
||||||
|
Expect(actualURL).To(Equal("https://proxy.example/"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("prefers X-Forwarded-Host over the Forwarded host directive", func() {
|
||||||
|
app := echo.New()
|
||||||
|
actualURL := ""
|
||||||
|
app.GET("/x", func(c echo.Context) error {
|
||||||
|
actualURL = BaseURL(c)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
req := httptest.NewRequest("GET", "/x", nil)
|
||||||
|
req.Header.Set("X-Forwarded-Host", "xfh.example")
|
||||||
|
req.Header.Set("Forwarded", "host=fwd.example;proto=https")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
app.ServeHTTP(rec, req)
|
||||||
|
Expect(actualURL).To(Equal("https://xfh.example/"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Context("explicit external base URL override", func() {
|
||||||
|
It("uses the configured origin over conflicting forwarded headers", func() {
|
||||||
|
app := echo.New()
|
||||||
|
actualURL := ""
|
||||||
|
app.GET("/x", func(c echo.Context) error {
|
||||||
|
c.Set("_external_base_url", "https://192.168.0.13:34567")
|
||||||
|
actualURL = BaseURL(c)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
req := httptest.NewRequest("GET", "/x", nil)
|
||||||
|
req.Header.Set("X-Forwarded-Proto", "http")
|
||||||
|
req.Header.Set("X-Forwarded-Host", "internal:8080")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
app.ServeHTTP(rec, req)
|
||||||
|
Expect(actualURL).To(Equal("https://192.168.0.13:34567/"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("combines the configured origin with a detected path prefix", func() {
|
||||||
|
app := echo.New()
|
||||||
|
actualURL := ""
|
||||||
|
app.GET("/hello", func(c echo.Context) error {
|
||||||
|
c.Set("_original_path", "/localai/hello")
|
||||||
|
c.Set("_external_base_url", "https://ext.example")
|
||||||
|
actualURL = BaseURL(c)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
req := httptest.NewRequest("GET", "/hello", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
app.ServeHTTP(rec, req)
|
||||||
|
Expect(actualURL).To(Equal("https://ext.example/localai/"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("ignores an empty override", func() {
|
||||||
|
app := echo.New()
|
||||||
|
actualURL := ""
|
||||||
|
app.GET("/x", func(c echo.Context) error {
|
||||||
|
c.Set("_external_base_url", "")
|
||||||
|
actualURL = BaseURL(c)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
req := httptest.NewRequest("GET", "/x", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
app.ServeHTTP(rec, req)
|
||||||
|
Expect(actualURL).To(Equal("http://example.com/"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Context("parseForwarded helper", func() {
|
||||||
|
It("parses unquoted proto and host", func() {
|
||||||
|
proto, host := parseForwarded("for=192.0.2.1;proto=https;host=h.example")
|
||||||
|
Expect(proto).To(Equal("https"))
|
||||||
|
Expect(host).To(Equal("h.example"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("strips quotes around values", func() {
|
||||||
|
proto, host := parseForwarded(`proto="https";host="h.example"`)
|
||||||
|
Expect(proto).To(Equal("https"))
|
||||||
|
Expect(host).To(Equal("h.example"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("uses only the first element of a multi-element header", func() {
|
||||||
|
proto, host := parseForwarded("proto=https;host=first.example, proto=http;host=second.example")
|
||||||
|
Expect(proto).To(Equal("https"))
|
||||||
|
Expect(host).To(Equal("first.example"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns empty strings for an empty header", func() {
|
||||||
|
proto, host := parseForwarded("")
|
||||||
|
Expect(proto).To(BeEmpty())
|
||||||
|
Expect(host).To(BeEmpty())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("skips directives without a value", func() {
|
||||||
|
proto, host := parseForwarded("proto;host=h.example")
|
||||||
|
Expect(proto).To(BeEmpty())
|
||||||
|
Expect(host).To(Equal("h.example"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Context("firstToken helper", func() {
|
||||||
|
It("returns the whole trimmed string when there is no comma", func() {
|
||||||
|
Expect(firstToken(" https ")).To(Equal("https"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns the first trimmed token when there is a comma", func() {
|
||||||
|
Expect(firstToken("https , http")).To(Equal("https"))
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -86,6 +86,7 @@
|
|||||||
"input": {
|
"input": {
|
||||||
"placeholder": "Message...",
|
"placeholder": "Message...",
|
||||||
"attachFile": "Attach file",
|
"attachFile": "Attach file",
|
||||||
|
"send": "Send message",
|
||||||
"stopGenerating": "Stop generating",
|
"stopGenerating": "Stop generating",
|
||||||
"canvasTitle": "Canvas — extract code blocks and media into a side panel for preview, copy, and download",
|
"canvasTitle": "Canvas — extract code blocks and media into a side panel for preview, copy, and download",
|
||||||
"canvasLabel": "Canvas",
|
"canvasLabel": "Canvas",
|
||||||
|
|||||||
@@ -77,6 +77,20 @@
|
|||||||
"noModelsTitle": "No Models Available",
|
"noModelsTitle": "No Models Available",
|
||||||
"noModelsBody": "There are no models installed yet. Ask your administrator to set up models so you can start chatting."
|
"noModelsBody": "There are no models installed yet. Ask your administrator to set up models so you can start chatting."
|
||||||
},
|
},
|
||||||
|
"starters": {
|
||||||
|
"title": "Recommended for your hardware",
|
||||||
|
"tier": {
|
||||||
|
"cpu": "CPU-only",
|
||||||
|
"gpu-small": "GPU",
|
||||||
|
"gpu-large": "GPU"
|
||||||
|
},
|
||||||
|
"cpuNote": "No GPU detected — these small models stay responsive on CPU.",
|
||||||
|
"gpuNote": "Picked to fit your available VRAM with room for context.",
|
||||||
|
"install": "Install",
|
||||||
|
"installing": "Installing",
|
||||||
|
"installStarted": "Installing {{model}}…",
|
||||||
|
"installFailed": "Install failed: {{message}}"
|
||||||
|
},
|
||||||
"connect": {
|
"connect": {
|
||||||
"title": "One endpoint, every API",
|
"title": "One endpoint, every API",
|
||||||
"subtitle": "LocalAI serves its own full API — image & video generation, depth, object detection, reranking, audio, face & voice recognition, and realtime voice over WebRTC and WebSocket. On top of that, a drop-in compatibility layer lets any app built for OpenAI, Anthropic, Ollama or OpenAI Responses talk to it unchanged.",
|
"subtitle": "LocalAI serves its own full API — image & video generation, depth, object detection, reranking, audio, face & voice recognition, and realtime voice over WebRTC and WebSocket. On top of that, a drop-in compatibility layer lets any app built for OpenAI, Anthropic, Ollama or OpenAI Responses talk to it unchanged.",
|
||||||
|
|||||||
@@ -45,7 +45,7 @@
|
|||||||
},
|
},
|
||||||
"scheduling": {
|
"scheduling": {
|
||||||
"title": "Penjadwalan",
|
"title": "Penjadwalan",
|
||||||
"subtitle": "Aturan penempatan model dan replika di seluruh klaster"
|
"subtitle": "Aturan penempatan model dan replika di seluruh kluster"
|
||||||
},
|
},
|
||||||
"p2p": {
|
"p2p": {
|
||||||
"title": "Komputasi AI Terdistribusi",
|
"title": "Komputasi AI Terdistribusi",
|
||||||
|
|||||||
@@ -72,7 +72,7 @@
|
|||||||
"actions": {
|
"actions": {
|
||||||
"copy": "Salin",
|
"copy": "Salin",
|
||||||
"regenerate": "Hasilkan ulang",
|
"regenerate": "Hasilkan ulang",
|
||||||
"jumpToLatest": "Jump to latest"
|
"jumpToLatest": "Lompat ke terbaru"
|
||||||
},
|
},
|
||||||
"streaming": {
|
"streaming": {
|
||||||
"transferring": "Mentransfer model...",
|
"transferring": "Mentransfer model...",
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"unsaved": {
|
"unsaved": {
|
||||||
"title": "Discard unsaved changes?",
|
"title": "Buang perubahan yang belum disimpan?",
|
||||||
"message": "You have unsaved changes that will be lost if you leave this page.",
|
"message": "Anda memiliki perubahan yang belum disimpan. Perubahan tersebut akan hilang jika Anda meninggalkan halaman ini.",
|
||||||
"leave": "Leave"
|
"leave": "Tinggalkan Halaman"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"save": "Simpan",
|
"save": "Simpan",
|
||||||
|
|||||||
@@ -7,15 +7,15 @@
|
|||||||
"resourceGpu": "GPU",
|
"resourceGpu": "GPU",
|
||||||
"resourceRam": "RAM",
|
"resourceRam": "RAM",
|
||||||
"greeting": {
|
"greeting": {
|
||||||
"morning": "Good morning",
|
"morning": "Selamat pagi",
|
||||||
"afternoon": "Good afternoon",
|
"afternoon": "Selamat siang",
|
||||||
"evening": "Good evening",
|
"evening": "Selamat malam",
|
||||||
"night": "Working late"
|
"night": "Selamat lembur"
|
||||||
},
|
},
|
||||||
"statusLine": {
|
"statusLine": {
|
||||||
"modelsLoaded_one": "{{count}} model loaded",
|
"modelsLoaded_one": "{{count}} model dimuat",
|
||||||
"modelsLoaded_other": "{{count}} models loaded",
|
"modelsLoaded_other": "{{count}} model dimuat",
|
||||||
"noModelsLoaded": "No models loaded",
|
"noModelsLoaded": "Tidak ada model yang dimuat",
|
||||||
"nodes_one": "{{count}} node",
|
"nodes_one": "{{count}} node",
|
||||||
"nodes_other": "{{count}} nodes"
|
"nodes_other": "{{count}} nodes"
|
||||||
},
|
},
|
||||||
@@ -79,14 +79,14 @@
|
|||||||
},
|
},
|
||||||
"connect": {
|
"connect": {
|
||||||
"title": "Satu endpoint, semua API",
|
"title": "Satu endpoint, semua API",
|
||||||
"subtitle": "LocalAI menyediakan API miliknya sendiri yang lengkap — pembuatan gambar & video, depth, deteksi objek, reranking, audio, pengenalan wajah & suara, serta suara realtime melalui WebRTC dan WebSocket. Di atas itu, lapisan kompatibilitas drop-in membuat aplikasi apa pun yang dibuat untuk OpenAI, Anthropic, Ollama, atau OpenAI Responses bekerja tanpa perubahan.",
|
"subtitle": "LocalAI menyediakan API miliknya sendiri yang lengkap — pembuatan gambar & video, depth, deteksi objek, reranking, audio, pengenalan wajah & suara, serta suara realtime melalui WebRTC dan WebSocket. Selain itu, lapisan kompatibilitas drop-in membuat aplikasi apa pun yang dibuat untuk OpenAI, Anthropic, Ollama, atau OpenAI Responses bekerja tanpa perubahan.",
|
||||||
"nativeTitle": "API native",
|
"nativeTitle": "API native",
|
||||||
"compatTitle": "Kompatibilitas drop-in",
|
"compatTitle": "Kompatibilitas drop-in",
|
||||||
"apiReference": "Referensi API lengkap",
|
"apiReference": "Referensi API lengkap",
|
||||||
"copy": "Salin",
|
"copy": "Salin",
|
||||||
"copied": "Disalin",
|
"copied": "Disalin",
|
||||||
"browse": "Browse the API",
|
"browse": "Jelajahi API",
|
||||||
"hide": "Hide endpoints",
|
"hide": "Sembunyikan endpoint",
|
||||||
"dismiss": "Dismiss"
|
"dismiss": "Abaikan"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"video": "Video",
|
"video": "Video",
|
||||||
"tts": "TTS",
|
"tts": "TTS",
|
||||||
"sound": "Suara",
|
"sound": "Suara",
|
||||||
"transform": "Transform"
|
"transform": "Transformasi"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"image": {
|
"image": {
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
"refImagesAdded_other": "{{count}} gambar ditambahkan"
|
"refImagesAdded_other": "{{count}} gambar ditambahkan"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"view": "View",
|
"view": "Lihat",
|
||||||
"generate": "Hasilkan",
|
"generate": "Hasilkan",
|
||||||
"generating": "Menghasilkan..."
|
"generating": "Menghasilkan..."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -19,11 +19,11 @@
|
|||||||
"operate": "Operasikan"
|
"operate": "Operasikan"
|
||||||
},
|
},
|
||||||
"operate": {
|
"operate": {
|
||||||
"inference": "Inference",
|
"inference": "Inferensi",
|
||||||
"cluster": "Cluster",
|
"cluster": "Kluster",
|
||||||
"observability": "Observability",
|
"observability": "Observabilitas",
|
||||||
"access": "Access",
|
"access": "Akses",
|
||||||
"system": "System"
|
"system": "Sistem"
|
||||||
},
|
},
|
||||||
"items": {
|
"items": {
|
||||||
"home": "Beranda",
|
"home": "Beranda",
|
||||||
@@ -64,7 +64,7 @@
|
|||||||
"copyright": "© 2023-{{year}} {{author}}"
|
"copyright": "© 2023-{{year}} {{author}}"
|
||||||
},
|
},
|
||||||
"console": {
|
"console": {
|
||||||
"automation": "Otomasi",
|
"automation": "Automasi",
|
||||||
"training": "Pelatihan"
|
"training": "Pelatihan"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6363,6 +6363,59 @@ select.input {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ──────────────────── Home: hardware-aware starter models ──────────────────── */
|
||||||
|
|
||||||
|
.home-starters {
|
||||||
|
margin: var(--spacing-lg) 0;
|
||||||
|
padding: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
.home-starters-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
}
|
||||||
|
.home-starters-head strong {
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
}
|
||||||
|
.home-starters-tier {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
.home-starters-sub {
|
||||||
|
margin: var(--spacing-xs) 0 var(--spacing-md);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
.home-starters-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
.home-starters-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
padding: var(--spacing-xs) 0;
|
||||||
|
}
|
||||||
|
.home-starters-name {
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.home-starters-size {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
/* ──────────────────── Home: drop-in endpoint / API compatibility ──────────────────── */
|
/* ──────────────────── Home: drop-in endpoint / API compatibility ──────────────────── */
|
||||||
|
|
||||||
.home-connect {
|
.home-connect {
|
||||||
|
|||||||
@@ -1,8 +1,25 @@
|
|||||||
import { useEffect, useMemo } from 'react'
|
import { useEffect, useMemo, useCallback } from 'react'
|
||||||
import { useModels } from '../hooks/useModels'
|
import { useModels } from '../hooks/useModels'
|
||||||
import SearchableSelect from './SearchableSelect'
|
import SearchableSelect from './SearchableSelect'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
// Remember the last model the user picked, keyed by capability, so returning to
|
||||||
|
// a page (Home chat box, Image, TTS, Talk...) defaults to that model instead of
|
||||||
|
// whatever happens to sort first. Only persisted when a capability key exists —
|
||||||
|
// `externalOptions` callers pass no capability and get the old first-item
|
||||||
|
// behaviour. localStorage access is wrapped because private-browsing modes throw.
|
||||||
|
const LAST_MODEL_PREFIX = 'localai_last_model:'
|
||||||
|
|
||||||
|
function readLastModel(capability) {
|
||||||
|
if (!capability) return null
|
||||||
|
try { return localStorage.getItem(LAST_MODEL_PREFIX + capability) } catch { return null }
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeLastModel(capability, model) {
|
||||||
|
if (!capability || !model) return
|
||||||
|
try { localStorage.setItem(LAST_MODEL_PREFIX + capability, model) } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
export default function ModelSelector({
|
export default function ModelSelector({
|
||||||
value, onChange, capability, className = '',
|
value, onChange, capability, className = '',
|
||||||
options: externalOptions, loading: externalLoading,
|
options: externalOptions, loading: externalLoading,
|
||||||
@@ -19,16 +36,27 @@ export default function ModelSelector({
|
|||||||
const isLoading = externalOptions ? (externalLoading || false) : hookLoading
|
const isLoading = externalOptions ? (externalLoading || false) : hookLoading
|
||||||
const isDisabled = isLoading || (externalDisabled || false)
|
const isDisabled = isLoading || (externalDisabled || false)
|
||||||
|
|
||||||
|
// Persist genuine selections so the next visit can restore them.
|
||||||
|
const handleChange = useCallback((next) => {
|
||||||
|
writeLastModel(capability, next)
|
||||||
|
onChange(next)
|
||||||
|
}, [capability, onChange])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (modelNames.length > 0 && (!value || !modelNames.includes(value))) {
|
if (modelNames.length > 0 && (!value || !modelNames.includes(value))) {
|
||||||
onChange(modelNames[0])
|
// Prefer the remembered model when it's still available; otherwise fall
|
||||||
|
// back to the first option. Don't re-persist here — auto-select is not a
|
||||||
|
// user choice, and writing back the stored value would be a harmless but
|
||||||
|
// pointless round-trip.
|
||||||
|
const remembered = readLastModel(capability)
|
||||||
|
onChange(remembered && modelNames.includes(remembered) ? remembered : modelNames[0])
|
||||||
}
|
}
|
||||||
}, [modelNames, value, onChange])
|
}, [modelNames, value, onChange, capability])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SearchableSelect
|
<SearchableSelect
|
||||||
value={value || ''}
|
value={value || ''}
|
||||||
onChange={onChange}
|
onChange={handleChange}
|
||||||
options={modelNames}
|
options={modelNames}
|
||||||
placeholder={isLoading ? t('selector.loading') : (modelNames.length === 0 ? t('selector.noModels') : t('selector.selectModel'))}
|
placeholder={isLoading ? t('selector.loading') : (modelNames.length === 0 ? t('selector.noModels') : t('selector.selectModel'))}
|
||||||
searchPlaceholder={searchPlaceholder || t('selector.searchPlaceholder')}
|
searchPlaceholder={searchPlaceholder || t('selector.searchPlaceholder')}
|
||||||
|
|||||||
129
core/http/react-ui/src/components/StarterModels.jsx
Normal file
129
core/http/react-ui/src/components/StarterModels.jsx
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import { useState, useEffect, useMemo } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { modelsApi } from '../utils/api'
|
||||||
|
import { useResources } from '../hooks/useResources'
|
||||||
|
|
||||||
|
// Curated, hardware-tiered starter models for the empty-state onboarding. Names
|
||||||
|
// are real gallery entries (gallery/index.yaml); we intersect them against the
|
||||||
|
// live gallery at render time so a custom/trimmed gallery degrades gracefully
|
||||||
|
// (unmatched entries simply don't render).
|
||||||
|
//
|
||||||
|
// The guiding rule the maintainer asked for: CPU-only machines should be
|
||||||
|
// steered to genuinely small models (1-4B, Q4) that stay responsive without a
|
||||||
|
// GPU. GPU tiers scale the suggestion up with available VRAM.
|
||||||
|
const SMALL = [
|
||||||
|
{ name: 'llama-3.2-1b-instruct:q4_k_m', size: '~0.8 GB' },
|
||||||
|
{ name: 'llama-3.2-3b-instruct:q4_k_m', size: '~2 GB' },
|
||||||
|
{ name: 'qwen3-1.7b', size: '~1.4 GB' },
|
||||||
|
{ name: 'gemma-3-1b-it', size: '~0.8 GB' },
|
||||||
|
]
|
||||||
|
const MID = [
|
||||||
|
{ name: 'qwen3-4b', size: '~2.5 GB' },
|
||||||
|
{ name: 'gemma-3-4b-it', size: '~3 GB' },
|
||||||
|
{ name: 'llama-3.2-3b-instruct:q4_k_m', size: '~2 GB' },
|
||||||
|
]
|
||||||
|
const LARGE = [
|
||||||
|
{ name: 'meta-llama-3.1-8b-instruct', size: '~5 GB' },
|
||||||
|
{ name: 'qwen3-4b', size: '~2.5 GB' },
|
||||||
|
{ name: 'mistral-7b-instruct-v0.3', size: '~4 GB' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const GB = 1024 * 1024 * 1024
|
||||||
|
|
||||||
|
// Pick a tier from detected hardware. total_memory is GPU VRAM in bytes (0 when
|
||||||
|
// CPU-only). Thresholds are deliberately conservative so a suggestion that
|
||||||
|
// "fits" really does.
|
||||||
|
function pickTier(resources) {
|
||||||
|
const isGpu = resources?.type === 'gpu'
|
||||||
|
const vram = resources?.aggregate?.total_memory || 0
|
||||||
|
if (!isGpu || vram <= 0) return { id: 'cpu', list: SMALL }
|
||||||
|
if (vram < 8 * GB) return { id: 'gpu-small', list: MID }
|
||||||
|
return { id: 'gpu-large', list: LARGE }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StarterModels({ addToast, onInstallStarted }) {
|
||||||
|
const { t } = useTranslation('home')
|
||||||
|
const { resources } = useResources()
|
||||||
|
const [available, setAvailable] = useState(null) // Set of gallery names, or null while loading
|
||||||
|
const [installing, setInstalling] = useState(() => new Set())
|
||||||
|
|
||||||
|
const tier = useMemo(() => pickTier(resources), [resources])
|
||||||
|
const candidates = tier.list
|
||||||
|
|
||||||
|
// Verify candidates exist in the live gallery. One search per name (the tier
|
||||||
|
// has at most a handful) keeps this resilient to gallery customization.
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
const names = [...new Set(candidates.map(c => c.name))]
|
||||||
|
Promise.all(names.map(name =>
|
||||||
|
modelsApi.list({ search: name, page: 1 })
|
||||||
|
.then(data => (data?.models || []).some(m => (m.name || m.id) === name) ? name : null)
|
||||||
|
.catch(() => null)
|
||||||
|
)).then(found => {
|
||||||
|
if (cancelled) return
|
||||||
|
const hits = found.filter(Boolean)
|
||||||
|
// If verification yielded nothing (e.g. gallery unreachable), fall back to
|
||||||
|
// showing the curated list rather than an empty widget.
|
||||||
|
setAvailable(hits.length > 0 ? new Set(hits) : null)
|
||||||
|
})
|
||||||
|
return () => { cancelled = true }
|
||||||
|
}, [candidates])
|
||||||
|
|
||||||
|
const visible = available === null
|
||||||
|
? candidates
|
||||||
|
: candidates.filter(c => available.has(c.name))
|
||||||
|
|
||||||
|
if (visible.length === 0) return null
|
||||||
|
|
||||||
|
const install = async (name) => {
|
||||||
|
setInstalling(prev => new Set(prev).add(name))
|
||||||
|
try {
|
||||||
|
await modelsApi.install(name)
|
||||||
|
addToast?.(t('starters.installStarted', { model: name }), 'success')
|
||||||
|
onInstallStarted?.(name)
|
||||||
|
} catch (err) {
|
||||||
|
addToast?.(t('starters.installFailed', { message: err.message }), 'error')
|
||||||
|
setInstalling(prev => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
next.delete(name)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="home-starters card">
|
||||||
|
<div className="home-starters-head">
|
||||||
|
<strong>{t('starters.title')}</strong>
|
||||||
|
<span className="home-starters-tier">
|
||||||
|
<i className={`fas ${tier.id === 'cpu' ? 'fa-memory' : 'fa-microchip'}`} aria-hidden="true" />
|
||||||
|
{t(`starters.tier.${tier.id}`)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="home-starters-sub">
|
||||||
|
{tier.id === 'cpu' ? t('starters.cpuNote') : t('starters.gpuNote')}
|
||||||
|
</p>
|
||||||
|
<ul className="home-starters-list">
|
||||||
|
{visible.map(c => {
|
||||||
|
const busy = installing.has(c.name)
|
||||||
|
return (
|
||||||
|
<li key={c.name} className="home-starters-item">
|
||||||
|
<span className="home-starters-name">{c.name}</span>
|
||||||
|
<span className="home-starters-size">{c.size}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary btn-sm"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => install(c.name)}
|
||||||
|
>
|
||||||
|
{busy
|
||||||
|
? (<><i className="fas fa-spinner fa-spin" aria-hidden="true" /> {t('starters.installing')}</>)
|
||||||
|
: (<><i className="fas fa-download" aria-hidden="true" /> {t('starters.install')}</>)}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
66
core/http/react-ui/src/hooks/usePolling.js
vendored
Normal file
66
core/http/react-ui/src/hooks/usePolling.js
vendored
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { useEffect, useRef, useCallback } from 'react'
|
||||||
|
|
||||||
|
// usePolling runs `fn` immediately and then on a fixed interval, with two
|
||||||
|
// behaviours every hand-rolled setInterval in this app was missing:
|
||||||
|
//
|
||||||
|
// 1. Visibility-aware: the timer pauses while the tab is hidden
|
||||||
|
// (document.hidden) and fires an immediate catch-up poll when the tab
|
||||||
|
// becomes visible again. A backgrounded dashboard no longer hammers the
|
||||||
|
// server every few seconds for data nobody is looking at.
|
||||||
|
// 2. Non-overlapping: if `fn` returns a promise that takes longer than the
|
||||||
|
// interval, the next tick waits for it instead of stacking requests.
|
||||||
|
//
|
||||||
|
// `enabled: false` stops polling entirely (one-shot or gated polls). The
|
||||||
|
// returned `refetch` runs `fn` on demand and is stable across renders.
|
||||||
|
export function usePolling(fn, intervalMs = 5000, { enabled = true, immediate = true } = {}) {
|
||||||
|
const fnRef = useRef(fn)
|
||||||
|
fnRef.current = fn
|
||||||
|
|
||||||
|
const runningRef = useRef(false)
|
||||||
|
const refetch = useCallback(async () => {
|
||||||
|
// Guard against overlap: a slow poll shouldn't pile up behind a fast timer.
|
||||||
|
if (runningRef.current) return
|
||||||
|
runningRef.current = true
|
||||||
|
try {
|
||||||
|
return await fnRef.current()
|
||||||
|
} finally {
|
||||||
|
runningRef.current = false
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return
|
||||||
|
let timer = null
|
||||||
|
|
||||||
|
const tick = () => { refetch() }
|
||||||
|
|
||||||
|
const start = () => {
|
||||||
|
if (timer != null) return
|
||||||
|
timer = setInterval(tick, intervalMs)
|
||||||
|
}
|
||||||
|
const stop = () => {
|
||||||
|
if (timer != null) { clearInterval(timer); timer = null }
|
||||||
|
}
|
||||||
|
|
||||||
|
const onVisibility = () => {
|
||||||
|
if (document.hidden) {
|
||||||
|
stop()
|
||||||
|
} else {
|
||||||
|
// Catch up immediately on return, then resume the cadence.
|
||||||
|
tick()
|
||||||
|
start()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (immediate) tick()
|
||||||
|
if (!document.hidden) start()
|
||||||
|
document.addEventListener('visibilitychange', onVisibility)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
stop()
|
||||||
|
document.removeEventListener('visibilitychange', onVisibility)
|
||||||
|
}
|
||||||
|
}, [enabled, intervalMs, immediate, refetch])
|
||||||
|
|
||||||
|
return { refetch }
|
||||||
|
}
|
||||||
17
core/http/react-ui/src/hooks/useResources.js
vendored
17
core/http/react-ui/src/hooks/useResources.js
vendored
@@ -1,11 +1,11 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
import { useState, useCallback } from 'react'
|
||||||
import { resourcesApi } from '../utils/api'
|
import { resourcesApi } from '../utils/api'
|
||||||
|
import { usePolling } from './usePolling'
|
||||||
|
|
||||||
export function useResources(pollInterval = 5000) {
|
export function useResources(pollInterval = 5000) {
|
||||||
const [resources, setResources] = useState(null)
|
const [resources, setResources] = useState(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
const intervalRef = useRef(null)
|
|
||||||
|
|
||||||
const fetchResources = useCallback(async () => {
|
const fetchResources = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -19,13 +19,10 @@ export function useResources(pollInterval = 5000) {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
// Visibility-aware polling: pauses while the tab is hidden and catches up on
|
||||||
fetchResources()
|
// return (see usePolling). Resource stats are pure dashboard data, so there's
|
||||||
intervalRef.current = setInterval(fetchResources, pollInterval)
|
// no reason to keep fetching them for a backgrounded tab.
|
||||||
return () => {
|
const { refetch } = usePolling(fetchResources, pollInterval)
|
||||||
if (intervalRef.current) clearInterval(intervalRef.current)
|
|
||||||
}
|
|
||||||
}, [fetchResources, pollInterval])
|
|
||||||
|
|
||||||
return { resources, loading, error, refetch: fetchResources }
|
return { resources, loading, error, refetch }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -765,8 +765,10 @@ export default function AgentChat() {
|
|||||||
className="chat-send-btn"
|
className="chat-send-btn"
|
||||||
onClick={handleSend}
|
onClick={handleSend}
|
||||||
disabled={processing || !input.trim()}
|
disabled={processing || !input.trim()}
|
||||||
|
aria-label="Send message"
|
||||||
|
title="Send message"
|
||||||
>
|
>
|
||||||
<i className="fas fa-paper-plane" />
|
<i className="fas fa-paper-plane" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1427,8 +1427,10 @@ export default function Chat() {
|
|||||||
className="chat-send-btn"
|
className="chat-send-btn"
|
||||||
onClick={handleSend}
|
onClick={handleSend}
|
||||||
disabled={!input.trim() && files.length === 0}
|
disabled={!input.trim() && files.length === 0}
|
||||||
|
aria-label={t('input.send')}
|
||||||
|
title={t('input.send')}
|
||||||
>
|
>
|
||||||
<i className="fas fa-paper-plane" />
|
<i className="fas fa-paper-plane" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import UnifiedMCPDropdown from '../components/UnifiedMCPDropdown'
|
|||||||
import ConfirmDialog from '../components/ConfirmDialog'
|
import ConfirmDialog from '../components/ConfirmDialog'
|
||||||
import HomeConnect from '../components/HomeConnect'
|
import HomeConnect from '../components/HomeConnect'
|
||||||
import { useResources } from '../hooks/useResources'
|
import { useResources } from '../hooks/useResources'
|
||||||
|
import { usePolling } from '../hooks/usePolling'
|
||||||
import { fileToBase64, backendControlApi, systemApi, modelsApi, mcpApi, nodesApi } from '../utils/api'
|
import { fileToBase64, backendControlApi, systemApi, modelsApi, mcpApi, nodesApi } from '../utils/api'
|
||||||
import { API_CONFIG } from '../utils/config'
|
import { API_CONFIG } from '../utils/config'
|
||||||
import { greetingKey } from '../utils/greeting'
|
import { greetingKey } from '../utils/greeting'
|
||||||
@@ -17,6 +18,7 @@ import StatusPill from '../components/StatusPill'
|
|||||||
import Skeleton from '../components/Skeleton'
|
import Skeleton from '../components/Skeleton'
|
||||||
import SectionHeading from '../components/SectionHeading'
|
import SectionHeading from '../components/SectionHeading'
|
||||||
import EmptyState from '../components/EmptyState'
|
import EmptyState from '../components/EmptyState'
|
||||||
|
import StarterModels from '../components/StarterModels'
|
||||||
import { staggerStyle } from '../hooks/useStagger'
|
import { staggerStyle } from '../hooks/useStagger'
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
@@ -68,40 +70,36 @@ export default function Home() {
|
|||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Poll cluster node data in distributed mode
|
// Poll cluster node data in distributed mode. Visibility-aware + gated on
|
||||||
useEffect(() => {
|
// distributedMode so a non-distributed or backgrounded tab makes no calls.
|
||||||
if (!distributedMode) return
|
const fetchCluster = useCallback(async () => {
|
||||||
const fetchCluster = async () => {
|
try {
|
||||||
try {
|
const data = await nodesApi.list()
|
||||||
const data = await nodesApi.list()
|
const nodes = Array.isArray(data) ? data : []
|
||||||
const nodes = Array.isArray(data) ? data : []
|
const backendNodes = nodes.filter(n => !n.node_type || n.node_type === 'backend')
|
||||||
const backendNodes = nodes.filter(n => !n.node_type || n.node_type === 'backend')
|
const totalVRAM = backendNodes.reduce((sum, n) => sum + (n.total_vram || 0), 0)
|
||||||
const totalVRAM = backendNodes.reduce((sum, n) => sum + (n.total_vram || 0), 0)
|
const usedVRAM = backendNodes.reduce((sum, n) => {
|
||||||
const usedVRAM = backendNodes.reduce((sum, n) => {
|
if (n.total_vram && n.available_vram != null) return sum + (n.total_vram - n.available_vram)
|
||||||
if (n.total_vram && n.available_vram != null) return sum + (n.total_vram - n.available_vram)
|
return sum
|
||||||
return sum
|
}, 0)
|
||||||
}, 0)
|
const totalRAM = backendNodes.reduce((sum, n) => sum + (n.total_ram || 0), 0)
|
||||||
const totalRAM = backendNodes.reduce((sum, n) => sum + (n.total_ram || 0), 0)
|
const usedRAM = backendNodes.reduce((sum, n) => {
|
||||||
const usedRAM = backendNodes.reduce((sum, n) => {
|
if (n.total_ram && n.available_ram != null) return sum + (n.total_ram - n.available_ram)
|
||||||
if (n.total_ram && n.available_ram != null) return sum + (n.total_ram - n.available_ram)
|
return sum
|
||||||
return sum
|
}, 0)
|
||||||
}, 0)
|
const isGPU = totalVRAM > 0
|
||||||
const isGPU = totalVRAM > 0
|
const healthyCount = backendNodes.filter(n => n.status === 'healthy').length
|
||||||
const healthyCount = backendNodes.filter(n => n.status === 'healthy').length
|
const totalCount = backendNodes.length
|
||||||
const totalCount = backendNodes.length
|
setClusterData({
|
||||||
setClusterData({
|
totalMem: isGPU ? totalVRAM : totalRAM,
|
||||||
totalMem: isGPU ? totalVRAM : totalRAM,
|
usedMem: isGPU ? usedVRAM : usedRAM,
|
||||||
usedMem: isGPU ? usedVRAM : usedRAM,
|
isGPU,
|
||||||
isGPU,
|
healthyCount,
|
||||||
healthyCount,
|
totalCount,
|
||||||
totalCount,
|
})
|
||||||
})
|
} catch { setClusterData(null) }
|
||||||
} catch { setClusterData(null) }
|
}, [])
|
||||||
}
|
usePolling(fetchCluster, 5000, { enabled: distributedMode })
|
||||||
fetchCluster()
|
|
||||||
const interval = setInterval(fetchCluster, 5000)
|
|
||||||
return () => clearInterval(interval)
|
|
||||||
}, [distributedMode])
|
|
||||||
|
|
||||||
// Fetch configured models (to know if any exist) and loaded models (currently running)
|
// Fetch configured models (to know if any exist) and loaded models (currently running)
|
||||||
const fetchSystemInfo = useCallback(async () => {
|
const fetchSystemInfo = useCallback(async () => {
|
||||||
@@ -123,11 +121,7 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
usePolling(fetchSystemInfo, 5000)
|
||||||
fetchSystemInfo()
|
|
||||||
const interval = setInterval(fetchSystemInfo, 5000)
|
|
||||||
return () => clearInterval(interval)
|
|
||||||
}, [fetchSystemInfo])
|
|
||||||
|
|
||||||
// Check MCP availability when selected model changes
|
// Check MCP availability when selected model changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -523,6 +517,8 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<StarterModels addToast={addToast} onInstallStarted={fetchSystemInfo} />
|
||||||
|
|
||||||
<div className="home-wizard-actions">
|
<div className="home-wizard-actions">
|
||||||
<button className="btn btn-primary" onClick={() => navigate('/app/models')}>
|
<button className="btn btn-primary" onClick={() => navigate('/app/models')}>
|
||||||
<i className="fas fa-store" /> {t('wizard.browseGallery')}
|
<i className="fas fa-store" /> {t('wizard.browseGallery')}
|
||||||
|
|||||||
@@ -24,7 +24,37 @@ function formatNumber(n) {
|
|||||||
return String(n)
|
return String(n)
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatCard({ icon, label, value, muted }) {
|
// Opt-in token pricing. LocalAI is self-hosted and has no inherent monetary
|
||||||
|
// cost, but multi-user deployments use estimated cost for chargeback/budgeting.
|
||||||
|
// Prices are admin-supplied $ per 1M tokens, stored locally (per-browser), and
|
||||||
|
// the whole cost surface stays hidden until a non-zero price is set.
|
||||||
|
const TOKEN_PRICING_KEY = 'localai_token_pricing'
|
||||||
|
|
||||||
|
function loadPricing() {
|
||||||
|
try {
|
||||||
|
const p = JSON.parse(localStorage.getItem(TOKEN_PRICING_KEY) || '{}')
|
||||||
|
return { prompt: Number(p.prompt) || 0, completion: Number(p.completion) || 0 }
|
||||||
|
} catch { return { prompt: 0, completion: 0 } }
|
||||||
|
}
|
||||||
|
|
||||||
|
function savePricing(p) {
|
||||||
|
try { localStorage.setItem(TOKEN_PRICING_KEY, JSON.stringify(p)) } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function pricingEnabled(p) { return (p?.prompt || 0) > 0 || (p?.completion || 0) > 0 }
|
||||||
|
|
||||||
|
function costOf(row, p) {
|
||||||
|
return (row.prompt_tokens / 1_000_000) * (p.prompt || 0)
|
||||||
|
+ (row.completion_tokens / 1_000_000) * (p.completion || 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCost(n) {
|
||||||
|
if (!n) return '$0.00'
|
||||||
|
if (n < 0.01) return '<$0.01'
|
||||||
|
return '$' + n.toFixed(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({ icon, label, value, muted, text }) {
|
||||||
return (
|
return (
|
||||||
<div className="card" style={{ padding: 'var(--spacing-sm) var(--spacing-md)', flex: '1 1 0', minWidth: 120, opacity: muted ? 0.7 : 1 }}>
|
<div className="card" style={{ padding: 'var(--spacing-sm) var(--spacing-md)', flex: '1 1 0', minWidth: 120, opacity: muted ? 0.7 : 1 }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 2 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 2 }}>
|
||||||
@@ -32,7 +62,7 @@ function StatCard({ icon, label, value, muted }) {
|
|||||||
<span style={{ fontSize: '0.6875rem', color: 'var(--color-text-muted)', fontWeight: 500, textTransform: 'uppercase', letterSpacing: '0.03em' }}>{label}</span>
|
<span style={{ fontSize: '0.6875rem', color: 'var(--color-text-muted)', fontWeight: 500, textTransform: 'uppercase', letterSpacing: '0.03em' }}>{label}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: '1.375rem', fontWeight: 700, fontFamily: 'var(--font-mono)', color: muted ? 'var(--color-text-secondary)' : 'var(--color-text-primary)' }}>
|
<div style={{ fontSize: '1.375rem', fontWeight: 700, fontFamily: 'var(--font-mono)', color: muted ? 'var(--color-text-secondary)' : 'var(--color-text-primary)' }}>
|
||||||
{muted ? '~' : ''}{formatNumber(value)}
|
{text != null ? text : `${muted ? '~' : ''}${formatNumber(value)}`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -642,6 +672,10 @@ export default function Usage() {
|
|||||||
const [activeTab, setActiveTab] = useState('models')
|
const [activeTab, setActiveTab] = useState('models')
|
||||||
const [quotas, setQuotas] = useState([])
|
const [quotas, setQuotas] = useState([])
|
||||||
const [selectedUserId, setSelectedUserId] = useState(null)
|
const [selectedUserId, setSelectedUserId] = useState(null)
|
||||||
|
const [pricing, setPricingState] = useState(loadPricing)
|
||||||
|
const [showPricing, setShowPricing] = useState(false)
|
||||||
|
const setPricing = (p) => { setPricingState(p); savePricing(p) }
|
||||||
|
const costEnabled = pricingEnabled(pricing)
|
||||||
|
|
||||||
const fetchUsage = useCallback(async () => {
|
const fetchUsage = useCallback(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@@ -743,11 +777,50 @@ export default function Usage() {
|
|||||||
<i className="fas fa-key" style={{ fontSize: '0.7rem' }} /> {t('usage.sources.tab')}
|
<i className="fas fa-key" style={{ fontSize: '0.7rem' }} /> {t('usage.sources.tab')}
|
||||||
</button>
|
</button>
|
||||||
<div style={{ flex: 1 }} />
|
<div style={{ flex: 1 }} />
|
||||||
|
<button
|
||||||
|
className={`btn btn-sm ${costEnabled ? 'btn-primary' : 'btn-secondary'}`}
|
||||||
|
onClick={() => setShowPricing(v => !v)}
|
||||||
|
style={{ gap: 4 }}
|
||||||
|
title="Set token pricing to estimate cost"
|
||||||
|
>
|
||||||
|
<i className="fas fa-dollar-sign" /> {costEnabled ? 'Pricing' : 'Set pricing'}
|
||||||
|
</button>
|
||||||
<button className="btn btn-secondary btn-sm" onClick={fetchUsage} disabled={loading} style={{ gap: 4 }}>
|
<button className="btn btn-secondary btn-sm" onClick={fetchUsage} disabled={loading} style={{ gap: 4 }}>
|
||||||
<i className={`fas fa-rotate${loading ? ' fa-spin' : ''}`} /> Refresh
|
<i className={`fas fa-rotate${loading ? ' fa-spin' : ''}`} /> Refresh
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{showPricing && (
|
||||||
|
<div className="card" style={{ display: 'flex', alignItems: 'flex-end', gap: 'var(--spacing-md)', flexWrap: 'wrap', padding: 'var(--spacing-md)', marginBottom: 'var(--spacing-md)' }}>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
|
<label style={{ fontSize: '0.6875rem', color: 'var(--color-text-muted)', textTransform: 'uppercase', letterSpacing: '0.03em' }}>Prompt $/1M tokens</label>
|
||||||
|
<input
|
||||||
|
className="input" type="number" min="0" step="0.01" style={{ width: 140 }}
|
||||||
|
value={pricing.prompt || ''}
|
||||||
|
placeholder="0.00"
|
||||||
|
onChange={e => setPricing({ ...pricing, prompt: Number(e.target.value) || 0 })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
|
<label style={{ fontSize: '0.6875rem', color: 'var(--color-text-muted)', textTransform: 'uppercase', letterSpacing: '0.03em' }}>Completion $/1M tokens</label>
|
||||||
|
<input
|
||||||
|
className="input" type="number" min="0" step="0.01" style={{ width: 140 }}
|
||||||
|
value={pricing.completion || ''}
|
||||||
|
placeholder="0.00"
|
||||||
|
onChange={e => setPricing({ ...pricing, completion: Number(e.target.value) || 0 })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{costEnabled && (
|
||||||
|
<button className="btn btn-secondary btn-sm" onClick={() => setPricing({ prompt: 0, completion: 0 })} style={{ gap: 4 }}>
|
||||||
|
<i className="fas fa-times" /> Clear
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<span style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)', flex: '1 1 200px' }}>
|
||||||
|
Estimated cost only. Prices are stored in this browser and applied to recorded token counts.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', padding: 'var(--spacing-xl)' }}>
|
<div style={{ display: 'flex', justifyContent: 'center', padding: 'var(--spacing-xl)' }}>
|
||||||
<LoadingSpinner size="lg" />
|
<LoadingSpinner size="lg" />
|
||||||
@@ -760,6 +833,9 @@ export default function Usage() {
|
|||||||
<StatCard icon="fas fa-arrow-up" label="Prompt" value={displayTotals.prompt_tokens} />
|
<StatCard icon="fas fa-arrow-up" label="Prompt" value={displayTotals.prompt_tokens} />
|
||||||
<StatCard icon="fas fa-arrow-down" label="Completion" value={displayTotals.completion_tokens} />
|
<StatCard icon="fas fa-arrow-down" label="Completion" value={displayTotals.completion_tokens} />
|
||||||
<StatCard icon="fas fa-coins" label="Total" value={displayTotals.total_tokens} />
|
<StatCard icon="fas fa-coins" label="Total" value={displayTotals.total_tokens} />
|
||||||
|
{costEnabled && (
|
||||||
|
<StatCard icon="fas fa-dollar-sign" label="Est. Cost" text={formatCost(costOf(displayTotals, pricing))} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Predictions */}
|
{/* Predictions */}
|
||||||
@@ -789,6 +865,7 @@ export default function Usage() {
|
|||||||
<th style={{ width: 110 }}>Prompt</th>
|
<th style={{ width: 110 }}>Prompt</th>
|
||||||
<th style={{ width: 110 }}>Completion</th>
|
<th style={{ width: 110 }}>Completion</th>
|
||||||
<th style={{ width: 110 }}>Total</th>
|
<th style={{ width: 110 }}>Total</th>
|
||||||
|
{costEnabled && <th style={{ width: 100 }}>Est. Cost</th>}
|
||||||
<th style={{ width: 140 }}></th>
|
<th style={{ width: 140 }}></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -800,6 +877,7 @@ export default function Usage() {
|
|||||||
<td style={monoCell}>{formatNumber(row.prompt_tokens)}</td>
|
<td style={monoCell}>{formatNumber(row.prompt_tokens)}</td>
|
||||||
<td style={monoCell}>{formatNumber(row.completion_tokens)}</td>
|
<td style={monoCell}>{formatNumber(row.completion_tokens)}</td>
|
||||||
<td style={{ ...monoCell, fontWeight: 600 }}>{formatNumber(row.total_tokens)}</td>
|
<td style={{ ...monoCell, fontWeight: 600 }}>{formatNumber(row.total_tokens)}</td>
|
||||||
|
{costEnabled && <td style={monoCell}>{formatCost(costOf(row, pricing))}</td>}
|
||||||
<td><UsageBar value={row.total_tokens} max={maxTokens} /></td>
|
<td><UsageBar value={row.total_tokens} max={maxTokens} /></td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
@@ -827,6 +905,7 @@ export default function Usage() {
|
|||||||
<th style={{ width: 110 }}>Prompt</th>
|
<th style={{ width: 110 }}>Prompt</th>
|
||||||
<th style={{ width: 110 }}>Completion</th>
|
<th style={{ width: 110 }}>Completion</th>
|
||||||
<th style={{ width: 110 }}>Total</th>
|
<th style={{ width: 110 }}>Total</th>
|
||||||
|
{costEnabled && <th style={{ width: 100 }}>Est. Cost</th>}
|
||||||
<th style={{ width: 110 }}>Proj. Total</th>
|
<th style={{ width: 110 }}>Proj. Total</th>
|
||||||
<th style={{ width: 140 }}></th>
|
<th style={{ width: 140 }}></th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -849,6 +928,7 @@ export default function Usage() {
|
|||||||
<td style={monoCell}>{formatNumber(row.prompt_tokens)}</td>
|
<td style={monoCell}>{formatNumber(row.prompt_tokens)}</td>
|
||||||
<td style={monoCell}>{formatNumber(row.completion_tokens)}</td>
|
<td style={monoCell}>{formatNumber(row.completion_tokens)}</td>
|
||||||
<td style={{ ...monoCell, fontWeight: 600 }}>{formatNumber(row.total_tokens)}</td>
|
<td style={{ ...monoCell, fontWeight: 600 }}>{formatNumber(row.total_tokens)}</td>
|
||||||
|
{costEnabled && <td style={monoCell}>{formatCost(costOf(row, pricing))}</td>}
|
||||||
<td style={{ ...monoCell, color: 'var(--color-text-muted)', fontStyle: 'italic' }}>
|
<td style={{ ...monoCell, color: 'var(--color-text-muted)', fontStyle: 'italic' }}>
|
||||||
{up?.predictions ? `~${formatNumber(up.predictions.projectedTotals.total_tokens)}` : '-'}
|
{up?.predictions ? `~${formatNumber(up.predictions.projectedTotals.total_tokens)}` : '-'}
|
||||||
</td>
|
</td>
|
||||||
@@ -856,7 +936,7 @@ export default function Usage() {
|
|||||||
</tr>
|
</tr>
|
||||||
{isExpanded && up && (
|
{isExpanded && up && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={8} style={{ padding: 0, background: 'var(--color-bg-secondary)' }}>
|
<td colSpan={costEnabled ? 9 : 8} style={{ padding: 0, background: 'var(--color-bg-secondary)' }}>
|
||||||
<div style={{ padding: 'var(--spacing-md)' }}>
|
<div style={{ padding: 'var(--spacing-md)' }}>
|
||||||
{up.predictions && (
|
{up.predictions && (
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(100px, 1fr))', gap: 'var(--spacing-xs)', marginBottom: 'var(--spacing-sm)' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(100px, 1fr))', gap: 'var(--spacing-xs)', marginBottom: 'var(--spacing-sm)' }}>
|
||||||
|
|||||||
@@ -268,7 +268,7 @@ func RegisterAuthRoutes(e *echo.Echo, app *application.Application) {
|
|||||||
// Set up OAuth manager when any OAuth/OIDC provider is configured
|
// Set up OAuth manager when any OAuth/OIDC provider is configured
|
||||||
if appConfig.Auth.GitHubClientID != "" || appConfig.Auth.OIDCClientID != "" {
|
if appConfig.Auth.GitHubClientID != "" || appConfig.Auth.OIDCClientID != "" {
|
||||||
oauthMgr, err := auth.NewOAuthManager(
|
oauthMgr, err := auth.NewOAuthManager(
|
||||||
appConfig.Auth.BaseURL,
|
appConfig.ExternalBaseURL,
|
||||||
auth.OAuthParams{
|
auth.OAuthParams{
|
||||||
GitHubClientID: appConfig.Auth.GitHubClientID,
|
GitHubClientID: appConfig.Auth.GitHubClientID,
|
||||||
GitHubClientSecret: appConfig.Auth.GitHubClientSecret,
|
GitHubClientSecret: appConfig.Auth.GitHubClientSecret,
|
||||||
|
|||||||
@@ -14,6 +14,26 @@ When running LocalAI behind a TLS termination reverse proxy, the Web UI may fail
|
|||||||
|
|
||||||
LocalAI uses the `X-Forwarded-Proto` HTTP header to determine the protocol used by clients. When this header is set to `https`, LocalAI will generate HTTPS URLs for static assets in the Web UI.
|
LocalAI uses the `X-Forwarded-Proto` HTTP header to determine the protocol used by clients. When this header is set to `https`, LocalAI will generate HTTPS URLs for static assets in the Web UI.
|
||||||
|
|
||||||
|
## Running behind a reverse proxy (HTTPS / subpath)
|
||||||
|
|
||||||
|
LocalAI does not terminate TLS itself, so HTTPS is provided by a reverse
|
||||||
|
proxy in front of it. Self-referential links (generated image and video
|
||||||
|
URLs, async job status URLs, OAuth callbacks) need the externally visible
|
||||||
|
scheme, host and port.
|
||||||
|
|
||||||
|
LocalAI determines these in this order:
|
||||||
|
|
||||||
|
1. `LOCALAI_BASE_URL` - if set, it is authoritative for the origin. Set it to
|
||||||
|
the externally visible base URL, e.g. `LOCALAI_BASE_URL=https://localai.example.com`
|
||||||
|
or `https://192.168.0.13:34567`. Recommended whenever links come back with
|
||||||
|
the wrong scheme or host.
|
||||||
|
2. Otherwise, the `X-Forwarded-Proto` and `X-Forwarded-Host` headers (or the
|
||||||
|
RFC 7239 `Forwarded` header) sent by the proxy. Ensure your proxy forwards
|
||||||
|
`X-Forwarded-Proto: https`.
|
||||||
|
|
||||||
|
A reverse-proxy subpath mount is supported via `X-Forwarded-Prefix`; it is
|
||||||
|
appended to `LOCALAI_BASE_URL` when both are present.
|
||||||
|
|
||||||
## Required Headers
|
## Required Headers
|
||||||
|
|
||||||
Your reverse proxy must forward these headers to LocalAI:
|
Your reverse proxy must forward these headers to LocalAI:
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"version": "v4.4.3"
|
"version": "v4.5.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,24 +3,7 @@
|
|||||||
url: "github:mudler/LocalAI/gallery/virtual.yaml@master"
|
url: "github:mudler/LocalAI/gallery/virtual.yaml@master"
|
||||||
urls:
|
urls:
|
||||||
- https://huggingface.co/LiquidAI/LFM2.5-1.2B-Instruct-GGUF
|
- https://huggingface.co/LiquidAI/LFM2.5-1.2B-Instruct-GGUF
|
||||||
description: |
|
description: "Try LFM • Docs • LEAP • Discord\n\n# LFM2.5-1.2B-Instruct\n\nLFM2.5 is a new family of hybrid models designed for **on-device deployment**. It builds on the LFM2 architecture with extended pre-training and reinforcement learning.\n\n - **Best-in-class performance**: A 1.2B model rivaling much larger models, bringing high-quality AI to your pocket.\n - **Fast edge inference**: 239 tok/s decode on AMD CPU, 82 tok/s on mobile NPU. Runs under 1GB of memory with day-one support for llama.cpp, MLX, and vLLM.\n - **Scaled training**: Extended pre-training from 10T to 28T tokens and large-scale multi-stage reinforcement learning.\n\nFind more information about LFM2.5 in our blog post.\n\n## \U0001F5D2️ Model Details\n\nLFM2.5-1.2B-Instruct is a general-purpose text-only model with the following features:\n\n...\n"
|
||||||
Try LFM • Docs • LEAP • Discord
|
|
||||||
|
|
||||||
# LFM2.5-1.2B-Instruct
|
|
||||||
|
|
||||||
LFM2.5 is a new family of hybrid models designed for **on-device deployment**. It builds on the LFM2 architecture with extended pre-training and reinforcement learning.
|
|
||||||
|
|
||||||
- **Best-in-class performance**: A 1.2B model rivaling much larger models, bringing high-quality AI to your pocket.
|
|
||||||
- **Fast edge inference**: 239 tok/s decode on AMD CPU, 82 tok/s on mobile NPU. Runs under 1GB of memory with day-one support for llama.cpp, MLX, and vLLM.
|
|
||||||
- **Scaled training**: Extended pre-training from 10T to 28T tokens and large-scale multi-stage reinforcement learning.
|
|
||||||
|
|
||||||
Find more information about LFM2.5 in our blog post.
|
|
||||||
|
|
||||||
## 🗒️ Model Details
|
|
||||||
|
|
||||||
LFM2.5-1.2B-Instruct is a general-purpose text-only model with the following features:
|
|
||||||
|
|
||||||
...
|
|
||||||
license: "other"
|
license: "other"
|
||||||
tags:
|
tags:
|
||||||
- llm
|
- llm
|
||||||
@@ -842,8 +825,8 @@
|
|||||||
use_tokenizer_template: true
|
use_tokenizer_template: true
|
||||||
files:
|
files:
|
||||||
- filename: llama-cpp/models/Qwopus3.6-27B-Coder-MTP-GGUF/Qwopus3.6-27B-Coder-MTP-Q4_K_M.gguf
|
- filename: llama-cpp/models/Qwopus3.6-27B-Coder-MTP-GGUF/Qwopus3.6-27B-Coder-MTP-Q4_K_M.gguf
|
||||||
sha256: b2898667ed7b2388f0ab7691393833ae777f247492bbe62fdb4b2bd3e3cf3f79
|
|
||||||
uri: https://huggingface.co/Jackrong/Qwopus3.6-27B-Coder-MTP-GGUF/resolve/main/Qwopus3.6-27B-Coder-MTP-Q4_K_M.gguf
|
uri: https://huggingface.co/Jackrong/Qwopus3.6-27B-Coder-MTP-GGUF/resolve/main/Qwopus3.6-27B-Coder-MTP-Q4_K_M.gguf
|
||||||
|
sha256: b2b9180093496da2e00439e3fa23227c591355901bfa579bc6897bbc01b755ef
|
||||||
- filename: llama-cpp/mmproj/Qwopus3.6-27B-Coder-MTP-GGUF/mmproj-F32.gguf
|
- filename: llama-cpp/mmproj/Qwopus3.6-27B-Coder-MTP-GGUF/mmproj-F32.gguf
|
||||||
sha256: 32f7ea0600c07272547da401d460f8abbd980f3a57b69d6df87be0e2505e0b9c
|
sha256: 32f7ea0600c07272547da401d460f8abbd980f3a57b69d6df87be0e2505e0b9c
|
||||||
uri: https://huggingface.co/Jackrong/Qwopus3.6-27B-Coder-MTP-GGUF/resolve/main/mmproj-F32.gguf
|
uri: https://huggingface.co/Jackrong/Qwopus3.6-27B-Coder-MTP-GGUF/resolve/main/mmproj-F32.gguf
|
||||||
|
|||||||
Reference in New Issue
Block a user