Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6b7f87dffb | |||
| 779a3dc452 | |||
| a65417eabe | |||
| 291871c3b5 | |||
| a564f7ec77 | |||
| a80ddc0fe4 | |||
| d6994bff48 | |||
| daa8c87cf4 | |||
| 9007faab0d | |||
| 7fe4286d34 |
+13
-1
@@ -5,7 +5,7 @@
|
|||||||
# All variables have sensible defaults - only set what you need to change.
|
# All variables have sensible defaults - only set what you need to change.
|
||||||
|
|
||||||
# ----- Backend -----
|
# ----- Backend -----
|
||||||
# Server port (default: 8080, but 9090 recommended for local dev)
|
# Server port (default: 9090 for local dev, matches vite proxy)
|
||||||
PORT=9090
|
PORT=9090
|
||||||
|
|
||||||
# SQLite database path (relative to backend working directory)
|
# SQLite database path (relative to backend working directory)
|
||||||
@@ -26,3 +26,15 @@ BACKEND_URL=http://localhost:9090
|
|||||||
|
|
||||||
# Development server port
|
# Development server port
|
||||||
DEV_PORT=7842
|
DEV_PORT=7842
|
||||||
|
|
||||||
|
# ----- llama.cpp -----
|
||||||
|
# llama.cpp server port (used by `just llama-server`)
|
||||||
|
LLAMA_PORT=8081
|
||||||
|
|
||||||
|
# ----- Additional Ports (for health checks) -----
|
||||||
|
# Ollama port (extracted from OLLAMA_URL for health checks)
|
||||||
|
OLLAMA_PORT=11434
|
||||||
|
|
||||||
|
# ----- Models -----
|
||||||
|
# Directory for GGUF model files
|
||||||
|
VESSEL_MODELS_DIR=~/.vessel/models
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<h1 align="center">Vessel</h1>
|
<h1 align="center">Vessel</h1>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<strong>A modern, feature-rich web interface for Ollama</strong>
|
<strong>A modern, feature-rich web interface for local LLMs</strong>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
@@ -28,13 +28,14 @@
|
|||||||
|
|
||||||
**Vessel** is intentionally focused on:
|
**Vessel** is intentionally focused on:
|
||||||
|
|
||||||
- A clean, local-first UI for **Ollama**
|
- A clean, local-first UI for **local LLMs**
|
||||||
|
- **Multiple backends**: Ollama, llama.cpp, LM Studio
|
||||||
- Minimal configuration
|
- Minimal configuration
|
||||||
- Low visual and cognitive overhead
|
- Low visual and cognitive overhead
|
||||||
- Doing a small set of things well
|
- Doing a small set of things well
|
||||||
|
|
||||||
If you want a **universal, highly configurable platform** → [open-webui](https://github.com/open-webui/open-webui) is a great choice.
|
If you want a **universal, highly configurable platform** → [open-webui](https://github.com/open-webui/open-webui) is a great choice.
|
||||||
If you want a **small, focused UI for local Ollama usage** → Vessel is built for that.
|
If you want a **small, focused UI for local LLM usage** → Vessel is built for that.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -65,7 +66,13 @@ If you want a **small, focused UI for local Ollama usage** → Vessel is built f
|
|||||||
- Agentic tool calling with chain-of-thought reasoning
|
- Agentic tool calling with chain-of-thought reasoning
|
||||||
- Test tools before saving with the built-in testing panel
|
- Test tools before saving with the built-in testing panel
|
||||||
|
|
||||||
### Models
|
### LLM Backends
|
||||||
|
- **Ollama** — Full model management, pull/delete/create custom models
|
||||||
|
- **llama.cpp** — High-performance inference with GGUF models
|
||||||
|
- **LM Studio** — Desktop app integration
|
||||||
|
- Switch backends without restart, auto-detection of available backends
|
||||||
|
|
||||||
|
### Models (Ollama)
|
||||||
- Browse and pull models from ollama.com
|
- Browse and pull models from ollama.com
|
||||||
- Create custom models with embedded system prompts
|
- Create custom models with embedded system prompts
|
||||||
- **Per-model parameters** — customize temperature, context size, top_k/top_p
|
- **Per-model parameters** — customize temperature, context size, top_k/top_p
|
||||||
@@ -112,7 +119,10 @@ If you want a **small, focused UI for local Ollama usage** → Vessel is built f
|
|||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- [Docker](https://docs.docker.com/get-docker/) and Docker Compose
|
- [Docker](https://docs.docker.com/get-docker/) and Docker Compose
|
||||||
- [Ollama](https://ollama.com/download) running locally
|
- An LLM backend (at least one):
|
||||||
|
- [Ollama](https://ollama.com/download) (recommended)
|
||||||
|
- [llama.cpp](https://github.com/ggerganov/llama.cpp)
|
||||||
|
- [LM Studio](https://lmstudio.ai/)
|
||||||
|
|
||||||
### Configure Ollama
|
### Configure Ollama
|
||||||
|
|
||||||
@@ -160,6 +170,7 @@ Full documentation is available on the **[GitHub Wiki](https://github.com/Viking
|
|||||||
| Guide | Description |
|
| Guide | Description |
|
||||||
|-------|-------------|
|
|-------|-------------|
|
||||||
| [Getting Started](https://github.com/VikingOwl91/vessel/wiki/Getting-Started) | Installation and configuration |
|
| [Getting Started](https://github.com/VikingOwl91/vessel/wiki/Getting-Started) | Installation and configuration |
|
||||||
|
| [LLM Backends](https://github.com/VikingOwl91/vessel/wiki/LLM-Backends) | Configure Ollama, llama.cpp, or LM Studio |
|
||||||
| [Projects](https://github.com/VikingOwl91/vessel/wiki/Projects) | Organize conversations into projects |
|
| [Projects](https://github.com/VikingOwl91/vessel/wiki/Projects) | Organize conversations into projects |
|
||||||
| [Knowledge Base](https://github.com/VikingOwl91/vessel/wiki/Knowledge-Base) | RAG with document upload and semantic search |
|
| [Knowledge Base](https://github.com/VikingOwl91/vessel/wiki/Knowledge-Base) | RAG with document upload and semantic search |
|
||||||
| [Search](https://github.com/VikingOwl91/vessel/wiki/Search) | Semantic and content search across chats |
|
| [Search](https://github.com/VikingOwl91/vessel/wiki/Search) | Semantic and content search across chats |
|
||||||
@@ -178,6 +189,7 @@ Full documentation is available on the **[GitHub Wiki](https://github.com/Viking
|
|||||||
Vessel prioritizes **usability and simplicity** over feature breadth.
|
Vessel prioritizes **usability and simplicity** over feature breadth.
|
||||||
|
|
||||||
**Completed:**
|
**Completed:**
|
||||||
|
- [x] Multi-backend support (Ollama, llama.cpp, LM Studio)
|
||||||
- [x] Model browser with filtering and update detection
|
- [x] Model browser with filtering and update detection
|
||||||
- [x] Custom tools (JavaScript, Python, HTTP)
|
- [x] Custom tools (JavaScript, Python, HTTP)
|
||||||
- [x] System prompt library with model-specific defaults
|
- [x] System prompt library with model-specific defaults
|
||||||
@@ -197,7 +209,7 @@ Vessel prioritizes **usability and simplicity** over feature breadth.
|
|||||||
- Multi-user systems
|
- Multi-user systems
|
||||||
- Cloud sync
|
- Cloud sync
|
||||||
- Plugin ecosystems
|
- Plugin ecosystems
|
||||||
- Support for every LLM runtime
|
- Cloud/API-based LLM providers (OpenAI, Anthropic, etc.)
|
||||||
|
|
||||||
> *Do one thing well. Keep the UI out of the way.*
|
> *Do one thing well. Keep the UI out of the way.*
|
||||||
|
|
||||||
@@ -223,5 +235,5 @@ Contributions are welcome!
|
|||||||
GPL-3.0 — See [LICENSE](LICENSE) for details.
|
GPL-3.0 — See [LICENSE](LICENSE) for details.
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
Made with <a href="https://ollama.com">Ollama</a> and <a href="https://svelte.dev">Svelte</a>
|
Made with <a href="https://svelte.dev">Svelte</a> • Supports <a href="https://ollama.com">Ollama</a>, <a href="https://github.com/ggerganov/llama.cpp">llama.cpp</a>, and <a href="https://lmstudio.ai/">LM Studio</a>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -14,11 +14,14 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
"vessel-backend/internal/api"
|
"vessel-backend/internal/api"
|
||||||
|
"vessel-backend/internal/backends"
|
||||||
|
"vessel-backend/internal/backends/ollama"
|
||||||
|
"vessel-backend/internal/backends/openai"
|
||||||
"vessel-backend/internal/database"
|
"vessel-backend/internal/database"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Version is set at build time via -ldflags, or defaults to dev
|
// Version is set at build time via -ldflags, or defaults to dev
|
||||||
var Version = "0.6.0"
|
var Version = "0.7.1"
|
||||||
|
|
||||||
func getEnvOrDefault(key, defaultValue string) string {
|
func getEnvOrDefault(key, defaultValue string) string {
|
||||||
if value := os.Getenv(key); value != "" {
|
if value := os.Getenv(key); value != "" {
|
||||||
@@ -29,9 +32,11 @@ func getEnvOrDefault(key, defaultValue string) string {
|
|||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
var (
|
var (
|
||||||
port = flag.String("port", getEnvOrDefault("PORT", "8080"), "Server port")
|
port = flag.String("port", getEnvOrDefault("PORT", "8080"), "Server port")
|
||||||
dbPath = flag.String("db", getEnvOrDefault("DB_PATH", "./data/vessel.db"), "Database file path")
|
dbPath = flag.String("db", getEnvOrDefault("DB_PATH", "./data/vessel.db"), "Database file path")
|
||||||
ollamaURL = flag.String("ollama-url", getEnvOrDefault("OLLAMA_URL", "http://localhost:11434"), "Ollama API URL")
|
ollamaURL = flag.String("ollama-url", getEnvOrDefault("OLLAMA_URL", "http://localhost:11434"), "Ollama API URL")
|
||||||
|
llamacppURL = flag.String("llamacpp-url", getEnvOrDefault("LLAMACPP_URL", "http://localhost:8081"), "llama.cpp server URL")
|
||||||
|
lmstudioURL = flag.String("lmstudio-url", getEnvOrDefault("LMSTUDIO_URL", "http://localhost:1234"), "LM Studio server URL")
|
||||||
)
|
)
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
@@ -47,6 +52,52 @@ func main() {
|
|||||||
log.Fatalf("Failed to run migrations: %v", err)
|
log.Fatalf("Failed to run migrations: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialize backend registry
|
||||||
|
registry := backends.NewRegistry()
|
||||||
|
|
||||||
|
// Register Ollama backend
|
||||||
|
ollamaAdapter, err := ollama.NewAdapter(backends.BackendConfig{
|
||||||
|
Type: backends.BackendTypeOllama,
|
||||||
|
BaseURL: *ollamaURL,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Warning: Failed to create Ollama adapter: %v", err)
|
||||||
|
} else {
|
||||||
|
if err := registry.Register(ollamaAdapter); err != nil {
|
||||||
|
log.Printf("Warning: Failed to register Ollama backend: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register llama.cpp backend (if URL is configured)
|
||||||
|
if *llamacppURL != "" {
|
||||||
|
llamacppAdapter, err := openai.NewAdapter(backends.BackendConfig{
|
||||||
|
Type: backends.BackendTypeLlamaCpp,
|
||||||
|
BaseURL: *llamacppURL,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Warning: Failed to create llama.cpp adapter: %v", err)
|
||||||
|
} else {
|
||||||
|
if err := registry.Register(llamacppAdapter); err != nil {
|
||||||
|
log.Printf("Warning: Failed to register llama.cpp backend: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register LM Studio backend (if URL is configured)
|
||||||
|
if *lmstudioURL != "" {
|
||||||
|
lmstudioAdapter, err := openai.NewAdapter(backends.BackendConfig{
|
||||||
|
Type: backends.BackendTypeLMStudio,
|
||||||
|
BaseURL: *lmstudioURL,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Warning: Failed to create LM Studio adapter: %v", err)
|
||||||
|
} else {
|
||||||
|
if err := registry.Register(lmstudioAdapter); err != nil {
|
||||||
|
log.Printf("Warning: Failed to register LM Studio backend: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Setup Gin router
|
// Setup Gin router
|
||||||
gin.SetMode(gin.ReleaseMode)
|
gin.SetMode(gin.ReleaseMode)
|
||||||
r := gin.New()
|
r := gin.New()
|
||||||
@@ -64,7 +115,7 @@ func main() {
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
// Register routes
|
// Register routes
|
||||||
api.SetupRoutes(r, db, *ollamaURL, Version)
|
api.SetupRoutes(r, db, *ollamaURL, Version, registry)
|
||||||
|
|
||||||
// Create server
|
// Create server
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
@@ -79,8 +130,12 @@ func main() {
|
|||||||
// Graceful shutdown handling
|
// Graceful shutdown handling
|
||||||
go func() {
|
go func() {
|
||||||
log.Printf("Server starting on port %s", *port)
|
log.Printf("Server starting on port %s", *port)
|
||||||
log.Printf("Ollama URL: %s (using official Go client)", *ollamaURL)
|
|
||||||
log.Printf("Database: %s", *dbPath)
|
log.Printf("Database: %s", *dbPath)
|
||||||
|
log.Printf("Backends configured:")
|
||||||
|
log.Printf(" - Ollama: %s", *ollamaURL)
|
||||||
|
log.Printf(" - llama.cpp: %s", *llamacppURL)
|
||||||
|
log.Printf(" - LM Studio: %s", *lmstudioURL)
|
||||||
|
log.Printf("Active backend: %s", registry.ActiveType().String())
|
||||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
log.Fatalf("Failed to start server: %v", err)
|
log.Fatalf("Failed to start server: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,275 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"vessel-backend/internal/backends"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AIHandlers provides HTTP handlers for the unified AI API
|
||||||
|
type AIHandlers struct {
|
||||||
|
registry *backends.Registry
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAIHandlers creates a new AIHandlers instance
|
||||||
|
func NewAIHandlers(registry *backends.Registry) *AIHandlers {
|
||||||
|
return &AIHandlers{
|
||||||
|
registry: registry,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListBackendsHandler returns information about all configured backends
|
||||||
|
func (h *AIHandlers) ListBackendsHandler() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
infos := h.registry.AllInfo(c.Request.Context())
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"backends": infos,
|
||||||
|
"active": h.registry.ActiveType().String(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DiscoverBackendsHandler probes for available backends
|
||||||
|
func (h *AIHandlers) DiscoverBackendsHandler() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
Endpoints []backends.DiscoveryEndpoint `json:"endpoints"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
// Use default endpoints if none provided
|
||||||
|
req.Endpoints = backends.DefaultDiscoveryEndpoints()
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(req.Endpoints) == 0 {
|
||||||
|
req.Endpoints = backends.DefaultDiscoveryEndpoints()
|
||||||
|
}
|
||||||
|
|
||||||
|
results := h.registry.Discover(c.Request.Context(), req.Endpoints)
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"results": results,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetActiveHandler sets the active backend
|
||||||
|
func (h *AIHandlers) SetActiveHandler() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
Type string `json:"type" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "type is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
backendType, err := backends.ParseBackendType(req.Type)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.registry.SetActive(backendType); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"active": backendType.String(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HealthCheckHandler checks the health of a specific backend
|
||||||
|
func (h *AIHandlers) HealthCheckHandler() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
typeParam := c.Param("type")
|
||||||
|
|
||||||
|
backendType, err := backends.ParseBackendType(typeParam)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
backend, ok := h.registry.Get(backendType)
|
||||||
|
if !ok {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "backend not registered"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := backend.HealthCheck(c.Request.Context()); err != nil {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||||
|
"status": "unhealthy",
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"status": "healthy",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListModelsHandler returns models from the active backend
|
||||||
|
func (h *AIHandlers) ListModelsHandler() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
active := h.registry.Active()
|
||||||
|
if active == nil {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "no active backend"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
models, err := active.ListModels(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"models": models,
|
||||||
|
"backend": active.Type().String(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChatHandler handles chat requests through the active backend
|
||||||
|
func (h *AIHandlers) ChatHandler() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
active := h.registry.Active()
|
||||||
|
if active == nil {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "no active backend"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req backends.ChatRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := req.Validate(); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if streaming is requested
|
||||||
|
streaming := req.Stream != nil && *req.Stream
|
||||||
|
|
||||||
|
if streaming {
|
||||||
|
h.handleStreamingChat(c, active, &req)
|
||||||
|
} else {
|
||||||
|
h.handleNonStreamingChat(c, active, &req)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleNonStreamingChat handles non-streaming chat requests
|
||||||
|
func (h *AIHandlers) handleNonStreamingChat(c *gin.Context, backend backends.LLMBackend, req *backends.ChatRequest) {
|
||||||
|
resp, err := backend.Chat(c.Request.Context(), req)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleStreamingChat handles streaming chat requests
|
||||||
|
func (h *AIHandlers) handleStreamingChat(c *gin.Context, backend backends.LLMBackend, req *backends.ChatRequest) {
|
||||||
|
// Set headers for NDJSON streaming
|
||||||
|
c.Header("Content-Type", "application/x-ndjson")
|
||||||
|
c.Header("Cache-Control", "no-cache")
|
||||||
|
c.Header("Connection", "keep-alive")
|
||||||
|
c.Header("Transfer-Encoding", "chunked")
|
||||||
|
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
flusher, ok := c.Writer.(http.Flusher)
|
||||||
|
if !ok {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "streaming not supported"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
chunkCh, err := backend.StreamChat(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
errResp := gin.H{"error": err.Error()}
|
||||||
|
data, _ := json.Marshal(errResp)
|
||||||
|
c.Writer.Write(append(data, '\n'))
|
||||||
|
flusher.Flush()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for chunk := range chunkCh {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.Marshal(chunk)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = c.Writer.Write(append(data, '\n'))
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterBackendHandler registers a new backend
|
||||||
|
func (h *AIHandlers) RegisterBackendHandler() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var req backends.BackendConfig
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := req.Validate(); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create adapter based on type
|
||||||
|
var backend backends.LLMBackend
|
||||||
|
var err error
|
||||||
|
|
||||||
|
switch req.Type {
|
||||||
|
case backends.BackendTypeOllama:
|
||||||
|
// Would import ollama adapter
|
||||||
|
c.JSON(http.StatusNotImplemented, gin.H{"error": "use /api/v1/ai/backends/discover to register backends"})
|
||||||
|
return
|
||||||
|
case backends.BackendTypeLlamaCpp, backends.BackendTypeLMStudio:
|
||||||
|
// Would import openai adapter
|
||||||
|
c.JSON(http.StatusNotImplemented, gin.H{"error": "use /api/v1/ai/backends/discover to register backends"})
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "unknown backend type"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.registry.Register(backend); err != nil {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, gin.H{
|
||||||
|
"type": req.Type.String(),
|
||||||
|
"baseUrl": req.BaseURL,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,354 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"vessel-backend/internal/backends"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupAITestRouter(registry *backends.Registry) *gin.Engine {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
r := gin.New()
|
||||||
|
|
||||||
|
handlers := NewAIHandlers(registry)
|
||||||
|
|
||||||
|
ai := r.Group("/api/v1/ai")
|
||||||
|
{
|
||||||
|
ai.GET("/backends", handlers.ListBackendsHandler())
|
||||||
|
ai.POST("/backends/discover", handlers.DiscoverBackendsHandler())
|
||||||
|
ai.POST("/backends/active", handlers.SetActiveHandler())
|
||||||
|
ai.GET("/backends/:type/health", handlers.HealthCheckHandler())
|
||||||
|
ai.POST("/chat", handlers.ChatHandler())
|
||||||
|
ai.GET("/models", handlers.ListModelsHandler())
|
||||||
|
}
|
||||||
|
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAIHandlers_ListBackends(t *testing.T) {
|
||||||
|
registry := backends.NewRegistry()
|
||||||
|
|
||||||
|
mock := &mockAIBackend{
|
||||||
|
backendType: backends.BackendTypeOllama,
|
||||||
|
config: backends.BackendConfig{
|
||||||
|
Type: backends.BackendTypeOllama,
|
||||||
|
BaseURL: "http://localhost:11434",
|
||||||
|
},
|
||||||
|
info: backends.BackendInfo{
|
||||||
|
Type: backends.BackendTypeOllama,
|
||||||
|
BaseURL: "http://localhost:11434",
|
||||||
|
Status: backends.BackendStatusConnected,
|
||||||
|
Capabilities: backends.OllamaCapabilities(),
|
||||||
|
Version: "0.3.0",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
registry.Register(mock)
|
||||||
|
registry.SetActive(backends.BackendTypeOllama)
|
||||||
|
|
||||||
|
router := setupAITestRouter(registry)
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req, _ := http.NewRequest("GET", "/api/v1/ai/backends", nil)
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("ListBackends() status = %d, want %d", w.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp struct {
|
||||||
|
Backends []backends.BackendInfo `json:"backends"`
|
||||||
|
Active string `json:"active"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("Failed to unmarshal response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(resp.Backends) != 1 {
|
||||||
|
t.Errorf("ListBackends() returned %d backends, want 1", len(resp.Backends))
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.Active != "ollama" {
|
||||||
|
t.Errorf("ListBackends() active = %q, want %q", resp.Active, "ollama")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAIHandlers_SetActive(t *testing.T) {
|
||||||
|
registry := backends.NewRegistry()
|
||||||
|
|
||||||
|
mock := &mockAIBackend{
|
||||||
|
backendType: backends.BackendTypeOllama,
|
||||||
|
config: backends.BackendConfig{
|
||||||
|
Type: backends.BackendTypeOllama,
|
||||||
|
BaseURL: "http://localhost:11434",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
registry.Register(mock)
|
||||||
|
|
||||||
|
router := setupAITestRouter(registry)
|
||||||
|
|
||||||
|
t.Run("set valid backend active", func(t *testing.T) {
|
||||||
|
body, _ := json.Marshal(map[string]string{"type": "ollama"})
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req, _ := http.NewRequest("POST", "/api/v1/ai/backends/active", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("SetActive() status = %d, want %d", w.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
if registry.ActiveType() != backends.BackendTypeOllama {
|
||||||
|
t.Errorf("Active backend = %v, want %v", registry.ActiveType(), backends.BackendTypeOllama)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("set invalid backend active", func(t *testing.T) {
|
||||||
|
body, _ := json.Marshal(map[string]string{"type": "llamacpp"})
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req, _ := http.NewRequest("POST", "/api/v1/ai/backends/active", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("SetActive() status = %d, want %d", w.Code, http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAIHandlers_HealthCheck(t *testing.T) {
|
||||||
|
registry := backends.NewRegistry()
|
||||||
|
|
||||||
|
mock := &mockAIBackend{
|
||||||
|
backendType: backends.BackendTypeOllama,
|
||||||
|
config: backends.BackendConfig{
|
||||||
|
Type: backends.BackendTypeOllama,
|
||||||
|
BaseURL: "http://localhost:11434",
|
||||||
|
},
|
||||||
|
healthErr: nil,
|
||||||
|
}
|
||||||
|
registry.Register(mock)
|
||||||
|
|
||||||
|
router := setupAITestRouter(registry)
|
||||||
|
|
||||||
|
t.Run("healthy backend", func(t *testing.T) {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req, _ := http.NewRequest("GET", "/api/v1/ai/backends/ollama/health", nil)
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("HealthCheck() status = %d, want %d", w.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("non-existent backend", func(t *testing.T) {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req, _ := http.NewRequest("GET", "/api/v1/ai/backends/llamacpp/health", nil)
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("HealthCheck() status = %d, want %d", w.Code, http.StatusNotFound)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAIHandlers_ListModels(t *testing.T) {
|
||||||
|
registry := backends.NewRegistry()
|
||||||
|
|
||||||
|
mock := &mockAIBackend{
|
||||||
|
backendType: backends.BackendTypeOllama,
|
||||||
|
config: backends.BackendConfig{
|
||||||
|
Type: backends.BackendTypeOllama,
|
||||||
|
BaseURL: "http://localhost:11434",
|
||||||
|
},
|
||||||
|
models: []backends.Model{
|
||||||
|
{ID: "llama3.2:8b", Name: "llama3.2:8b", Family: "llama"},
|
||||||
|
{ID: "mistral:7b", Name: "mistral:7b", Family: "mistral"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
registry.Register(mock)
|
||||||
|
registry.SetActive(backends.BackendTypeOllama)
|
||||||
|
|
||||||
|
router := setupAITestRouter(registry)
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req, _ := http.NewRequest("GET", "/api/v1/ai/models", nil)
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("ListModels() status = %d, want %d", w.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp struct {
|
||||||
|
Models []backends.Model `json:"models"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("Failed to unmarshal response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(resp.Models) != 2 {
|
||||||
|
t.Errorf("ListModels() returned %d models, want 2", len(resp.Models))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAIHandlers_ListModels_NoActiveBackend(t *testing.T) {
|
||||||
|
registry := backends.NewRegistry()
|
||||||
|
router := setupAITestRouter(registry)
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req, _ := http.NewRequest("GET", "/api/v1/ai/models", nil)
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusServiceUnavailable {
|
||||||
|
t.Errorf("ListModels() status = %d, want %d", w.Code, http.StatusServiceUnavailable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAIHandlers_Chat(t *testing.T) {
|
||||||
|
registry := backends.NewRegistry()
|
||||||
|
|
||||||
|
mock := &mockAIBackend{
|
||||||
|
backendType: backends.BackendTypeOllama,
|
||||||
|
config: backends.BackendConfig{
|
||||||
|
Type: backends.BackendTypeOllama,
|
||||||
|
BaseURL: "http://localhost:11434",
|
||||||
|
},
|
||||||
|
chatResponse: &backends.ChatChunk{
|
||||||
|
Model: "llama3.2:8b",
|
||||||
|
Message: &backends.ChatMessage{
|
||||||
|
Role: "assistant",
|
||||||
|
Content: "Hello! How can I help?",
|
||||||
|
},
|
||||||
|
Done: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
registry.Register(mock)
|
||||||
|
registry.SetActive(backends.BackendTypeOllama)
|
||||||
|
|
||||||
|
router := setupAITestRouter(registry)
|
||||||
|
|
||||||
|
t.Run("non-streaming chat", func(t *testing.T) {
|
||||||
|
chatReq := backends.ChatRequest{
|
||||||
|
Model: "llama3.2:8b",
|
||||||
|
Messages: []backends.ChatMessage{
|
||||||
|
{Role: "user", Content: "Hello"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(chatReq)
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req, _ := http.NewRequest("POST", "/api/v1/ai/chat", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("Chat() status = %d, want %d, body: %s", w.Code, http.StatusOK, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp backends.ChatChunk
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("Failed to unmarshal response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !resp.Done {
|
||||||
|
t.Error("Chat() response.Done = false, want true")
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.Message == nil || resp.Message.Content != "Hello! How can I help?" {
|
||||||
|
t.Errorf("Chat() unexpected response: %+v", resp)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAIHandlers_Chat_InvalidRequest(t *testing.T) {
|
||||||
|
registry := backends.NewRegistry()
|
||||||
|
|
||||||
|
mock := &mockAIBackend{
|
||||||
|
backendType: backends.BackendTypeOllama,
|
||||||
|
}
|
||||||
|
registry.Register(mock)
|
||||||
|
registry.SetActive(backends.BackendTypeOllama)
|
||||||
|
|
||||||
|
router := setupAITestRouter(registry)
|
||||||
|
|
||||||
|
// Missing model
|
||||||
|
chatReq := map[string]interface{}{
|
||||||
|
"messages": []map[string]string{
|
||||||
|
{"role": "user", "content": "Hello"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(chatReq)
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req, _ := http.NewRequest("POST", "/api/v1/ai/chat", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("Chat() status = %d, want %d", w.Code, http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// mockAIBackend implements backends.LLMBackend for testing
|
||||||
|
type mockAIBackend struct {
|
||||||
|
backendType backends.BackendType
|
||||||
|
config backends.BackendConfig
|
||||||
|
info backends.BackendInfo
|
||||||
|
healthErr error
|
||||||
|
models []backends.Model
|
||||||
|
chatResponse *backends.ChatChunk
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockAIBackend) Type() backends.BackendType {
|
||||||
|
return m.backendType
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockAIBackend) Config() backends.BackendConfig {
|
||||||
|
return m.config
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockAIBackend) HealthCheck(ctx context.Context) error {
|
||||||
|
return m.healthErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockAIBackend) ListModels(ctx context.Context) ([]backends.Model, error) {
|
||||||
|
return m.models, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockAIBackend) StreamChat(ctx context.Context, req *backends.ChatRequest) (<-chan backends.ChatChunk, error) {
|
||||||
|
ch := make(chan backends.ChatChunk, 1)
|
||||||
|
if m.chatResponse != nil {
|
||||||
|
ch <- *m.chatResponse
|
||||||
|
}
|
||||||
|
close(ch)
|
||||||
|
return ch, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockAIBackend) Chat(ctx context.Context, req *backends.ChatRequest) (*backends.ChatChunk, error) {
|
||||||
|
if m.chatResponse != nil {
|
||||||
|
return m.chatResponse, nil
|
||||||
|
}
|
||||||
|
return &backends.ChatChunk{Done: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockAIBackend) Capabilities() backends.BackendCapabilities {
|
||||||
|
return backends.OllamaCapabilities()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockAIBackend) Info(ctx context.Context) backends.BackendInfo {
|
||||||
|
if m.info.Type != "" {
|
||||||
|
return m.info
|
||||||
|
}
|
||||||
|
return backends.BackendInfo{
|
||||||
|
Type: m.backendType,
|
||||||
|
BaseURL: m.config.BaseURL,
|
||||||
|
Status: backends.BackendStatusConnected,
|
||||||
|
Capabilities: m.Capabilities(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,10 +5,12 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"vessel-backend/internal/backends"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SetupRoutes configures all API routes
|
// SetupRoutes configures all API routes
|
||||||
func SetupRoutes(r *gin.Engine, db *sql.DB, ollamaURL string, appVersion string) {
|
func SetupRoutes(r *gin.Engine, db *sql.DB, ollamaURL string, appVersion string, registry *backends.Registry) {
|
||||||
// Initialize Ollama service with official client
|
// Initialize Ollama service with official client
|
||||||
ollamaService, err := NewOllamaService(ollamaURL)
|
ollamaService, err := NewOllamaService(ollamaURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -97,6 +99,24 @@ func SetupRoutes(r *gin.Engine, db *sql.DB, ollamaURL string, appVersion string)
|
|||||||
models.GET("/remote/status", modelRegistry.SyncStatusHandler())
|
models.GET("/remote/status", modelRegistry.SyncStatusHandler())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Unified AI routes (multi-backend support)
|
||||||
|
if registry != nil {
|
||||||
|
aiHandlers := NewAIHandlers(registry)
|
||||||
|
ai := v1.Group("/ai")
|
||||||
|
{
|
||||||
|
// Backend management
|
||||||
|
ai.GET("/backends", aiHandlers.ListBackendsHandler())
|
||||||
|
ai.POST("/backends/discover", aiHandlers.DiscoverBackendsHandler())
|
||||||
|
ai.POST("/backends/active", aiHandlers.SetActiveHandler())
|
||||||
|
ai.GET("/backends/:type/health", aiHandlers.HealthCheckHandler())
|
||||||
|
ai.POST("/backends/register", aiHandlers.RegisterBackendHandler())
|
||||||
|
|
||||||
|
// Unified model and chat endpoints (route to active backend)
|
||||||
|
ai.GET("/models", aiHandlers.ListModelsHandler())
|
||||||
|
ai.POST("/chat", aiHandlers.ChatHandler())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Ollama API routes (using official client)
|
// Ollama API routes (using official client)
|
||||||
if ollamaService != nil {
|
if ollamaService != nil {
|
||||||
ollama := v1.Group("/ollama")
|
ollama := v1.Group("/ollama")
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package backends
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LLMBackend defines the interface for LLM backend implementations.
|
||||||
|
// All backends (Ollama, llama.cpp, LM Studio) must implement this interface.
|
||||||
|
type LLMBackend interface {
|
||||||
|
// Type returns the backend type identifier
|
||||||
|
Type() BackendType
|
||||||
|
|
||||||
|
// Config returns the backend configuration
|
||||||
|
Config() BackendConfig
|
||||||
|
|
||||||
|
// HealthCheck verifies the backend is reachable and operational
|
||||||
|
HealthCheck(ctx context.Context) error
|
||||||
|
|
||||||
|
// ListModels returns all models available from this backend
|
||||||
|
ListModels(ctx context.Context) ([]Model, error)
|
||||||
|
|
||||||
|
// StreamChat sends a chat request and returns a channel for streaming responses.
|
||||||
|
// The channel is closed when the stream completes or an error occurs.
|
||||||
|
// Callers should check ChatChunk.Error for stream errors.
|
||||||
|
StreamChat(ctx context.Context, req *ChatRequest) (<-chan ChatChunk, error)
|
||||||
|
|
||||||
|
// Chat sends a non-streaming chat request and returns the final response
|
||||||
|
Chat(ctx context.Context, req *ChatRequest) (*ChatChunk, error)
|
||||||
|
|
||||||
|
// Capabilities returns what features this backend supports
|
||||||
|
Capabilities() BackendCapabilities
|
||||||
|
|
||||||
|
// Info returns detailed information about the backend including status
|
||||||
|
Info(ctx context.Context) BackendInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModelManager extends LLMBackend with model management capabilities.
|
||||||
|
// Only Ollama implements this interface.
|
||||||
|
type ModelManager interface {
|
||||||
|
LLMBackend
|
||||||
|
|
||||||
|
// PullModel downloads a model from the registry.
|
||||||
|
// Returns a channel for progress updates.
|
||||||
|
PullModel(ctx context.Context, name string) (<-chan PullProgress, error)
|
||||||
|
|
||||||
|
// DeleteModel removes a model from local storage
|
||||||
|
DeleteModel(ctx context.Context, name string) error
|
||||||
|
|
||||||
|
// CreateModel creates a custom model with the given Modelfile content
|
||||||
|
CreateModel(ctx context.Context, name string, modelfile string) (<-chan CreateProgress, error)
|
||||||
|
|
||||||
|
// CopyModel creates a copy of an existing model
|
||||||
|
CopyModel(ctx context.Context, source, destination string) error
|
||||||
|
|
||||||
|
// ShowModel returns detailed information about a specific model
|
||||||
|
ShowModel(ctx context.Context, name string) (*ModelDetails, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EmbeddingProvider extends LLMBackend with embedding capabilities.
|
||||||
|
type EmbeddingProvider interface {
|
||||||
|
LLMBackend
|
||||||
|
|
||||||
|
// Embed generates embeddings for the given input
|
||||||
|
Embed(ctx context.Context, model string, input []string) ([][]float64, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PullProgress represents progress during model download
|
||||||
|
type PullProgress struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Digest string `json:"digest,omitempty"`
|
||||||
|
Total int64 `json:"total,omitempty"`
|
||||||
|
Completed int64 `json:"completed,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateProgress represents progress during model creation
|
||||||
|
type CreateProgress struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModelDetails contains detailed information about a model
|
||||||
|
type ModelDetails struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
ModifiedAt string `json:"modified_at"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
Digest string `json:"digest"`
|
||||||
|
Format string `json:"format"`
|
||||||
|
Family string `json:"family"`
|
||||||
|
Families []string `json:"families"`
|
||||||
|
ParamSize string `json:"parameter_size"`
|
||||||
|
QuantLevel string `json:"quantization_level"`
|
||||||
|
Template string `json:"template"`
|
||||||
|
System string `json:"system"`
|
||||||
|
License string `json:"license"`
|
||||||
|
Modelfile string `json:"modelfile"`
|
||||||
|
Parameters map[string]string `json:"parameters"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,624 @@
|
|||||||
|
package ollama
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"vessel-backend/internal/backends"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Adapter implements the LLMBackend interface for Ollama.
|
||||||
|
// It also implements ModelManager and EmbeddingProvider.
|
||||||
|
type Adapter struct {
|
||||||
|
config backends.BackendConfig
|
||||||
|
httpClient *http.Client
|
||||||
|
baseURL *url.URL
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure Adapter implements all required interfaces
|
||||||
|
var (
|
||||||
|
_ backends.LLMBackend = (*Adapter)(nil)
|
||||||
|
_ backends.ModelManager = (*Adapter)(nil)
|
||||||
|
_ backends.EmbeddingProvider = (*Adapter)(nil)
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewAdapter creates a new Ollama backend adapter
|
||||||
|
func NewAdapter(config backends.BackendConfig) (*Adapter, error) {
|
||||||
|
if config.Type != backends.BackendTypeOllama {
|
||||||
|
return nil, fmt.Errorf("invalid backend type: expected %s, got %s", backends.BackendTypeOllama, config.Type)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := config.Validate(); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
baseURL, err := url.Parse(config.BaseURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid base URL: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Adapter{
|
||||||
|
config: config,
|
||||||
|
baseURL: baseURL,
|
||||||
|
httpClient: &http.Client{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type returns the backend type
|
||||||
|
func (a *Adapter) Type() backends.BackendType {
|
||||||
|
return backends.BackendTypeOllama
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config returns the backend configuration
|
||||||
|
func (a *Adapter) Config() backends.BackendConfig {
|
||||||
|
return a.config
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capabilities returns what features this backend supports
|
||||||
|
func (a *Adapter) Capabilities() backends.BackendCapabilities {
|
||||||
|
return backends.OllamaCapabilities()
|
||||||
|
}
|
||||||
|
|
||||||
|
// HealthCheck verifies the backend is reachable
|
||||||
|
func (a *Adapter) HealthCheck(ctx context.Context) error {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", a.baseURL.String()+"/api/version", nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := a.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to reach Ollama: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("Ollama returned status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ollamaListResponse represents the response from /api/tags
|
||||||
|
type ollamaListResponse struct {
|
||||||
|
Models []ollamaModel `json:"models"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ollamaModel struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
ModifiedAt string `json:"modified_at"`
|
||||||
|
Details ollamaModelDetails `json:"details"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ollamaModelDetails struct {
|
||||||
|
Family string `json:"family"`
|
||||||
|
QuantLevel string `json:"quantization_level"`
|
||||||
|
ParamSize string `json:"parameter_size"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListModels returns all models available from Ollama
|
||||||
|
func (a *Adapter) ListModels(ctx context.Context) ([]backends.Model, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", a.baseURL.String()+"/api/tags", nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := a.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to list models: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
var listResp ollamaListResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&listResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
models := make([]backends.Model, len(listResp.Models))
|
||||||
|
for i, m := range listResp.Models {
|
||||||
|
models[i] = backends.Model{
|
||||||
|
ID: m.Name,
|
||||||
|
Name: m.Name,
|
||||||
|
Size: m.Size,
|
||||||
|
ModifiedAt: m.ModifiedAt,
|
||||||
|
Family: m.Details.Family,
|
||||||
|
QuantLevel: m.Details.QuantLevel,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return models, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chat sends a non-streaming chat request
|
||||||
|
func (a *Adapter) Chat(ctx context.Context, req *backends.ChatRequest) (*backends.ChatChunk, error) {
|
||||||
|
if err := req.Validate(); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to Ollama format
|
||||||
|
ollamaReq := a.convertChatRequest(req)
|
||||||
|
ollamaReq["stream"] = false
|
||||||
|
|
||||||
|
body, err := json.Marshal(ollamaReq)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
httpReq, err := http.NewRequestWithContext(ctx, "POST", a.baseURL.String()+"/api/chat", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
httpReq.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := a.httpClient.Do(httpReq)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("chat request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
var ollamaResp ollamaChatResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&ollamaResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return a.convertChatResponse(&ollamaResp), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StreamChat sends a streaming chat request
|
||||||
|
func (a *Adapter) StreamChat(ctx context.Context, req *backends.ChatRequest) (<-chan backends.ChatChunk, error) {
|
||||||
|
if err := req.Validate(); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to Ollama format
|
||||||
|
ollamaReq := a.convertChatRequest(req)
|
||||||
|
ollamaReq["stream"] = true
|
||||||
|
|
||||||
|
body, err := json.Marshal(ollamaReq)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create HTTP request without timeout for streaming
|
||||||
|
httpReq, err := http.NewRequestWithContext(ctx, "POST", a.baseURL.String()+"/api/chat", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
httpReq.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
// Use a client without timeout for streaming
|
||||||
|
client := &http.Client{}
|
||||||
|
resp, err := client.Do(httpReq)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("chat request failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
chunkCh := make(chan backends.ChatChunk)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer close(chunkCh)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(resp.Body)
|
||||||
|
for scanner.Scan() {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
line := scanner.Bytes()
|
||||||
|
if len(line) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var ollamaResp ollamaChatResponse
|
||||||
|
if err := json.Unmarshal(line, &ollamaResp); err != nil {
|
||||||
|
chunkCh <- backends.ChatChunk{Error: fmt.Sprintf("failed to parse response: %v", err)}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
chunkCh <- *a.convertChatResponse(&ollamaResp)
|
||||||
|
|
||||||
|
if ollamaResp.Done {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := scanner.Err(); err != nil && ctx.Err() == nil {
|
||||||
|
chunkCh <- backends.ChatChunk{Error: fmt.Sprintf("stream error: %v", err)}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return chunkCh, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Info returns detailed information about the backend
|
||||||
|
func (a *Adapter) Info(ctx context.Context) backends.BackendInfo {
|
||||||
|
info := backends.BackendInfo{
|
||||||
|
Type: backends.BackendTypeOllama,
|
||||||
|
BaseURL: a.config.BaseURL,
|
||||||
|
Capabilities: a.Capabilities(),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to get version
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", a.baseURL.String()+"/api/version", nil)
|
||||||
|
if err != nil {
|
||||||
|
info.Status = backends.BackendStatusDisconnected
|
||||||
|
info.Error = err.Error()
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := a.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
info.Status = backends.BackendStatusDisconnected
|
||||||
|
info.Error = err.Error()
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
var versionResp struct {
|
||||||
|
Version string `json:"version"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&versionResp); err != nil {
|
||||||
|
info.Status = backends.BackendStatusDisconnected
|
||||||
|
info.Error = err.Error()
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
|
||||||
|
info.Status = backends.BackendStatusConnected
|
||||||
|
info.Version = versionResp.Version
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShowModel returns detailed information about a specific model
|
||||||
|
func (a *Adapter) ShowModel(ctx context.Context, name string) (*backends.ModelDetails, error) {
|
||||||
|
body, err := json.Marshal(map[string]string{"name": name})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "POST", a.baseURL.String()+"/api/show", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := a.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to show model: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
var showResp struct {
|
||||||
|
Modelfile string `json:"modelfile"`
|
||||||
|
Template string `json:"template"`
|
||||||
|
System string `json:"system"`
|
||||||
|
Details struct {
|
||||||
|
Family string `json:"family"`
|
||||||
|
ParamSize string `json:"parameter_size"`
|
||||||
|
QuantLevel string `json:"quantization_level"`
|
||||||
|
} `json:"details"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&showResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &backends.ModelDetails{
|
||||||
|
Name: name,
|
||||||
|
Family: showResp.Details.Family,
|
||||||
|
ParamSize: showResp.Details.ParamSize,
|
||||||
|
QuantLevel: showResp.Details.QuantLevel,
|
||||||
|
Template: showResp.Template,
|
||||||
|
System: showResp.System,
|
||||||
|
Modelfile: showResp.Modelfile,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PullModel downloads a model from the registry
|
||||||
|
func (a *Adapter) PullModel(ctx context.Context, name string) (<-chan backends.PullProgress, error) {
|
||||||
|
body, err := json.Marshal(map[string]interface{}{"name": name, "stream": true})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "POST", a.baseURL.String()+"/api/pull", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
client := &http.Client{}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to pull model: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
progressCh := make(chan backends.PullProgress)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer close(progressCh)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(resp.Body)
|
||||||
|
for scanner.Scan() {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
var progress struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Digest string `json:"digest"`
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
Completed int64 `json:"completed"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(scanner.Bytes(), &progress); err != nil {
|
||||||
|
progressCh <- backends.PullProgress{Error: err.Error()}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
progressCh <- backends.PullProgress{
|
||||||
|
Status: progress.Status,
|
||||||
|
Digest: progress.Digest,
|
||||||
|
Total: progress.Total,
|
||||||
|
Completed: progress.Completed,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := scanner.Err(); err != nil && ctx.Err() == nil {
|
||||||
|
progressCh <- backends.PullProgress{Error: err.Error()}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return progressCh, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteModel removes a model from local storage
|
||||||
|
func (a *Adapter) DeleteModel(ctx context.Context, name string) error {
|
||||||
|
body, err := json.Marshal(map[string]string{"name": name})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "DELETE", a.baseURL.String()+"/api/delete", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := a.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to delete model: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||||
|
return fmt.Errorf("delete failed: %s", string(bodyBytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateModel creates a custom model with the given Modelfile content
|
||||||
|
func (a *Adapter) CreateModel(ctx context.Context, name string, modelfile string) (<-chan backends.CreateProgress, error) {
|
||||||
|
body, err := json.Marshal(map[string]interface{}{
|
||||||
|
"name": name,
|
||||||
|
"modelfile": modelfile,
|
||||||
|
"stream": true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "POST", a.baseURL.String()+"/api/create", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
client := &http.Client{}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create model: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
progressCh := make(chan backends.CreateProgress)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer close(progressCh)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(resp.Body)
|
||||||
|
for scanner.Scan() {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
var progress struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(scanner.Bytes(), &progress); err != nil {
|
||||||
|
progressCh <- backends.CreateProgress{Error: err.Error()}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
progressCh <- backends.CreateProgress{Status: progress.Status}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := scanner.Err(); err != nil && ctx.Err() == nil {
|
||||||
|
progressCh <- backends.CreateProgress{Error: err.Error()}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return progressCh, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CopyModel creates a copy of an existing model
|
||||||
|
func (a *Adapter) CopyModel(ctx context.Context, source, destination string) error {
|
||||||
|
body, err := json.Marshal(map[string]string{
|
||||||
|
"source": source,
|
||||||
|
"destination": destination,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "POST", a.baseURL.String()+"/api/copy", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := a.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to copy model: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||||
|
return fmt.Errorf("copy failed: %s", string(bodyBytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Embed generates embeddings for the given input
|
||||||
|
func (a *Adapter) Embed(ctx context.Context, model string, input []string) ([][]float64, error) {
|
||||||
|
body, err := json.Marshal(map[string]interface{}{
|
||||||
|
"model": model,
|
||||||
|
"input": input,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "POST", a.baseURL.String()+"/api/embed", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := a.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("embed request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
var embedResp struct {
|
||||||
|
Embeddings [][]float64 `json:"embeddings"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&embedResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return embedResp.Embeddings, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ollamaChatResponse represents the response from /api/chat
|
||||||
|
type ollamaChatResponse struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
Message ollamaChatMessage `json:"message"`
|
||||||
|
Done bool `json:"done"`
|
||||||
|
DoneReason string `json:"done_reason,omitempty"`
|
||||||
|
PromptEvalCount int `json:"prompt_eval_count,omitempty"`
|
||||||
|
EvalCount int `json:"eval_count,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ollamaChatMessage struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Images []string `json:"images,omitempty"`
|
||||||
|
ToolCalls []ollamaToolCall `json:"tool_calls,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ollamaToolCall struct {
|
||||||
|
Function struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Arguments json.RawMessage `json:"arguments"`
|
||||||
|
} `json:"function"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// convertChatRequest converts a backends.ChatRequest to Ollama format
|
||||||
|
func (a *Adapter) convertChatRequest(req *backends.ChatRequest) map[string]interface{} {
|
||||||
|
messages := make([]map[string]interface{}, len(req.Messages))
|
||||||
|
for i, msg := range req.Messages {
|
||||||
|
m := map[string]interface{}{
|
||||||
|
"role": msg.Role,
|
||||||
|
"content": msg.Content,
|
||||||
|
}
|
||||||
|
if len(msg.Images) > 0 {
|
||||||
|
m["images"] = msg.Images
|
||||||
|
}
|
||||||
|
messages[i] = m
|
||||||
|
}
|
||||||
|
|
||||||
|
ollamaReq := map[string]interface{}{
|
||||||
|
"model": req.Model,
|
||||||
|
"messages": messages,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add optional parameters
|
||||||
|
if req.Options != nil {
|
||||||
|
ollamaReq["options"] = req.Options
|
||||||
|
}
|
||||||
|
if len(req.Tools) > 0 {
|
||||||
|
ollamaReq["tools"] = req.Tools
|
||||||
|
}
|
||||||
|
|
||||||
|
return ollamaReq
|
||||||
|
}
|
||||||
|
|
||||||
|
// convertChatResponse converts an Ollama response to backends.ChatChunk
|
||||||
|
func (a *Adapter) convertChatResponse(resp *ollamaChatResponse) *backends.ChatChunk {
|
||||||
|
chunk := &backends.ChatChunk{
|
||||||
|
Model: resp.Model,
|
||||||
|
CreatedAt: resp.CreatedAt,
|
||||||
|
Done: resp.Done,
|
||||||
|
DoneReason: resp.DoneReason,
|
||||||
|
PromptEvalCount: resp.PromptEvalCount,
|
||||||
|
EvalCount: resp.EvalCount,
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.Message.Role != "" || resp.Message.Content != "" {
|
||||||
|
msg := &backends.ChatMessage{
|
||||||
|
Role: resp.Message.Role,
|
||||||
|
Content: resp.Message.Content,
|
||||||
|
Images: resp.Message.Images,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert tool calls
|
||||||
|
for _, tc := range resp.Message.ToolCalls {
|
||||||
|
msg.ToolCalls = append(msg.ToolCalls, backends.ToolCall{
|
||||||
|
Type: "function",
|
||||||
|
Function: struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Arguments string `json:"arguments"`
|
||||||
|
}{
|
||||||
|
Name: tc.Function.Name,
|
||||||
|
Arguments: string(tc.Function.Arguments),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
chunk.Message = msg
|
||||||
|
}
|
||||||
|
|
||||||
|
return chunk
|
||||||
|
}
|
||||||
@@ -0,0 +1,574 @@
|
|||||||
|
package ollama
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"vessel-backend/internal/backends"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAdapter_Type(t *testing.T) {
|
||||||
|
adapter, _ := NewAdapter(backends.BackendConfig{
|
||||||
|
Type: backends.BackendTypeOllama,
|
||||||
|
BaseURL: "http://localhost:11434",
|
||||||
|
})
|
||||||
|
|
||||||
|
if adapter.Type() != backends.BackendTypeOllama {
|
||||||
|
t.Errorf("Type() = %v, want %v", adapter.Type(), backends.BackendTypeOllama)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdapter_Config(t *testing.T) {
|
||||||
|
cfg := backends.BackendConfig{
|
||||||
|
Type: backends.BackendTypeOllama,
|
||||||
|
BaseURL: "http://localhost:11434",
|
||||||
|
Enabled: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
adapter, _ := NewAdapter(cfg)
|
||||||
|
got := adapter.Config()
|
||||||
|
|
||||||
|
if got.Type != cfg.Type {
|
||||||
|
t.Errorf("Config().Type = %v, want %v", got.Type, cfg.Type)
|
||||||
|
}
|
||||||
|
if got.BaseURL != cfg.BaseURL {
|
||||||
|
t.Errorf("Config().BaseURL = %v, want %v", got.BaseURL, cfg.BaseURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdapter_Capabilities(t *testing.T) {
|
||||||
|
adapter, _ := NewAdapter(backends.BackendConfig{
|
||||||
|
Type: backends.BackendTypeOllama,
|
||||||
|
BaseURL: "http://localhost:11434",
|
||||||
|
})
|
||||||
|
|
||||||
|
caps := adapter.Capabilities()
|
||||||
|
|
||||||
|
if !caps.CanListModels {
|
||||||
|
t.Error("Ollama adapter should support listing models")
|
||||||
|
}
|
||||||
|
if !caps.CanPullModels {
|
||||||
|
t.Error("Ollama adapter should support pulling models")
|
||||||
|
}
|
||||||
|
if !caps.CanDeleteModels {
|
||||||
|
t.Error("Ollama adapter should support deleting models")
|
||||||
|
}
|
||||||
|
if !caps.CanCreateModels {
|
||||||
|
t.Error("Ollama adapter should support creating models")
|
||||||
|
}
|
||||||
|
if !caps.CanStreamChat {
|
||||||
|
t.Error("Ollama adapter should support streaming chat")
|
||||||
|
}
|
||||||
|
if !caps.CanEmbed {
|
||||||
|
t.Error("Ollama adapter should support embeddings")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdapter_HealthCheck(t *testing.T) {
|
||||||
|
t.Run("healthy server", func(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/" || r.URL.Path == "/api/version" {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"version": "0.1.0"})
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
adapter, err := NewAdapter(backends.BackendConfig{
|
||||||
|
Type: backends.BackendTypeOllama,
|
||||||
|
BaseURL: server.URL,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create adapter: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := adapter.HealthCheck(ctx); err != nil {
|
||||||
|
t.Errorf("HealthCheck() error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("unreachable server", func(t *testing.T) {
|
||||||
|
adapter, err := NewAdapter(backends.BackendConfig{
|
||||||
|
Type: backends.BackendTypeOllama,
|
||||||
|
BaseURL: "http://localhost:19999", // unlikely to be running
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create adapter: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := adapter.HealthCheck(ctx); err == nil {
|
||||||
|
t.Error("HealthCheck() expected error for unreachable server")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdapter_ListModels(t *testing.T) {
|
||||||
|
t.Run("returns model list", func(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/api/tags" {
|
||||||
|
resp := map[string]interface{}{
|
||||||
|
"models": []map[string]interface{}{
|
||||||
|
{
|
||||||
|
"name": "llama3.2:8b",
|
||||||
|
"size": int64(4700000000),
|
||||||
|
"modified_at": "2024-01-15T10:30:00Z",
|
||||||
|
"details": map[string]interface{}{
|
||||||
|
"family": "llama",
|
||||||
|
"quantization_level": "Q4_K_M",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "mistral:7b",
|
||||||
|
"size": int64(4100000000),
|
||||||
|
"modified_at": "2024-01-14T08:00:00Z",
|
||||||
|
"details": map[string]interface{}{
|
||||||
|
"family": "mistral",
|
||||||
|
"quantization_level": "Q4_0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
json.NewEncoder(w).Encode(resp)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
adapter, _ := NewAdapter(backends.BackendConfig{
|
||||||
|
Type: backends.BackendTypeOllama,
|
||||||
|
BaseURL: server.URL,
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
models, err := adapter.ListModels(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListModels() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(models) != 2 {
|
||||||
|
t.Errorf("ListModels() returned %d models, want 2", len(models))
|
||||||
|
}
|
||||||
|
|
||||||
|
if models[0].Name != "llama3.2:8b" {
|
||||||
|
t.Errorf("First model name = %q, want %q", models[0].Name, "llama3.2:8b")
|
||||||
|
}
|
||||||
|
|
||||||
|
if models[0].Family != "llama" {
|
||||||
|
t.Errorf("First model family = %q, want %q", models[0].Family, "llama")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("handles empty model list", func(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/api/tags" {
|
||||||
|
resp := map[string]interface{}{
|
||||||
|
"models": []map[string]interface{}{},
|
||||||
|
}
|
||||||
|
json.NewEncoder(w).Encode(resp)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
adapter, _ := NewAdapter(backends.BackendConfig{
|
||||||
|
Type: backends.BackendTypeOllama,
|
||||||
|
BaseURL: server.URL,
|
||||||
|
})
|
||||||
|
|
||||||
|
models, err := adapter.ListModels(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListModels() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(models) != 0 {
|
||||||
|
t.Errorf("ListModels() returned %d models, want 0", len(models))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdapter_Chat(t *testing.T) {
|
||||||
|
t.Run("non-streaming chat", func(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/api/chat" && r.Method == "POST" {
|
||||||
|
var req map[string]interface{}
|
||||||
|
json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
|
||||||
|
// Check stream is false
|
||||||
|
if stream, ok := req["stream"].(bool); !ok || stream {
|
||||||
|
t.Error("Expected stream=false for non-streaming chat")
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := map[string]interface{}{
|
||||||
|
"model": "llama3.2:8b",
|
||||||
|
"message": map[string]interface{}{"role": "assistant", "content": "Hello! How can I help you?"},
|
||||||
|
"done": true,
|
||||||
|
}
|
||||||
|
json.NewEncoder(w).Encode(resp)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
adapter, _ := NewAdapter(backends.BackendConfig{
|
||||||
|
Type: backends.BackendTypeOllama,
|
||||||
|
BaseURL: server.URL,
|
||||||
|
})
|
||||||
|
|
||||||
|
req := &backends.ChatRequest{
|
||||||
|
Model: "llama3.2:8b",
|
||||||
|
Messages: []backends.ChatMessage{
|
||||||
|
{Role: "user", Content: "Hello"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := adapter.Chat(context.Background(), req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Chat() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !resp.Done {
|
||||||
|
t.Error("Chat() response.Done = false, want true")
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.Message == nil || resp.Message.Content != "Hello! How can I help you?" {
|
||||||
|
t.Errorf("Chat() response content unexpected: %+v", resp.Message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdapter_StreamChat(t *testing.T) {
|
||||||
|
t.Run("streaming chat", func(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/api/chat" && r.Method == "POST" {
|
||||||
|
var req map[string]interface{}
|
||||||
|
json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
|
||||||
|
// Check stream is true
|
||||||
|
if stream, ok := req["stream"].(bool); ok && !stream {
|
||||||
|
t.Error("Expected stream=true for streaming chat")
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/x-ndjson")
|
||||||
|
flusher := w.(http.Flusher)
|
||||||
|
|
||||||
|
// Send streaming chunks
|
||||||
|
chunks := []map[string]interface{}{
|
||||||
|
{"model": "llama3.2:8b", "message": map[string]interface{}{"role": "assistant", "content": "Hello"}, "done": false},
|
||||||
|
{"model": "llama3.2:8b", "message": map[string]interface{}{"role": "assistant", "content": "!"}, "done": false},
|
||||||
|
{"model": "llama3.2:8b", "message": map[string]interface{}{"role": "assistant", "content": ""}, "done": true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, chunk := range chunks {
|
||||||
|
data, _ := json.Marshal(chunk)
|
||||||
|
w.Write(append(data, '\n'))
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
adapter, _ := NewAdapter(backends.BackendConfig{
|
||||||
|
Type: backends.BackendTypeOllama,
|
||||||
|
BaseURL: server.URL,
|
||||||
|
})
|
||||||
|
|
||||||
|
streaming := true
|
||||||
|
req := &backends.ChatRequest{
|
||||||
|
Model: "llama3.2:8b",
|
||||||
|
Messages: []backends.ChatMessage{
|
||||||
|
{Role: "user", Content: "Hello"},
|
||||||
|
},
|
||||||
|
Stream: &streaming,
|
||||||
|
}
|
||||||
|
|
||||||
|
chunkCh, err := adapter.StreamChat(context.Background(), req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("StreamChat() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var chunks []backends.ChatChunk
|
||||||
|
for chunk := range chunkCh {
|
||||||
|
chunks = append(chunks, chunk)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(chunks) != 3 {
|
||||||
|
t.Errorf("StreamChat() received %d chunks, want 3", len(chunks))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last chunk should be done
|
||||||
|
if !chunks[len(chunks)-1].Done {
|
||||||
|
t.Error("Last chunk should have Done=true")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("handles context cancellation", func(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/api/chat" {
|
||||||
|
w.Header().Set("Content-Type", "application/x-ndjson")
|
||||||
|
flusher := w.(http.Flusher)
|
||||||
|
|
||||||
|
// Send first chunk then wait
|
||||||
|
chunk := map[string]interface{}{"model": "llama3.2:8b", "message": map[string]interface{}{"role": "assistant", "content": "Starting..."}, "done": false}
|
||||||
|
data, _ := json.Marshal(chunk)
|
||||||
|
w.Write(append(data, '\n'))
|
||||||
|
flusher.Flush()
|
||||||
|
|
||||||
|
// Wait long enough for context to be cancelled
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
adapter, _ := NewAdapter(backends.BackendConfig{
|
||||||
|
Type: backends.BackendTypeOllama,
|
||||||
|
BaseURL: server.URL,
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
streaming := true
|
||||||
|
req := &backends.ChatRequest{
|
||||||
|
Model: "llama3.2:8b",
|
||||||
|
Messages: []backends.ChatMessage{
|
||||||
|
{Role: "user", Content: "Hello"},
|
||||||
|
},
|
||||||
|
Stream: &streaming,
|
||||||
|
}
|
||||||
|
|
||||||
|
chunkCh, err := adapter.StreamChat(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("StreamChat() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should receive at least one chunk before timeout
|
||||||
|
receivedChunks := 0
|
||||||
|
for range chunkCh {
|
||||||
|
receivedChunks++
|
||||||
|
}
|
||||||
|
|
||||||
|
if receivedChunks == 0 {
|
||||||
|
t.Error("Expected to receive at least one chunk before cancellation")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdapter_Info(t *testing.T) {
|
||||||
|
t.Run("connected server", func(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/" || r.URL.Path == "/api/version" {
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"version": "0.3.0"})
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
adapter, _ := NewAdapter(backends.BackendConfig{
|
||||||
|
Type: backends.BackendTypeOllama,
|
||||||
|
BaseURL: server.URL,
|
||||||
|
})
|
||||||
|
|
||||||
|
info := adapter.Info(context.Background())
|
||||||
|
|
||||||
|
if info.Type != backends.BackendTypeOllama {
|
||||||
|
t.Errorf("Info().Type = %v, want %v", info.Type, backends.BackendTypeOllama)
|
||||||
|
}
|
||||||
|
|
||||||
|
if info.Status != backends.BackendStatusConnected {
|
||||||
|
t.Errorf("Info().Status = %v, want %v", info.Status, backends.BackendStatusConnected)
|
||||||
|
}
|
||||||
|
|
||||||
|
if info.Version != "0.3.0" {
|
||||||
|
t.Errorf("Info().Version = %v, want %v", info.Version, "0.3.0")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("disconnected server", func(t *testing.T) {
|
||||||
|
adapter, _ := NewAdapter(backends.BackendConfig{
|
||||||
|
Type: backends.BackendTypeOllama,
|
||||||
|
BaseURL: "http://localhost:19999",
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
info := adapter.Info(ctx)
|
||||||
|
|
||||||
|
if info.Status != backends.BackendStatusDisconnected {
|
||||||
|
t.Errorf("Info().Status = %v, want %v", info.Status, backends.BackendStatusDisconnected)
|
||||||
|
}
|
||||||
|
|
||||||
|
if info.Error == "" {
|
||||||
|
t.Error("Info().Error should be set for disconnected server")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdapter_ShowModel(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/api/show" && r.Method == "POST" {
|
||||||
|
var req map[string]string
|
||||||
|
json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
|
||||||
|
resp := map[string]interface{}{
|
||||||
|
"modelfile": "FROM llama3.2:8b\nSYSTEM You are helpful.",
|
||||||
|
"template": "{{ .Prompt }}",
|
||||||
|
"system": "You are helpful.",
|
||||||
|
"details": map[string]interface{}{
|
||||||
|
"family": "llama",
|
||||||
|
"parameter_size": "8B",
|
||||||
|
"quantization_level": "Q4_K_M",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
json.NewEncoder(w).Encode(resp)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
adapter, _ := NewAdapter(backends.BackendConfig{
|
||||||
|
Type: backends.BackendTypeOllama,
|
||||||
|
BaseURL: server.URL,
|
||||||
|
})
|
||||||
|
|
||||||
|
details, err := adapter.ShowModel(context.Background(), "llama3.2:8b")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ShowModel() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if details.Family != "llama" {
|
||||||
|
t.Errorf("ShowModel().Family = %q, want %q", details.Family, "llama")
|
||||||
|
}
|
||||||
|
|
||||||
|
if details.System != "You are helpful." {
|
||||||
|
t.Errorf("ShowModel().System = %q, want %q", details.System, "You are helpful.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdapter_DeleteModel(t *testing.T) {
|
||||||
|
deleted := false
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/api/delete" && r.Method == "DELETE" {
|
||||||
|
deleted = true
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
adapter, _ := NewAdapter(backends.BackendConfig{
|
||||||
|
Type: backends.BackendTypeOllama,
|
||||||
|
BaseURL: server.URL,
|
||||||
|
})
|
||||||
|
|
||||||
|
err := adapter.DeleteModel(context.Background(), "test-model")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DeleteModel() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !deleted {
|
||||||
|
t.Error("DeleteModel() did not call the delete endpoint")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdapter_CopyModel(t *testing.T) {
|
||||||
|
copied := false
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/api/copy" && r.Method == "POST" {
|
||||||
|
var req map[string]string
|
||||||
|
json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
|
||||||
|
if req["source"] == "source-model" && req["destination"] == "dest-model" {
|
||||||
|
copied = true
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
adapter, _ := NewAdapter(backends.BackendConfig{
|
||||||
|
Type: backends.BackendTypeOllama,
|
||||||
|
BaseURL: server.URL,
|
||||||
|
})
|
||||||
|
|
||||||
|
err := adapter.CopyModel(context.Background(), "source-model", "dest-model")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CopyModel() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !copied {
|
||||||
|
t.Error("CopyModel() did not call the copy endpoint with correct params")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdapter_Embed(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/api/embed" && r.Method == "POST" {
|
||||||
|
resp := map[string]interface{}{
|
||||||
|
"embeddings": [][]float64{
|
||||||
|
{0.1, 0.2, 0.3},
|
||||||
|
{0.4, 0.5, 0.6},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
json.NewEncoder(w).Encode(resp)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
adapter, _ := NewAdapter(backends.BackendConfig{
|
||||||
|
Type: backends.BackendTypeOllama,
|
||||||
|
BaseURL: server.URL,
|
||||||
|
})
|
||||||
|
|
||||||
|
embeddings, err := adapter.Embed(context.Background(), "nomic-embed-text", []string{"hello", "world"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Embed() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(embeddings) != 2 {
|
||||||
|
t.Errorf("Embed() returned %d embeddings, want 2", len(embeddings))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(embeddings[0]) != 3 {
|
||||||
|
t.Errorf("First embedding has %d dimensions, want 3", len(embeddings[0]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewAdapter_Validation(t *testing.T) {
|
||||||
|
t.Run("invalid URL", func(t *testing.T) {
|
||||||
|
_, err := NewAdapter(backends.BackendConfig{
|
||||||
|
Type: backends.BackendTypeOllama,
|
||||||
|
BaseURL: "not-a-url",
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Error("NewAdapter() should fail with invalid URL")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("wrong backend type", func(t *testing.T) {
|
||||||
|
_, err := NewAdapter(backends.BackendConfig{
|
||||||
|
Type: backends.BackendTypeLlamaCpp,
|
||||||
|
BaseURL: "http://localhost:11434",
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Error("NewAdapter() should fail with wrong backend type")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("valid config", func(t *testing.T) {
|
||||||
|
adapter, err := NewAdapter(backends.BackendConfig{
|
||||||
|
Type: backends.BackendTypeOllama,
|
||||||
|
BaseURL: "http://localhost:11434",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("NewAdapter() error = %v", err)
|
||||||
|
}
|
||||||
|
if adapter == nil {
|
||||||
|
t.Error("NewAdapter() returned nil adapter")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,538 @@
|
|||||||
|
package openai
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"vessel-backend/internal/backends"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Adapter implements the LLMBackend interface for OpenAI-compatible APIs.
|
||||||
|
// This includes llama.cpp server and LM Studio.
|
||||||
|
type Adapter struct {
|
||||||
|
config backends.BackendConfig
|
||||||
|
httpClient *http.Client
|
||||||
|
baseURL *url.URL
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure Adapter implements required interfaces
|
||||||
|
var (
|
||||||
|
_ backends.LLMBackend = (*Adapter)(nil)
|
||||||
|
_ backends.EmbeddingProvider = (*Adapter)(nil)
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewAdapter creates a new OpenAI-compatible backend adapter
|
||||||
|
func NewAdapter(config backends.BackendConfig) (*Adapter, error) {
|
||||||
|
if config.Type != backends.BackendTypeLlamaCpp && config.Type != backends.BackendTypeLMStudio {
|
||||||
|
return nil, fmt.Errorf("invalid backend type: expected %s or %s, got %s",
|
||||||
|
backends.BackendTypeLlamaCpp, backends.BackendTypeLMStudio, config.Type)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := config.Validate(); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
baseURL, err := url.Parse(config.BaseURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid base URL: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Adapter{
|
||||||
|
config: config,
|
||||||
|
baseURL: baseURL,
|
||||||
|
httpClient: &http.Client{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type returns the backend type
|
||||||
|
func (a *Adapter) Type() backends.BackendType {
|
||||||
|
return a.config.Type
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config returns the backend configuration
|
||||||
|
func (a *Adapter) Config() backends.BackendConfig {
|
||||||
|
return a.config
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capabilities returns what features this backend supports
|
||||||
|
func (a *Adapter) Capabilities() backends.BackendCapabilities {
|
||||||
|
if a.config.Type == backends.BackendTypeLlamaCpp {
|
||||||
|
return backends.LlamaCppCapabilities()
|
||||||
|
}
|
||||||
|
return backends.LMStudioCapabilities()
|
||||||
|
}
|
||||||
|
|
||||||
|
// HealthCheck verifies the backend is reachable
|
||||||
|
func (a *Adapter) HealthCheck(ctx context.Context) error {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", a.baseURL.String()+"/v1/models", nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := a.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to reach backend: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("backend returned status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// openaiModelsResponse represents the response from /v1/models
|
||||||
|
type openaiModelsResponse struct {
|
||||||
|
Data []openaiModel `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type openaiModel struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Object string `json:"object"`
|
||||||
|
OwnedBy string `json:"owned_by"`
|
||||||
|
Created int64 `json:"created"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListModels returns all models available from this backend
|
||||||
|
func (a *Adapter) ListModels(ctx context.Context) ([]backends.Model, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", a.baseURL.String()+"/v1/models", nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := a.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to list models: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
var listResp openaiModelsResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&listResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
models := make([]backends.Model, len(listResp.Data))
|
||||||
|
for i, m := range listResp.Data {
|
||||||
|
models[i] = backends.Model{
|
||||||
|
ID: m.ID,
|
||||||
|
Name: m.ID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return models, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chat sends a non-streaming chat request
|
||||||
|
func (a *Adapter) Chat(ctx context.Context, req *backends.ChatRequest) (*backends.ChatChunk, error) {
|
||||||
|
if err := req.Validate(); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
openaiReq := a.convertChatRequest(req)
|
||||||
|
openaiReq["stream"] = false
|
||||||
|
|
||||||
|
body, err := json.Marshal(openaiReq)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
httpReq, err := http.NewRequestWithContext(ctx, "POST", a.baseURL.String()+"/v1/chat/completions", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
httpReq.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := a.httpClient.Do(httpReq)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("chat request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
var openaiResp openaiChatResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&openaiResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return a.convertChatResponse(&openaiResp), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StreamChat sends a streaming chat request
|
||||||
|
func (a *Adapter) StreamChat(ctx context.Context, req *backends.ChatRequest) (<-chan backends.ChatChunk, error) {
|
||||||
|
if err := req.Validate(); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
openaiReq := a.convertChatRequest(req)
|
||||||
|
openaiReq["stream"] = true
|
||||||
|
|
||||||
|
body, err := json.Marshal(openaiReq)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
httpReq, err := http.NewRequestWithContext(ctx, "POST", a.baseURL.String()+"/v1/chat/completions", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
httpReq.Header.Set("Content-Type", "application/json")
|
||||||
|
httpReq.Header.Set("Accept", "text/event-stream")
|
||||||
|
|
||||||
|
// Use a client without timeout for streaming
|
||||||
|
client := &http.Client{}
|
||||||
|
resp, err := client.Do(httpReq)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("chat request failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
chunkCh := make(chan backends.ChatChunk)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer close(chunkCh)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
a.parseSSEStream(ctx, resp.Body, chunkCh)
|
||||||
|
}()
|
||||||
|
|
||||||
|
return chunkCh, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseSSEStream parses Server-Sent Events and emits ChatChunks
|
||||||
|
func (a *Adapter) parseSSEStream(ctx context.Context, body io.Reader, chunkCh chan<- backends.ChatChunk) {
|
||||||
|
scanner := bufio.NewScanner(body)
|
||||||
|
|
||||||
|
// Track accumulated tool call arguments
|
||||||
|
toolCallArgs := make(map[int]string)
|
||||||
|
|
||||||
|
for scanner.Scan() {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
line := scanner.Text()
|
||||||
|
|
||||||
|
// Skip empty lines and comments
|
||||||
|
if line == "" || strings.HasPrefix(line, ":") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse SSE data line
|
||||||
|
if !strings.HasPrefix(line, "data: ") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
data := strings.TrimPrefix(line, "data: ")
|
||||||
|
|
||||||
|
// Check for stream end
|
||||||
|
if data == "[DONE]" {
|
||||||
|
chunkCh <- backends.ChatChunk{Done: true}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var streamResp openaiStreamResponse
|
||||||
|
if err := json.Unmarshal([]byte(data), &streamResp); err != nil {
|
||||||
|
chunkCh <- backends.ChatChunk{Error: fmt.Sprintf("failed to parse SSE data: %v", err)}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
chunk := a.convertStreamResponse(&streamResp, toolCallArgs)
|
||||||
|
chunkCh <- chunk
|
||||||
|
|
||||||
|
if chunk.Done {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := scanner.Err(); err != nil && ctx.Err() == nil {
|
||||||
|
chunkCh <- backends.ChatChunk{Error: fmt.Sprintf("stream error: %v", err)}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Info returns detailed information about the backend
|
||||||
|
func (a *Adapter) Info(ctx context.Context) backends.BackendInfo {
|
||||||
|
info := backends.BackendInfo{
|
||||||
|
Type: a.config.Type,
|
||||||
|
BaseURL: a.config.BaseURL,
|
||||||
|
Capabilities: a.Capabilities(),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to reach the models endpoint
|
||||||
|
if err := a.HealthCheck(ctx); err != nil {
|
||||||
|
info.Status = backends.BackendStatusDisconnected
|
||||||
|
info.Error = err.Error()
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
|
||||||
|
info.Status = backends.BackendStatusConnected
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
|
||||||
|
// Embed generates embeddings for the given input
|
||||||
|
func (a *Adapter) Embed(ctx context.Context, model string, input []string) ([][]float64, error) {
|
||||||
|
body, err := json.Marshal(map[string]interface{}{
|
||||||
|
"model": model,
|
||||||
|
"input": input,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "POST", a.baseURL.String()+"/v1/embeddings", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := a.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("embed request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
var embedResp struct {
|
||||||
|
Data []struct {
|
||||||
|
Embedding []float64 `json:"embedding"`
|
||||||
|
Index int `json:"index"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&embedResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
embeddings := make([][]float64, len(embedResp.Data))
|
||||||
|
for _, d := range embedResp.Data {
|
||||||
|
embeddings[d.Index] = d.Embedding
|
||||||
|
}
|
||||||
|
|
||||||
|
return embeddings, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenAI API response types
|
||||||
|
|
||||||
|
type openaiChatResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Object string `json:"object"`
|
||||||
|
Created int64 `json:"created"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
Choices []openaiChoice `json:"choices"`
|
||||||
|
Usage *openaiUsage `json:"usage,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type openaiChoice struct {
|
||||||
|
Index int `json:"index"`
|
||||||
|
Message *openaiMessage `json:"message,omitempty"`
|
||||||
|
Delta *openaiMessage `json:"delta,omitempty"`
|
||||||
|
FinishReason string `json:"finish_reason,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type openaiMessage struct {
|
||||||
|
Role string `json:"role,omitempty"`
|
||||||
|
Content string `json:"content,omitempty"`
|
||||||
|
ToolCalls []openaiToolCall `json:"tool_calls,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type openaiToolCall struct {
|
||||||
|
ID string `json:"id,omitempty"`
|
||||||
|
Index int `json:"index,omitempty"`
|
||||||
|
Type string `json:"type,omitempty"`
|
||||||
|
Function struct {
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Arguments string `json:"arguments,omitempty"`
|
||||||
|
} `json:"function"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type openaiUsage struct {
|
||||||
|
PromptTokens int `json:"prompt_tokens"`
|
||||||
|
CompletionTokens int `json:"completion_tokens"`
|
||||||
|
TotalTokens int `json:"total_tokens"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type openaiStreamResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Object string `json:"object"`
|
||||||
|
Created int64 `json:"created"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
Choices []openaiChoice `json:"choices"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// convertChatRequest converts a backends.ChatRequest to OpenAI format
|
||||||
|
func (a *Adapter) convertChatRequest(req *backends.ChatRequest) map[string]interface{} {
|
||||||
|
messages := make([]map[string]interface{}, len(req.Messages))
|
||||||
|
for i, msg := range req.Messages {
|
||||||
|
m := map[string]interface{}{
|
||||||
|
"role": msg.Role,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle messages with images (vision support)
|
||||||
|
if len(msg.Images) > 0 {
|
||||||
|
// Build content as array of parts for multimodal messages
|
||||||
|
contentParts := make([]map[string]interface{}, 0, len(msg.Images)+1)
|
||||||
|
|
||||||
|
// Add text part if content is not empty
|
||||||
|
if msg.Content != "" {
|
||||||
|
contentParts = append(contentParts, map[string]interface{}{
|
||||||
|
"type": "text",
|
||||||
|
"text": msg.Content,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add image parts
|
||||||
|
for _, img := range msg.Images {
|
||||||
|
// Images are expected as base64 data URLs or URLs
|
||||||
|
imageURL := img
|
||||||
|
if !strings.HasPrefix(img, "http://") && !strings.HasPrefix(img, "https://") && !strings.HasPrefix(img, "data:") {
|
||||||
|
// Assume base64 encoded image, default to JPEG
|
||||||
|
imageURL = "data:image/jpeg;base64," + img
|
||||||
|
}
|
||||||
|
contentParts = append(contentParts, map[string]interface{}{
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": map[string]interface{}{
|
||||||
|
"url": imageURL,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
m["content"] = contentParts
|
||||||
|
} else {
|
||||||
|
// Plain text message
|
||||||
|
m["content"] = msg.Content
|
||||||
|
}
|
||||||
|
|
||||||
|
if msg.Name != "" {
|
||||||
|
m["name"] = msg.Name
|
||||||
|
}
|
||||||
|
if msg.ToolCallID != "" {
|
||||||
|
m["tool_call_id"] = msg.ToolCallID
|
||||||
|
}
|
||||||
|
messages[i] = m
|
||||||
|
}
|
||||||
|
|
||||||
|
openaiReq := map[string]interface{}{
|
||||||
|
"model": req.Model,
|
||||||
|
"messages": messages,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add optional parameters
|
||||||
|
if req.Temperature != nil {
|
||||||
|
openaiReq["temperature"] = *req.Temperature
|
||||||
|
}
|
||||||
|
if req.TopP != nil {
|
||||||
|
openaiReq["top_p"] = *req.TopP
|
||||||
|
}
|
||||||
|
if req.MaxTokens != nil {
|
||||||
|
openaiReq["max_tokens"] = *req.MaxTokens
|
||||||
|
}
|
||||||
|
if len(req.Tools) > 0 {
|
||||||
|
openaiReq["tools"] = req.Tools
|
||||||
|
}
|
||||||
|
|
||||||
|
return openaiReq
|
||||||
|
}
|
||||||
|
|
||||||
|
// convertChatResponse converts an OpenAI response to backends.ChatChunk
|
||||||
|
func (a *Adapter) convertChatResponse(resp *openaiChatResponse) *backends.ChatChunk {
|
||||||
|
chunk := &backends.ChatChunk{
|
||||||
|
Model: resp.Model,
|
||||||
|
Done: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(resp.Choices) > 0 {
|
||||||
|
choice := resp.Choices[0]
|
||||||
|
if choice.Message != nil {
|
||||||
|
msg := &backends.ChatMessage{
|
||||||
|
Role: choice.Message.Role,
|
||||||
|
Content: choice.Message.Content,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert tool calls
|
||||||
|
for _, tc := range choice.Message.ToolCalls {
|
||||||
|
msg.ToolCalls = append(msg.ToolCalls, backends.ToolCall{
|
||||||
|
ID: tc.ID,
|
||||||
|
Type: tc.Type,
|
||||||
|
Function: struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Arguments string `json:"arguments"`
|
||||||
|
}{
|
||||||
|
Name: tc.Function.Name,
|
||||||
|
Arguments: tc.Function.Arguments,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
chunk.Message = msg
|
||||||
|
}
|
||||||
|
|
||||||
|
if choice.FinishReason != "" {
|
||||||
|
chunk.DoneReason = choice.FinishReason
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.Usage != nil {
|
||||||
|
chunk.PromptEvalCount = resp.Usage.PromptTokens
|
||||||
|
chunk.EvalCount = resp.Usage.CompletionTokens
|
||||||
|
}
|
||||||
|
|
||||||
|
return chunk
|
||||||
|
}
|
||||||
|
|
||||||
|
// convertStreamResponse converts an OpenAI stream response to backends.ChatChunk
|
||||||
|
func (a *Adapter) convertStreamResponse(resp *openaiStreamResponse, toolCallArgs map[int]string) backends.ChatChunk {
|
||||||
|
chunk := backends.ChatChunk{
|
||||||
|
Model: resp.Model,
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(resp.Choices) > 0 {
|
||||||
|
choice := resp.Choices[0]
|
||||||
|
|
||||||
|
if choice.FinishReason != "" {
|
||||||
|
chunk.Done = true
|
||||||
|
chunk.DoneReason = choice.FinishReason
|
||||||
|
}
|
||||||
|
|
||||||
|
if choice.Delta != nil {
|
||||||
|
msg := &backends.ChatMessage{
|
||||||
|
Role: choice.Delta.Role,
|
||||||
|
Content: choice.Delta.Content,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle streaming tool calls
|
||||||
|
for _, tc := range choice.Delta.ToolCalls {
|
||||||
|
// Accumulate arguments
|
||||||
|
if tc.Function.Arguments != "" {
|
||||||
|
toolCallArgs[tc.Index] += tc.Function.Arguments
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only add tool call when we have the initial info
|
||||||
|
if tc.ID != "" || tc.Function.Name != "" {
|
||||||
|
msg.ToolCalls = append(msg.ToolCalls, backends.ToolCall{
|
||||||
|
ID: tc.ID,
|
||||||
|
Type: tc.Type,
|
||||||
|
Function: struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Arguments string `json:"arguments"`
|
||||||
|
}{
|
||||||
|
Name: tc.Function.Name,
|
||||||
|
Arguments: toolCallArgs[tc.Index],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
chunk.Message = msg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return chunk
|
||||||
|
}
|
||||||
@@ -0,0 +1,594 @@
|
|||||||
|
package openai
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"vessel-backend/internal/backends"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAdapter_Type(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
backendType backends.BackendType
|
||||||
|
expectedType backends.BackendType
|
||||||
|
}{
|
||||||
|
{"llamacpp type", backends.BackendTypeLlamaCpp, backends.BackendTypeLlamaCpp},
|
||||||
|
{"lmstudio type", backends.BackendTypeLMStudio, backends.BackendTypeLMStudio},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
adapter, _ := NewAdapter(backends.BackendConfig{
|
||||||
|
Type: tt.backendType,
|
||||||
|
BaseURL: "http://localhost:8081",
|
||||||
|
})
|
||||||
|
|
||||||
|
if adapter.Type() != tt.expectedType {
|
||||||
|
t.Errorf("Type() = %v, want %v", adapter.Type(), tt.expectedType)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdapter_Config(t *testing.T) {
|
||||||
|
cfg := backends.BackendConfig{
|
||||||
|
Type: backends.BackendTypeLlamaCpp,
|
||||||
|
BaseURL: "http://localhost:8081",
|
||||||
|
Enabled: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
adapter, _ := NewAdapter(cfg)
|
||||||
|
got := adapter.Config()
|
||||||
|
|
||||||
|
if got.Type != cfg.Type {
|
||||||
|
t.Errorf("Config().Type = %v, want %v", got.Type, cfg.Type)
|
||||||
|
}
|
||||||
|
if got.BaseURL != cfg.BaseURL {
|
||||||
|
t.Errorf("Config().BaseURL = %v, want %v", got.BaseURL, cfg.BaseURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdapter_Capabilities(t *testing.T) {
|
||||||
|
t.Run("llamacpp capabilities", func(t *testing.T) {
|
||||||
|
adapter, _ := NewAdapter(backends.BackendConfig{
|
||||||
|
Type: backends.BackendTypeLlamaCpp,
|
||||||
|
BaseURL: "http://localhost:8081",
|
||||||
|
})
|
||||||
|
|
||||||
|
caps := adapter.Capabilities()
|
||||||
|
|
||||||
|
if !caps.CanListModels {
|
||||||
|
t.Error("llama.cpp adapter should support listing models")
|
||||||
|
}
|
||||||
|
if caps.CanPullModels {
|
||||||
|
t.Error("llama.cpp adapter should NOT support pulling models")
|
||||||
|
}
|
||||||
|
if caps.CanDeleteModels {
|
||||||
|
t.Error("llama.cpp adapter should NOT support deleting models")
|
||||||
|
}
|
||||||
|
if caps.CanCreateModels {
|
||||||
|
t.Error("llama.cpp adapter should NOT support creating models")
|
||||||
|
}
|
||||||
|
if !caps.CanStreamChat {
|
||||||
|
t.Error("llama.cpp adapter should support streaming chat")
|
||||||
|
}
|
||||||
|
if !caps.CanEmbed {
|
||||||
|
t.Error("llama.cpp adapter should support embeddings")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("lmstudio capabilities", func(t *testing.T) {
|
||||||
|
adapter, _ := NewAdapter(backends.BackendConfig{
|
||||||
|
Type: backends.BackendTypeLMStudio,
|
||||||
|
BaseURL: "http://localhost:1234",
|
||||||
|
})
|
||||||
|
|
||||||
|
caps := adapter.Capabilities()
|
||||||
|
|
||||||
|
if !caps.CanListModels {
|
||||||
|
t.Error("LM Studio adapter should support listing models")
|
||||||
|
}
|
||||||
|
if caps.CanPullModels {
|
||||||
|
t.Error("LM Studio adapter should NOT support pulling models")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdapter_HealthCheck(t *testing.T) {
|
||||||
|
t.Run("healthy server", func(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/v1/models" {
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"data": []map[string]string{{"id": "llama3.2:8b"}},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
adapter, err := NewAdapter(backends.BackendConfig{
|
||||||
|
Type: backends.BackendTypeLlamaCpp,
|
||||||
|
BaseURL: server.URL,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create adapter: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := adapter.HealthCheck(ctx); err != nil {
|
||||||
|
t.Errorf("HealthCheck() error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("unreachable server", func(t *testing.T) {
|
||||||
|
adapter, _ := NewAdapter(backends.BackendConfig{
|
||||||
|
Type: backends.BackendTypeLlamaCpp,
|
||||||
|
BaseURL: "http://localhost:19999",
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := adapter.HealthCheck(ctx); err == nil {
|
||||||
|
t.Error("HealthCheck() expected error for unreachable server")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdapter_ListModels(t *testing.T) {
|
||||||
|
t.Run("returns model list", func(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/v1/models" {
|
||||||
|
resp := map[string]interface{}{
|
||||||
|
"data": []map[string]interface{}{
|
||||||
|
{
|
||||||
|
"id": "llama3.2-8b-instruct",
|
||||||
|
"object": "model",
|
||||||
|
"owned_by": "local",
|
||||||
|
"created": 1700000000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "mistral-7b-v0.2",
|
||||||
|
"object": "model",
|
||||||
|
"owned_by": "local",
|
||||||
|
"created": 1700000001,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
json.NewEncoder(w).Encode(resp)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
adapter, _ := NewAdapter(backends.BackendConfig{
|
||||||
|
Type: backends.BackendTypeLlamaCpp,
|
||||||
|
BaseURL: server.URL,
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
models, err := adapter.ListModels(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListModels() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(models) != 2 {
|
||||||
|
t.Errorf("ListModels() returned %d models, want 2", len(models))
|
||||||
|
}
|
||||||
|
|
||||||
|
if models[0].ID != "llama3.2-8b-instruct" {
|
||||||
|
t.Errorf("First model ID = %q, want %q", models[0].ID, "llama3.2-8b-instruct")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("handles empty model list", func(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/v1/models" {
|
||||||
|
resp := map[string]interface{}{
|
||||||
|
"data": []map[string]interface{}{},
|
||||||
|
}
|
||||||
|
json.NewEncoder(w).Encode(resp)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
adapter, _ := NewAdapter(backends.BackendConfig{
|
||||||
|
Type: backends.BackendTypeLlamaCpp,
|
||||||
|
BaseURL: server.URL,
|
||||||
|
})
|
||||||
|
|
||||||
|
models, err := adapter.ListModels(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListModels() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(models) != 0 {
|
||||||
|
t.Errorf("ListModels() returned %d models, want 0", len(models))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdapter_Chat(t *testing.T) {
|
||||||
|
t.Run("non-streaming chat", func(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/v1/chat/completions" && r.Method == "POST" {
|
||||||
|
var req map[string]interface{}
|
||||||
|
json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
|
||||||
|
// Check stream is false
|
||||||
|
if stream, ok := req["stream"].(bool); ok && stream {
|
||||||
|
t.Error("Expected stream=false for non-streaming chat")
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := map[string]interface{}{
|
||||||
|
"id": "chatcmpl-123",
|
||||||
|
"object": "chat.completion",
|
||||||
|
"created": 1700000000,
|
||||||
|
"model": "llama3.2:8b",
|
||||||
|
"choices": []map[string]interface{}{
|
||||||
|
{
|
||||||
|
"index": 0,
|
||||||
|
"message": map[string]interface{}{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "Hello! How can I help you?",
|
||||||
|
},
|
||||||
|
"finish_reason": "stop",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"usage": map[string]int{
|
||||||
|
"prompt_tokens": 10,
|
||||||
|
"completion_tokens": 8,
|
||||||
|
"total_tokens": 18,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
json.NewEncoder(w).Encode(resp)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
adapter, _ := NewAdapter(backends.BackendConfig{
|
||||||
|
Type: backends.BackendTypeLlamaCpp,
|
||||||
|
BaseURL: server.URL,
|
||||||
|
})
|
||||||
|
|
||||||
|
req := &backends.ChatRequest{
|
||||||
|
Model: "llama3.2:8b",
|
||||||
|
Messages: []backends.ChatMessage{
|
||||||
|
{Role: "user", Content: "Hello"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := adapter.Chat(context.Background(), req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Chat() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !resp.Done {
|
||||||
|
t.Error("Chat() response.Done = false, want true")
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.Message == nil || resp.Message.Content != "Hello! How can I help you?" {
|
||||||
|
t.Errorf("Chat() response content unexpected: %+v", resp.Message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdapter_StreamChat(t *testing.T) {
|
||||||
|
t.Run("streaming chat with SSE", func(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/v1/chat/completions" && r.Method == "POST" {
|
||||||
|
var req map[string]interface{}
|
||||||
|
json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
|
||||||
|
// Check stream is true
|
||||||
|
if stream, ok := req["stream"].(bool); !ok || !stream {
|
||||||
|
t.Error("Expected stream=true for streaming chat")
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
flusher := w.(http.Flusher)
|
||||||
|
|
||||||
|
// Send SSE chunks
|
||||||
|
chunks := []string{
|
||||||
|
`{"id":"chatcmpl-1","choices":[{"delta":{"role":"assistant","content":"Hello"}}]}`,
|
||||||
|
`{"id":"chatcmpl-1","choices":[{"delta":{"content":"!"}}]}`,
|
||||||
|
`{"id":"chatcmpl-1","choices":[{"delta":{},"finish_reason":"stop"}]}`,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, chunk := range chunks {
|
||||||
|
fmt.Fprintf(w, "data: %s\n\n", chunk)
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "data: [DONE]\n\n")
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
adapter, _ := NewAdapter(backends.BackendConfig{
|
||||||
|
Type: backends.BackendTypeLlamaCpp,
|
||||||
|
BaseURL: server.URL,
|
||||||
|
})
|
||||||
|
|
||||||
|
streaming := true
|
||||||
|
req := &backends.ChatRequest{
|
||||||
|
Model: "llama3.2:8b",
|
||||||
|
Messages: []backends.ChatMessage{
|
||||||
|
{Role: "user", Content: "Hello"},
|
||||||
|
},
|
||||||
|
Stream: &streaming,
|
||||||
|
}
|
||||||
|
|
||||||
|
chunkCh, err := adapter.StreamChat(context.Background(), req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("StreamChat() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var chunks []backends.ChatChunk
|
||||||
|
for chunk := range chunkCh {
|
||||||
|
chunks = append(chunks, chunk)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(chunks) < 2 {
|
||||||
|
t.Errorf("StreamChat() received %d chunks, want at least 2", len(chunks))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last chunk should be done
|
||||||
|
if !chunks[len(chunks)-1].Done {
|
||||||
|
t.Error("Last chunk should have Done=true")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("handles context cancellation", func(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/v1/chat/completions" {
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
flusher := w.(http.Flusher)
|
||||||
|
|
||||||
|
// Send first chunk then wait
|
||||||
|
fmt.Fprintf(w, "data: %s\n\n", `{"id":"chatcmpl-1","choices":[{"delta":{"role":"assistant","content":"Starting..."}}]}`)
|
||||||
|
flusher.Flush()
|
||||||
|
|
||||||
|
// Wait long enough for context to be cancelled
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
adapter, _ := NewAdapter(backends.BackendConfig{
|
||||||
|
Type: backends.BackendTypeLlamaCpp,
|
||||||
|
BaseURL: server.URL,
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
streaming := true
|
||||||
|
req := &backends.ChatRequest{
|
||||||
|
Model: "llama3.2:8b",
|
||||||
|
Messages: []backends.ChatMessage{
|
||||||
|
{Role: "user", Content: "Hello"},
|
||||||
|
},
|
||||||
|
Stream: &streaming,
|
||||||
|
}
|
||||||
|
|
||||||
|
chunkCh, err := adapter.StreamChat(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("StreamChat() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should receive at least one chunk before timeout
|
||||||
|
receivedChunks := 0
|
||||||
|
for range chunkCh {
|
||||||
|
receivedChunks++
|
||||||
|
}
|
||||||
|
|
||||||
|
if receivedChunks == 0 {
|
||||||
|
t.Error("Expected to receive at least one chunk before cancellation")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdapter_Info(t *testing.T) {
|
||||||
|
t.Run("connected server", func(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/v1/models" {
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"data": []map[string]string{{"id": "llama3.2:8b"}},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
adapter, _ := NewAdapter(backends.BackendConfig{
|
||||||
|
Type: backends.BackendTypeLlamaCpp,
|
||||||
|
BaseURL: server.URL,
|
||||||
|
})
|
||||||
|
|
||||||
|
info := adapter.Info(context.Background())
|
||||||
|
|
||||||
|
if info.Type != backends.BackendTypeLlamaCpp {
|
||||||
|
t.Errorf("Info().Type = %v, want %v", info.Type, backends.BackendTypeLlamaCpp)
|
||||||
|
}
|
||||||
|
|
||||||
|
if info.Status != backends.BackendStatusConnected {
|
||||||
|
t.Errorf("Info().Status = %v, want %v", info.Status, backends.BackendStatusConnected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("disconnected server", func(t *testing.T) {
|
||||||
|
adapter, _ := NewAdapter(backends.BackendConfig{
|
||||||
|
Type: backends.BackendTypeLlamaCpp,
|
||||||
|
BaseURL: "http://localhost:19999",
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
info := adapter.Info(ctx)
|
||||||
|
|
||||||
|
if info.Status != backends.BackendStatusDisconnected {
|
||||||
|
t.Errorf("Info().Status = %v, want %v", info.Status, backends.BackendStatusDisconnected)
|
||||||
|
}
|
||||||
|
|
||||||
|
if info.Error == "" {
|
||||||
|
t.Error("Info().Error should be set for disconnected server")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdapter_Embed(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/v1/embeddings" && r.Method == "POST" {
|
||||||
|
resp := map[string]interface{}{
|
||||||
|
"data": []map[string]interface{}{
|
||||||
|
{"embedding": []float64{0.1, 0.2, 0.3}, "index": 0},
|
||||||
|
{"embedding": []float64{0.4, 0.5, 0.6}, "index": 1},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
json.NewEncoder(w).Encode(resp)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
adapter, _ := NewAdapter(backends.BackendConfig{
|
||||||
|
Type: backends.BackendTypeLlamaCpp,
|
||||||
|
BaseURL: server.URL,
|
||||||
|
})
|
||||||
|
|
||||||
|
embeddings, err := adapter.Embed(context.Background(), "nomic-embed-text", []string{"hello", "world"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Embed() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(embeddings) != 2 {
|
||||||
|
t.Errorf("Embed() returned %d embeddings, want 2", len(embeddings))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(embeddings[0]) != 3 {
|
||||||
|
t.Errorf("First embedding has %d dimensions, want 3", len(embeddings[0]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewAdapter_Validation(t *testing.T) {
|
||||||
|
t.Run("invalid URL", func(t *testing.T) {
|
||||||
|
_, err := NewAdapter(backends.BackendConfig{
|
||||||
|
Type: backends.BackendTypeLlamaCpp,
|
||||||
|
BaseURL: "not-a-url",
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Error("NewAdapter() should fail with invalid URL")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("wrong backend type", func(t *testing.T) {
|
||||||
|
_, err := NewAdapter(backends.BackendConfig{
|
||||||
|
Type: backends.BackendTypeOllama,
|
||||||
|
BaseURL: "http://localhost:8081",
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Error("NewAdapter() should fail with Ollama backend type")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("valid llamacpp config", func(t *testing.T) {
|
||||||
|
adapter, err := NewAdapter(backends.BackendConfig{
|
||||||
|
Type: backends.BackendTypeLlamaCpp,
|
||||||
|
BaseURL: "http://localhost:8081",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("NewAdapter() error = %v", err)
|
||||||
|
}
|
||||||
|
if adapter == nil {
|
||||||
|
t.Error("NewAdapter() returned nil adapter")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("valid lmstudio config", func(t *testing.T) {
|
||||||
|
adapter, err := NewAdapter(backends.BackendConfig{
|
||||||
|
Type: backends.BackendTypeLMStudio,
|
||||||
|
BaseURL: "http://localhost:1234",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("NewAdapter() error = %v", err)
|
||||||
|
}
|
||||||
|
if adapter == nil {
|
||||||
|
t.Error("NewAdapter() returned nil adapter")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdapter_ToolCalls(t *testing.T) {
|
||||||
|
t.Run("streaming with tool calls", func(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/v1/chat/completions" {
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
flusher := w.(http.Flusher)
|
||||||
|
|
||||||
|
// Send tool call chunks
|
||||||
|
chunks := []string{
|
||||||
|
`{"id":"chatcmpl-1","choices":[{"delta":{"role":"assistant","tool_calls":[{"id":"call_1","type":"function","function":{"name":"get_weather","arguments":""}}]}}]}`,
|
||||||
|
`{"id":"chatcmpl-1","choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"location\":"}}]}}]}`,
|
||||||
|
`{"id":"chatcmpl-1","choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\"Tokyo\"}"}}]}}]}`,
|
||||||
|
`{"id":"chatcmpl-1","choices":[{"delta":{},"finish_reason":"tool_calls"}]}`,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, chunk := range chunks {
|
||||||
|
fmt.Fprintf(w, "data: %s\n\n", chunk)
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "data: [DONE]\n\n")
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
adapter, _ := NewAdapter(backends.BackendConfig{
|
||||||
|
Type: backends.BackendTypeLlamaCpp,
|
||||||
|
BaseURL: server.URL,
|
||||||
|
})
|
||||||
|
|
||||||
|
streaming := true
|
||||||
|
req := &backends.ChatRequest{
|
||||||
|
Model: "llama3.2:8b",
|
||||||
|
Messages: []backends.ChatMessage{
|
||||||
|
{Role: "user", Content: "What's the weather in Tokyo?"},
|
||||||
|
},
|
||||||
|
Stream: &streaming,
|
||||||
|
Tools: []backends.Tool{
|
||||||
|
{
|
||||||
|
Type: "function",
|
||||||
|
Function: struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Parameters map[string]interface{} `json:"parameters"`
|
||||||
|
}{
|
||||||
|
Name: "get_weather",
|
||||||
|
Description: "Get weather for a location",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
chunkCh, err := adapter.StreamChat(context.Background(), req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("StreamChat() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var lastChunk backends.ChatChunk
|
||||||
|
for chunk := range chunkCh {
|
||||||
|
lastChunk = chunk
|
||||||
|
}
|
||||||
|
|
||||||
|
if !lastChunk.Done {
|
||||||
|
t.Error("Last chunk should have Done=true")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
package backends
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Registry manages multiple LLM backend instances
|
||||||
|
type Registry struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
backends map[BackendType]LLMBackend
|
||||||
|
active BackendType
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRegistry creates a new backend registry
|
||||||
|
func NewRegistry() *Registry {
|
||||||
|
return &Registry{
|
||||||
|
backends: make(map[BackendType]LLMBackend),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register adds a backend to the registry
|
||||||
|
func (r *Registry) Register(backend LLMBackend) error {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
|
||||||
|
bt := backend.Type()
|
||||||
|
if _, exists := r.backends[bt]; exists {
|
||||||
|
return fmt.Errorf("backend %q already registered", bt)
|
||||||
|
}
|
||||||
|
|
||||||
|
r.backends[bt] = backend
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unregister removes a backend from the registry
|
||||||
|
func (r *Registry) Unregister(backendType BackendType) error {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
|
||||||
|
if _, exists := r.backends[backendType]; !exists {
|
||||||
|
return fmt.Errorf("backend %q not registered", backendType)
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(r.backends, backendType)
|
||||||
|
|
||||||
|
// Clear active if it was the unregistered backend
|
||||||
|
if r.active == backendType {
|
||||||
|
r.active = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get retrieves a backend by type
|
||||||
|
func (r *Registry) Get(backendType BackendType) (LLMBackend, bool) {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
|
||||||
|
backend, ok := r.backends[backendType]
|
||||||
|
return backend, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetActive sets the active backend
|
||||||
|
func (r *Registry) SetActive(backendType BackendType) error {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
|
||||||
|
if _, exists := r.backends[backendType]; !exists {
|
||||||
|
return fmt.Errorf("backend %q not registered", backendType)
|
||||||
|
}
|
||||||
|
|
||||||
|
r.active = backendType
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Active returns the currently active backend
|
||||||
|
func (r *Registry) Active() LLMBackend {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
|
||||||
|
if r.active == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.backends[r.active]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActiveType returns the type of the currently active backend
|
||||||
|
func (r *Registry) ActiveType() BackendType {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
|
||||||
|
return r.active
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backends returns all registered backend types
|
||||||
|
func (r *Registry) Backends() []BackendType {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
|
||||||
|
types := make([]BackendType, 0, len(r.backends))
|
||||||
|
for bt := range r.backends {
|
||||||
|
types = append(types, bt)
|
||||||
|
}
|
||||||
|
return types
|
||||||
|
}
|
||||||
|
|
||||||
|
// AllInfo returns information about all registered backends
|
||||||
|
func (r *Registry) AllInfo(ctx context.Context) []BackendInfo {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
|
||||||
|
infos := make([]BackendInfo, 0, len(r.backends))
|
||||||
|
for _, backend := range r.backends {
|
||||||
|
infos = append(infos, backend.Info(ctx))
|
||||||
|
}
|
||||||
|
return infos
|
||||||
|
}
|
||||||
|
|
||||||
|
// DiscoveryEndpoint represents a potential backend endpoint to probe
|
||||||
|
type DiscoveryEndpoint struct {
|
||||||
|
Type BackendType
|
||||||
|
BaseURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
// DiscoveryResult represents the result of probing an endpoint
|
||||||
|
type DiscoveryResult struct {
|
||||||
|
Type BackendType `json:"type"`
|
||||||
|
BaseURL string `json:"baseUrl"`
|
||||||
|
Available bool `json:"available"`
|
||||||
|
Version string `json:"version,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Discover probes the given endpoints to find available backends
|
||||||
|
func (r *Registry) Discover(ctx context.Context, endpoints []DiscoveryEndpoint) []DiscoveryResult {
|
||||||
|
results := make([]DiscoveryResult, len(endpoints))
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
for i, endpoint := range endpoints {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(idx int, ep DiscoveryEndpoint) {
|
||||||
|
defer wg.Done()
|
||||||
|
results[idx] = probeEndpoint(ctx, ep)
|
||||||
|
}(i, endpoint)
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
// probeEndpoint checks if a backend is available at the given endpoint
|
||||||
|
func probeEndpoint(ctx context.Context, endpoint DiscoveryEndpoint) DiscoveryResult {
|
||||||
|
result := DiscoveryResult{
|
||||||
|
Type: endpoint.Type,
|
||||||
|
BaseURL: endpoint.BaseURL,
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &http.Client{
|
||||||
|
Timeout: 3 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine probe path based on backend type
|
||||||
|
var probePath string
|
||||||
|
switch endpoint.Type {
|
||||||
|
case BackendTypeOllama:
|
||||||
|
probePath = "/api/version"
|
||||||
|
case BackendTypeLlamaCpp, BackendTypeLMStudio:
|
||||||
|
probePath = "/v1/models"
|
||||||
|
default:
|
||||||
|
probePath = "/health"
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", endpoint.BaseURL+probePath, nil)
|
||||||
|
if err != nil {
|
||||||
|
result.Error = err.Error()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
result.Error = err.Error()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode == http.StatusOK {
|
||||||
|
result.Available = true
|
||||||
|
} else {
|
||||||
|
result.Error = fmt.Sprintf("HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// getEnvOrDefault returns the environment variable value or a default
|
||||||
|
func getEnvOrDefault(key, defaultValue string) string {
|
||||||
|
if value := os.Getenv(key); value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultDiscoveryEndpoints returns the default endpoints to probe.
|
||||||
|
// URLs can be overridden via environment variables (useful for Docker).
|
||||||
|
func DefaultDiscoveryEndpoints() []DiscoveryEndpoint {
|
||||||
|
ollamaURL := getEnvOrDefault("OLLAMA_URL", "http://localhost:11434")
|
||||||
|
llamacppURL := getEnvOrDefault("LLAMACPP_URL", "http://localhost:8081")
|
||||||
|
lmstudioURL := getEnvOrDefault("LMSTUDIO_URL", "http://localhost:1234")
|
||||||
|
|
||||||
|
return []DiscoveryEndpoint{
|
||||||
|
{Type: BackendTypeOllama, BaseURL: ollamaURL},
|
||||||
|
{Type: BackendTypeLlamaCpp, BaseURL: llamacppURL},
|
||||||
|
{Type: BackendTypeLMStudio, BaseURL: lmstudioURL},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DiscoverAndRegister probes endpoints and registers available backends
|
||||||
|
func (r *Registry) DiscoverAndRegister(ctx context.Context, endpoints []DiscoveryEndpoint, adapterFactory AdapterFactory) []DiscoveryResult {
|
||||||
|
results := r.Discover(ctx, endpoints)
|
||||||
|
|
||||||
|
for _, result := range results {
|
||||||
|
if !result.Available {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip if already registered
|
||||||
|
if _, exists := r.Get(result.Type); exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
config := BackendConfig{
|
||||||
|
Type: result.Type,
|
||||||
|
BaseURL: result.BaseURL,
|
||||||
|
Enabled: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
adapter, err := adapterFactory(config)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
r.Register(adapter)
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdapterFactory creates an LLMBackend from a config
|
||||||
|
type AdapterFactory func(config BackendConfig) (LLMBackend, error)
|
||||||
@@ -0,0 +1,352 @@
|
|||||||
|
package backends
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewRegistry(t *testing.T) {
|
||||||
|
registry := NewRegistry()
|
||||||
|
|
||||||
|
if registry == nil {
|
||||||
|
t.Fatal("NewRegistry() returned nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(registry.Backends()) != 0 {
|
||||||
|
t.Errorf("New registry should have no backends, got %d", len(registry.Backends()))
|
||||||
|
}
|
||||||
|
|
||||||
|
if registry.Active() != nil {
|
||||||
|
t.Error("New registry should have no active backend")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistry_Register(t *testing.T) {
|
||||||
|
registry := NewRegistry()
|
||||||
|
|
||||||
|
// Create a mock backend
|
||||||
|
mock := &mockBackend{
|
||||||
|
backendType: BackendTypeOllama,
|
||||||
|
config: BackendConfig{
|
||||||
|
Type: BackendTypeOllama,
|
||||||
|
BaseURL: "http://localhost:11434",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := registry.Register(mock)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Register() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(registry.Backends()) != 1 {
|
||||||
|
t.Errorf("Registry should have 1 backend, got %d", len(registry.Backends()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should not allow duplicate registration
|
||||||
|
err = registry.Register(mock)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("Register() should fail for duplicate backend type")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistry_Get(t *testing.T) {
|
||||||
|
registry := NewRegistry()
|
||||||
|
|
||||||
|
mock := &mockBackend{
|
||||||
|
backendType: BackendTypeOllama,
|
||||||
|
config: BackendConfig{
|
||||||
|
Type: BackendTypeOllama,
|
||||||
|
BaseURL: "http://localhost:11434",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
registry.Register(mock)
|
||||||
|
|
||||||
|
t.Run("existing backend", func(t *testing.T) {
|
||||||
|
backend, ok := registry.Get(BackendTypeOllama)
|
||||||
|
if !ok {
|
||||||
|
t.Error("Get() should return ok=true for registered backend")
|
||||||
|
}
|
||||||
|
if backend != mock {
|
||||||
|
t.Error("Get() returned wrong backend")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("non-existing backend", func(t *testing.T) {
|
||||||
|
_, ok := registry.Get(BackendTypeLlamaCpp)
|
||||||
|
if ok {
|
||||||
|
t.Error("Get() should return ok=false for unregistered backend")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistry_SetActive(t *testing.T) {
|
||||||
|
registry := NewRegistry()
|
||||||
|
|
||||||
|
mock := &mockBackend{
|
||||||
|
backendType: BackendTypeOllama,
|
||||||
|
config: BackendConfig{
|
||||||
|
Type: BackendTypeOllama,
|
||||||
|
BaseURL: "http://localhost:11434",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
registry.Register(mock)
|
||||||
|
|
||||||
|
t.Run("set registered backend as active", func(t *testing.T) {
|
||||||
|
err := registry.SetActive(BackendTypeOllama)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("SetActive() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
active := registry.Active()
|
||||||
|
if active == nil {
|
||||||
|
t.Fatal("Active() returned nil after SetActive()")
|
||||||
|
}
|
||||||
|
if active.Type() != BackendTypeOllama {
|
||||||
|
t.Errorf("Active().Type() = %v, want %v", active.Type(), BackendTypeOllama)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("set unregistered backend as active", func(t *testing.T) {
|
||||||
|
err := registry.SetActive(BackendTypeLlamaCpp)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("SetActive() should fail for unregistered backend")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistry_ActiveType(t *testing.T) {
|
||||||
|
registry := NewRegistry()
|
||||||
|
|
||||||
|
t.Run("no active backend", func(t *testing.T) {
|
||||||
|
activeType := registry.ActiveType()
|
||||||
|
if activeType != "" {
|
||||||
|
t.Errorf("ActiveType() = %q, want empty string", activeType)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("with active backend", func(t *testing.T) {
|
||||||
|
mock := &mockBackend{backendType: BackendTypeOllama}
|
||||||
|
registry.Register(mock)
|
||||||
|
registry.SetActive(BackendTypeOllama)
|
||||||
|
|
||||||
|
activeType := registry.ActiveType()
|
||||||
|
if activeType != BackendTypeOllama {
|
||||||
|
t.Errorf("ActiveType() = %v, want %v", activeType, BackendTypeOllama)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistry_Unregister(t *testing.T) {
|
||||||
|
registry := NewRegistry()
|
||||||
|
|
||||||
|
mock := &mockBackend{backendType: BackendTypeOllama}
|
||||||
|
registry.Register(mock)
|
||||||
|
registry.SetActive(BackendTypeOllama)
|
||||||
|
|
||||||
|
err := registry.Unregister(BackendTypeOllama)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Unregister() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(registry.Backends()) != 0 {
|
||||||
|
t.Error("Registry should have no backends after unregister")
|
||||||
|
}
|
||||||
|
|
||||||
|
if registry.Active() != nil {
|
||||||
|
t.Error("Active backend should be nil after unregistering it")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistry_AllInfo(t *testing.T) {
|
||||||
|
registry := NewRegistry()
|
||||||
|
|
||||||
|
mock1 := &mockBackend{
|
||||||
|
backendType: BackendTypeOllama,
|
||||||
|
config: BackendConfig{Type: BackendTypeOllama, BaseURL: "http://localhost:11434"},
|
||||||
|
info: BackendInfo{
|
||||||
|
Type: BackendTypeOllama,
|
||||||
|
Status: BackendStatusConnected,
|
||||||
|
Version: "0.1.0",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
mock2 := &mockBackend{
|
||||||
|
backendType: BackendTypeLlamaCpp,
|
||||||
|
config: BackendConfig{Type: BackendTypeLlamaCpp, BaseURL: "http://localhost:8081"},
|
||||||
|
info: BackendInfo{
|
||||||
|
Type: BackendTypeLlamaCpp,
|
||||||
|
Status: BackendStatusDisconnected,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
registry.Register(mock1)
|
||||||
|
registry.Register(mock2)
|
||||||
|
registry.SetActive(BackendTypeOllama)
|
||||||
|
|
||||||
|
infos := registry.AllInfo(context.Background())
|
||||||
|
|
||||||
|
if len(infos) != 2 {
|
||||||
|
t.Errorf("AllInfo() returned %d infos, want 2", len(infos))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the active one
|
||||||
|
var foundActive bool
|
||||||
|
for _, info := range infos {
|
||||||
|
if info.Type == BackendTypeOllama {
|
||||||
|
foundActive = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !foundActive {
|
||||||
|
t.Error("AllInfo() did not include ollama backend info")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistry_Discover(t *testing.T) {
|
||||||
|
// Create test servers for each backend type
|
||||||
|
ollamaServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/api/version" || r.URL.Path == "/" {
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"version": "0.3.0"})
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer ollamaServer.Close()
|
||||||
|
|
||||||
|
llamacppServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/v1/models" {
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"data": []map[string]string{{"id": "llama3.2:8b"}},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if r.URL.Path == "/health" {
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer llamacppServer.Close()
|
||||||
|
|
||||||
|
registry := NewRegistry()
|
||||||
|
|
||||||
|
// Configure discovery endpoints
|
||||||
|
endpoints := []DiscoveryEndpoint{
|
||||||
|
{Type: BackendTypeOllama, BaseURL: ollamaServer.URL},
|
||||||
|
{Type: BackendTypeLlamaCpp, BaseURL: llamacppServer.URL},
|
||||||
|
{Type: BackendTypeLMStudio, BaseURL: "http://localhost:19999"}, // Not running
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
results := registry.Discover(ctx, endpoints)
|
||||||
|
|
||||||
|
if len(results) != 3 {
|
||||||
|
t.Errorf("Discover() returned %d results, want 3", len(results))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Ollama was discovered
|
||||||
|
var ollamaResult *DiscoveryResult
|
||||||
|
for i := range results {
|
||||||
|
if results[i].Type == BackendTypeOllama {
|
||||||
|
ollamaResult = &results[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ollamaResult == nil {
|
||||||
|
t.Fatal("Ollama not found in discovery results")
|
||||||
|
}
|
||||||
|
if !ollamaResult.Available {
|
||||||
|
t.Errorf("Ollama should be available, error: %s", ollamaResult.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check LM Studio was not discovered
|
||||||
|
var lmstudioResult *DiscoveryResult
|
||||||
|
for i := range results {
|
||||||
|
if results[i].Type == BackendTypeLMStudio {
|
||||||
|
lmstudioResult = &results[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if lmstudioResult == nil {
|
||||||
|
t.Fatal("LM Studio not found in discovery results")
|
||||||
|
}
|
||||||
|
if lmstudioResult.Available {
|
||||||
|
t.Error("LM Studio should NOT be available")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistry_DefaultEndpoints(t *testing.T) {
|
||||||
|
endpoints := DefaultDiscoveryEndpoints()
|
||||||
|
|
||||||
|
if len(endpoints) < 3 {
|
||||||
|
t.Errorf("DefaultDiscoveryEndpoints() returned %d endpoints, want at least 3", len(endpoints))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check that all expected types are present
|
||||||
|
types := make(map[BackendType]bool)
|
||||||
|
for _, e := range endpoints {
|
||||||
|
types[e.Type] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if !types[BackendTypeOllama] {
|
||||||
|
t.Error("DefaultDiscoveryEndpoints() missing Ollama")
|
||||||
|
}
|
||||||
|
if !types[BackendTypeLlamaCpp] {
|
||||||
|
t.Error("DefaultDiscoveryEndpoints() missing llama.cpp")
|
||||||
|
}
|
||||||
|
if !types[BackendTypeLMStudio] {
|
||||||
|
t.Error("DefaultDiscoveryEndpoints() missing LM Studio")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// mockBackend implements LLMBackend for testing
|
||||||
|
type mockBackend struct {
|
||||||
|
backendType BackendType
|
||||||
|
config BackendConfig
|
||||||
|
info BackendInfo
|
||||||
|
healthErr error
|
||||||
|
models []Model
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockBackend) Type() BackendType {
|
||||||
|
return m.backendType
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockBackend) Config() BackendConfig {
|
||||||
|
return m.config
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockBackend) HealthCheck(ctx context.Context) error {
|
||||||
|
return m.healthErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockBackend) ListModels(ctx context.Context) ([]Model, error) {
|
||||||
|
return m.models, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockBackend) StreamChat(ctx context.Context, req *ChatRequest) (<-chan ChatChunk, error) {
|
||||||
|
ch := make(chan ChatChunk)
|
||||||
|
close(ch)
|
||||||
|
return ch, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockBackend) Chat(ctx context.Context, req *ChatRequest) (*ChatChunk, error) {
|
||||||
|
return &ChatChunk{Done: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockBackend) Capabilities() BackendCapabilities {
|
||||||
|
return OllamaCapabilities()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockBackend) Info(ctx context.Context) BackendInfo {
|
||||||
|
if m.info.Type != "" {
|
||||||
|
return m.info
|
||||||
|
}
|
||||||
|
return BackendInfo{
|
||||||
|
Type: m.backendType,
|
||||||
|
BaseURL: m.config.BaseURL,
|
||||||
|
Status: BackendStatusConnected,
|
||||||
|
Capabilities: m.Capabilities(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
package backends
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BackendType identifies the type of LLM backend
|
||||||
|
type BackendType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
BackendTypeOllama BackendType = "ollama"
|
||||||
|
BackendTypeLlamaCpp BackendType = "llamacpp"
|
||||||
|
BackendTypeLMStudio BackendType = "lmstudio"
|
||||||
|
)
|
||||||
|
|
||||||
|
// String returns the string representation of the backend type
|
||||||
|
func (bt BackendType) String() string {
|
||||||
|
return string(bt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseBackendType parses a string into a BackendType
|
||||||
|
func ParseBackendType(s string) (BackendType, error) {
|
||||||
|
switch strings.ToLower(s) {
|
||||||
|
case "ollama":
|
||||||
|
return BackendTypeOllama, nil
|
||||||
|
case "llamacpp", "llama.cpp", "llama-cpp":
|
||||||
|
return BackendTypeLlamaCpp, nil
|
||||||
|
case "lmstudio", "lm-studio", "lm_studio":
|
||||||
|
return BackendTypeLMStudio, nil
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("unknown backend type: %q", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BackendCapabilities describes what features a backend supports
|
||||||
|
type BackendCapabilities struct {
|
||||||
|
CanListModels bool `json:"canListModels"`
|
||||||
|
CanPullModels bool `json:"canPullModels"`
|
||||||
|
CanDeleteModels bool `json:"canDeleteModels"`
|
||||||
|
CanCreateModels bool `json:"canCreateModels"`
|
||||||
|
CanStreamChat bool `json:"canStreamChat"`
|
||||||
|
CanEmbed bool `json:"canEmbed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OllamaCapabilities returns the capabilities for Ollama backend
|
||||||
|
func OllamaCapabilities() BackendCapabilities {
|
||||||
|
return BackendCapabilities{
|
||||||
|
CanListModels: true,
|
||||||
|
CanPullModels: true,
|
||||||
|
CanDeleteModels: true,
|
||||||
|
CanCreateModels: true,
|
||||||
|
CanStreamChat: true,
|
||||||
|
CanEmbed: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LlamaCppCapabilities returns the capabilities for llama.cpp backend
|
||||||
|
func LlamaCppCapabilities() BackendCapabilities {
|
||||||
|
return BackendCapabilities{
|
||||||
|
CanListModels: true,
|
||||||
|
CanPullModels: false,
|
||||||
|
CanDeleteModels: false,
|
||||||
|
CanCreateModels: false,
|
||||||
|
CanStreamChat: true,
|
||||||
|
CanEmbed: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LMStudioCapabilities returns the capabilities for LM Studio backend
|
||||||
|
func LMStudioCapabilities() BackendCapabilities {
|
||||||
|
return BackendCapabilities{
|
||||||
|
CanListModels: true,
|
||||||
|
CanPullModels: false,
|
||||||
|
CanDeleteModels: false,
|
||||||
|
CanCreateModels: false,
|
||||||
|
CanStreamChat: true,
|
||||||
|
CanEmbed: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BackendStatus represents the connection status of a backend
|
||||||
|
type BackendStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
BackendStatusConnected BackendStatus = "connected"
|
||||||
|
BackendStatusDisconnected BackendStatus = "disconnected"
|
||||||
|
BackendStatusUnknown BackendStatus = "unknown"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BackendConfig holds configuration for a backend
|
||||||
|
type BackendConfig struct {
|
||||||
|
Type BackendType `json:"type"`
|
||||||
|
BaseURL string `json:"baseUrl"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate checks if the backend config is valid
|
||||||
|
func (c BackendConfig) Validate() error {
|
||||||
|
if c.BaseURL == "" {
|
||||||
|
return errors.New("base URL is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := url.Parse(c.BaseURL)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid base URL: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if u.Scheme == "" || u.Host == "" {
|
||||||
|
return errors.New("invalid URL: missing scheme or host")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BackendInfo describes a configured backend and its current state
|
||||||
|
type BackendInfo struct {
|
||||||
|
Type BackendType `json:"type"`
|
||||||
|
BaseURL string `json:"baseUrl"`
|
||||||
|
Status BackendStatus `json:"status"`
|
||||||
|
Capabilities BackendCapabilities `json:"capabilities"`
|
||||||
|
Version string `json:"version,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsConnected returns true if the backend is connected
|
||||||
|
func (bi BackendInfo) IsConnected() bool {
|
||||||
|
return bi.Status == BackendStatusConnected
|
||||||
|
}
|
||||||
|
|
||||||
|
// Model represents an LLM model available from a backend
|
||||||
|
type Model struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Size int64 `json:"size,omitempty"`
|
||||||
|
ModifiedAt string `json:"modifiedAt,omitempty"`
|
||||||
|
Family string `json:"family,omitempty"`
|
||||||
|
QuantLevel string `json:"quantLevel,omitempty"`
|
||||||
|
Capabilities []string `json:"capabilities,omitempty"`
|
||||||
|
Metadata map[string]string `json:"metadata,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasCapability checks if the model has a specific capability
|
||||||
|
func (m Model) HasCapability(cap string) bool {
|
||||||
|
for _, c := range m.Capabilities {
|
||||||
|
if c == cap {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChatMessage represents a message in a chat conversation
|
||||||
|
type ChatMessage struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Images []string `json:"images,omitempty"`
|
||||||
|
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
||||||
|
ToolCallID string `json:"tool_call_id,omitempty"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var validRoles = map[string]bool{
|
||||||
|
"user": true,
|
||||||
|
"assistant": true,
|
||||||
|
"system": true,
|
||||||
|
"tool": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate checks if the chat message is valid
|
||||||
|
func (m ChatMessage) Validate() error {
|
||||||
|
if m.Role == "" {
|
||||||
|
return errors.New("role is required")
|
||||||
|
}
|
||||||
|
if !validRoles[m.Role] {
|
||||||
|
return fmt.Errorf("invalid role: %q", m.Role)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToolCall represents a tool invocation
|
||||||
|
type ToolCall struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Function struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Arguments string `json:"arguments"`
|
||||||
|
} `json:"function"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tool represents a tool definition
|
||||||
|
type Tool struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Function struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Parameters map[string]interface{} `json:"parameters"`
|
||||||
|
} `json:"function"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChatRequest represents a chat completion request
|
||||||
|
type ChatRequest struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
Messages []ChatMessage `json:"messages"`
|
||||||
|
Stream *bool `json:"stream,omitempty"`
|
||||||
|
Temperature *float64 `json:"temperature,omitempty"`
|
||||||
|
TopP *float64 `json:"top_p,omitempty"`
|
||||||
|
MaxTokens *int `json:"max_tokens,omitempty"`
|
||||||
|
Tools []Tool `json:"tools,omitempty"`
|
||||||
|
Options map[string]any `json:"options,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate checks if the chat request is valid
|
||||||
|
func (r ChatRequest) Validate() error {
|
||||||
|
if r.Model == "" {
|
||||||
|
return errors.New("model is required")
|
||||||
|
}
|
||||||
|
if len(r.Messages) == 0 {
|
||||||
|
return errors.New("at least one message is required")
|
||||||
|
}
|
||||||
|
for i, msg := range r.Messages {
|
||||||
|
if err := msg.Validate(); err != nil {
|
||||||
|
return fmt.Errorf("message %d: %w", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChatChunk represents a streaming chat response chunk
|
||||||
|
type ChatChunk struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
CreatedAt string `json:"created_at,omitempty"`
|
||||||
|
Message *ChatMessage `json:"message,omitempty"`
|
||||||
|
Done bool `json:"done"`
|
||||||
|
DoneReason string `json:"done_reason,omitempty"`
|
||||||
|
|
||||||
|
// Token counts (final chunk only)
|
||||||
|
PromptEvalCount int `json:"prompt_eval_count,omitempty"`
|
||||||
|
EvalCount int `json:"eval_count,omitempty"`
|
||||||
|
|
||||||
|
// Error information
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,323 @@
|
|||||||
|
package backends
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBackendType_String(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
bt BackendType
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{"ollama type", BackendTypeOllama, "ollama"},
|
||||||
|
{"llamacpp type", BackendTypeLlamaCpp, "llamacpp"},
|
||||||
|
{"lmstudio type", BackendTypeLMStudio, "lmstudio"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := tt.bt.String(); got != tt.expected {
|
||||||
|
t.Errorf("BackendType.String() = %v, want %v", got, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseBackendType(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
expected BackendType
|
||||||
|
expectErr bool
|
||||||
|
}{
|
||||||
|
{"parse ollama", "ollama", BackendTypeOllama, false},
|
||||||
|
{"parse llamacpp", "llamacpp", BackendTypeLlamaCpp, false},
|
||||||
|
{"parse lmstudio", "lmstudio", BackendTypeLMStudio, false},
|
||||||
|
{"parse llama.cpp alias", "llama.cpp", BackendTypeLlamaCpp, false},
|
||||||
|
{"parse llama-cpp alias", "llama-cpp", BackendTypeLlamaCpp, false},
|
||||||
|
{"parse unknown", "unknown", "", true},
|
||||||
|
{"parse empty", "", "", true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := ParseBackendType(tt.input)
|
||||||
|
if (err != nil) != tt.expectErr {
|
||||||
|
t.Errorf("ParseBackendType() error = %v, expectErr %v", err, tt.expectErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if got != tt.expected {
|
||||||
|
t.Errorf("ParseBackendType() = %v, want %v", got, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackendCapabilities(t *testing.T) {
|
||||||
|
t.Run("ollama capabilities", func(t *testing.T) {
|
||||||
|
caps := OllamaCapabilities()
|
||||||
|
|
||||||
|
if !caps.CanListModels {
|
||||||
|
t.Error("Ollama should be able to list models")
|
||||||
|
}
|
||||||
|
if !caps.CanPullModels {
|
||||||
|
t.Error("Ollama should be able to pull models")
|
||||||
|
}
|
||||||
|
if !caps.CanDeleteModels {
|
||||||
|
t.Error("Ollama should be able to delete models")
|
||||||
|
}
|
||||||
|
if !caps.CanCreateModels {
|
||||||
|
t.Error("Ollama should be able to create models")
|
||||||
|
}
|
||||||
|
if !caps.CanStreamChat {
|
||||||
|
t.Error("Ollama should be able to stream chat")
|
||||||
|
}
|
||||||
|
if !caps.CanEmbed {
|
||||||
|
t.Error("Ollama should be able to embed")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("llamacpp capabilities", func(t *testing.T) {
|
||||||
|
caps := LlamaCppCapabilities()
|
||||||
|
|
||||||
|
if !caps.CanListModels {
|
||||||
|
t.Error("llama.cpp should be able to list models")
|
||||||
|
}
|
||||||
|
if caps.CanPullModels {
|
||||||
|
t.Error("llama.cpp should NOT be able to pull models")
|
||||||
|
}
|
||||||
|
if caps.CanDeleteModels {
|
||||||
|
t.Error("llama.cpp should NOT be able to delete models")
|
||||||
|
}
|
||||||
|
if caps.CanCreateModels {
|
||||||
|
t.Error("llama.cpp should NOT be able to create models")
|
||||||
|
}
|
||||||
|
if !caps.CanStreamChat {
|
||||||
|
t.Error("llama.cpp should be able to stream chat")
|
||||||
|
}
|
||||||
|
if !caps.CanEmbed {
|
||||||
|
t.Error("llama.cpp should be able to embed")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("lmstudio capabilities", func(t *testing.T) {
|
||||||
|
caps := LMStudioCapabilities()
|
||||||
|
|
||||||
|
if !caps.CanListModels {
|
||||||
|
t.Error("LM Studio should be able to list models")
|
||||||
|
}
|
||||||
|
if caps.CanPullModels {
|
||||||
|
t.Error("LM Studio should NOT be able to pull models")
|
||||||
|
}
|
||||||
|
if caps.CanDeleteModels {
|
||||||
|
t.Error("LM Studio should NOT be able to delete models")
|
||||||
|
}
|
||||||
|
if caps.CanCreateModels {
|
||||||
|
t.Error("LM Studio should NOT be able to create models")
|
||||||
|
}
|
||||||
|
if !caps.CanStreamChat {
|
||||||
|
t.Error("LM Studio should be able to stream chat")
|
||||||
|
}
|
||||||
|
if !caps.CanEmbed {
|
||||||
|
t.Error("LM Studio should be able to embed")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackendConfig_Validate(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
config BackendConfig
|
||||||
|
expectErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid ollama config",
|
||||||
|
config: BackendConfig{
|
||||||
|
Type: BackendTypeOllama,
|
||||||
|
BaseURL: "http://localhost:11434",
|
||||||
|
},
|
||||||
|
expectErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid llamacpp config",
|
||||||
|
config: BackendConfig{
|
||||||
|
Type: BackendTypeLlamaCpp,
|
||||||
|
BaseURL: "http://localhost:8081",
|
||||||
|
},
|
||||||
|
expectErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty base URL",
|
||||||
|
config: BackendConfig{
|
||||||
|
Type: BackendTypeOllama,
|
||||||
|
BaseURL: "",
|
||||||
|
},
|
||||||
|
expectErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid URL",
|
||||||
|
config: BackendConfig{
|
||||||
|
Type: BackendTypeOllama,
|
||||||
|
BaseURL: "not-a-url",
|
||||||
|
},
|
||||||
|
expectErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
err := tt.config.Validate()
|
||||||
|
if (err != nil) != tt.expectErr {
|
||||||
|
t.Errorf("BackendConfig.Validate() error = %v, expectErr %v", err, tt.expectErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModel_HasCapability(t *testing.T) {
|
||||||
|
model := Model{
|
||||||
|
ID: "llama3.2:8b",
|
||||||
|
Name: "llama3.2:8b",
|
||||||
|
Capabilities: []string{"chat", "vision", "tools"},
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
capability string
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
{"has chat", "chat", true},
|
||||||
|
{"has vision", "vision", true},
|
||||||
|
{"has tools", "tools", true},
|
||||||
|
{"no thinking", "thinking", false},
|
||||||
|
{"no code", "code", false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := model.HasCapability(tt.capability); got != tt.expected {
|
||||||
|
t.Errorf("Model.HasCapability(%q) = %v, want %v", tt.capability, got, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChatMessage_Validation(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
msg ChatMessage
|
||||||
|
expectErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid user message",
|
||||||
|
msg: ChatMessage{Role: "user", Content: "Hello"},
|
||||||
|
expectErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid assistant message",
|
||||||
|
msg: ChatMessage{Role: "assistant", Content: "Hi there"},
|
||||||
|
expectErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid system message",
|
||||||
|
msg: ChatMessage{Role: "system", Content: "You are helpful"},
|
||||||
|
expectErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid role",
|
||||||
|
msg: ChatMessage{Role: "invalid", Content: "Hello"},
|
||||||
|
expectErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty role",
|
||||||
|
msg: ChatMessage{Role: "", Content: "Hello"},
|
||||||
|
expectErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
err := tt.msg.Validate()
|
||||||
|
if (err != nil) != tt.expectErr {
|
||||||
|
t.Errorf("ChatMessage.Validate() error = %v, expectErr %v", err, tt.expectErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChatRequest_Validation(t *testing.T) {
|
||||||
|
streaming := true
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
req ChatRequest
|
||||||
|
expectErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid request",
|
||||||
|
req: ChatRequest{
|
||||||
|
Model: "llama3.2:8b",
|
||||||
|
Messages: []ChatMessage{
|
||||||
|
{Role: "user", Content: "Hello"},
|
||||||
|
},
|
||||||
|
Stream: &streaming,
|
||||||
|
},
|
||||||
|
expectErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty model",
|
||||||
|
req: ChatRequest{
|
||||||
|
Model: "",
|
||||||
|
Messages: []ChatMessage{
|
||||||
|
{Role: "user", Content: "Hello"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty messages",
|
||||||
|
req: ChatRequest{
|
||||||
|
Model: "llama3.2:8b",
|
||||||
|
Messages: []ChatMessage{},
|
||||||
|
},
|
||||||
|
expectErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nil messages",
|
||||||
|
req: ChatRequest{
|
||||||
|
Model: "llama3.2:8b",
|
||||||
|
Messages: nil,
|
||||||
|
},
|
||||||
|
expectErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
err := tt.req.Validate()
|
||||||
|
if (err != nil) != tt.expectErr {
|
||||||
|
t.Errorf("ChatRequest.Validate() error = %v, expectErr %v", err, tt.expectErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackendInfo(t *testing.T) {
|
||||||
|
info := BackendInfo{
|
||||||
|
Type: BackendTypeOllama,
|
||||||
|
BaseURL: "http://localhost:11434",
|
||||||
|
Status: BackendStatusConnected,
|
||||||
|
Capabilities: OllamaCapabilities(),
|
||||||
|
Version: "0.1.0",
|
||||||
|
}
|
||||||
|
|
||||||
|
if !info.IsConnected() {
|
||||||
|
t.Error("BackendInfo.IsConnected() should be true when status is connected")
|
||||||
|
}
|
||||||
|
|
||||||
|
info.Status = BackendStatusDisconnected
|
||||||
|
if info.IsConnected() {
|
||||||
|
t.Error("BackendInfo.IsConnected() should be false when status is disconnected")
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
BIN
Binary file not shown.
@@ -1,6 +1,8 @@
|
|||||||
name: vessel-dev
|
name: vessel-dev
|
||||||
|
|
||||||
# Development docker-compose - uses host network for direct Ollama access
|
# Development docker-compose - uses host network for direct Ollama access
|
||||||
|
# Reads configuration from .env file
|
||||||
|
|
||||||
services:
|
services:
|
||||||
frontend:
|
frontend:
|
||||||
build:
|
build:
|
||||||
@@ -12,8 +14,8 @@ services:
|
|||||||
- ./frontend:/app
|
- ./frontend:/app
|
||||||
- /app/node_modules
|
- /app/node_modules
|
||||||
environment:
|
environment:
|
||||||
- OLLAMA_API_URL=http://localhost:11434
|
- OLLAMA_API_URL=${OLLAMA_API_URL:-http://localhost:11434}
|
||||||
- BACKEND_URL=http://localhost:9090
|
- BACKEND_URL=${BACKEND_URL:-http://localhost:9090}
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
|
|
||||||
@@ -26,4 +28,4 @@ services:
|
|||||||
- ./backend/data:/app/data
|
- ./backend/data:/app/data
|
||||||
environment:
|
environment:
|
||||||
- GIN_MODE=release
|
- GIN_MODE=release
|
||||||
command: ["./server", "-port", "9090", "-db", "/app/data/vessel.db", "-ollama-url", "http://localhost:11434"]
|
command: ["./server", "-port", "${PORT:-9090}", "-db", "${DB_PATH:-/app/data/vessel.db}", "-ollama-url", "${OLLAMA_URL:-http://localhost:11434}"]
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ services:
|
|||||||
- "9090:9090"
|
- "9090:9090"
|
||||||
environment:
|
environment:
|
||||||
- OLLAMA_URL=http://host.docker.internal:11434
|
- OLLAMA_URL=http://host.docker.internal:11434
|
||||||
|
- LLAMACPP_URL=http://host.docker.internal:8081
|
||||||
|
- LMSTUDIO_URL=http://host.docker.internal:1234
|
||||||
- PORT=9090
|
- PORT=9090
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "host.docker.internal:host-gateway"
|
- "host.docker.internal:host-gateway"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "vessel",
|
"name": "vessel",
|
||||||
"version": "0.6.0",
|
"version": "0.7.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
/**
|
/**
|
||||||
* BranchNavigator - Navigate between message branches
|
* BranchNavigator - Navigate between message branches
|
||||||
* Shows "< 1/3 >" style navigation for sibling messages
|
* Shows "< 1/3 >" style navigation for sibling messages
|
||||||
* Supports keyboard navigation with arrow keys when focused
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { BranchInfo } from '$lib/types';
|
import type { BranchInfo } from '$lib/types';
|
||||||
@@ -15,7 +14,7 @@
|
|||||||
const { branchInfo, onSwitch }: Props = $props();
|
const { branchInfo, onSwitch }: Props = $props();
|
||||||
|
|
||||||
// Reference to the navigator container for focus management
|
// Reference to the navigator container for focus management
|
||||||
let navigatorRef: HTMLDivElement | null = $state(null);
|
let navigatorRef: HTMLElement | null = $state(null);
|
||||||
|
|
||||||
// Track transition state for smooth animations
|
// Track transition state for smooth animations
|
||||||
let isTransitioning = $state(false);
|
let isTransitioning = $state(false);
|
||||||
@@ -52,7 +51,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle keyboard navigation when the component is focused
|
* Handle keyboard navigation with arrow keys
|
||||||
*/
|
*/
|
||||||
function handleKeydown(event: KeyboardEvent): void {
|
function handleKeydown(event: KeyboardEvent): void {
|
||||||
if (event.key === 'ArrowLeft' && canGoPrev) {
|
if (event.key === 'ArrowLeft' && canGoPrev) {
|
||||||
@@ -65,11 +64,10 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<nav
|
||||||
bind:this={navigatorRef}
|
bind:this={navigatorRef}
|
||||||
class="inline-flex items-center gap-1 rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-600 transition-all duration-150 ease-out dark:bg-gray-700 dark:text-gray-300"
|
class="inline-flex items-center gap-1 rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-600 transition-all duration-150 ease-out dark:bg-gray-700 dark:text-gray-300"
|
||||||
class:opacity-50={isTransitioning}
|
class:opacity-50={isTransitioning}
|
||||||
role="navigation"
|
|
||||||
aria-label="Message branch navigation - Use left/right arrow keys to navigate"
|
aria-label="Message branch navigation - Use left/right arrow keys to navigate"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
onkeydown={handleKeydown}
|
onkeydown={handleKeydown}
|
||||||
@@ -126,16 +124,16 @@
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</nav>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* Focus ring style for keyboard navigation */
|
/* Focus ring style for keyboard navigation */
|
||||||
div:focus {
|
nav:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.5);
|
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
div:focus-visible {
|
nav:focus-visible {
|
||||||
outline: none;
|
outline: none;
|
||||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.5);
|
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.5);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,12 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { chatState, modelsState, conversationsState, toolsState, promptsState, toastState, agentsState } from '$lib/stores';
|
import { chatState, modelsState, conversationsState, toolsState, promptsState, toastState, agentsState } from '$lib/stores';
|
||||||
|
import { backendsState } from '$lib/stores/backends.svelte';
|
||||||
import { resolveSystemPrompt } from '$lib/services/prompt-resolution.js';
|
import { resolveSystemPrompt } from '$lib/services/prompt-resolution.js';
|
||||||
import { serverConversationsState } from '$lib/stores/server-conversations.svelte';
|
import { serverConversationsState } from '$lib/stores/server-conversations.svelte';
|
||||||
import { streamingMetricsState } from '$lib/stores/streaming-metrics.svelte';
|
import { streamingMetricsState } from '$lib/stores/streaming-metrics.svelte';
|
||||||
import { ollamaClient } from '$lib/ollama';
|
import { ollamaClient } from '$lib/ollama';
|
||||||
|
import { unifiedLLMClient, type ChatMessage as UnifiedChatMessage } from '$lib/llm';
|
||||||
import { addMessage as addStoredMessage, updateConversation, createConversation as createStoredConversation, saveAttachments } from '$lib/storage';
|
import { addMessage as addStoredMessage, updateConversation, createConversation as createStoredConversation, saveAttachments } from '$lib/storage';
|
||||||
import type { FileAttachment } from '$lib/types/attachment.js';
|
import type { FileAttachment } from '$lib/types/attachment.js';
|
||||||
import { fileAnalyzer, analyzeFilesInBatches, formatAnalyzedAttachment, type AnalysisResult } from '$lib/services/fileAnalyzer.js';
|
import { fileAnalyzer, analyzeFilesInBatches, formatAnalyzedAttachment, type AnalysisResult } from '$lib/services/fileAnalyzer.js';
|
||||||
@@ -530,11 +532,33 @@
|
|||||||
await sendMessageInternal(content, images, attachments);
|
await sendMessageInternal(content, images, attachments);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current model name based on active backend
|
||||||
|
*/
|
||||||
|
async function getCurrentModelName(): Promise<string | null> {
|
||||||
|
if (backendsState.activeType === 'ollama') {
|
||||||
|
return modelsState.selectedId;
|
||||||
|
} else if (backendsState.activeType === 'llamacpp' || backendsState.activeType === 'lmstudio') {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/v1/ai/models');
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.models && data.models.length > 0) {
|
||||||
|
return data.models[0].name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to get model from backend:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Internal: Send message and stream response (bypasses context check)
|
* Internal: Send message and stream response (bypasses context check)
|
||||||
*/
|
*/
|
||||||
async function sendMessageInternal(content: string, images?: string[], attachments?: FileAttachment[]): Promise<void> {
|
async function sendMessageInternal(content: string, images?: string[], attachments?: FileAttachment[]): Promise<void> {
|
||||||
const selectedModel = modelsState.selectedId;
|
const selectedModel = await getCurrentModelName();
|
||||||
if (!selectedModel) return;
|
if (!selectedModel) return;
|
||||||
|
|
||||||
// In 'new' mode with no messages yet, create conversation first
|
// In 'new' mode with no messages yet, create conversation first
|
||||||
@@ -807,7 +831,91 @@
|
|||||||
let streamingThinking = '';
|
let streamingThinking = '';
|
||||||
let thinkingClosed = false;
|
let thinkingClosed = false;
|
||||||
|
|
||||||
await ollamaClient.streamChatWithCallbacks(
|
// Common completion handler for both clients
|
||||||
|
const handleStreamComplete = async () => {
|
||||||
|
// Close thinking block if it was opened but not closed (e.g., tool calls without content)
|
||||||
|
if (streamingThinking && !thinkingClosed) {
|
||||||
|
chatState.appendToStreaming('</think>\n\n');
|
||||||
|
thinkingClosed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
chatState.finishStreaming();
|
||||||
|
streamingMetricsState.endStream();
|
||||||
|
abortController = null;
|
||||||
|
|
||||||
|
// Handle native tool calls if received (Ollama only)
|
||||||
|
if (pendingToolCalls && pendingToolCalls.length > 0) {
|
||||||
|
await executeToolsAndContinue(
|
||||||
|
model,
|
||||||
|
assistantMessageId,
|
||||||
|
pendingToolCalls,
|
||||||
|
conversationId
|
||||||
|
);
|
||||||
|
return; // Tool continuation handles persistence
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for text-based tool calls (models without native tool calling)
|
||||||
|
const node = chatState.messageTree.get(assistantMessageId);
|
||||||
|
if (node && toolsState.toolsEnabled) {
|
||||||
|
const { toolCalls: textToolCalls, cleanContent } = parseTextToolCalls(node.message.content);
|
||||||
|
if (textToolCalls.length > 0) {
|
||||||
|
// Convert to OllamaToolCall format
|
||||||
|
const convertedCalls: OllamaToolCall[] = textToolCalls.map(tc => ({
|
||||||
|
function: {
|
||||||
|
name: tc.name,
|
||||||
|
arguments: tc.arguments
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Update message content to remove the raw tool call text
|
||||||
|
if (cleanContent !== node.message.content) {
|
||||||
|
node.message.content = cleanContent || 'Using tool...';
|
||||||
|
}
|
||||||
|
|
||||||
|
await executeToolsAndContinue(
|
||||||
|
model,
|
||||||
|
assistantMessageId,
|
||||||
|
convertedCalls,
|
||||||
|
conversationId
|
||||||
|
);
|
||||||
|
return; // Tool continuation handles persistence
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist assistant message to IndexedDB with the SAME ID as chatState
|
||||||
|
if (conversationId) {
|
||||||
|
const nodeForPersist = chatState.messageTree.get(assistantMessageId);
|
||||||
|
if (nodeForPersist) {
|
||||||
|
await addStoredMessage(
|
||||||
|
conversationId,
|
||||||
|
{ role: 'assistant', content: nodeForPersist.message.content },
|
||||||
|
parentMessageId,
|
||||||
|
assistantMessageId
|
||||||
|
);
|
||||||
|
await updateConversation(conversationId, {});
|
||||||
|
conversationsState.update(conversationId, {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for auto-compact after response completes
|
||||||
|
await handleAutoCompact();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Common error handler for both clients
|
||||||
|
const handleStreamError = (error: unknown) => {
|
||||||
|
console.error('Streaming error:', error);
|
||||||
|
// Show error to user instead of leaving "Processing..."
|
||||||
|
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
chatState.setStreamContent(`⚠️ Error: ${errorMsg}`);
|
||||||
|
chatState.finishStreaming();
|
||||||
|
streamingMetricsState.endStream();
|
||||||
|
abortController = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Use appropriate client based on active backend
|
||||||
|
if (backendsState.activeType === 'ollama') {
|
||||||
|
// Ollama - full feature support (thinking, native tool calls)
|
||||||
|
await ollamaClient.streamChatWithCallbacks(
|
||||||
{
|
{
|
||||||
model: chatModel,
|
model: chatModel,
|
||||||
messages,
|
messages,
|
||||||
@@ -851,86 +959,42 @@
|
|||||||
// Store tool calls to process after streaming completes
|
// Store tool calls to process after streaming completes
|
||||||
pendingToolCalls = toolCalls;
|
pendingToolCalls = toolCalls;
|
||||||
},
|
},
|
||||||
onComplete: async () => {
|
onComplete: handleStreamComplete,
|
||||||
// Close thinking block if it was opened but not closed (e.g., tool calls without content)
|
onError: handleStreamError
|
||||||
if (streamingThinking && !thinkingClosed) {
|
|
||||||
chatState.appendToStreaming('</think>\n\n');
|
|
||||||
thinkingClosed = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
chatState.finishStreaming();
|
|
||||||
streamingMetricsState.endStream();
|
|
||||||
abortController = null;
|
|
||||||
|
|
||||||
// Handle native tool calls if received
|
|
||||||
if (pendingToolCalls && pendingToolCalls.length > 0) {
|
|
||||||
await executeToolsAndContinue(
|
|
||||||
model,
|
|
||||||
assistantMessageId,
|
|
||||||
pendingToolCalls,
|
|
||||||
conversationId
|
|
||||||
);
|
|
||||||
return; // Tool continuation handles persistence
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for text-based tool calls (models without native tool calling)
|
|
||||||
const node = chatState.messageTree.get(assistantMessageId);
|
|
||||||
if (node && toolsState.toolsEnabled) {
|
|
||||||
const { toolCalls: textToolCalls, cleanContent } = parseTextToolCalls(node.message.content);
|
|
||||||
if (textToolCalls.length > 0) {
|
|
||||||
// Convert to OllamaToolCall format
|
|
||||||
const convertedCalls: OllamaToolCall[] = textToolCalls.map(tc => ({
|
|
||||||
function: {
|
|
||||||
name: tc.name,
|
|
||||||
arguments: tc.arguments
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Update message content to remove the raw tool call text
|
|
||||||
if (cleanContent !== node.message.content) {
|
|
||||||
node.message.content = cleanContent || 'Using tool...';
|
|
||||||
}
|
|
||||||
|
|
||||||
await executeToolsAndContinue(
|
|
||||||
model,
|
|
||||||
assistantMessageId,
|
|
||||||
convertedCalls,
|
|
||||||
conversationId
|
|
||||||
);
|
|
||||||
return; // Tool continuation handles persistence
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Persist assistant message to IndexedDB with the SAME ID as chatState
|
|
||||||
if (conversationId) {
|
|
||||||
const nodeForPersist = chatState.messageTree.get(assistantMessageId);
|
|
||||||
if (nodeForPersist) {
|
|
||||||
await addStoredMessage(
|
|
||||||
conversationId,
|
|
||||||
{ role: 'assistant', content: nodeForPersist.message.content },
|
|
||||||
parentMessageId,
|
|
||||||
assistantMessageId
|
|
||||||
);
|
|
||||||
await updateConversation(conversationId, {});
|
|
||||||
conversationsState.update(conversationId, {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for auto-compact after response completes
|
|
||||||
await handleAutoCompact();
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
console.error('Streaming error:', error);
|
|
||||||
// Show error to user instead of leaving "Processing..."
|
|
||||||
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
|
|
||||||
chatState.setStreamContent(`⚠️ Error: ${errorMsg}`);
|
|
||||||
chatState.finishStreaming();
|
|
||||||
streamingMetricsState.endStream();
|
|
||||||
abortController = null;
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
abortController.signal
|
abortController.signal
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
// llama.cpp / LM Studio - basic streaming via unified API
|
||||||
|
const unifiedMessages: UnifiedChatMessage[] = messages.map(m => ({
|
||||||
|
role: m.role as 'system' | 'user' | 'assistant' | 'tool',
|
||||||
|
content: m.content,
|
||||||
|
images: m.images
|
||||||
|
}));
|
||||||
|
|
||||||
|
await unifiedLLMClient.streamChatWithCallbacks(
|
||||||
|
{
|
||||||
|
model: chatModel,
|
||||||
|
messages: unifiedMessages,
|
||||||
|
options: settingsState.apiParameters
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onToken: (token) => {
|
||||||
|
// Clear "Processing..." on first token
|
||||||
|
if (needsClearOnFirstToken) {
|
||||||
|
chatState.setStreamContent('');
|
||||||
|
needsClearOnFirstToken = false;
|
||||||
|
}
|
||||||
|
chatState.appendToStreaming(token);
|
||||||
|
// Track content tokens for metrics
|
||||||
|
streamingMetricsState.incrementTokens();
|
||||||
|
},
|
||||||
|
onComplete: handleStreamComplete,
|
||||||
|
onError: handleStreamError
|
||||||
|
},
|
||||||
|
abortController.signal
|
||||||
|
);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to send message:', error);
|
console.error('Failed to send message:', error);
|
||||||
// Show error to user
|
// Show error to user
|
||||||
@@ -1346,6 +1410,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
role="switch"
|
role="switch"
|
||||||
aria-checked={thinkingEnabled}
|
aria-checked={thinkingEnabled}
|
||||||
|
aria-label="Toggle thinking mode"
|
||||||
onclick={() => (thinkingEnabled = !thinkingEnabled)}
|
onclick={() => (thinkingEnabled = !thinkingEnabled)}
|
||||||
class="relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-amber-500 focus:ring-offset-2 focus:ring-offset-theme-primary {thinkingEnabled ? 'bg-amber-600' : 'bg-theme-tertiary'}"
|
class="relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-amber-500 focus:ring-offset-2 focus:ring-offset-theme-primary {thinkingEnabled ? 'bg-amber-600' : 'bg-theme-tertiary'}"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -13,12 +13,25 @@
|
|||||||
height?: number;
|
height?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { html, title = 'Preview', height = 300 }: Props = $props();
|
const props: Props = $props();
|
||||||
|
|
||||||
|
// Derive values from props
|
||||||
|
const html = $derived(props.html);
|
||||||
|
const title = $derived(props.title ?? 'Preview');
|
||||||
|
const height = $derived(props.height ?? 300);
|
||||||
|
|
||||||
// State
|
// State
|
||||||
let iframeRef: HTMLIFrameElement | null = $state(null);
|
let iframeRef: HTMLIFrameElement | null = $state(null);
|
||||||
let isExpanded = $state(false);
|
let isExpanded = $state(false);
|
||||||
let actualHeight = $state(height);
|
// actualHeight tracks the current display height, synced from prop when not expanded
|
||||||
|
let actualHeight = $state(props.height ?? 300);
|
||||||
|
|
||||||
|
// Sync actualHeight when height prop changes (only when not expanded)
|
||||||
|
$effect(() => {
|
||||||
|
if (!isExpanded) {
|
||||||
|
actualHeight = height;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Generate a complete HTML document if the code is just a fragment
|
// Generate a complete HTML document if the code is just a fragment
|
||||||
const fullHtml = $derived.by(() => {
|
const fullHtml = $derived.by(() => {
|
||||||
|
|||||||
@@ -14,9 +14,15 @@
|
|||||||
inProgress?: boolean;
|
inProgress?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { content, defaultExpanded = false, inProgress = false }: Props = $props();
|
const props: Props = $props();
|
||||||
|
|
||||||
let isExpanded = $state(defaultExpanded);
|
// Initialize isExpanded from defaultExpanded prop
|
||||||
|
// This intentionally captures the initial value only - user controls expansion independently
|
||||||
|
let isExpanded = $state(props.defaultExpanded ?? false);
|
||||||
|
|
||||||
|
// Derived values from props for reactivity
|
||||||
|
const content = $derived(props.content);
|
||||||
|
const inProgress = $derived(props.inProgress ?? false);
|
||||||
|
|
||||||
// Keep collapsed during and after streaming - user can expand manually if desired
|
// Keep collapsed during and after streaming - user can expand manually if desired
|
||||||
|
|
||||||
|
|||||||
@@ -109,9 +109,11 @@
|
|||||||
<div
|
<div
|
||||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"
|
||||||
onclick={handleBackdropClick}
|
onclick={handleBackdropClick}
|
||||||
|
onkeydown={handleKeydown}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby="model-editor-title"
|
aria-labelledby="model-editor-title"
|
||||||
|
tabindex="-1"
|
||||||
>
|
>
|
||||||
<!-- Dialog -->
|
<!-- Dialog -->
|
||||||
<div class="w-full max-w-lg rounded-xl bg-theme-secondary shadow-xl">
|
<div class="w-full max-w-lg rounded-xl bg-theme-secondary shadow-xl">
|
||||||
|
|||||||
@@ -40,9 +40,11 @@
|
|||||||
<div
|
<div
|
||||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||||
onclick={handleBackdropClick}
|
onclick={handleBackdropClick}
|
||||||
|
onkeydown={handleKeydown}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby="pull-dialog-title"
|
aria-labelledby="pull-dialog-title"
|
||||||
|
tabindex="-1"
|
||||||
>
|
>
|
||||||
<!-- Dialog -->
|
<!-- Dialog -->
|
||||||
<div class="w-full max-w-md rounded-xl bg-theme-secondary p-6 shadow-xl">
|
<div class="w-full max-w-md rounded-xl bg-theme-secondary p-6 shadow-xl">
|
||||||
|
|||||||
@@ -71,9 +71,11 @@
|
|||||||
<div
|
<div
|
||||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||||
onclick={handleBackdropClick}
|
onclick={handleBackdropClick}
|
||||||
|
onkeydown={handleKeydown}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby="move-dialog-title"
|
aria-labelledby="move-dialog-title"
|
||||||
|
tabindex="-1"
|
||||||
>
|
>
|
||||||
<!-- Dialog -->
|
<!-- Dialog -->
|
||||||
<div class="mx-4 w-full max-w-sm rounded-xl border border-theme bg-theme-primary shadow-2xl">
|
<div class="mx-4 w-full max-w-sm rounded-xl border border-theme bg-theme-primary shadow-2xl">
|
||||||
|
|||||||
@@ -210,9 +210,11 @@
|
|||||||
<div
|
<div
|
||||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||||
onclick={handleBackdropClick}
|
onclick={handleBackdropClick}
|
||||||
|
onkeydown={handleKeydown}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby="project-dialog-title"
|
aria-labelledby="project-dialog-title"
|
||||||
|
tabindex="-1"
|
||||||
>
|
>
|
||||||
<!-- Dialog -->
|
<!-- Dialog -->
|
||||||
<div class="mx-4 w-full max-w-lg rounded-xl border border-theme bg-theme-primary shadow-2xl">
|
<div class="mx-4 w-full max-w-lg rounded-xl border border-theme bg-theme-primary shadow-2xl">
|
||||||
@@ -313,9 +315,9 @@
|
|||||||
|
|
||||||
<!-- Color -->
|
<!-- Color -->
|
||||||
<div>
|
<div>
|
||||||
<label class="mb-1.5 block text-sm font-medium text-theme-secondary">
|
<span class="mb-1.5 block text-sm font-medium text-theme-secondary">
|
||||||
Color
|
Color
|
||||||
</label>
|
</span>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
{#each presetColors as presetColor}
|
{#each presetColors as presetColor}
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* AIProvidersTab - Combined Backends and Models management
|
||||||
|
* Sub-tabs for backend configuration and model management
|
||||||
|
* Models sub-tab only available when Ollama is active
|
||||||
|
*/
|
||||||
|
import { backendsState } from '$lib/stores/backends.svelte';
|
||||||
|
import BackendsPanel from './BackendsPanel.svelte';
|
||||||
|
import ModelsTab from './ModelsTab.svelte';
|
||||||
|
|
||||||
|
type SubTab = 'backends' | 'models';
|
||||||
|
|
||||||
|
let activeSubTab = $state<SubTab>('backends');
|
||||||
|
|
||||||
|
// Models tab only available for Ollama
|
||||||
|
const isOllamaActive = $derived(backendsState.activeType === 'ollama');
|
||||||
|
|
||||||
|
// If Models tab is active but Ollama is no longer active, switch to Backends
|
||||||
|
$effect(() => {
|
||||||
|
if (activeSubTab === 'models' && !isOllamaActive) {
|
||||||
|
activeSubTab = 'backends';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Sub-tab Navigation -->
|
||||||
|
<div class="flex gap-1 border-b border-theme">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (activeSubTab = 'backends')}
|
||||||
|
class="flex items-center gap-2 border-b-2 px-4 py-2 text-sm font-medium transition-colors {activeSubTab === 'backends'
|
||||||
|
? 'border-violet-500 text-violet-400'
|
||||||
|
: 'border-transparent text-theme-muted hover:border-theme hover:text-theme-primary'}"
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M5 12h14M5 12a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2M5 12a2 2 0 0 0-2 2v4a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-4a2 2 0 0 0-2-2m-2-4h.01M17 16h.01" />
|
||||||
|
</svg>
|
||||||
|
Backends
|
||||||
|
</button>
|
||||||
|
{#if isOllamaActive}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (activeSubTab = 'models')}
|
||||||
|
class="flex items-center gap-2 border-b-2 px-4 py-2 text-sm font-medium transition-colors {activeSubTab === 'models'
|
||||||
|
? 'border-violet-500 text-violet-400'
|
||||||
|
: 'border-transparent text-theme-muted hover:border-theme hover:text-theme-primary'}"
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 3v1.5M4.5 8.25H3m18 0h-1.5M4.5 12H3m18 0h-1.5m-15 3.75H3m18 0h-1.5M8.25 19.5V21M12 3v1.5m0 15V21m3.75-18v1.5m0 15V21m-9-1.5h10.5a2.25 2.25 0 0 0 2.25-2.25V6.75a2.25 2.25 0 0 0-2.25-2.25H6.75A2.25 2.25 0 0 0 4.5 6.75v10.5a2.25 2.25 0 0 0 2.25 2.25Zm.75-12h9v9h-9v-9Z" />
|
||||||
|
</svg>
|
||||||
|
Models
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<span
|
||||||
|
class="flex cursor-not-allowed items-center gap-2 border-b-2 border-transparent px-4 py-2 text-sm font-medium text-theme-muted/50"
|
||||||
|
title="Models tab only available when Ollama is the active backend"
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 3v1.5M4.5 8.25H3m18 0h-1.5M4.5 12H3m18 0h-1.5m-15 3.75H3m18 0h-1.5M8.25 19.5V21M12 3v1.5m0 15V21m3.75-18v1.5m0 15V21m-9-1.5h10.5a2.25 2.25 0 0 0 2.25-2.25V6.75a2.25 2.25 0 0 0-2.25-2.25H6.75A2.25 2.25 0 0 0 4.5 6.75v10.5a2.25 2.25 0 0 0 2.25 2.25Zm.75-12h9v9h-9v-9Z" />
|
||||||
|
</svg>
|
||||||
|
Models
|
||||||
|
<span class="text-xs">(Ollama only)</span>
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sub-tab Content -->
|
||||||
|
{#if activeSubTab === 'backends'}
|
||||||
|
<BackendsPanel />
|
||||||
|
{:else if activeSubTab === 'models'}
|
||||||
|
<ModelsTab />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* AboutTab - App information, version, and update status
|
||||||
|
*/
|
||||||
|
import { versionState } from '$lib/stores';
|
||||||
|
|
||||||
|
const GITHUB_URL = 'https://github.com/VikingOwl91/vessel';
|
||||||
|
const ISSUES_URL = `${GITHUB_URL}/issues`;
|
||||||
|
const LICENSE = 'MIT';
|
||||||
|
|
||||||
|
async function handleCheckForUpdates(): Promise<void> {
|
||||||
|
await versionState.checkForUpdates();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLastChecked(timestamp: number): string {
|
||||||
|
if (!timestamp) return 'Never';
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
return date.toLocaleString();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-8">
|
||||||
|
<!-- App Identity -->
|
||||||
|
<section>
|
||||||
|
<div class="flex items-center gap-6">
|
||||||
|
<div class="flex h-20 w-20 items-center justify-center rounded-xl bg-gradient-to-br from-violet-500 to-indigo-600">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12" viewBox="0 0 24 24">
|
||||||
|
<path d="M12 20 L4 6 Q4 5 5 5 L8 5 L12 12.5 L16 5 L19 5 Q20 5 20 6 L12 20 Z" fill="white"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-bold text-theme-primary">Vessel</h1>
|
||||||
|
<p class="mt-1 text-theme-muted">
|
||||||
|
A modern interface for local AI with chat, tools, and memory management.
|
||||||
|
</p>
|
||||||
|
{#if versionState.current}
|
||||||
|
<div class="mt-2 flex items-center gap-2">
|
||||||
|
<span class="rounded-full bg-emerald-500/20 px-3 py-0.5 text-sm font-medium text-emerald-400">
|
||||||
|
v{versionState.current}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Version & Updates -->
|
||||||
|
<section>
|
||||||
|
<h2 class="mb-4 flex items-center gap-2 text-lg font-semibold text-theme-primary">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99" />
|
||||||
|
</svg>
|
||||||
|
Updates
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div class="rounded-lg border border-theme bg-theme-secondary p-4 space-y-4">
|
||||||
|
{#if versionState.hasUpdate}
|
||||||
|
<div class="flex items-start gap-3 rounded-lg bg-amber-500/10 border border-amber-500/30 p-3">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-amber-400 flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 3.75h.008v.008H12v-.008Z" />
|
||||||
|
</svg>
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="font-medium text-amber-200">Update Available</p>
|
||||||
|
<p class="text-sm text-amber-300/80">
|
||||||
|
Version {versionState.latest} is available. You're currently on v{versionState.current}.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
|
||||||
|
</svg>
|
||||||
|
<span class="text-sm text-theme-secondary">You're running the latest version</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={handleCheckForUpdates}
|
||||||
|
disabled={versionState.isChecking}
|
||||||
|
class="flex items-center gap-2 rounded-lg bg-theme-tertiary px-4 py-2 text-sm font-medium text-theme-secondary transition-colors hover:bg-theme-hover disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{#if versionState.isChecking}
|
||||||
|
<svg class="h-4 w-4 animate-spin" 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>
|
||||||
|
Checking...
|
||||||
|
{:else}
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99" />
|
||||||
|
</svg>
|
||||||
|
Check for Updates
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if versionState.hasUpdate && versionState.updateUrl}
|
||||||
|
<a
|
||||||
|
href={versionState.updateUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="flex items-center gap-2 rounded-lg bg-emerald-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-emerald-500"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
|
||||||
|
</svg>
|
||||||
|
Download v{versionState.latest}
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if versionState.lastChecked}
|
||||||
|
<p class="text-xs text-theme-muted">
|
||||||
|
Last checked: {formatLastChecked(versionState.lastChecked)}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Links -->
|
||||||
|
<section>
|
||||||
|
<h2 class="mb-4 flex items-center gap-2 text-lg font-semibold text-theme-primary">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-purple-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244" />
|
||||||
|
</svg>
|
||||||
|
Links
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div class="grid gap-3 sm:grid-cols-2">
|
||||||
|
<a
|
||||||
|
href={GITHUB_URL}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="flex items-center gap-3 rounded-lg border border-theme bg-theme-secondary p-4 transition-colors hover:bg-theme-tertiary"
|
||||||
|
>
|
||||||
|
<svg class="h-6 w-6 text-theme-secondary" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path fill-rule="evenodd" d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<p class="font-medium text-theme-primary">GitHub Repository</p>
|
||||||
|
<p class="text-xs text-theme-muted">Source code and releases</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href={ISSUES_URL}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="flex items-center gap-3 rounded-lg border border-theme bg-theme-secondary p-4 transition-colors hover:bg-theme-tertiary"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-theme-secondary" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 12.75c1.148 0 2.278.08 3.383.237 1.037.146 1.866.966 1.866 2.013 0 3.728-2.35 6.75-5.25 6.75S6.75 18.728 6.75 15c0-1.046.83-1.867 1.866-2.013A24.204 24.204 0 0 1 12 12.75Zm0 0c2.883 0 5.647.508 8.207 1.44a23.91 23.91 0 0 1-1.152 6.06M12 12.75c-2.883 0-5.647.508-8.208 1.44.125 2.104.52 4.136 1.153 6.06M12 12.75a2.25 2.25 0 0 0 2.248-2.354M12 12.75a2.25 2.25 0 0 1-2.248-2.354M12 8.25c.995 0 1.971-.08 2.922-.236.403-.066.74-.358.795-.762a3.778 3.778 0 0 0-.399-2.25M12 8.25c-.995 0-1.97-.08-2.922-.236-.402-.066-.74-.358-.795-.762a3.734 3.734 0 0 1 .4-2.253M12 8.25a2.25 2.25 0 0 0-2.248 2.146M12 8.25a2.25 2.25 0 0 1 2.248 2.146M8.683 5a6.032 6.032 0 0 1-1.155-1.002c.07-.63.27-1.222.574-1.747m.581 2.749A3.75 3.75 0 0 1 15.318 5m0 0c.427-.283.815-.62 1.155-.999a4.471 4.471 0 0 0-.575-1.752M4.921 6a24.048 24.048 0 0 0-.392 3.314c1.668.546 3.416.914 5.223 1.082M19.08 6c.205 1.08.337 2.187.392 3.314a23.882 23.882 0 0 1-5.223 1.082" />
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<p class="font-medium text-theme-primary">Report an Issue</p>
|
||||||
|
<p class="text-xs text-theme-muted">Bug reports and feature requests</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Tech Stack & License -->
|
||||||
|
<section>
|
||||||
|
<h2 class="mb-4 flex items-center gap-2 text-lg font-semibold text-theme-primary">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-teal-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="m6.75 7.5 3 2.25-3 2.25m4.5 0h3m-9 8.25h13.5A2.25 2.25 0 0 0 21 18V6a2.25 2.25 0 0 0-2.25-2.25H5.25A2.25 2.25 0 0 0 3 6v12a2.25 2.25 0 0 0 2.25 2.25Z" />
|
||||||
|
</svg>
|
||||||
|
Technical Info
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div class="rounded-lg border border-theme bg-theme-secondary p-4 space-y-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-theme-secondary">Built With</p>
|
||||||
|
<div class="mt-2 flex flex-wrap gap-2">
|
||||||
|
<span class="rounded-full bg-orange-500/20 px-3 py-1 text-xs font-medium text-orange-300">Svelte 5</span>
|
||||||
|
<span class="rounded-full bg-blue-500/20 px-3 py-1 text-xs font-medium text-blue-300">SvelteKit</span>
|
||||||
|
<span class="rounded-full bg-cyan-500/20 px-3 py-1 text-xs font-medium text-cyan-300">Go</span>
|
||||||
|
<span class="rounded-full bg-sky-500/20 px-3 py-1 text-xs font-medium text-sky-300">Tailwind CSS</span>
|
||||||
|
<span class="rounded-full bg-emerald-500/20 px-3 py-1 text-xs font-medium text-emerald-300">Ollama</span>
|
||||||
|
<span class="rounded-full bg-purple-500/20 px-3 py-1 text-xs font-medium text-purple-300">llama.cpp</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-t border-theme pt-4">
|
||||||
|
<p class="text-sm font-medium text-theme-secondary">License</p>
|
||||||
|
<p class="mt-1 text-sm text-theme-muted">
|
||||||
|
Released under the <span class="text-theme-secondary">{LICENSE}</span> license
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
@@ -435,7 +435,7 @@
|
|||||||
|
|
||||||
<!-- Tools Selection -->
|
<!-- Tools Selection -->
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="mb-2 block text-sm font-medium text-theme-primary"> Allowed Tools </label>
|
<span class="mb-2 block text-sm font-medium text-theme-primary"> Allowed Tools </span>
|
||||||
<div class="max-h-48 overflow-y-auto rounded-lg border border-theme bg-theme-secondary p-2">
|
<div class="max-h-48 overflow-y-auto rounded-lg border border-theme bg-theme-secondary p-2">
|
||||||
{#if availableTools.length === 0}
|
{#if availableTools.length === 0}
|
||||||
<p class="p-2 text-sm text-theme-muted">No tools available</p>
|
<p class="p-2 text-sm text-theme-muted">No tools available</p>
|
||||||
|
|||||||
@@ -0,0 +1,305 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* BackendsPanel - Multi-backend LLM management
|
||||||
|
* Configure and switch between Ollama, llama.cpp, and LM Studio
|
||||||
|
*/
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { backendsState, type BackendType, type BackendInfo, type DiscoveryResult } from '$lib/stores/backends.svelte';
|
||||||
|
|
||||||
|
let discovering = $state(false);
|
||||||
|
let discoveryResults = $state<DiscoveryResult[]>([]);
|
||||||
|
let showDiscoveryResults = $state(false);
|
||||||
|
|
||||||
|
async function handleDiscover(): Promise<void> {
|
||||||
|
discovering = true;
|
||||||
|
showDiscoveryResults = false;
|
||||||
|
try {
|
||||||
|
discoveryResults = await backendsState.discover();
|
||||||
|
showDiscoveryResults = true;
|
||||||
|
// Reload backends after discovery
|
||||||
|
await backendsState.load();
|
||||||
|
} finally {
|
||||||
|
discovering = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSetActive(type: BackendType): Promise<void> {
|
||||||
|
await backendsState.setActive(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBackendDisplayName(type: BackendType): string {
|
||||||
|
switch (type) {
|
||||||
|
case 'ollama':
|
||||||
|
return 'Ollama';
|
||||||
|
case 'llamacpp':
|
||||||
|
return 'llama.cpp';
|
||||||
|
case 'lmstudio':
|
||||||
|
return 'LM Studio';
|
||||||
|
default:
|
||||||
|
return type;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBackendDescription(type: BackendType): string {
|
||||||
|
switch (type) {
|
||||||
|
case 'ollama':
|
||||||
|
return 'Full model management - pull, delete, create custom models';
|
||||||
|
case 'llamacpp':
|
||||||
|
return 'OpenAI-compatible API - models loaded at server startup';
|
||||||
|
case 'lmstudio':
|
||||||
|
return 'OpenAI-compatible API - manage models via LM Studio app';
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDefaultPort(type: BackendType): string {
|
||||||
|
switch (type) {
|
||||||
|
case 'ollama':
|
||||||
|
return '11434';
|
||||||
|
case 'llamacpp':
|
||||||
|
return '8081';
|
||||||
|
case 'lmstudio':
|
||||||
|
return '1234';
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusColor(status: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'connected':
|
||||||
|
return 'bg-green-500';
|
||||||
|
case 'disconnected':
|
||||||
|
return 'bg-red-500';
|
||||||
|
default:
|
||||||
|
return 'bg-yellow-500';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
backendsState.load();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-xl font-bold text-theme-primary">AI Backends</h2>
|
||||||
|
<p class="mt-1 text-sm text-theme-muted">
|
||||||
|
Configure LLM backends: Ollama, llama.cpp server, or LM Studio
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={handleDiscover}
|
||||||
|
disabled={discovering}
|
||||||
|
class="flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{#if discovering}
|
||||||
|
<svg class="h-4 w-4 animate-spin" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none"></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>
|
||||||
|
<span>Discovering...</span>
|
||||||
|
{:else}
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||||
|
</svg>
|
||||||
|
<span>Auto-Detect</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error Message -->
|
||||||
|
{#if backendsState.error}
|
||||||
|
<div class="rounded-lg border border-red-900/50 bg-red-900/20 p-4">
|
||||||
|
<div class="flex items-center gap-2 text-red-400">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<span>{backendsState.error}</span>
|
||||||
|
<button type="button" onclick={() => backendsState.clearError()} class="ml-auto text-red-400 hover:text-red-300" aria-label="Dismiss error">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Discovery Results -->
|
||||||
|
{#if showDiscoveryResults && discoveryResults.length > 0}
|
||||||
|
<div class="rounded-lg border border-theme bg-theme-secondary p-4">
|
||||||
|
<h3 class="mb-3 text-sm font-medium text-theme-secondary">Discovery Results</h3>
|
||||||
|
<div class="space-y-2">
|
||||||
|
{#each discoveryResults as result}
|
||||||
|
<div class="flex items-center justify-between rounded-lg bg-theme-tertiary/50 px-3 py-2">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="h-2 w-2 rounded-full {result.available ? 'bg-green-500' : 'bg-red-500'}"></span>
|
||||||
|
<span class="text-sm text-theme-primary">{getBackendDisplayName(result.type)}</span>
|
||||||
|
<span class="text-xs text-theme-muted">{result.baseUrl}</span>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs {result.available ? 'text-green-400' : 'text-red-400'}">
|
||||||
|
{result.available ? 'Available' : result.error || 'Not found'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => showDiscoveryResults = false}
|
||||||
|
class="mt-3 text-xs text-theme-muted hover:text-theme-primary"
|
||||||
|
>
|
||||||
|
Dismiss
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Active Backend Info -->
|
||||||
|
{#if backendsState.activeBackend}
|
||||||
|
<div class="rounded-lg border border-blue-900/50 bg-blue-900/20 p-4">
|
||||||
|
<div class="flex items-center gap-2 text-blue-400">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<span class="font-medium">Active: {getBackendDisplayName(backendsState.activeBackend.type)}</span>
|
||||||
|
{#if backendsState.activeBackend.version}
|
||||||
|
<span class="text-xs text-blue-300/70">v{backendsState.activeBackend.version}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-sm text-blue-300/70">{backendsState.activeBackend.baseUrl}</p>
|
||||||
|
|
||||||
|
<!-- Capabilities -->
|
||||||
|
<div class="mt-3 flex flex-wrap gap-2">
|
||||||
|
{#if backendsState.canPullModels}
|
||||||
|
<span class="rounded bg-green-900/30 px-2 py-1 text-xs text-green-400">Pull Models</span>
|
||||||
|
{/if}
|
||||||
|
{#if backendsState.canDeleteModels}
|
||||||
|
<span class="rounded bg-green-900/30 px-2 py-1 text-xs text-green-400">Delete Models</span>
|
||||||
|
{/if}
|
||||||
|
{#if backendsState.canCreateModels}
|
||||||
|
<span class="rounded bg-green-900/30 px-2 py-1 text-xs text-green-400">Create Custom</span>
|
||||||
|
{/if}
|
||||||
|
{#if backendsState.activeBackend.capabilities.canStreamChat}
|
||||||
|
<span class="rounded bg-blue-900/30 px-2 py-1 text-xs text-blue-400">Streaming</span>
|
||||||
|
{/if}
|
||||||
|
{#if backendsState.activeBackend.capabilities.canEmbed}
|
||||||
|
<span class="rounded bg-purple-900/30 px-2 py-1 text-xs text-purple-400">Embeddings</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if !backendsState.isLoading}
|
||||||
|
<div class="rounded-lg border border-amber-900/50 bg-amber-900/20 p-4">
|
||||||
|
<div class="flex items-center gap-2 text-amber-400">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||||
|
</svg>
|
||||||
|
<span>No active backend configured. Click "Auto-Detect" to find available backends.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Backend Cards -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
<h3 class="text-sm font-medium text-theme-secondary">Available Backends</h3>
|
||||||
|
|
||||||
|
{#if backendsState.isLoading}
|
||||||
|
<div class="space-y-3">
|
||||||
|
{#each Array(3) as _}
|
||||||
|
<div class="animate-pulse rounded-lg border border-theme bg-theme-secondary p-4">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<div class="h-10 w-10 rounded-lg bg-theme-tertiary"></div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="h-5 w-32 rounded bg-theme-tertiary"></div>
|
||||||
|
<div class="mt-2 h-4 w-48 rounded bg-theme-tertiary"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else if backendsState.backends.length === 0}
|
||||||
|
<div class="rounded-lg border border-dashed border-theme bg-theme-secondary/50 p-8 text-center">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="mx-auto h-12 w-12 text-theme-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
|
||||||
|
</svg>
|
||||||
|
<h3 class="mt-4 text-sm font-medium text-theme-muted">No backends configured</h3>
|
||||||
|
<p class="mt-1 text-sm text-theme-muted">
|
||||||
|
Click "Auto-Detect" to scan for available LLM backends
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{#each backendsState.backends as backend}
|
||||||
|
{@const isActive = backendsState.activeType === backend.type}
|
||||||
|
<div class="rounded-lg border transition-colors {isActive ? 'border-blue-500 bg-blue-900/10' : 'border-theme bg-theme-secondary hover:border-theme-subtle'}">
|
||||||
|
<div class="p-4">
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<!-- Backend Icon -->
|
||||||
|
<div class="flex h-12 w-12 items-center justify-center rounded-lg bg-theme-tertiary">
|
||||||
|
{#if backend.type === 'ollama'}
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-theme-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2" />
|
||||||
|
</svg>
|
||||||
|
{:else if backend.type === 'llamacpp'}
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-theme-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-theme-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<h4 class="font-medium text-theme-primary">{getBackendDisplayName(backend.type)}</h4>
|
||||||
|
<span class="flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs {backend.status === 'connected' ? 'bg-green-900/30 text-green-400' : 'bg-red-900/30 text-red-400'}">
|
||||||
|
<span class="h-1.5 w-1.5 rounded-full {getStatusColor(backend.status)}"></span>
|
||||||
|
{backend.status}
|
||||||
|
</span>
|
||||||
|
{#if isActive}
|
||||||
|
<span class="rounded bg-blue-600 px-2 py-0.5 text-xs font-medium text-white">Active</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-sm text-theme-muted">{getBackendDescription(backend.type)}</p>
|
||||||
|
<p class="mt-1 text-xs text-theme-muted/70">{backend.baseUrl}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
{#if !isActive && backend.status === 'connected'}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => handleSetActive(backend.type)}
|
||||||
|
class="rounded-lg bg-blue-600 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
Set Active
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if backend.error}
|
||||||
|
<div class="mt-3 rounded bg-red-900/20 px-3 py-2 text-xs text-red-400">
|
||||||
|
{backend.error}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Help Section -->
|
||||||
|
<div class="rounded-lg border border-theme bg-theme-secondary/50 p-4">
|
||||||
|
<h3 class="text-sm font-medium text-theme-secondary">Quick Start</h3>
|
||||||
|
<div class="mt-2 space-y-2 text-sm text-theme-muted">
|
||||||
|
<p><strong>Ollama:</strong> Run <code class="rounded bg-theme-tertiary px-1.5 py-0.5 text-xs">ollama serve</code> (default port 11434)</p>
|
||||||
|
<p><strong>llama.cpp:</strong> Run <code class="rounded bg-theme-tertiary px-1.5 py-0.5 text-xs">llama-server -m model.gguf</code> (default port 8081)</p>
|
||||||
|
<p><strong>LM Studio:</strong> Start local server from the app (default port 1234)</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -41,6 +41,7 @@
|
|||||||
class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 focus:ring-offset-theme {uiState.darkMode ? 'bg-purple-600' : 'bg-theme-tertiary'}"
|
class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 focus:ring-offset-theme {uiState.darkMode ? 'bg-purple-600' : 'bg-theme-tertiary'}"
|
||||||
role="switch"
|
role="switch"
|
||||||
aria-checked={uiState.darkMode}
|
aria-checked={uiState.darkMode}
|
||||||
|
aria-label="Toggle dark mode"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out {uiState.darkMode ? 'translate-x-5' : 'translate-x-0'}"
|
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out {uiState.darkMode ? 'translate-x-5' : 'translate-x-0'}"
|
||||||
@@ -127,29 +128,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- About Section -->
|
|
||||||
<section>
|
|
||||||
<h2 class="mb-4 flex items-center gap-2 text-lg font-semibold text-theme-primary">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
</svg>
|
|
||||||
About
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div class="rounded-lg border border-theme bg-theme-secondary p-4">
|
|
||||||
<div class="flex items-center gap-4">
|
|
||||||
<div class="rounded-lg bg-theme-tertiary p-3">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 0v3.75m-16.5-3.75v3.75m16.5 0v3.75C20.25 16.153 16.556 18 12 18s-8.25-1.847-8.25-4.125v-3.75m16.5 0c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 class="font-semibold text-theme-primary">Vessel</h3>
|
|
||||||
<p class="text-sm text-theme-muted">
|
|
||||||
A modern interface for local AI with chat, tools, and memory management.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
let dragOver = $state(false);
|
let dragOver = $state(false);
|
||||||
let deleteConfirm = $state<{ show: boolean; doc: StoredDocument | null }>({ show: false, doc: null });
|
let deleteConfirm = $state<{ show: boolean; doc: StoredDocument | null }>({ show: false, doc: null });
|
||||||
|
|
||||||
let fileInput: HTMLInputElement;
|
let fileInput = $state<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
await refreshData();
|
await refreshData();
|
||||||
|
|||||||
@@ -108,6 +108,7 @@
|
|||||||
class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 focus:ring-offset-theme {settingsState.autoCompactEnabled ? 'bg-emerald-600' : 'bg-theme-tertiary'}"
|
class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 focus:ring-offset-theme {settingsState.autoCompactEnabled ? 'bg-emerald-600' : 'bg-theme-tertiary'}"
|
||||||
role="switch"
|
role="switch"
|
||||||
aria-checked={settingsState.autoCompactEnabled}
|
aria-checked={settingsState.autoCompactEnabled}
|
||||||
|
aria-label="Toggle auto-compact"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out {settingsState.autoCompactEnabled ? 'translate-x-5' : 'translate-x-0'}"
|
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out {settingsState.autoCompactEnabled ? 'translate-x-5' : 'translate-x-0'}"
|
||||||
@@ -192,6 +193,7 @@
|
|||||||
class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-orange-500 focus:ring-offset-2 focus:ring-offset-theme {settingsState.useCustomParameters ? 'bg-orange-600' : 'bg-theme-tertiary'}"
|
class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-orange-500 focus:ring-offset-2 focus:ring-offset-theme {settingsState.useCustomParameters ? 'bg-orange-600' : 'bg-theme-tertiary'}"
|
||||||
role="switch"
|
role="switch"
|
||||||
aria-checked={settingsState.useCustomParameters}
|
aria-checked={settingsState.useCustomParameters}
|
||||||
|
aria-label="Toggle custom model parameters"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out {settingsState.useCustomParameters ? 'translate-x-5' : 'translate-x-0'}"
|
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out {settingsState.useCustomParameters ? 'translate-x-5' : 'translate-x-0'}"
|
||||||
|
|||||||
@@ -93,13 +93,12 @@
|
|||||||
|
|
||||||
<!-- Enable custom parameters toggle -->
|
<!-- Enable custom parameters toggle -->
|
||||||
<div class="mb-4 flex items-center justify-between">
|
<div class="mb-4 flex items-center justify-between">
|
||||||
<label class="flex items-center gap-2 text-sm text-theme-secondary">
|
<span class="text-sm text-theme-secondary">Use custom parameters</span>
|
||||||
<span>Use custom parameters</span>
|
|
||||||
</label>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="switch"
|
role="switch"
|
||||||
aria-checked={settingsState.useCustomParameters}
|
aria-checked={settingsState.useCustomParameters}
|
||||||
|
aria-label="Toggle custom model parameters"
|
||||||
onclick={() => settingsState.toggleCustomParameters(modelDefaults)}
|
onclick={() => settingsState.toggleCustomParameters(modelDefaults)}
|
||||||
class="relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-sky-500 focus:ring-offset-2 focus:ring-offset-theme-secondary {settingsState.useCustomParameters ? 'bg-sky-600' : 'bg-theme-tertiary'}"
|
class="relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-sky-500 focus:ring-offset-2 focus:ring-offset-theme-secondary {settingsState.useCustomParameters ? 'bg-sky-600' : 'bg-theme-tertiary'}"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -427,7 +427,7 @@
|
|||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
</svg>
|
</svg>
|
||||||
<span>{deleteError}</span>
|
<span>{deleteError}</span>
|
||||||
<button type="button" onclick={() => deleteError = null} class="ml-auto text-red-400 hover:text-red-300">
|
<button type="button" onclick={() => deleteError = null} class="ml-auto text-red-400 hover:text-red-300" aria-label="Dismiss error">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -833,13 +833,13 @@
|
|||||||
|
|
||||||
{#if modelRegistry.totalPages > 1}
|
{#if modelRegistry.totalPages > 1}
|
||||||
<div class="mt-6 flex items-center justify-center gap-2">
|
<div class="mt-6 flex items-center justify-center gap-2">
|
||||||
<button type="button" onclick={() => modelRegistry.prevPage()} disabled={!modelRegistry.hasPrevPage} class="rounded-lg border border-theme bg-theme-secondary px-3 py-2 text-sm text-theme-muted transition-colors hover:bg-theme-tertiary hover:text-theme-primary disabled:cursor-not-allowed disabled:opacity-50">
|
<button type="button" onclick={() => modelRegistry.prevPage()} disabled={!modelRegistry.hasPrevPage} class="rounded-lg border border-theme bg-theme-secondary px-3 py-2 text-sm text-theme-muted transition-colors hover:bg-theme-tertiary hover:text-theme-primary disabled:cursor-not-allowed disabled:opacity-50" aria-label="Previous page">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<span class="text-sm text-theme-muted">Page {modelRegistry.currentPage + 1} of {modelRegistry.totalPages}</span>
|
<span class="text-sm text-theme-muted">Page {modelRegistry.currentPage + 1} of {modelRegistry.totalPages}</span>
|
||||||
<button type="button" onclick={() => modelRegistry.nextPage()} disabled={!modelRegistry.hasNextPage} class="rounded-lg border border-theme bg-theme-secondary px-3 py-2 text-sm text-theme-muted transition-colors hover:bg-theme-tertiary hover:text-theme-primary disabled:cursor-not-allowed disabled:opacity-50">
|
<button type="button" onclick={() => modelRegistry.nextPage()} disabled={!modelRegistry.hasNextPage} class="rounded-lg border border-theme bg-theme-secondary px-3 py-2 text-sm text-theme-muted transition-colors hover:bg-theme-tertiary hover:text-theme-primary disabled:cursor-not-allowed disabled:opacity-50" aria-label="Next page">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -855,7 +855,7 @@
|
|||||||
<div class="w-80 flex-shrink-0 overflow-y-auto border-l border-theme bg-theme-secondary p-4">
|
<div class="w-80 flex-shrink-0 overflow-y-auto border-l border-theme bg-theme-secondary p-4">
|
||||||
<div class="mb-4 flex items-start justify-between">
|
<div class="mb-4 flex items-start justify-between">
|
||||||
<h3 class="text-lg font-semibold text-theme-primary">{selectedModel.name}</h3>
|
<h3 class="text-lg font-semibold text-theme-primary">{selectedModel.name}</h3>
|
||||||
<button type="button" onclick={closeDetails} class="rounded p-1 text-theme-muted hover:bg-theme-tertiary hover:text-theme-primary">
|
<button type="button" onclick={closeDetails} class="rounded p-1 text-theme-muted hover:bg-theme-tertiary hover:text-theme-primary" aria-label="Close details">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
@@ -358,11 +358,11 @@
|
|||||||
|
|
||||||
<!-- Editor Modal -->
|
<!-- Editor Modal -->
|
||||||
{#if showEditor}
|
{#if showEditor}
|
||||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onclick={(e) => { if (e.target === e.currentTarget) closeEditor(); }} role="dialog" aria-modal="true">
|
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onclick={(e) => { if (e.target === e.currentTarget) closeEditor(); }} onkeydown={(e) => { if (e.key === 'Escape') closeEditor(); }} role="dialog" aria-modal="true" tabindex="-1">
|
||||||
<div class="w-full max-w-2xl rounded-xl bg-theme-secondary shadow-xl">
|
<div class="w-full max-w-2xl rounded-xl bg-theme-secondary shadow-xl">
|
||||||
<div class="flex items-center justify-between border-b border-theme px-6 py-4">
|
<div class="flex items-center justify-between border-b border-theme px-6 py-4">
|
||||||
<h3 class="text-lg font-semibold text-theme-primary">{editingPrompt ? 'Edit Prompt' : 'Create Prompt'}</h3>
|
<h3 class="text-lg font-semibold text-theme-primary">{editingPrompt ? 'Edit Prompt' : 'Create Prompt'}</h3>
|
||||||
<button type="button" onclick={closeEditor} class="rounded p-1 text-theme-muted hover:bg-theme-tertiary hover:text-theme-primary">
|
<button type="button" onclick={closeEditor} aria-label="Close dialog" class="rounded p-1 text-theme-muted hover:bg-theme-tertiary hover:text-theme-primary">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -392,8 +392,8 @@
|
|||||||
<label for="prompt-default" class="text-sm text-theme-secondary">Set as default for new chats</label>
|
<label for="prompt-default" class="text-sm text-theme-secondary">Set as default for new chats</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<fieldset>
|
||||||
<label class="mb-2 block text-sm font-medium text-theme-secondary">Auto-use for model types</label>
|
<legend class="mb-2 block text-sm font-medium text-theme-secondary">Auto-use for model types</legend>
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
{#each CAPABILITIES as cap (cap.id)}
|
{#each CAPABILITIES as cap (cap.id)}
|
||||||
<button type="button" onclick={() => toggleCapability(cap.id)} class="rounded-lg border px-3 py-1.5 text-sm transition-colors {formTargetCapabilities.includes(cap.id) ? 'border-blue-500 bg-blue-500/20 text-blue-300' : 'border-theme-subtle bg-theme-tertiary text-theme-muted hover:border-theme hover:text-theme-secondary'}" title={cap.description}>
|
<button type="button" onclick={() => toggleCapability(cap.id)} class="rounded-lg border px-3 py-1.5 text-sm transition-colors {formTargetCapabilities.includes(cap.id) ? 'border-blue-500 bg-blue-500/20 text-blue-300' : 'border-theme-subtle bg-theme-tertiary text-theme-muted hover:border-theme hover:text-theme-secondary'}" title={cap.description}>
|
||||||
@@ -401,7 +401,7 @@
|
|||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</fieldset>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-6 flex justify-end gap-3">
|
<div class="mt-6 flex justify-end gap-3">
|
||||||
@@ -418,7 +418,7 @@
|
|||||||
<!-- Template Preview Modal -->
|
<!-- Template Preview Modal -->
|
||||||
{#if previewTemplate}
|
{#if previewTemplate}
|
||||||
{@const info = categoryInfo[previewTemplate.category]}
|
{@const info = categoryInfo[previewTemplate.category]}
|
||||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onclick={(e) => { if (e.target === e.currentTarget) previewTemplate = null; }} role="dialog" aria-modal="true">
|
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onclick={(e) => { if (e.target === e.currentTarget) previewTemplate = null; }} onkeydown={(e) => { if (e.key === 'Escape') previewTemplate = null; }} role="dialog" aria-modal="true" tabindex="-1">
|
||||||
<div class="w-full max-w-2xl max-h-[80vh] flex flex-col rounded-xl bg-theme-secondary shadow-xl">
|
<div class="w-full max-w-2xl max-h-[80vh] flex flex-col rounded-xl bg-theme-secondary shadow-xl">
|
||||||
<div class="flex items-center justify-between border-b border-theme px-6 py-4">
|
<div class="flex items-center justify-between border-b border-theme px-6 py-4">
|
||||||
<div>
|
<div>
|
||||||
@@ -428,7 +428,7 @@
|
|||||||
{info.label}
|
{info.label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" onclick={() => (previewTemplate = null)} class="rounded p-1 text-theme-muted hover:bg-theme-tertiary hover:text-theme-primary">
|
<button type="button" onclick={() => (previewTemplate = null)} aria-label="Close dialog" class="rounded p-1 text-theme-muted hover:bg-theme-tertiary hover:text-theme-primary">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
/**
|
/**
|
||||||
* SettingsTabs - Horizontal tab navigation for Settings Hub
|
* SettingsTabs - Horizontal tab navigation for Settings Hub
|
||||||
*/
|
*/
|
||||||
export type SettingsTab = 'general' | 'models' | 'prompts' | 'tools' | 'agents' | 'knowledge' | 'memory';
|
export type SettingsTab = 'general' | 'ai' | 'prompts' | 'tools' | 'agents' | 'knowledge' | 'memory' | 'about';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
@@ -16,12 +16,13 @@
|
|||||||
|
|
||||||
const tabs: Tab[] = [
|
const tabs: Tab[] = [
|
||||||
{ id: 'general', label: 'General', icon: 'settings' },
|
{ id: 'general', label: 'General', icon: 'settings' },
|
||||||
{ id: 'models', label: 'Models', icon: 'cpu' },
|
{ id: 'ai', label: 'AI Providers', icon: 'server' },
|
||||||
{ id: 'prompts', label: 'Prompts', icon: 'message' },
|
{ id: 'prompts', label: 'Prompts', icon: 'message' },
|
||||||
{ id: 'tools', label: 'Tools', icon: 'wrench' },
|
{ id: 'tools', label: 'Tools', icon: 'wrench' },
|
||||||
{ id: 'agents', label: 'Agents', icon: 'robot' },
|
{ id: 'agents', label: 'Agents', icon: 'robot' },
|
||||||
{ id: 'knowledge', label: 'Knowledge', icon: 'book' },
|
{ id: 'knowledge', label: 'Knowledge', icon: 'book' },
|
||||||
{ id: 'memory', label: 'Memory', icon: 'brain' }
|
{ id: 'memory', label: 'Memory', icon: 'brain' },
|
||||||
|
{ id: 'about', label: 'About', icon: 'info' }
|
||||||
];
|
];
|
||||||
|
|
||||||
// Get active tab from URL, default to 'general'
|
// Get active tab from URL, default to 'general'
|
||||||
@@ -44,7 +45,11 @@
|
|||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10.343 3.94c.09-.542.56-.94 1.11-.94h1.093c.55 0 1.02.398 1.11.94l.149.894c.07.424.384.764.78.93.398.164.855.142 1.205-.108l.737-.527a1.125 1.125 0 0 1 1.45.12l.773.774c.39.389.44 1.002.12 1.45l-.527.737c-.25.35-.272.806-.107 1.204.165.397.505.71.93.78l.893.15c.543.09.94.559.94 1.109v1.094c0 .55-.397 1.02-.94 1.11l-.894.149c-.424.07-.764.383-.929.78-.165.398-.143.854.107 1.204l.527.738c.32.447.269 1.06-.12 1.45l-.774.773a1.125 1.125 0 0 1-1.449.12l-.738-.527c-.35-.25-.806-.272-1.203-.107-.398.165-.71.505-.781.929l-.149.894c-.09.542-.56.94-1.11.94h-1.094c-.55 0-1.019-.398-1.11-.94l-.148-.894c-.071-.424-.384-.764-.781-.93-.398-.164-.854-.142-1.204.108l-.738.527c-.447.32-1.06.269-1.45-.12l-.773-.774a1.125 1.125 0 0 1-.12-1.45l.527-.737c.25-.35.272-.806.108-1.204-.165-.397-.506-.71-.93-.78l-.894-.15c-.542-.09-.94-.56-.94-1.109v-1.094c0-.55.398-1.02.94-1.11l.894-.149c.424-.07.765-.383.93-.78.165-.398.143-.854-.108-1.204l-.526-.738a1.125 1.125 0 0 1 .12-1.45l.773-.773a1.125 1.125 0 0 1 1.45-.12l.737.527c.35.25.807.272 1.204.107.397-.165.71-.505.78-.929l.15-.894Z" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M10.343 3.94c.09-.542.56-.94 1.11-.94h1.093c.55 0 1.02.398 1.11.94l.149.894c.07.424.384.764.78.93.398.164.855.142 1.205-.108l.737-.527a1.125 1.125 0 0 1 1.45.12l.773.774c.39.389.44 1.002.12 1.45l-.527.737c-.25.35-.272.806-.107 1.204.165.397.505.71.93.78l.893.15c.543.09.94.559.94 1.109v1.094c0 .55-.397 1.02-.94 1.11l-.894.149c-.424.07-.764.383-.929.78-.165.398-.143.854.107 1.204l.527.738c.32.447.269 1.06-.12 1.45l-.774.773a1.125 1.125 0 0 1-1.449.12l-.738-.527c-.35-.25-.806-.272-1.203-.107-.398.165-.71.505-.781.929l-.149.894c-.09.542-.56.94-1.11.94h-1.094c-.55 0-1.019-.398-1.11-.94l-.148-.894c-.071-.424-.384-.764-.781-.93-.398-.164-.854-.142-1.204.108l-.738.527c-.447.32-1.06.269-1.45-.12l-.773-.774a1.125 1.125 0 0 1-.12-1.45l.527-.737c.25-.35.272-.806.108-1.204-.165-.397-.506-.71-.93-.78l-.894-.15c-.542-.09-.94-.56-.94-1.109v-1.094c0-.55.398-1.02.94-1.11l.894-.149c.424-.07.765-.383.93-.78.165-.398.143-.854-.108-1.204l-.526-.738a1.125 1.125 0 0 1 .12-1.45l.773-.773a1.125 1.125 0 0 1 1.45-.12l.737.527c.35.25.807.272 1.204.107.397-.165.71-.505.78-.929l.15-.894Z" />
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
|
||||||
</svg>
|
</svg>
|
||||||
{:else if tab.icon === 'cpu'}
|
{:else if tab.icon === 'server'}
|
||||||
|
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M5 12h14M5 12a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2M5 12a2 2 0 0 0-2 2v4a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-4a2 2 0 0 0-2-2m-2-4h.01M17 16h.01" />
|
||||||
|
</svg>
|
||||||
|
{:else if tab.icon === 'cpu'}
|
||||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 3v1.5M4.5 8.25H3m18 0h-1.5M4.5 12H3m18 0h-1.5m-15 3.75H3m18 0h-1.5M8.25 19.5V21M12 3v1.5m0 15V21m3.75-18v1.5m0 15V21m-9-1.5h10.5a2.25 2.25 0 0 0 2.25-2.25V6.75a2.25 2.25 0 0 0-2.25-2.25H6.75A2.25 2.25 0 0 0 4.5 6.75v10.5a2.25 2.25 0 0 0 2.25 2.25Zm.75-12h9v9h-9v-9Z" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 3v1.5M4.5 8.25H3m18 0h-1.5M4.5 12H3m18 0h-1.5m-15 3.75H3m18 0h-1.5M8.25 19.5V21M12 3v1.5m0 15V21m3.75-18v1.5m0 15V21m-9-1.5h10.5a2.25 2.25 0 0 0 2.25-2.25V6.75a2.25 2.25 0 0 0-2.25-2.25H6.75A2.25 2.25 0 0 0 4.5 6.75v10.5a2.25 2.25 0 0 0 2.25 2.25Zm.75-12h9v9h-9v-9Z" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -68,6 +73,10 @@
|
|||||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9.813 15.904 9 18.75l-.813-2.846a4.5 4.5 0 0 0-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 0 0 3.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 0 0 3.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 0 0-3.09 3.09ZM18.259 8.715 18 9.75l-.259-1.035a3.375 3.375 0 0 0-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 0 0 2.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 0 0 2.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 0 0-2.456 2.456ZM16.894 20.567 16.5 21.75l-.394-1.183a2.25 2.25 0 0 0-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 0 0 1.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 0 0 1.423 1.423l1.183.394-1.183.394a2.25 2.25 0 0 0-1.423 1.423Z" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9.813 15.904 9 18.75l-.813-2.846a4.5 4.5 0 0 0-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 0 0 3.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 0 0 3.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 0 0-3.09 3.09ZM18.259 8.715 18 9.75l-.259-1.035a3.375 3.375 0 0 0-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 0 0 2.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 0 0 2.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 0 0-2.456 2.456ZM16.894 20.567 16.5 21.75l-.394-1.183a2.25 2.25 0 0 0-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 0 0 1.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 0 0 1.423 1.423l1.183.394-1.183.394a2.25 2.25 0 0 0-1.423 1.423Z" />
|
||||||
</svg>
|
</svg>
|
||||||
|
{:else if tab.icon === 'info'}
|
||||||
|
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z" />
|
||||||
|
</svg>
|
||||||
{/if}
|
{/if}
|
||||||
{tab.label}
|
{tab.label}
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -151,6 +151,7 @@
|
|||||||
class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-violet-500 focus:ring-offset-2 focus:ring-offset-theme-primary {toolsState.toolsEnabled ? 'bg-violet-600' : 'bg-theme-tertiary'}"
|
class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-violet-500 focus:ring-offset-2 focus:ring-offset-theme-primary {toolsState.toolsEnabled ? 'bg-violet-600' : 'bg-theme-tertiary'}"
|
||||||
role="switch"
|
role="switch"
|
||||||
aria-checked={toolsState.toolsEnabled}
|
aria-checked={toolsState.toolsEnabled}
|
||||||
|
aria-label="Toggle all tools"
|
||||||
>
|
>
|
||||||
<span class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out {toolsState.toolsEnabled ? 'translate-x-5' : 'translate-x-0'}"></span>
|
<span class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out {toolsState.toolsEnabled ? 'translate-x-5' : 'translate-x-0'}"></span>
|
||||||
</button>
|
</button>
|
||||||
@@ -194,6 +195,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
onclick={() => searchQuery = ''}
|
onclick={() => searchQuery = ''}
|
||||||
class="absolute right-3 top-1/2 -translate-y-1/2 text-theme-muted hover:text-theme-primary"
|
class="absolute right-3 top-1/2 -translate-y-1/2 text-theme-muted hover:text-theme-primary"
|
||||||
|
aria-label="Clear search"
|
||||||
>
|
>
|
||||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
@@ -289,6 +291,7 @@
|
|||||||
class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-theme {tool.enabled ? 'bg-blue-600' : 'bg-theme-tertiary'}"
|
class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-theme {tool.enabled ? 'bg-blue-600' : 'bg-theme-tertiary'}"
|
||||||
role="switch"
|
role="switch"
|
||||||
aria-checked={tool.enabled}
|
aria-checked={tool.enabled}
|
||||||
|
aria-label="Toggle {tool.definition.function.name} tool"
|
||||||
disabled={!toolsState.toolsEnabled}
|
disabled={!toolsState.toolsEnabled}
|
||||||
>
|
>
|
||||||
<span class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out {tool.enabled ? 'translate-x-5' : 'translate-x-0'}"></span>
|
<span class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out {tool.enabled ? 'translate-x-5' : 'translate-x-0'}"></span>
|
||||||
@@ -438,6 +441,7 @@
|
|||||||
class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-violet-500 focus:ring-offset-2 focus:ring-offset-theme {tool.enabled ? 'bg-violet-600' : 'bg-theme-tertiary'}"
|
class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-violet-500 focus:ring-offset-2 focus:ring-offset-theme {tool.enabled ? 'bg-violet-600' : 'bg-theme-tertiary'}"
|
||||||
role="switch"
|
role="switch"
|
||||||
aria-checked={tool.enabled}
|
aria-checked={tool.enabled}
|
||||||
|
aria-label="Toggle {tool.name} tool"
|
||||||
disabled={!toolsState.toolsEnabled}
|
disabled={!toolsState.toolsEnabled}
|
||||||
>
|
>
|
||||||
<span class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out {tool.enabled ? 'translate-x-5' : 'translate-x-0'}"></span>
|
<span class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out {tool.enabled ? 'translate-x-5' : 'translate-x-0'}"></span>
|
||||||
|
|||||||
@@ -3,12 +3,13 @@
|
|||||||
*/
|
*/
|
||||||
export { default as SettingsTabs } from './SettingsTabs.svelte';
|
export { default as SettingsTabs } from './SettingsTabs.svelte';
|
||||||
export { default as GeneralTab } from './GeneralTab.svelte';
|
export { default as GeneralTab } from './GeneralTab.svelte';
|
||||||
export { default as ModelsTab } from './ModelsTab.svelte';
|
export { default as AIProvidersTab } from './AIProvidersTab.svelte';
|
||||||
export { default as PromptsTab } from './PromptsTab.svelte';
|
export { default as PromptsTab } from './PromptsTab.svelte';
|
||||||
export { default as ToolsTab } from './ToolsTab.svelte';
|
export { default as ToolsTab } from './ToolsTab.svelte';
|
||||||
export { default as AgentsTab } from './AgentsTab.svelte';
|
export { default as AgentsTab } from './AgentsTab.svelte';
|
||||||
export { default as KnowledgeTab } from './KnowledgeTab.svelte';
|
export { default as KnowledgeTab } from './KnowledgeTab.svelte';
|
||||||
export { default as MemoryTab } from './MemoryTab.svelte';
|
export { default as MemoryTab } from './MemoryTab.svelte';
|
||||||
|
export { default as AboutTab } from './AboutTab.svelte';
|
||||||
export { default as ModelParametersPanel } from './ModelParametersPanel.svelte';
|
export { default as ModelParametersPanel } from './ModelParametersPanel.svelte';
|
||||||
|
|
||||||
export type { SettingsTab } from './SettingsTabs.svelte';
|
export type { SettingsTab } from './SettingsTabs.svelte';
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
|
|
||||||
let { isOpen, onClose }: Props = $props();
|
let { isOpen, onClose }: Props = $props();
|
||||||
|
|
||||||
let fileInput: HTMLInputElement;
|
let fileInput = $state<HTMLInputElement | null>(null);
|
||||||
let isDragOver = $state(false);
|
let isDragOver = $state(false);
|
||||||
let selectedFile = $state<File | null>(null);
|
let selectedFile = $state<File | null>(null);
|
||||||
let validationResult = $state<ValidationResult | null>(null);
|
let validationResult = $state<ValidationResult | null>(null);
|
||||||
@@ -168,9 +168,11 @@
|
|||||||
<div
|
<div
|
||||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||||
onclick={handleBackdropClick}
|
onclick={handleBackdropClick}
|
||||||
|
onkeydown={handleKeydown}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby="import-dialog-title"
|
aria-labelledby="import-dialog-title"
|
||||||
|
tabindex="-1"
|
||||||
>
|
>
|
||||||
<!-- Dialog -->
|
<!-- Dialog -->
|
||||||
<div class="mx-4 w-full max-w-lg rounded-xl border border-theme bg-theme-primary shadow-2xl">
|
<div class="mx-4 w-full max-w-lg rounded-xl border border-theme bg-theme-primary shadow-2xl">
|
||||||
|
|||||||
@@ -163,9 +163,11 @@
|
|||||||
<div
|
<div
|
||||||
class="fixed inset-0 z-50 flex items-start justify-center bg-black/60 pt-[15vh] backdrop-blur-sm"
|
class="fixed inset-0 z-50 flex items-start justify-center bg-black/60 pt-[15vh] backdrop-blur-sm"
|
||||||
onclick={handleBackdropClick}
|
onclick={handleBackdropClick}
|
||||||
|
onkeydown={handleKeydown}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby="search-dialog-title"
|
aria-labelledby="search-dialog-title"
|
||||||
|
tabindex="-1"
|
||||||
>
|
>
|
||||||
<!-- Dialog -->
|
<!-- Dialog -->
|
||||||
<div class="mx-4 w-full max-w-2xl rounded-xl border border-theme bg-theme-primary shadow-2xl">
|
<div class="mx-4 w-full max-w-2xl rounded-xl border border-theme bg-theme-primary shadow-2xl">
|
||||||
|
|||||||
@@ -61,9 +61,11 @@
|
|||||||
<div
|
<div
|
||||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||||
onclick={handleBackdropClick}
|
onclick={handleBackdropClick}
|
||||||
|
onkeydown={handleKeydown}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby="shortcuts-dialog-title"
|
aria-labelledby="shortcuts-dialog-title"
|
||||||
|
tabindex="-1"
|
||||||
>
|
>
|
||||||
<!-- Dialog -->
|
<!-- Dialog -->
|
||||||
<div class="mx-4 w-full max-w-md rounded-xl border border-theme bg-theme-primary shadow-2xl">
|
<div class="mx-4 w-full max-w-md rounded-xl border border-theme bg-theme-primary shadow-2xl">
|
||||||
|
|||||||
@@ -248,6 +248,7 @@ print(json.dumps(result))`;
|
|||||||
type="button"
|
type="button"
|
||||||
onclick={onClose}
|
onclick={onClose}
|
||||||
class="rounded-lg p-1.5 text-theme-muted hover:bg-theme-tertiary hover:text-theme-primary"
|
class="rounded-lg p-1.5 text-theme-muted hover:bg-theme-tertiary hover:text-theme-primary"
|
||||||
|
aria-label="Close dialog"
|
||||||
>
|
>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||||
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
|
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
|
||||||
@@ -290,7 +291,7 @@ print(json.dumps(result))`;
|
|||||||
<!-- Parameters -->
|
<!-- Parameters -->
|
||||||
<div>
|
<div>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<label class="block text-sm font-medium text-theme-secondary">Parameters</label>
|
<span class="block text-sm font-medium text-theme-secondary">Parameters</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={addParameter}
|
onclick={addParameter}
|
||||||
@@ -335,6 +336,7 @@ print(json.dumps(result))`;
|
|||||||
type="button"
|
type="button"
|
||||||
onclick={() => removeParameter(index)}
|
onclick={() => removeParameter(index)}
|
||||||
class="text-theme-muted hover:text-red-400"
|
class="text-theme-muted hover:text-red-400"
|
||||||
|
aria-label="Remove parameter"
|
||||||
>
|
>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||||
<path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd" />
|
<path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd" />
|
||||||
@@ -352,8 +354,8 @@ print(json.dumps(result))`;
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Implementation Type -->
|
<!-- Implementation Type -->
|
||||||
<div>
|
<fieldset>
|
||||||
<label class="block text-sm font-medium text-theme-secondary">Implementation</label>
|
<legend class="block text-sm font-medium text-theme-secondary">Implementation</legend>
|
||||||
<div class="mt-2 flex flex-wrap gap-4">
|
<div class="mt-2 flex flex-wrap gap-4">
|
||||||
<label class="flex items-center gap-2 text-theme-secondary">
|
<label class="flex items-center gap-2 text-theme-secondary">
|
||||||
<input
|
<input
|
||||||
@@ -383,15 +385,15 @@ print(json.dumps(result))`;
|
|||||||
HTTP Endpoint
|
HTTP Endpoint
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</fieldset>
|
||||||
|
|
||||||
<!-- Code Editor (JavaScript or Python) -->
|
<!-- Code Editor (JavaScript or Python) -->
|
||||||
{#if implementation === 'javascript' || implementation === 'python'}
|
{#if implementation === 'javascript' || implementation === 'python'}
|
||||||
<div>
|
<div>
|
||||||
<div class="flex items-center justify-between mb-1">
|
<div class="flex items-center justify-between mb-1">
|
||||||
<label class="block text-sm font-medium text-theme-secondary">
|
<span class="block text-sm font-medium text-theme-secondary">
|
||||||
{implementation === 'javascript' ? 'JavaScript' : 'Python'} Code
|
{implementation === 'javascript' ? 'JavaScript' : 'Python'} Code
|
||||||
</label>
|
</span>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<!-- Templates dropdown -->
|
<!-- Templates dropdown -->
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
@@ -500,8 +502,8 @@ print(json.dumps(result))`;
|
|||||||
<p class="mt-1 text-sm text-red-400">{errors.endpoint}</p>
|
<p class="mt-1 text-sm text-red-400">{errors.endpoint}</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<fieldset>
|
||||||
<label class="block text-sm font-medium text-theme-secondary">HTTP Method</label>
|
<legend class="block text-sm font-medium text-theme-secondary">HTTP Method</legend>
|
||||||
<div class="mt-2 flex gap-4">
|
<div class="mt-2 flex gap-4">
|
||||||
<label class="flex items-center gap-2 text-theme-secondary">
|
<label class="flex items-center gap-2 text-theme-secondary">
|
||||||
<input type="radio" bind:group={httpMethod} value="GET" />
|
<input type="radio" bind:group={httpMethod} value="GET" />
|
||||||
@@ -512,7 +514,7 @@ print(json.dumps(result))`;
|
|||||||
POST
|
POST
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</fieldset>
|
||||||
|
|
||||||
<!-- Test button for HTTP -->
|
<!-- Test button for HTTP -->
|
||||||
<button
|
<button
|
||||||
@@ -548,6 +550,7 @@ print(json.dumps(result))`;
|
|||||||
class="relative inline-flex h-6 w-11 cursor-pointer rounded-full transition-colors {enabled ? 'bg-blue-600' : 'bg-theme-tertiary'}"
|
class="relative inline-flex h-6 w-11 cursor-pointer rounded-full transition-colors {enabled ? 'bg-blue-600' : 'bg-theme-tertiary'}"
|
||||||
role="switch"
|
role="switch"
|
||||||
aria-checked={enabled}
|
aria-checked={enabled}
|
||||||
|
aria-label="Enable tool"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow transition {enabled ? 'translate-x-5' : 'translate-x-0'}"
|
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow transition {enabled ? 'translate-x-5' : 'translate-x-0'}"
|
||||||
|
|||||||
@@ -209,7 +209,7 @@
|
|||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<!-- Input -->
|
<!-- Input -->
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-medium text-theme-secondary mb-1">Input Arguments (JSON)</label>
|
<span class="block text-xs font-medium text-theme-secondary mb-1">Input Arguments (JSON)</span>
|
||||||
<CodeEditor bind:value={testInput} language="json" minHeight="80px" />
|
<CodeEditor bind:value={testInput} language="json" minHeight="80px" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -237,7 +237,7 @@
|
|||||||
<!-- Result -->
|
<!-- Result -->
|
||||||
{#if testResult}
|
{#if testResult}
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-medium text-theme-secondary mb-1">Result</label>
|
<span class="block text-xs font-medium text-theme-secondary mb-1">Result</span>
|
||||||
<div
|
<div
|
||||||
class="rounded-lg p-3 text-sm font-mono overflow-x-auto {testResult.success
|
class="rounded-lg p-3 text-sm font-mono overflow-x-auto {testResult.success
|
||||||
? 'bg-emerald-900/30 border border-emerald-500/30'
|
? 'bg-emerald-900/30 border border-emerald-500/30'
|
||||||
|
|||||||
@@ -0,0 +1,225 @@
|
|||||||
|
/**
|
||||||
|
* Tests for Unified LLM Client
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
|
||||||
|
// Types matching the backend response
|
||||||
|
interface ChatChunk {
|
||||||
|
model: string;
|
||||||
|
message?: {
|
||||||
|
role: string;
|
||||||
|
content: string;
|
||||||
|
};
|
||||||
|
done: boolean;
|
||||||
|
done_reason?: string;
|
||||||
|
total_duration?: number;
|
||||||
|
load_duration?: number;
|
||||||
|
prompt_eval_count?: number;
|
||||||
|
eval_count?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Model {
|
||||||
|
name: string;
|
||||||
|
size: number;
|
||||||
|
digest: string;
|
||||||
|
modified_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('UnifiedLLMClient', () => {
|
||||||
|
let UnifiedLLMClient: typeof import('./client.js').UnifiedLLMClient;
|
||||||
|
let client: InstanceType<typeof UnifiedLLMClient>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.resetModules();
|
||||||
|
|
||||||
|
// Mock fetch
|
||||||
|
global.fetch = vi.fn();
|
||||||
|
|
||||||
|
const module = await import('./client.js');
|
||||||
|
UnifiedLLMClient = module.UnifiedLLMClient;
|
||||||
|
client = new UnifiedLLMClient();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('listModels', () => {
|
||||||
|
it('fetches models from unified API', async () => {
|
||||||
|
const mockModels: Model[] = [
|
||||||
|
{
|
||||||
|
name: 'llama3.2:8b',
|
||||||
|
size: 4500000000,
|
||||||
|
digest: 'abc123',
|
||||||
|
modified_at: '2024-01-15T10:00:00Z'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ models: mockModels, backend: 'ollama' })
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await client.listModels();
|
||||||
|
|
||||||
|
expect(result.models).toEqual(mockModels);
|
||||||
|
expect(result.backend).toBe('ollama');
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('/api/v1/ai/models'),
|
||||||
|
expect.objectContaining({ method: 'GET' })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on API error', async () => {
|
||||||
|
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 503,
|
||||||
|
statusText: 'Service Unavailable',
|
||||||
|
json: async () => ({ error: 'no active backend' })
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(client.listModels()).rejects.toThrow('no active backend');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('chat', () => {
|
||||||
|
it('sends chat request to unified API', async () => {
|
||||||
|
const mockResponse: ChatChunk = {
|
||||||
|
model: 'llama3.2:8b',
|
||||||
|
message: { role: 'assistant', content: 'Hello!' },
|
||||||
|
done: true,
|
||||||
|
total_duration: 1000000000,
|
||||||
|
eval_count: 10
|
||||||
|
};
|
||||||
|
|
||||||
|
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => mockResponse
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await client.chat({
|
||||||
|
model: 'llama3.2:8b',
|
||||||
|
messages: [{ role: 'user', content: 'Hi' }]
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.message?.content).toBe('Hello!');
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('/api/v1/ai/chat'),
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'POST',
|
||||||
|
body: expect.stringContaining('"model":"llama3.2:8b"')
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('streamChat', () => {
|
||||||
|
it('streams chat responses as NDJSON', async () => {
|
||||||
|
const chunks: ChatChunk[] = [
|
||||||
|
{ model: 'llama3.2:8b', message: { role: 'assistant', content: 'Hello' }, done: false },
|
||||||
|
{ model: 'llama3.2:8b', message: { role: 'assistant', content: ' there' }, done: false },
|
||||||
|
{ model: 'llama3.2:8b', message: { role: 'assistant', content: '!' }, done: true }
|
||||||
|
];
|
||||||
|
|
||||||
|
// Create a mock readable stream
|
||||||
|
const mockBody = new ReadableStream({
|
||||||
|
start(controller) {
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
controller.enqueue(new TextEncoder().encode(JSON.stringify(chunk) + '\n'));
|
||||||
|
}
|
||||||
|
controller.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
body: mockBody
|
||||||
|
});
|
||||||
|
|
||||||
|
const receivedChunks: ChatChunk[] = [];
|
||||||
|
for await (const chunk of client.streamChat({
|
||||||
|
model: 'llama3.2:8b',
|
||||||
|
messages: [{ role: 'user', content: 'Hi' }]
|
||||||
|
})) {
|
||||||
|
receivedChunks.push(chunk);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(receivedChunks).toHaveLength(3);
|
||||||
|
expect(receivedChunks[0].message?.content).toBe('Hello');
|
||||||
|
expect(receivedChunks[2].done).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles stream errors', async () => {
|
||||||
|
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 500,
|
||||||
|
json: async () => ({ error: 'Internal Server Error' })
|
||||||
|
});
|
||||||
|
|
||||||
|
const generator = client.streamChat({
|
||||||
|
model: 'llama3.2:8b',
|
||||||
|
messages: [{ role: 'user', content: 'Hi' }]
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(generator.next()).rejects.toThrow('Internal Server Error');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('healthCheck', () => {
|
||||||
|
it('returns true when backend is healthy', async () => {
|
||||||
|
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ status: 'healthy' })
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await client.healthCheck('ollama');
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('/api/v1/ai/backends/ollama/health'),
|
||||||
|
expect.any(Object)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when backend is unhealthy', async () => {
|
||||||
|
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 503,
|
||||||
|
json: async () => ({ status: 'unhealthy', error: 'Connection refused' })
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await client.healthCheck('ollama');
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('configuration', () => {
|
||||||
|
it('uses custom base URL', async () => {
|
||||||
|
const customClient = new UnifiedLLMClient({ baseUrl: 'http://custom:9090' });
|
||||||
|
|
||||||
|
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ models: [], backend: 'ollama' })
|
||||||
|
});
|
||||||
|
|
||||||
|
await customClient.listModels();
|
||||||
|
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith(
|
||||||
|
'http://custom:9090/api/v1/ai/models',
|
||||||
|
expect.any(Object)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects abort signal', async () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
controller.abort();
|
||||||
|
|
||||||
|
(global.fetch as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
|
||||||
|
new DOMException('The user aborted a request.', 'AbortError')
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(client.listModels(controller.signal)).rejects.toThrow('aborted');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,340 @@
|
|||||||
|
/**
|
||||||
|
* Unified LLM Client
|
||||||
|
* Routes chat requests through the unified /api/v1/ai/* endpoints
|
||||||
|
* Supports Ollama, llama.cpp, and LM Studio backends transparently
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { BackendType } from '../stores/backends.svelte.js';
|
||||||
|
|
||||||
|
/** Message format (compatible with Ollama and OpenAI) */
|
||||||
|
export interface ChatMessage {
|
||||||
|
role: 'system' | 'user' | 'assistant' | 'tool';
|
||||||
|
content: string;
|
||||||
|
images?: string[];
|
||||||
|
tool_calls?: ToolCall[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tool call in assistant message */
|
||||||
|
export interface ToolCall {
|
||||||
|
function: {
|
||||||
|
name: string;
|
||||||
|
arguments: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tool definition */
|
||||||
|
export interface ToolDefinition {
|
||||||
|
type: 'function';
|
||||||
|
function: {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
parameters: {
|
||||||
|
type: 'object';
|
||||||
|
properties: Record<string, unknown>;
|
||||||
|
required?: string[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Chat request options */
|
||||||
|
export interface ChatRequest {
|
||||||
|
model: string;
|
||||||
|
messages: ChatMessage[];
|
||||||
|
stream?: boolean;
|
||||||
|
format?: 'json' | object;
|
||||||
|
tools?: ToolDefinition[];
|
||||||
|
options?: ModelOptions;
|
||||||
|
keep_alive?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Model-specific options */
|
||||||
|
export interface ModelOptions {
|
||||||
|
temperature?: number;
|
||||||
|
top_p?: number;
|
||||||
|
top_k?: number;
|
||||||
|
num_ctx?: number;
|
||||||
|
num_predict?: number;
|
||||||
|
stop?: string[];
|
||||||
|
seed?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Chat response chunk (NDJSON streaming format) */
|
||||||
|
export interface ChatChunk {
|
||||||
|
model: string;
|
||||||
|
message?: ChatMessage;
|
||||||
|
done: boolean;
|
||||||
|
done_reason?: string;
|
||||||
|
total_duration?: number;
|
||||||
|
load_duration?: number;
|
||||||
|
prompt_eval_count?: number;
|
||||||
|
prompt_eval_duration?: number;
|
||||||
|
eval_count?: number;
|
||||||
|
eval_duration?: number;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Model information */
|
||||||
|
export interface Model {
|
||||||
|
name: string;
|
||||||
|
size: number;
|
||||||
|
digest: string;
|
||||||
|
modified_at: string;
|
||||||
|
details?: {
|
||||||
|
family?: string;
|
||||||
|
parameter_size?: string;
|
||||||
|
quantization_level?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Models list response */
|
||||||
|
export interface ModelsResponse {
|
||||||
|
models: Model[];
|
||||||
|
backend: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Client configuration */
|
||||||
|
export interface UnifiedLLMClientConfig {
|
||||||
|
baseUrl?: string;
|
||||||
|
defaultTimeoutMs?: number;
|
||||||
|
fetchFn?: typeof fetch;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_CONFIG = {
|
||||||
|
baseUrl: '',
|
||||||
|
defaultTimeoutMs: 120000
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unified LLM client that routes requests through the multi-backend API
|
||||||
|
*/
|
||||||
|
export class UnifiedLLMClient {
|
||||||
|
private readonly config: Required<Omit<UnifiedLLMClientConfig, 'fetchFn'>>;
|
||||||
|
private readonly fetchFn: typeof fetch;
|
||||||
|
|
||||||
|
constructor(config: UnifiedLLMClientConfig = {}) {
|
||||||
|
this.config = {
|
||||||
|
...DEFAULT_CONFIG,
|
||||||
|
...config
|
||||||
|
};
|
||||||
|
this.fetchFn = config.fetchFn ?? fetch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lists models from the active backend
|
||||||
|
*/
|
||||||
|
async listModels(signal?: AbortSignal): Promise<ModelsResponse> {
|
||||||
|
return this.request<ModelsResponse>('/api/v1/ai/models', {
|
||||||
|
method: 'GET',
|
||||||
|
signal
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Non-streaming chat completion
|
||||||
|
*/
|
||||||
|
async chat(request: ChatRequest, signal?: AbortSignal): Promise<ChatChunk> {
|
||||||
|
return this.request<ChatChunk>('/api/v1/ai/chat', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ ...request, stream: false }),
|
||||||
|
signal
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Streaming chat completion (async generator)
|
||||||
|
* Yields NDJSON chunks as they arrive
|
||||||
|
*/
|
||||||
|
async *streamChat(
|
||||||
|
request: ChatRequest,
|
||||||
|
signal?: AbortSignal
|
||||||
|
): AsyncGenerator<ChatChunk, void, unknown> {
|
||||||
|
const url = `${this.config.baseUrl}/api/v1/ai/chat`;
|
||||||
|
|
||||||
|
const response = await this.fetchFn(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ ...request, stream: true }),
|
||||||
|
signal
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(errorData.error || `HTTP ${response.status}: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.body) {
|
||||||
|
throw new Error('No response body for streaming');
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
|
||||||
|
// Process complete NDJSON lines
|
||||||
|
let newlineIndex: number;
|
||||||
|
while ((newlineIndex = buffer.indexOf('\n')) !== -1) {
|
||||||
|
const line = buffer.slice(0, newlineIndex).trim();
|
||||||
|
buffer = buffer.slice(newlineIndex + 1);
|
||||||
|
|
||||||
|
if (!line) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const chunk = JSON.parse(line) as ChatChunk;
|
||||||
|
|
||||||
|
// Check for error in chunk
|
||||||
|
if (chunk.error) {
|
||||||
|
throw new Error(chunk.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
yield chunk;
|
||||||
|
|
||||||
|
// Stop if done
|
||||||
|
if (chunk.done) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof SyntaxError) {
|
||||||
|
console.warn('[UnifiedLLM] Failed to parse chunk:', line);
|
||||||
|
} else {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
reader.releaseLock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Streaming chat with callbacks (more ergonomic for UI)
|
||||||
|
*/
|
||||||
|
async streamChatWithCallbacks(
|
||||||
|
request: ChatRequest,
|
||||||
|
callbacks: {
|
||||||
|
onChunk?: (chunk: ChatChunk) => void;
|
||||||
|
onToken?: (token: string) => void;
|
||||||
|
onComplete?: (fullResponse: ChatChunk) => void;
|
||||||
|
onError?: (error: Error) => void;
|
||||||
|
},
|
||||||
|
signal?: AbortSignal
|
||||||
|
): Promise<string> {
|
||||||
|
let accumulatedContent = '';
|
||||||
|
let lastChunk: ChatChunk | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const chunk of this.streamChat(request, signal)) {
|
||||||
|
lastChunk = chunk;
|
||||||
|
callbacks.onChunk?.(chunk);
|
||||||
|
|
||||||
|
if (chunk.message?.content) {
|
||||||
|
accumulatedContent += chunk.message.content;
|
||||||
|
callbacks.onToken?.(chunk.message.content);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chunk.done && callbacks.onComplete) {
|
||||||
|
callbacks.onComplete(chunk);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (callbacks.onError && error instanceof Error) {
|
||||||
|
callbacks.onError(error);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return accumulatedContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check health of a specific backend
|
||||||
|
*/
|
||||||
|
async healthCheck(type: BackendType, signal?: AbortSignal): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await this.request<{ status: string }>(`/api/v1/ai/backends/${type}/health`, {
|
||||||
|
method: 'GET',
|
||||||
|
signal,
|
||||||
|
timeoutMs: 5000
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make an HTTP request to the unified API
|
||||||
|
*/
|
||||||
|
private async request<T>(
|
||||||
|
endpoint: string,
|
||||||
|
options: {
|
||||||
|
method: 'GET' | 'POST';
|
||||||
|
body?: string;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
timeoutMs?: number;
|
||||||
|
}
|
||||||
|
): Promise<T> {
|
||||||
|
const { method, body, signal, timeoutMs = this.config.defaultTimeoutMs } = options;
|
||||||
|
const url = `${this.config.baseUrl}${endpoint}`;
|
||||||
|
|
||||||
|
// Create timeout controller
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
|
||||||
|
// Combine with external signal
|
||||||
|
const combinedSignal = signal ? this.combineSignals(signal, controller.signal) : controller.signal;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.fetchFn(url, {
|
||||||
|
method,
|
||||||
|
headers: body ? { 'Content-Type': 'application/json' } : undefined,
|
||||||
|
body,
|
||||||
|
signal: combinedSignal
|
||||||
|
});
|
||||||
|
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(errorData.error || `HTTP ${response.status}: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (await response.json()) as T;
|
||||||
|
} catch (error) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Combines multiple AbortSignals into one
|
||||||
|
*/
|
||||||
|
private combineSignals(...signals: AbortSignal[]): AbortSignal {
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
for (const signal of signals) {
|
||||||
|
if (signal.aborted) {
|
||||||
|
controller.abort(signal.reason);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
signal.addEventListener('abort', () => controller.abort(signal.reason), {
|
||||||
|
once: true,
|
||||||
|
signal: controller.signal
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return controller.signal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Default client instance */
|
||||||
|
export const unifiedLLMClient = new UnifiedLLMClient();
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* Unified LLM Client exports
|
||||||
|
*/
|
||||||
|
export { UnifiedLLMClient, unifiedLLMClient } from './client.js';
|
||||||
|
export type {
|
||||||
|
ChatMessage,
|
||||||
|
ChatRequest,
|
||||||
|
ChatChunk,
|
||||||
|
Model,
|
||||||
|
ModelsResponse,
|
||||||
|
ModelOptions,
|
||||||
|
ToolCall,
|
||||||
|
ToolDefinition,
|
||||||
|
UnifiedLLMClientConfig
|
||||||
|
} from './client.js';
|
||||||
@@ -18,7 +18,7 @@ import type { DocumentChunk } from './types';
|
|||||||
let uuidCounter = 0;
|
let uuidCounter = 0;
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
uuidCounter = 0;
|
uuidCounter = 0;
|
||||||
vi.spyOn(crypto, 'randomUUID').mockImplementation(() => `test-uuid-${++uuidCounter}`);
|
vi.spyOn(crypto, 'randomUUID').mockImplementation(() => `00000000-0000-0000-0000-00000000000${++uuidCounter}` as `${string}-${string}-${string}-${string}-${string}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
|||||||
@@ -23,11 +23,11 @@ function createMessageNode(
|
|||||||
return {
|
return {
|
||||||
id: id || crypto.randomUUID(),
|
id: id || crypto.randomUUID(),
|
||||||
parentId: null,
|
parentId: null,
|
||||||
siblingIds: [],
|
childIds: [],
|
||||||
|
createdAt: new Date(),
|
||||||
message: {
|
message: {
|
||||||
role,
|
role,
|
||||||
content,
|
content
|
||||||
timestamp: Date.now()
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,8 @@ function mockStreamResponse(chunks: unknown[]): Response {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('OllamaClient', () => {
|
describe('OllamaClient', () => {
|
||||||
let mockFetch: ReturnType<typeof vi.fn>;
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
let mockFetch: any;
|
||||||
let client: OllamaClient;
|
let client: OllamaClient;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -228,7 +229,11 @@ describe('OllamaClient', () => {
|
|||||||
tools: [
|
tools: [
|
||||||
{
|
{
|
||||||
type: 'function',
|
type: 'function',
|
||||||
function: { name: 'get_time', description: 'Get current time' }
|
function: {
|
||||||
|
name: 'get_time',
|
||||||
|
description: 'Get current time',
|
||||||
|
parameters: { type: 'object', properties: {} }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,8 +15,7 @@ function createMessage(
|
|||||||
): Message {
|
): Message {
|
||||||
return {
|
return {
|
||||||
role,
|
role,
|
||||||
content,
|
content
|
||||||
timestamp: Date.now()
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,301 @@
|
|||||||
|
/**
|
||||||
|
* Backends state management using Svelte 5 runes
|
||||||
|
* Manages multiple LLM backend configurations (Ollama, llama.cpp, LM Studio)
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Backend type identifiers */
|
||||||
|
export type BackendType = 'ollama' | 'llamacpp' | 'lmstudio';
|
||||||
|
|
||||||
|
/** Backend connection status */
|
||||||
|
export type BackendStatus = 'connected' | 'disconnected' | 'unknown';
|
||||||
|
|
||||||
|
/** Backend capabilities */
|
||||||
|
export interface BackendCapabilities {
|
||||||
|
canListModels: boolean;
|
||||||
|
canPullModels: boolean;
|
||||||
|
canDeleteModels: boolean;
|
||||||
|
canCreateModels: boolean;
|
||||||
|
canStreamChat: boolean;
|
||||||
|
canEmbed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Backend information */
|
||||||
|
export interface BackendInfo {
|
||||||
|
type: BackendType;
|
||||||
|
baseUrl: string;
|
||||||
|
status: BackendStatus;
|
||||||
|
capabilities: BackendCapabilities;
|
||||||
|
version?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Discovery result for a backend endpoint */
|
||||||
|
export interface DiscoveryResult {
|
||||||
|
type: BackendType;
|
||||||
|
baseUrl: string;
|
||||||
|
available: boolean;
|
||||||
|
version?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Health check result */
|
||||||
|
export interface HealthResult {
|
||||||
|
healthy: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** API response wrapper */
|
||||||
|
interface ApiResponse<T> {
|
||||||
|
data?: T;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get base URL for API calls */
|
||||||
|
function getApiBaseUrl(): string {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const envUrl = (import.meta.env as Record<string, string>)?.PUBLIC_BACKEND_URL;
|
||||||
|
if (envUrl) return envUrl;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Make an API request */
|
||||||
|
async function apiRequest<T>(
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
body?: unknown
|
||||||
|
): Promise<ApiResponse<T>> {
|
||||||
|
const baseUrl = getApiBaseUrl();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${baseUrl}${path}`, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: body ? JSON.stringify(body) : undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
return { error: errorData.error || `HTTP ${response.status}: ${response.statusText}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return { data };
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Error) {
|
||||||
|
return { error: err.message };
|
||||||
|
}
|
||||||
|
return { error: 'Unknown error occurred' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Backends state class with reactive properties */
|
||||||
|
export class BackendsState {
|
||||||
|
/** All configured backends */
|
||||||
|
backends = $state<BackendInfo[]>([]);
|
||||||
|
|
||||||
|
/** Currently active backend type */
|
||||||
|
activeType = $state<BackendType | null>(null);
|
||||||
|
|
||||||
|
/** Loading state */
|
||||||
|
isLoading = $state(false);
|
||||||
|
|
||||||
|
/** Discovering state */
|
||||||
|
isDiscovering = $state(false);
|
||||||
|
|
||||||
|
/** Error state */
|
||||||
|
error = $state<string | null>(null);
|
||||||
|
|
||||||
|
/** Promise that resolves when initial load is complete */
|
||||||
|
private _readyPromise: Promise<void> | null = null;
|
||||||
|
private _readyResolve: (() => void) | null = null;
|
||||||
|
|
||||||
|
/** Derived: the currently active backend info */
|
||||||
|
get activeBackend(): BackendInfo | null {
|
||||||
|
if (!this.activeType) return null;
|
||||||
|
return this.backends.find((b) => b.type === this.activeType) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Derived: whether the active backend can pull models (Ollama only) */
|
||||||
|
get canPullModels(): boolean {
|
||||||
|
return this.activeBackend?.capabilities.canPullModels ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Derived: whether the active backend can delete models (Ollama only) */
|
||||||
|
get canDeleteModels(): boolean {
|
||||||
|
return this.activeBackend?.capabilities.canDeleteModels ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Derived: whether the active backend can create custom models (Ollama only) */
|
||||||
|
get canCreateModels(): boolean {
|
||||||
|
return this.activeBackend?.capabilities.canCreateModels ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Derived: connected backends */
|
||||||
|
get connectedBackends(): BackendInfo[] {
|
||||||
|
return this.backends.filter((b) => b.status === 'connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// Create ready promise
|
||||||
|
this._readyPromise = new Promise((resolve) => {
|
||||||
|
this._readyResolve = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load backends on initialization (client-side only)
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
this.load();
|
||||||
|
} else {
|
||||||
|
// SSR: resolve immediately
|
||||||
|
this._readyResolve?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wait for initial load to complete */
|
||||||
|
async ready(): Promise<void> {
|
||||||
|
return this._readyPromise ?? Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load backends from the API
|
||||||
|
*/
|
||||||
|
async load(): Promise<void> {
|
||||||
|
this.isLoading = true;
|
||||||
|
this.error = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await apiRequest<{ backends: BackendInfo[]; active: string }>(
|
||||||
|
'GET',
|
||||||
|
'/api/v1/ai/backends'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.data) {
|
||||||
|
this.backends = result.data.backends || [];
|
||||||
|
this.activeType = (result.data.active as BackendType) || null;
|
||||||
|
} else if (result.error) {
|
||||||
|
this.error = result.error;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.error = err instanceof Error ? err.message : 'Failed to load backends';
|
||||||
|
} finally {
|
||||||
|
this.isLoading = false;
|
||||||
|
this._readyResolve?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discover available backends by probing default endpoints
|
||||||
|
*/
|
||||||
|
async discover(endpoints?: Array<{ type: BackendType; baseUrl: string }>): Promise<DiscoveryResult[]> {
|
||||||
|
this.isDiscovering = true;
|
||||||
|
this.error = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await apiRequest<{ results: DiscoveryResult[] }>(
|
||||||
|
'POST',
|
||||||
|
'/api/v1/ai/backends/discover',
|
||||||
|
endpoints ? { endpoints } : {}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.data?.results) {
|
||||||
|
return result.data.results;
|
||||||
|
} else if (result.error) {
|
||||||
|
this.error = result.error;
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
} catch (err) {
|
||||||
|
this.error = err instanceof Error ? err.message : 'Failed to discover backends';
|
||||||
|
return [];
|
||||||
|
} finally {
|
||||||
|
this.isDiscovering = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the active backend
|
||||||
|
*/
|
||||||
|
async setActive(type: BackendType): Promise<boolean> {
|
||||||
|
this.error = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await apiRequest<{ active: string }>('POST', '/api/v1/ai/backends/active', {
|
||||||
|
type
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.data) {
|
||||||
|
this.activeType = result.data.active as BackendType;
|
||||||
|
return true;
|
||||||
|
} else if (result.error) {
|
||||||
|
this.error = result.error;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} catch (err) {
|
||||||
|
this.error = err instanceof Error ? err.message : 'Failed to set active backend';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check the health of a specific backend
|
||||||
|
*/
|
||||||
|
async checkHealth(type: BackendType): Promise<HealthResult> {
|
||||||
|
try {
|
||||||
|
const result = await apiRequest<{ status: string; error?: string }>(
|
||||||
|
'GET',
|
||||||
|
`/api/v1/ai/backends/${type}/health`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.data) {
|
||||||
|
return {
|
||||||
|
healthy: result.data.status === 'healthy',
|
||||||
|
error: result.data.error
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
healthy: false,
|
||||||
|
error: result.error
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
healthy: false,
|
||||||
|
error: err instanceof Error ? err.message : 'Health check failed'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update local backend configuration (URL)
|
||||||
|
* Note: This updates local state only; backend registration happens via discovery
|
||||||
|
*/
|
||||||
|
updateConfig(type: BackendType, config: { baseUrl?: string }): void {
|
||||||
|
this.backends = this.backends.map((b) => {
|
||||||
|
if (b.type === type) {
|
||||||
|
return {
|
||||||
|
...b,
|
||||||
|
...config
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return b;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a backend by type
|
||||||
|
*/
|
||||||
|
get(type: BackendType): BackendInfo | undefined {
|
||||||
|
return this.backends.find((b) => b.type === type);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear any error state
|
||||||
|
*/
|
||||||
|
clearError(): void {
|
||||||
|
this.error = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Singleton backends state instance */
|
||||||
|
export const backendsState = new BackendsState();
|
||||||
@@ -0,0 +1,386 @@
|
|||||||
|
/**
|
||||||
|
* Tests for BackendsState store
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
|
||||||
|
// Types for the backends API
|
||||||
|
interface BackendInfo {
|
||||||
|
type: 'ollama' | 'llamacpp' | 'lmstudio';
|
||||||
|
baseUrl: string;
|
||||||
|
status: 'connected' | 'disconnected' | 'unknown';
|
||||||
|
capabilities: BackendCapabilities;
|
||||||
|
version?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BackendCapabilities {
|
||||||
|
canListModels: boolean;
|
||||||
|
canPullModels: boolean;
|
||||||
|
canDeleteModels: boolean;
|
||||||
|
canCreateModels: boolean;
|
||||||
|
canStreamChat: boolean;
|
||||||
|
canEmbed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DiscoveryResult {
|
||||||
|
type: 'ollama' | 'llamacpp' | 'lmstudio';
|
||||||
|
baseUrl: string;
|
||||||
|
available: boolean;
|
||||||
|
version?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('BackendsState', () => {
|
||||||
|
let BackendsState: typeof import('./backends.svelte.js').BackendsState;
|
||||||
|
let backendsState: InstanceType<typeof BackendsState>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Reset modules for fresh state
|
||||||
|
vi.resetModules();
|
||||||
|
|
||||||
|
// Mock fetch globally with default empty response for initial load
|
||||||
|
global.fetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ backends: [], active: '' })
|
||||||
|
});
|
||||||
|
|
||||||
|
// Import fresh module
|
||||||
|
const module = await import('./backends.svelte.js');
|
||||||
|
BackendsState = module.BackendsState;
|
||||||
|
backendsState = new BackendsState();
|
||||||
|
|
||||||
|
// Wait for initial load to complete
|
||||||
|
await backendsState.ready();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('initialization', () => {
|
||||||
|
it('starts with empty backends array', () => {
|
||||||
|
expect(backendsState.backends).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts with no active backend', () => {
|
||||||
|
expect(backendsState.activeType).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts with not loading', () => {
|
||||||
|
expect(backendsState.isLoading).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts with no error', () => {
|
||||||
|
expect(backendsState.error).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('load', () => {
|
||||||
|
it('loads backends from API', async () => {
|
||||||
|
const mockBackends: BackendInfo[] = [
|
||||||
|
{
|
||||||
|
type: 'ollama',
|
||||||
|
baseUrl: 'http://localhost:11434',
|
||||||
|
status: 'connected',
|
||||||
|
capabilities: {
|
||||||
|
canListModels: true,
|
||||||
|
canPullModels: true,
|
||||||
|
canDeleteModels: true,
|
||||||
|
canCreateModels: true,
|
||||||
|
canStreamChat: true,
|
||||||
|
canEmbed: true
|
||||||
|
},
|
||||||
|
version: '0.3.0'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ backends: mockBackends, active: 'ollama' })
|
||||||
|
});
|
||||||
|
|
||||||
|
await backendsState.load();
|
||||||
|
|
||||||
|
expect(backendsState.backends).toEqual(mockBackends);
|
||||||
|
expect(backendsState.activeType).toBe('ollama');
|
||||||
|
expect(backendsState.isLoading).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles load error', async () => {
|
||||||
|
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 500,
|
||||||
|
statusText: 'Internal Server Error',
|
||||||
|
json: async () => ({ error: 'Server error' })
|
||||||
|
});
|
||||||
|
|
||||||
|
await backendsState.load();
|
||||||
|
|
||||||
|
expect(backendsState.error).not.toBeNull();
|
||||||
|
expect(backendsState.isLoading).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles network error', async () => {
|
||||||
|
(global.fetch as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
|
||||||
|
new Error('Network error')
|
||||||
|
);
|
||||||
|
|
||||||
|
await backendsState.load();
|
||||||
|
|
||||||
|
expect(backendsState.error).toBe('Network error');
|
||||||
|
expect(backendsState.isLoading).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('discover', () => {
|
||||||
|
it('discovers available backends', async () => {
|
||||||
|
const mockResults: DiscoveryResult[] = [
|
||||||
|
{
|
||||||
|
type: 'ollama',
|
||||||
|
baseUrl: 'http://localhost:11434',
|
||||||
|
available: true,
|
||||||
|
version: '0.3.0'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'llamacpp',
|
||||||
|
baseUrl: 'http://localhost:8081',
|
||||||
|
available: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'lmstudio',
|
||||||
|
baseUrl: 'http://localhost:1234',
|
||||||
|
available: false,
|
||||||
|
error: 'Connection refused'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ results: mockResults })
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = await backendsState.discover();
|
||||||
|
|
||||||
|
expect(results).toEqual(mockResults);
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('/api/v1/ai/backends/discover'),
|
||||||
|
expect.objectContaining({ method: 'POST' })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array on error', async () => {
|
||||||
|
(global.fetch as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
|
||||||
|
new Error('Network error')
|
||||||
|
);
|
||||||
|
|
||||||
|
const results = await backendsState.discover();
|
||||||
|
|
||||||
|
expect(results).toEqual([]);
|
||||||
|
expect(backendsState.error).toBe('Network error');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('setActive', () => {
|
||||||
|
it('sets active backend', async () => {
|
||||||
|
// First load some backends
|
||||||
|
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
backends: [
|
||||||
|
{ type: 'ollama', baseUrl: 'http://localhost:11434', status: 'connected' }
|
||||||
|
],
|
||||||
|
active: ''
|
||||||
|
})
|
||||||
|
});
|
||||||
|
await backendsState.load();
|
||||||
|
|
||||||
|
// Then set active
|
||||||
|
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ active: 'ollama' })
|
||||||
|
});
|
||||||
|
|
||||||
|
const success = await backendsState.setActive('ollama');
|
||||||
|
|
||||||
|
expect(success).toBe(true);
|
||||||
|
expect(backendsState.activeType).toBe('ollama');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles setActive error', async () => {
|
||||||
|
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 400,
|
||||||
|
statusText: 'Bad Request',
|
||||||
|
json: async () => ({ error: 'Backend not registered' })
|
||||||
|
});
|
||||||
|
|
||||||
|
const success = await backendsState.setActive('llamacpp');
|
||||||
|
|
||||||
|
expect(success).toBe(false);
|
||||||
|
expect(backendsState.error).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('checkHealth', () => {
|
||||||
|
it('checks backend health', async () => {
|
||||||
|
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ status: 'healthy' })
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await backendsState.checkHealth('ollama');
|
||||||
|
|
||||||
|
expect(result.healthy).toBe(true);
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('/api/v1/ai/backends/ollama/health'),
|
||||||
|
expect.any(Object)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns unhealthy on error response', async () => {
|
||||||
|
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 503,
|
||||||
|
statusText: 'Service Unavailable',
|
||||||
|
json: async () => ({ status: 'unhealthy', error: 'Connection refused' })
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await backendsState.checkHealth('ollama');
|
||||||
|
|
||||||
|
expect(result.healthy).toBe(false);
|
||||||
|
expect(result.error).toBe('Connection refused');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('derived state', () => {
|
||||||
|
it('activeBackend returns the active backend info', async () => {
|
||||||
|
const mockBackends: BackendInfo[] = [
|
||||||
|
{
|
||||||
|
type: 'ollama',
|
||||||
|
baseUrl: 'http://localhost:11434',
|
||||||
|
status: 'connected',
|
||||||
|
capabilities: {
|
||||||
|
canListModels: true,
|
||||||
|
canPullModels: true,
|
||||||
|
canDeleteModels: true,
|
||||||
|
canCreateModels: true,
|
||||||
|
canStreamChat: true,
|
||||||
|
canEmbed: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'llamacpp',
|
||||||
|
baseUrl: 'http://localhost:8081',
|
||||||
|
status: 'connected',
|
||||||
|
capabilities: {
|
||||||
|
canListModels: true,
|
||||||
|
canPullModels: false,
|
||||||
|
canDeleteModels: false,
|
||||||
|
canCreateModels: false,
|
||||||
|
canStreamChat: true,
|
||||||
|
canEmbed: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ backends: mockBackends, active: 'llamacpp' })
|
||||||
|
});
|
||||||
|
|
||||||
|
await backendsState.load();
|
||||||
|
|
||||||
|
const active = backendsState.activeBackend;
|
||||||
|
expect(active?.type).toBe('llamacpp');
|
||||||
|
expect(active?.baseUrl).toBe('http://localhost:8081');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('canPullModels is true only for Ollama', async () => {
|
||||||
|
const mockBackends: BackendInfo[] = [
|
||||||
|
{
|
||||||
|
type: 'ollama',
|
||||||
|
baseUrl: 'http://localhost:11434',
|
||||||
|
status: 'connected',
|
||||||
|
capabilities: {
|
||||||
|
canListModels: true,
|
||||||
|
canPullModels: true,
|
||||||
|
canDeleteModels: true,
|
||||||
|
canCreateModels: true,
|
||||||
|
canStreamChat: true,
|
||||||
|
canEmbed: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ backends: mockBackends, active: 'ollama' })
|
||||||
|
});
|
||||||
|
|
||||||
|
await backendsState.load();
|
||||||
|
|
||||||
|
expect(backendsState.canPullModels).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('canPullModels is false for llama.cpp', async () => {
|
||||||
|
const mockBackends: BackendInfo[] = [
|
||||||
|
{
|
||||||
|
type: 'llamacpp',
|
||||||
|
baseUrl: 'http://localhost:8081',
|
||||||
|
status: 'connected',
|
||||||
|
capabilities: {
|
||||||
|
canListModels: true,
|
||||||
|
canPullModels: false,
|
||||||
|
canDeleteModels: false,
|
||||||
|
canCreateModels: false,
|
||||||
|
canStreamChat: true,
|
||||||
|
canEmbed: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ backends: mockBackends, active: 'llamacpp' })
|
||||||
|
});
|
||||||
|
|
||||||
|
await backendsState.load();
|
||||||
|
|
||||||
|
expect(backendsState.canPullModels).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('updateConfig', () => {
|
||||||
|
it('updates backend URL', async () => {
|
||||||
|
// Load initial backends
|
||||||
|
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
backends: [
|
||||||
|
{
|
||||||
|
type: 'ollama',
|
||||||
|
baseUrl: 'http://localhost:11434',
|
||||||
|
status: 'connected',
|
||||||
|
capabilities: {
|
||||||
|
canListModels: true,
|
||||||
|
canPullModels: true,
|
||||||
|
canDeleteModels: true,
|
||||||
|
canCreateModels: true,
|
||||||
|
canStreamChat: true,
|
||||||
|
canEmbed: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
active: 'ollama'
|
||||||
|
})
|
||||||
|
});
|
||||||
|
await backendsState.load();
|
||||||
|
|
||||||
|
// Update config
|
||||||
|
backendsState.updateConfig('ollama', { baseUrl: 'http://192.168.1.100:11434' });
|
||||||
|
|
||||||
|
const backend = backendsState.backends.find((b) => b.type === 'ollama');
|
||||||
|
expect(backend?.baseUrl).toBe('http://192.168.1.100:11434');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { chatState, conversationsState, modelsState, uiState, promptsState, versionState, projectsState } from '$lib/stores';
|
import { chatState, conversationsState, modelsState, uiState, promptsState, versionState, projectsState } from '$lib/stores';
|
||||||
|
import { backendsState, type BackendType } from '$lib/stores/backends.svelte';
|
||||||
import { getAllConversations } from '$lib/storage';
|
import { getAllConversations } from '$lib/storage';
|
||||||
import { syncManager } from '$lib/backend';
|
import { syncManager } from '$lib/backend';
|
||||||
import { keyboardShortcuts, getShortcuts } from '$lib/utils';
|
import { keyboardShortcuts, getShortcuts } from '$lib/utils';
|
||||||
@@ -22,6 +23,12 @@
|
|||||||
import type { LayoutData } from './$types';
|
import type { LayoutData } from './$types';
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
// LocalStorage key for persisting backend selection
|
||||||
|
const BACKEND_STORAGE_KEY = 'vessel:selectedBackend';
|
||||||
|
|
||||||
|
// Flag to track if initial backend restoration is complete
|
||||||
|
let backendRestoreComplete = $state(false);
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
data: LayoutData;
|
data: LayoutData;
|
||||||
children: Snippet;
|
children: Snippet;
|
||||||
@@ -35,6 +42,88 @@
|
|||||||
// Shortcuts modal state
|
// Shortcuts modal state
|
||||||
let showShortcutsModal = $state(false);
|
let showShortcutsModal = $state(false);
|
||||||
|
|
||||||
|
// Model name for non-Ollama backends
|
||||||
|
let nonOllamaModelName = $state<string | null>(null);
|
||||||
|
let modelFetchFailed = $state(false);
|
||||||
|
|
||||||
|
// Fetch model name when backend changes to non-Ollama
|
||||||
|
$effect(() => {
|
||||||
|
const backendType = backendsState.activeType;
|
||||||
|
if (backendType && backendType !== 'ollama') {
|
||||||
|
fetchNonOllamaModel();
|
||||||
|
} else {
|
||||||
|
nonOllamaModelName = null;
|
||||||
|
modelFetchFailed = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch model name from unified API for non-Ollama backends
|
||||||
|
*/
|
||||||
|
async function fetchNonOllamaModel(): Promise<void> {
|
||||||
|
modelFetchFailed = false;
|
||||||
|
nonOllamaModelName = null;
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/v1/ai/models');
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.models && data.models.length > 0) {
|
||||||
|
// Extract just the model name (strip path/extension for cleaner display)
|
||||||
|
const fullName = data.models[0].name;
|
||||||
|
nonOllamaModelName = fullName.replace(/\.gguf$/i, '');
|
||||||
|
} else {
|
||||||
|
// No models loaded
|
||||||
|
modelFetchFailed = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
modelFetchFailed = true;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch model from backend:', err);
|
||||||
|
modelFetchFailed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist backend selection to localStorage
|
||||||
|
*/
|
||||||
|
function persistBackendSelection(type: BackendType): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(BACKEND_STORAGE_KEY, type);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to persist backend selection:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore last selected backend if it's available
|
||||||
|
*/
|
||||||
|
async function restoreLastBackend(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const lastBackend = localStorage.getItem(BACKEND_STORAGE_KEY) as BackendType | null;
|
||||||
|
if (lastBackend && lastBackend !== backendsState.activeType) {
|
||||||
|
// Check if the last backend is connected
|
||||||
|
const backend = backendsState.get(lastBackend);
|
||||||
|
if (backend?.status === 'connected') {
|
||||||
|
await backendsState.setActive(lastBackend);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to restore backend selection:', err);
|
||||||
|
} finally {
|
||||||
|
// Mark restore as complete so persistence effect can start working
|
||||||
|
backendRestoreComplete = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watch for backend changes and persist (only after initial restore is complete)
|
||||||
|
$effect(() => {
|
||||||
|
const activeType = backendsState.activeType;
|
||||||
|
if (activeType && backendRestoreComplete) {
|
||||||
|
persistBackendSelection(activeType);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
// Initialize UI state (handles responsive detection, theme, etc.)
|
// Initialize UI state (handles responsive detection, theme, etc.)
|
||||||
uiState.initialize();
|
uiState.initialize();
|
||||||
@@ -68,6 +157,9 @@
|
|||||||
// Load projects from IndexedDB
|
// Load projects from IndexedDB
|
||||||
projectsState.load();
|
projectsState.load();
|
||||||
|
|
||||||
|
// Restore last selected backend after backends finish loading
|
||||||
|
backendsState.ready().then(() => restoreLastBackend());
|
||||||
|
|
||||||
// Schedule background migration for chat indexing (runs after 5 seconds)
|
// Schedule background migration for chat indexing (runs after 5 seconds)
|
||||||
scheduleMigration(5000);
|
scheduleMigration(5000);
|
||||||
|
|
||||||
@@ -167,7 +259,30 @@
|
|||||||
<header class="relative z-40 flex-shrink-0">
|
<header class="relative z-40 flex-shrink-0">
|
||||||
<TopNav onNavigateHome={handleNavigateHome}>
|
<TopNav onNavigateHome={handleNavigateHome}>
|
||||||
{#snippet modelSelect()}
|
{#snippet modelSelect()}
|
||||||
<ModelSelect />
|
{#if backendsState.activeType === 'ollama'}
|
||||||
|
<ModelSelect />
|
||||||
|
{:else if backendsState.activeBackend}
|
||||||
|
<!-- Non-Ollama backend indicator with model name -->
|
||||||
|
<div class="flex items-center gap-2 rounded-lg border border-theme bg-theme-secondary/50 px-3 py-2 text-sm">
|
||||||
|
<svg class="h-4 w-4 text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2" />
|
||||||
|
</svg>
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<span class="font-medium text-theme-primary">
|
||||||
|
{#if nonOllamaModelName}
|
||||||
|
{nonOllamaModelName}
|
||||||
|
{:else if modelFetchFailed}
|
||||||
|
<span class="text-amber-400">No model loaded</span>
|
||||||
|
{:else}
|
||||||
|
Loading...
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-theme-muted">
|
||||||
|
{backendsState.activeType === 'llamacpp' ? 'llama.cpp' : 'LM Studio'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</TopNav>
|
</TopNav>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -7,11 +7,13 @@
|
|||||||
|
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { chatState, conversationsState, modelsState, toolsState, promptsState } from '$lib/stores';
|
import { chatState, conversationsState, modelsState, toolsState, promptsState } from '$lib/stores';
|
||||||
|
import { backendsState } from '$lib/stores/backends.svelte';
|
||||||
import { resolveSystemPrompt } from '$lib/services/prompt-resolution.js';
|
import { resolveSystemPrompt } from '$lib/services/prompt-resolution.js';
|
||||||
import { streamingMetricsState } from '$lib/stores/streaming-metrics.svelte';
|
import { streamingMetricsState } from '$lib/stores/streaming-metrics.svelte';
|
||||||
import { settingsState } from '$lib/stores/settings.svelte';
|
import { settingsState } from '$lib/stores/settings.svelte';
|
||||||
import { createConversation as createStoredConversation, addMessage as addStoredMessage, updateConversation, saveAttachments } from '$lib/storage';
|
import { createConversation as createStoredConversation, addMessage as addStoredMessage, updateConversation, saveAttachments } from '$lib/storage';
|
||||||
import { ollamaClient } from '$lib/ollama';
|
import { ollamaClient } from '$lib/ollama';
|
||||||
|
import { unifiedLLMClient } from '$lib/llm';
|
||||||
import type { OllamaMessage, OllamaToolDefinition, OllamaToolCall } from '$lib/ollama';
|
import type { OllamaMessage, OllamaToolDefinition, OllamaToolCall } from '$lib/ollama';
|
||||||
import { getFunctionModel, USE_FUNCTION_MODEL, runToolCalls, formatToolResultsForChat } from '$lib/tools';
|
import { getFunctionModel, USE_FUNCTION_MODEL, runToolCalls, formatToolResultsForChat } from '$lib/tools';
|
||||||
import { searchSimilar, formatResultsAsContext, getKnowledgeBaseStats } from '$lib/memory';
|
import { searchSimilar, formatResultsAsContext, getKnowledgeBaseStats } from '$lib/memory';
|
||||||
@@ -80,9 +82,28 @@
|
|||||||
* Creates a new conversation and starts streaming the response
|
* Creates a new conversation and starts streaming the response
|
||||||
*/
|
*/
|
||||||
async function handleFirstMessage(content: string, images?: string[], attachments?: FileAttachment[]): Promise<void> {
|
async function handleFirstMessage(content: string, images?: string[], attachments?: FileAttachment[]): Promise<void> {
|
||||||
const model = modelsState.selectedId;
|
// Get model name based on active backend
|
||||||
|
let model: string | null = null;
|
||||||
|
|
||||||
|
if (backendsState.activeType === 'ollama') {
|
||||||
|
model = modelsState.selectedId;
|
||||||
|
} else if (backendsState.activeType === 'llamacpp' || backendsState.activeType === 'lmstudio') {
|
||||||
|
// For OpenAI-compatible backends, fetch model from the unified API
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/v1/ai/models');
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.models && data.models.length > 0) {
|
||||||
|
model = data.models[0].name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to get model from backend:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!model) {
|
if (!model) {
|
||||||
console.error('No model selected');
|
console.error('No model available');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -298,92 +319,121 @@
|
|||||||
let streamingThinking = '';
|
let streamingThinking = '';
|
||||||
let thinkingClosed = false;
|
let thinkingClosed = false;
|
||||||
|
|
||||||
await ollamaClient.streamChatWithCallbacks(
|
// Helper to handle completion (shared by both backends)
|
||||||
{ model: chatModel, messages, tools, think: useNativeThinking, options: settingsState.apiParameters },
|
const handleComplete = async () => {
|
||||||
{
|
// Close thinking block if it was opened but not closed
|
||||||
onThinkingToken: (token) => {
|
if (streamingThinking && !thinkingClosed) {
|
||||||
// Clear "Processing..." on first token
|
chatState.appendToStreaming('</think>\n\n');
|
||||||
if (needsClearOnFirstToken) {
|
thinkingClosed = true;
|
||||||
chatState.setStreamContent('');
|
|
||||||
needsClearOnFirstToken = false;
|
|
||||||
}
|
|
||||||
// Accumulate thinking and update the message
|
|
||||||
if (!streamingThinking) {
|
|
||||||
// Start the thinking block
|
|
||||||
chatState.appendToStreaming('<think>');
|
|
||||||
}
|
|
||||||
streamingThinking += token;
|
|
||||||
chatState.appendToStreaming(token);
|
|
||||||
streamingMetricsState.incrementTokens();
|
|
||||||
},
|
|
||||||
onToken: (token) => {
|
|
||||||
// Clear "Processing..." on first token
|
|
||||||
if (needsClearOnFirstToken) {
|
|
||||||
chatState.setStreamContent('');
|
|
||||||
needsClearOnFirstToken = false;
|
|
||||||
}
|
|
||||||
// Close thinking block when content starts
|
|
||||||
if (streamingThinking && !thinkingClosed) {
|
|
||||||
chatState.appendToStreaming('</think>\n\n');
|
|
||||||
thinkingClosed = true;
|
|
||||||
}
|
|
||||||
chatState.appendToStreaming(token);
|
|
||||||
streamingMetricsState.incrementTokens();
|
|
||||||
},
|
|
||||||
onToolCall: (toolCalls) => {
|
|
||||||
pendingToolCalls = toolCalls;
|
|
||||||
},
|
|
||||||
onComplete: async () => {
|
|
||||||
// Close thinking block if it was opened but not closed (e.g., tool calls without content)
|
|
||||||
if (streamingThinking && !thinkingClosed) {
|
|
||||||
chatState.appendToStreaming('</think>\n\n');
|
|
||||||
thinkingClosed = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
chatState.finishStreaming();
|
|
||||||
streamingMetricsState.endStream();
|
|
||||||
|
|
||||||
// Handle tool calls if received
|
|
||||||
if (pendingToolCalls && pendingToolCalls.length > 0) {
|
|
||||||
await executeToolsAndContinue(
|
|
||||||
model,
|
|
||||||
assistantMessageId,
|
|
||||||
userMessageId,
|
|
||||||
pendingToolCalls,
|
|
||||||
conversationId
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Persist assistant message with the SAME ID as chatState
|
|
||||||
const node = chatState.messageTree.get(assistantMessageId);
|
|
||||||
if (node) {
|
|
||||||
await addStoredMessage(
|
|
||||||
conversationId,
|
|
||||||
{ role: 'assistant', content: node.message.content },
|
|
||||||
userMessageId,
|
|
||||||
assistantMessageId
|
|
||||||
);
|
|
||||||
await updateConversation(conversationId, {});
|
|
||||||
conversationsState.update(conversationId, {});
|
|
||||||
|
|
||||||
// Generate a smarter title in the background (don't await)
|
|
||||||
generateSmartTitle(conversationId, content, node.message.content);
|
|
||||||
|
|
||||||
// Update URL now that streaming is complete
|
|
||||||
replaceState(`/chat/${conversationId}`, {});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
console.error('Streaming error:', error);
|
|
||||||
// Show error to user instead of leaving "Processing..."
|
|
||||||
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
|
|
||||||
chatState.setStreamContent(`⚠️ Error: ${errorMsg}`);
|
|
||||||
chatState.finishStreaming();
|
|
||||||
streamingMetricsState.endStream();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
);
|
|
||||||
|
chatState.finishStreaming();
|
||||||
|
streamingMetricsState.endStream();
|
||||||
|
|
||||||
|
// Handle tool calls if received (Ollama only)
|
||||||
|
if (pendingToolCalls && pendingToolCalls.length > 0) {
|
||||||
|
await executeToolsAndContinue(
|
||||||
|
model,
|
||||||
|
assistantMessageId,
|
||||||
|
userMessageId,
|
||||||
|
pendingToolCalls,
|
||||||
|
conversationId
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist assistant message with the SAME ID as chatState
|
||||||
|
const node = chatState.messageTree.get(assistantMessageId);
|
||||||
|
if (node) {
|
||||||
|
await addStoredMessage(
|
||||||
|
conversationId,
|
||||||
|
{ role: 'assistant', content: node.message.content },
|
||||||
|
userMessageId,
|
||||||
|
assistantMessageId
|
||||||
|
);
|
||||||
|
await updateConversation(conversationId, {});
|
||||||
|
conversationsState.update(conversationId, {});
|
||||||
|
|
||||||
|
// Generate a smarter title in the background (don't await)
|
||||||
|
generateSmartTitle(conversationId, content, node.message.content);
|
||||||
|
|
||||||
|
// Update URL now that streaming is complete
|
||||||
|
replaceState(`/chat/${conversationId}`, {});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper to handle errors (shared by both backends)
|
||||||
|
const handleError = (error: unknown) => {
|
||||||
|
console.error('Streaming error:', error);
|
||||||
|
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
chatState.setStreamContent(`⚠️ Error: ${errorMsg}`);
|
||||||
|
chatState.finishStreaming();
|
||||||
|
streamingMetricsState.endStream();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Use appropriate client based on active backend
|
||||||
|
if (backendsState.activeType === 'ollama') {
|
||||||
|
// Ollama: full features including tools and thinking
|
||||||
|
await ollamaClient.streamChatWithCallbacks(
|
||||||
|
{ model: chatModel, messages, tools, think: useNativeThinking, options: settingsState.apiParameters },
|
||||||
|
{
|
||||||
|
onThinkingToken: (token) => {
|
||||||
|
if (needsClearOnFirstToken) {
|
||||||
|
chatState.setStreamContent('');
|
||||||
|
needsClearOnFirstToken = false;
|
||||||
|
}
|
||||||
|
if (!streamingThinking) {
|
||||||
|
chatState.appendToStreaming('<think>');
|
||||||
|
}
|
||||||
|
streamingThinking += token;
|
||||||
|
chatState.appendToStreaming(token);
|
||||||
|
streamingMetricsState.incrementTokens();
|
||||||
|
},
|
||||||
|
onToken: (token) => {
|
||||||
|
if (needsClearOnFirstToken) {
|
||||||
|
chatState.setStreamContent('');
|
||||||
|
needsClearOnFirstToken = false;
|
||||||
|
}
|
||||||
|
if (streamingThinking && !thinkingClosed) {
|
||||||
|
chatState.appendToStreaming('</think>\n\n');
|
||||||
|
thinkingClosed = true;
|
||||||
|
}
|
||||||
|
chatState.appendToStreaming(token);
|
||||||
|
streamingMetricsState.incrementTokens();
|
||||||
|
},
|
||||||
|
onToolCall: (toolCalls) => {
|
||||||
|
pendingToolCalls = toolCalls;
|
||||||
|
},
|
||||||
|
onComplete: handleComplete,
|
||||||
|
onError: handleError
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// llama.cpp / LM Studio: use unified API (no tools/thinking support)
|
||||||
|
try {
|
||||||
|
await unifiedLLMClient.streamChatWithCallbacks(
|
||||||
|
{
|
||||||
|
model: chatModel,
|
||||||
|
messages: messages.map(m => ({ role: m.role, content: m.content })),
|
||||||
|
stream: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onToken: (token) => {
|
||||||
|
if (needsClearOnFirstToken) {
|
||||||
|
chatState.setStreamContent('');
|
||||||
|
needsClearOnFirstToken = false;
|
||||||
|
}
|
||||||
|
chatState.appendToStreaming(token);
|
||||||
|
streamingMetricsState.incrementTokens();
|
||||||
|
},
|
||||||
|
onComplete: handleComplete,
|
||||||
|
onError: handleError
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to send message:', error);
|
console.error('Failed to send message:', error);
|
||||||
// Show error to user
|
// Show error to user
|
||||||
|
|||||||
@@ -21,11 +21,11 @@
|
|||||||
let currentConversationId = $state<string | null>(null);
|
let currentConversationId = $state<string | null>(null);
|
||||||
let isLoading = $state(false);
|
let isLoading = $state(false);
|
||||||
|
|
||||||
// Extract first message from data and clear from URL
|
// Extract first message from data (captured once per page load)
|
||||||
let initialMessage = $state<string | null>(data.firstMessage);
|
const initialMessage = $derived(data.firstMessage);
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
// Clear firstMessage from URL to keep it clean
|
// Clear firstMessage from URL to keep it clean
|
||||||
if (data.firstMessage && $page.url.searchParams.has('firstMessage')) {
|
if (initialMessage && $page.url.searchParams.has('firstMessage')) {
|
||||||
const url = new URL($page.url);
|
const url = new URL($page.url);
|
||||||
url.searchParams.delete('firstMessage');
|
url.searchParams.delete('firstMessage');
|
||||||
replaceState(url, {});
|
replaceState(url, {});
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
let dragOver = $state(false);
|
let dragOver = $state(false);
|
||||||
|
|
||||||
// File input reference
|
// File input reference
|
||||||
let fileInput: HTMLInputElement;
|
let fileInput = $state<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
// Load documents on mount
|
// Load documents on mount
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
|
|||||||
@@ -466,7 +466,7 @@
|
|||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
</svg>
|
</svg>
|
||||||
<span>{deleteError}</span>
|
<span>{deleteError}</span>
|
||||||
<button type="button" onclick={() => deleteError = null} class="ml-auto text-red-400 hover:text-red-300">
|
<button type="button" onclick={() => deleteError = null} class="ml-auto text-red-400 hover:text-red-300" aria-label="Dismiss error">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -987,6 +987,7 @@
|
|||||||
onclick={() => modelRegistry.prevPage()}
|
onclick={() => modelRegistry.prevPage()}
|
||||||
disabled={!modelRegistry.hasPrevPage}
|
disabled={!modelRegistry.hasPrevPage}
|
||||||
class="rounded-lg border border-theme bg-theme-secondary px-3 py-2 text-sm text-theme-muted transition-colors hover:bg-theme-tertiary hover:text-theme-primary disabled:cursor-not-allowed disabled:opacity-50"
|
class="rounded-lg border border-theme bg-theme-secondary px-3 py-2 text-sm text-theme-muted transition-colors hover:bg-theme-tertiary hover:text-theme-primary disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
aria-label="Previous page"
|
||||||
>
|
>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
|
||||||
@@ -1002,6 +1003,7 @@
|
|||||||
onclick={() => modelRegistry.nextPage()}
|
onclick={() => modelRegistry.nextPage()}
|
||||||
disabled={!modelRegistry.hasNextPage}
|
disabled={!modelRegistry.hasNextPage}
|
||||||
class="rounded-lg border border-theme bg-theme-secondary px-3 py-2 text-sm text-theme-muted transition-colors hover:bg-theme-tertiary hover:text-theme-primary disabled:cursor-not-allowed disabled:opacity-50"
|
class="rounded-lg border border-theme bg-theme-secondary px-3 py-2 text-sm text-theme-muted transition-colors hover:bg-theme-tertiary hover:text-theme-primary disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
aria-label="Next page"
|
||||||
>
|
>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
|
||||||
@@ -1024,6 +1026,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
onclick={closeDetails}
|
onclick={closeDetails}
|
||||||
class="rounded p-1 text-theme-muted transition-colors hover:bg-theme-tertiary hover:text-theme-primary"
|
class="rounded p-1 text-theme-muted transition-colors hover:bg-theme-tertiary hover:text-theme-primary"
|
||||||
|
aria-label="Close model details"
|
||||||
>
|
>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
|||||||
@@ -46,7 +46,7 @@
|
|||||||
let isLoadingDocs = $state(false);
|
let isLoadingDocs = $state(false);
|
||||||
let selectedEmbeddingModel = $state(DEFAULT_EMBEDDING_MODEL);
|
let selectedEmbeddingModel = $state(DEFAULT_EMBEDDING_MODEL);
|
||||||
let activeTab = $state<'chats' | 'files' | 'links'>('chats');
|
let activeTab = $state<'chats' | 'files' | 'links'>('chats');
|
||||||
let fileInput: HTMLInputElement;
|
let fileInput = $state<HTMLInputElement | null>(null);
|
||||||
let dragOver = $state(false);
|
let dragOver = $state(false);
|
||||||
let isSearching = $state(false);
|
let isSearching = $state(false);
|
||||||
let searchResults = $state<ChatSearchResult[]>([]);
|
let searchResults = $state<ChatSearchResult[]>([]);
|
||||||
@@ -399,6 +399,7 @@
|
|||||||
onclick={() => showProjectModal = true}
|
onclick={() => showProjectModal = true}
|
||||||
class="rounded-lg p-2 text-theme-muted transition-colors hover:bg-theme-secondary hover:text-theme-primary"
|
class="rounded-lg p-2 text-theme-muted transition-colors hover:bg-theme-secondary hover:text-theme-primary"
|
||||||
title="Project settings"
|
title="Project settings"
|
||||||
|
aria-label="Project settings"
|
||||||
>
|
>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" />
|
||||||
@@ -428,6 +429,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
aria-label="Send message"
|
||||||
onclick={handleCreateChat}
|
onclick={handleCreateChat}
|
||||||
disabled={!newChatMessage.trim() || isCreatingChat || !modelsState.selectedId}
|
disabled={!newChatMessage.trim() || isCreatingChat || !modelsState.selectedId}
|
||||||
class="rounded-full bg-emerald-600 p-2 text-white transition-colors hover:bg-emerald-500 disabled:opacity-50"
|
class="rounded-full bg-emerald-600 p-2 text-white transition-colors hover:bg-emerald-500 disabled:opacity-50"
|
||||||
@@ -579,6 +581,8 @@
|
|||||||
ondragover={(e) => { e.preventDefault(); dragOver = true; }}
|
ondragover={(e) => { e.preventDefault(); dragOver = true; }}
|
||||||
ondragleave={() => dragOver = false}
|
ondragleave={() => dragOver = false}
|
||||||
ondrop={handleDrop}
|
ondrop={handleDrop}
|
||||||
|
role="region"
|
||||||
|
aria-label="File upload drop zone"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
bind:this={fileInput}
|
bind:this={fileInput}
|
||||||
@@ -593,7 +597,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<p class="text-sm text-theme-muted">
|
<p class="text-sm text-theme-muted">
|
||||||
Drag & drop files here, or
|
Drag & drop files here, or
|
||||||
<button type="button" onclick={() => fileInput.click()} class="text-emerald-500 hover:text-emerald-400">browse</button>
|
<button type="button" onclick={() => fileInput?.click()} class="text-emerald-500 hover:text-emerald-400">browse</button>
|
||||||
</p>
|
</p>
|
||||||
<p class="mt-1 text-xs text-theme-muted">
|
<p class="mt-1 text-xs text-theme-muted">
|
||||||
Text files, code, markdown, JSON, etc.
|
Text files, code, markdown, JSON, etc.
|
||||||
@@ -640,6 +644,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
aria-label="Delete document"
|
||||||
onclick={() => handleDeleteDocumentClick(doc)}
|
onclick={() => handleDeleteDocumentClick(doc)}
|
||||||
class="rounded p-1.5 text-theme-muted transition-colors hover:bg-red-900/30 hover:text-red-400"
|
class="rounded p-1.5 text-theme-muted transition-colors hover:bg-red-900/30 hover:text-red-400"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -646,6 +646,7 @@
|
|||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby="editor-title"
|
aria-labelledby="editor-title"
|
||||||
|
tabindex="-1"
|
||||||
>
|
>
|
||||||
<div class="w-full max-w-2xl rounded-xl bg-theme-secondary shadow-xl">
|
<div class="w-full max-w-2xl rounded-xl bg-theme-secondary shadow-xl">
|
||||||
<div class="flex items-center justify-between border-b border-theme px-6 py-4">
|
<div class="flex items-center justify-between border-b border-theme px-6 py-4">
|
||||||
@@ -655,6 +656,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={closeEditor}
|
onclick={closeEditor}
|
||||||
|
aria-label="Close dialog"
|
||||||
class="rounded p-1 text-theme-muted transition-colors hover:bg-theme-tertiary hover:text-theme-primary"
|
class="rounded p-1 text-theme-muted transition-colors hover:bg-theme-tertiary hover:text-theme-primary"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
@@ -742,10 +744,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Capability targeting -->
|
<!-- Capability targeting -->
|
||||||
<div>
|
<fieldset>
|
||||||
<label class="mb-2 block text-sm font-medium text-theme-secondary">
|
<legend class="mb-2 block text-sm font-medium text-theme-secondary">
|
||||||
Auto-use for model types
|
Auto-use for model types
|
||||||
</label>
|
</legend>
|
||||||
<p class="mb-3 text-xs text-theme-muted">
|
<p class="mb-3 text-xs text-theme-muted">
|
||||||
When a model has these capabilities and no other prompt is selected, this prompt will
|
When a model has these capabilities and no other prompt is selected, this prompt will
|
||||||
be used automatically.
|
be used automatically.
|
||||||
@@ -766,7 +768,7 @@
|
|||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</fieldset>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Actions -->
|
<!-- Actions -->
|
||||||
@@ -804,11 +806,13 @@
|
|||||||
}}
|
}}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
|
aria-labelledby="preview-title"
|
||||||
|
tabindex="-1"
|
||||||
>
|
>
|
||||||
<div class="w-full max-w-2xl max-h-[80vh] flex flex-col rounded-xl bg-theme-secondary shadow-xl">
|
<div class="w-full max-w-2xl max-h-[80vh] flex flex-col rounded-xl bg-theme-secondary shadow-xl">
|
||||||
<div class="flex items-center justify-between border-b border-theme px-6 py-4">
|
<div class="flex items-center justify-between border-b border-theme px-6 py-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-lg font-semibold text-theme-primary">{previewTemplate.name}</h2>
|
<h2 id="preview-title" class="text-lg font-semibold text-theme-primary">{previewTemplate.name}</h2>
|
||||||
<div class="mt-1 flex items-center gap-2">
|
<div class="mt-1 flex items-center gap-2">
|
||||||
<span class="inline-flex items-center gap-1 rounded px-2 py-0.5 text-xs {info.color}">
|
<span class="inline-flex items-center gap-1 rounded px-2 py-0.5 text-xs {info.color}">
|
||||||
<span>{info.icon}</span>
|
<span>{info.icon}</span>
|
||||||
@@ -826,6 +830,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => (previewTemplate = null)}
|
onclick={() => (previewTemplate = null)}
|
||||||
|
aria-label="Close dialog"
|
||||||
class="rounded p-1 text-theme-muted transition-colors hover:bg-theme-tertiary hover:text-theme-primary"
|
class="rounded p-1 text-theme-muted transition-colors hover:bg-theme-tertiary hover:text-theme-primary"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
|
|||||||
@@ -211,6 +211,7 @@
|
|||||||
{:else if searchQuery}
|
{:else if searchQuery}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
aria-label="Clear search"
|
||||||
onclick={() => { searchQuery = ''; titleResults = []; messageResults = []; semanticResults = []; updateUrl(''); }}
|
onclick={() => { searchQuery = ''; titleResults = []; messageResults = []; semanticResults = []; updateUrl(''); }}
|
||||||
class="absolute right-4 top-1/2 -translate-y-1/2 rounded p-1 text-theme-muted hover:text-theme-primary"
|
class="absolute right-4 top-1/2 -translate-y-1/2 rounded p-1 text-theme-muted hover:text-theme-primary"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -7,12 +7,13 @@
|
|||||||
import {
|
import {
|
||||||
SettingsTabs,
|
SettingsTabs,
|
||||||
GeneralTab,
|
GeneralTab,
|
||||||
ModelsTab,
|
AIProvidersTab,
|
||||||
PromptsTab,
|
PromptsTab,
|
||||||
ToolsTab,
|
ToolsTab,
|
||||||
AgentsTab,
|
AgentsTab,
|
||||||
KnowledgeTab,
|
KnowledgeTab,
|
||||||
MemoryTab,
|
MemoryTab,
|
||||||
|
AboutTab,
|
||||||
type SettingsTab
|
type SettingsTab
|
||||||
} from '$lib/components/settings';
|
} from '$lib/components/settings';
|
||||||
|
|
||||||
@@ -36,8 +37,8 @@
|
|||||||
<div class="mx-auto max-w-5xl">
|
<div class="mx-auto max-w-5xl">
|
||||||
{#if activeTab === 'general'}
|
{#if activeTab === 'general'}
|
||||||
<GeneralTab />
|
<GeneralTab />
|
||||||
{:else if activeTab === 'models'}
|
{:else if activeTab === 'ai'}
|
||||||
<ModelsTab />
|
<AIProvidersTab />
|
||||||
{:else if activeTab === 'prompts'}
|
{:else if activeTab === 'prompts'}
|
||||||
<PromptsTab />
|
<PromptsTab />
|
||||||
{:else if activeTab === 'tools'}
|
{:else if activeTab === 'tools'}
|
||||||
@@ -48,6 +49,8 @@
|
|||||||
<KnowledgeTab />
|
<KnowledgeTab />
|
||||||
{:else if activeTab === 'memory'}
|
{:else if activeTab === 'memory'}
|
||||||
<MemoryTab />
|
<MemoryTab />
|
||||||
|
{:else if activeTab === 'about'}
|
||||||
|
<AboutTab />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -97,6 +97,7 @@
|
|||||||
class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-theme-primary {toolsState.toolsEnabled ? 'bg-blue-600' : 'bg-theme-tertiary'}"
|
class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-theme-primary {toolsState.toolsEnabled ? 'bg-blue-600' : 'bg-theme-tertiary'}"
|
||||||
role="switch"
|
role="switch"
|
||||||
aria-checked={toolsState.toolsEnabled}
|
aria-checked={toolsState.toolsEnabled}
|
||||||
|
aria-label="Toggle all tools"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out {toolsState.toolsEnabled ? 'translate-x-5' : 'translate-x-0'}"
|
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out {toolsState.toolsEnabled ? 'translate-x-5' : 'translate-x-0'}"
|
||||||
@@ -144,6 +145,7 @@
|
|||||||
class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-theme {tool.enabled ? 'bg-blue-600' : 'bg-theme-tertiary'}"
|
class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-theme {tool.enabled ? 'bg-blue-600' : 'bg-theme-tertiary'}"
|
||||||
role="switch"
|
role="switch"
|
||||||
aria-checked={tool.enabled}
|
aria-checked={tool.enabled}
|
||||||
|
aria-label="Toggle {tool.definition.function.name}"
|
||||||
disabled={!toolsState.toolsEnabled}
|
disabled={!toolsState.toolsEnabled}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
@@ -246,6 +248,7 @@
|
|||||||
class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 focus:ring-offset-theme {tool.enabled ? 'bg-emerald-600' : 'bg-theme-tertiary'}"
|
class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 focus:ring-offset-theme {tool.enabled ? 'bg-emerald-600' : 'bg-theme-tertiary'}"
|
||||||
role="switch"
|
role="switch"
|
||||||
aria-checked={tool.enabled}
|
aria-checked={tool.enabled}
|
||||||
|
aria-label="Toggle {tool.name}"
|
||||||
disabled={!toolsState.toolsEnabled}
|
disabled={!toolsState.toolsEnabled}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -1,16 +1,30 @@
|
|||||||
# Vessel development commands
|
# Vessel development commands
|
||||||
# Run `just --list` to see all available commands
|
# Run `just --list` to see all available commands
|
||||||
|
|
||||||
# Default backend port for local development (matches vite proxy default)
|
# Load .env file if present
|
||||||
backend_port := "9090"
|
set dotenv-load
|
||||||
|
|
||||||
# Default llama.cpp server port
|
# ----- Port Configuration -----
|
||||||
llama_port := "8081"
|
# All ports can be overridden via .env or environment variables
|
||||||
|
|
||||||
|
# Backend API port
|
||||||
|
backend_port := env_var_or_default("PORT", "9090")
|
||||||
|
|
||||||
|
# Frontend dev server port
|
||||||
|
frontend_port := env_var_or_default("DEV_PORT", "7842")
|
||||||
|
|
||||||
|
# llama.cpp server port
|
||||||
|
llama_port := env_var_or_default("LLAMA_PORT", "8081")
|
||||||
|
|
||||||
|
# Ollama API port
|
||||||
|
ollama_port := env_var_or_default("OLLAMA_PORT", "11434")
|
||||||
|
|
||||||
# Models directory
|
# Models directory
|
||||||
models_dir := env_var_or_default("VESSEL_MODELS_DIR", "~/.vessel/models")
|
models_dir := env_var_or_default("VESSEL_MODELS_DIR", "~/.vessel/models")
|
||||||
|
|
||||||
# Run backend locally on port 9090 (matches vite proxy default)
|
# ----- Local Development -----
|
||||||
|
|
||||||
|
# Run backend locally
|
||||||
backend:
|
backend:
|
||||||
cd backend && go run ./cmd/server -port {{backend_port}}
|
cd backend && go run ./cmd/server -port {{backend_port}}
|
||||||
|
|
||||||
@@ -18,6 +32,8 @@ backend:
|
|||||||
frontend:
|
frontend:
|
||||||
cd frontend && npm run dev
|
cd frontend && npm run dev
|
||||||
|
|
||||||
|
# ----- Docker Development -----
|
||||||
|
|
||||||
# Start frontend + backend in Docker
|
# Start frontend + backend in Docker
|
||||||
dev:
|
dev:
|
||||||
docker compose -f docker-compose.dev.yml up
|
docker compose -f docker-compose.dev.yml up
|
||||||
@@ -30,36 +46,50 @@ dev-detach:
|
|||||||
dev-stop:
|
dev-stop:
|
||||||
docker compose -f docker-compose.dev.yml down
|
docker compose -f docker-compose.dev.yml down
|
||||||
|
|
||||||
|
# Rebuild Docker images (use after code changes)
|
||||||
|
dev-build:
|
||||||
|
docker compose -f docker-compose.dev.yml build
|
||||||
|
|
||||||
|
# Rebuild Docker images from scratch (no cache)
|
||||||
|
dev-rebuild:
|
||||||
|
docker compose -f docker-compose.dev.yml build --no-cache
|
||||||
|
|
||||||
# View Docker dev logs
|
# View Docker dev logs
|
||||||
dev-logs:
|
dev-logs:
|
||||||
docker compose -f docker-compose.dev.yml logs -f
|
docker compose -f docker-compose.dev.yml logs -f
|
||||||
|
|
||||||
|
# ----- llama.cpp -----
|
||||||
|
|
||||||
# List local GGUF models
|
# List local GGUF models
|
||||||
models:
|
models:
|
||||||
@ls -lh {{models_dir}}/*.gguf 2>/dev/null || echo "No models found in {{models_dir}}"
|
@ls -lh {{models_dir}}/*.gguf 2>/dev/null || echo "No models found in {{models_dir}}"
|
||||||
|
|
||||||
# Start llama.cpp server with a model
|
# Start llama.cpp server with a model (--host 0.0.0.0 for Docker access)
|
||||||
llama-server model:
|
llama-server model:
|
||||||
llama-server -m {{models_dir}}/{{model}} --port {{llama_port}} -c 8192 -ngl 99
|
llama-server -m {{models_dir}}/{{model}} --host 0.0.0.0 --port {{llama_port}} -c 8192 -ngl 99
|
||||||
|
|
||||||
# Start llama.cpp server with custom settings
|
# Start llama.cpp server with custom settings
|
||||||
llama-server-custom model port ctx gpu:
|
llama-server-custom model port ctx gpu:
|
||||||
llama-server -m {{models_dir}}/{{model}} --port {{port}} -c {{ctx}} -ngl {{gpu}}
|
llama-server -m {{models_dir}}/{{model}} --host 0.0.0.0 --port {{port}} -c {{ctx}} -ngl {{gpu}}
|
||||||
|
|
||||||
# Start Docker dev + llama.cpp server
|
# Start Docker dev + llama.cpp server
|
||||||
all model: dev-detach
|
all model: dev-detach
|
||||||
just llama-server {{model}}
|
just llama-server {{model}}
|
||||||
|
|
||||||
|
# ----- Health & Status -----
|
||||||
|
|
||||||
# Check health of all services
|
# Check health of all services
|
||||||
health:
|
health:
|
||||||
@echo "Frontend (7842):"
|
@echo "Frontend ({{frontend_port}}):"
|
||||||
@curl -sf http://localhost:7842/health 2>/dev/null && echo " OK" || echo " Not running"
|
@curl -sf http://localhost:{{frontend_port}}/health 2>/dev/null && echo " OK" || echo " Not running"
|
||||||
@echo "Backend (9090):"
|
@echo "Backend ({{backend_port}}):"
|
||||||
@curl -sf http://localhost:9090/health 2>/dev/null && echo " OK" || echo " Not running"
|
@curl -sf http://localhost:{{backend_port}}/health 2>/dev/null && echo " OK" || echo " Not running"
|
||||||
@echo "Ollama (11434):"
|
@echo "Ollama ({{ollama_port}}):"
|
||||||
@curl -sf http://localhost:11434/api/tags 2>/dev/null && echo " OK" || echo " Not running"
|
@curl -sf http://localhost:{{ollama_port}}/api/tags 2>/dev/null && echo " OK" || echo " Not running"
|
||||||
@echo "llama.cpp (8081):"
|
@echo "llama.cpp ({{llama_port}}):"
|
||||||
@curl -sf http://localhost:8081/health 2>/dev/null && echo " OK" || echo " Not running"
|
@curl -sf http://localhost:{{llama_port}}/health 2>/dev/null && echo " OK" || echo " Not running"
|
||||||
|
|
||||||
|
# ----- Testing & Building -----
|
||||||
|
|
||||||
# Run backend tests
|
# Run backend tests
|
||||||
test-backend:
|
test-backend:
|
||||||
@@ -75,4 +105,4 @@ build-frontend:
|
|||||||
|
|
||||||
# Build backend for production
|
# Build backend for production
|
||||||
build-backend:
|
build-backend:
|
||||||
cd backend && go build -o vessel ./cmd/server
|
cd backend && go build -v -o vessel ./cmd/server && echo "Built: backend/vessel"
|
||||||
|
|||||||
Reference in New Issue
Block a user