Compare commits

..

1 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
1c073f6640 Initial plan 2026-01-10 00:10:07 +00:00
45 changed files with 329 additions and 6301 deletions

View File

@@ -1,5 +1,5 @@
LLAMA_VERSION?=b1377188784f9aea26b8abde56d4aee8c733eec7
LLAMA_VERSION?=593da7fa49503b68f9f01700be9f508f1e528992
LLAMA_REPO?=https://github.com/ggerganov/llama.cpp
CMAKE_ARGS?=

View File

@@ -17,9 +17,4 @@ 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

View File

@@ -1,5 +0,0 @@
# 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

View File

@@ -16,10 +16,6 @@ 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"

View File

@@ -16,8 +16,4 @@ 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

View File

@@ -26,12 +26,6 @@ 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 ./

View File

@@ -23,10 +23,6 @@ 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

View File

@@ -63,25 +63,6 @@ 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
}

View File

@@ -172,252 +172,6 @@ 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{

View File

@@ -226,16 +226,6 @@ 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)
@@ -251,17 +241,7 @@ func availableBackendsWithFilter(galleries []config.Gallery, systemState *system
if err != nil {
return nil, err
}
// 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...)
}
backends = append(backends, galleryBackends...)
}
return backends, nil

View File

@@ -205,7 +205,6 @@ func API(application *application.Application) (*echo.Echo, error) {
routes.RegisterLocalAIRoutes(e, requestExtractor, application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig(), application.GalleryService(), opcache, application.TemplatesEvaluator(), application)
routes.RegisterOpenAIRoutes(e, requestExtractor, application)
routes.RegisterAnthropicRoutes(e, requestExtractor, application)
if !application.ApplicationConfig().DisableWebUI {
routes.RegisterUIAPIRoutes(e, application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig(), application.GalleryService(), opcache, application)
routes.RegisterUIRoutes(e, application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig(), application.GalleryService())

View File

@@ -1,537 +0,0 @@
package anthropic
import (
"encoding/json"
"fmt"
"github.com/google/uuid"
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/backend"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/http/middleware"
"github.com/mudler/LocalAI/core/schema"
"github.com/mudler/LocalAI/core/templates"
"github.com/mudler/LocalAI/pkg/functions"
"github.com/mudler/LocalAI/pkg/model"
"github.com/mudler/xlog"
)
// MessagesEndpoint is the Anthropic Messages API endpoint
// https://docs.anthropic.com/claude/reference/messages_post
// @Summary Generate a message response for the given messages and model.
// @Param request body schema.AnthropicRequest true "query params"
// @Success 200 {object} schema.AnthropicResponse "Response"
// @Router /v1/messages [post]
func MessagesEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator *templates.Evaluator, appConfig *config.ApplicationConfig) echo.HandlerFunc {
return func(c echo.Context) error {
id := uuid.New().String()
input, ok := c.Get(middleware.CONTEXT_LOCALS_KEY_LOCALAI_REQUEST).(*schema.AnthropicRequest)
if !ok || input.Model == "" {
return sendAnthropicError(c, 400, "invalid_request_error", "model is required")
}
cfg, ok := c.Get(middleware.CONTEXT_LOCALS_KEY_MODEL_CONFIG).(*config.ModelConfig)
if !ok || cfg == nil {
return sendAnthropicError(c, 400, "invalid_request_error", "model configuration not found")
}
if input.MaxTokens <= 0 {
return sendAnthropicError(c, 400, "invalid_request_error", "max_tokens is required and must be greater than 0")
}
xlog.Debug("Anthropic Messages endpoint configuration read", "config", cfg)
// Convert Anthropic messages to OpenAI format for internal processing
openAIMessages := convertAnthropicToOpenAIMessages(input)
// Convert Anthropic tools to internal Functions format
funcs, shouldUseFn := convertAnthropicTools(input, cfg)
// Create an OpenAI-compatible request for internal processing
openAIReq := &schema.OpenAIRequest{
PredictionOptions: schema.PredictionOptions{
BasicModelRequest: schema.BasicModelRequest{Model: input.Model},
Temperature: input.Temperature,
TopK: input.TopK,
TopP: input.TopP,
Maxtokens: &input.MaxTokens,
},
Messages: openAIMessages,
Stream: input.Stream,
Context: input.Context,
Cancel: input.Cancel,
}
// Set stop sequences
if len(input.StopSequences) > 0 {
openAIReq.Stop = input.StopSequences
}
// Merge config settings
if input.Temperature != nil {
cfg.Temperature = input.Temperature
}
if input.TopK != nil {
cfg.TopK = input.TopK
}
if input.TopP != nil {
cfg.TopP = input.TopP
}
cfg.Maxtokens = &input.MaxTokens
if len(input.StopSequences) > 0 {
cfg.StopWords = append(cfg.StopWords, input.StopSequences...)
}
// Template the prompt with tools if available
predInput := evaluator.TemplateMessages(*openAIReq, openAIReq.Messages, cfg, funcs, shouldUseFn)
xlog.Debug("Anthropic Messages - Prompt (after templating)", "prompt", predInput)
if input.Stream {
return handleAnthropicStream(c, id, input, cfg, ml, predInput, openAIReq, funcs, shouldUseFn)
}
return handleAnthropicNonStream(c, id, input, cfg, ml, predInput, openAIReq, funcs, shouldUseFn)
}
}
func handleAnthropicNonStream(c echo.Context, id string, input *schema.AnthropicRequest, cfg *config.ModelConfig, ml *model.ModelLoader, predInput string, openAIReq *schema.OpenAIRequest, funcs functions.Functions, shouldUseFn bool) error {
images := []string{}
for _, m := range openAIReq.Messages {
images = append(images, m.StringImages...)
}
predFunc, err := backend.ModelInference(
input.Context, predInput, openAIReq.Messages, images, nil, nil, ml, cfg, nil, nil, nil, "", "", nil, nil, nil)
if err != nil {
xlog.Error("Anthropic model inference failed", "error", err)
return sendAnthropicError(c, 500, "api_error", fmt.Sprintf("model inference failed: %v", err))
}
prediction, err := predFunc()
if err != nil {
xlog.Error("Anthropic prediction failed", "error", err)
return sendAnthropicError(c, 500, "api_error", fmt.Sprintf("prediction failed: %v", err))
}
result := backend.Finetune(*cfg, predInput, prediction.Response)
// Check if the result contains tool calls
toolCalls := functions.ParseFunctionCall(result, cfg.FunctionsConfig)
var contentBlocks []schema.AnthropicContentBlock
var stopReason string
if shouldUseFn && len(toolCalls) > 0 {
// Model wants to use tools
stopReason = "tool_use"
for _, tc := range toolCalls {
// Parse arguments as JSON
var inputArgs map[string]interface{}
if err := json.Unmarshal([]byte(tc.Arguments), &inputArgs); err != nil {
xlog.Warn("Failed to parse tool call arguments as JSON", "error", err, "args", tc.Arguments)
inputArgs = map[string]interface{}{"raw": tc.Arguments}
}
contentBlocks = append(contentBlocks, schema.AnthropicContentBlock{
Type: "tool_use",
ID: fmt.Sprintf("toolu_%s_%d", id, len(contentBlocks)),
Name: tc.Name,
Input: inputArgs,
})
}
// Add any text content before the tool calls
textContent := functions.ParseTextContent(result, cfg.FunctionsConfig)
if textContent != "" {
// Prepend text block
contentBlocks = append([]schema.AnthropicContentBlock{{Type: "text", Text: textContent}}, contentBlocks...)
}
} else {
// Normal text response
stopReason = "end_turn"
contentBlocks = []schema.AnthropicContentBlock{
{Type: "text", Text: result},
}
}
resp := &schema.AnthropicResponse{
ID: fmt.Sprintf("msg_%s", id),
Type: "message",
Role: "assistant",
Model: input.Model,
StopReason: &stopReason,
Content: contentBlocks,
Usage: schema.AnthropicUsage{
InputTokens: prediction.Usage.Prompt,
OutputTokens: prediction.Usage.Completion,
},
}
if respData, err := json.Marshal(resp); err == nil {
xlog.Debug("Anthropic Response", "response", string(respData))
}
return c.JSON(200, resp)
}
func handleAnthropicStream(c echo.Context, id string, input *schema.AnthropicRequest, cfg *config.ModelConfig, ml *model.ModelLoader, predInput string, openAIReq *schema.OpenAIRequest, funcs functions.Functions, shouldUseFn bool) error {
c.Response().Header().Set("Content-Type", "text/event-stream")
c.Response().Header().Set("Cache-Control", "no-cache")
c.Response().Header().Set("Connection", "keep-alive")
// Create OpenAI messages for inference
openAIMessages := openAIReq.Messages
images := []string{}
for _, m := range openAIMessages {
images = append(images, m.StringImages...)
}
// Send message_start event
messageStart := schema.AnthropicStreamEvent{
Type: "message_start",
Message: &schema.AnthropicStreamMessage{
ID: fmt.Sprintf("msg_%s", id),
Type: "message",
Role: "assistant",
Content: []schema.AnthropicContentBlock{},
Model: input.Model,
Usage: schema.AnthropicUsage{InputTokens: 0, OutputTokens: 0},
},
}
sendAnthropicSSE(c, messageStart)
// Track accumulated content for tool call detection
accumulatedContent := ""
currentBlockIndex := 0
inToolCall := false
toolCallsEmitted := 0
// Send initial content_block_start event
contentBlockStart := schema.AnthropicStreamEvent{
Type: "content_block_start",
Index: currentBlockIndex,
ContentBlock: &schema.AnthropicContentBlock{Type: "text", Text: ""},
}
sendAnthropicSSE(c, contentBlockStart)
// Stream content deltas
tokenCallback := func(token string, usage backend.TokenUsage) bool {
accumulatedContent += token
// If we're using functions, try to detect tool calls incrementally
if shouldUseFn {
cleanedResult := functions.CleanupLLMResult(accumulatedContent, cfg.FunctionsConfig)
// Try parsing for tool calls
toolCalls := functions.ParseFunctionCall(cleanedResult, cfg.FunctionsConfig)
// If we detected new tool calls and haven't emitted them yet
if len(toolCalls) > toolCallsEmitted {
// Stop the current text block if we were in one
if !inToolCall && currentBlockIndex == 0 {
sendAnthropicSSE(c, schema.AnthropicStreamEvent{
Type: "content_block_stop",
Index: currentBlockIndex,
})
currentBlockIndex++
inToolCall = true
}
// Emit new tool calls
for i := toolCallsEmitted; i < len(toolCalls); i++ {
tc := toolCalls[i]
// Send content_block_start for tool_use
sendAnthropicSSE(c, schema.AnthropicStreamEvent{
Type: "content_block_start",
Index: currentBlockIndex,
ContentBlock: &schema.AnthropicContentBlock{
Type: "tool_use",
ID: fmt.Sprintf("toolu_%s_%d", id, i),
Name: tc.Name,
},
})
// Send input_json_delta with the arguments
sendAnthropicSSE(c, schema.AnthropicStreamEvent{
Type: "content_block_delta",
Index: currentBlockIndex,
Delta: &schema.AnthropicStreamDelta{
Type: "input_json_delta",
PartialJSON: tc.Arguments,
},
})
// Send content_block_stop
sendAnthropicSSE(c, schema.AnthropicStreamEvent{
Type: "content_block_stop",
Index: currentBlockIndex,
})
currentBlockIndex++
}
toolCallsEmitted = len(toolCalls)
return true
}
}
// Send regular text delta if not in tool call mode
if !inToolCall {
delta := schema.AnthropicStreamEvent{
Type: "content_block_delta",
Index: 0,
Delta: &schema.AnthropicStreamDelta{
Type: "text_delta",
Text: token,
},
}
sendAnthropicSSE(c, delta)
}
return true
}
predFunc, err := backend.ModelInference(
input.Context, predInput, openAIMessages, images, nil, nil, ml, cfg, nil, nil, tokenCallback, "", "", nil, nil, nil)
if err != nil {
xlog.Error("Anthropic stream model inference failed", "error", err)
return sendAnthropicError(c, 500, "api_error", fmt.Sprintf("model inference failed: %v", err))
}
prediction, err := predFunc()
if err != nil {
xlog.Error("Anthropic stream prediction failed", "error", err)
return sendAnthropicError(c, 500, "api_error", fmt.Sprintf("prediction failed: %v", err))
}
// Send content_block_stop event for last block if we didn't close it yet
if !inToolCall {
contentBlockStop := schema.AnthropicStreamEvent{
Type: "content_block_stop",
Index: 0,
}
sendAnthropicSSE(c, contentBlockStop)
}
// Determine stop reason
stopReason := "end_turn"
if toolCallsEmitted > 0 {
stopReason = "tool_use"
}
// Send message_delta event with stop_reason
messageDelta := schema.AnthropicStreamEvent{
Type: "message_delta",
Delta: &schema.AnthropicStreamDelta{
StopReason: &stopReason,
},
Usage: &schema.AnthropicUsage{
OutputTokens: prediction.Usage.Completion,
},
}
sendAnthropicSSE(c, messageDelta)
// Send message_stop event
messageStop := schema.AnthropicStreamEvent{
Type: "message_stop",
}
sendAnthropicSSE(c, messageStop)
return nil
}
func sendAnthropicSSE(c echo.Context, event schema.AnthropicStreamEvent) {
data, err := json.Marshal(event)
if err != nil {
xlog.Error("Failed to marshal SSE event", "error", err)
return
}
fmt.Fprintf(c.Response().Writer, "event: %s\ndata: %s\n\n", event.Type, string(data))
c.Response().Flush()
}
func sendAnthropicError(c echo.Context, statusCode int, errorType, message string) error {
resp := schema.AnthropicErrorResponse{
Type: "error",
Error: schema.AnthropicError{
Type: errorType,
Message: message,
},
}
return c.JSON(statusCode, resp)
}
func convertAnthropicToOpenAIMessages(input *schema.AnthropicRequest) []schema.Message {
var messages []schema.Message
// Add system message if present
if input.System != "" {
messages = append(messages, schema.Message{
Role: "system",
StringContent: input.System,
Content: input.System,
})
}
// Convert Anthropic messages to OpenAI format
for _, msg := range input.Messages {
openAIMsg := schema.Message{
Role: msg.Role,
}
// Handle content (can be string or array of content blocks)
switch content := msg.Content.(type) {
case string:
openAIMsg.StringContent = content
openAIMsg.Content = content
case []interface{}:
// Handle array of content blocks
var textContent string
var stringImages []string
var toolCalls []schema.ToolCall
toolCallIndex := 0
for _, block := range content {
if blockMap, ok := block.(map[string]interface{}); ok {
blockType, _ := blockMap["type"].(string)
switch blockType {
case "text":
if text, ok := blockMap["text"].(string); ok {
textContent += text
}
case "image":
// Handle image content
if source, ok := blockMap["source"].(map[string]interface{}); ok {
if sourceType, ok := source["type"].(string); ok && sourceType == "base64" {
if data, ok := source["data"].(string); ok {
mediaType, _ := source["media_type"].(string)
// Format as data URI
dataURI := fmt.Sprintf("data:%s;base64,%s", mediaType, data)
stringImages = append(stringImages, dataURI)
}
}
}
case "tool_use":
// Convert tool_use to ToolCall format
toolID, _ := blockMap["id"].(string)
toolName, _ := blockMap["name"].(string)
toolInput := blockMap["input"]
// Serialize input to JSON string
inputJSON, err := json.Marshal(toolInput)
if err != nil {
xlog.Warn("Failed to marshal tool input", "error", err)
inputJSON = []byte("{}")
}
toolCalls = append(toolCalls, schema.ToolCall{
Index: toolCallIndex,
ID: toolID,
Type: "function",
FunctionCall: schema.FunctionCall{
Name: toolName,
Arguments: string(inputJSON),
},
})
toolCallIndex++
case "tool_result":
// Convert tool_result to a message with role "tool"
// This is handled by creating a separate message after this block
// For now, we'll add it as text content
toolUseID, _ := blockMap["tool_use_id"].(string)
isError := false
if isErrorPtr, ok := blockMap["is_error"].(*bool); ok && isErrorPtr != nil {
isError = *isErrorPtr
}
var resultText string
if resultContent, ok := blockMap["content"]; ok {
switch rc := resultContent.(type) {
case string:
resultText = rc
case []interface{}:
// Array of content blocks
for _, cb := range rc {
if cbMap, ok := cb.(map[string]interface{}); ok {
if cbMap["type"] == "text" {
if text, ok := cbMap["text"].(string); ok {
resultText += text
}
}
}
}
}
}
// Add tool result as a tool role message
// We need to handle this differently - create a new message
if msg.Role == "user" {
// Store tool result info for creating separate message
prefix := ""
if isError {
prefix = "Error: "
}
textContent += fmt.Sprintf("\n[Tool Result for %s]: %s%s", toolUseID, prefix, resultText)
}
}
}
}
openAIMsg.StringContent = textContent
openAIMsg.Content = textContent
openAIMsg.StringImages = stringImages
// Add tool calls if present
if len(toolCalls) > 0 {
openAIMsg.ToolCalls = toolCalls
}
}
messages = append(messages, openAIMsg)
}
return messages
}
// convertAnthropicTools converts Anthropic tools to internal Functions format
func convertAnthropicTools(input *schema.AnthropicRequest, cfg *config.ModelConfig) (functions.Functions, bool) {
if len(input.Tools) == 0 {
return nil, false
}
var funcs functions.Functions
for _, tool := range input.Tools {
f := functions.Function{
Name: tool.Name,
Description: tool.Description,
Parameters: tool.InputSchema,
}
funcs = append(funcs, f)
}
// Handle tool_choice
if input.ToolChoice != nil {
switch tc := input.ToolChoice.(type) {
case string:
// "auto", "any", or "none"
if tc == "any" {
// Force the model to use one of the tools
cfg.SetFunctionCallString("required")
} else if tc == "none" {
// Don't use tools
return nil, false
}
// "auto" is the default - let model decide
case map[string]interface{}:
// Specific tool selection: {"type": "tool", "name": "tool_name"}
if tcType, ok := tc["type"].(string); ok && tcType == "tool" {
if name, ok := tc["name"].(string); ok {
// Force specific tool
cfg.SetFunctionCallString(name)
}
}
}
}
return funcs, len(funcs) > 0 && cfg.ShouldUseFunctions()
}

View File

@@ -65,9 +65,13 @@ func WelcomeEndpoint(appConfig *config.ApplicationConfig,
// The client expects a JSON response
return c.JSON(200, summary)
} else {
// 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)
// 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)
}
}
}

View File

@@ -3,7 +3,6 @@ package openai
import (
"encoding/json"
"fmt"
"strings"
"time"
"github.com/google/uuid"
@@ -35,54 +34,11 @@ func ChatEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator
Created: created,
Model: req.Model, // we have to return what the user sent here, due to OpenAI spec.
Choices: []schema.Choice{{Delta: &schema.Message{Role: "assistant"}, Index: 0, FinishReason: nil}},
Object: "chat.completion.chunk",
}
responses <- initialMessage
// Track accumulated content for reasoning extraction
accumulatedContent := ""
lastEmittedReasoning := ""
lastEmittedCleanedContent := ""
_, _, err := ComputeChoices(req, s, config, cl, startupOptions, loader, func(s string, c *[]schema.Choice) {}, func(s string, tokenUsage backend.TokenUsage) bool {
accumulatedContent += s
// Extract reasoning from accumulated content
currentReasoning, cleanedContent := functions.ExtractReasoning(accumulatedContent)
// Calculate new reasoning delta (what we haven't emitted yet)
var reasoningDelta *string
if currentReasoning != lastEmittedReasoning {
// Extract only the new part
if len(currentReasoning) > len(lastEmittedReasoning) && strings.HasPrefix(currentReasoning, lastEmittedReasoning) {
newReasoning := currentReasoning[len(lastEmittedReasoning):]
reasoningDelta = &newReasoning
lastEmittedReasoning = currentReasoning
} else if currentReasoning != "" {
// If reasoning changed in a non-append way, emit the full current reasoning
reasoningDelta = &currentReasoning
lastEmittedReasoning = currentReasoning
}
}
// Calculate content delta from cleaned content
var deltaContent string
if len(cleanedContent) > len(lastEmittedCleanedContent) && strings.HasPrefix(cleanedContent, lastEmittedCleanedContent) {
deltaContent = cleanedContent[len(lastEmittedCleanedContent):]
lastEmittedCleanedContent = cleanedContent
} else if cleanedContent != lastEmittedCleanedContent {
// If cleaned content changed but not in a simple append, extract delta from cleaned content
// This handles cases where thinking tags are removed mid-stream
if lastEmittedCleanedContent == "" {
deltaContent = cleanedContent
lastEmittedCleanedContent = cleanedContent
} else {
// Content changed in non-append way, use the new cleaned content
deltaContent = cleanedContent
lastEmittedCleanedContent = cleanedContent
}
}
// Only emit content if there's actual content (not just thinking tags)
// If deltaContent is empty, we still emit the response but with empty content
usage := schema.OpenAIUsage{
PromptTokens: tokenUsage.Prompt,
CompletionTokens: tokenUsage.Completion,
@@ -93,20 +49,11 @@ func ChatEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator
usage.TimingPromptProcessing = tokenUsage.TimingPromptProcessing
}
delta := &schema.Message{}
// Only include content if there's actual content (not just thinking tags)
if deltaContent != "" {
delta.Content = &deltaContent
}
if reasoningDelta != nil && *reasoningDelta != "" {
delta.Reasoning = reasoningDelta
}
resp := schema.OpenAIResponse{
ID: id,
Created: created,
Model: req.Model, // we have to return what the user sent here, due to OpenAI spec.
Choices: []schema.Choice{{Delta: delta, Index: 0, FinishReason: nil}},
Choices: []schema.Choice{{Delta: &schema.Message{Content: &s}, Index: 0, FinishReason: nil}},
Object: "chat.completion.chunk",
Usage: usage,
}
@@ -229,10 +176,6 @@ func ChatEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator
if err != nil {
return err
}
// Extract reasoning before processing tool calls
reasoning, cleanedResult := functions.ExtractReasoning(result)
result = cleanedResult
textContentToReturn = functions.ParseTextContent(result, config.FunctionsConfig)
result = functions.CleanupLLMResult(result, config.FunctionsConfig)
functionResults := functions.ParseFunctionCall(result, config.FunctionsConfig)
@@ -265,20 +208,11 @@ func ChatEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator
usage.TimingPromptProcessing = tokenUsage.TimingPromptProcessing
}
var deltaReasoning *string
if reasoning != "" {
deltaReasoning = &reasoning
}
delta := &schema.Message{Content: &result}
if deltaReasoning != nil {
delta.Reasoning = deltaReasoning
}
resp := schema.OpenAIResponse{
ID: id,
Created: created,
Model: req.Model, // we have to return what the user sent here, due to OpenAI spec.
Choices: []schema.Choice{{Delta: delta, Index: 0, FinishReason: nil}},
Choices: []schema.Choice{{Delta: &schema.Message{Content: &result}, Index: 0, FinishReason: nil}},
Object: "chat.completion.chunk",
Usage: usage,
}
@@ -619,18 +553,10 @@ func ChatEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator
default:
tokenCallback := func(s string, c *[]schema.Choice) {
// Extract reasoning from the response
reasoning, cleanedS := functions.ExtractReasoning(s)
s = cleanedS
if !shouldUseFn {
// no function is called, just reply and use stop as finish reason
stopReason := FinishReasonStop
message := &schema.Message{Role: "assistant", Content: &s}
if reasoning != "" {
message.Reasoning = &reasoning
}
*c = append(*c, schema.Choice{FinishReason: &stopReason, Index: 0, Message: message})
*c = append(*c, schema.Choice{FinishReason: &stopReason, Index: 0, Message: &schema.Message{Role: "assistant", Content: &s}})
return
}
@@ -649,13 +575,9 @@ func ChatEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator
}
stopReason := FinishReasonStop
message := &schema.Message{Role: "assistant", Content: &result}
if reasoning != "" {
message.Reasoning = &reasoning
}
*c = append(*c, schema.Choice{
FinishReason: &stopReason,
Message: message})
Message: &schema.Message{Role: "assistant", Content: &result}})
default:
toolCallsReason := FinishReasonToolCalls
toolChoice := schema.Choice{
@@ -664,9 +586,6 @@ func ChatEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator
Role: "assistant",
},
}
if reasoning != "" {
toolChoice.Message.Reasoning = &reasoning
}
for _, ss := range results {
name, args := ss.Name, ss.Arguments
@@ -687,20 +606,16 @@ func ChatEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator
} else {
// otherwise we return more choices directly (deprecated)
functionCallReason := FinishReasonFunctionCall
message := &schema.Message{
Role: "assistant",
Content: &textContentToReturn,
FunctionCall: map[string]interface{}{
"name": name,
"arguments": args,
},
}
if reasoning != "" {
message.Reasoning = &reasoning
}
*c = append(*c, schema.Choice{
FinishReason: &functionCallReason,
Message: message,
Message: &schema.Message{
Role: "assistant",
Content: &textContentToReturn,
FunctionCall: map[string]interface{}{
"name": name,
"arguments": args,
},
},
})
}
}

View File

@@ -1,108 +0,0 @@
package routes
import (
"context"
"fmt"
"net/http"
"github.com/google/uuid"
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/application"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/http/endpoints/anthropic"
"github.com/mudler/LocalAI/core/http/middleware"
"github.com/mudler/LocalAI/core/schema"
"github.com/mudler/xlog"
)
func RegisterAnthropicRoutes(app *echo.Echo,
re *middleware.RequestExtractor,
application *application.Application) {
// Anthropic Messages API endpoint
messagesHandler := anthropic.MessagesEndpoint(
application.ModelConfigLoader(),
application.ModelLoader(),
application.TemplatesEvaluator(),
application.ApplicationConfig(),
)
messagesMiddleware := []echo.MiddlewareFunc{
middleware.TraceMiddleware(application),
re.BuildFilteredFirstAvailableDefaultModel(config.BuildUsecaseFilterFn(config.FLAG_CHAT)),
re.SetModelAndConfig(func() schema.LocalAIRequest { return new(schema.AnthropicRequest) }),
setAnthropicRequestContext(application.ApplicationConfig()),
}
// Main Anthropic endpoint
app.POST("/v1/messages", messagesHandler, messagesMiddleware...)
// Also support without version prefix for compatibility
app.POST("/messages", messagesHandler, messagesMiddleware...)
}
// setAnthropicRequestContext sets up the context and cancel function for Anthropic requests
func setAnthropicRequestContext(appConfig *config.ApplicationConfig) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
input, ok := c.Get(middleware.CONTEXT_LOCALS_KEY_LOCALAI_REQUEST).(*schema.AnthropicRequest)
if !ok || input.Model == "" {
return echo.NewHTTPError(http.StatusBadRequest, "model is required")
}
cfg, ok := c.Get(middleware.CONTEXT_LOCALS_KEY_MODEL_CONFIG).(*config.ModelConfig)
if !ok || cfg == nil {
return echo.NewHTTPError(http.StatusBadRequest, "model configuration not found")
}
// Extract or generate the correlation ID
// Anthropic uses x-request-id header
correlationID := c.Request().Header.Get("x-request-id")
if correlationID == "" {
correlationID = uuid.New().String()
}
c.Response().Header().Set("x-request-id", correlationID)
// Set up context with cancellation
reqCtx := c.Request().Context()
c1, cancel := context.WithCancel(appConfig.Context)
// Cancel when request context is cancelled (client disconnects)
go func() {
select {
case <-reqCtx.Done():
cancel()
case <-c1.Done():
// Already cancelled
}
}()
// Add the correlation ID to the new context
ctxWithCorrelationID := context.WithValue(c1, middleware.CorrelationIDKey, correlationID)
input.Context = ctxWithCorrelationID
input.Cancel = cancel
if cfg.Model == "" {
xlog.Debug("replacing empty cfg.Model with input value", "input.Model", input.Model)
cfg.Model = input.Model
}
c.Set(middleware.CONTEXT_LOCALS_KEY_LOCALAI_REQUEST, input)
c.Set(middleware.CONTEXT_LOCALS_KEY_MODEL_CONFIG, cfg)
// Log the Anthropic API version if provided
anthropicVersion := c.Request().Header.Get("anthropic-version")
if anthropicVersion != "" {
xlog.Debug("Anthropic API version", "version", anthropicVersion)
}
// Validate max_tokens is provided
if input.MaxTokens <= 0 {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("max_tokens is required and must be greater than 0"))
}
return next(c)
}
}
}

View File

@@ -3,6 +3,7 @@ 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"
@@ -114,24 +115,208 @@ func RegisterUIRoutes(app *echo.Echo,
registerBackendGalleryRoutes(app, appConfig, galleryService, processingOps)
}
// Talk route - now served by SPA
app.GET("/talk", localai.WelcomeEndpoint(appConfig, cl, ml, processingOps))
app.GET("/talk", func(c echo.Context) error {
modelConfigs, _ := services.ListModels(cl, ml, config.NoFilterFn, services.SKIP_IF_CONFIGURED)
// Chat routes - now served by SPA
app.GET("/chat", 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))
}
// Show the Chat page with specific model
app.GET("/chat/:model", localai.WelcomeEndpoint(appConfig, cl, ml, processingOps))
summary := map[string]interface{}{
"Title": "LocalAI - Talk",
"BaseURL": middleware.BaseURL(c),
"ModelsConfig": modelConfigs,
"Model": modelConfigs[0],
// Text2Image routes - now served by SPA
app.GET("/text2image/:model", localai.WelcomeEndpoint(appConfig, cl, ml, processingOps))
"Version": internal.PrintableVersion(),
}
app.GET("/text2image", localai.WelcomeEndpoint(appConfig, cl, ml, processingOps))
// Render index
return c.Render(200, "views/talk", summary)
})
// TTS routes - now served by SPA
app.GET("/tts/:model", 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)
app.GET("/tts", 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)
})
// Traces UI
app.GET("/traces", func(c echo.Context) error {

View File

@@ -617,12 +617,6 @@ 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,
@@ -635,7 +629,6 @@ func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model
"totalPages": totalPages,
"prevPage": prevPage,
"nextPage": nextPage,
"systemCapability": detectedCapability,
})
})

View File

@@ -1368,7 +1368,6 @@ async function promptGPT(systemPrompt, input) {
let lastAssistantMessageIndex = -1;
let lastThinkingMessageIndex = -1;
let lastThinkingScrollTime = 0;
let hasReasoningFromAPI = false; // Track if we're receiving reasoning from API (skip tag-based detection)
const THINKING_SCROLL_THROTTLE = 200; // Throttle scrolling to every 200ms
try {
@@ -1402,24 +1401,19 @@ async function promptGPT(systemPrompt, input) {
// Handle different event types
switch (eventData.type) {
case "reasoning":
hasReasoningFromAPI = true; // Mark that we're receiving reasoning from API
if (eventData.content) {
const currentChat = chatStore.getChat(chatId);
if (!currentChat) break; // Chat was deleted
const isMCPMode = currentChat.mcpMode || false;
const shouldExpand = !isMCPMode; // Expanded in non-MCP mode, collapsed in MCP mode
// Insert thinking before assistant message if it exists (always use "thinking" role)
// Insert reasoning before assistant message if it exists
if (lastAssistantMessageIndex >= 0 && targetHistory[lastAssistantMessageIndex]?.role === "assistant") {
targetHistory.splice(lastAssistantMessageIndex, 0, {
role: "thinking",
role: "reasoning",
content: eventData.content,
html: DOMPurify.sanitize(marked.parse(eventData.content)),
image: [],
audio: [],
expanded: shouldExpand
expanded: false // Reasoning is always collapsed
});
lastAssistantMessageIndex++; // Adjust index since we inserted
// Scroll smoothly after adding thinking
// Scroll smoothly after adding reasoning
setTimeout(() => {
const chatContainer = document.getElementById('chat');
if (chatContainer) {
@@ -1431,7 +1425,7 @@ async function promptGPT(systemPrompt, input) {
}, 100);
} else {
// No assistant message yet, just add normally
chatStore.add("thinking", eventData.content, null, null, chatId);
chatStore.add("reasoning", eventData.content, null, null, chatId);
}
}
break;
@@ -1497,17 +1491,14 @@ async function promptGPT(systemPrompt, input) {
// Only update display if this is the active chat (interval will handle it)
// Don't call updateTokensPerSecond here to avoid unnecessary updates
// Only check for thinking tags if we're NOT receiving reasoning from API
// This prevents duplicate thinking/reasoning messages
if (!hasReasoningFromAPI) {
// Check for thinking tags in the chunk (incremental detection)
if (contentChunk.includes("<thinking>") || contentChunk.includes("<think>")) {
isThinking = true;
thinkingContent = "";
lastThinkingMessageIndex = -1;
}
if (contentChunk.includes("</thinking>") || contentChunk.includes("</think>")) {
// Check for thinking tags in the chunk (incremental detection)
if (contentChunk.includes("<thinking>") || contentChunk.includes("<think>")) {
isThinking = true;
thinkingContent = "";
lastThinkingMessageIndex = -1;
}
if (contentChunk.includes("</thinking>") || contentChunk.includes("</think>")) {
isThinking = false;
// When closing tag is detected, process the accumulated thinking content
if (thinkingContent.trim()) {
@@ -1561,11 +1552,10 @@ async function promptGPT(systemPrompt, input) {
}
thinkingContent = "";
}
}
}
// Handle content based on thinking state (only if not receiving reasoning from API)
if (!hasReasoningFromAPI && isThinking) {
// Handle content based on thinking state
if (isThinking) {
thinkingContent += contentChunk;
const currentChat = chatStore.getChat(chatId);
if (!currentChat) break; // Chat was deleted
@@ -1647,10 +1637,7 @@ async function promptGPT(systemPrompt, input) {
// Process any thinking tags that might be in the accumulated content
// This handles cases where tags are split across chunks
// Only process if we're NOT receiving reasoning from API (to avoid duplicates)
const { regularContent: processedRegular, thinkingContent: processedThinking } = hasReasoningFromAPI
? { regularContent: regularContent, thinkingContent: "" }
: processThinkingTags(regularContent);
const { regularContent: processedRegular, thinkingContent: processedThinking } = processThinkingTags(regularContent);
// Update or create assistant message with processed regular content
const currentChat = chatStore.getChat(chatId);
@@ -1658,10 +1645,10 @@ async function promptGPT(systemPrompt, input) {
const request = activeRequests.get(chatId);
const requestModel = request?.model || null;
if (lastAssistantMessageIndex === -1) {
// Create assistant message if we have any content (even if empty string after processing)
// This ensures the message is created and can be updated with more content later
chatStore.add("assistant", processedRegular || "", null, null, chatId, requestModel);
lastAssistantMessageIndex = targetHistory.length - 1;
if (processedRegular && processedRegular.trim()) {
chatStore.add("assistant", processedRegular, null, null, chatId, requestModel);
lastAssistantMessageIndex = targetHistory.length - 1;
}
} else {
const lastMessage = targetHistory[lastAssistantMessageIndex];
if (lastMessage && lastMessage.role === "assistant") {
@@ -1699,10 +1686,7 @@ async function promptGPT(systemPrompt, input) {
if (assistantContentBuffer.length > 0) {
const regularContent = assistantContentBuffer.join("");
// Process any remaining thinking tags that might be in the buffer
// Only process if we're NOT receiving reasoning from API (to avoid duplicates)
const { regularContent: processedRegular, thinkingContent: processedThinking } = hasReasoningFromAPI
? { regularContent: regularContent, thinkingContent: "" }
: processThinkingTags(regularContent);
const { regularContent: processedRegular, thinkingContent: processedThinking } = processThinkingTags(regularContent);
const currentChat = chatStore.getChat(chatId);
if (!currentChat) {
@@ -1735,26 +1719,23 @@ async function promptGPT(systemPrompt, input) {
}
// Then update or create assistant message
// Always create/update assistant message if we have any content
if (lastAssistantMessageIndex !== -1) {
const lastMessage = targetHistory[lastAssistantMessageIndex];
if (lastMessage && lastMessage.role === "assistant") {
lastMessage.content = (lastMessage.content || "") + (processedRegular || "");
lastMessage.html = DOMPurify.sanitize(marked.parse(lastMessage.content));
}
} else {
// Create assistant message (even if empty, so it can be updated with more content)
} else if (processedRegular && processedRegular.trim()) {
const request = activeRequests.get(chatId);
const requestModel = request?.model || null;
chatStore.add("assistant", processedRegular || "", null, null, chatId, requestModel);
chatStore.add("assistant", processedRegular, null, null, chatId, requestModel);
lastAssistantMessageIndex = targetHistory.length - 1;
}
}
// Final thinking content flush if any data remains (from incremental detection)
// Only process if we're NOT receiving reasoning from API (to avoid duplicates)
const finalChat = chatStore.getChat(chatId);
if (finalChat && !hasReasoningFromAPI && thinkingContent.trim() && lastThinkingMessageIndex === -1) {
if (finalChat && thinkingContent.trim() && lastThinkingMessageIndex === -1) {
const finalHistory = finalChat.history;
// Extract thinking content if tags are present
const thinkingMatch = thinkingContent.match(/<(?:thinking|redacted_reasoning)>(.*?)<\/(?:thinking|redacted_reasoning)>/s);
@@ -1910,13 +1891,9 @@ async function promptGPT(systemPrompt, input) {
let buffer = "";
let contentBuffer = [];
let thinkingContent = "";
let reasoningContent = ""; // Track reasoning from API reasoning field
let isThinking = false;
let lastThinkingMessageIndex = -1;
let lastReasoningMessageIndex = -1; // Track reasoning message separately
let lastAssistantMessageIndex = -1; // Track assistant message for reasoning placement
let lastThinkingScrollTime = 0;
let hasReasoningFromAPI = false; // Track if we're receiving reasoning from API (skip tag-based detection)
const THINKING_SCROLL_THROTTLE = 200; // Throttle scrolling to every 200ms
try {
@@ -1952,100 +1929,30 @@ async function promptGPT(systemPrompt, input) {
chatStore.updateTokenUsage(jsonData.usage, chatId);
}
const token = jsonData.choices?.[0]?.delta?.content;
const reasoningDelta = jsonData.choices?.[0]?.delta?.reasoning;
const token = jsonData.choices[0].delta.content;
// Handle reasoning from API reasoning field - always use "thinking" role
if (reasoningDelta && reasoningDelta.trim() !== "") {
hasReasoningFromAPI = true; // Mark that we're receiving reasoning from API
reasoningContent += reasoningDelta;
const currentChat = chatStore.getChat(chatId);
if (!currentChat) {
// Chat was deleted, skip this line
if (token) {
// Check for thinking tags
if (token.includes("<thinking>") || token.includes("<think>")) {
isThinking = true;
thinkingContent = "";
lastThinkingMessageIndex = -1;
return;
}
const isMCPMode = currentChat.mcpMode || false;
const shouldExpand = !isMCPMode; // Expanded in non-MCP mode, collapsed in MCP mode
// Only create/update thinking message if we have actual content
if (reasoningContent.trim() !== "") {
// Update or create thinking message (always use "thinking" role, not "reasoning")
if (lastReasoningMessageIndex === -1) {
// Find the last assistant message index to insert thinking before it
const targetHistory = currentChat.history;
const assistantIndex = targetHistory.length - 1;
if (assistantIndex >= 0 && targetHistory[assistantIndex]?.role === "assistant") {
// Insert thinking before assistant message
targetHistory.splice(assistantIndex, 0, {
role: "thinking",
content: reasoningContent,
html: DOMPurify.sanitize(marked.parse(reasoningContent)),
image: [],
audio: [],
expanded: shouldExpand
});
lastReasoningMessageIndex = assistantIndex;
lastAssistantMessageIndex = assistantIndex + 1; // Adjust for inserted thinking
} else {
// No assistant message yet, just add normally
chatStore.add("thinking", reasoningContent, null, null, chatId);
lastReasoningMessageIndex = currentChat.history.length - 1;
}
} else {
// Update existing thinking message
const targetHistory = currentChat.history;
if (lastReasoningMessageIndex >= 0 && lastReasoningMessageIndex < targetHistory.length) {
const thinkingMessage = targetHistory[lastReasoningMessageIndex];
if (thinkingMessage && thinkingMessage.role === "thinking") {
thinkingMessage.content = reasoningContent;
thinkingMessage.html = DOMPurify.sanitize(marked.parse(reasoningContent));
}
if (token.includes("</thinking>") || token.includes("</think>")) {
isThinking = false;
if (thinkingContent.trim()) {
// Only add the final thinking message if we don't already have one
if (lastThinkingMessageIndex === -1) {
chatStore.add("thinking", thinkingContent, null, null, chatId);
}
}
return;
}
// Scroll when reasoning is updated (throttled)
const now = Date.now();
if (now - lastThinkingScrollTime > THINKING_SCROLL_THROTTLE) {
lastThinkingScrollTime = now;
setTimeout(() => {
const chatContainer = document.getElementById('chat');
if (chatContainer) {
chatContainer.scrollTo({
top: chatContainer.scrollHeight,
behavior: 'smooth'
});
}
scrollThinkingBoxToBottom();
}, 100);
}
}
if (token && token.trim() !== "") {
// Only check for thinking tags if we're NOT receiving reasoning from API
// This prevents duplicate thinking/reasoning messages
if (!hasReasoningFromAPI) {
// Check for thinking tags (legacy support - models that output tags directly)
if (token.includes("<thinking>") || token.includes("<think>")) {
isThinking = true;
thinkingContent = "";
lastThinkingMessageIndex = -1;
return;
}
if (token.includes("</thinking>") || token.includes("</think>")) {
isThinking = false;
if (thinkingContent.trim()) {
// Only add the final thinking message if we don't already have one
if (lastThinkingMessageIndex === -1) {
chatStore.add("thinking", thinkingContent, null, null, chatId);
}
}
return;
}
// Handle content based on thinking state
if (isThinking) {
thinkingContent += token;
// Handle content based on thinking state
if (isThinking) {
thinkingContent += token;
// Count tokens for rate calculation (per chat)
const request = activeRequests.get(chatId);
if (request) {
@@ -2088,42 +1995,7 @@ async function promptGPT(systemPrompt, input) {
}, 100);
}
} else {
// Not in thinking state, add to content buffer
contentBuffer.push(token);
// Track assistant message index for reasoning placement
if (lastAssistantMessageIndex === -1) {
const currentChat = chatStore.getChat(chatId);
if (currentChat) {
const targetHistory = currentChat.history;
// Find or create assistant message index
for (let i = targetHistory.length - 1; i >= 0; i--) {
if (targetHistory[i].role === "assistant") {
lastAssistantMessageIndex = i;
break;
}
}
// If no assistant message yet, it will be created when we flush contentBuffer
}
}
}
} else {
// Receiving reasoning from API, just add token to content buffer
contentBuffer.push(token);
// Track assistant message index for reasoning placement
if (lastAssistantMessageIndex === -1) {
const currentChat = chatStore.getChat(chatId);
if (currentChat) {
const targetHistory = currentChat.history;
// Find or create assistant message index
for (let i = targetHistory.length - 1; i >= 0; i--) {
if (targetHistory[i].role === "assistant") {
lastAssistantMessageIndex = i;
break;
}
}
// If no assistant message yet, it will be created when we flush contentBuffer
}
}
}
}
} catch (error) {
@@ -2135,17 +2007,6 @@ async function promptGPT(systemPrompt, input) {
// Efficiently update the chat in batch
if (contentBuffer.length > 0) {
addToChat(contentBuffer.join(""));
// Update assistant message index after adding content
const currentChat = chatStore.getChat(chatId);
if (currentChat) {
const targetHistory = currentChat.history;
for (let i = targetHistory.length - 1; i >= 0; i--) {
if (targetHistory[i].role === "assistant") {
lastAssistantMessageIndex = i;
break;
}
}
}
contentBuffer = [];
// Scroll when assistant content is updated (this will also show thinking messages above)
setTimeout(() => {
@@ -2164,30 +2025,7 @@ async function promptGPT(systemPrompt, input) {
if (contentBuffer.length > 0) {
addToChat(contentBuffer.join(""));
}
// Final reasoning flush if any data remains - always use "thinking" role
const finalChat = chatStore.getChat(chatId);
if (finalChat && reasoningContent.trim() && lastReasoningMessageIndex === -1) {
const isMCPMode = finalChat.mcpMode || false;
const shouldExpand = !isMCPMode;
const targetHistory = finalChat.history;
// Find assistant message to insert before
const assistantIndex = targetHistory.length - 1;
if (assistantIndex >= 0 && targetHistory[assistantIndex]?.role === "assistant") {
targetHistory.splice(assistantIndex, 0, {
role: "thinking",
content: reasoningContent,
html: DOMPurify.sanitize(marked.parse(reasoningContent)),
image: [],
audio: [],
expanded: shouldExpand
});
} else {
chatStore.add("thinking", reasoningContent, null, null, chatId);
}
}
// Final thinking content flush (legacy tag-based thinking)
if (finalChat && thinkingContent.trim() && lastThinkingMessageIndex === -1) {
chatStore.add("thinking", thinkingContent, null, null, chatId);
}

View File

@@ -1,411 +0,0 @@
/**
* 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;

View File

@@ -1,148 +0,0 @@
/**
* 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;

View File

@@ -54,11 +54,6 @@
<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>
@@ -593,7 +588,6 @@ function backendsGallery() {
totalPages: 1,
availableBackends: 0,
installedBackends: 0,
systemCapability: '',
selectedBackend: null,
jobProgress: {},
notifications: [],
@@ -689,7 +683,6 @@ 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 {

View File

@@ -41,7 +41,7 @@ SOFTWARE.
__chatContextSize = {{ .ContextSize }};
{{ end }}
// Store gallery configs for header icon display and model info modal
// Store gallery configs for header icon display
window.__galleryConfigs = {};
{{ $allGalleryConfigs:=.GalleryConfig }}
{{ range $modelName, $galleryConfig := $allGalleryConfigs }}
@@ -49,16 +49,6 @@ SOFTWARE.
{{ 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 }}
// Function to initialize store
@@ -336,10 +326,10 @@ SOFTWARE.
c += DOMPurify.sanitize(marked.parse(line));
});
}
// Set expanded state: thinking and reasoning are expanded by default in non-MCP mode, collapsed in MCP mode
// tool_call and tool_result are always collapsed by default
// Set expanded state: thinking is expanded by default in non-MCP mode, collapsed in MCP mode
// Reasoning, tool_call, and tool_result are always collapsed by default
const isMCPMode = chat.mcpMode || false;
const shouldExpand = ((role === "thinking" || role === "reasoning") && !isMCPMode) || false;
const shouldExpand = (role === "thinking" && !isMCPMode) || false;
chat.history.push({ role, content, html: c, image, audio, expanded: shouldExpand, model: messageModel });
// Auto-name chat from first user message
@@ -507,11 +497,6 @@ SOFTWARE.
activeChat.model = modelName;
activeChat.updatedAt = Date.now();
// Update model info modal with new model
if (window.updateModelInfoModal) {
window.updateModelInfoModal(modelName);
}
// Get context size from data attribute
let contextSize = null;
if (selectedOption.dataset.contextSize) {
@@ -551,23 +536,18 @@ SOFTWARE.
}
// Update model selector to reflect the change (ensure it stays in sync)
// Note: We don't dispatch a change event here to avoid infinite loop
// The selector is already updated via user interaction or programmatic change
const modelSelector = document.getElementById('modelSelector');
if (modelSelector) {
// Find and select the option matching the model
const optionValue = 'chat/' + modelName;
for (let i = 0; i < modelSelector.options.length; i++) {
if (modelSelector.options[i].value === optionValue) {
// Only update if it's different to avoid unnecessary updates
if (modelSelector.selectedIndex !== i) {
modelSelector.selectedIndex = i;
}
modelSelector.selectedIndex = i;
break;
}
}
// Don't dispatch change event here - it would cause infinite recursion
// The selector is already in sync with the model
// Trigger Alpine reactivity by dispatching change event
modelSelector.dispatchEvent(new Event('change', { bubbles: true }));
}
// Trigger MCP availability check in Alpine component
@@ -623,52 +603,27 @@ SOFTWARE.
<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 class="flex items-center gap-1 flex-shrink-0">
<!-- Info button - reactive to active chat model -->
<template x-if="$store.chat.activeChat() && $store.chat.activeChat().model && window.__galleryConfigs && window.__galleryConfigs[$store.chat.activeChat().model]">
<button
data-twe-ripple-init
data-twe-ripple-color="light"
class="text-[var(--color-text-secondary)] hover:text-[var(--color-primary)] transition-colors text-xs p-1 rounded hover:bg-[var(--color-bg-primary)]"
data-modal-target="model-info-modal"
data-modal-toggle="model-info-modal"
:data-model-name="$store.chat.activeChat().model"
@click="if (window.updateModelInfoModal) { window.updateModelInfoModal($store.chat.activeChat().model, true); }"
title="Model Information">
<i class="fas fa-info-circle"></i>
</button>
</template>
<!-- Fallback info button for initial model from server -->
<template x-if="(!$store.chat.activeChat() || !$store.chat.activeChat().model) && window.__galleryConfigs && window.__galleryConfigs['{{$model}}']">
<button
data-twe-ripple-init
data-twe-ripple-color="light"
class="text-[var(--color-text-secondary)] hover:text-[var(--color-primary)] transition-colors text-xs p-1 rounded hover:bg-[var(--color-bg-primary)]"
data-modal-target="model-info-modal"
data-modal-toggle="model-info-modal"
data-model-name="{{$model}}"
@click="if (window.updateModelInfoModal) { window.updateModelInfoModal('{{$model}}', true); }"
title="Model Information">
<i class="fas fa-info-circle"></i>
</button>
</template>
<!-- Edit button - reactive to active chat model -->
<template x-if="$store.chat.activeChat() && $store.chat.activeChat().model">
<a :href="'/models/edit/' + $store.chat.activeChat().model"
class="text-[var(--color-text-secondary)] hover:text-[var(--color-warning)] transition-colors text-xs p-1 rounded hover:bg-[var(--color-bg-primary)]"
title="Edit Model Configuration">
<i class="fas fa-edit"></i>
</a>
</template>
<!-- Fallback edit button for initial model from server -->
<template x-if="!$store.chat.activeChat() || !$store.chat.activeChat().model">
{{ if $model }}
<a href="/models/edit/{{$model}}"
class="text-[var(--color-text-secondary)] hover:text-[var(--color-warning)] transition-colors text-xs p-1 rounded hover:bg-[var(--color-bg-primary)]"
title="Edit Model Configuration">
<i class="fas fa-edit"></i>
</a>
{{ end }}
</template>
{{ if $model }}
{{ $galleryConfig:= index $allGalleryConfigs $model}}
{{ if $galleryConfig }}
<button
data-twe-ripple-init
data-twe-ripple-color="light"
class="text-[var(--color-text-secondary)] hover:text-[var(--color-primary)] transition-colors text-xs p-1 rounded hover:bg-[var(--color-bg-primary)]"
data-modal-target="model-info-modal"
data-modal-toggle="model-info-modal"
title="Model Information">
<i class="fas fa-info-circle"></i>
</button>
{{ end }}
{{ end }}
{{ if $model }}
<a href="/models/edit/{{$model}}"
class="text-[var(--color-text-secondary)] hover:text-[var(--color-warning)] transition-colors text-xs p-1 rounded hover:bg-[var(--color-bg-primary)]"
title="Edit Model Configuration">
<i class="fas fa-edit"></i>
</a>
{{ end }}
</div>
</div>
<select
@@ -1533,14 +1488,17 @@ SOFTWARE.
</div>
</div>
<!-- Modal moved outside of sidebar to appear in center of page - Always available, content updated dynamically -->
<div id="model-info-modal" tabindex="-1" aria-hidden="true" class="hidden overflow-y-auto overflow-x-hidden fixed top-0 right-0 left-0 z-50 flex justify-center items-center w-full h-full md:inset-0 max-h-full" style="padding: 1rem;">
<!-- Modal moved outside of sidebar to appear in center of page -->
{{ if $model }}
{{ $galleryConfig:= index $allGalleryConfigs $model}}
{{ if $galleryConfig }}
<div id="model-info-modal" tabindex="-1" aria-hidden="true" class="hidden overflow-y-auto overflow-x-hidden fixed top-0 right-0 left-0 z-50 flex justify-center items-center w-full md:inset-0 h-[calc(100%-1rem)] max-h-full">
<div class="relative p-4 w-full max-w-2xl max-h-full">
<div class="relative p-4 w-full max-w-2xl max-h-full bg-white rounded-lg shadow dark:bg-gray-700">
<!-- Header -->
<div class="flex items-center justify-between p-4 md:p-5 border-b rounded-t dark:border-gray-600">
<h3 id="model-info-modal-title" class="text-xl font-semibold text-gray-900 dark:text-white">{{ if $model }}{{ $model }}{{ end }}</h3>
<button class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 ms-auto inline-flex justify-center items-center dark:hover:bg-gray-600 dark:hover:text-white" data-modal-hide="model-info-modal" @click="if (window.closeModelInfoModal) { window.closeModelInfoModal(); }">
<h3 class="text-xl font-semibold text-gray-900 dark:text-white">{{ $model }}</h3>
<button class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 ms-auto inline-flex justify-center items-center dark:hover:bg-gray-600 dark:hover:text-white" data-modal-hide="model-info-modal">
<svg class="w-3 h-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6"/>
</svg>
@@ -1551,24 +1509,29 @@ SOFTWARE.
<!-- Body -->
<div class="p-4 md:p-5 space-y-4">
<div class="flex justify-center items-center">
<img id="model-info-modal-icon" class="lazy rounded-t-lg max-h-48 max-w-96 object-cover mt-3 entered loaded" style="display: none;" loading="lazy"/>
{{ if $galleryConfig.Icon }}<img class="lazy rounded-t-lg max-h-48 max-w-96 object-cover mt-3 entered loaded" src="{{$galleryConfig.Icon}}" loading="lazy"/>{{end}}
</div>
<div id="model-info-description" class="text-base leading-relaxed text-gray-500 dark:text-gray-400 break-words max-w-full"></div>
<div id="model-info-description" class="text-base leading-relaxed text-gray-500 dark:text-gray-400 break-words max-w-full">{{ $galleryConfig.Description }}</div>
<hr>
<p class="text-sm font-semibold text-gray-900 dark:text-white">Links</p>
<ul id="model-info-links">
<ul>
{{range $galleryConfig.URLs}}
<li><a href="{{ . }}" target="_blank">{{ . }}</a></li>
{{end}}
</ul>
</div>
<!-- Footer -->
<div class="flex items-center p-4 md:p-5 border-t border-gray-200 rounded-b dark:border-gray-600">
<button data-modal-hide="model-info-modal" class="py-2.5 px-5 ms-3 text-sm font-medium text-gray-900 focus:outline-none bg-white rounded-lg border border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-4 focus:ring-gray-100 dark:focus:ring-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700" @click="if (window.closeModelInfoModal) { window.closeModelInfoModal(); }">
<button data-modal-hide="model-info-modal" class="py-2.5 px-5 ms-3 text-sm font-medium text-gray-900 focus:outline-none bg-white rounded-lg border border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-4 focus:ring-gray-100 dark:focus:ring-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700">
Close
</button>
</div>
</div>
</div>
</div>
{{ end }}
{{ end }}
<!-- Alpine store initialization and utilities -->
<script>
@@ -1779,20 +1742,10 @@ SOFTWARE.
});
// Also listen for click events on modal toggle buttons
// Use event delegation to handle dynamically created buttons
document.addEventListener('click', (e) => {
const button = e.target.closest('[data-modal-toggle="model-info-modal"]');
if (button) {
// Update modal with current model before showing
if (window.Alpine && window.Alpine.store("chat")) {
const activeChat = window.Alpine.store("chat").activeChat();
const modelName = activeChat ? activeChat.model : (button.dataset.modelName || (document.getElementById("chat-model") ? document.getElementById("chat-model").value : null));
if (modelName && window.updateModelInfoModal) {
window.updateModelInfoModal(modelName, true);
}
}
document.querySelectorAll('[data-modal-toggle="model-info-modal"]').forEach(button => {
button.addEventListener('click', () => {
setTimeout(processMarkdown, 300);
}
});
});
// Process on initial load if libraries are ready
@@ -1833,176 +1786,12 @@ SOFTWARE.
syncModelSelectorOnLoad();
}
// Function to update model info modal with current model
// Set openModal to true to actually open the modal, false to just update content
window.updateModelInfoModal = function(modelName, openModal = false) {
if (!modelName) {
return;
}
if (!window.__galleryConfigs) {
return;
}
const galleryConfig = window.__galleryConfigs[modelName];
// Check if galleryConfig exists and has at least one property
if (!galleryConfig || Object.keys(galleryConfig).length === 0) {
// Still update the modal title even if no config, so user can see which model they clicked
const titleEl = document.getElementById('model-info-modal-title');
if (titleEl) {
titleEl.textContent = modelName;
}
// Show message that no info is available
const descEl = document.getElementById('model-info-description');
if (descEl) {
descEl.textContent = 'No additional information available for this model.';
}
const linksEl = document.getElementById('model-info-links');
if (linksEl) {
linksEl.innerHTML = '';
}
const iconEl = document.getElementById('model-info-modal-icon');
if (iconEl) {
iconEl.style.display = 'none';
}
// Only open the modal if explicitly requested
if (openModal) {
const modalElement = document.getElementById('model-info-modal');
if (modalElement) {
modalElement.classList.remove('hidden');
modalElement.setAttribute('aria-hidden', 'false');
// Add backdrop
let backdrop = document.querySelector('.modal-backdrop');
if (!backdrop) {
backdrop = document.createElement('div');
backdrop.className = 'modal-backdrop fixed inset-0 bg-gray-900 bg-opacity-50 dark:bg-opacity-80 z-40';
document.body.appendChild(backdrop);
backdrop.addEventListener('click', () => {
closeModelInfoModal();
});
}
}
}
return;
}
// Update modal title
const titleEl = document.getElementById('model-info-modal-title');
if (titleEl) {
titleEl.textContent = modelName;
}
// Update icon
const iconEl = document.getElementById('model-info-modal-icon');
if (iconEl) {
if (galleryConfig.Icon) {
iconEl.src = galleryConfig.Icon;
iconEl.style.display = 'block';
} else {
iconEl.style.display = 'none';
}
}
// Update description
const descEl = document.getElementById('model-info-description');
if (descEl) {
descEl.textContent = galleryConfig.Description || 'No description available.';
}
// Update links
const linksEl = document.getElementById('model-info-links');
if (linksEl && galleryConfig.URLs && Array.isArray(galleryConfig.URLs) && galleryConfig.URLs.length > 0) {
linksEl.innerHTML = '';
galleryConfig.URLs.forEach(url => {
const li = document.createElement('li');
const a = document.createElement('a');
a.href = url;
a.target = '_blank';
a.textContent = url;
li.appendChild(a);
linksEl.appendChild(li);
});
} else if (linksEl) {
linksEl.innerHTML = '<li>No links available</li>';
}
// Only open the modal if explicitly requested
if (openModal) {
const modalElement = document.getElementById('model-info-modal');
if (modalElement) {
// Ensure positioning classes are present (they might have been removed)
if (!modalElement.classList.contains('flex')) {
modalElement.classList.add('flex');
}
if (!modalElement.classList.contains('justify-center')) {
modalElement.classList.add('justify-center');
}
if (!modalElement.classList.contains('items-center')) {
modalElement.classList.add('items-center');
}
// Ensure fixed positioning
if (!modalElement.classList.contains('fixed')) {
modalElement.classList.add('fixed');
}
// Ensure full width and height
if (!modalElement.classList.contains('w-full')) {
modalElement.classList.add('w-full');
}
if (!modalElement.classList.contains('h-full')) {
modalElement.classList.add('h-full');
}
// Ensure padding is set
if (!modalElement.style.padding) {
modalElement.style.padding = '1rem';
}
// Remove hidden class if present
modalElement.classList.remove('hidden');
// Set aria-hidden to false
modalElement.setAttribute('aria-hidden', 'false');
// Add backdrop if needed
let backdrop = document.querySelector('.modal-backdrop');
if (!backdrop) {
backdrop = document.createElement('div');
backdrop.className = 'modal-backdrop fixed inset-0 bg-gray-900 bg-opacity-50 dark:bg-opacity-80 z-40';
document.body.appendChild(backdrop);
backdrop.addEventListener('click', () => {
window.closeModelInfoModal();
});
}
}
}
};
// Function to close the model info modal
window.closeModelInfoModal = function() {
const modalElement = document.getElementById('model-info-modal');
if (modalElement) {
modalElement.classList.add('hidden');
modalElement.setAttribute('aria-hidden', 'true');
}
const backdrop = document.querySelector('.modal-backdrop');
if (backdrop) {
backdrop.remove();
}
};
// Also sync after Alpine initializes (in case it runs after DOMContentLoaded)
function initializeModelInfo() {
syncModelSelectorOnLoad();
// Initialize model info modal content with current model (but don't open it)
if (window.updateModelInfoModal && window.Alpine && window.Alpine.store("chat")) {
const activeChat = window.Alpine.store("chat").activeChat();
const modelName = activeChat ? activeChat.model : (document.getElementById("chat-model") ? document.getElementById("chat-model").value : null);
if (modelName) {
window.updateModelInfoModal(modelName, false); // false = don't open, just update content
}
}
}
if (window.Alpine) {
Alpine.nextTick(initializeModelInfo);
Alpine.nextTick(syncModelSelectorOnLoad);
} else {
document.addEventListener('alpine:init', () => {
Alpine.nextTick(initializeModelInfo);
Alpine.nextTick(syncModelSelectorOnLoad);
});
}
</script>

View File

@@ -1,154 +0,0 @@
<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>

View File

@@ -1,565 +0,0 @@
<!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>

View File

@@ -1,221 +0,0 @@
<!-- 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>

View File

@@ -1,273 +0,0 @@
<!-- 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>

View File

@@ -1,329 +0,0 @@
<!-- 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>

View File

@@ -1,322 +0,0 @@
<!-- 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>

View File

@@ -1,229 +0,0 @@
<!-- 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>

View File

@@ -1,155 +0,0 @@
<!-- 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>

View File

@@ -1,138 +0,0 @@
<!-- 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>

View File

@@ -1,176 +0,0 @@
package schema
import (
"context"
"encoding/json"
)
// AnthropicRequest represents a request to the Anthropic Messages API
// https://docs.anthropic.com/claude/reference/messages_post
type AnthropicRequest struct {
Model string `json:"model"`
Messages []AnthropicMessage `json:"messages"`
MaxTokens int `json:"max_tokens"`
Metadata map[string]string `json:"metadata,omitempty"`
StopSequences []string `json:"stop_sequences,omitempty"`
Stream bool `json:"stream,omitempty"`
System string `json:"system,omitempty"`
Temperature *float64 `json:"temperature,omitempty"`
TopK *int `json:"top_k,omitempty"`
TopP *float64 `json:"top_p,omitempty"`
Tools []AnthropicTool `json:"tools,omitempty"`
ToolChoice interface{} `json:"tool_choice,omitempty"`
// Internal fields for request handling
Context context.Context `json:"-"`
Cancel context.CancelFunc `json:"-"`
}
// ModelName implements the LocalAIRequest interface
func (ar *AnthropicRequest) ModelName(s *string) string {
if s != nil {
ar.Model = *s
}
return ar.Model
}
// AnthropicTool represents a tool definition in the Anthropic format
type AnthropicTool struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
InputSchema map[string]interface{} `json:"input_schema"`
}
// AnthropicMessage represents a message in the Anthropic format
type AnthropicMessage struct {
Role string `json:"role"`
Content interface{} `json:"content"`
}
// AnthropicContentBlock represents a content block in an Anthropic message
type AnthropicContentBlock struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
Source *AnthropicImageSource `json:"source,omitempty"`
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Input map[string]interface{} `json:"input,omitempty"`
ToolUseID string `json:"tool_use_id,omitempty"`
Content interface{} `json:"content,omitempty"`
IsError *bool `json:"is_error,omitempty"`
}
// AnthropicImageSource represents an image source in Anthropic format
type AnthropicImageSource struct {
Type string `json:"type"`
MediaType string `json:"media_type"`
Data string `json:"data"`
}
// AnthropicResponse represents a response from the Anthropic Messages API
type AnthropicResponse struct {
ID string `json:"id"`
Type string `json:"type"`
Role string `json:"role"`
Content []AnthropicContentBlock `json:"content"`
Model string `json:"model"`
StopReason *string `json:"stop_reason"`
StopSequence *string `json:"stop_sequence,omitempty"`
Usage AnthropicUsage `json:"usage"`
}
// AnthropicUsage represents token usage in Anthropic format
type AnthropicUsage struct {
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
}
// AnthropicStreamEvent represents a streaming event from the Anthropic API
type AnthropicStreamEvent struct {
Type string `json:"type"`
Index int `json:"index,omitempty"`
ContentBlock *AnthropicContentBlock `json:"content_block,omitempty"`
Delta *AnthropicStreamDelta `json:"delta,omitempty"`
Message *AnthropicStreamMessage `json:"message,omitempty"`
Usage *AnthropicUsage `json:"usage,omitempty"`
}
// AnthropicStreamDelta represents the delta in a streaming response
type AnthropicStreamDelta struct {
Type string `json:"type,omitempty"`
Text string `json:"text,omitempty"`
PartialJSON string `json:"partial_json,omitempty"`
StopReason *string `json:"stop_reason,omitempty"`
StopSequence *string `json:"stop_sequence,omitempty"`
}
// AnthropicStreamMessage represents the message object in streaming events
type AnthropicStreamMessage struct {
ID string `json:"id"`
Type string `json:"type"`
Role string `json:"role"`
Content []AnthropicContentBlock `json:"content"`
Model string `json:"model"`
StopReason *string `json:"stop_reason"`
StopSequence *string `json:"stop_sequence,omitempty"`
Usage AnthropicUsage `json:"usage"`
}
// AnthropicErrorResponse represents an error response from the Anthropic API
type AnthropicErrorResponse struct {
Type string `json:"type"`
Error AnthropicError `json:"error"`
}
// AnthropicError represents an error in the Anthropic format
type AnthropicError struct {
Type string `json:"type"`
Message string `json:"message"`
}
// GetStringContent extracts the string content from an AnthropicMessage
// Content can be either a string or an array of content blocks
func (m *AnthropicMessage) GetStringContent() string {
switch content := m.Content.(type) {
case string:
return content
case []interface{}:
var result string
for _, block := range content {
if blockMap, ok := block.(map[string]interface{}); ok {
if blockMap["type"] == "text" {
if text, ok := blockMap["text"].(string); ok {
result += text
}
}
}
}
return result
}
return ""
}
// GetContentBlocks extracts content blocks from an AnthropicMessage
func (m *AnthropicMessage) GetContentBlocks() []AnthropicContentBlock {
switch content := m.Content.(type) {
case string:
return []AnthropicContentBlock{{Type: "text", Text: content}}
case []interface{}:
var blocks []AnthropicContentBlock
for _, block := range content {
if blockMap, ok := block.(map[string]interface{}); ok {
cb := AnthropicContentBlock{}
data, err := json.Marshal(blockMap)
if err != nil {
continue
}
if err := json.Unmarshal(data, &cb); err != nil {
continue
}
blocks = append(blocks, cb)
}
}
return blocks
}
return nil
}

View File

@@ -1,216 +0,0 @@
package schema_test
import (
"encoding/json"
"github.com/mudler/LocalAI/core/schema"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Anthropic Schema", func() {
Describe("AnthropicRequest", func() {
It("should unmarshal a valid request", func() {
jsonData := `{
"model": "claude-3-sonnet-20240229",
"max_tokens": 1024,
"messages": [
{"role": "user", "content": "Hello, world!"}
],
"system": "You are a helpful assistant.",
"temperature": 0.7
}`
var req schema.AnthropicRequest
err := json.Unmarshal([]byte(jsonData), &req)
Expect(err).ToNot(HaveOccurred())
Expect(req.Model).To(Equal("claude-3-sonnet-20240229"))
Expect(req.MaxTokens).To(Equal(1024))
Expect(len(req.Messages)).To(Equal(1))
Expect(req.System).To(Equal("You are a helpful assistant."))
Expect(*req.Temperature).To(Equal(0.7))
})
It("should unmarshal a request with tools", func() {
jsonData := `{
"model": "claude-3-sonnet-20240229",
"max_tokens": 1024,
"messages": [
{"role": "user", "content": "What's the weather?"}
],
"tools": [
{
"name": "get_weather",
"description": "Get the current weather",
"input_schema": {
"type": "object",
"properties": {
"location": {"type": "string"}
}
}
}
],
"tool_choice": {"type": "tool", "name": "get_weather"}
}`
var req schema.AnthropicRequest
err := json.Unmarshal([]byte(jsonData), &req)
Expect(err).ToNot(HaveOccurred())
Expect(len(req.Tools)).To(Equal(1))
Expect(req.Tools[0].Name).To(Equal("get_weather"))
Expect(req.Tools[0].Description).To(Equal("Get the current weather"))
Expect(req.ToolChoice).ToNot(BeNil())
})
It("should implement LocalAIRequest interface", func() {
req := &schema.AnthropicRequest{Model: "test-model"}
Expect(req.ModelName(nil)).To(Equal("test-model"))
newModel := "new-model"
Expect(req.ModelName(&newModel)).To(Equal("new-model"))
Expect(req.Model).To(Equal("new-model"))
})
})
Describe("AnthropicMessage", func() {
It("should get string content from string content", func() {
msg := schema.AnthropicMessage{
Role: "user",
Content: "Hello, world!",
}
Expect(msg.GetStringContent()).To(Equal("Hello, world!"))
})
It("should get string content from array content", func() {
msg := schema.AnthropicMessage{
Role: "user",
Content: []interface{}{
map[string]interface{}{"type": "text", "text": "Hello, "},
map[string]interface{}{"type": "text", "text": "world!"},
},
}
Expect(msg.GetStringContent()).To(Equal("Hello, world!"))
})
It("should get content blocks from string content", func() {
msg := schema.AnthropicMessage{
Role: "user",
Content: "Hello, world!",
}
blocks := msg.GetContentBlocks()
Expect(len(blocks)).To(Equal(1))
Expect(blocks[0].Type).To(Equal("text"))
Expect(blocks[0].Text).To(Equal("Hello, world!"))
})
It("should get content blocks from array content", func() {
msg := schema.AnthropicMessage{
Role: "user",
Content: []interface{}{
map[string]interface{}{"type": "text", "text": "Hello"},
map[string]interface{}{"type": "image", "source": map[string]interface{}{"type": "base64", "data": "abc123"}},
},
}
blocks := msg.GetContentBlocks()
Expect(len(blocks)).To(Equal(2))
Expect(blocks[0].Type).To(Equal("text"))
Expect(blocks[0].Text).To(Equal("Hello"))
})
})
Describe("AnthropicResponse", func() {
It("should marshal a valid response", func() {
stopReason := "end_turn"
resp := schema.AnthropicResponse{
ID: "msg_123",
Type: "message",
Role: "assistant",
Model: "claude-3-sonnet-20240229",
StopReason: &stopReason,
Content: []schema.AnthropicContentBlock{
{Type: "text", Text: "Hello!"},
},
Usage: schema.AnthropicUsage{
InputTokens: 10,
OutputTokens: 5,
},
}
data, err := json.Marshal(resp)
Expect(err).ToNot(HaveOccurred())
var result map[string]interface{}
err = json.Unmarshal(data, &result)
Expect(err).ToNot(HaveOccurred())
Expect(result["id"]).To(Equal("msg_123"))
Expect(result["type"]).To(Equal("message"))
Expect(result["role"]).To(Equal("assistant"))
Expect(result["stop_reason"]).To(Equal("end_turn"))
})
It("should marshal a response with tool use", func() {
stopReason := "tool_use"
resp := schema.AnthropicResponse{
ID: "msg_123",
Type: "message",
Role: "assistant",
Model: "claude-3-sonnet-20240229",
StopReason: &stopReason,
Content: []schema.AnthropicContentBlock{
{
Type: "tool_use",
ID: "toolu_123",
Name: "get_weather",
Input: map[string]interface{}{
"location": "San Francisco",
},
},
},
Usage: schema.AnthropicUsage{
InputTokens: 10,
OutputTokens: 5,
},
}
data, err := json.Marshal(resp)
Expect(err).ToNot(HaveOccurred())
var result map[string]interface{}
err = json.Unmarshal(data, &result)
Expect(err).ToNot(HaveOccurred())
Expect(result["stop_reason"]).To(Equal("tool_use"))
content := result["content"].([]interface{})
Expect(len(content)).To(Equal(1))
toolUse := content[0].(map[string]interface{})
Expect(toolUse["type"]).To(Equal("tool_use"))
Expect(toolUse["id"]).To(Equal("toolu_123"))
Expect(toolUse["name"]).To(Equal("get_weather"))
})
})
Describe("AnthropicErrorResponse", func() {
It("should marshal an error response", func() {
resp := schema.AnthropicErrorResponse{
Type: "error",
Error: schema.AnthropicError{
Type: "invalid_request_error",
Message: "max_tokens is required",
},
}
data, err := json.Marshal(resp)
Expect(err).ToNot(HaveOccurred())
var result map[string]interface{}
err = json.Unmarshal(data, &result)
Expect(err).ToNot(HaveOccurred())
Expect(result["type"]).To(Equal("error"))
errorObj := result["error"].(map[string]interface{})
Expect(errorObj["type"]).To(Equal("invalid_request_error"))
Expect(errorObj["message"]).To(Equal("max_tokens is required"))
})
})
})

View File

@@ -27,9 +27,6 @@ type Message struct {
FunctionCall interface{} `json:"function_call,omitempty" yaml:"function_call,omitempty"`
ToolCalls []ToolCall `json:"tool_calls,omitempty" yaml:"tool_call,omitempty"`
// Reasoning content extracted from <thinking>...</thinking> tags
Reasoning *string `json:"reasoning,omitempty" yaml:"reasoning,omitempty"`
}
type ToolCall struct {
@@ -81,8 +78,8 @@ func (messages Messages) ToProto() []*proto.Message {
}
}
// Note: tool_call_id is not in schema.Message yet
// Reasoning field is now available in schema.Message but not yet in proto.Message
// Note: tool_call_id and reasoning_content are not in schema.Message yet
// They may need to be added to schema.Message if needed in the future
}
return protoMessages
}

View File

@@ -1,56 +1,4 @@
---
- name: "qwen3-vl-reranker-8b"
url: "github:mudler/LocalAI/gallery/virtual.yaml@master"
urls:
- https://huggingface.co/mradermacher/Qwen3-VL-Reranker-8B-GGUF
description: |
**Model Name:** Qwen3-VL-Reranker-8B
**Base Model:** Qwen/Qwen3-VL-Reranker-8B
**Description:**
A high-performance multimodal reranking model for state-of-the-art cross-modal search. It supports 30+ languages and handles text, images, screenshots, videos, and mixed modalities. With 8B parameters and a 32K context length, it refines retrieval results by combining embedding vectors with precise relevance scores. Optimized for efficiency, it supports quantized versions (e.g., Q8_0, Q4_K_M) and is ideal for applications requiring accurate multimodal content matching.
**Key Features:**
- **Multimodal**: Text, images, videos, and mixed content.
- **Language Support**: 30+ languages.
- **Quantization**: Available in Q8_0 (best quality), Q4_K_M (fast, recommended), and lower-precision options.
- **Performance**: Outperforms base models in retrieval tasks (e.g., JinaVDR, ViDoRe v3).
- **Use Case**: Enhances search pipelines by refining embeddings with precise relevance scores.
**Downloads:**
- [GGUF Files](https://huggingface.co/mradermacher/Qwen3-VL-Reranker-8B-GGUF) (e.g., `Qwen3-VL-Reranker-8B.Q8_0.gguf`).
**Usage:**
- Requires `transformers`, `qwen-vl-utils`, and `torch`.
- Example: `from scripts.qwen3_vl_reranker import Qwen3VLReranker; model = Qwen3VLReranker(...)`
**Citation:**
@article{qwen3vlembedding, ...}
This description emphasizes its capabilities, efficiency, and versatility for multimodal search tasks.
overrides:
parameters:
model: llama-cpp/models/Qwen3-VL-Reranker-8B.Q4_K_M.gguf
name: Qwen3-VL-Reranker-8B-GGUF
backend: llama-cpp
template:
use_tokenizer_template: true
known_usecases:
- chat
function:
grammar:
disable: true
mmproj: llama-cpp/mmproj/Qwen3-VL-Reranker-8B.mmproj-f16.gguf
description: Imported from https://huggingface.co/mradermacher/Qwen3-VL-Reranker-8B-GGUF
options:
- use_jinja:true
files:
- filename: llama-cpp/models/Qwen3-VL-Reranker-8B.Q4_K_M.gguf
sha256: f73e62ea68abf741c3e713af823cfb4d2fd2ca35c8b68277b87b4b3d8570b66d
uri: https://huggingface.co/mradermacher/Qwen3-VL-Reranker-8B-GGUF/resolve/main/Qwen3-VL-Reranker-8B.Q4_K_M.gguf
- filename: llama-cpp/mmproj/Qwen3-VL-Reranker-8B.mmproj-f16.gguf
sha256: 15cd9bd4882dae771344f0ac204fce07de91b47c1438ada3861dfc817403c31e
uri: https://huggingface.co/mradermacher/Qwen3-VL-Reranker-8B-GGUF/resolve/main/Qwen3-VL-Reranker-8B.mmproj-f16.gguf
- name: "liquidai.lfm2-2.6b-transcript"
url: "github:mudler/LocalAI/gallery/virtual.yaml@master"
urls:

7
go.mod
View File

@@ -9,7 +9,6 @@ require (
fyne.io/fyne/v2 v2.7.1
github.com/Masterminds/sprig/v3 v3.3.0
github.com/alecthomas/kong v1.13.0
github.com/anthropics/anthropic-sdk-go v1.19.0
github.com/charmbracelet/glamour v0.10.0
github.com/containerd/containerd v1.7.30
github.com/ebitengine/purego v0.9.1
@@ -59,7 +58,6 @@ require (
go.opentelemetry.io/otel/metric v1.39.0
go.opentelemetry.io/otel/sdk/metric v1.39.0
google.golang.org/grpc v1.78.0
google.golang.org/protobuf v1.36.10
gopkg.in/yaml.v2 v2.4.0
gopkg.in/yaml.v3 v3.0.1
oras.land/oras-go/v2 v2.6.0
@@ -69,11 +67,8 @@ require (
github.com/ghodss/yaml v1.0.0 // indirect
github.com/labstack/gommon v0.4.2 // indirect
github.com/swaggo/files/v2 v2.0.2 // indirect
github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
google.golang.org/protobuf v1.36.10 // indirect
)
require (

4
go.sum
View File

@@ -44,8 +44,6 @@ github.com/andybalholm/brotli v1.0.1/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c=
github.com/anthropics/anthropic-sdk-go v1.19.0 h1:mO6E+ffSzLRvR/YUH9KJC0uGw0uV8GjISIuzem//3KE=
github.com/anthropics/anthropic-sdk-go v1.19.0/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
@@ -764,12 +762,10 @@ github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4
github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07/go.mod h1:kDXzergiv9cbyO7IOYJZWg1U88JhDg3PB6klq9Hg2pA=
github.com/testcontainers/testcontainers-go v0.40.0 h1:pSdJYLOVgLE8YdUY2FHQ1Fxu+aMnb6JfVz1mxk7OeMU=
github.com/testcontainers/testcontainers-go v0.40.0/go.mod h1:FSXV5KQtX2HAMlm7U3APNyLkkap35zNLxukw9oBi/MY=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=

View File

@@ -1,114 +0,0 @@
package functions
import (
"strings"
)
// ExtractReasoning extracts reasoning content from thinking tags and returns
// both the extracted reasoning and the cleaned content (with tags removed).
// It handles <thinking>...</thinking> and <think>...</think> tags.
// Multiple reasoning blocks are concatenated with newlines.
func ExtractReasoning(content string) (reasoning string, cleanedContent string) {
if content == "" {
return "", content
}
var reasoningParts []string
var cleanedParts []string
remaining := content
// Define tag pairs to look for
tagPairs := []struct {
start string
end string
}{
{"<thinking>", "</thinking>"},
{"<think>", "</think>"},
}
// Track the last position we've processed
lastPos := 0
for {
// Find the earliest tag start
earliestStart := -1
earliestEnd := -1
isUnclosed := false
var matchedTag struct {
start string
end string
}
for _, tagPair := range tagPairs {
startIdx := strings.Index(remaining[lastPos:], tagPair.start)
if startIdx == -1 {
continue
}
startIdx += lastPos
// Find the corresponding end tag
endIdx := strings.Index(remaining[startIdx+len(tagPair.start):], tagPair.end)
if endIdx == -1 {
// Unclosed tag - extract what we have
if earliestStart == -1 || startIdx < earliestStart {
earliestStart = startIdx
earliestEnd = len(remaining)
isUnclosed = true
matchedTag = tagPair
}
continue
}
endIdx += startIdx + len(tagPair.start)
// Found a complete tag pair
if earliestStart == -1 || startIdx < earliestStart {
earliestStart = startIdx
earliestEnd = endIdx + len(tagPair.end)
isUnclosed = false
matchedTag = tagPair
}
}
if earliestStart == -1 {
// No more tags found, add remaining content
if lastPos < len(remaining) {
cleanedParts = append(cleanedParts, remaining[lastPos:])
}
break
}
// Add content before the tag
if earliestStart > lastPos {
cleanedParts = append(cleanedParts, remaining[lastPos:earliestStart])
}
// Extract reasoning content
reasoningStart := earliestStart + len(matchedTag.start)
// For unclosed tags, earliestEnd is already at the end of the string
// For closed tags, earliestEnd points to after the closing tag, so we subtract the end tag length
var reasoningEnd int
if isUnclosed {
// Unclosed tag - extract everything to the end
reasoningEnd = len(remaining)
} else {
// Closed tag - exclude the end tag
reasoningEnd = earliestEnd - len(matchedTag.end)
}
if reasoningEnd > reasoningStart {
reasoningContent := strings.TrimSpace(remaining[reasoningStart:reasoningEnd])
if reasoningContent != "" {
reasoningParts = append(reasoningParts, reasoningContent)
}
}
// Move past this tag
lastPos = earliestEnd
}
// Combine reasoning parts
reasoning = strings.Join(reasoningParts, "\n\n")
// Combine cleaned content parts
cleanedContent = strings.Join(cleanedParts, "")
return reasoning, cleanedContent
}

View File

@@ -1,261 +0,0 @@
package functions_test
import (
"strings"
. "github.com/mudler/LocalAI/pkg/functions"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("ExtractReasoning", func() {
Context("when content has no reasoning tags", func() {
It("should return empty reasoning and original content", func() {
content := "This is regular content without any tags."
reasoning, cleaned := ExtractReasoning(content)
Expect(reasoning).To(BeEmpty())
Expect(cleaned).To(Equal(content))
})
It("should handle empty string", func() {
content := ""
reasoning, cleaned := ExtractReasoning(content)
Expect(reasoning).To(BeEmpty())
Expect(cleaned).To(BeEmpty())
})
It("should handle content with only whitespace", func() {
content := " \n\t "
reasoning, cleaned := ExtractReasoning(content)
Expect(reasoning).To(BeEmpty())
Expect(cleaned).To(Equal(content))
})
})
Context("when content has <thinking> tags", func() {
It("should extract reasoning from single thinking block", func() {
content := "Some text <thinking>This is my reasoning</thinking> More text"
reasoning, cleaned := ExtractReasoning(content)
Expect(reasoning).To(Equal("This is my reasoning"))
Expect(cleaned).To(Equal("Some text More text"))
})
It("should extract reasoning and preserve surrounding content", func() {
content := "Before <thinking>Reasoning here</thinking> After"
reasoning, cleaned := ExtractReasoning(content)
Expect(reasoning).To(Equal("Reasoning here"))
Expect(cleaned).To(Equal("Before After"))
})
It("should handle thinking block at the start", func() {
content := "<thinking>Start reasoning</thinking> Regular content"
reasoning, cleaned := ExtractReasoning(content)
Expect(reasoning).To(Equal("Start reasoning"))
Expect(cleaned).To(Equal(" Regular content"))
})
It("should handle thinking block at the end", func() {
content := "Regular content <thinking>End reasoning</thinking>"
reasoning, cleaned := ExtractReasoning(content)
Expect(reasoning).To(Equal("End reasoning"))
Expect(cleaned).To(Equal("Regular content "))
})
It("should handle only thinking block", func() {
content := "<thinking>Only reasoning</thinking>"
reasoning, cleaned := ExtractReasoning(content)
Expect(reasoning).To(Equal("Only reasoning"))
Expect(cleaned).To(BeEmpty())
})
It("should trim whitespace from reasoning content", func() {
content := "Text <thinking> \n Reasoning with spaces \n </thinking> More"
reasoning, cleaned := ExtractReasoning(content)
Expect(reasoning).To(Equal("Reasoning with spaces"))
Expect(cleaned).To(Equal("Text More"))
})
})
Context("when content has <think> tags", func() {
It("should extract reasoning from redacted_reasoning block", func() {
content := "Text <think>Redacted reasoning</think> More"
reasoning, cleaned := ExtractReasoning(content)
Expect(reasoning).To(Equal("Redacted reasoning"))
Expect(cleaned).To(Equal("Text More"))
})
It("should handle redacted_reasoning with multiline content", func() {
content := "Before <think>Line 1\nLine 2\nLine 3</think> After"
reasoning, cleaned := ExtractReasoning(content)
Expect(reasoning).To(Equal("Line 1\nLine 2\nLine 3"))
Expect(cleaned).To(Equal("Before After"))
})
It("should handle redacted_reasoning with complex content", func() {
content := "Start <think>Complex reasoning\nwith\nmultiple\nlines</think> End"
reasoning, cleaned := ExtractReasoning(content)
Expect(reasoning).To(Equal("Complex reasoning\nwith\nmultiple\nlines"))
Expect(cleaned).To(Equal("Start End"))
})
})
Context("when content has multiple reasoning blocks", func() {
It("should concatenate multiple thinking blocks with newlines", func() {
content := "Text <thinking>First</thinking> Middle <thinking>Second</thinking> End"
reasoning, cleaned := ExtractReasoning(content)
Expect(reasoning).To(Equal("First\n\nSecond"))
Expect(cleaned).To(Equal("Text Middle End"))
})
It("should handle multiple different tag types", func() {
content := "A <thinking>One</thinking> B <think>Two</think> C <think>Three</think> D"
reasoning, cleaned := ExtractReasoning(content)
Expect(reasoning).To(ContainSubstring("One"))
Expect(reasoning).To(ContainSubstring("Two"))
Expect(reasoning).To(ContainSubstring("Three"))
Expect(cleaned).To(Equal("A B C D"))
})
It("should handle nested tags correctly (extracts first match)", func() {
content := "Text <thinking>Outer <think>Inner</think></thinking> More"
reasoning, cleaned := ExtractReasoning(content)
// Should extract the outer thinking block
Expect(reasoning).To(ContainSubstring("Outer"))
Expect(reasoning).To(ContainSubstring("Inner"))
Expect(cleaned).To(Equal("Text More"))
})
})
Context("when content has unclosed reasoning tags", func() {
It("should extract unclosed thinking block", func() {
content := "Text <thinking>Unclosed reasoning"
reasoning, cleaned := ExtractReasoning(content)
Expect(reasoning).To(Equal("Unclosed reasoning"))
Expect(cleaned).To(Equal("Text "))
})
It("should extract unclosed think block", func() {
content := "Before <think>Incomplete"
reasoning, cleaned := ExtractReasoning(content)
Expect(reasoning).To(Equal("Incomplete"))
Expect(cleaned).To(Equal("Before "))
})
It("should extract unclosed redacted_reasoning block", func() {
content := "Start <think>Partial reasoning content"
reasoning, cleaned := ExtractReasoning(content)
Expect(reasoning).To(Equal("Partial reasoning content"))
Expect(cleaned).To(Equal("Start "))
})
It("should handle unclosed tag at the end", func() {
content := "Regular content <thinking>Unclosed at end"
reasoning, cleaned := ExtractReasoning(content)
Expect(reasoning).To(Equal("Unclosed at end"))
Expect(cleaned).To(Equal("Regular content "))
})
})
Context("when content has empty reasoning blocks", func() {
It("should ignore empty thinking block", func() {
content := "Text <thinking></thinking> More"
reasoning, cleaned := ExtractReasoning(content)
Expect(reasoning).To(BeEmpty())
Expect(cleaned).To(Equal("Text More"))
})
It("should ignore thinking block with only whitespace", func() {
content := "Text <thinking> \n\t </thinking> More"
reasoning, cleaned := ExtractReasoning(content)
Expect(reasoning).To(BeEmpty())
Expect(cleaned).To(Equal("Text More"))
})
})
Context("when content has reasoning tags with special characters", func() {
It("should handle reasoning with newlines", func() {
content := "Before <thinking>Line 1\nLine 2\nLine 3</thinking> After"
reasoning, cleaned := ExtractReasoning(content)
Expect(reasoning).To(Equal("Line 1\nLine 2\nLine 3"))
Expect(cleaned).To(Equal("Before After"))
})
It("should handle reasoning with code blocks", func() {
content := "Text <thinking>Reasoning with ```code``` blocks</thinking> More"
reasoning, cleaned := ExtractReasoning(content)
Expect(reasoning).To(Equal("Reasoning with ```code``` blocks"))
Expect(cleaned).To(Equal("Text More"))
})
It("should handle reasoning with JSON", func() {
content := "Before <think>{\"key\": \"value\"}</think> After"
reasoning, cleaned := ExtractReasoning(content)
Expect(reasoning).To(Equal("{\"key\": \"value\"}"))
Expect(cleaned).To(Equal("Before After"))
})
It("should handle reasoning with HTML-like content", func() {
content := "Text <thinking>Reasoning with <tags> inside</thinking> More"
reasoning, cleaned := ExtractReasoning(content)
Expect(reasoning).To(Equal("Reasoning with <tags> inside"))
Expect(cleaned).To(Equal("Text More"))
})
})
Context("when content has reasoning mixed with regular content", func() {
It("should preserve content order correctly", func() {
content := "Start <thinking>Reasoning</thinking> Middle <think>More reasoning</think> End"
reasoning, cleaned := ExtractReasoning(content)
Expect(reasoning).To(ContainSubstring("Reasoning"))
Expect(reasoning).To(ContainSubstring("More reasoning"))
Expect(cleaned).To(Equal("Start Middle End"))
})
It("should handle reasoning in the middle of a sentence", func() {
content := "This is a <thinking>reasoning</thinking> sentence."
reasoning, cleaned := ExtractReasoning(content)
Expect(reasoning).To(Equal("reasoning"))
Expect(cleaned).To(Equal("This is a sentence."))
})
})
Context("edge cases", func() {
It("should handle content with only opening tag", func() {
content := "<thinking>"
reasoning, cleaned := ExtractReasoning(content)
Expect(reasoning).To(BeEmpty())
Expect(cleaned).To(Equal(""))
})
It("should handle content with only closing tag", func() {
content := "</thinking>"
reasoning, cleaned := ExtractReasoning(content)
Expect(reasoning).To(BeEmpty())
Expect(cleaned).To(Equal("</thinking>"))
})
It("should handle mismatched tags", func() {
content := "<thinking>Content</think>"
reasoning, cleaned := ExtractReasoning(content)
// Should extract unclosed thinking block
Expect(reasoning).To(ContainSubstring("Content"))
Expect(cleaned).To(Equal(""))
})
It("should handle very long reasoning content", func() {
longReasoning := strings.Repeat("This is reasoning content. ", 100)
content := "Text <thinking>" + longReasoning + "</thinking> More"
reasoning, cleaned := ExtractReasoning(content)
// TrimSpace is applied, so we need to account for that
Expect(reasoning).To(Equal(strings.TrimSpace(longReasoning)))
Expect(cleaned).To(Equal("Text More"))
})
It("should handle reasoning with unicode characters", func() {
content := "Text <thinking>Reasoning with 中文 and emoji 🧠</thinking> More"
reasoning, cleaned := ExtractReasoning(content)
Expect(reasoning).To(Equal("Reasoning with 中文 and emoji 🧠"))
Expect(cleaned).To(Equal("Text More"))
})
})
})

View File

@@ -24,6 +24,8 @@ 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)
@@ -46,7 +48,6 @@ 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
}
@@ -55,10 +56,6 @@ 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
}

View File

@@ -12,17 +12,15 @@ 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"
vulkan = "vulkan"
nvidia = "nvidia"
amd = "amd"
intel = "intel"
vulkan = "vulkan"
nvidiaCuda13 = "nvidia-cuda-13"
nvidiaCuda12 = "nvidia-cuda-12"
@@ -32,16 +30,6 @@ 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 (
@@ -108,7 +96,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
@@ -143,6 +131,7 @@ 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
@@ -150,76 +139,19 @@ func (s *SystemState) getSystemCapabilities() string {
func (s *SystemState) BackendPreferenceTokens() []string {
capStr := strings.ToLower(s.getSystemCapabilities())
switch {
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, 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, metal):
return []string{backendTokenMetal, "cpu"}
return []string{"metal", "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
}

View File

@@ -1198,30 +1198,6 @@ 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.",
@@ -1763,169 +1739,6 @@ 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": {
@@ -2416,10 +2229,6 @@ 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"

View File

@@ -1191,30 +1191,6 @@
}
}
},
"/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.",
@@ -1756,169 +1732,6 @@
}
}
},
"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": {
@@ -2409,10 +2222,6 @@
"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"

View File

@@ -239,114 +239,6 @@ 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:
@@ -681,9 +573,6 @@ 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
@@ -1924,21 +1813,6 @@ 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:

View File

@@ -1,375 +0,0 @@
package e2e_test
import (
"context"
"github.com/anthropics/anthropic-sdk-go"
"github.com/anthropics/anthropic-sdk-go/option"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Anthropic API E2E test", func() {
var client anthropic.Client
Context("API with Anthropic SDK", func() {
BeforeEach(func() {
// Create Anthropic client pointing to LocalAI
client = anthropic.NewClient(
option.WithBaseURL(localAIURL),
option.WithAPIKey("test-api-key"), // LocalAI doesn't require a real API key
)
// Wait for API to be ready by attempting a simple request
Eventually(func() error {
_, err := client.Messages.New(context.TODO(), anthropic.MessageNewParams{
Model: "gpt-4",
MaxTokens: 10,
Messages: []anthropic.MessageParam{
anthropic.NewUserMessage(anthropic.NewTextBlock("Hi")),
},
})
return err
}, "2m").ShouldNot(HaveOccurred())
})
Context("Non-streaming responses", func() {
It("generates a response for a simple message", func() {
message, err := client.Messages.New(context.TODO(), anthropic.MessageNewParams{
Model: "gpt-4",
MaxTokens: 1024,
Messages: []anthropic.MessageParam{
anthropic.NewUserMessage(anthropic.NewTextBlock("How much is 2+2? Reply with just the number.")),
},
})
Expect(err).ToNot(HaveOccurred())
Expect(message.Content).ToNot(BeEmpty())
// Role is a constant type that defaults to "assistant"
Expect(string(message.Role)).To(Equal("assistant"))
Expect(message.StopReason).To(Equal(anthropic.MessageStopReasonEndTurn))
Expect(string(message.Type)).To(Equal("message"))
// Check that content contains text block with expected answer
Expect(len(message.Content)).To(BeNumerically(">=", 1))
textBlock := message.Content[0]
Expect(string(textBlock.Type)).To(Equal("text"))
Expect(textBlock.Text).To(Or(ContainSubstring("4"), ContainSubstring("four")))
})
It("handles system prompts", func() {
message, err := client.Messages.New(context.TODO(), anthropic.MessageNewParams{
Model: "gpt-4",
MaxTokens: 1024,
System: []anthropic.TextBlockParam{
{Text: "You are a helpful assistant. Always respond in uppercase letters."},
},
Messages: []anthropic.MessageParam{
anthropic.NewUserMessage(anthropic.NewTextBlock("Say hello")),
},
})
Expect(err).ToNot(HaveOccurred())
Expect(message.Content).ToNot(BeEmpty())
Expect(len(message.Content)).To(BeNumerically(">=", 1))
})
It("returns usage information", func() {
message, err := client.Messages.New(context.TODO(), anthropic.MessageNewParams{
Model: "gpt-4",
MaxTokens: 100,
Messages: []anthropic.MessageParam{
anthropic.NewUserMessage(anthropic.NewTextBlock("Hello")),
},
})
Expect(err).ToNot(HaveOccurred())
Expect(message.Usage.InputTokens).To(BeNumerically(">", 0))
Expect(message.Usage.OutputTokens).To(BeNumerically(">", 0))
})
})
Context("Streaming responses", func() {
It("streams tokens for a simple message", func() {
stream := client.Messages.NewStreaming(context.TODO(), anthropic.MessageNewParams{
Model: "gpt-4",
MaxTokens: 1024,
Messages: []anthropic.MessageParam{
anthropic.NewUserMessage(anthropic.NewTextBlock("Count from 1 to 5")),
},
})
message := anthropic.Message{}
eventCount := 0
hasContentDelta := false
for stream.Next() {
event := stream.Current()
err := message.Accumulate(event)
Expect(err).ToNot(HaveOccurred())
eventCount++
// Check for content block delta events
switch event.AsAny().(type) {
case anthropic.ContentBlockDeltaEvent:
hasContentDelta = true
}
}
Expect(stream.Err()).ToNot(HaveOccurred())
Expect(eventCount).To(BeNumerically(">", 0))
Expect(hasContentDelta).To(BeTrue())
// Check accumulated message
Expect(message.Content).ToNot(BeEmpty())
// Role is a constant type that defaults to "assistant"
Expect(string(message.Role)).To(Equal("assistant"))
})
It("streams with system prompt", func() {
stream := client.Messages.NewStreaming(context.TODO(), anthropic.MessageNewParams{
Model: "gpt-4",
MaxTokens: 1024,
System: []anthropic.TextBlockParam{
{Text: "You are a helpful assistant."},
},
Messages: []anthropic.MessageParam{
anthropic.NewUserMessage(anthropic.NewTextBlock("Say hello")),
},
})
message := anthropic.Message{}
for stream.Next() {
event := stream.Current()
err := message.Accumulate(event)
Expect(err).ToNot(HaveOccurred())
}
Expect(stream.Err()).ToNot(HaveOccurred())
Expect(message.Content).ToNot(BeEmpty())
})
})
Context("Tool calling", func() {
It("handles tool calls in non-streaming mode", func() {
message, err := client.Messages.New(context.TODO(), anthropic.MessageNewParams{
Model: "gpt-4",
MaxTokens: 1024,
Messages: []anthropic.MessageParam{
anthropic.NewUserMessage(anthropic.NewTextBlock("What's the weather like in San Francisco?")),
},
Tools: []anthropic.ToolParam{
{
Name: "get_weather",
Description: anthropic.F("Get the current weather in a given location"),
InputSchema: anthropic.F(map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"location": map[string]interface{}{
"type": "string",
"description": "The city and state, e.g. San Francisco, CA",
},
},
"required": []string{"location"},
}),
},
},
})
Expect(err).ToNot(HaveOccurred())
Expect(message.Content).ToNot(BeEmpty())
// The model must use tools - find the tool use in the response
hasToolUse := false
for _, block := range message.Content {
if block.Type == anthropic.ContentBlockTypeToolUse {
hasToolUse = true
Expect(block.Name).To(Equal("get_weather"))
Expect(block.ID).ToNot(BeEmpty())
// Verify that input contains location
inputMap, ok := block.Input.(map[string]interface{})
Expect(ok).To(BeTrue())
_, hasLocation := inputMap["location"]
Expect(hasLocation).To(BeTrue())
}
}
// Model must have called the tool
Expect(hasToolUse).To(BeTrue(), "Model should have called the get_weather tool")
Expect(message.StopReason).To(Equal(anthropic.MessageStopReasonToolUse))
})
It("handles tool_choice parameter", func() {
message, err := client.Messages.New(context.TODO(), anthropic.MessageNewParams{
Model: "gpt-4",
MaxTokens: 1024,
Messages: []anthropic.MessageParam{
anthropic.NewUserMessage(anthropic.NewTextBlock("Tell me about the weather")),
},
Tools: []anthropic.ToolParam{
{
Name: "get_weather",
Description: anthropic.F("Get the current weather"),
InputSchema: anthropic.F(map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"location": map[string]interface{}{
"type": "string",
},
},
}),
},
},
ToolChoice: anthropic.F[anthropic.ToolChoiceUnionParam](
anthropic.ToolChoiceAutoParam{
Type: anthropic.F(anthropic.ToolChoiceAutoTypeAuto),
},
),
})
Expect(err).ToNot(HaveOccurred())
Expect(message.Content).ToNot(BeEmpty())
})
It("handles tool results in messages", func() {
// First, make a request that should trigger a tool call
firstMessage, err := client.Messages.New(context.TODO(), anthropic.MessageNewParams{
Model: "gpt-4",
MaxTokens: 1024,
Messages: []anthropic.MessageParam{
anthropic.NewUserMessage(anthropic.NewTextBlock("What's the weather in SF?")),
},
Tools: []anthropic.ToolParam{
{
Name: "get_weather",
Description: anthropic.F("Get weather"),
InputSchema: anthropic.F(map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"location": map[string]interface{}{"type": "string"},
},
}),
},
},
})
Expect(err).ToNot(HaveOccurred())
// Find the tool use block - model must call the tool
var toolUseID string
var toolUseName string
for _, block := range firstMessage.Content {
if block.Type == anthropic.ContentBlockTypeToolUse {
toolUseID = block.ID
toolUseName = block.Name
break
}
}
// Model must have called the tool
Expect(toolUseID).ToNot(BeEmpty(), "Model should have called the get_weather tool")
// Send back a tool result and verify it's handled correctly
secondMessage, err := client.Messages.New(context.TODO(), anthropic.MessageNewParams{
Model: "gpt-4",
MaxTokens: 1024,
Messages: []anthropic.MessageParam{
anthropic.NewUserMessage(anthropic.NewTextBlock("What's the weather in SF?")),
anthropic.NewAssistantMessage(firstMessage.Content...),
anthropic.NewUserMessage(
anthropic.NewToolResultBlock(toolUseID, "Sunny, 72°F", false),
),
},
Tools: []anthropic.ToolParam{
{
Name: toolUseName,
Description: anthropic.F("Get weather"),
InputSchema: anthropic.F(map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"location": map[string]interface{}{"type": "string"},
},
}),
},
},
})
Expect(err).ToNot(HaveOccurred())
Expect(secondMessage.Content).ToNot(BeEmpty())
})
It("handles tool calls in streaming mode", func() {
stream := client.Messages.NewStreaming(context.TODO(), anthropic.MessageNewParams{
Model: "gpt-4",
MaxTokens: 1024,
Messages: []anthropic.MessageParam{
anthropic.NewUserMessage(anthropic.NewTextBlock("What's the weather like in San Francisco?")),
},
Tools: []anthropic.ToolParam{
{
Name: "get_weather",
Description: anthropic.F("Get the current weather in a given location"),
InputSchema: anthropic.F(map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"location": map[string]interface{}{
"type": "string",
"description": "The city and state, e.g. San Francisco, CA",
},
},
"required": []string{"location"},
}),
},
},
})
message := anthropic.Message{}
eventCount := 0
hasToolUseBlock := false
hasContentBlockStart := false
hasContentBlockDelta := false
hasContentBlockStop := false
for stream.Next() {
event := stream.Current()
err := message.Accumulate(event)
Expect(err).ToNot(HaveOccurred())
eventCount++
// Check for different event types related to tool use
switch e := event.AsAny().(type) {
case anthropic.ContentBlockStartEvent:
hasContentBlockStart = true
if e.ContentBlock.Type == anthropic.ContentBlockTypeToolUse {
hasToolUseBlock = true
}
case anthropic.ContentBlockDeltaEvent:
hasContentBlockDelta = true
case anthropic.ContentBlockStopEvent:
hasContentBlockStop = true
}
}
Expect(stream.Err()).ToNot(HaveOccurred())
Expect(eventCount).To(BeNumerically(">", 0))
// Verify streaming events were emitted
Expect(hasContentBlockStart).To(BeTrue(), "Should have content_block_start event")
Expect(hasContentBlockDelta).To(BeTrue(), "Should have content_block_delta event")
Expect(hasContentBlockStop).To(BeTrue(), "Should have content_block_stop event")
// Check accumulated message has tool use
Expect(message.Content).ToNot(BeEmpty())
// Model must have called the tool
foundToolUse := false
for _, block := range message.Content {
if block.Type == anthropic.ContentBlockTypeToolUse {
foundToolUse = true
Expect(block.Name).To(Equal("get_weather"))
Expect(block.ID).ToNot(BeEmpty())
}
}
Expect(foundToolUse).To(BeTrue(), "Model should have called the get_weather tool in streaming mode")
Expect(message.StopReason).To(Equal(anthropic.MessageStopReasonToolUse))
})
})
})
})