mirror of
https://github.com/mudler/LocalAI.git
synced 2026-02-04 11:42:57 -05:00
Compare commits
12 Commits
copilot/up
...
copilot/co
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ecda78be4 | ||
|
|
8da5ef7231 | ||
|
|
4758996936 | ||
|
|
9a50215867 | ||
|
|
4435c8af57 | ||
|
|
65a57daba6 | ||
|
|
b5465cbc3a | ||
|
|
2de30440fe | ||
|
|
673a80a578 | ||
|
|
2554e9fabe | ||
|
|
5bfc3eebf8 | ||
|
|
ab893fe302 |
@@ -1,5 +1,5 @@
|
||||
|
||||
LLAMA_VERSION?=593da7fa49503b68f9f01700be9f508f1e528992
|
||||
LLAMA_VERSION?=b1377188784f9aea26b8abde56d4aee8c733eec7
|
||||
LLAMA_REPO?=https://github.com/ggerganov/llama.cpp
|
||||
|
||||
CMAKE_ARGS?=
|
||||
|
||||
@@ -17,4 +17,9 @@ if [ "x${BUILD_PROFILE}" == "xintel" ]; then
|
||||
fi
|
||||
EXTRA_PIP_INSTALL_FLAGS+=" --no-build-isolation"
|
||||
|
||||
if [ "x${BUILD_PROFILE}" == "xl4t12" ]; then
|
||||
USE_PIP=true
|
||||
fi
|
||||
|
||||
|
||||
installRequirements
|
||||
|
||||
5
backend/python/chatterbox/requirements-install.txt
Normal file
5
backend/python/chatterbox/requirements-install.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
# Build dependencies needed for packages installed from source (e.g., git dependencies)
|
||||
# When using --no-build-isolation, these must be installed in the venv first
|
||||
wheel
|
||||
setuptools
|
||||
packaging
|
||||
@@ -16,6 +16,10 @@ if [ "x${BUILD_PROFILE}" == "xintel" ]; then
|
||||
EXTRA_PIP_INSTALL_FLAGS+=" --upgrade --index-strategy=unsafe-first-match"
|
||||
fi
|
||||
|
||||
if [ "x${BUILD_PROFILE}" == "xl4t12" ]; then
|
||||
USE_PIP=true
|
||||
fi
|
||||
|
||||
# Use python 3.12 for l4t
|
||||
if [ "x${BUILD_PROFILE}" == "xl4t13" ]; then
|
||||
PYTHON_VERSION="3.12"
|
||||
|
||||
@@ -16,4 +16,8 @@ if [ "x${BUILD_PROFILE}" == "xintel" ]; then
|
||||
EXTRA_PIP_INSTALL_FLAGS+=" --upgrade --index-strategy=unsafe-first-match"
|
||||
fi
|
||||
|
||||
if [ "x${BUILD_PROFILE}" == "xl4t12" ]; then
|
||||
USE_PIP=true
|
||||
fi
|
||||
|
||||
installRequirements
|
||||
|
||||
@@ -26,6 +26,12 @@ fi
|
||||
|
||||
EXTRA_PIP_INSTALL_FLAGS+=" --no-build-isolation"
|
||||
|
||||
|
||||
if [ "x${BUILD_PROFILE}" == "xl4t12" ]; then
|
||||
USE_PIP=true
|
||||
fi
|
||||
|
||||
|
||||
git clone https://github.com/neuphonic/neutts-air neutts-air
|
||||
|
||||
cp -rfv neutts-air/neuttsair ./
|
||||
|
||||
@@ -23,6 +23,10 @@ if [ "x${BUILD_PROFILE}" == "xl4t13" ]; then
|
||||
PY_STANDALONE_TAG="20251120"
|
||||
fi
|
||||
|
||||
if [ "x${BUILD_PROFILE}" == "xl4t12" ]; then
|
||||
USE_PIP=true
|
||||
fi
|
||||
|
||||
installRequirements
|
||||
|
||||
git clone https://github.com/microsoft/VibeVoice.git
|
||||
|
||||
@@ -63,6 +63,25 @@ func (m *GalleryBackend) IsMeta() bool {
|
||||
return len(m.CapabilitiesMap) > 0 && m.URI == ""
|
||||
}
|
||||
|
||||
// IsCompatibleWith checks if the backend is compatible with the current system capability.
|
||||
// For meta backends, it checks if any of the capabilities in the map match the system capability.
|
||||
// For concrete backends, it delegates to SystemState.IsBackendCompatible.
|
||||
func (m *GalleryBackend) IsCompatibleWith(systemState *system.SystemState) bool {
|
||||
if systemState == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
// Meta backends are compatible if the system capability matches one of the keys
|
||||
if m.IsMeta() {
|
||||
capability := systemState.Capability(m.CapabilitiesMap)
|
||||
_, exists := m.CapabilitiesMap[capability]
|
||||
return exists
|
||||
}
|
||||
|
||||
// For concrete backends, delegate to the system package
|
||||
return systemState.IsBackendCompatible(m.Name, m.URI)
|
||||
}
|
||||
|
||||
func (m *GalleryBackend) SetInstalled(installed bool) {
|
||||
m.Installed = installed
|
||||
}
|
||||
|
||||
@@ -172,6 +172,252 @@ var _ = Describe("Gallery Backends", func() {
|
||||
Expect(nilMetaBackend.IsMeta()).To(BeFalse())
|
||||
})
|
||||
|
||||
It("should check IsCompatibleWith correctly for meta backends", func() {
|
||||
metaBackend := &GalleryBackend{
|
||||
Metadata: Metadata{
|
||||
Name: "meta-backend",
|
||||
},
|
||||
CapabilitiesMap: map[string]string{
|
||||
"nvidia": "nvidia-backend",
|
||||
"amd": "amd-backend",
|
||||
"default": "default-backend",
|
||||
},
|
||||
}
|
||||
|
||||
// Test with nil state - should be compatible
|
||||
Expect(metaBackend.IsCompatibleWith(nil)).To(BeTrue())
|
||||
|
||||
// Test with NVIDIA system - should be compatible (has nvidia key)
|
||||
nvidiaState := &system.SystemState{GPUVendor: "nvidia", VRAM: 8 * 1024 * 1024 * 1024}
|
||||
Expect(metaBackend.IsCompatibleWith(nvidiaState)).To(BeTrue())
|
||||
|
||||
// Test with default (no GPU) - should be compatible (has default key)
|
||||
defaultState := &system.SystemState{}
|
||||
Expect(metaBackend.IsCompatibleWith(defaultState)).To(BeTrue())
|
||||
})
|
||||
|
||||
Describe("IsCompatibleWith for concrete backends", func() {
|
||||
Context("CPU backends", func() {
|
||||
It("should be compatible on all systems", func() {
|
||||
cpuBackend := &GalleryBackend{
|
||||
Metadata: Metadata{
|
||||
Name: "cpu-llama-cpp",
|
||||
},
|
||||
URI: "quay.io/go-skynet/local-ai-backends:latest-cpu-llama-cpp",
|
||||
}
|
||||
Expect(cpuBackend.IsCompatibleWith(&system.SystemState{})).To(BeTrue())
|
||||
Expect(cpuBackend.IsCompatibleWith(&system.SystemState{GPUVendor: system.Nvidia, VRAM: 8 * 1024 * 1024 * 1024})).To(BeTrue())
|
||||
Expect(cpuBackend.IsCompatibleWith(&system.SystemState{GPUVendor: system.AMD, VRAM: 8 * 1024 * 1024 * 1024})).To(BeTrue())
|
||||
})
|
||||
})
|
||||
|
||||
Context("Darwin/Metal backends", func() {
|
||||
When("running on darwin", func() {
|
||||
BeforeEach(func() {
|
||||
if runtime.GOOS != "darwin" {
|
||||
Skip("Skipping darwin-specific tests on non-darwin system")
|
||||
}
|
||||
})
|
||||
|
||||
It("should be compatible for MLX backend", func() {
|
||||
mlxBackend := &GalleryBackend{
|
||||
Metadata: Metadata{
|
||||
Name: "mlx",
|
||||
},
|
||||
URI: "quay.io/go-skynet/local-ai-backends:latest-metal-darwin-arm64-mlx",
|
||||
}
|
||||
Expect(mlxBackend.IsCompatibleWith(&system.SystemState{})).To(BeTrue())
|
||||
})
|
||||
|
||||
It("should be compatible for metal-llama-cpp backend", func() {
|
||||
metalBackend := &GalleryBackend{
|
||||
Metadata: Metadata{
|
||||
Name: "metal-llama-cpp",
|
||||
},
|
||||
URI: "quay.io/go-skynet/local-ai-backends:latest-metal-darwin-arm64-llama-cpp",
|
||||
}
|
||||
Expect(metalBackend.IsCompatibleWith(&system.SystemState{})).To(BeTrue())
|
||||
})
|
||||
})
|
||||
|
||||
When("running on non-darwin", func() {
|
||||
BeforeEach(func() {
|
||||
if runtime.GOOS == "darwin" {
|
||||
Skip("Skipping non-darwin-specific tests on darwin system")
|
||||
}
|
||||
})
|
||||
|
||||
It("should NOT be compatible for MLX backend", func() {
|
||||
mlxBackend := &GalleryBackend{
|
||||
Metadata: Metadata{
|
||||
Name: "mlx",
|
||||
},
|
||||
URI: "quay.io/go-skynet/local-ai-backends:latest-metal-darwin-arm64-mlx",
|
||||
}
|
||||
Expect(mlxBackend.IsCompatibleWith(&system.SystemState{})).To(BeFalse())
|
||||
})
|
||||
|
||||
It("should NOT be compatible for metal-llama-cpp backend", func() {
|
||||
metalBackend := &GalleryBackend{
|
||||
Metadata: Metadata{
|
||||
Name: "metal-llama-cpp",
|
||||
},
|
||||
URI: "quay.io/go-skynet/local-ai-backends:latest-metal-darwin-arm64-llama-cpp",
|
||||
}
|
||||
Expect(metalBackend.IsCompatibleWith(&system.SystemState{})).To(BeFalse())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Context("NVIDIA/CUDA backends", func() {
|
||||
When("running on non-darwin", func() {
|
||||
BeforeEach(func() {
|
||||
if runtime.GOOS == "darwin" {
|
||||
Skip("Skipping CUDA tests on darwin system")
|
||||
}
|
||||
})
|
||||
|
||||
It("should NOT be compatible without nvidia GPU", func() {
|
||||
cudaBackend := &GalleryBackend{
|
||||
Metadata: Metadata{
|
||||
Name: "cuda12-llama-cpp",
|
||||
},
|
||||
URI: "quay.io/go-skynet/local-ai-backends:latest-gpu-nvidia-cuda-12-llama-cpp",
|
||||
}
|
||||
Expect(cudaBackend.IsCompatibleWith(&system.SystemState{})).To(BeFalse())
|
||||
Expect(cudaBackend.IsCompatibleWith(&system.SystemState{GPUVendor: system.AMD, VRAM: 8 * 1024 * 1024 * 1024})).To(BeFalse())
|
||||
})
|
||||
|
||||
It("should be compatible with nvidia GPU", func() {
|
||||
cudaBackend := &GalleryBackend{
|
||||
Metadata: Metadata{
|
||||
Name: "cuda12-llama-cpp",
|
||||
},
|
||||
URI: "quay.io/go-skynet/local-ai-backends:latest-gpu-nvidia-cuda-12-llama-cpp",
|
||||
}
|
||||
Expect(cudaBackend.IsCompatibleWith(&system.SystemState{GPUVendor: system.Nvidia, VRAM: 8 * 1024 * 1024 * 1024})).To(BeTrue())
|
||||
})
|
||||
|
||||
It("should be compatible with cuda13 backend on nvidia GPU", func() {
|
||||
cuda13Backend := &GalleryBackend{
|
||||
Metadata: Metadata{
|
||||
Name: "cuda13-llama-cpp",
|
||||
},
|
||||
URI: "quay.io/go-skynet/local-ai-backends:latest-gpu-nvidia-cuda-13-llama-cpp",
|
||||
}
|
||||
Expect(cuda13Backend.IsCompatibleWith(&system.SystemState{GPUVendor: system.Nvidia, VRAM: 8 * 1024 * 1024 * 1024})).To(BeTrue())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Context("AMD/ROCm backends", func() {
|
||||
When("running on non-darwin", func() {
|
||||
BeforeEach(func() {
|
||||
if runtime.GOOS == "darwin" {
|
||||
Skip("Skipping AMD/ROCm tests on darwin system")
|
||||
}
|
||||
})
|
||||
|
||||
It("should NOT be compatible without AMD GPU", func() {
|
||||
rocmBackend := &GalleryBackend{
|
||||
Metadata: Metadata{
|
||||
Name: "rocm-llama-cpp",
|
||||
},
|
||||
URI: "quay.io/go-skynet/local-ai-backends:latest-gpu-rocm-hipblas-llama-cpp",
|
||||
}
|
||||
Expect(rocmBackend.IsCompatibleWith(&system.SystemState{})).To(BeFalse())
|
||||
Expect(rocmBackend.IsCompatibleWith(&system.SystemState{GPUVendor: system.Nvidia, VRAM: 8 * 1024 * 1024 * 1024})).To(BeFalse())
|
||||
})
|
||||
|
||||
It("should be compatible with AMD GPU", func() {
|
||||
rocmBackend := &GalleryBackend{
|
||||
Metadata: Metadata{
|
||||
Name: "rocm-llama-cpp",
|
||||
},
|
||||
URI: "quay.io/go-skynet/local-ai-backends:latest-gpu-rocm-hipblas-llama-cpp",
|
||||
}
|
||||
Expect(rocmBackend.IsCompatibleWith(&system.SystemState{GPUVendor: system.AMD, VRAM: 8 * 1024 * 1024 * 1024})).To(BeTrue())
|
||||
})
|
||||
|
||||
It("should be compatible with hipblas backend on AMD GPU", func() {
|
||||
hipBackend := &GalleryBackend{
|
||||
Metadata: Metadata{
|
||||
Name: "hip-llama-cpp",
|
||||
},
|
||||
URI: "quay.io/go-skynet/local-ai-backends:latest-gpu-hip-llama-cpp",
|
||||
}
|
||||
Expect(hipBackend.IsCompatibleWith(&system.SystemState{GPUVendor: system.AMD, VRAM: 8 * 1024 * 1024 * 1024})).To(BeTrue())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Context("Intel/SYCL backends", func() {
|
||||
When("running on non-darwin", func() {
|
||||
BeforeEach(func() {
|
||||
if runtime.GOOS == "darwin" {
|
||||
Skip("Skipping Intel/SYCL tests on darwin system")
|
||||
}
|
||||
})
|
||||
|
||||
It("should NOT be compatible without Intel GPU", func() {
|
||||
intelBackend := &GalleryBackend{
|
||||
Metadata: Metadata{
|
||||
Name: "intel-sycl-f16-llama-cpp",
|
||||
},
|
||||
URI: "quay.io/go-skynet/local-ai-backends:latest-gpu-intel-sycl-f16-llama-cpp",
|
||||
}
|
||||
Expect(intelBackend.IsCompatibleWith(&system.SystemState{})).To(BeFalse())
|
||||
Expect(intelBackend.IsCompatibleWith(&system.SystemState{GPUVendor: system.Nvidia, VRAM: 8 * 1024 * 1024 * 1024})).To(BeFalse())
|
||||
})
|
||||
|
||||
It("should be compatible with Intel GPU", func() {
|
||||
intelBackend := &GalleryBackend{
|
||||
Metadata: Metadata{
|
||||
Name: "intel-sycl-f16-llama-cpp",
|
||||
},
|
||||
URI: "quay.io/go-skynet/local-ai-backends:latest-gpu-intel-sycl-f16-llama-cpp",
|
||||
}
|
||||
Expect(intelBackend.IsCompatibleWith(&system.SystemState{GPUVendor: system.Intel, VRAM: 8 * 1024 * 1024 * 1024})).To(BeTrue())
|
||||
})
|
||||
|
||||
It("should be compatible with intel-sycl-f32 backend on Intel GPU", func() {
|
||||
intelF32Backend := &GalleryBackend{
|
||||
Metadata: Metadata{
|
||||
Name: "intel-sycl-f32-llama-cpp",
|
||||
},
|
||||
URI: "quay.io/go-skynet/local-ai-backends:latest-gpu-intel-sycl-f32-llama-cpp",
|
||||
}
|
||||
Expect(intelF32Backend.IsCompatibleWith(&system.SystemState{GPUVendor: system.Intel, VRAM: 8 * 1024 * 1024 * 1024})).To(BeTrue())
|
||||
})
|
||||
|
||||
It("should be compatible with intel-transformers backend on Intel GPU", func() {
|
||||
intelTransformersBackend := &GalleryBackend{
|
||||
Metadata: Metadata{
|
||||
Name: "intel-transformers",
|
||||
},
|
||||
URI: "quay.io/go-skynet/local-ai-backends:latest-intel-transformers",
|
||||
}
|
||||
Expect(intelTransformersBackend.IsCompatibleWith(&system.SystemState{GPUVendor: system.Intel, VRAM: 8 * 1024 * 1024 * 1024})).To(BeTrue())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Context("Vulkan backends", func() {
|
||||
It("should be compatible on CPU-only systems", func() {
|
||||
// Vulkan backends don't have a specific GPU vendor requirement in the current logic
|
||||
// They are compatible if no other GPU-specific pattern matches
|
||||
vulkanBackend := &GalleryBackend{
|
||||
Metadata: Metadata{
|
||||
Name: "vulkan-llama-cpp",
|
||||
},
|
||||
URI: "quay.io/go-skynet/local-ai-backends:latest-gpu-vulkan-llama-cpp",
|
||||
}
|
||||
// Vulkan doesn't have vendor-specific filtering in current implementation
|
||||
Expect(vulkanBackend.IsCompatibleWith(&system.SystemState{})).To(BeTrue())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
It("should find best backend from meta based on system capabilities", func() {
|
||||
|
||||
metaBackend := &GalleryBackend{
|
||||
|
||||
@@ -226,6 +226,16 @@ func AvailableGalleryModels(galleries []config.Gallery, systemState *system.Syst
|
||||
|
||||
// List available backends
|
||||
func AvailableBackends(galleries []config.Gallery, systemState *system.SystemState) (GalleryElements[*GalleryBackend], error) {
|
||||
return availableBackendsWithFilter(galleries, systemState, true)
|
||||
}
|
||||
|
||||
// AvailableBackendsUnfiltered returns all available backends without filtering by system capability.
|
||||
func AvailableBackendsUnfiltered(galleries []config.Gallery, systemState *system.SystemState) (GalleryElements[*GalleryBackend], error) {
|
||||
return availableBackendsWithFilter(galleries, systemState, false)
|
||||
}
|
||||
|
||||
// availableBackendsWithFilter is a helper function that lists available backends with optional filtering.
|
||||
func availableBackendsWithFilter(galleries []config.Gallery, systemState *system.SystemState, filterByCapability bool) (GalleryElements[*GalleryBackend], error) {
|
||||
var backends []*GalleryBackend
|
||||
|
||||
systemBackends, err := ListSystemBackends(systemState)
|
||||
@@ -241,7 +251,17 @@ func AvailableBackends(galleries []config.Gallery, systemState *system.SystemSta
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
backends = append(backends, galleryBackends...)
|
||||
|
||||
// Filter backends by system capability if requested
|
||||
if filterByCapability {
|
||||
for _, backend := range galleryBackends {
|
||||
if backend.IsCompatibleWith(systemState) {
|
||||
backends = append(backends, backend)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
backends = append(backends, galleryBackends...)
|
||||
}
|
||||
}
|
||||
|
||||
return backends, nil
|
||||
|
||||
@@ -65,13 +65,9 @@ func WelcomeEndpoint(appConfig *config.ApplicationConfig,
|
||||
// The client expects a JSON response
|
||||
return c.JSON(200, summary)
|
||||
} else {
|
||||
// Check if this is the manage route
|
||||
templateName := "views/index"
|
||||
if strings.HasSuffix(c.Request().URL.Path, "/manage") || c.Request().URL.Path == "/manage" {
|
||||
templateName = "views/manage"
|
||||
}
|
||||
// Render appropriate template
|
||||
return c.Render(200, templateName, summary)
|
||||
// Serve the SPA for both index and manage routes
|
||||
// The SPA handles routing client-side via Alpine.js
|
||||
return c.Render(200, "views/spa", summary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ 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"
|
||||
@@ -115,208 +114,24 @@ func RegisterUIRoutes(app *echo.Echo,
|
||||
registerBackendGalleryRoutes(app, appConfig, galleryService, processingOps)
|
||||
}
|
||||
|
||||
app.GET("/talk", func(c echo.Context) error {
|
||||
modelConfigs, _ := services.ListModels(cl, ml, config.NoFilterFn, services.SKIP_IF_CONFIGURED)
|
||||
// Talk route - now served by SPA
|
||||
app.GET("/talk", localai.WelcomeEndpoint(appConfig, cl, ml, processingOps))
|
||||
|
||||
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))
|
||||
}
|
||||
// Chat routes - now served by SPA
|
||||
app.GET("/chat", localai.WelcomeEndpoint(appConfig, cl, ml, processingOps))
|
||||
|
||||
summary := map[string]interface{}{
|
||||
"Title": "LocalAI - Talk",
|
||||
"BaseURL": middleware.BaseURL(c),
|
||||
"ModelsConfig": modelConfigs,
|
||||
"Model": modelConfigs[0],
|
||||
// Show the Chat page with specific model
|
||||
app.GET("/chat/:model", localai.WelcomeEndpoint(appConfig, cl, ml, processingOps))
|
||||
|
||||
"Version": internal.PrintableVersion(),
|
||||
}
|
||||
// Text2Image routes - now served by SPA
|
||||
app.GET("/text2image/:model", localai.WelcomeEndpoint(appConfig, cl, ml, processingOps))
|
||||
|
||||
// Render index
|
||||
return c.Render(200, "views/talk", summary)
|
||||
})
|
||||
app.GET("/text2image", localai.WelcomeEndpoint(appConfig, cl, ml, processingOps))
|
||||
|
||||
app.GET("/chat", func(c echo.Context) error {
|
||||
modelConfigs := cl.GetAllModelsConfigs()
|
||||
modelsWithoutConfig, _ := services.ListModels(cl, ml, config.NoFilterFn, services.LOOSE_ONLY)
|
||||
// TTS routes - now served by SPA
|
||||
app.GET("/tts/:model", localai.WelcomeEndpoint(appConfig, cl, ml, processingOps))
|
||||
|
||||
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("/text2image/: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/text2image", summary)
|
||||
})
|
||||
|
||||
app.GET("/text2image", 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/text2image", 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("/tts", localai.WelcomeEndpoint(appConfig, cl, ml, processingOps))
|
||||
|
||||
// Traces UI
|
||||
app.GET("/traces", func(c echo.Context) error {
|
||||
|
||||
@@ -617,6 +617,12 @@ func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model
|
||||
installedBackendsCount = len(installedBackends)
|
||||
}
|
||||
|
||||
// Get the detected system capability
|
||||
detectedCapability := ""
|
||||
if appConfig.SystemState != nil {
|
||||
detectedCapability = appConfig.SystemState.DetectedCapability()
|
||||
}
|
||||
|
||||
return c.JSON(200, map[string]interface{}{
|
||||
"backends": backendsJSON,
|
||||
"repositories": appConfig.BackendGalleries,
|
||||
@@ -629,6 +635,7 @@ func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model
|
||||
"totalPages": totalPages,
|
||||
"prevPage": prevPage,
|
||||
"nextPage": nextPage,
|
||||
"systemCapability": detectedCapability,
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
411
core/http/static/spa-home.js
Normal file
411
core/http/static/spa-home.js
Normal file
@@ -0,0 +1,411 @@
|
||||
/**
|
||||
* SPA Home View JavaScript
|
||||
* Contains Alpine.js components and functions for the home view
|
||||
*/
|
||||
|
||||
// Home input form component
|
||||
function homeInputForm() {
|
||||
return {
|
||||
selectedModel: '',
|
||||
inputValue: '',
|
||||
shiftPressed: false,
|
||||
fileName: '',
|
||||
imageFiles: [],
|
||||
audioFiles: [],
|
||||
textFiles: [],
|
||||
attachedFiles: [],
|
||||
mcpMode: false,
|
||||
mcpAvailable: false,
|
||||
mcpModels: {},
|
||||
currentPlaceholder: 'Send a message...',
|
||||
placeholderIndex: 0,
|
||||
charIndex: 0,
|
||||
isTyping: false,
|
||||
typingTimeout: null,
|
||||
displayTimeout: null,
|
||||
placeholderMessages: [
|
||||
'What is Nuclear fusion?',
|
||||
'How does a combustion engine work?',
|
||||
'Explain quantum computing',
|
||||
'What causes climate change?',
|
||||
'How do neural networks learn?',
|
||||
'What is the theory of relativity?',
|
||||
'How does photosynthesis work?',
|
||||
'Explain the water cycle',
|
||||
'What is machine learning?',
|
||||
'How do black holes form?',
|
||||
'What is DNA and how does it work?',
|
||||
'Explain the greenhouse effect',
|
||||
'How does the immune system work?',
|
||||
'What is artificial intelligence?',
|
||||
'How do solar panels generate electricity?',
|
||||
'Explain the process of evolution',
|
||||
'What is the difference between weather and climate?',
|
||||
'How does the human brain process information?',
|
||||
'What is the structure of an atom?',
|
||||
'How do vaccines work?',
|
||||
'Explain the concept of entropy',
|
||||
'What is the speed of light?',
|
||||
'How does gravity work?',
|
||||
'What is the difference between mass and weight?'
|
||||
],
|
||||
|
||||
init() {
|
||||
window.currentPlaceholderText = this.currentPlaceholder;
|
||||
this.startTypingAnimation();
|
||||
// Build MCP models map from data attributes
|
||||
this.buildMCPModelsMap();
|
||||
// Select first model by default
|
||||
this.$nextTick(() => {
|
||||
const select = this.$el.querySelector('select');
|
||||
if (select && select.options.length > 1) {
|
||||
const firstModelOption = select.options[1];
|
||||
if (firstModelOption && firstModelOption.value) {
|
||||
this.selectedModel = firstModelOption.value;
|
||||
this.checkMCPAvailability();
|
||||
}
|
||||
}
|
||||
});
|
||||
// Watch for changes to selectedModel to update MCP availability
|
||||
this.$watch('selectedModel', () => {
|
||||
this.checkMCPAvailability();
|
||||
});
|
||||
},
|
||||
|
||||
buildMCPModelsMap() {
|
||||
const select = this.$el.querySelector('select');
|
||||
if (!select) return;
|
||||
this.mcpModels = {};
|
||||
for (let i = 0; i < select.options.length; i++) {
|
||||
const option = select.options[i];
|
||||
if (option.value) {
|
||||
const hasMcpAttr = option.getAttribute('data-has-mcp');
|
||||
this.mcpModels[option.value] = hasMcpAttr === 'true';
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
checkMCPAvailability() {
|
||||
if (!this.selectedModel) {
|
||||
this.mcpAvailable = false;
|
||||
this.mcpMode = false;
|
||||
return;
|
||||
}
|
||||
const hasMCP = this.mcpModels[this.selectedModel] === true;
|
||||
this.mcpAvailable = hasMCP;
|
||||
if (!hasMCP) {
|
||||
this.mcpMode = false;
|
||||
}
|
||||
},
|
||||
|
||||
startTypingAnimation() {
|
||||
if (this.isTyping) return;
|
||||
this.typeNextPlaceholder();
|
||||
},
|
||||
|
||||
typeNextPlaceholder() {
|
||||
if (this.isTyping) return;
|
||||
this.isTyping = true;
|
||||
this.charIndex = 0;
|
||||
const message = this.placeholderMessages[this.placeholderIndex];
|
||||
this.currentPlaceholder = '';
|
||||
window.currentPlaceholderText = '';
|
||||
|
||||
const typeChar = () => {
|
||||
if (this.charIndex < message.length) {
|
||||
this.currentPlaceholder = message.substring(0, this.charIndex + 1);
|
||||
window.currentPlaceholderText = this.currentPlaceholder;
|
||||
this.charIndex++;
|
||||
this.typingTimeout = setTimeout(typeChar, 30);
|
||||
} else {
|
||||
this.isTyping = false;
|
||||
window.currentPlaceholderText = this.currentPlaceholder;
|
||||
this.displayTimeout = setTimeout(() => {
|
||||
this.placeholderIndex = (this.placeholderIndex + 1) % this.placeholderMessages.length;
|
||||
this.typeNextPlaceholder();
|
||||
}, 2000);
|
||||
}
|
||||
};
|
||||
|
||||
typeChar();
|
||||
},
|
||||
|
||||
pauseTyping() {
|
||||
if (this.typingTimeout) {
|
||||
clearTimeout(this.typingTimeout);
|
||||
this.typingTimeout = null;
|
||||
}
|
||||
if (this.displayTimeout) {
|
||||
clearTimeout(this.displayTimeout);
|
||||
this.displayTimeout = null;
|
||||
}
|
||||
this.isTyping = false;
|
||||
},
|
||||
|
||||
resumeTyping() {
|
||||
if (!this.inputValue.trim() && !this.isTyping) {
|
||||
this.startTypingAnimation();
|
||||
}
|
||||
},
|
||||
|
||||
handleFocus() {
|
||||
if (this.isTyping && this.placeholderIndex < this.placeholderMessages.length) {
|
||||
const fullMessage = this.placeholderMessages[this.placeholderIndex];
|
||||
this.currentPlaceholder = fullMessage;
|
||||
window.currentPlaceholderText = fullMessage;
|
||||
}
|
||||
this.pauseTyping();
|
||||
},
|
||||
|
||||
handleBlur() {
|
||||
if (!this.inputValue.trim()) {
|
||||
this.resumeTyping();
|
||||
}
|
||||
},
|
||||
|
||||
handleInput() {
|
||||
if (this.inputValue.trim()) {
|
||||
this.pauseTyping();
|
||||
} else {
|
||||
this.resumeTyping();
|
||||
}
|
||||
},
|
||||
|
||||
handleFileSelection(files, fileType) {
|
||||
Array.from(files).forEach(file => {
|
||||
const exists = this.attachedFiles.some(f => f.name === file.name && f.type === fileType);
|
||||
if (!exists) {
|
||||
this.attachedFiles.push({ name: file.name, type: fileType });
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
removeAttachedFile(fileType, fileName) {
|
||||
const index = this.attachedFiles.findIndex(f => f.name === fileName && f.type === fileType);
|
||||
if (index !== -1) {
|
||||
this.attachedFiles.splice(index, 1);
|
||||
}
|
||||
if (fileType === 'image') {
|
||||
this.imageFiles = this.imageFiles.filter(f => f.name !== fileName);
|
||||
} else if (fileType === 'audio') {
|
||||
this.audioFiles = this.audioFiles.filter(f => f.name !== fileName);
|
||||
} else if (fileType === 'file') {
|
||||
this.textFiles = this.textFiles.filter(f => f.name !== fileName);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Start chat function for SPA - navigates to chat view instead of full page redirect
|
||||
function startChatSPA(event) {
|
||||
if (event) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
const form = event ? event.target.closest('form') : document.querySelector('form');
|
||||
if (!form) return;
|
||||
|
||||
const alpineComponent = form.closest('[x-data]');
|
||||
const select = alpineComponent ? alpineComponent.querySelector('select') : null;
|
||||
const textarea = form.querySelector('textarea');
|
||||
|
||||
const selectedModel = select ? select.value : '';
|
||||
let message = textarea ? textarea.value : '';
|
||||
|
||||
if (!message.trim() && window.currentPlaceholderText) {
|
||||
message = window.currentPlaceholderText;
|
||||
}
|
||||
|
||||
if (!selectedModel || !message.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get MCP mode from checkbox
|
||||
let mcpMode = false;
|
||||
const mcpToggle = document.getElementById('spa_home_mcp_toggle');
|
||||
if (mcpToggle && mcpToggle.checked) {
|
||||
mcpMode = true;
|
||||
}
|
||||
|
||||
// Store message and files in localStorage for chat view to pick up
|
||||
const chatData = {
|
||||
message: message,
|
||||
imageFiles: [],
|
||||
audioFiles: [],
|
||||
textFiles: [],
|
||||
mcpMode: mcpMode
|
||||
};
|
||||
|
||||
// Convert files to base64 for storage
|
||||
const imageInput = document.getElementById('spa_home_input_image');
|
||||
const audioInput = document.getElementById('spa_home_input_audio');
|
||||
const fileInput = document.getElementById('spa_home_input_file');
|
||||
|
||||
const filePromises = [
|
||||
...Array.from(imageInput?.files || []).map(file =>
|
||||
new Promise(resolve => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = e => resolve({ name: file.name, data: e.target.result, type: file.type });
|
||||
reader.readAsDataURL(file);
|
||||
})
|
||||
),
|
||||
...Array.from(audioInput?.files || []).map(file =>
|
||||
new Promise(resolve => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = e => resolve({ name: file.name, data: e.target.result, type: file.type });
|
||||
reader.readAsDataURL(file);
|
||||
})
|
||||
),
|
||||
...Array.from(fileInput?.files || []).map(file =>
|
||||
new Promise(resolve => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = e => resolve({ name: file.name, data: e.target.result, type: file.type });
|
||||
reader.readAsText(file);
|
||||
})
|
||||
)
|
||||
];
|
||||
|
||||
const navigateToChat = () => {
|
||||
// Store in localStorage
|
||||
localStorage.setItem('localai_index_chat_data', JSON.stringify(chatData));
|
||||
|
||||
// Use SPA router to navigate to chat
|
||||
if (window.Alpine && Alpine.store('router')) {
|
||||
Alpine.store('router').navigate('chat', { model: selectedModel });
|
||||
} else {
|
||||
// Fallback to full page redirect if router not available
|
||||
window.location.href = `/chat/${selectedModel}`;
|
||||
}
|
||||
};
|
||||
|
||||
if (filePromises.length > 0) {
|
||||
Promise.all(filePromises).then(files => {
|
||||
files.forEach(file => {
|
||||
if (file.type.startsWith('image/')) {
|
||||
chatData.imageFiles.push(file);
|
||||
} else if (file.type.startsWith('audio/')) {
|
||||
chatData.audioFiles.push(file);
|
||||
} else {
|
||||
chatData.textFiles.push(file);
|
||||
}
|
||||
});
|
||||
navigateToChat();
|
||||
}).catch(err => {
|
||||
console.error('Error processing files:', err);
|
||||
navigateToChat();
|
||||
});
|
||||
} else {
|
||||
navigateToChat();
|
||||
}
|
||||
}
|
||||
|
||||
// Resource Monitor component (GPU if available, otherwise RAM)
|
||||
function resourceMonitor() {
|
||||
return {
|
||||
resourceData: null,
|
||||
pollInterval: null,
|
||||
|
||||
async fetchResourceData() {
|
||||
try {
|
||||
const response = await fetch('/api/resources');
|
||||
if (response.ok) {
|
||||
this.resourceData = await response.json();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching resource data:', error);
|
||||
}
|
||||
},
|
||||
|
||||
startPolling() {
|
||||
this.fetchResourceData();
|
||||
this.pollInterval = setInterval(() => this.fetchResourceData(), 5000);
|
||||
},
|
||||
|
||||
stopPolling() {
|
||||
if (this.pollInterval) {
|
||||
clearInterval(this.pollInterval);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Stop individual model
|
||||
async function stopModel(modelName) {
|
||||
if (!confirm(`Are you sure you want to stop "${modelName}"?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/backend/shutdown', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ model: modelName })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 500);
|
||||
} else {
|
||||
alert('Failed to stop model');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error stopping model:', error);
|
||||
alert('Failed to stop model');
|
||||
}
|
||||
}
|
||||
|
||||
// Stop all loaded models
|
||||
async function stopAllModels(component) {
|
||||
// Get loaded models from DOM
|
||||
const loadedModelElements = document.querySelectorAll('[data-loaded-model]');
|
||||
const loadedModelNames = Array.from(loadedModelElements).map(el => {
|
||||
const span = el.querySelector('span.truncate');
|
||||
return span ? span.textContent.trim() : '';
|
||||
}).filter(name => name.length > 0);
|
||||
|
||||
if (loadedModelNames.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(`Are you sure you want to stop all ${loadedModelNames.length} loaded model(s)?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (component) {
|
||||
component.stoppingAll = true;
|
||||
}
|
||||
|
||||
try {
|
||||
const stopPromises = loadedModelNames.map(modelName =>
|
||||
fetch('/backend/shutdown', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ model: modelName })
|
||||
})
|
||||
);
|
||||
|
||||
await Promise.all(stopPromises);
|
||||
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
console.error('Error stopping models:', error);
|
||||
alert('Failed to stop some models');
|
||||
if (component) {
|
||||
component.stoppingAll = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Make functions available globally
|
||||
window.homeInputForm = homeInputForm;
|
||||
window.startChatSPA = startChatSPA;
|
||||
window.resourceMonitor = resourceMonitor;
|
||||
window.stopModel = stopModel;
|
||||
window.stopAllModels = stopAllModels;
|
||||
148
core/http/static/spa-router.js
Normal file
148
core/http/static/spa-router.js
Normal file
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* LocalAI SPA Router
|
||||
* Client-side routing for the single-page application
|
||||
*/
|
||||
|
||||
// Define routes and their corresponding view IDs
|
||||
const SPA_ROUTES = {
|
||||
'home': { title: 'LocalAI', viewId: 'view-home', paths: ['/', ''] },
|
||||
'chat': { title: 'LocalAI - Chat', viewId: 'view-chat', paths: ['/chat'] },
|
||||
'text2image': { title: 'LocalAI - Images', viewId: 'view-text2image', paths: ['/text2image'] },
|
||||
'tts': { title: 'LocalAI - TTS', viewId: 'view-tts', paths: ['/tts'] },
|
||||
'talk': { title: 'LocalAI - Talk', viewId: 'view-talk', paths: ['/talk'] },
|
||||
'manage': { title: 'LocalAI - System', viewId: 'view-manage', paths: ['/manage'] },
|
||||
'browse': { title: 'LocalAI - Model Gallery', viewId: 'view-browse', paths: ['/browse'] }
|
||||
};
|
||||
|
||||
// Parse URL path to determine route
|
||||
function parseUrlPath(pathname) {
|
||||
// Remove trailing slash
|
||||
pathname = pathname.replace(/\/$/, '') || '/';
|
||||
|
||||
// Check for hash-based routes first (for SPA navigation)
|
||||
const hash = window.location.hash.slice(1);
|
||||
if (hash) {
|
||||
const hashParts = hash.split('/');
|
||||
const route = hashParts[0];
|
||||
const model = hashParts[1] || null;
|
||||
if (SPA_ROUTES[route]) {
|
||||
return { route, params: model ? { model } : {} };
|
||||
}
|
||||
}
|
||||
|
||||
// Check path-based routes
|
||||
for (const [route, config] of Object.entries(SPA_ROUTES)) {
|
||||
for (const path of config.paths) {
|
||||
if (pathname === path) {
|
||||
return { route, params: {} };
|
||||
}
|
||||
// Check for parameterized routes like /chat/:model
|
||||
if (pathname.startsWith(path + '/')) {
|
||||
const param = pathname.slice(path.length + 1);
|
||||
if (param) {
|
||||
return { route, params: { model: param } };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default to home
|
||||
return { route: 'home', params: {} };
|
||||
}
|
||||
|
||||
// Initialize the router store for Alpine.js
|
||||
document.addEventListener('alpine:init', () => {
|
||||
// Parse initial route from URL
|
||||
const initialRoute = parseUrlPath(window.location.pathname);
|
||||
|
||||
Alpine.store('router', {
|
||||
currentRoute: initialRoute.route,
|
||||
routeParams: initialRoute.params,
|
||||
previousRoute: null,
|
||||
|
||||
/**
|
||||
* Navigate to a route
|
||||
* @param {string} route - The route name to navigate to
|
||||
* @param {Object} params - Optional parameters for the route
|
||||
*/
|
||||
navigate(route, params = {}) {
|
||||
if (!SPA_ROUTES[route]) {
|
||||
console.warn(`Unknown route: ${route}`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.previousRoute = this.currentRoute;
|
||||
this.currentRoute = route;
|
||||
this.routeParams = params;
|
||||
|
||||
// Update document title
|
||||
document.title = SPA_ROUTES[route].title;
|
||||
|
||||
// Update URL without page reload using history API
|
||||
const url = route === 'home' ? '/' : `/#${route}`;
|
||||
if (params.model) {
|
||||
window.history.pushState({ route, params }, '', `/#${route}/${params.model}`);
|
||||
} else {
|
||||
window.history.pushState({ route, params }, '', url);
|
||||
}
|
||||
|
||||
// Scroll to top on navigation
|
||||
window.scrollTo(0, 0);
|
||||
|
||||
// Emit custom event for route change listeners
|
||||
window.dispatchEvent(new CustomEvent('spa:navigate', {
|
||||
detail: { route, params, previousRoute: this.previousRoute }
|
||||
}));
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if the current route matches
|
||||
* @param {string} route - The route to check
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isRoute(route) {
|
||||
return this.currentRoute === route;
|
||||
},
|
||||
|
||||
/**
|
||||
* Navigate to chat with a specific model
|
||||
* @param {string} model - The model name
|
||||
*/
|
||||
navigateToChat(model) {
|
||||
this.navigate('chat', { model });
|
||||
},
|
||||
|
||||
/**
|
||||
* Navigate to text2image with a specific model
|
||||
* @param {string} model - The model name
|
||||
*/
|
||||
navigateToText2Image(model) {
|
||||
this.navigate('text2image', { model });
|
||||
},
|
||||
|
||||
/**
|
||||
* Navigate to TTS with a specific model
|
||||
* @param {string} model - The model name
|
||||
*/
|
||||
navigateToTTS(model) {
|
||||
this.navigate('tts', { model });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Handle browser back/forward buttons
|
||||
window.addEventListener('popstate', (event) => {
|
||||
if (event.state && event.state.route) {
|
||||
Alpine.store('router').currentRoute = event.state.route;
|
||||
Alpine.store('router').routeParams = event.state.params || {};
|
||||
} else {
|
||||
// Parse URL for route
|
||||
const parsed = parseUrlPath(window.location.pathname);
|
||||
Alpine.store('router').currentRoute = parsed.route;
|
||||
Alpine.store('router').routeParams = parsed.params;
|
||||
}
|
||||
});
|
||||
|
||||
// Export for use in other scripts
|
||||
window.SPA_ROUTES = SPA_ROUTES;
|
||||
window.parseUrlPath = parseUrlPath;
|
||||
@@ -54,6 +54,11 @@
|
||||
<span class="font-semibold text-cyan-300" x-text="installedBackends"></span>
|
||||
<span class="text-[#94A3B8] ml-1">installed</span>
|
||||
</a>
|
||||
<div class="flex items-center bg-[#101827] rounded-lg px-4 py-2 border border-[#38BDF8]/30">
|
||||
<i class="fas fa-microchip text-[#38BDF8] mr-2"></i>
|
||||
<span class="text-[#94A3B8] mr-1">Capability:</span>
|
||||
<span class="font-semibold text-[#38BDF8]" x-text="systemCapability"></span>
|
||||
</div>
|
||||
<a href="https://localai.io/backends/" target="_blank" class="btn-primary">
|
||||
<i class="fas fa-info-circle mr-2"></i>
|
||||
<span>Documentation</span>
|
||||
@@ -588,6 +593,7 @@ function backendsGallery() {
|
||||
totalPages: 1,
|
||||
availableBackends: 0,
|
||||
installedBackends: 0,
|
||||
systemCapability: '',
|
||||
selectedBackend: null,
|
||||
jobProgress: {},
|
||||
notifications: [],
|
||||
@@ -683,6 +689,7 @@ function backendsGallery() {
|
||||
this.totalPages = data.totalPages || 1;
|
||||
this.availableBackends = data.availableBackends || 0;
|
||||
this.installedBackends = data.installedBackends || 0;
|
||||
this.systemCapability = data.systemCapability || 'default';
|
||||
} catch (error) {
|
||||
console.error('Error fetching backends:', error);
|
||||
} finally {
|
||||
|
||||
154
core/http/views/partials/spa_navbar.html
Normal file
154
core/http/views/partials/spa_navbar.html
Normal file
@@ -0,0 +1,154 @@
|
||||
<nav class="bg-[var(--color-bg-primary)] shadow-2xl border-b border-[var(--color-bg-secondary)]">
|
||||
<div class="container mx-auto px-4 py-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<!-- Logo Image -->
|
||||
<a href="#" @click.prevent="$store.router.navigate('home')" class="flex items-center group">
|
||||
<img src="static/logo_horizontal.png"
|
||||
alt="LocalAI Logo"
|
||||
class="h-10 mr-3 brightness-110 transition-all duration-300 group-hover:brightness-125 group-hover:drop-shadow-[0_0_8px_var(--color-primary-border)]">
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Menu button for small screens -->
|
||||
<div class="lg:hidden">
|
||||
<button @click="mobileMenuOpen = !mobileMenuOpen" class="text-[var(--color-text-secondary)] hover:text-[var(--color-primary)] focus:outline-none p-2 rounded-lg transition duration-300 ease-in-out hover:bg-[var(--color-bg-secondary)]">
|
||||
<i class="fas fa-bars fa-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Navigation links -->
|
||||
<div class="hidden lg:flex lg:items-center lg:justify-end lg:space-x-1">
|
||||
<a href="#" @click.prevent="$store.router.navigate('home')"
|
||||
:class="$store.router.currentRoute === 'home' ? 'text-[var(--color-primary)] bg-[var(--color-bg-secondary)]' : 'text-[var(--color-text-secondary)]'"
|
||||
class="hover:text-[var(--color-text-primary)] px-2 py-2 rounded-lg transition duration-300 ease-in-out hover:bg-[var(--color-bg-secondary)] flex items-center group text-sm">
|
||||
<i class="fas fa-home text-[var(--color-primary)] mr-1.5 text-sm group-hover:scale-110 transition-transform"></i>Home
|
||||
</a>
|
||||
<a href="#" @click.prevent="$store.router.navigate('chat')"
|
||||
:class="$store.router.currentRoute === 'chat' ? 'text-[var(--color-primary)] bg-[var(--color-bg-secondary)]' : 'text-[var(--color-text-secondary)]'"
|
||||
class="hover:text-[var(--color-text-primary)] px-2 py-2 rounded-lg transition duration-300 ease-in-out hover:bg-[var(--color-bg-secondary)] flex items-center group text-sm">
|
||||
<i class="fa-solid fa-comments text-[var(--color-primary)] mr-1.5 text-sm group-hover:scale-110 transition-transform"></i>Chat
|
||||
</a>
|
||||
<a href="#" @click.prevent="$store.router.navigate('text2image')"
|
||||
:class="$store.router.currentRoute === 'text2image' ? 'text-[var(--color-primary)] bg-[var(--color-bg-secondary)]' : 'text-[var(--color-text-secondary)]'"
|
||||
class="hover:text-[var(--color-text-primary)] px-2 py-2 rounded-lg transition duration-300 ease-in-out hover:bg-[var(--color-bg-secondary)] flex items-center group text-sm">
|
||||
<i class="fas fa-image text-[var(--color-primary)] mr-1.5 text-sm group-hover:scale-110 transition-transform"></i>Images
|
||||
</a>
|
||||
<a href="#" @click.prevent="$store.router.navigate('tts')"
|
||||
:class="$store.router.currentRoute === 'tts' ? 'text-[var(--color-primary)] bg-[var(--color-bg-secondary)]' : 'text-[var(--color-text-secondary)]'"
|
||||
class="hover:text-[var(--color-text-primary)] px-2 py-2 rounded-lg transition duration-300 ease-in-out hover:bg-[var(--color-bg-secondary)] flex items-center group text-sm">
|
||||
<i class="fa-solid fa-music text-[var(--color-primary)] mr-1.5 text-sm group-hover:scale-110 transition-transform"></i>TTS
|
||||
</a>
|
||||
<a href="#" @click.prevent="$store.router.navigate('talk')"
|
||||
:class="$store.router.currentRoute === 'talk' ? 'text-[var(--color-primary)] bg-[var(--color-bg-secondary)]' : 'text-[var(--color-text-secondary)]'"
|
||||
class="hover:text-[var(--color-text-primary)] px-2 py-2 rounded-lg transition duration-300 ease-in-out hover:bg-[var(--color-bg-secondary)] flex items-center group text-sm">
|
||||
<i class="fa-solid fa-phone text-[var(--color-primary)] mr-1.5 text-sm group-hover:scale-110 transition-transform"></i>Talk
|
||||
</a>
|
||||
<a href="agent-jobs" class="text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] px-2 py-2 rounded-lg transition duration-300 ease-in-out hover:bg-[var(--color-bg-secondary)] flex items-center group text-sm">
|
||||
<i class="fas fa-tasks text-[var(--color-primary)] mr-1.5 text-sm group-hover:scale-110 transition-transform"></i>Agent Jobs
|
||||
</a>
|
||||
<a href="traces/" class="text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] px-2 py-2 rounded-lg transition duration-300 ease-in-out hover:bg-[var(--color-bg-secondary)] flex items-center group text-sm">
|
||||
<i class="fas fa-chart-line text-[var(--color-primary)] mr-1.5 text-sm group-hover:scale-110 transition-transform"></i>Traces
|
||||
</a>
|
||||
<a href="swagger/index.html" class="text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] px-2 py-2 rounded-lg transition duration-300 ease-in-out hover:bg-[var(--color-bg-secondary)] flex items-center group text-sm">
|
||||
<i class="fas fa-code text-[var(--color-primary)] mr-1.5 text-sm group-hover:scale-110 transition-transform"></i>API
|
||||
</a>
|
||||
|
||||
<!-- System Dropdown -->
|
||||
<div class="relative" @click.away="settingsOpen = false">
|
||||
<button @click="settingsOpen = !settingsOpen"
|
||||
class="text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] px-2 py-2 rounded-lg transition duration-300 ease-in-out hover:bg-[var(--color-bg-secondary)] flex items-center group text-sm">
|
||||
<i class="fas fa-cog text-[var(--color-primary)] mr-1.5 text-sm group-hover:scale-110 transition-transform"></i>Settings
|
||||
<i class="fas fa-chevron-down ml-1 text-xs transition-transform" :class="settingsOpen ? 'rotate-180' : ''"></i>
|
||||
</button>
|
||||
<div x-show="settingsOpen"
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0 scale-95"
|
||||
x-transition:enter-end="opacity-100 scale-100"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100 scale-100"
|
||||
x-transition:leave-end="opacity-0 scale-95"
|
||||
class="absolute top-full right-0 mt-1 w-48 bg-[var(--color-bg-secondary)] border border-[var(--color-primary-border)]/20 rounded-lg shadow-lg z-50 py-1">
|
||||
<a href="#" @click.prevent="$store.router.navigate('browse'); settingsOpen = false" class="block text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-primary)] px-3 py-2 text-sm transition-colors flex items-center">
|
||||
<i class="fas fa-brain text-[var(--color-primary)] mr-2 text-xs"></i>Models
|
||||
</a>
|
||||
<a href="browse/backends" class="block text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-primary)] px-3 py-2 text-sm transition-colors flex items-center">
|
||||
<i class="fas fa-server text-[var(--color-primary)] mr-2 text-xs"></i>Backends
|
||||
</a>
|
||||
<a href="p2p/" class="block text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-primary)] px-3 py-2 text-sm transition-colors flex items-center">
|
||||
<i class="fa-solid fa-circle-nodes text-[var(--color-primary)] mr-2 text-xs"></i>Swarm
|
||||
</a>
|
||||
<a href="#" @click.prevent="$store.router.navigate('manage'); settingsOpen = false" class="block text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-primary)] px-3 py-2 text-sm transition-colors flex items-center">
|
||||
<i class="fas fa-cog text-[var(--color-primary)] mr-2 text-xs"></i>System
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Collapsible menu for small screens -->
|
||||
<div class="lg:hidden" x-show="mobileMenuOpen" x-transition>
|
||||
<div class="pt-3 pb-2 space-y-1 border-t border-[var(--color-bg-secondary)] mt-2">
|
||||
<a href="#" @click.prevent="$store.router.navigate('home'); mobileMenuOpen = false"
|
||||
:class="$store.router.currentRoute === 'home' ? 'text-[var(--color-primary)] bg-[var(--color-bg-secondary)]' : 'text-[var(--color-text-secondary)]'"
|
||||
class="block hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-secondary)] px-3 py-2 rounded-lg transition duration-300 ease-in-out flex items-center text-sm">
|
||||
<i class="fas fa-home text-[var(--color-primary)] mr-3 w-5 text-center text-sm"></i>Home
|
||||
</a>
|
||||
<a href="#" @click.prevent="$store.router.navigate('chat'); mobileMenuOpen = false"
|
||||
:class="$store.router.currentRoute === 'chat' ? 'text-[var(--color-primary)] bg-[var(--color-bg-secondary)]' : 'text-[var(--color-text-secondary)]'"
|
||||
class="block hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-secondary)] px-3 py-2 rounded-lg transition duration-300 ease-in-out flex items-center text-sm">
|
||||
<i class="fa-solid fa-comments text-[var(--color-primary)] mr-3 w-5 text-center text-sm"></i>Chat
|
||||
</a>
|
||||
<a href="#" @click.prevent="$store.router.navigate('text2image'); mobileMenuOpen = false"
|
||||
:class="$store.router.currentRoute === 'text2image' ? 'text-[var(--color-primary)] bg-[var(--color-bg-secondary)]' : 'text-[var(--color-text-secondary)]'"
|
||||
class="block hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-secondary)] px-3 py-2 rounded-lg transition duration-300 ease-in-out flex items-center text-sm">
|
||||
<i class="fas fa-image text-[var(--color-primary)] mr-3 w-5 text-center text-sm"></i>Images
|
||||
</a>
|
||||
<a href="#" @click.prevent="$store.router.navigate('tts'); mobileMenuOpen = false"
|
||||
:class="$store.router.currentRoute === 'tts' ? 'text-[var(--color-primary)] bg-[var(--color-bg-secondary)]' : 'text-[var(--color-text-secondary)]'"
|
||||
class="block hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-secondary)] px-3 py-2 rounded-lg transition duration-300 ease-in-out flex items-center text-sm">
|
||||
<i class="fa-solid fa-music text-[var(--color-primary)] mr-3 w-5 text-center text-sm"></i>TTS
|
||||
</a>
|
||||
<a href="#" @click.prevent="$store.router.navigate('talk'); mobileMenuOpen = false"
|
||||
:class="$store.router.currentRoute === 'talk' ? 'text-[var(--color-primary)] bg-[var(--color-bg-secondary)]' : 'text-[var(--color-text-secondary)]'"
|
||||
class="block hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-secondary)] px-3 py-2 rounded-lg transition duration-300 ease-in-out flex items-center text-sm">
|
||||
<i class="fa-solid fa-phone text-[var(--color-primary)] mr-3 w-5 text-center text-sm"></i>Talk
|
||||
</a>
|
||||
<a href="agent-jobs" class="block text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-secondary)] px-3 py-2 rounded-lg transition duration-300 ease-in-out flex items-center text-sm">
|
||||
<i class="fas fa-tasks text-[var(--color-primary)] mr-3 w-5 text-center text-sm"></i>Agent Jobs
|
||||
</a>
|
||||
<a href="traces/" class="block text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-secondary)] px-3 py-2 rounded-lg transition duration-300 ease-in-out flex items-center text-sm">
|
||||
<i class="fas fa-chart-line text-[var(--color-primary)] mr-3 w-5 text-center text-sm"></i>Traces
|
||||
</a>
|
||||
<a href="swagger/index.html" class="block text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-secondary)] px-3 py-2 rounded-lg transition duration-300 ease-in-out flex items-center text-sm">
|
||||
<i class="fas fa-code text-[var(--color-primary)] mr-3 w-5 text-center text-sm"></i>API
|
||||
</a>
|
||||
|
||||
<!-- System with submenu -->
|
||||
<div>
|
||||
<button @click="mobileSettingsOpen = !mobileSettingsOpen"
|
||||
class="w-full text-left text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-secondary)] px-3 py-2 rounded-lg transition duration-300 ease-in-out flex items-center justify-between text-sm">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-cog text-[var(--color-primary)] mr-3 w-5 text-center text-sm"></i>Settings
|
||||
</div>
|
||||
<i class="fas fa-chevron-down text-xs transition-transform" :class="mobileSettingsOpen ? 'rotate-180' : ''"></i>
|
||||
</button>
|
||||
<div x-show="mobileSettingsOpen" x-transition class="overflow-hidden">
|
||||
<a href="#" @click.prevent="$store.router.navigate('browse'); mobileMenuOpen = false" class="block text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-secondary)] pl-8 pr-3 py-2 rounded-lg transition duration-300 ease-in-out flex items-center text-sm">
|
||||
<i class="fas fa-brain text-[var(--color-primary)] mr-3 w-5 text-center text-xs"></i>Models
|
||||
</a>
|
||||
<a href="browse/backends" class="block text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-secondary)] pl-8 pr-3 py-2 rounded-lg transition duration-300 ease-in-out flex items-center text-sm">
|
||||
<i class="fas fa-server text-[var(--color-primary)] mr-3 w-5 text-center text-xs"></i>Backends
|
||||
</a>
|
||||
<a href="p2p/" class="block text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-secondary)] pl-8 pr-3 py-2 rounded-lg transition duration-300 ease-in-out flex items-center text-sm">
|
||||
<i class="fa-solid fa-circle-nodes text-[var(--color-primary)] mr-3 w-5 text-center text-xs"></i>Swarm
|
||||
</a>
|
||||
<a href="#" @click.prevent="$store.router.navigate('manage'); mobileMenuOpen = false" class="block text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-secondary)] pl-8 pr-3 py-2 rounded-lg transition duration-300 ease-in-out flex items-center text-sm">
|
||||
<i class="fas fa-cog text-[var(--color-primary)] mr-3 w-5 text-center text-xs"></i>System
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
565
core/http/views/spa.html
Normal file
565
core/http/views/spa.html
Normal file
@@ -0,0 +1,565 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
{{template "views/partials/head" .}}
|
||||
|
||||
<!-- Critical Alpine.js component functions must be defined before Alpine loads -->
|
||||
<script>
|
||||
// Resource Monitor component (GPU if available, otherwise RAM)
|
||||
function resourceMonitor() {
|
||||
return {
|
||||
resourceData: null,
|
||||
pollInterval: null,
|
||||
|
||||
async fetchResourceData() {
|
||||
try {
|
||||
const response = await fetch('/api/resources');
|
||||
if (response.ok) {
|
||||
this.resourceData = await response.json();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching resource data:', error);
|
||||
}
|
||||
},
|
||||
|
||||
startPolling() {
|
||||
this.fetchResourceData();
|
||||
this.pollInterval = setInterval(() => this.fetchResourceData(), 5000);
|
||||
},
|
||||
|
||||
stopPolling() {
|
||||
if (this.pollInterval) {
|
||||
clearInterval(this.pollInterval);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Format bytes helper
|
||||
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(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
// Home input form component
|
||||
function homeInputForm() {
|
||||
return {
|
||||
selectedModel: '',
|
||||
inputValue: '',
|
||||
shiftPressed: false,
|
||||
fileName: '',
|
||||
imageFiles: [],
|
||||
audioFiles: [],
|
||||
textFiles: [],
|
||||
attachedFiles: [],
|
||||
mcpMode: false,
|
||||
mcpAvailable: false,
|
||||
mcpModels: {},
|
||||
currentPlaceholder: 'Send a message...',
|
||||
placeholderIndex: 0,
|
||||
charIndex: 0,
|
||||
isTyping: false,
|
||||
typingTimeout: null,
|
||||
displayTimeout: null,
|
||||
placeholderMessages: [
|
||||
'What is Nuclear fusion?',
|
||||
'How does a combustion engine work?',
|
||||
'Explain quantum computing',
|
||||
'What causes climate change?',
|
||||
'How do neural networks learn?',
|
||||
'What is the theory of relativity?',
|
||||
'How does photosynthesis work?',
|
||||
'Explain the water cycle',
|
||||
'What is machine learning?',
|
||||
'How do black holes form?'
|
||||
],
|
||||
|
||||
init() {
|
||||
window.currentPlaceholderText = this.currentPlaceholder;
|
||||
this.startTypingAnimation();
|
||||
this.buildMCPModelsMap();
|
||||
this.$nextTick(() => {
|
||||
const select = this.$el.querySelector('select');
|
||||
if (select && select.options.length > 1) {
|
||||
const firstModelOption = select.options[1];
|
||||
if (firstModelOption && firstModelOption.value) {
|
||||
this.selectedModel = firstModelOption.value;
|
||||
this.checkMCPAvailability();
|
||||
}
|
||||
}
|
||||
});
|
||||
this.$watch('selectedModel', () => {
|
||||
this.checkMCPAvailability();
|
||||
});
|
||||
},
|
||||
|
||||
buildMCPModelsMap() {
|
||||
const select = this.$el.querySelector('select');
|
||||
if (!select) return;
|
||||
this.mcpModels = {};
|
||||
for (let i = 0; i < select.options.length; i++) {
|
||||
const option = select.options[i];
|
||||
if (option.value) {
|
||||
const hasMcpAttr = option.getAttribute('data-has-mcp');
|
||||
this.mcpModels[option.value] = hasMcpAttr === 'true';
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
checkMCPAvailability() {
|
||||
if (!this.selectedModel) {
|
||||
this.mcpAvailable = false;
|
||||
this.mcpMode = false;
|
||||
return;
|
||||
}
|
||||
const hasMCP = this.mcpModels[this.selectedModel] === true;
|
||||
this.mcpAvailable = hasMCP;
|
||||
if (!hasMCP) {
|
||||
this.mcpMode = false;
|
||||
}
|
||||
},
|
||||
|
||||
startTypingAnimation() {
|
||||
if (this.isTyping) return;
|
||||
this.typeNextPlaceholder();
|
||||
},
|
||||
|
||||
typeNextPlaceholder() {
|
||||
if (this.isTyping) return;
|
||||
this.isTyping = true;
|
||||
this.charIndex = 0;
|
||||
const message = this.placeholderMessages[this.placeholderIndex];
|
||||
this.currentPlaceholder = '';
|
||||
window.currentPlaceholderText = '';
|
||||
|
||||
const typeChar = () => {
|
||||
if (this.charIndex < message.length) {
|
||||
this.currentPlaceholder = message.substring(0, this.charIndex + 1);
|
||||
window.currentPlaceholderText = this.currentPlaceholder;
|
||||
this.charIndex++;
|
||||
this.typingTimeout = setTimeout(typeChar, 30);
|
||||
} else {
|
||||
this.isTyping = false;
|
||||
window.currentPlaceholderText = this.currentPlaceholder;
|
||||
this.displayTimeout = setTimeout(() => {
|
||||
this.placeholderIndex = (this.placeholderIndex + 1) % this.placeholderMessages.length;
|
||||
this.typeNextPlaceholder();
|
||||
}, 2000);
|
||||
}
|
||||
};
|
||||
|
||||
typeChar();
|
||||
},
|
||||
|
||||
pauseTyping() {
|
||||
if (this.typingTimeout) {
|
||||
clearTimeout(this.typingTimeout);
|
||||
this.typingTimeout = null;
|
||||
}
|
||||
if (this.displayTimeout) {
|
||||
clearTimeout(this.displayTimeout);
|
||||
this.displayTimeout = null;
|
||||
}
|
||||
this.isTyping = false;
|
||||
},
|
||||
|
||||
resumeTyping() {
|
||||
if (!this.inputValue.trim() && !this.isTyping) {
|
||||
this.startTypingAnimation();
|
||||
}
|
||||
},
|
||||
|
||||
handleFocus() {
|
||||
if (this.isTyping && this.placeholderIndex < this.placeholderMessages.length) {
|
||||
const fullMessage = this.placeholderMessages[this.placeholderIndex];
|
||||
this.currentPlaceholder = fullMessage;
|
||||
window.currentPlaceholderText = fullMessage;
|
||||
}
|
||||
this.pauseTyping();
|
||||
},
|
||||
|
||||
handleBlur() {
|
||||
if (!this.inputValue.trim()) {
|
||||
this.resumeTyping();
|
||||
}
|
||||
},
|
||||
|
||||
handleInput() {
|
||||
if (this.inputValue.trim()) {
|
||||
this.pauseTyping();
|
||||
} else {
|
||||
this.resumeTyping();
|
||||
}
|
||||
},
|
||||
|
||||
handleFileSelection(files, fileType) {
|
||||
Array.from(files).forEach(file => {
|
||||
const exists = this.attachedFiles.some(f => f.name === file.name && f.type === fileType);
|
||||
if (!exists) {
|
||||
this.attachedFiles.push({ name: file.name, type: fileType });
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
removeAttachedFile(fileType, fileName) {
|
||||
const index = this.attachedFiles.findIndex(f => f.name === fileName && f.type === fileType);
|
||||
if (index !== -1) {
|
||||
this.attachedFiles.splice(index, 1);
|
||||
}
|
||||
if (fileType === 'image') {
|
||||
this.imageFiles = this.imageFiles.filter(f => f.name !== fileName);
|
||||
} else if (fileType === 'audio') {
|
||||
this.audioFiles = this.audioFiles.filter(f => f.name !== fileName);
|
||||
} else if (fileType === 'file') {
|
||||
this.textFiles = this.textFiles.filter(f => f.name !== fileName);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Start chat function for SPA
|
||||
function startChatSPA(event) {
|
||||
if (event) event.preventDefault();
|
||||
const form = event ? event.target.closest('form') : document.querySelector('form');
|
||||
if (!form) return;
|
||||
|
||||
const alpineComponent = form.closest('[x-data]');
|
||||
const select = alpineComponent ? alpineComponent.querySelector('select') : null;
|
||||
const textarea = form.querySelector('textarea');
|
||||
|
||||
const selectedModel = select ? select.value : '';
|
||||
let message = textarea ? textarea.value : '';
|
||||
|
||||
if (!message.trim() && window.currentPlaceholderText) {
|
||||
message = window.currentPlaceholderText;
|
||||
}
|
||||
|
||||
if (!selectedModel || !message.trim()) return;
|
||||
|
||||
let mcpMode = false;
|
||||
const mcpToggle = document.getElementById('spa_home_mcp_toggle');
|
||||
if (mcpToggle && mcpToggle.checked) mcpMode = true;
|
||||
|
||||
const chatData = { message, imageFiles: [], audioFiles: [], textFiles: [], mcpMode };
|
||||
const imageInput = document.getElementById('spa_home_input_image');
|
||||
const audioInput = document.getElementById('spa_home_input_audio');
|
||||
const fileInput = document.getElementById('spa_home_input_file');
|
||||
|
||||
const filePromises = [
|
||||
...Array.from(imageInput?.files || []).map(file =>
|
||||
new Promise(resolve => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = e => resolve({ name: file.name, data: e.target.result, type: file.type });
|
||||
reader.readAsDataURL(file);
|
||||
})
|
||||
),
|
||||
...Array.from(audioInput?.files || []).map(file =>
|
||||
new Promise(resolve => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = e => resolve({ name: file.name, data: e.target.result, type: file.type });
|
||||
reader.readAsDataURL(file);
|
||||
})
|
||||
),
|
||||
...Array.from(fileInput?.files || []).map(file =>
|
||||
new Promise(resolve => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = e => resolve({ name: file.name, data: e.target.result, type: file.type });
|
||||
reader.readAsText(file);
|
||||
})
|
||||
)
|
||||
];
|
||||
|
||||
const navigateToChat = () => {
|
||||
localStorage.setItem('localai_index_chat_data', JSON.stringify(chatData));
|
||||
if (window.Alpine && Alpine.store('router')) {
|
||||
Alpine.store('router').navigate('chat', { model: selectedModel });
|
||||
} else {
|
||||
window.location.href = `/chat/${selectedModel}`;
|
||||
}
|
||||
};
|
||||
|
||||
if (filePromises.length > 0) {
|
||||
Promise.all(filePromises).then(files => {
|
||||
files.forEach(file => {
|
||||
if (file.type.startsWith('image/')) chatData.imageFiles.push(file);
|
||||
else if (file.type.startsWith('audio/')) chatData.audioFiles.push(file);
|
||||
else chatData.textFiles.push(file);
|
||||
});
|
||||
navigateToChat();
|
||||
}).catch(() => navigateToChat());
|
||||
} else {
|
||||
navigateToChat();
|
||||
}
|
||||
}
|
||||
|
||||
// Stop individual model
|
||||
async function stopModel(modelName) {
|
||||
if (!confirm(`Are you sure you want to stop "${modelName}"?`)) return;
|
||||
try {
|
||||
const response = await fetch('/backend/shutdown', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ model: modelName })
|
||||
});
|
||||
if (response.ok) {
|
||||
setTimeout(() => window.location.reload(), 500);
|
||||
} else {
|
||||
alert('Failed to stop model');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error stopping model:', error);
|
||||
alert('Failed to stop model');
|
||||
}
|
||||
}
|
||||
|
||||
// Stop all loaded models
|
||||
async function stopAllModels(component) {
|
||||
const loadedModelElements = document.querySelectorAll('[data-loaded-model]');
|
||||
const loadedModelNames = Array.from(loadedModelElements).map(el => {
|
||||
const span = el.querySelector('span.truncate');
|
||||
return span ? span.textContent.trim() : '';
|
||||
}).filter(name => name.length > 0);
|
||||
|
||||
if (loadedModelNames.length === 0) return;
|
||||
if (!confirm(`Are you sure you want to stop all ${loadedModelNames.length} loaded model(s)?`)) return;
|
||||
|
||||
if (component) component.stoppingAll = true;
|
||||
|
||||
try {
|
||||
const stopPromises = loadedModelNames.map(modelName =>
|
||||
fetch('/backend/shutdown', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ model: modelName })
|
||||
})
|
||||
);
|
||||
await Promise.all(stopPromises);
|
||||
setTimeout(() => window.location.reload(), 1000);
|
||||
} catch (error) {
|
||||
console.error('Error stopping models:', error);
|
||||
alert('Failed to stop some models');
|
||||
if (component) component.stoppingAll = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Make available globally
|
||||
window.resourceMonitor = resourceMonitor;
|
||||
window.formatBytes = formatBytes;
|
||||
window.homeInputForm = homeInputForm;
|
||||
window.startChatSPA = startChatSPA;
|
||||
window.stopModel = stopModel;
|
||||
window.stopAllModels = stopAllModels;
|
||||
|
||||
// ========================================
|
||||
// SPA Router - Alpine.js Store Definition
|
||||
// Must be defined before Alpine.js initializes
|
||||
// ========================================
|
||||
|
||||
// Define routes and their corresponding view IDs
|
||||
const SPA_ROUTES = {
|
||||
'home': { title: 'LocalAI', viewId: 'view-home', paths: ['/', ''] },
|
||||
'chat': { title: 'LocalAI - Chat', viewId: 'view-chat', paths: ['/chat'] },
|
||||
'text2image': { title: 'LocalAI - Images', viewId: 'view-text2image', paths: ['/text2image'] },
|
||||
'tts': { title: 'LocalAI - TTS', viewId: 'view-tts', paths: ['/tts'] },
|
||||
'talk': { title: 'LocalAI - Talk', viewId: 'view-talk', paths: ['/talk'] },
|
||||
'manage': { title: 'LocalAI - System', viewId: 'view-manage', paths: ['/manage'] },
|
||||
'browse': { title: 'LocalAI - Model Gallery', viewId: 'view-browse', paths: ['/browse'] }
|
||||
};
|
||||
|
||||
// Parse URL path to determine route
|
||||
function parseUrlPath(pathname) {
|
||||
pathname = pathname.replace(/\/$/, '') || '/';
|
||||
|
||||
// Check for hash-based routes first
|
||||
const hash = window.location.hash.slice(1);
|
||||
if (hash) {
|
||||
const hashParts = hash.split('/');
|
||||
const route = hashParts[0];
|
||||
const model = hashParts[1] || null;
|
||||
if (SPA_ROUTES[route]) {
|
||||
return { route, params: model ? { model } : {} };
|
||||
}
|
||||
}
|
||||
|
||||
// Check path-based routes
|
||||
for (const [route, config] of Object.entries(SPA_ROUTES)) {
|
||||
for (const path of config.paths) {
|
||||
if (pathname === path) {
|
||||
return { route, params: {} };
|
||||
}
|
||||
if (pathname.startsWith(path + '/')) {
|
||||
const param = pathname.slice(path.length + 1);
|
||||
if (param) {
|
||||
return { route, params: { model: param } };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { route: 'home', params: {} };
|
||||
}
|
||||
|
||||
// Register the router store with Alpine.js on init event
|
||||
document.addEventListener('alpine:init', () => {
|
||||
const initialRoute = parseUrlPath(window.location.pathname);
|
||||
|
||||
Alpine.store('router', {
|
||||
currentRoute: initialRoute.route,
|
||||
routeParams: initialRoute.params,
|
||||
previousRoute: null,
|
||||
|
||||
navigate(route, params = {}) {
|
||||
if (!SPA_ROUTES[route]) {
|
||||
console.warn('Unknown route:', route);
|
||||
return;
|
||||
}
|
||||
|
||||
this.previousRoute = this.currentRoute;
|
||||
this.currentRoute = route;
|
||||
this.routeParams = params;
|
||||
|
||||
document.title = SPA_ROUTES[route].title;
|
||||
|
||||
const url = route === 'home' ? '/' : '/#' + route;
|
||||
if (params.model) {
|
||||
window.history.pushState({ route, params }, '', '/#' + route + '/' + params.model);
|
||||
} else {
|
||||
window.history.pushState({ route, params }, '', url);
|
||||
}
|
||||
|
||||
window.scrollTo(0, 0);
|
||||
window.dispatchEvent(new CustomEvent('spa:navigate', {
|
||||
detail: { route, params, previousRoute: this.previousRoute }
|
||||
}));
|
||||
},
|
||||
|
||||
isRoute(route) {
|
||||
return this.currentRoute === route;
|
||||
},
|
||||
|
||||
navigateToChat(model) {
|
||||
this.navigate('chat', { model });
|
||||
},
|
||||
|
||||
navigateToText2Image(model) {
|
||||
this.navigate('text2image', { model });
|
||||
},
|
||||
|
||||
navigateToTTS(model) {
|
||||
this.navigate('tts', { model });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Handle browser back/forward buttons
|
||||
window.addEventListener('popstate', (event) => {
|
||||
if (window.Alpine && Alpine.store('router')) {
|
||||
if (event.state && event.state.route) {
|
||||
Alpine.store('router').currentRoute = event.state.route;
|
||||
Alpine.store('router').routeParams = event.state.params || {};
|
||||
} else {
|
||||
const parsed = parseUrlPath(window.location.pathname);
|
||||
Alpine.store('router').currentRoute = parsed.route;
|
||||
Alpine.store('router').routeParams = parsed.params;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Export for use in other scripts
|
||||
window.SPA_ROUTES = SPA_ROUTES;
|
||||
window.parseUrlPath = parseUrlPath;
|
||||
</script>
|
||||
|
||||
<!-- SPA Scripts -->
|
||||
<script defer src="static/spa-router.js"></script>
|
||||
<script defer src="static/spa-home.js"></script>
|
||||
<script defer src="static/chat.js"></script>
|
||||
<script defer src="static/image.js"></script>
|
||||
<script defer src="static/tts.js"></script>
|
||||
<!-- Note: talk.js is NOT included here because it has global-scope DOM access that
|
||||
conflicts with the SPA architecture. The SPA talk view has its own inline JS. -->
|
||||
<script src="static/assets/pdf.min.js"></script>
|
||||
<script>
|
||||
// Initialize PDF.js worker
|
||||
if (typeof pdfjsLib !== 'undefined') {
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = 'static/assets/pdf.worker.min.js';
|
||||
}
|
||||
|
||||
// Store gallery configs for header icon display and model info modal
|
||||
window.__galleryConfigs = {};
|
||||
{{ $allGalleryConfigs:=.GalleryConfig }}
|
||||
{{ range $modelName, $galleryConfig := $allGalleryConfigs }}
|
||||
window.__galleryConfigs["{{$modelName}}"] = {};
|
||||
{{ if $galleryConfig.Icon }}
|
||||
window.__galleryConfigs["{{$modelName}}"].Icon = "{{$galleryConfig.Icon}}";
|
||||
{{ end }}
|
||||
{{ if $galleryConfig.Description }}
|
||||
window.__galleryConfigs["{{$modelName}}"].Description = {{ printf "%q" $galleryConfig.Description }};
|
||||
{{ end }}
|
||||
{{ if $galleryConfig.URLs }}
|
||||
window.__galleryConfigs["{{$modelName}}"].URLs = [
|
||||
{{ range $idx, $url := $galleryConfig.URLs }}
|
||||
{{ if $idx }},{{ end }}{{ printf "%q" $url }}
|
||||
{{ end }}
|
||||
];
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
</script>
|
||||
|
||||
<body class="bg-[var(--color-bg-primary)] text-[var(--color-text-primary)]">
|
||||
<div class="flex flex-col min-h-screen" x-data="{ mobileMenuOpen: false, settingsOpen: false, mobileSettingsOpen: false }">
|
||||
|
||||
{{template "views/partials/spa_navbar" .}}
|
||||
|
||||
<!-- SPA View Container -->
|
||||
<div class="flex-1 flex flex-col">
|
||||
|
||||
<!-- Home View -->
|
||||
<div x-show="$store.router.currentRoute === 'home'" x-cloak>
|
||||
{{template "views/spa/home" .}}
|
||||
</div>
|
||||
|
||||
<!-- Chat View -->
|
||||
<div x-show="$store.router.currentRoute === 'chat'" x-cloak class="flex-1 flex flex-col">
|
||||
{{template "views/spa/chat" .}}
|
||||
</div>
|
||||
|
||||
<!-- Text2Image View -->
|
||||
<div x-show="$store.router.currentRoute === 'text2image'" x-cloak class="flex-1 flex flex-col">
|
||||
{{template "views/spa/text2image" .}}
|
||||
</div>
|
||||
|
||||
<!-- TTS View -->
|
||||
<div x-show="$store.router.currentRoute === 'tts'" x-cloak class="flex-1 flex flex-col">
|
||||
{{template "views/spa/tts" .}}
|
||||
</div>
|
||||
|
||||
<!-- Talk View -->
|
||||
<div x-show="$store.router.currentRoute === 'talk'" x-cloak class="flex-1 flex flex-col">
|
||||
{{template "views/spa/talk" .}}
|
||||
</div>
|
||||
|
||||
<!-- Manage View -->
|
||||
<div x-show="$store.router.currentRoute === 'manage'" x-cloak class="flex-1 flex flex-col">
|
||||
{{template "views/spa/manage" .}}
|
||||
</div>
|
||||
|
||||
<!-- Browse View (Model Gallery) -->
|
||||
<div x-show="$store.router.currentRoute === 'browse'" x-cloak class="flex-1 flex flex-col">
|
||||
{{template "views/spa/browse" .}}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{{template "views/partials/footer" .}}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Hide elements until Alpine.js initializes */
|
||||
[x-cloak] { display: none !important; }
|
||||
</style>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
221
core/http/views/spa/browse.html
Normal file
221
core/http/views/spa/browse.html
Normal file
@@ -0,0 +1,221 @@
|
||||
<!-- Browse/Gallery View Content for SPA -->
|
||||
<!-- This is a simplified gallery view - for full functionality, use the /browse/ URL -->
|
||||
<div class="container mx-auto px-4 py-8 flex-grow" x-data="browseGallery()">
|
||||
|
||||
<!-- Hero Header -->
|
||||
<div class="hero-section">
|
||||
<div class="hero-content">
|
||||
<h1 class="hero-title">
|
||||
<i class="fas fa-images mr-2"></i>Model Gallery
|
||||
</h1>
|
||||
<p class="hero-subtitle">Browse and install AI models</p>
|
||||
|
||||
<!-- Search and Filter -->
|
||||
<div class="flex flex-wrap justify-center gap-3 mt-6">
|
||||
<div class="relative">
|
||||
<input type="text"
|
||||
x-model="searchQuery"
|
||||
@input="filterModels()"
|
||||
placeholder="Search models..."
|
||||
class="input pl-10 py-2 w-64">
|
||||
<i class="fas fa-search absolute left-3 top-1/2 transform -translate-y-1/2 text-[var(--color-text-secondary)]"></i>
|
||||
</div>
|
||||
|
||||
<select x-model="categoryFilter" @change="filterModels()" class="input py-2">
|
||||
<option value="">All Categories</option>
|
||||
<option value="chat">Chat</option>
|
||||
<option value="image">Image Generation</option>
|
||||
<option value="audio">Audio</option>
|
||||
<option value="embedding">Embeddings</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div x-show="loading" class="flex justify-center py-12">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-[var(--color-primary)]"></div>
|
||||
</div>
|
||||
|
||||
<!-- Models Grid -->
|
||||
<div x-show="!loading" class="mt-8 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
<template x-for="model in filteredModels" :key="model.name">
|
||||
<div class="card overflow-hidden hover:border-[var(--color-primary-border)] transition-colors">
|
||||
<!-- Model Header -->
|
||||
<div class="p-4 border-b border-[var(--color-border)]">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex items-center">
|
||||
<div class="w-10 h-10 bg-[var(--color-primary-light)] rounded-lg flex items-center justify-center mr-3">
|
||||
<template x-if="model.icon">
|
||||
<img :src="model.icon" :alt="model.name" class="w-8 h-8 rounded">
|
||||
</template>
|
||||
<template x-if="!model.icon">
|
||||
<i class="fas fa-brain text-[var(--color-primary)]"></i>
|
||||
</template>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-[var(--color-text-primary)] truncate max-w-[150px]" x-text="model.name"></h3>
|
||||
<p class="text-xs text-[var(--color-text-secondary)]" x-text="model.gallery?.name || 'Unknown'"></p>
|
||||
</div>
|
||||
</div>
|
||||
<template x-if="model.installed">
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-500/20 text-green-300">
|
||||
<i class="fas fa-check mr-1"></i>Installed
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Model Info -->
|
||||
<div class="p-4">
|
||||
<p class="text-xs text-[var(--color-text-secondary)] line-clamp-2 mb-3" x-text="model.description || 'No description available'"></p>
|
||||
|
||||
<!-- Tags -->
|
||||
<div class="flex flex-wrap gap-1 mb-3">
|
||||
<template x-for="tag in (model.tags || []).slice(0, 3)" :key="tag">
|
||||
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] bg-[var(--color-bg-secondary)] text-[var(--color-text-secondary)]" x-text="tag"></span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center gap-2">
|
||||
<template x-if="model.installed">
|
||||
<button @click="$store.router.navigate('chat', { model: model.name })"
|
||||
class="flex-1 btn-primary text-xs py-1.5">
|
||||
<i class="fas fa-comments mr-1"></i>Use
|
||||
</button>
|
||||
</template>
|
||||
<template x-if="!model.installed">
|
||||
<button @click="installModel(model)"
|
||||
:disabled="model.installing"
|
||||
:class="model.installing ? 'opacity-50 cursor-not-allowed' : ''"
|
||||
class="flex-1 btn-primary text-xs py-1.5">
|
||||
<i class="fas fa-download mr-1"></i>
|
||||
<span x-text="model.installing ? 'Installing...' : 'Install'"></span>
|
||||
</button>
|
||||
</template>
|
||||
<a :href="`/browse/${model.gallery?.name || ''}/${model.name}`"
|
||||
class="btn-secondary text-xs py-1.5 px-2" title="View details">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div x-show="!loading && filteredModels.length === 0" class="text-center py-12 text-[var(--color-text-secondary)]">
|
||||
<i class="fas fa-search text-4xl mb-3 opacity-50"></i>
|
||||
<p>No models found</p>
|
||||
<p class="text-sm mt-2">Try adjusting your search or filters</p>
|
||||
</div>
|
||||
|
||||
<!-- Link to Full Gallery -->
|
||||
<div class="mt-8 text-center">
|
||||
<a href="/browse/" class="btn-secondary">
|
||||
<i class="fas fa-external-link-alt mr-2"></i>
|
||||
View Full Model Gallery
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Browse gallery component
|
||||
function browseGallery() {
|
||||
return {
|
||||
loading: true,
|
||||
searchQuery: '',
|
||||
categoryFilter: '',
|
||||
models: [],
|
||||
filteredModels: [],
|
||||
|
||||
init() {
|
||||
this.loadModels();
|
||||
},
|
||||
|
||||
async loadModels() {
|
||||
try {
|
||||
// Fetch available models from gallery
|
||||
const response = await fetch('/models/available');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
this.models = data || [];
|
||||
this.filterModels();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading models:', error);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
filterModels() {
|
||||
let filtered = this.models;
|
||||
|
||||
// Search filter
|
||||
if (this.searchQuery.trim()) {
|
||||
const query = this.searchQuery.toLowerCase();
|
||||
filtered = filtered.filter(m =>
|
||||
(m.name && m.name.toLowerCase().includes(query)) ||
|
||||
(m.description && m.description.toLowerCase().includes(query))
|
||||
);
|
||||
}
|
||||
|
||||
// Category filter
|
||||
if (this.categoryFilter) {
|
||||
filtered = filtered.filter(m => {
|
||||
const tags = m.tags || [];
|
||||
const name = (m.name || '').toLowerCase();
|
||||
switch (this.categoryFilter) {
|
||||
case 'chat':
|
||||
return tags.includes('chat') || tags.includes('llm') || name.includes('chat');
|
||||
case 'image':
|
||||
return tags.includes('image') || tags.includes('diffusion') || name.includes('stable');
|
||||
case 'audio':
|
||||
return tags.includes('audio') || tags.includes('tts') || tags.includes('whisper');
|
||||
case 'embedding':
|
||||
return tags.includes('embedding') || name.includes('embed');
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
this.filteredModels = filtered.slice(0, 20); // Limit to first 20 for performance
|
||||
},
|
||||
|
||||
async installModel(model) {
|
||||
model.installing = true;
|
||||
|
||||
try {
|
||||
const response = await fetch('/models/apply', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
id: model.gallery?.name + '@' + model.name
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
// Model installation started
|
||||
alert(`Installation of ${model.name} started. This may take a while.`);
|
||||
// Refresh after a delay
|
||||
setTimeout(() => this.loadModels(), 5000);
|
||||
} else {
|
||||
alert('Failed to start installation');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error installing model:', error);
|
||||
alert('Error: ' + error.message);
|
||||
} finally {
|
||||
model.installing = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
window.browseGallery = browseGallery;
|
||||
</script>
|
||||
273
core/http/views/spa/chat.html
Normal file
273
core/http/views/spa/chat.html
Normal file
@@ -0,0 +1,273 @@
|
||||
<!-- Chat View Content for SPA -->
|
||||
<!-- This embeds the chat interface inline in the SPA -->
|
||||
<div class="flex flex-col flex-1 overflow-hidden" x-data="chatSPA()">
|
||||
|
||||
<!-- Main Chat Area -->
|
||||
<div class="flex flex-1 overflow-hidden">
|
||||
<!-- Sidebar for chat list -->
|
||||
<aside class="hidden lg:flex w-64 flex-col bg-[var(--color-bg-secondary)] border-r border-[var(--color-bg-primary)]">
|
||||
<div class="p-3 border-b border-[var(--color-bg-primary)]">
|
||||
<button @click="createNewChatSPA()" class="w-full btn-primary text-sm py-2">
|
||||
<i class="fas fa-plus mr-2"></i>New Chat
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto p-2 space-y-1">
|
||||
<template x-for="chat in $store.chat.chats" :key="chat.id">
|
||||
<div
|
||||
@click="switchChatSPA(chat.id)"
|
||||
:class="$store.chat.activeChatId === chat.id ? 'bg-[var(--color-primary-light)] border-[var(--color-primary-border)]' : 'hover:bg-[var(--color-bg-primary)] border-transparent'"
|
||||
class="p-2 rounded-lg cursor-pointer border transition-colors group relative">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="truncate text-sm text-[var(--color-text-primary)]" x-text="chat.name"></span>
|
||||
<div class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
@click.stop="deleteChatSPA(chat.id)"
|
||||
class="p-1 text-red-400 hover:text-red-300 transition-colors"
|
||||
title="Delete chat">
|
||||
<i class="fas fa-trash text-xs"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mt-1 text-xs text-[var(--color-text-secondary)]">
|
||||
<span x-text="chat.model || 'No model'"></span>
|
||||
<span x-show="$store.chat.hasActiveRequest(chat.id)" class="flex items-center gap-1">
|
||||
<span class="animate-pulse w-1.5 h-1.5 rounded-full bg-green-400"></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Chat Content -->
|
||||
<div class="flex-1 flex flex-col min-w-0">
|
||||
<!-- Chat Header -->
|
||||
<header class="flex items-center justify-between px-4 py-2 border-b border-[var(--color-bg-secondary)] bg-[var(--color-bg-primary)]">
|
||||
<div class="flex items-center gap-3">
|
||||
<button @click="showMobileSidebar = !showMobileSidebar" class="lg:hidden p-2 text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]">
|
||||
<i class="fas fa-bars"></i>
|
||||
</button>
|
||||
<div class="flex items-center gap-2">
|
||||
<select
|
||||
x-model="currentModel"
|
||||
@change="updateChatModel()"
|
||||
class="input text-sm py-1.5 px-3">
|
||||
<option value="" disabled>Select model...</option>
|
||||
{{ range .ModelsConfig }}
|
||||
{{ $cfg := . }}
|
||||
{{ range .KnownUsecaseStrings }}
|
||||
{{ if eq . "FLAG_CHAT" }}
|
||||
<option value="{{$cfg.Name}}">{{$cfg.Name}}</option>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
{{ range .ModelsWithoutConfig }}
|
||||
<option value="{{.}}">{{.}}</option>
|
||||
{{ end }}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span id="tokens-per-second" class="text-xs text-[var(--color-text-secondary)]">-</span>
|
||||
<span id="max-tokens-per-second-badge" class="hidden text-xs bg-green-500/20 text-green-300 px-2 py-0.5 rounded"></span>
|
||||
<div id="header-loading-indicator" class="hidden">
|
||||
<svg class="animate-spin h-4 w-4 text-[var(--color-primary)]" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<button @click="clearChat()" class="p-2 text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] transition-colors" title="Clear chat">
|
||||
<i class="fas fa-eraser"></i>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Messages Container -->
|
||||
<div id="chat" class="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
<template x-for="(message, index) in $store.chat.activeHistory" :key="index">
|
||||
<div :class="message.role === 'user' ? 'justify-end' : 'justify-start'" class="flex">
|
||||
<div :class="message.role === 'user' ? 'bg-[var(--color-primary)] text-white max-w-[80%]' : 'bg-[var(--color-bg-secondary)] text-[var(--color-text-primary)] max-w-[90%]'"
|
||||
class="rounded-lg px-4 py-2">
|
||||
<!-- Thinking/Reasoning messages -->
|
||||
<template x-if="message.role === 'thinking' || message.role === 'reasoning'">
|
||||
<div class="text-xs">
|
||||
<button @click="message.expanded = !message.expanded" class="flex items-center gap-2 text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]">
|
||||
<i :class="message.expanded ? 'fa-chevron-down' : 'fa-chevron-right'" class="fas text-xs"></i>
|
||||
<span>Thinking...</span>
|
||||
</button>
|
||||
<div x-show="message.expanded" x-html="message.html" class="mt-2 prose prose-sm prose-invert max-w-none"></div>
|
||||
</div>
|
||||
</template>
|
||||
<!-- Regular messages -->
|
||||
<template x-if="message.role !== 'thinking' && message.role !== 'reasoning'">
|
||||
<div x-html="message.html" class="prose prose-sm prose-invert max-w-none"></div>
|
||||
</template>
|
||||
<!-- Images -->
|
||||
<template x-if="message.image && message.image.length > 0">
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
<template x-for="img in message.image" :key="img">
|
||||
<img :src="img" class="max-w-[200px] rounded-lg" alt="Attached image">
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div x-show="!$store.chat.activeHistory || $store.chat.activeHistory.length === 0" class="flex flex-col items-center justify-center h-full text-center text-[var(--color-text-secondary)]">
|
||||
<i class="fas fa-comments text-4xl mb-4 opacity-50"></i>
|
||||
<p>Start a conversation</p>
|
||||
<p class="text-sm mt-2">Select a model and send a message to begin</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Input Area -->
|
||||
<div class="border-t border-[var(--color-bg-secondary)] p-4 bg-[var(--color-bg-primary)]">
|
||||
<form id="prompt" @submit.prevent="submitPrompt($event)" class="relative">
|
||||
<div class="flex items-end gap-2">
|
||||
<div class="flex-1 relative">
|
||||
<textarea
|
||||
id="input"
|
||||
name="input"
|
||||
placeholder="Type a message..."
|
||||
class="input w-full resize-none py-3 pr-12"
|
||||
rows="1"
|
||||
@keydown.enter.prevent="if (!$event.shiftKey) submitPrompt($event)"
|
||||
@input="autoResize($event.target)"
|
||||
></textarea>
|
||||
<div class="absolute right-2 bottom-2 flex items-center gap-1">
|
||||
<button type="button" @click="document.getElementById('input_image').click()" class="p-1.5 text-[var(--color-text-secondary)] hover:text-[var(--color-primary)] transition-colors" title="Attach image">
|
||||
<i class="fas fa-image"></i>
|
||||
</button>
|
||||
<button type="button" @click="document.getElementById('input_audio').click()" class="p-1.5 text-[var(--color-text-secondary)] hover:text-[var(--color-primary)] transition-colors" title="Attach audio">
|
||||
<i class="fas fa-microphone"></i>
|
||||
</button>
|
||||
<button type="button" @click="document.getElementById('input_file').click()" class="p-1.5 text-[var(--color-text-secondary)] hover:text-[var(--color-primary)] transition-colors" title="Attach file">
|
||||
<i class="fas fa-paperclip"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" id="send-button" class="btn-primary p-3">
|
||||
<i class="fas fa-paper-plane"></i>
|
||||
</button>
|
||||
<button type="button" id="stop-button" @click="stopRequest()" class="btn-primary p-3 bg-red-500 hover:bg-red-600" style="display: none;">
|
||||
<i class="fas fa-stop"></i>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Hidden file inputs -->
|
||||
<input type="file" id="input_image" multiple accept="image/*" class="hidden" @change="readInputImage">
|
||||
<input type="file" id="input_audio" multiple accept="audio/*" class="hidden" @change="readInputAudio">
|
||||
<input type="file" id="input_file" multiple accept=".txt,.md,.pdf" class="hidden" @change="readInputFile">
|
||||
</form>
|
||||
|
||||
<!-- System prompt form (hidden) -->
|
||||
<form id="system_prompt" @submit.prevent="submitSystemPrompt($event)" style="display: none;">
|
||||
<input type="text" id="systemPrompt" name="systemPrompt">
|
||||
</form>
|
||||
<input type="hidden" id="chat-model" value="{{.Model}}">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Sidebar Overlay -->
|
||||
<div x-show="showMobileSidebar" @click="showMobileSidebar = false" class="lg:hidden fixed inset-0 bg-black/50 z-40"></div>
|
||||
<aside x-show="showMobileSidebar" class="lg:hidden fixed left-0 top-0 bottom-0 w-64 bg-[var(--color-bg-secondary)] z-50 transform transition-transform"
|
||||
:class="showMobileSidebar ? 'translate-x-0' : '-translate-x-full'">
|
||||
<div class="p-3 border-b border-[var(--color-bg-primary)] flex items-center justify-between">
|
||||
<span class="font-medium text-[var(--color-text-primary)]">Chats</span>
|
||||
<button @click="showMobileSidebar = false" class="p-2 text-[var(--color-text-secondary)]">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-3">
|
||||
<button @click="createNewChatSPA(); showMobileSidebar = false" class="w-full btn-primary text-sm py-2">
|
||||
<i class="fas fa-plus mr-2"></i>New Chat
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto p-2 space-y-1">
|
||||
<template x-for="chat in $store.chat.chats" :key="chat.id">
|
||||
<div
|
||||
@click="switchChatSPA(chat.id); showMobileSidebar = false"
|
||||
:class="$store.chat.activeChatId === chat.id ? 'bg-[var(--color-primary-light)] border-[var(--color-primary-border)]' : 'hover:bg-[var(--color-bg-primary)] border-transparent'"
|
||||
class="p-2 rounded-lg cursor-pointer border transition-colors">
|
||||
<span class="truncate text-sm text-[var(--color-text-primary)]" x-text="chat.name"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Chat SPA component
|
||||
function chatSPA() {
|
||||
return {
|
||||
currentModel: '{{.Model}}',
|
||||
showMobileSidebar: false,
|
||||
|
||||
init() {
|
||||
// Initialize chat store if not already done
|
||||
this.$nextTick(() => {
|
||||
if (window.Alpine && Alpine.store('chat') && Alpine.store('chat').chats.length === 0) {
|
||||
Alpine.store('chat').createChat(this.currentModel, '', false);
|
||||
}
|
||||
// Update model from route params if available
|
||||
const routeParams = Alpine.store('router')?.routeParams;
|
||||
if (routeParams?.model) {
|
||||
this.currentModel = routeParams.model;
|
||||
const activeChat = Alpine.store('chat').activeChat();
|
||||
if (activeChat) {
|
||||
activeChat.model = this.currentModel;
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
updateChatModel() {
|
||||
const activeChat = Alpine.store('chat').activeChat();
|
||||
if (activeChat) {
|
||||
activeChat.model = this.currentModel;
|
||||
if (typeof window.autoSaveChats === 'function') {
|
||||
window.autoSaveChats();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
clearChat() {
|
||||
if (confirm('Clear all messages in this chat?')) {
|
||||
Alpine.store('chat').clear();
|
||||
}
|
||||
},
|
||||
|
||||
autoResize(textarea) {
|
||||
textarea.style.height = 'auto';
|
||||
textarea.style.height = Math.min(textarea.scrollHeight, 200) + 'px';
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Helper functions for chat in SPA context
|
||||
function createNewChatSPA() {
|
||||
const currentModel = document.getElementById('chat-model')?.value || '';
|
||||
if (window.createNewChat) {
|
||||
window.createNewChat(currentModel, '', false);
|
||||
}
|
||||
}
|
||||
|
||||
function switchChatSPA(chatId) {
|
||||
if (window.switchChat) {
|
||||
window.switchChat(chatId);
|
||||
}
|
||||
}
|
||||
|
||||
function deleteChatSPA(chatId) {
|
||||
if (confirm('Delete this chat?')) {
|
||||
if (window.deleteChat) {
|
||||
window.deleteChat(chatId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Make component available globally
|
||||
window.chatSPA = chatSPA;
|
||||
</script>
|
||||
329
core/http/views/spa/home.html
Normal file
329
core/http/views/spa/home.html
Normal file
@@ -0,0 +1,329 @@
|
||||
<!-- Home View Content for SPA -->
|
||||
<!-- Main Content - ChatGPT-style minimal interface -->
|
||||
<div class="flex-1 flex flex-col items-center justify-center px-4 py-12">
|
||||
<div class="w-full max-w-3xl mx-auto">
|
||||
{{ if eq (len .ModelsConfig) 0 }}
|
||||
<!-- No Models - Wizard Guide -->
|
||||
<div class="hero-section">
|
||||
<div class="hero-content">
|
||||
<h2 class="hero-title">
|
||||
No Models Installed
|
||||
</h2>
|
||||
<p class="hero-subtitle">
|
||||
Get started with LocalAI by installing your first model. Choose from our gallery, import your own, or use the API to download models.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Features Preview -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||
<div class="card card-animate">
|
||||
<div class="w-10 h-10 bg-[var(--color-primary-light)] rounded-lg flex items-center justify-center mx-auto mb-3">
|
||||
<i class="fas fa-images text-[var(--color-primary)] text-xl"></i>
|
||||
</div>
|
||||
<h3 class="text-sm font-semibold text-[var(--color-text-primary)] mb-2">Model Gallery</h3>
|
||||
<p class="text-xs text-[var(--color-text-secondary)]">Browse and install pre-configured models</p>
|
||||
</div>
|
||||
<div class="card card-animate">
|
||||
<div class="w-10 h-10 bg-[var(--color-accent-light)] rounded-lg flex items-center justify-center mx-auto mb-3">
|
||||
<i class="fas fa-upload text-[var(--color-accent)] text-xl"></i>
|
||||
</div>
|
||||
<h3 class="text-sm font-semibold text-[var(--color-text-primary)] mb-2">Import Models</h3>
|
||||
<p class="text-xs text-[var(--color-text-secondary)]">Upload your own model files</p>
|
||||
</div>
|
||||
<div class="card card-animate">
|
||||
<div class="w-10 h-10 bg-[var(--color-success-light)] rounded-lg flex items-center justify-center mx-auto mb-3">
|
||||
<i class="fas fa-code text-[var(--color-success)] text-xl"></i>
|
||||
</div>
|
||||
<h3 class="text-sm font-semibold text-[var(--color-text-primary)] mb-2">API Download</h3>
|
||||
<p class="text-xs text-[var(--color-text-secondary)]">Use the API to download models programmatically</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Setup Instructions -->
|
||||
<div class="card mb-6 text-left">
|
||||
<h3 class="text-lg font-bold text-[var(--color-text-primary)] mb-4 flex items-center">
|
||||
<i class="fas fa-rocket text-[var(--color-accent)] mr-2"></i>
|
||||
How to Get Started
|
||||
</h3>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-start">
|
||||
<div class="flex-shrink-0 w-8 h-8 rounded-full bg-[var(--color-accent-light)] flex items-center justify-center mr-3 mt-0.5">
|
||||
<span class="text-[var(--color-accent)] font-bold text-sm">1</span>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="text-[var(--color-text-primary)] font-medium mb-2">Browse the Model Gallery</p>
|
||||
<p class="text-[var(--color-text-secondary)] text-sm">Explore our curated collection of pre-configured models. Find models for chat, image generation, audio processing, and more.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start">
|
||||
<div class="flex-shrink-0 w-8 h-8 rounded-full bg-[var(--color-accent-light)] flex items-center justify-center mr-3 mt-0.5">
|
||||
<span class="text-[var(--color-accent)] font-bold text-sm">2</span>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="text-[var(--color-text-primary)] font-medium mb-2">Install a Model</p>
|
||||
<p class="text-[var(--color-text-secondary)] text-sm">Click on a model from the gallery to install it, or use the import feature to upload your own model files.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start">
|
||||
<div class="flex-shrink-0 w-8 h-8 rounded-full bg-[var(--color-accent-light)] flex items-center justify-center mr-3 mt-0.5">
|
||||
<span class="text-[var(--color-accent)] font-bold text-sm">3</span>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="text-[var(--color-text-primary)] font-medium mb-2">Start Chatting</p>
|
||||
<p class="text-[var(--color-text-secondary)] text-sm">Once installed, return to this page to start chatting with your model or use the API to interact programmatically.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap justify-center gap-4 mb-8">
|
||||
<a href="#" @click.prevent="$store.router.navigate('browse')" class="btn-primary">
|
||||
<i class="fas fa-images mr-2"></i>
|
||||
Browse Model Gallery
|
||||
</a>
|
||||
<a href="/import-model" class="btn-primary">
|
||||
<i class="fas fa-upload mr-2"></i>
|
||||
Import Model
|
||||
</a>
|
||||
<a href="https://localai.io/basics/getting_started/" target="_blank" class="btn-secondary">
|
||||
<i class="fas fa-graduation-cap mr-2"></i>
|
||||
Getting Started
|
||||
<i class="fas fa-external-link-alt ml-2 text-sm"></i>
|
||||
</a>
|
||||
</div>
|
||||
{{ else }}
|
||||
<!-- Welcome Message / Hero Section -->
|
||||
<div class="hero-section">
|
||||
<div class="hero-content">
|
||||
<div class="mb-4 flex justify-center">
|
||||
<img src="static/logo.png" alt="LocalAI Logo" class="h-16 md:h-20">
|
||||
</div>
|
||||
<h1 class="hero-title">How can I help you today?</h1>
|
||||
<p class="hero-subtitle">Ask me anything, and I'll do my best to assist you.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chat Input Form -->
|
||||
<div class="mb-8" x-data="homeInputForm()">
|
||||
<!-- Model Selector with MCP Toggle -->
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-[var(--color-text-secondary)] mb-2">Select Model</label>
|
||||
<div class="flex items-center gap-3">
|
||||
<select
|
||||
x-model="selectedModel"
|
||||
@change="$nextTick(() => checkMCPAvailability())"
|
||||
class="input flex-1"
|
||||
required
|
||||
>
|
||||
<option value="" disabled class="text-[var(--color-text-secondary)]">Select a model to chat with...</option>
|
||||
{{ range .ModelsConfig }}
|
||||
{{ $cfg := . }}
|
||||
{{ $hasMCP := or (ne $cfg.MCP.Servers "") (ne $cfg.MCP.Stdio "") }}
|
||||
{{ range .KnownUsecaseStrings }}
|
||||
{{ if eq . "FLAG_CHAT" }}
|
||||
<option value="{{$cfg.Name}}" data-has-mcp="{{if $hasMCP}}true{{else}}false{{end}}" class="bg-[var(--color-bg-secondary)] text-[var(--color-text-primary)]">{{$cfg.Name}}</option>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
</select>
|
||||
|
||||
<!-- Compact MCP Toggle - Show only if MCP is available for selected model -->
|
||||
<div
|
||||
x-show="mcpAvailable"
|
||||
class="flex items-center gap-2 px-3 py-2 text-xs rounded text-[var(--color-text-primary)] bg-[var(--color-bg-secondary)] border border-[var(--color-primary-border)] whitespace-nowrap">
|
||||
<i class="fa-solid fa-plug text-[var(--color-primary)] text-sm"></i>
|
||||
<span class="text-[var(--color-text-secondary)]">MCP</span>
|
||||
<label class="relative inline-flex items-center cursor-pointer ml-1">
|
||||
<input type="checkbox" id="spa_home_mcp_toggle" class="sr-only peer" x-model="mcpMode">
|
||||
<div class="w-9 h-5 bg-[var(--color-bg-primary)] peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-[var(--color-primary-border)] rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-[var(--color-bg-secondary)] after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-[var(--color-primary)]"></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MCP Mode Notification - Compact tooltip style -->
|
||||
<div
|
||||
x-show="mcpMode && mcpAvailable"
|
||||
class="mt-2 p-2 bg-[var(--color-primary-light)] border border-[var(--color-primary-border)] rounded text-[var(--color-text-secondary)] text-xs">
|
||||
<div class="flex items-start space-x-2">
|
||||
<i class="fa-solid fa-info-circle text-[var(--color-primary)] mt-0.5 text-xs"></i>
|
||||
<p class="text-[var(--color-text-secondary)]">Non-streaming mode active. Responses may take longer to process.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Input Bar -->
|
||||
<form @submit.prevent="startChatSPA($event)" class="relative w-full">
|
||||
<!-- Attachment Tags - Show above input when files are attached -->
|
||||
<div x-show="attachedFiles.length > 0" class="mb-3 flex flex-wrap gap-2 items-center">
|
||||
<template x-for="(file, index) in attachedFiles" :key="index">
|
||||
<div class="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm bg-[var(--color-primary-light)] border border-[var(--color-primary-border)] text-[var(--color-text-primary)]">
|
||||
<i :class="file.type === 'image' ? 'fa-solid fa-image' : file.type === 'audio' ? 'fa-solid fa-microphone' : 'fa-solid fa-file'" class="text-[var(--color-primary)]"></i>
|
||||
<span x-text="file.name" class="max-w-[200px] truncate"></span>
|
||||
<button
|
||||
type="button"
|
||||
@click="attachedFiles.splice(index, 1); removeAttachedFile(file.type, file.name)"
|
||||
class="ml-1 text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] transition-colors"
|
||||
title="Remove attachment"
|
||||
>
|
||||
<i class="fa-solid fa-times text-xs"></i>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="relative w-full">
|
||||
<textarea
|
||||
x-model="inputValue"
|
||||
:placeholder="currentPlaceholder"
|
||||
class="input p-3 pr-16 w-full resize-none border-0"
|
||||
required
|
||||
@keydown.shift="shiftPressed = true"
|
||||
@keyup.shift="shiftPressed = false"
|
||||
@keydown.enter.prevent="if (!shiftPressed && selectedModel && (inputValue.trim() || currentPlaceholder.trim())) { startChatSPA($event); }"
|
||||
@focus="handleFocus()"
|
||||
@blur="handleBlur()"
|
||||
@input="handleInput()"
|
||||
rows="2"
|
||||
></textarea>
|
||||
|
||||
<!-- Attachment Buttons -->
|
||||
<button
|
||||
type="button"
|
||||
@click="document.getElementById('spa_home_input_image').click()"
|
||||
class="fa-solid fa-image text-[var(--color-text-secondary)] absolute right-12 top-3 text-base p-1.5 hover:text-[var(--color-primary)] transition-colors duration-200"
|
||||
title="Attach images"
|
||||
></button>
|
||||
<button
|
||||
type="button"
|
||||
@click="document.getElementById('spa_home_input_audio').click()"
|
||||
class="fa-solid fa-microphone text-[var(--color-text-secondary)] absolute right-20 top-3 text-base p-1.5 hover:text-[var(--color-primary)] transition-colors duration-200"
|
||||
title="Attach an audio file"
|
||||
></button>
|
||||
<button
|
||||
type="button"
|
||||
@click="document.getElementById('spa_home_input_file').click()"
|
||||
class="fa-solid fa-file text-[var(--color-text-secondary)] absolute right-28 top-3 text-base p-1.5 hover:text-[var(--color-primary)] transition-colors duration-200"
|
||||
title="Upload text, markdown or PDF file"
|
||||
></button>
|
||||
|
||||
<!-- Send Button -->
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="!selectedModel || (!inputValue.trim() && !currentPlaceholder.trim())"
|
||||
:class="!selectedModel || (!inputValue.trim() && !currentPlaceholder.trim()) ? 'opacity-50 cursor-not-allowed' : ''"
|
||||
class="text-lg p-2 text-[var(--color-text-secondary)] hover:text-[var(--color-primary)] transition-colors duration-200 absolute right-3 top-3"
|
||||
title="Send message (Enter)"
|
||||
>
|
||||
<i class="fa-solid fa-paper-plane"></i>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Hidden File Inputs -->
|
||||
<input
|
||||
id="spa_home_input_image"
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/*"
|
||||
style="display: none;"
|
||||
@change="imageFiles = Array.from($event.target.files); handleFileSelection($event.target.files, 'image')"
|
||||
/>
|
||||
<input
|
||||
id="spa_home_input_audio"
|
||||
type="file"
|
||||
multiple
|
||||
accept="audio/*"
|
||||
style="display: none;"
|
||||
@change="audioFiles = Array.from($event.target.files); handleFileSelection($event.target.files, 'audio')"
|
||||
/>
|
||||
<input
|
||||
id="spa_home_input_file"
|
||||
type="file"
|
||||
multiple
|
||||
accept=".txt,.md,.pdf"
|
||||
style="display: none;"
|
||||
@change="textFiles = Array.from($event.target.files); handleFileSelection($event.target.files, 'file')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Quick Links -->
|
||||
<div class="flex flex-wrap justify-center gap-3 mb-8">
|
||||
<a href="#" @click.prevent="$store.router.navigate('manage')" class="btn-tertiary">
|
||||
<i class="fas fa-cog mr-2"></i>
|
||||
Installed Models and Backends
|
||||
</a>
|
||||
<a href="/import-model" class="btn-tertiary">
|
||||
<i class="fas fa-upload mr-2"></i>
|
||||
Import Model
|
||||
</a>
|
||||
<a href="#" @click.prevent="$store.router.navigate('browse')" class="btn-tertiary">
|
||||
<i class="fas fa-images mr-2"></i>
|
||||
Browse Gallery
|
||||
</a>
|
||||
<a href="https://localai.io" target="_blank" class="btn-tertiary">
|
||||
<i class="fas fa-book mr-2"></i>
|
||||
Documentation
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Memory Status Indicator (GPU or RAM) -->
|
||||
<div class="mb-4" x-data="resourceMonitor()" x-init="startPolling()">
|
||||
<template x-if="resourceData && resourceData.available">
|
||||
<div class="flex items-center justify-center gap-3 text-xs text-[var(--color-text-secondary)]">
|
||||
<div class="flex items-center gap-2 px-3 py-1.5 rounded-full bg-[var(--color-bg-secondary)] border border-[var(--color-primary-border)]/20">
|
||||
<i :class="resourceData.type === 'gpu' ? 'fas fa-microchip' : 'fas fa-memory'"
|
||||
:class="resourceData.aggregate.usage_percent > 90 ? 'text-red-400' : resourceData.aggregate.usage_percent > 70 ? 'text-yellow-400' : 'text-green-400'"></i>
|
||||
<span class="text-[var(--color-text-secondary)]" x-text="resourceData.type === 'gpu' ? 'GPU' : 'RAM'"></span>
|
||||
<span class="font-mono"
|
||||
:class="resourceData.aggregate.usage_percent > 90 ? 'text-red-400' : resourceData.aggregate.usage_percent > 70 ? 'text-yellow-400' : 'text-green-400'"
|
||||
x-text="`${resourceData.aggregate.usage_percent.toFixed(0)}%`"></span>
|
||||
<div class="w-16 bg-[var(--color-bg-primary)] rounded-full h-1.5 overflow-hidden">
|
||||
<div class="h-full rounded-full transition-all duration-300"
|
||||
:class="resourceData.aggregate.usage_percent > 90 ? 'bg-red-500' : resourceData.aggregate.usage_percent > 70 ? 'bg-yellow-500' : 'bg-[var(--color-success)]'"
|
||||
:style="`width: ${resourceData.aggregate.usage_percent}%`"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Model Status Summary - Subtle -->
|
||||
{{ $loadedModels := .LoadedModels }}
|
||||
<div class="mb-8 flex items-center justify-center gap-2 text-xs text-[var(--color-text-secondary)]"
|
||||
x-data="{ stoppingAll: false, stopAllModels() { window.stopAllModels(this); }, stopModel(name) { window.stopModel(name); }, getLoadedCount() { return document.querySelectorAll('[data-loaded-model]').length; } }"
|
||||
x-show="getLoadedCount() > 0"
|
||||
style="display: none;">
|
||||
<span class="flex items-center gap-1.5">
|
||||
<i class="fas fa-circle text-green-500 text-[10px]"></i>
|
||||
<span x-text="`${getLoadedCount()} model(s) loaded`"></span>
|
||||
</span>
|
||||
<span class="text-[var(--color-primary)] opacity-40">•</span>
|
||||
{{ range .ModelsConfig }}
|
||||
{{ if index $loadedModels .Name }}
|
||||
<span class="inline-flex items-center gap-1 text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] transition-colors" data-loaded-model>
|
||||
<span class="truncate max-w-[100px]">{{.Name}}</span>
|
||||
<button
|
||||
@click="stopModel('{{.Name}}')"
|
||||
class="text-red-400/60 hover:text-red-400 transition-colors ml-0.5"
|
||||
title="Stop {{.Name}}"
|
||||
>
|
||||
<i class="fas fa-times text-[10px]"></i>
|
||||
</button>
|
||||
</span>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
<span class="text-[var(--color-primary)] opacity-40">•</span>
|
||||
<button
|
||||
@click="stopAllModels()"
|
||||
:disabled="stoppingAll"
|
||||
:class="stoppingAll ? 'opacity-50 cursor-not-allowed' : ''"
|
||||
class="text-red-400/60 hover:text-red-400 transition-colors text-xs"
|
||||
title="Stop all loaded models"
|
||||
>
|
||||
<span x-text="stoppingAll ? 'Stopping...' : 'Stop all'"></span>
|
||||
</button>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
322
core/http/views/spa/manage.html
Normal file
322
core/http/views/spa/manage.html
Normal file
@@ -0,0 +1,322 @@
|
||||
<!-- Manage View Content for SPA -->
|
||||
<div class="container mx-auto px-4 py-8 flex-grow" x-data="manageDashboard()">
|
||||
|
||||
<!-- Notifications -->
|
||||
<div class="fixed top-20 right-4 z-50 space-y-2" style="max-width: 400px;">
|
||||
<template x-for="notification in notifications" :key="notification.id">
|
||||
<div x-show="true"
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
:class="notification.type === 'error' ? 'bg-red-500' : 'bg-[var(--color-success)]'"
|
||||
class="rounded-lg p-4 text-white flex items-start space-x-3">
|
||||
<div class="flex-shrink-0">
|
||||
<i :class="notification.type === 'error' ? 'fas fa-exclamation-circle' : 'fas fa-check-circle'" class="text-xl"></i>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium break-words" x-text="notification.message"></p>
|
||||
</div>
|
||||
<button @click="dismissNotification(notification.id)" class="flex-shrink-0 text-white hover:opacity-80 transition-opacity">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Hero Header -->
|
||||
<div class="hero-section">
|
||||
<div class="hero-content">
|
||||
<h1 class="hero-title">
|
||||
Model & Backend Management
|
||||
</h1>
|
||||
<p class="hero-subtitle">Manage your installed models and backends</p>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="flex flex-wrap justify-center gap-3">
|
||||
<a href="#" @click.prevent="$store.router.navigate('browse')" class="btn-primary text-sm py-1.5 px-3">
|
||||
<i class="fas fa-images mr-1.5 text-[10px]"></i>
|
||||
<span>Model Gallery</span>
|
||||
</a>
|
||||
|
||||
<a href="/import-model" class="btn-primary text-sm py-1.5 px-3">
|
||||
<i class="fas fa-plus mr-1.5 text-[10px]"></i>
|
||||
<span>Import Model</span>
|
||||
</a>
|
||||
|
||||
<button @click="reloadModels()" class="btn-primary text-sm py-1.5 px-3">
|
||||
<i class="fas fa-sync-alt mr-1.5 text-[10px]"></i>
|
||||
<span>Update Models</span>
|
||||
</button>
|
||||
|
||||
<a href="/browse/backends" class="btn-secondary text-sm py-1.5 px-3">
|
||||
<i class="fas fa-cogs mr-1.5 text-[10px]"></i>
|
||||
<span>Backend Gallery</span>
|
||||
</a>
|
||||
|
||||
{{ if not .DisableRuntimeSettings }}
|
||||
<a href="/settings" class="btn-secondary text-sm py-1.5 px-3">
|
||||
<i class="fas fa-cog mr-1.5 text-[10px]"></i>
|
||||
<span>Settings</span>
|
||||
</a>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Memory Info Section -->
|
||||
<div class="mt-8" x-data="resourceMonitor()" x-init="startPolling()">
|
||||
<template x-if="resourceData && resourceData.available">
|
||||
<div class="bg-[var(--color-bg-secondary)] border border-[var(--color-primary-border)]/20 rounded-lg p-4 mb-6">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h2 class="h3 flex items-center">
|
||||
<i :class="resourceData.type === 'gpu' ? 'fas fa-microchip' : 'fas fa-memory'" class="mr-2 text-[var(--color-primary)] text-sm"></i>
|
||||
<span x-text="resourceData.type === 'gpu' ? 'GPU Status' : 'Memory Status'"></span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<!-- Aggregate Stats -->
|
||||
<div class="bg-[var(--color-bg-primary)] rounded p-3">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-xs font-medium text-[var(--color-text-primary)]" x-text="resourceData.type === 'gpu' ? 'Total GPU Memory' : 'System RAM'"></span>
|
||||
<span class="text-xs font-mono"
|
||||
:class="resourceData.aggregate.usage_percent > 90 ? 'text-red-400' : resourceData.aggregate.usage_percent > 70 ? 'text-yellow-400' : 'text-green-400'"
|
||||
x-text="`${resourceData.aggregate.usage_percent.toFixed(1)}%`"></span>
|
||||
</div>
|
||||
<div class="w-full bg-[var(--color-bg-secondary)] rounded-full h-2 overflow-hidden">
|
||||
<div class="h-full rounded-full transition-all duration-300"
|
||||
:class="resourceData.aggregate.usage_percent > 90 ? 'bg-red-500' : resourceData.aggregate.usage_percent > 70 ? 'bg-yellow-500' : 'bg-[var(--color-success)]'"
|
||||
:style="`width: ${resourceData.aggregate.usage_percent}%`"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Installed Models Section -->
|
||||
<div class="mt-8">
|
||||
<h2 class="text-xl font-semibold text-[var(--color-text-primary)] mb-4 flex items-center">
|
||||
<i class="fas fa-brain text-[var(--color-primary)] mr-2"></i>
|
||||
Installed Models
|
||||
</h2>
|
||||
|
||||
<div class="card overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr class="bg-[var(--color-bg-secondary)] border-b border-[var(--color-border)]">
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold text-[var(--color-text-secondary)] uppercase">Model</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold text-[var(--color-text-secondary)] uppercase">Status</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold text-[var(--color-text-secondary)] uppercase">Backend</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-semibold text-[var(--color-text-secondary)] uppercase">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-[var(--color-border)]">
|
||||
{{ $loadedModels := .LoadedModels }}
|
||||
{{ range .ModelsConfig }}
|
||||
<tr class="hover:bg-[var(--color-bg-secondary)]/50 transition-colors">
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center">
|
||||
<span class="text-sm font-medium text-[var(--color-text-primary)]">{{.Name}}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
{{ if index $loadedModels .Name }}
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-500/20 text-green-300">
|
||||
<i class="fas fa-circle text-[6px] mr-1.5"></i>Loaded
|
||||
</span>
|
||||
{{ else }}
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-[var(--color-bg-secondary)] text-[var(--color-text-secondary)]">
|
||||
<i class="fas fa-circle text-[6px] mr-1.5"></i>Idle
|
||||
</span>
|
||||
{{ end }}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="text-xs text-[var(--color-text-secondary)]">{{.Backend}}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
{{ $hasChat := false }}
|
||||
{{ range .KnownUsecaseStrings }}
|
||||
{{ if eq . "FLAG_CHAT" }}{{ $hasChat = true }}{{ end }}
|
||||
{{ end }}
|
||||
{{ if $hasChat }}
|
||||
<button @click="$store.router.navigate('chat', { model: '{{.Name}}' })"
|
||||
class="px-2 py-1 text-xs rounded bg-[var(--color-primary)] text-white hover:opacity-80 transition-opacity">
|
||||
<i class="fas fa-comments mr-1"></i>Chat
|
||||
</button>
|
||||
{{ end }}
|
||||
{{ if index $loadedModels .Name }}
|
||||
<button onclick="stopModelManage('{{.Name}}')"
|
||||
class="px-2 py-1 text-xs rounded bg-red-500/20 text-red-300 hover:bg-red-500/30 transition-colors">
|
||||
<i class="fas fa-stop mr-1"></i>Stop
|
||||
</button>
|
||||
{{ end }}
|
||||
<a href="/model-editor/{{.Name}}" class="px-2 py-1 text-xs rounded bg-[var(--color-bg-secondary)] text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] transition-colors">
|
||||
<i class="fas fa-edit"></i>
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
{{ range .Models }}
|
||||
<tr class="hover:bg-[var(--color-bg-secondary)]/50 transition-colors">
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center">
|
||||
<span class="text-sm font-medium text-[var(--color-text-primary)]">{{.}}</span>
|
||||
<span class="ml-2 text-xs text-[var(--color-text-secondary)]">(no config)</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-[var(--color-bg-secondary)] text-[var(--color-text-secondary)]">
|
||||
<i class="fas fa-circle text-[6px] mr-1.5"></i>Idle
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="text-xs text-[var(--color-text-secondary)]">-</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<button @click="$store.router.navigate('chat', { model: '{{.}}' })"
|
||||
class="px-2 py-1 text-xs rounded bg-[var(--color-primary)] text-white hover:opacity-80 transition-opacity">
|
||||
<i class="fas fa-comments mr-1"></i>Chat
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{{ if and (eq (len .ModelsConfig) 0) (eq (len .Models) 0) }}
|
||||
<div class="text-center py-8 text-[var(--color-text-secondary)]">
|
||||
<i class="fas fa-box-open text-4xl mb-3 opacity-50"></i>
|
||||
<p>No models installed yet</p>
|
||||
<p class="text-sm mt-2">
|
||||
<a href="#" @click.prevent="$store.router.navigate('browse')" class="text-[var(--color-primary)] hover:underline">Browse the gallery</a> to get started
|
||||
</p>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Installed Backends Section -->
|
||||
<div class="mt-8">
|
||||
<h2 class="text-xl font-semibold text-[var(--color-text-primary)] mb-4 flex items-center">
|
||||
<i class="fas fa-server text-[var(--color-accent)] mr-2"></i>
|
||||
Installed Backends
|
||||
</h2>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{{ range .InstalledBackends }}
|
||||
<div class="card p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<div class="w-10 h-10 bg-[var(--color-accent-light)] rounded-lg flex items-center justify-center mr-3">
|
||||
<i class="fas fa-cogs text-[var(--color-accent)]"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-[var(--color-text-primary)]">{{.Name}}</h3>
|
||||
<div class="flex flex-wrap gap-1 mt-1">
|
||||
{{ if .IsSystem }}
|
||||
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-blue-500/10 text-blue-300">
|
||||
<i class="fas fa-shield-alt text-[8px] mr-1"></i>System
|
||||
</span>
|
||||
{{ else }}
|
||||
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-[var(--color-success)]/10 text-green-300">
|
||||
<i class="fas fa-download text-[8px] mr-1"></i>User
|
||||
</span>
|
||||
{{ end }}
|
||||
{{ if .IsMeta }}
|
||||
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-[var(--color-accent-light)] text-[var(--color-accent)]">
|
||||
<i class="fas fa-layer-group text-[8px] mr-1"></i>Meta
|
||||
</span>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-500/20 text-green-300">
|
||||
Installed
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{{ else }}
|
||||
<div class="col-span-full text-center py-8 text-[var(--color-text-secondary)]">
|
||||
<i class="fas fa-plug text-4xl mb-3 opacity-50"></i>
|
||||
<p>No backends installed yet</p>
|
||||
<p class="text-sm mt-2">
|
||||
<a href="/browse/backends" class="text-[var(--color-primary)] hover:underline">Browse the backend gallery</a>
|
||||
</p>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Manage dashboard component
|
||||
function manageDashboard() {
|
||||
return {
|
||||
notifications: [],
|
||||
|
||||
init() {
|
||||
// Initialize
|
||||
},
|
||||
|
||||
addNotification(message, type = 'success') {
|
||||
const id = Date.now();
|
||||
this.notifications.push({ id, message, type });
|
||||
setTimeout(() => this.dismissNotification(id), 5000);
|
||||
},
|
||||
|
||||
dismissNotification(id) {
|
||||
this.notifications = this.notifications.filter(n => n.id !== id);
|
||||
},
|
||||
|
||||
reloadModels() {
|
||||
fetch('/models/reload', { method: 'POST' })
|
||||
.then(response => {
|
||||
if (response.ok) {
|
||||
this.addNotification('Models reloaded successfully');
|
||||
setTimeout(() => window.location.reload(), 1000);
|
||||
} else {
|
||||
this.addNotification('Failed to reload models', 'error');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
this.addNotification('Error: ' + error.message, 'error');
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Stop model function
|
||||
async function stopModelManage(modelName) {
|
||||
if (!confirm(`Are you sure you want to stop "${modelName}"?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/backend/shutdown', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ model: modelName })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setTimeout(() => window.location.reload(), 500);
|
||||
} else {
|
||||
alert('Failed to stop model');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error stopping model:', error);
|
||||
alert('Failed to stop model');
|
||||
}
|
||||
}
|
||||
|
||||
window.manageDashboard = manageDashboard;
|
||||
window.stopModelManage = stopModelManage;
|
||||
</script>
|
||||
229
core/http/views/spa/talk.html
Normal file
229
core/http/views/spa/talk.html
Normal file
@@ -0,0 +1,229 @@
|
||||
<!-- Talk View Content for SPA -->
|
||||
<div class="container mx-auto px-4 py-8 flex-grow">
|
||||
<!-- Hero Section -->
|
||||
<div class="hero-section">
|
||||
<div class="hero-content">
|
||||
<h1 class="hero-title">
|
||||
<i class="fas fa-comments mr-2"></i>Talk Interface
|
||||
</h1>
|
||||
<p class="hero-subtitle">Speak with your AI models using voice interaction</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Talk Interface -->
|
||||
<div class="max-w-3xl mx-auto">
|
||||
<div class="card overflow-hidden">
|
||||
<!-- Talk Interface Body -->
|
||||
<div class="p-6">
|
||||
<!-- Recording Status -->
|
||||
<div id="spa-recording" class="bg-red-500/10 border border-red-500/30 rounded-lg p-4 mb-4 flex items-center space-x-3" style="display: none;">
|
||||
<i class="fa-solid fa-microphone text-2xl text-red-400"></i>
|
||||
<span class="text-red-300 font-medium">Recording... press "Stop recording" to stop</span>
|
||||
</div>
|
||||
|
||||
<!-- Loader -->
|
||||
<div id="spa-talk-loader" class="my-4 flex justify-center" style="display: none;">
|
||||
<div class="animate-spin rounded-full h-10 w-10 border-t-2 border-b-2 border-[var(--color-primary)]"></div>
|
||||
</div>
|
||||
|
||||
<!-- Status Text -->
|
||||
<div id="spa-statustext" class="my-4 p-3 bg-[var(--color-bg-secondary)] border border-[var(--color-border)] rounded-lg text-[var(--color-text-primary)]" style="min-height: 3rem;">Press the record button to start recording.</div>
|
||||
|
||||
<!-- Note -->
|
||||
<div class="bg-[var(--color-primary-light)] border border-[var(--color-primary-border)] rounded-lg p-4 mb-6">
|
||||
<div class="flex items-start">
|
||||
<i class="fas fa-info-circle text-[var(--color-primary)] mt-1 mr-3 flex-shrink-0"></i>
|
||||
<p class="text-[var(--color-text-secondary)]">
|
||||
<strong class="text-[var(--color-primary)]">Note:</strong> You need an LLM, an audio-transcription (whisper), and a TTS model installed for this to work. Select the appropriate models below and click 'Talk' to start recording.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Model Selectors -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
|
||||
<!-- LLM Model -->
|
||||
<div class="space-y-2">
|
||||
<label for="spa-modelSelect" class="flex items-center text-[var(--color-text-secondary)] font-medium">
|
||||
<i class="fas fa-brain text-[var(--color-primary)] mr-2"></i>LLM Model
|
||||
</label>
|
||||
<select id="spa-modelSelect" class="input w-full p-2.5">
|
||||
<option value="" disabled class="text-[var(--color-text-secondary)]">Select a model</option>
|
||||
{{ range .ModelsConfig }}
|
||||
<option value="{{.Name}}" class="bg-[var(--color-bg-primary)] text-[var(--color-text-primary)]">{{.Name}}</option>
|
||||
{{ end }}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Whisper Model -->
|
||||
<div class="space-y-2">
|
||||
<label for="spa-whisperModelSelect" class="flex items-center text-[var(--color-text-secondary)] font-medium">
|
||||
<i class="fas fa-ear-listen text-[var(--color-accent)] mr-2"></i>Whisper Model
|
||||
</label>
|
||||
<select id="spa-whisperModelSelect" class="input w-full p-2.5">
|
||||
<option value="" disabled class="text-[var(--color-text-secondary)]">Select a model</option>
|
||||
{{ range .ModelsConfig }}
|
||||
<option value="{{.Name}}" class="bg-[var(--color-bg-primary)] text-[var(--color-text-primary)]">{{.Name}}</option>
|
||||
{{ end }}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- TTS Model -->
|
||||
<div class="space-y-2">
|
||||
<label for="spa-ttsModelSelect" class="flex items-center text-[var(--color-text-secondary)] font-medium">
|
||||
<i class="fas fa-volume-high text-green-400 mr-2"></i>TTS Model
|
||||
</label>
|
||||
<select id="spa-ttsModelSelect" class="input w-full p-2.5">
|
||||
<option value="" disabled class="text-[var(--color-text-secondary)]">Select a model</option>
|
||||
{{ range .ModelsConfig }}
|
||||
<option value="{{.Name}}" class="bg-[var(--color-bg-primary)] text-[var(--color-text-primary)]">{{.Name}}</option>
|
||||
{{ end }}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Buttons -->
|
||||
<div class="flex items-center justify-between mt-8">
|
||||
<button id="spa-recordButton" onclick="startTalkRecording()"
|
||||
class="inline-flex items-center bg-red-500 hover:bg-red-600 text-white font-semibold py-2 px-6 rounded-lg transition-colors">
|
||||
<i class="fas fa-microphone mr-2"></i>
|
||||
<span>Talk</span>
|
||||
</button>
|
||||
<button id="spa-stopRecordButton" onclick="stopTalkRecording()" style="display: none;"
|
||||
class="inline-flex items-center bg-gray-500 hover:bg-gray-600 text-white font-semibold py-2 px-6 rounded-lg transition-colors">
|
||||
<i class="fas fa-stop mr-2"></i>
|
||||
<span>Stop Recording</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Audio Result -->
|
||||
<div id="spa-talk-result" class="mt-6"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Simplified Talk functions for SPA
|
||||
let talkMediaRecorder = null;
|
||||
let talkAudioChunks = [];
|
||||
|
||||
function startTalkRecording() {
|
||||
const statusText = document.getElementById('spa-statustext');
|
||||
const recording = document.getElementById('spa-recording');
|
||||
const recordButton = document.getElementById('spa-recordButton');
|
||||
const stopButton = document.getElementById('spa-stopRecordButton');
|
||||
|
||||
navigator.mediaDevices.getUserMedia({ audio: true })
|
||||
.then(stream => {
|
||||
talkMediaRecorder = new MediaRecorder(stream);
|
||||
talkAudioChunks = [];
|
||||
|
||||
talkMediaRecorder.ondataavailable = event => {
|
||||
talkAudioChunks.push(event.data);
|
||||
};
|
||||
|
||||
talkMediaRecorder.onstop = () => {
|
||||
const audioBlob = new Blob(talkAudioChunks, { type: 'audio/wav' });
|
||||
processTalkAudio(audioBlob);
|
||||
};
|
||||
|
||||
talkMediaRecorder.start();
|
||||
recording.style.display = 'flex';
|
||||
recordButton.style.display = 'none';
|
||||
stopButton.style.display = 'inline-flex';
|
||||
statusText.textContent = 'Recording... Speak now.';
|
||||
})
|
||||
.catch(error => {
|
||||
statusText.textContent = 'Error accessing microphone: ' + error.message;
|
||||
});
|
||||
}
|
||||
|
||||
function stopTalkRecording() {
|
||||
const recording = document.getElementById('spa-recording');
|
||||
const recordButton = document.getElementById('spa-recordButton');
|
||||
const stopButton = document.getElementById('spa-stopRecordButton');
|
||||
|
||||
if (talkMediaRecorder && talkMediaRecorder.state !== 'inactive') {
|
||||
talkMediaRecorder.stop();
|
||||
talkMediaRecorder.stream.getTracks().forEach(track => track.stop());
|
||||
}
|
||||
|
||||
recording.style.display = 'none';
|
||||
recordButton.style.display = 'inline-flex';
|
||||
stopButton.style.display = 'none';
|
||||
}
|
||||
|
||||
function processTalkAudio(audioBlob) {
|
||||
const statusText = document.getElementById('spa-statustext');
|
||||
const loader = document.getElementById('spa-talk-loader');
|
||||
const result = document.getElementById('spa-talk-result');
|
||||
const llmModel = document.getElementById('spa-modelSelect').value;
|
||||
const whisperModel = document.getElementById('spa-whisperModelSelect').value;
|
||||
const ttsModel = document.getElementById('spa-ttsModelSelect').value;
|
||||
|
||||
if (!llmModel || !whisperModel || !ttsModel) {
|
||||
statusText.textContent = 'Please select all three models (LLM, Whisper, TTS)';
|
||||
return;
|
||||
}
|
||||
|
||||
loader.style.display = 'flex';
|
||||
statusText.textContent = 'Processing...';
|
||||
|
||||
// Step 1: Transcribe audio
|
||||
const formData = new FormData();
|
||||
formData.append('file', audioBlob, 'audio.wav');
|
||||
formData.append('model', whisperModel);
|
||||
|
||||
fetch('/v1/audio/transcriptions', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
const transcription = data.text;
|
||||
statusText.textContent = 'You said: ' + transcription;
|
||||
|
||||
// Step 2: Send to LLM
|
||||
return fetch('/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: llmModel,
|
||||
messages: [{ role: 'user', content: transcription }]
|
||||
})
|
||||
});
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
const reply = data.choices[0].message.content;
|
||||
statusText.textContent = 'AI: ' + reply;
|
||||
|
||||
// Step 3: Convert to speech
|
||||
return fetch('/tts', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: ttsModel,
|
||||
input: reply
|
||||
})
|
||||
});
|
||||
})
|
||||
.then(response => response.blob())
|
||||
.then(blob => {
|
||||
loader.style.display = 'none';
|
||||
const audioUrl = URL.createObjectURL(blob);
|
||||
result.innerHTML = `
|
||||
<audio controls autoplay class="w-full">
|
||||
<source src="${audioUrl}" type="audio/wav">
|
||||
</audio>
|
||||
`;
|
||||
})
|
||||
.catch(error => {
|
||||
loader.style.display = 'none';
|
||||
statusText.textContent = 'Error: ' + error.message;
|
||||
});
|
||||
}
|
||||
|
||||
window.startTalkRecording = startTalkRecording;
|
||||
window.stopTalkRecording = stopTalkRecording;
|
||||
</script>
|
||||
155
core/http/views/spa/text2image.html
Normal file
155
core/http/views/spa/text2image.html
Normal file
@@ -0,0 +1,155 @@
|
||||
<!-- Text2Image View Content for SPA -->
|
||||
<div class="flex flex-col flex-1 overflow-hidden">
|
||||
<div class="flex flex-1 overflow-hidden">
|
||||
<!-- Two Column Layout: Settings on Left, Preview on Right -->
|
||||
<div class="flex flex-col lg:flex-row flex-1 gap-4 p-4 overflow-hidden">
|
||||
<!-- Left Column: Generation Settings -->
|
||||
<div class="flex-shrink-0 lg:w-1/4 flex flex-col min-h-0">
|
||||
<div class="card p-3 space-y-3 overflow-y-auto flex-1">
|
||||
<!-- Model Selection -->
|
||||
<div class="space-y-1.5">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<label class="text-xs font-medium text-[var(--color-text-secondary)] uppercase tracking-wide flex-shrink-0">Model</label>
|
||||
</div>
|
||||
<select id="image-model-select" class="input w-full p-1.5 text-xs" @change="document.getElementById('image-model').value = $event.target.value">
|
||||
<option value="" disabled class="text-[var(--color-text-secondary)]">Select a model</option>
|
||||
{{ $model:=.Model}}
|
||||
{{ range .ModelsConfig }}
|
||||
{{ $cfg := . }}
|
||||
{{ range .KnownUsecaseStrings }}
|
||||
{{ if eq . "FLAG_IMAGE" }}
|
||||
<option value="{{$cfg.Name}}" {{ if eq $cfg.Name $model }} selected {{end}} class="bg-[var(--color-bg-primary)] text-[var(--color-text-primary)]">{{$cfg.Name}}</option>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
{{ range .ModelsWithoutConfig }}
|
||||
<option value="{{.}}" {{ if eq . $model }} selected {{ end }} class="bg-[var(--color-bg-primary)] text-[var(--color-text-primary)]">{{.}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="relative">
|
||||
<input id="image-model" type="hidden" value="{{.Model}}">
|
||||
<form id="genimage" @submit.prevent="genImage($event)">
|
||||
<!-- Basic Settings -->
|
||||
<div class="space-y-2">
|
||||
<!-- Prompt -->
|
||||
<div class="space-y-1">
|
||||
<label for="image-input" class="block text-xs font-medium text-[var(--color-text-secondary)] uppercase tracking-wide">
|
||||
<i class="fas fa-magic mr-1.5 text-[var(--color-primary)]"></i>Prompt
|
||||
</label>
|
||||
<textarea
|
||||
id="image-input"
|
||||
name="input"
|
||||
placeholder="Describe the image you want to generate..."
|
||||
autocomplete="off"
|
||||
rows="3"
|
||||
class="input w-full p-1.5 text-xs resize-y"
|
||||
required
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Negative Prompt -->
|
||||
<div class="space-y-1">
|
||||
<label for="negative-prompt" class="block text-xs font-medium text-[var(--color-text-secondary)] uppercase tracking-wide">
|
||||
<i class="fas fa-ban mr-1.5 text-[var(--color-primary)]"></i>Negative Prompt
|
||||
</label>
|
||||
<textarea
|
||||
id="negative-prompt"
|
||||
name="negative-prompt"
|
||||
placeholder="Things to avoid in the image..."
|
||||
rows="2"
|
||||
class="input w-full p-1.5 text-xs resize-y"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Size Selection -->
|
||||
<div class="space-y-1">
|
||||
<label for="image-size" class="block text-xs font-medium text-[var(--color-text-secondary)] uppercase tracking-wide">
|
||||
<i class="fas fa-expand-arrows-alt mr-1.5 text-[var(--color-primary)]"></i>Image Size
|
||||
</label>
|
||||
<div class="flex flex-wrap gap-1.5 mb-1.5">
|
||||
<button type="button" class="size-preset px-2 py-0.5 text-[10px] rounded border border-[var(--color-border)] hover:bg-[var(--color-bg-secondary)]" data-size="256x256">256×256</button>
|
||||
<button type="button" class="size-preset px-2 py-0.5 text-[10px] rounded border border-[var(--color-border)] hover:bg-[var(--color-bg-secondary)] bg-[var(--color-primary)] text-white" data-size="512x512">512×512</button>
|
||||
<button type="button" class="size-preset px-2 py-0.5 text-[10px] rounded border border-[var(--color-border)] hover:bg-[var(--color-bg-secondary)]" data-size="768x768">768×768</button>
|
||||
<button type="button" class="size-preset px-2 py-0.5 text-[10px] rounded border border-[var(--color-border)] hover:bg-[var(--color-bg-secondary)]" data-size="1024x1024">1024×1024</button>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
id="image-size"
|
||||
value="512x512"
|
||||
placeholder="e.g., 256x256, 512x512, 1024x1024"
|
||||
class="input p-1.5 text-xs w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Number of Images -->
|
||||
<div class="space-y-1">
|
||||
<label for="image-count" class="block text-xs font-medium text-[var(--color-text-secondary)] uppercase tracking-wide">
|
||||
<i class="fas fa-images mr-1.5 text-[var(--color-primary)]"></i>Number of Images
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="image-count"
|
||||
name="n"
|
||||
min="1"
|
||||
max="4"
|
||||
value="1"
|
||||
class="input p-1.5 text-xs w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<div class="mt-4">
|
||||
<button
|
||||
type="submit"
|
||||
id="generate-btn"
|
||||
class="w-full px-2 py-1.5 text-xs rounded text-[var(--color-bg-primary)] bg-[var(--color-primary)] hover:bg-[var(--color-primary)]/90 transition-colors font-medium"
|
||||
>
|
||||
<i class="fas fa-magic mr-1.5"></i>Generate Image
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column: Image Preview -->
|
||||
<div class="flex-grow lg:w-3/4 flex flex-col min-h-0">
|
||||
<div class="relative flex-1 min-h-0 overflow-y-auto">
|
||||
<!-- Loading Animation -->
|
||||
<div id="loader" class="hidden absolute inset-0 flex items-center justify-center bg-[var(--color-bg-primary)]/80 rounded-xl z-10">
|
||||
<div class="text-center">
|
||||
<svg class="animate-spin h-10 w-10 text-[var(--color-primary)] mx-auto mb-3" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<p class="text-xs text-[var(--color-text-secondary)]">Generating image...</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Placeholder when no images -->
|
||||
<div id="result-placeholder" class="min-h-[400px] flex items-center justify-center flex-shrink-0">
|
||||
<p class="text-xs text-[var(--color-text-secondary)] italic text-center">Your generated images will appear here</p>
|
||||
</div>
|
||||
<!-- Results container -->
|
||||
<div id="result" class="grid grid-cols-1 sm:grid-cols-2 gap-4 pb-4"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Size preset buttons for SPA
|
||||
document.querySelectorAll('.size-preset').forEach(button => {
|
||||
button.addEventListener('click', function() {
|
||||
const size = this.getAttribute('data-size');
|
||||
document.getElementById('image-size').value = size;
|
||||
document.querySelectorAll('.size-preset').forEach(btn => {
|
||||
btn.classList.remove('bg-[var(--color-primary)]', 'text-white');
|
||||
});
|
||||
this.classList.add('bg-[var(--color-primary)]', 'text-white');
|
||||
});
|
||||
});
|
||||
</script>
|
||||
138
core/http/views/spa/tts.html
Normal file
138
core/http/views/spa/tts.html
Normal file
@@ -0,0 +1,138 @@
|
||||
<!-- TTS View Content for SPA -->
|
||||
<div class="container mx-auto px-4 py-8 flex-grow">
|
||||
<!-- Hero Section -->
|
||||
<div class="hero-section">
|
||||
<div class="hero-content">
|
||||
<h1 class="hero-title">
|
||||
<i class="fas fa-volume-high mr-2"></i>Text to Speech
|
||||
</h1>
|
||||
<p class="hero-subtitle">Convert your text into natural-sounding speech</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TTS Interface -->
|
||||
<div class="max-w-3xl mx-auto">
|
||||
<div class="card overflow-hidden">
|
||||
<!-- Header with Model Selection -->
|
||||
<div class="border-b border-[var(--color-bg-secondary)] p-5">
|
||||
<div class="flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<!-- Model Selection -->
|
||||
<div class="flex items-center">
|
||||
<label for="tts-model-select" class="mr-3 text-[var(--color-text-secondary)] font-medium">
|
||||
<i class="fas fa-microphone-lines text-[var(--color-accent)] mr-2"></i>Model:
|
||||
</label>
|
||||
<select id="tts-model-select" class="input p-2.5" @change="document.getElementById('tts-model').value = $event.target.value">
|
||||
<option value="" disabled class="text-[var(--color-text-secondary)]">Select a model</option>
|
||||
{{ $model:=.Model}}
|
||||
{{ range .ModelsConfig }}
|
||||
{{ $cfg := . }}
|
||||
{{ range .KnownUsecaseStrings }}
|
||||
{{ if eq . "FLAG_TTS" }}
|
||||
<option value="{{$cfg.Name}}" {{ if eq $cfg.Name $model }} selected {{end}} class="bg-[var(--color-bg-primary)] text-[var(--color-text-primary)]">{{$cfg.Name}}</option>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
{{ range .ModelsWithoutConfig }}
|
||||
<option value="{{.}}" {{ if eq . $model }} selected {{ end }} class="bg-[var(--color-bg-primary)] text-[var(--color-text-primary)]">{{.}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Input Area -->
|
||||
<div class="p-6">
|
||||
<div class="bg-[var(--color-accent-light)] border border-[var(--color-accent-border)] rounded-lg p-4 mb-6">
|
||||
<div class="flex items-start">
|
||||
<i class="fas fa-info-circle text-[var(--color-accent)] mt-1 mr-3 flex-shrink-0"></i>
|
||||
<p class="text-[var(--color-text-secondary)]">
|
||||
Enter your text below and submit to generate speech with the selected TTS model.
|
||||
The generated audio will appear below the input field.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input id="tts-model" type="hidden" value="{{.Model}}">
|
||||
<form id="tts" @submit.prevent="generateTTS($event)" class="mb-6">
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
id="tts-input"
|
||||
name="input"
|
||||
placeholder="Enter text to convert to speech..."
|
||||
autocomplete="off"
|
||||
class="input w-full p-4 pl-4 pr-12"
|
||||
required
|
||||
/>
|
||||
<button type="submit" class="absolute right-3 top-1/2 transform -translate-y-1/2 text-[var(--color-accent)] hover:text-[var(--color-primary)] transition icon-hover">
|
||||
<i class="fas fa-paper-plane"></i>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Loading indicator -->
|
||||
<div class="flex justify-center my-6">
|
||||
<div id="tts-loader" class="animate-spin rounded-full h-10 w-10 border-t-2 border-b-2 border-[var(--color-accent)]" style="display: none;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Results Area -->
|
||||
<div class="bg-[var(--color-bg-secondary)] border border-[var(--color-border)] rounded-lg p-4 min-h-[100px] flex items-center justify-center">
|
||||
<div id="tts-result" class="w-full text-center text-[var(--color-text-secondary)]">
|
||||
<p>Generated audio will appear here</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// TTS generation function for SPA
|
||||
function generateTTS(event) {
|
||||
if (event) event.preventDefault();
|
||||
|
||||
const input = document.getElementById('tts-input');
|
||||
const model = document.getElementById('tts-model')?.value;
|
||||
const loader = document.getElementById('tts-loader');
|
||||
const result = document.getElementById('tts-result');
|
||||
|
||||
if (!input?.value.trim() || !model) {
|
||||
alert('Please enter text and select a model');
|
||||
return;
|
||||
}
|
||||
|
||||
loader.style.display = 'block';
|
||||
result.innerHTML = '';
|
||||
|
||||
fetch('/tts', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: model,
|
||||
input: input.value.trim()
|
||||
})
|
||||
})
|
||||
.then(response => response.blob())
|
||||
.then(blob => {
|
||||
loader.style.display = 'none';
|
||||
const audioUrl = URL.createObjectURL(blob);
|
||||
result.innerHTML = `
|
||||
<audio controls class="w-full">
|
||||
<source src="${audioUrl}" type="audio/wav">
|
||||
Your browser does not support the audio element.
|
||||
</audio>
|
||||
<a href="${audioUrl}" download="tts_output.wav" class="mt-3 inline-block btn-secondary text-sm">
|
||||
<i class="fas fa-download mr-2"></i>Download
|
||||
</a>
|
||||
`;
|
||||
})
|
||||
.catch(error => {
|
||||
loader.style.display = 'none';
|
||||
result.innerHTML = `<p class="text-red-400">Error generating speech: ${error.message}</p>`;
|
||||
});
|
||||
}
|
||||
|
||||
window.generateTTS = generateTTS;
|
||||
</script>
|
||||
@@ -29,79 +29,3 @@ helm show values go-skynet/local-ai > values.yaml
|
||||
|
||||
helm install local-ai go-skynet/local-ai -f values.yaml
|
||||
```
|
||||
|
||||
## Security Context Requirements
|
||||
|
||||
LocalAI spawns child processes to run model backends (e.g., llama.cpp, diffusers, whisper). To properly stop these processes and free resources like VRAM, LocalAI needs permission to send signals to its child processes.
|
||||
|
||||
If you're using restrictive security contexts, ensure the `CAP_KILL` capability is available:
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: Pod
|
||||
metadata:
|
||||
name: local-ai
|
||||
spec:
|
||||
containers:
|
||||
- name: local-ai
|
||||
image: quay.io/go-skynet/local-ai:latest
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
add:
|
||||
- KILL # Required for LocalAI to stop backend processes
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1000
|
||||
```
|
||||
|
||||
Without the `KILL` capability, LocalAI cannot terminate backend processes when models are stopped, leading to:
|
||||
- VRAM and memory not being freed
|
||||
- Orphaned backend processes holding GPU resources
|
||||
- Error messages like `error while deleting process error=permission denied`
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Issue: VRAM is not freed when stopping models
|
||||
|
||||
**Symptoms:**
|
||||
- Models appear to stop but GPU memory remains allocated
|
||||
- Logs show `(deleteProcess) error while deleting process error=permission denied`
|
||||
- Backend processes remain running after model unload
|
||||
|
||||
**Common Causes:**
|
||||
- All capabilities are dropped without adding back `CAP_KILL`
|
||||
- Using user namespacing (`hostUsers: false`) with certain configurations
|
||||
- Overly restrictive seccomp profiles that block signal-related syscalls
|
||||
- Pod Security Policies or Pod Security Standards blocking required capabilities
|
||||
|
||||
**Solution:**
|
||||
|
||||
1. Add the `KILL` capability to your container's security context as shown in the example above.
|
||||
|
||||
2. If you're using a Helm chart, configure the security context in your `values.yaml`:
|
||||
|
||||
```yaml
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
add:
|
||||
- KILL
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
```
|
||||
|
||||
3. Verify the capability is present in the running pod:
|
||||
|
||||
```bash
|
||||
kubectl exec -it <pod-name> -- grep CapEff /proc/1/status
|
||||
```
|
||||
|
||||
4. If running in privileged mode works but the above doesn't, check your cluster's Pod Security Policies or Pod Security Standards. You may need to adjust cluster-level policies to allow the `KILL` capability.
|
||||
|
||||
5. Ensure your seccomp profile (if custom) allows the `kill` syscall. The `RuntimeDefault` profile typically includes this.
|
||||
|
||||
@@ -29,79 +29,3 @@ helm show values go-skynet/local-ai > values.yaml
|
||||
|
||||
helm install local-ai go-skynet/local-ai -f values.yaml
|
||||
```
|
||||
|
||||
## Security Context Requirements
|
||||
|
||||
LocalAI spawns child processes to run model backends (e.g., llama.cpp, diffusers, whisper). To properly stop these processes and free resources like VRAM, LocalAI needs permission to send signals to its child processes.
|
||||
|
||||
If you're using restrictive security contexts, ensure the `CAP_KILL` capability is available:
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: Pod
|
||||
metadata:
|
||||
name: local-ai
|
||||
spec:
|
||||
containers:
|
||||
- name: local-ai
|
||||
image: quay.io/go-skynet/local-ai:latest
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
add:
|
||||
- KILL # Required for LocalAI to stop backend processes
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1000
|
||||
```
|
||||
|
||||
Without the `KILL` capability, LocalAI cannot terminate backend processes when models are stopped, leading to:
|
||||
- VRAM and memory not being freed
|
||||
- Orphaned backend processes holding GPU resources
|
||||
- Error messages like `error while deleting process error=permission denied`
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Issue: VRAM is not freed when stopping models
|
||||
|
||||
**Symptoms:**
|
||||
- Models appear to stop but GPU memory remains allocated
|
||||
- Logs show `(deleteProcess) error while deleting process error=permission denied`
|
||||
- Backend processes remain running after model unload
|
||||
|
||||
**Common Causes:**
|
||||
- All capabilities are dropped without adding back `CAP_KILL`
|
||||
- Using user namespacing (`hostUsers: false`) with certain configurations
|
||||
- Overly restrictive seccomp profiles that block signal-related syscalls
|
||||
- Pod Security Policies or Pod Security Standards blocking required capabilities
|
||||
|
||||
**Solution:**
|
||||
|
||||
1. Add the `KILL` capability to your container's security context as shown in the example above.
|
||||
|
||||
2. If you're using a Helm chart, configure the security context in your `values.yaml`:
|
||||
|
||||
```yaml
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
add:
|
||||
- KILL
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
```
|
||||
|
||||
3. Verify the capability is present in the running pod:
|
||||
|
||||
```bash
|
||||
kubectl exec -it <pod-name> -- grep CapEff /proc/1/status
|
||||
```
|
||||
|
||||
4. If running in privileged mode works but the above doesn't, check your cluster's Pod Security Policies or Pod Security Standards. You may need to adjust cluster-level policies to allow the `KILL` capability.
|
||||
|
||||
5. Ensure your seccomp profile (if custom) allows the `kill` syscall. The `RuntimeDefault` profile typically includes this.
|
||||
|
||||
@@ -24,8 +24,6 @@ func (ml *ModelLoader) deleteProcess(s string) error {
|
||||
return fmt.Errorf("model %s not found", s)
|
||||
}
|
||||
|
||||
defer delete(ml.models, s)
|
||||
|
||||
retries := 1
|
||||
for model.GRPC(false, ml.wd).IsBusy() {
|
||||
xlog.Debug("Model busy. Waiting.", "model", s)
|
||||
@@ -48,6 +46,7 @@ func (ml *ModelLoader) deleteProcess(s string) error {
|
||||
if process == nil {
|
||||
xlog.Error("No process", "model", s)
|
||||
// Nothing to do as there is no process
|
||||
delete(ml.models, s)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -56,6 +55,10 @@ func (ml *ModelLoader) deleteProcess(s string) error {
|
||||
xlog.Error("(deleteProcess) error while deleting process", "error", err, "model", s)
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
delete(ml.models, s)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -12,15 +12,17 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
// Public constants - used by tests and external packages
|
||||
Nvidia = "nvidia"
|
||||
AMD = "amd"
|
||||
Intel = "intel"
|
||||
|
||||
// Private constants - only used within this package
|
||||
defaultCapability = "default"
|
||||
nvidiaL4T = "nvidia-l4t"
|
||||
darwinX86 = "darwin-x86"
|
||||
metal = "metal"
|
||||
nvidia = "nvidia"
|
||||
|
||||
amd = "amd"
|
||||
intel = "intel"
|
||||
vulkan = "vulkan"
|
||||
vulkan = "vulkan"
|
||||
|
||||
nvidiaCuda13 = "nvidia-cuda-13"
|
||||
nvidiaCuda12 = "nvidia-cuda-12"
|
||||
@@ -30,6 +32,16 @@ const (
|
||||
capabilityEnv = "LOCALAI_FORCE_META_BACKEND_CAPABILITY"
|
||||
capabilityRunFileEnv = "LOCALAI_FORCE_META_BACKEND_CAPABILITY_RUN_FILE"
|
||||
defaultRunFile = "/run/localai/capability"
|
||||
|
||||
// Backend detection tokens (private)
|
||||
backendTokenDarwin = "darwin"
|
||||
backendTokenMLX = "mlx"
|
||||
backendTokenMetal = "metal"
|
||||
backendTokenL4T = "l4t"
|
||||
backendTokenCUDA = "cuda"
|
||||
backendTokenROCM = "rocm"
|
||||
backendTokenHIP = "hip"
|
||||
backendTokenSYCL = "sycl"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -96,7 +108,7 @@ func (s *SystemState) getSystemCapabilities() string {
|
||||
|
||||
// If arm64 on linux and a nvidia gpu is detected, we will return nvidia-l4t
|
||||
if runtime.GOOS == "linux" && runtime.GOARCH == "arm64" {
|
||||
if s.GPUVendor == nvidia {
|
||||
if s.GPUVendor == Nvidia {
|
||||
xlog.Info("Using nvidia-l4t capability (arm64 on linux)", "env", capabilityEnv)
|
||||
if cuda13DirExists {
|
||||
return nvidiaL4TCuda13
|
||||
@@ -131,7 +143,6 @@ func (s *SystemState) getSystemCapabilities() string {
|
||||
return s.GPUVendor
|
||||
}
|
||||
|
||||
|
||||
// BackendPreferenceTokens returns a list of substrings that represent the preferred
|
||||
// backend implementation order for the current system capability. Callers can use
|
||||
// these tokens to select the most appropriate concrete backend among multiple
|
||||
@@ -139,19 +150,76 @@ func (s *SystemState) getSystemCapabilities() string {
|
||||
func (s *SystemState) BackendPreferenceTokens() []string {
|
||||
capStr := strings.ToLower(s.getSystemCapabilities())
|
||||
switch {
|
||||
case strings.HasPrefix(capStr, nvidia):
|
||||
return []string{"cuda", "vulkan", "cpu"}
|
||||
case strings.HasPrefix(capStr, amd):
|
||||
return []string{"rocm", "hip", "vulkan", "cpu"}
|
||||
case strings.HasPrefix(capStr, intel):
|
||||
return []string{"sycl", intel, "cpu"}
|
||||
case strings.HasPrefix(capStr, Nvidia):
|
||||
return []string{backendTokenCUDA, vulkan, "cpu"}
|
||||
case strings.HasPrefix(capStr, AMD):
|
||||
return []string{backendTokenROCM, backendTokenHIP, vulkan, "cpu"}
|
||||
case strings.HasPrefix(capStr, Intel):
|
||||
return []string{backendTokenSYCL, Intel, "cpu"}
|
||||
case strings.HasPrefix(capStr, metal):
|
||||
return []string{"metal", "cpu"}
|
||||
return []string{backendTokenMetal, "cpu"}
|
||||
case strings.HasPrefix(capStr, darwinX86):
|
||||
return []string{"darwin-x86", "cpu"}
|
||||
case strings.HasPrefix(capStr, vulkan):
|
||||
return []string{"vulkan", "cpu"}
|
||||
return []string{vulkan, "cpu"}
|
||||
default:
|
||||
return []string{"cpu"}
|
||||
}
|
||||
}
|
||||
|
||||
// DetectedCapability returns the detected system capability string.
|
||||
// This can be used by the UI to display what capability was detected.
|
||||
func (s *SystemState) DetectedCapability() string {
|
||||
return s.getSystemCapabilities()
|
||||
}
|
||||
|
||||
// IsBackendCompatible checks if a backend (identified by name and URI) is compatible
|
||||
// with the current system capability. This function uses getSystemCapabilities to ensure
|
||||
// consistency with capability detection (including VRAM checks, environment overrides, etc.).
|
||||
func (s *SystemState) IsBackendCompatible(name, uri string) bool {
|
||||
combined := strings.ToLower(name + " " + uri)
|
||||
capability := s.getSystemCapabilities()
|
||||
|
||||
// Check for darwin/macOS-specific backends (mlx, metal, darwin)
|
||||
isDarwinBackend := strings.Contains(combined, backendTokenDarwin) ||
|
||||
strings.Contains(combined, backendTokenMLX) ||
|
||||
strings.Contains(combined, backendTokenMetal)
|
||||
if isDarwinBackend {
|
||||
// Darwin backends require the system to be running on darwin with metal or darwin-x86 capability
|
||||
return capability == metal || capability == darwinX86
|
||||
}
|
||||
|
||||
// Check for NVIDIA L4T-specific backends (arm64 Linux with NVIDIA GPU)
|
||||
// This must be checked before the general NVIDIA check as L4T backends
|
||||
// may also contain "cuda" or "nvidia" in their names
|
||||
isL4TBackend := strings.Contains(combined, backendTokenL4T)
|
||||
if isL4TBackend {
|
||||
return strings.HasPrefix(capability, nvidiaL4T)
|
||||
}
|
||||
|
||||
// Check for NVIDIA/CUDA-specific backends (non-L4T)
|
||||
isNvidiaBackend := strings.Contains(combined, backendTokenCUDA) ||
|
||||
strings.Contains(combined, Nvidia)
|
||||
if isNvidiaBackend {
|
||||
// NVIDIA backends are compatible with nvidia, nvidia-cuda-12, nvidia-cuda-13, and l4t capabilities
|
||||
return strings.HasPrefix(capability, Nvidia)
|
||||
}
|
||||
|
||||
// Check for AMD/ROCm-specific backends
|
||||
isAMDBackend := strings.Contains(combined, backendTokenROCM) ||
|
||||
strings.Contains(combined, backendTokenHIP) ||
|
||||
strings.Contains(combined, AMD)
|
||||
if isAMDBackend {
|
||||
return capability == AMD
|
||||
}
|
||||
|
||||
// Check for Intel/SYCL-specific backends
|
||||
isIntelBackend := strings.Contains(combined, backendTokenSYCL) ||
|
||||
strings.Contains(combined, Intel)
|
||||
if isIntelBackend {
|
||||
return capability == Intel
|
||||
}
|
||||
|
||||
// CPU backends are always compatible
|
||||
return true
|
||||
}
|
||||
|
||||
191
swagger/docs.go
191
swagger/docs.go
@@ -1198,6 +1198,30 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/messages": {
|
||||
"post": {
|
||||
"summary": "Generate a message response for the given messages and model.",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "query params",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/schema.AnthropicRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Response",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/schema.AnthropicResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/models": {
|
||||
"get": {
|
||||
"summary": "List and describe the various models available in the API.",
|
||||
@@ -1739,6 +1763,169 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"schema.AnthropicContentBlock": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": {},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"input": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
},
|
||||
"is_error": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"source": {
|
||||
"$ref": "#/definitions/schema.AnthropicImageSource"
|
||||
},
|
||||
"text": {
|
||||
"type": "string"
|
||||
},
|
||||
"tool_use_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"schema.AnthropicImageSource": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "string"
|
||||
},
|
||||
"media_type": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"schema.AnthropicMessage": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": {},
|
||||
"role": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"schema.AnthropicRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"max_tokens": {
|
||||
"type": "integer"
|
||||
},
|
||||
"messages": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/schema.AnthropicMessage"
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"model": {
|
||||
"type": "string"
|
||||
},
|
||||
"stop_sequences": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"stream": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"system": {
|
||||
"type": "string"
|
||||
},
|
||||
"temperature": {
|
||||
"type": "number"
|
||||
},
|
||||
"tool_choice": {},
|
||||
"tools": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/schema.AnthropicTool"
|
||||
}
|
||||
},
|
||||
"top_k": {
|
||||
"type": "integer"
|
||||
},
|
||||
"top_p": {
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
},
|
||||
"schema.AnthropicResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/schema.AnthropicContentBlock"
|
||||
}
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"model": {
|
||||
"type": "string"
|
||||
},
|
||||
"role": {
|
||||
"type": "string"
|
||||
},
|
||||
"stop_reason": {
|
||||
"type": "string"
|
||||
},
|
||||
"stop_sequence": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"type": "string"
|
||||
},
|
||||
"usage": {
|
||||
"$ref": "#/definitions/schema.AnthropicUsage"
|
||||
}
|
||||
}
|
||||
},
|
||||
"schema.AnthropicTool": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"schema.AnthropicUsage": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"input_tokens": {
|
||||
"type": "integer"
|
||||
},
|
||||
"output_tokens": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"schema.BackendMonitorRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -2229,6 +2416,10 @@ const docTemplate = `{
|
||||
"description": "The message name (used for tools calls)",
|
||||
"type": "string"
|
||||
},
|
||||
"reasoning": {
|
||||
"description": "Reasoning content extracted from \u003cthinking\u003e...\u003c/thinking\u003e tags",
|
||||
"type": "string"
|
||||
},
|
||||
"role": {
|
||||
"description": "The message role",
|
||||
"type": "string"
|
||||
|
||||
@@ -1191,6 +1191,30 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/messages": {
|
||||
"post": {
|
||||
"summary": "Generate a message response for the given messages and model.",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "query params",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/schema.AnthropicRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Response",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/schema.AnthropicResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/models": {
|
||||
"get": {
|
||||
"summary": "List and describe the various models available in the API.",
|
||||
@@ -1732,6 +1756,169 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"schema.AnthropicContentBlock": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": {},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"input": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
},
|
||||
"is_error": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"source": {
|
||||
"$ref": "#/definitions/schema.AnthropicImageSource"
|
||||
},
|
||||
"text": {
|
||||
"type": "string"
|
||||
},
|
||||
"tool_use_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"schema.AnthropicImageSource": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "string"
|
||||
},
|
||||
"media_type": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"schema.AnthropicMessage": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": {},
|
||||
"role": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"schema.AnthropicRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"max_tokens": {
|
||||
"type": "integer"
|
||||
},
|
||||
"messages": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/schema.AnthropicMessage"
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"model": {
|
||||
"type": "string"
|
||||
},
|
||||
"stop_sequences": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"stream": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"system": {
|
||||
"type": "string"
|
||||
},
|
||||
"temperature": {
|
||||
"type": "number"
|
||||
},
|
||||
"tool_choice": {},
|
||||
"tools": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/schema.AnthropicTool"
|
||||
}
|
||||
},
|
||||
"top_k": {
|
||||
"type": "integer"
|
||||
},
|
||||
"top_p": {
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
},
|
||||
"schema.AnthropicResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/schema.AnthropicContentBlock"
|
||||
}
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"model": {
|
||||
"type": "string"
|
||||
},
|
||||
"role": {
|
||||
"type": "string"
|
||||
},
|
||||
"stop_reason": {
|
||||
"type": "string"
|
||||
},
|
||||
"stop_sequence": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"type": "string"
|
||||
},
|
||||
"usage": {
|
||||
"$ref": "#/definitions/schema.AnthropicUsage"
|
||||
}
|
||||
}
|
||||
},
|
||||
"schema.AnthropicTool": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"schema.AnthropicUsage": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"input_tokens": {
|
||||
"type": "integer"
|
||||
},
|
||||
"output_tokens": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"schema.BackendMonitorRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -2222,6 +2409,10 @@
|
||||
"description": "The message name (used for tools calls)",
|
||||
"type": "string"
|
||||
},
|
||||
"reasoning": {
|
||||
"description": "Reasoning content extracted from \u003cthinking\u003e...\u003c/thinking\u003e tags",
|
||||
"type": "string"
|
||||
},
|
||||
"role": {
|
||||
"description": "The message role",
|
||||
"type": "string"
|
||||
|
||||
@@ -239,6 +239,114 @@ definitions:
|
||||
start:
|
||||
type: number
|
||||
type: object
|
||||
schema.AnthropicContentBlock:
|
||||
properties:
|
||||
content: {}
|
||||
id:
|
||||
type: string
|
||||
input:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
is_error:
|
||||
type: boolean
|
||||
name:
|
||||
type: string
|
||||
source:
|
||||
$ref: '#/definitions/schema.AnthropicImageSource'
|
||||
text:
|
||||
type: string
|
||||
tool_use_id:
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
type: object
|
||||
schema.AnthropicImageSource:
|
||||
properties:
|
||||
data:
|
||||
type: string
|
||||
media_type:
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
type: object
|
||||
schema.AnthropicMessage:
|
||||
properties:
|
||||
content: {}
|
||||
role:
|
||||
type: string
|
||||
type: object
|
||||
schema.AnthropicRequest:
|
||||
properties:
|
||||
max_tokens:
|
||||
type: integer
|
||||
messages:
|
||||
items:
|
||||
$ref: '#/definitions/schema.AnthropicMessage'
|
||||
type: array
|
||||
metadata:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
model:
|
||||
type: string
|
||||
stop_sequences:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
stream:
|
||||
type: boolean
|
||||
system:
|
||||
type: string
|
||||
temperature:
|
||||
type: number
|
||||
tool_choice: {}
|
||||
tools:
|
||||
items:
|
||||
$ref: '#/definitions/schema.AnthropicTool'
|
||||
type: array
|
||||
top_k:
|
||||
type: integer
|
||||
top_p:
|
||||
type: number
|
||||
type: object
|
||||
schema.AnthropicResponse:
|
||||
properties:
|
||||
content:
|
||||
items:
|
||||
$ref: '#/definitions/schema.AnthropicContentBlock'
|
||||
type: array
|
||||
id:
|
||||
type: string
|
||||
model:
|
||||
type: string
|
||||
role:
|
||||
type: string
|
||||
stop_reason:
|
||||
type: string
|
||||
stop_sequence:
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
usage:
|
||||
$ref: '#/definitions/schema.AnthropicUsage'
|
||||
type: object
|
||||
schema.AnthropicTool:
|
||||
properties:
|
||||
description:
|
||||
type: string
|
||||
input_schema:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
name:
|
||||
type: string
|
||||
type: object
|
||||
schema.AnthropicUsage:
|
||||
properties:
|
||||
input_tokens:
|
||||
type: integer
|
||||
output_tokens:
|
||||
type: integer
|
||||
type: object
|
||||
schema.BackendMonitorRequest:
|
||||
properties:
|
||||
model:
|
||||
@@ -573,6 +681,9 @@ definitions:
|
||||
name:
|
||||
description: The message name (used for tools calls)
|
||||
type: string
|
||||
reasoning:
|
||||
description: Reasoning content extracted from <thinking>...</thinking> tags
|
||||
type: string
|
||||
role:
|
||||
description: The message role
|
||||
type: string
|
||||
@@ -1813,6 +1924,21 @@ paths:
|
||||
schema:
|
||||
$ref: '#/definitions/schema.OpenAIResponse'
|
||||
summary: Stream MCP chat completions with reasoning, tool calls, and results
|
||||
/v1/messages:
|
||||
post:
|
||||
parameters:
|
||||
- description: query params
|
||||
in: body
|
||||
name: request
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/schema.AnthropicRequest'
|
||||
responses:
|
||||
"200":
|
||||
description: Response
|
||||
schema:
|
||||
$ref: '#/definitions/schema.AnthropicResponse'
|
||||
summary: Generate a message response for the given messages and model.
|
||||
/v1/models:
|
||||
get:
|
||||
responses:
|
||||
|
||||
Reference in New Issue
Block a user