commit de835b7af7840ff52380ac5592bc684139875d36 Author: vikingowl Date: Wed Dec 31 08:11:33 2025 +0100 feat: initial commit - Ollama WebUI with tools, sync, and backend Complete Ollama Web UI implementation featuring: Frontend (SvelteKit + Svelte 5 + Tailwind CSS + Skeleton UI): - Chat interface with streaming responses and markdown rendering - Message tree with branching support (edit creates branches) - Vision model support with image upload/paste - Code syntax highlighting with Shiki - Built-in tools: get_current_time, calculate, fetch_url - Function model middleware (functiongemma) for tool routing - IndexedDB storage with Dexie.js - Context window tracking with token estimation - Knowledge base with embeddings (RAG support) - Keyboard shortcuts and responsive design - Export conversations as Markdown/JSON Backend (Go + Gin + SQLite): - RESTful API for conversations and messages - SQLite persistence with branching message tree - Sync endpoints for IndexedDB ↔ SQLite synchronization - URL proxy endpoint for CORS-bypassed web fetching - Health check endpoint - Docker support with host network mode Infrastructure: - Docker Compose for development and production - Vite proxy configuration for Ollama and backend APIs - Hot reload development setup 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..81e108b --- /dev/null +++ b/.gitignore @@ -0,0 +1,34 @@ +# Dependencies +node_modules/ + +# Build outputs +.svelte-kit/ +build/ +dist/ + +# Environment files +.env +.env.* +!.env.example + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# OS files +.DS_Store +Thumbs.db + +# Logs +*.log +npm-debug.log* + +# Backend +backend/bin/ +backend/data/*.db +backend/server + +# Docker +*.pid diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..cec7969 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,38 @@ +FROM golang:1.23-alpine AS builder + +WORKDIR /app + +# Install build dependencies +RUN apk add --no-cache gcc musl-dev + +# Copy go mod files +COPY go.mod go.sum ./ +RUN go mod download + +# Copy source code +COPY . . + +# Build the application +RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o server ./cmd/server + +# Final stage +FROM alpine:latest + +RUN apk --no-cache add ca-certificates + +WORKDIR /app + +# Copy binary from builder +COPY --from=builder /app/server . + +# Create data directory +RUN mkdir -p /app/data + +# Expose port +EXPOSE 8080 + +# Set environment variables +ENV GIN_MODE=release + +# Run the server +CMD ["./server", "-port", "8080", "-db", "/app/data/ollama-webui.db"] diff --git a/backend/Makefile b/backend/Makefile new file mode 100644 index 0000000..c74ef8b --- /dev/null +++ b/backend/Makefile @@ -0,0 +1,34 @@ +.PHONY: build run clean test deps + +# Build the server binary +build: + go build -o bin/server ./cmd/server + +# Run the server +run: + go run ./cmd/server + +# Run with custom options +run-dev: + go run ./cmd/server -port 8080 -db ./data/dev.db + +# Clean build artifacts +clean: + rm -rf bin/ + rm -rf data/ + +# Run tests +test: + go test -v ./... + +# Download dependencies +deps: + go mod download + go mod tidy + +# Build for multiple platforms +build-all: + GOOS=linux GOARCH=amd64 go build -o bin/server-linux-amd64 ./cmd/server + GOOS=darwin GOARCH=amd64 go build -o bin/server-darwin-amd64 ./cmd/server + GOOS=darwin GOARCH=arm64 go build -o bin/server-darwin-arm64 ./cmd/server + GOOS=windows GOARCH=amd64 go build -o bin/server-windows-amd64.exe ./cmd/server diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go new file mode 100644 index 0000000..99074c6 --- /dev/null +++ b/backend/cmd/server/main.go @@ -0,0 +1,89 @@ +package main + +import ( + "context" + "flag" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/gin-contrib/cors" + "github.com/gin-gonic/gin" + + "ollama-webui-backend/internal/api" + "ollama-webui-backend/internal/database" +) + +func main() { + var ( + port = flag.String("port", "8080", "Server port") + dbPath = flag.String("db", "./data/ollama-webui.db", "Database file path") + ollamaURL = flag.String("ollama-url", "http://localhost:11434", "Ollama API URL") + ) + flag.Parse() + + // Initialize database + db, err := database.OpenDatabase(*dbPath) + if err != nil { + log.Fatalf("Failed to open database: %v", err) + } + defer db.Close() + + // Run migrations + if err := database.RunMigrations(db); err != nil { + log.Fatalf("Failed to run migrations: %v", err) + } + + // Setup Gin router + gin.SetMode(gin.ReleaseMode) + r := gin.New() + r.Use(gin.Logger()) + r.Use(gin.Recovery()) + + // CORS configuration + r.Use(cors.New(cors.Config{ + AllowOrigins: []string{"*"}, + AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}, + AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Authorization"}, + ExposeHeaders: []string{"Content-Length"}, + AllowCredentials: true, + MaxAge: 12 * time.Hour, + })) + + // Register routes + api.SetupRoutes(r, db, *ollamaURL) + + // Create server + srv := &http.Server{ + Addr: ":" + *port, + Handler: r, + } + + // Graceful shutdown handling + go func() { + log.Printf("Server starting on port %s", *port) + log.Printf("Ollama URL: %s", *ollamaURL) + log.Printf("Database: %s", *dbPath) + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("Failed to start server: %v", err) + } + }() + + // Wait for interrupt signal + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + log.Println("Shutting down server...") + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err := srv.Shutdown(ctx); err != nil { + log.Fatalf("Server forced to shutdown: %v", err) + } + + log.Println("Server exited") +} diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 0000000..97f8faa --- /dev/null +++ b/backend/go.mod @@ -0,0 +1,50 @@ +module ollama-webui-backend + +go 1.23 + +require ( + github.com/gin-contrib/cors v1.7.2 + github.com/gin-gonic/gin v1.10.0 + github.com/google/uuid v1.6.0 + modernc.org/sqlite v1.34.4 +) + +require ( + github.com/bytedance/sonic v1.11.6 // indirect + github.com/bytedance/sonic/loader v0.1.1 // indirect + github.com/cloudwego/base64x v0.1.4 // indirect + github.com/cloudwego/iasm v0.2.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/gabriel-vasile/mimetype v1.4.3 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.20.0 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.2.7 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.12 // indirect + golang.org/x/arch v0.8.0 // indirect + golang.org/x/crypto v0.23.0 // indirect + golang.org/x/net v0.25.0 // indirect + golang.org/x/sys v0.22.0 // indirect + golang.org/x/text v0.15.0 // indirect + google.golang.org/protobuf v1.34.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect + modernc.org/libc v1.55.3 // indirect + modernc.org/mathutil v1.6.0 // indirect + modernc.org/memory v1.8.0 // indirect + modernc.org/strutil v1.2.0 // indirect + modernc.org/token v1.1.0 // indirect +) diff --git a/backend/go.sum b/backend/go.sum new file mode 100644 index 0000000..be2d0af --- /dev/null +++ b/backend/go.sum @@ -0,0 +1,141 @@ +github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= +github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= +github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= +github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= +github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= +github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= +github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= +github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/gin-contrib/cors v1.7.2 h1:oLDHxdg8W/XDoN/8zamqk/Drgt4oVZDvaV0YmvVICQw= +github.com/gin-contrib/cors v1.7.2/go.mod h1:SUJVARKgQ40dmrzgXEVxj2m7Ig1v1qIboQkPDTQ9t2E= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= +github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8= +github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= +github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= +github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8= +github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= +github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= +golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= +golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic= +golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= +golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw= +golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= +google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ= +modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= +modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y= +modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s= +modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= +modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= +modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw= +modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4= +modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U= +modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w= +modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= +modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= +modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= +modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= +modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= +modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= +modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc= +modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss= +modernc.org/sqlite v1.34.4 h1:sjdARozcL5KJBvYQvLlZEmctRgW9xqIZc2ncN7PU0P8= +modernc.org/sqlite v1.34.4/go.mod h1:3QQFCG2SEMtc2nv+Wq4cQCH7Hjcg+p/RMlS1XK+zwbk= +modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= +modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= +nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/backend/internal/api/chats.go b/backend/internal/api/chats.go new file mode 100644 index 0000000..adc8b8a --- /dev/null +++ b/backend/internal/api/chats.go @@ -0,0 +1,207 @@ +package api + +import ( + "database/sql" + "net/http" + + "github.com/gin-gonic/gin" + + "ollama-webui-backend/internal/models" +) + +// ListChatsHandler returns a handler for listing all chats +func ListChatsHandler(db *sql.DB) gin.HandlerFunc { + return func(c *gin.Context) { + includeArchived := c.Query("include_archived") == "true" + + chats, err := models.ListChats(db, includeArchived) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if chats == nil { + chats = []models.Chat{} + } + + c.JSON(http.StatusOK, gin.H{"chats": chats}) + } +} + +// GetChatHandler returns a handler for getting a single chat +func GetChatHandler(db *sql.DB) gin.HandlerFunc { + return func(c *gin.Context) { + id := c.Param("id") + + chat, err := models.GetChat(db, id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if chat == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "chat not found"}) + return + } + + c.JSON(http.StatusOK, chat) + } +} + +// CreateChatRequest represents the request body for creating a chat +type CreateChatRequest struct { + Title string `json:"title"` + Model string `json:"model"` +} + +// CreateChatHandler returns a handler for creating a new chat +func CreateChatHandler(db *sql.DB) gin.HandlerFunc { + return func(c *gin.Context) { + var req CreateChatRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) + return + } + + chat := &models.Chat{ + Title: req.Title, + Model: req.Model, + } + + if chat.Title == "" { + chat.Title = "New Chat" + } + + if err := models.CreateChat(db, chat); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusCreated, chat) + } +} + +// UpdateChatRequest represents the request body for updating a chat +type UpdateChatRequest struct { + Title *string `json:"title,omitempty"` + Model *string `json:"model,omitempty"` + Pinned *bool `json:"pinned,omitempty"` + Archived *bool `json:"archived,omitempty"` +} + +// UpdateChatHandler returns a handler for updating a chat +func UpdateChatHandler(db *sql.DB) gin.HandlerFunc { + return func(c *gin.Context) { + id := c.Param("id") + + // Get existing chat + chat, err := models.GetChat(db, id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if chat == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "chat not found"}) + return + } + + // Parse update request + var req UpdateChatRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) + return + } + + // Apply updates + if req.Title != nil { + chat.Title = *req.Title + } + if req.Model != nil { + chat.Model = *req.Model + } + if req.Pinned != nil { + chat.Pinned = *req.Pinned + } + if req.Archived != nil { + chat.Archived = *req.Archived + } + + if err := models.UpdateChat(db, chat); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, chat) + } +} + +// DeleteChatHandler returns a handler for deleting a chat +func DeleteChatHandler(db *sql.DB) gin.HandlerFunc { + return func(c *gin.Context) { + id := c.Param("id") + + if err := models.DeleteChat(db, id); err != nil { + if err.Error() == "chat not found" { + c.JSON(http.StatusNotFound, gin.H{"error": "chat not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "chat deleted"}) + } +} + +// CreateMessageRequest represents the request body for creating a message +type CreateMessageRequest struct { + ParentID *string `json:"parent_id,omitempty"` + Role string `json:"role" binding:"required"` + Content string `json:"content" binding:"required"` + SiblingIndex int `json:"sibling_index"` +} + +// CreateMessageHandler returns a handler for creating a new message +func CreateMessageHandler(db *sql.DB) gin.HandlerFunc { + return func(c *gin.Context) { + chatID := c.Param("id") + + // Verify chat exists + chat, err := models.GetChat(db, chatID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if chat == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "chat not found"}) + return + } + + var req CreateMessageRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) + return + } + + // Validate role + if req.Role != "user" && req.Role != "assistant" && req.Role != "system" { + c.JSON(http.StatusBadRequest, gin.H{"error": "role must be 'user', 'assistant', or 'system'"}) + return + } + + msg := &models.Message{ + ChatID: chatID, + ParentID: req.ParentID, + Role: req.Role, + Content: req.Content, + SiblingIndex: req.SiblingIndex, + } + + if err := models.CreateMessage(db, msg); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusCreated, msg) + } +} diff --git a/backend/internal/api/ollama.go b/backend/internal/api/ollama.go new file mode 100644 index 0000000..65b75c0 --- /dev/null +++ b/backend/internal/api/ollama.go @@ -0,0 +1,51 @@ +package api + +import ( + "io" + "net/http" + "strings" + + "github.com/gin-gonic/gin" +) + +// OllamaProxyHandler returns a handler that proxies requests to Ollama +func OllamaProxyHandler(ollamaURL string) gin.HandlerFunc { + return func(c *gin.Context) { + path := c.Param("path") + targetURL := strings.TrimSuffix(ollamaURL, "/") + path + + // Create proxy request + req, err := http.NewRequestWithContext(c.Request.Context(), c.Request.Method, targetURL, c.Request.Body) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create proxy request"}) + return + } + + // Copy headers + for key, values := range c.Request.Header { + for _, value := range values { + req.Header.Add(key, value) + } + } + + // Execute request + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": "failed to reach Ollama: " + err.Error()}) + return + } + defer resp.Body.Close() + + // Copy response headers + for key, values := range resp.Header { + for _, value := range values { + c.Header(key, value) + } + } + + // Stream response body + c.Status(resp.StatusCode) + io.Copy(c.Writer, resp.Body) + } +} diff --git a/backend/internal/api/proxy.go b/backend/internal/api/proxy.go new file mode 100644 index 0000000..2a4ae57 --- /dev/null +++ b/backend/internal/api/proxy.go @@ -0,0 +1,92 @@ +package api + +import ( + "io" + "net/http" + "net/url" + "time" + + "github.com/gin-gonic/gin" +) + +// URLFetchRequest represents a request to fetch a URL +type URLFetchRequest struct { + URL string `json:"url" binding:"required"` + MaxLength int `json:"maxLength"` +} + +// URLFetchProxyHandler returns a handler that fetches URLs for the frontend +// This bypasses CORS restrictions for the fetch_url tool +func URLFetchProxyHandler() gin.HandlerFunc { + return func(c *gin.Context) { + var req URLFetchRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request: " + err.Error()}) + return + } + + // Validate URL + parsedURL, err := url.Parse(req.URL) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid URL: " + err.Error()}) + return + } + + // Only allow HTTP/HTTPS + if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { + c.JSON(http.StatusBadRequest, gin.H{"error": "only HTTP and HTTPS URLs are supported"}) + return + } + + // Create HTTP client with timeout + client := &http.Client{ + Timeout: 15 * time.Second, + } + + // Create request + httpReq, err := http.NewRequestWithContext(c.Request.Context(), "GET", req.URL, nil) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create request: " + err.Error()}) + return + } + + // Set user agent + httpReq.Header.Set("User-Agent", "OllamaWebUI/1.0 (URL Fetch Proxy)") + httpReq.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") + + // Execute request + resp, err := client.Do(httpReq) + if err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": "failed to fetch URL: " + err.Error()}) + return + } + defer resp.Body.Close() + + // Check status + if resp.StatusCode >= 400 { + c.JSON(http.StatusBadGateway, gin.H{"error": "HTTP " + resp.Status}) + return + } + + // Set max length (default 500KB) + maxLen := req.MaxLength + if maxLen <= 0 || maxLen > 500000 { + maxLen = 500000 + } + + // Read response body with limit + body, err := io.ReadAll(io.LimitReader(resp.Body, int64(maxLen))) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read response: " + err.Error()}) + return + } + + // Return the content + c.JSON(http.StatusOK, gin.H{ + "content": string(body), + "contentType": resp.Header.Get("Content-Type"), + "url": resp.Request.URL.String(), // Final URL after redirects + "status": resp.StatusCode, + }) + } +} diff --git a/backend/internal/api/routes.go b/backend/internal/api/routes.go new file mode 100644 index 0000000..26a8e4a --- /dev/null +++ b/backend/internal/api/routes.go @@ -0,0 +1,45 @@ +package api + +import ( + "database/sql" + + "github.com/gin-gonic/gin" +) + +// SetupRoutes configures all API routes +func SetupRoutes(r *gin.Engine, db *sql.DB, ollamaURL string) { + // Health check + r.GET("/health", func(c *gin.Context) { + c.JSON(200, gin.H{"status": "ok"}) + }) + + // API v1 routes + v1 := r.Group("/api/v1") + { + // Chat routes + chats := v1.Group("/chats") + { + chats.GET("", ListChatsHandler(db)) + chats.POST("", CreateChatHandler(db)) + chats.GET("/:id", GetChatHandler(db)) + chats.PUT("/:id", UpdateChatHandler(db)) + chats.DELETE("/:id", DeleteChatHandler(db)) + + // Message routes (nested under chats) + chats.POST("/:id/messages", CreateMessageHandler(db)) + } + + // Sync routes + sync := v1.Group("/sync") + { + sync.POST("/push", PushChangesHandler(db)) + sync.GET("/pull", PullChangesHandler(db)) + } + + // URL fetch proxy (for tools that need to fetch external URLs) + v1.POST("/proxy/fetch", URLFetchProxyHandler()) + + // Ollama proxy (optional) + v1.Any("/ollama/*path", OllamaProxyHandler(ollamaURL)) + } +} diff --git a/backend/internal/api/sync.go b/backend/internal/api/sync.go new file mode 100644 index 0000000..6c5f7a0 --- /dev/null +++ b/backend/internal/api/sync.go @@ -0,0 +1,150 @@ +package api + +import ( + "database/sql" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + + "ollama-webui-backend/internal/models" +) + +// PushChangesRequest represents the request body for pushing changes +type PushChangesRequest struct { + Chats []models.Chat `json:"chats"` + Messages []models.Message `json:"messages"` +} + +// PushChangesHandler returns a handler for pushing changes from client +func PushChangesHandler(db *sql.DB) gin.HandlerFunc { + return func(c *gin.Context) { + var req PushChangesRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) + return + } + + tx, err := db.Begin() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to start transaction"}) + return + } + defer tx.Rollback() + + // Process chats + for _, chat := range req.Chats { + // Check if chat exists + var existingVersion int64 + err := tx.QueryRow("SELECT sync_version FROM chats WHERE id = ?", chat.ID).Scan(&existingVersion) + + if err == sql.ErrNoRows { + // Insert new chat + _, err = tx.Exec(` + INSERT INTO chats (id, title, model, pinned, archived, created_at, updated_at, sync_version) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + chat.ID, chat.Title, chat.Model, chat.Pinned, chat.Archived, + chat.CreatedAt, chat.UpdatedAt, chat.SyncVersion, + ) + } else if err == nil && chat.SyncVersion > existingVersion { + // Update existing chat if incoming version is higher + _, err = tx.Exec(` + UPDATE chats SET title = ?, model = ?, pinned = ?, archived = ?, + updated_at = ?, sync_version = ? + WHERE id = ?`, + chat.Title, chat.Model, chat.Pinned, chat.Archived, + chat.UpdatedAt, chat.SyncVersion, chat.ID, + ) + } + + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to sync chat: " + err.Error()}) + return + } + } + + // Process messages + for _, msg := range req.Messages { + // Check if message exists + var existingVersion int64 + err := tx.QueryRow("SELECT sync_version FROM messages WHERE id = ?", msg.ID).Scan(&existingVersion) + + if err == sql.ErrNoRows { + // Insert new message + _, err = tx.Exec(` + INSERT INTO messages (id, chat_id, parent_id, role, content, sibling_index, created_at, sync_version) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + msg.ID, msg.ChatID, msg.ParentID, msg.Role, msg.Content, + msg.SiblingIndex, msg.CreatedAt, msg.SyncVersion, + ) + } else if err == nil && msg.SyncVersion > existingVersion { + // Update existing message if incoming version is higher + _, err = tx.Exec(` + UPDATE messages SET content = ?, sibling_index = ?, sync_version = ? + WHERE id = ?`, + msg.Content, msg.SiblingIndex, msg.SyncVersion, msg.ID, + ) + } + + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to sync message: " + err.Error()}) + return + } + } + + if err := tx.Commit(); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to commit transaction"}) + return + } + + // Get current max sync version + maxVersion, err := models.GetMaxSyncVersion(db) + if err != nil { + maxVersion = 0 + } + + c.JSON(http.StatusOK, gin.H{ + "message": "changes pushed successfully", + "sync_version": maxVersion, + }) + } +} + +// PullChangesHandler returns a handler for pulling changes from server +func PullChangesHandler(db *sql.DB) gin.HandlerFunc { + return func(c *gin.Context) { + sinceVersionStr := c.Query("since_version") + var sinceVersion int64 = 0 + + if sinceVersionStr != "" { + var err error + sinceVersion, err = strconv.ParseInt(sinceVersionStr, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid since_version parameter"}) + return + } + } + + // Get changed chats + chats, err := models.GetChangedChats(db, sinceVersion) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if chats == nil { + chats = []models.Chat{} + } + + // Get current max sync version + maxVersion, err := models.GetMaxSyncVersion(db) + if err != nil { + maxVersion = 0 + } + + c.JSON(http.StatusOK, gin.H{ + "chats": chats, + "sync_version": maxVersion, + }) + } +} diff --git a/backend/internal/database/migrations.go b/backend/internal/database/migrations.go new file mode 100644 index 0000000..b76fb2e --- /dev/null +++ b/backend/internal/database/migrations.go @@ -0,0 +1,61 @@ +package database + +import ( + "database/sql" + "fmt" +) + +const migrationsSQL = ` +-- Chats table +CREATE TABLE IF NOT EXISTS chats ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL DEFAULT 'New Chat', + model TEXT NOT NULL DEFAULT '', + pinned INTEGER NOT NULL DEFAULT 0, + archived INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + sync_version INTEGER NOT NULL DEFAULT 1 +); + +-- Messages table +CREATE TABLE IF NOT EXISTS messages ( + id TEXT PRIMARY KEY, + chat_id TEXT NOT NULL, + parent_id TEXT, + role TEXT NOT NULL CHECK (role IN ('user', 'assistant', 'system')), + content TEXT NOT NULL, + sibling_index INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + sync_version INTEGER NOT NULL DEFAULT 1, + FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE, + FOREIGN KEY (parent_id) REFERENCES messages(id) ON DELETE SET NULL +); + +-- Attachments table +CREATE TABLE IF NOT EXISTS attachments ( + id TEXT PRIMARY KEY, + message_id TEXT NOT NULL, + mime_type TEXT NOT NULL, + data BLOB NOT NULL, + filename TEXT NOT NULL DEFAULT '', + FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE +); + +-- Indexes for better query performance +CREATE INDEX IF NOT EXISTS idx_messages_chat_id ON messages(chat_id); +CREATE INDEX IF NOT EXISTS idx_messages_parent_id ON messages(parent_id); +CREATE INDEX IF NOT EXISTS idx_attachments_message_id ON attachments(message_id); +CREATE INDEX IF NOT EXISTS idx_chats_updated_at ON chats(updated_at DESC); +CREATE INDEX IF NOT EXISTS idx_chats_sync_version ON chats(sync_version); +CREATE INDEX IF NOT EXISTS idx_messages_sync_version ON messages(sync_version); +` + +// RunMigrations executes all database migrations +func RunMigrations(db *sql.DB) error { + _, err := db.Exec(migrationsSQL) + if err != nil { + return fmt.Errorf("failed to run migrations: %w", err) + } + return nil +} diff --git a/backend/internal/database/sqlite.go b/backend/internal/database/sqlite.go new file mode 100644 index 0000000..e4e87d9 --- /dev/null +++ b/backend/internal/database/sqlite.go @@ -0,0 +1,38 @@ +package database + +import ( + "database/sql" + "fmt" + "os" + "path/filepath" + + _ "modernc.org/sqlite" +) + +// OpenDatabase opens a SQLite database connection with WAL mode enabled +func OpenDatabase(path string) (*sql.DB, error) { + // Ensure directory exists + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0755); err != nil { + return nil, fmt.Errorf("failed to create database directory: %w", err) + } + + // Open database with connection parameters + dsn := fmt.Sprintf("%s?_journal_mode=WAL&_busy_timeout=5000&_synchronous=NORMAL&_cache_size=10000&_foreign_keys=ON", path) + db, err := sql.Open("sqlite", dsn) + if err != nil { + return nil, fmt.Errorf("failed to open database: %w", err) + } + + // Configure connection pool + db.SetMaxOpenConns(25) + db.SetMaxIdleConns(5) + + // Verify connection + if err := db.Ping(); err != nil { + db.Close() + return nil, fmt.Errorf("failed to ping database: %w", err) + } + + return db, nil +} diff --git a/backend/internal/models/chat.go b/backend/internal/models/chat.go new file mode 100644 index 0000000..c9c1ae7 --- /dev/null +++ b/backend/internal/models/chat.go @@ -0,0 +1,286 @@ +package models + +import ( + "database/sql" + "fmt" + "time" + + "github.com/google/uuid" +) + +// Chat represents a chat conversation +type Chat struct { + ID string `json:"id"` + Title string `json:"title"` + Model string `json:"model"` + Pinned bool `json:"pinned"` + Archived bool `json:"archived"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + SyncVersion int64 `json:"sync_version"` + Messages []Message `json:"messages,omitempty"` +} + +// Message represents a chat message +type Message struct { + ID string `json:"id"` + ChatID string `json:"chat_id"` + ParentID *string `json:"parent_id,omitempty"` + Role string `json:"role"` + Content string `json:"content"` + SiblingIndex int `json:"sibling_index"` + CreatedAt time.Time `json:"created_at"` + SyncVersion int64 `json:"sync_version"` + Attachments []Attachment `json:"attachments,omitempty"` +} + +// Attachment represents a file attached to a message +type Attachment struct { + ID string `json:"id"` + MessageID string `json:"message_id"` + MimeType string `json:"mime_type"` + Data []byte `json:"data,omitempty"` + Filename string `json:"filename"` +} + +// CreateChat creates a new chat in the database +func CreateChat(db *sql.DB, chat *Chat) error { + if chat.ID == "" { + chat.ID = uuid.New().String() + } + now := time.Now().UTC() + chat.CreatedAt = now + chat.UpdatedAt = now + chat.SyncVersion = 1 + + _, err := db.Exec(` + INSERT INTO chats (id, title, model, pinned, archived, created_at, updated_at, sync_version) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + chat.ID, chat.Title, chat.Model, chat.Pinned, chat.Archived, + chat.CreatedAt.Format(time.RFC3339), chat.UpdatedAt.Format(time.RFC3339), chat.SyncVersion, + ) + if err != nil { + return fmt.Errorf("failed to create chat: %w", err) + } + return nil +} + +// GetChat retrieves a chat by ID with its messages +func GetChat(db *sql.DB, id string) (*Chat, error) { + chat := &Chat{} + var createdAt, updatedAt string + var pinned, archived int + + err := db.QueryRow(` + SELECT id, title, model, pinned, archived, created_at, updated_at, sync_version + FROM chats WHERE id = ?`, id).Scan( + &chat.ID, &chat.Title, &chat.Model, &pinned, &archived, + &createdAt, &updatedAt, &chat.SyncVersion, + ) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("failed to get chat: %w", err) + } + + chat.Pinned = pinned == 1 + chat.Archived = archived == 1 + chat.CreatedAt, _ = time.Parse(time.RFC3339, createdAt) + chat.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt) + + // Get messages + messages, err := GetMessagesByChatID(db, id) + if err != nil { + return nil, err + } + chat.Messages = messages + + return chat, nil +} + +// ListChats retrieves all chats ordered by updated_at +func ListChats(db *sql.DB, includeArchived bool) ([]Chat, error) { + query := ` + SELECT id, title, model, pinned, archived, created_at, updated_at, sync_version + FROM chats` + if !includeArchived { + query += " WHERE archived = 0" + } + query += " ORDER BY pinned DESC, updated_at DESC" + + rows, err := db.Query(query) + if err != nil { + return nil, fmt.Errorf("failed to list chats: %w", err) + } + defer rows.Close() + + var chats []Chat + for rows.Next() { + var chat Chat + var createdAt, updatedAt string + var pinned, archived int + + if err := rows.Scan(&chat.ID, &chat.Title, &chat.Model, &pinned, &archived, + &createdAt, &updatedAt, &chat.SyncVersion); err != nil { + return nil, fmt.Errorf("failed to scan chat: %w", err) + } + + chat.Pinned = pinned == 1 + chat.Archived = archived == 1 + chat.CreatedAt, _ = time.Parse(time.RFC3339, createdAt) + chat.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt) + chats = append(chats, chat) + } + + return chats, nil +} + +// UpdateChat updates an existing chat +func UpdateChat(db *sql.DB, chat *Chat) error { + chat.UpdatedAt = time.Now().UTC() + chat.SyncVersion++ + + result, err := db.Exec(` + UPDATE chats SET title = ?, model = ?, pinned = ?, archived = ?, + updated_at = ?, sync_version = ? + WHERE id = ?`, + chat.Title, chat.Model, chat.Pinned, chat.Archived, + chat.UpdatedAt.Format(time.RFC3339), chat.SyncVersion, chat.ID, + ) + if err != nil { + return fmt.Errorf("failed to update chat: %w", err) + } + + rowsAffected, _ := result.RowsAffected() + if rowsAffected == 0 { + return fmt.Errorf("chat not found") + } + + return nil +} + +// DeleteChat deletes a chat and its associated messages +func DeleteChat(db *sql.DB, id string) error { + result, err := db.Exec("DELETE FROM chats WHERE id = ?", id) + if err != nil { + return fmt.Errorf("failed to delete chat: %w", err) + } + + rowsAffected, _ := result.RowsAffected() + if rowsAffected == 0 { + return fmt.Errorf("chat not found") + } + + return nil +} + +// CreateMessage creates a new message in the database +func CreateMessage(db *sql.DB, msg *Message) error { + if msg.ID == "" { + msg.ID = uuid.New().String() + } + msg.CreatedAt = time.Now().UTC() + msg.SyncVersion = 1 + + _, err := db.Exec(` + INSERT INTO messages (id, chat_id, parent_id, role, content, sibling_index, created_at, sync_version) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + msg.ID, msg.ChatID, msg.ParentID, msg.Role, msg.Content, + msg.SiblingIndex, msg.CreatedAt.Format(time.RFC3339), msg.SyncVersion, + ) + if err != nil { + return fmt.Errorf("failed to create message: %w", err) + } + + // Update chat's updated_at timestamp + db.Exec("UPDATE chats SET updated_at = ?, sync_version = sync_version + 1 WHERE id = ?", + time.Now().UTC().Format(time.RFC3339), msg.ChatID) + + return nil +} + +// GetMessagesByChatID retrieves all messages for a chat +func GetMessagesByChatID(db *sql.DB, chatID string) ([]Message, error) { + rows, err := db.Query(` + SELECT id, chat_id, parent_id, role, content, sibling_index, created_at, sync_version + FROM messages WHERE chat_id = ? ORDER BY created_at ASC`, chatID) + if err != nil { + return nil, fmt.Errorf("failed to get messages: %w", err) + } + defer rows.Close() + + var messages []Message + for rows.Next() { + var msg Message + var createdAt string + var parentID sql.NullString + + if err := rows.Scan(&msg.ID, &msg.ChatID, &parentID, &msg.Role, + &msg.Content, &msg.SiblingIndex, &createdAt, &msg.SyncVersion); err != nil { + return nil, fmt.Errorf("failed to scan message: %w", err) + } + + if parentID.Valid { + msg.ParentID = &parentID.String + } + msg.CreatedAt, _ = time.Parse(time.RFC3339, createdAt) + messages = append(messages, msg) + } + + return messages, nil +} + +// GetChangedChats retrieves chats changed since a given sync version +func GetChangedChats(db *sql.DB, sinceVersion int64) ([]Chat, error) { + rows, err := db.Query(` + SELECT id, title, model, pinned, archived, created_at, updated_at, sync_version + FROM chats WHERE sync_version > ? ORDER BY sync_version ASC`, sinceVersion) + if err != nil { + return nil, fmt.Errorf("failed to get changed chats: %w", err) + } + defer rows.Close() + + var chats []Chat + for rows.Next() { + var chat Chat + var createdAt, updatedAt string + var pinned, archived int + + if err := rows.Scan(&chat.ID, &chat.Title, &chat.Model, &pinned, &archived, + &createdAt, &updatedAt, &chat.SyncVersion); err != nil { + return nil, fmt.Errorf("failed to scan chat: %w", err) + } + + chat.Pinned = pinned == 1 + chat.Archived = archived == 1 + chat.CreatedAt, _ = time.Parse(time.RFC3339, createdAt) + chat.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt) + + // Get messages for this chat + messages, err := GetMessagesByChatID(db, chat.ID) + if err != nil { + return nil, err + } + chat.Messages = messages + + chats = append(chats, chat) + } + + return chats, nil +} + +// GetMaxSyncVersion returns the maximum sync version across all tables +func GetMaxSyncVersion(db *sql.DB) (int64, error) { + var maxVersion int64 + err := db.QueryRow(` + SELECT MAX(sync_version) FROM ( + SELECT MAX(sync_version) as sync_version FROM chats + UNION ALL + SELECT MAX(sync_version) FROM messages + )`).Scan(&maxVersion) + if err != nil { + return 0, fmt.Errorf("failed to get max sync version: %w", err) + } + return maxVersion, nil +} diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..1e735f8 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,27 @@ +# Development docker-compose - uses host network for direct Ollama access +services: + frontend: + build: + context: ./frontend + dockerfile: Dockerfile.dev + # Use host network to access localhost:11434 and backend directly + network_mode: host + volumes: + - ./frontend:/app + - /app/node_modules + environment: + - OLLAMA_API_URL=http://localhost:11434 + - BACKEND_URL=http://localhost:9090 + depends_on: + - backend + + backend: + build: + context: ./backend + dockerfile: Dockerfile + network_mode: host + volumes: + - ./backend/data:/app/data + environment: + - GIN_MODE=release + command: ["./server", "-port", "9090", "-db", "/app/data/ollama-webui.db", "-ollama-url", "http://localhost:11434"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f55b28b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,62 @@ +services: + # Ollama WebUI Frontend + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + ports: + - "7842:3000" + environment: + - OLLAMA_API_URL=http://ollama:11434 + - BACKEND_URL=http://backend:9090 + depends_on: + - ollama + - backend + networks: + - ollama-network + restart: unless-stopped + + # Go Backend API + backend: + build: + context: ./backend + dockerfile: Dockerfile + ports: + - "9090:9090" + environment: + - OLLAMA_URL=http://ollama:11434 + - PORT=9090 + volumes: + - backend-data:/app/data + depends_on: + - ollama + networks: + - ollama-network + restart: unless-stopped + + # Ollama LLM Server + ollama: + image: ollama/ollama:latest + ports: + - "11434:11434" + volumes: + - ollama-data:/root/.ollama + networks: + - ollama-network + restart: unless-stopped + # Uncomment for GPU support (NVIDIA) + # deploy: + # resources: + # reservations: + # devices: + # - driver: nvidia + # count: all + # capabilities: [gpu] + +networks: + ollama-network: + driver: bridge + +volumes: + ollama-data: + backend-data: diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..b40b77f --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,12 @@ +node_modules +.svelte-kit +build +.env +.env.* +!.env.example +*.log +.DS_Store +.git +.gitignore +Dockerfile +docker-compose.yml diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..7885081 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,37 @@ +# Build stage +FROM node:22-alpine AS builder + +WORKDIR /app + +# Copy package files +COPY package*.json ./ + +# Install dependencies +RUN npm ci + +# Copy source code +COPY . . + +# Build the application +RUN npm run build + +# Production stage +FROM node:22-alpine AS production + +WORKDIR /app + +# Copy built application +COPY --from=builder /app/build ./build +COPY --from=builder /app/package*.json ./ +COPY --from=builder /app/node_modules ./node_modules + +# Expose port +EXPOSE 3000 + +# Set environment variables +ENV NODE_ENV=production +ENV PORT=3000 +ENV HOST=0.0.0.0 + +# Start the application +CMD ["node", "build"] diff --git a/frontend/Dockerfile.dev b/frontend/Dockerfile.dev new file mode 100644 index 0000000..a8985f7 --- /dev/null +++ b/frontend/Dockerfile.dev @@ -0,0 +1,16 @@ +# Development Dockerfile with hot reload +FROM node:22-alpine + +WORKDIR /app + +# Copy package files +COPY package*.json ./ + +# Install dependencies +RUN npm install + +# Expose dev server port +EXPOSE 7842 + +# Start dev server with host binding +CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"] diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..b6431c6 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,3270 @@ +{ + "name": "ollama-webui", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ollama-webui", + "version": "0.1.0", + "dependencies": { + "@skeletonlabs/skeleton": "^2.10.0", + "@skeletonlabs/tw-plugin": "^0.4.0", + "@sveltejs/adapter-node": "^5.4.0", + "@types/dompurify": "^3.0.5", + "dexie": "^4.0.10", + "dompurify": "^3.2.0", + "marked": "^15.0.0", + "shiki": "^1.26.0" + }, + "devDependencies": { + "@sveltejs/adapter-auto": "^4.0.0", + "@sveltejs/kit": "^2.16.0", + "@sveltejs/vite-plugin-svelte": "^5.0.0", + "@tailwindcss/typography": "^0.5.16", + "@types/node": "^22.10.0", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.49", + "svelte": "^5.16.0", + "svelte-check": "^4.1.0", + "tailwindcss": "^3.4.17", + "typescript": "^5.7.0", + "vite": "^6.0.0" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "license": "MIT" + }, + "node_modules/@rollup/plugin-commonjs": { + "version": "28.0.9", + "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-28.0.9.tgz", + "integrity": "sha512-PIR4/OHZ79romx0BVVll/PkwWpJ7e5lsqFa3gFfcrFPWwLXLV39JVUzQV9RKjWerE7B845Hqjj9VYlQeieZ2dA==", + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "commondir": "^1.0.1", + "estree-walker": "^2.0.2", + "fdir": "^6.2.0", + "is-reference": "1.2.1", + "magic-string": "^0.30.3", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=16.0.0 || 14 >= 14.17" + }, + "peerDependencies": { + "rollup": "^2.68.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-commonjs/node_modules/is-reference": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", + "integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@rollup/plugin-json": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-6.1.0.tgz", + "integrity": "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA==", + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.1.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-node-resolve": { + "version": "16.0.3", + "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.3.tgz", + "integrity": "sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==", + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "@types/resolve": "1.20.2", + "deepmerge": "^4.2.2", + "is-module": "^1.0.0", + "resolve": "^1.22.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^2.78.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", + "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.54.0.tgz", + "integrity": "sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.54.0.tgz", + "integrity": "sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.54.0.tgz", + "integrity": "sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.54.0.tgz", + "integrity": "sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.54.0.tgz", + "integrity": "sha512-miSvuFkmvFbgJ1BevMa4CPCFt5MPGw094knM64W9I0giUIMMmRYcGW/JWZDriaw/k1kOBtsWh1z6nIFV1vPNtA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.54.0.tgz", + "integrity": "sha512-KGXIs55+b/ZfZsq9aR026tmr/+7tq6VG6MsnrvF4H8VhwflTIuYh+LFUlIsRdQSgrgmtM3fVATzEAj4hBQlaqQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.54.0.tgz", + "integrity": "sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.54.0.tgz", + "integrity": "sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.54.0.tgz", + "integrity": "sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.54.0.tgz", + "integrity": "sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.54.0.tgz", + "integrity": "sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.54.0.tgz", + "integrity": "sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.54.0.tgz", + "integrity": "sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.54.0.tgz", + "integrity": "sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.54.0.tgz", + "integrity": "sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.54.0.tgz", + "integrity": "sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.54.0.tgz", + "integrity": "sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.54.0.tgz", + "integrity": "sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.54.0.tgz", + "integrity": "sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.54.0.tgz", + "integrity": "sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.54.0.tgz", + "integrity": "sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.54.0.tgz", + "integrity": "sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@shikijs/core": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-1.29.2.tgz", + "integrity": "sha512-vju0lY9r27jJfOY4Z7+Rt/nIOjzJpZ3y+nYpqtUZInVoXQ/TJZcfGnNOGnKjFdVZb8qexiCuSlZRKcGfhhTTZQ==", + "license": "MIT", + "dependencies": { + "@shikijs/engine-javascript": "1.29.2", + "@shikijs/engine-oniguruma": "1.29.2", + "@shikijs/types": "1.29.2", + "@shikijs/vscode-textmate": "^10.0.1", + "@types/hast": "^3.0.4", + "hast-util-to-html": "^9.0.4" + } + }, + "node_modules/@shikijs/engine-javascript": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-1.29.2.tgz", + "integrity": "sha512-iNEZv4IrLYPv64Q6k7EPpOCE/nuvGiKl7zxdq0WFuRPF5PAE9PRo2JGq/d8crLusM59BRemJ4eOqrFrC4wiQ+A==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "1.29.2", + "@shikijs/vscode-textmate": "^10.0.1", + "oniguruma-to-es": "^2.2.0" + } + }, + "node_modules/@shikijs/engine-oniguruma": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-1.29.2.tgz", + "integrity": "sha512-7iiOx3SG8+g1MnlzZVDYiaeHe7Ez2Kf2HrJzdmGwkRisT7r4rak0e655AcM/tF9JG/kg5fMNYlLLKglbN7gBqA==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "1.29.2", + "@shikijs/vscode-textmate": "^10.0.1" + } + }, + "node_modules/@shikijs/langs": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-1.29.2.tgz", + "integrity": "sha512-FIBA7N3LZ+223U7cJDUYd5shmciFQlYkFXlkKVaHsCPgfVLiO+e12FmQE6Tf9vuyEsFe3dIl8qGWKXgEHL9wmQ==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "1.29.2" + } + }, + "node_modules/@shikijs/themes": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-1.29.2.tgz", + "integrity": "sha512-i9TNZlsq4uoyqSbluIcZkmPL9Bfi3djVxRnofUHwvx/h6SRW3cwgBC5SML7vsDcWyukY0eCzVN980rqP6qNl9g==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "1.29.2" + } + }, + "node_modules/@shikijs/types": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-1.29.2.tgz", + "integrity": "sha512-VJjK0eIijTZf0QSTODEXCqinjBn0joAHQ+aPSBzrv4O2d/QSbsMw+ZeSRx03kV34Hy7NzUvV/7NqfYGRLrASmw==", + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.1", + "@types/hast": "^3.0.4" + } + }, + "node_modules/@shikijs/vscode-textmate": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", + "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", + "license": "MIT" + }, + "node_modules/@skeletonlabs/skeleton": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@skeletonlabs/skeleton/-/skeleton-2.11.0.tgz", + "integrity": "sha512-ORMZYACsIlfKyBx2ZIHBy7zE877t99fxU7LzcY1dveVmn2//+OeqnbQb5RryNILsMR62Tuu1VLnCu01/ByHlbQ==", + "license": "MIT", + "dependencies": { + "esm-env": "1.0.0" + }, + "peerDependencies": { + "svelte": "^3.56.0 || ^4.0.0 || ^5.0.0" + } + }, + "node_modules/@skeletonlabs/tw-plugin": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@skeletonlabs/tw-plugin/-/tw-plugin-0.4.1.tgz", + "integrity": "sha512-crrC8BGKis0GNTp7V2HF6mk1ECLUvAxgTTV26LMgt/rV3U6Xd7N7dL5qIL8fE4MTHvpKa1SBsdqsnMbEvATeEg==", + "license": "MIT", + "peerDependencies": { + "tailwindcss": ">=3.0.0" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.8.tgz", + "integrity": "sha512-esgN+54+q0NjB0Y/4BomT9samII7jGwNy/2a3wNZbT2A2RpmXsXwUt24LvLhx6jUq2gVk4cWEvcRO6MFQbOfNA==", + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/adapter-auto": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-4.0.0.tgz", + "integrity": "sha512-kmuYSQdD2AwThymQF0haQhM8rE5rhutQXG4LNbnbShwhMO4qQGnKaaTy+88DuNSuoQDi58+thpq8XpHc1+oEKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "import-meta-resolve": "^4.1.0" + }, + "peerDependencies": { + "@sveltejs/kit": "^2.0.0" + } + }, + "node_modules/@sveltejs/adapter-node": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-node/-/adapter-node-5.4.0.tgz", + "integrity": "sha512-NMsrwGVPEn+J73zH83Uhss/hYYZN6zT3u31R3IHAn3MiKC3h8fjmIAhLfTSOeNHr5wPYfjjMg8E+1gyFgyrEcQ==", + "license": "MIT", + "dependencies": { + "@rollup/plugin-commonjs": "^28.0.1", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^16.0.0", + "rollup": "^4.9.5" + }, + "peerDependencies": { + "@sveltejs/kit": "^2.4.0" + } + }, + "node_modules/@sveltejs/kit": { + "version": "2.49.2", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.49.2.tgz", + "integrity": "sha512-Vp3zX/qlwerQmHMP6x0Ry1oY7eKKRcOWGc2P59srOp4zcqyn+etJyQpELgOi4+ZSUgteX8Y387NuwruLgGXLUQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@sveltejs/acorn-typescript": "^1.0.5", + "@types/cookie": "^0.6.0", + "acorn": "^8.14.1", + "cookie": "^0.6.0", + "devalue": "^5.3.2", + "esm-env": "^1.2.2", + "kleur": "^4.1.5", + "magic-string": "^0.30.5", + "mrmime": "^2.0.0", + "sade": "^1.8.1", + "set-cookie-parser": "^2.6.0", + "sirv": "^3.0.0" + }, + "bin": { + "svelte-kit": "svelte-kit.js" + }, + "engines": { + "node": ">=18.13" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0", + "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0", + "svelte": "^4.0.0 || ^5.0.0-next.0", + "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + } + } + }, + "node_modules/@sveltejs/kit/node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "license": "MIT" + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-5.1.1.tgz", + "integrity": "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==", + "license": "MIT", + "dependencies": { + "@sveltejs/vite-plugin-svelte-inspector": "^4.0.1", + "debug": "^4.4.1", + "deepmerge": "^4.3.1", + "kleur": "^4.1.5", + "magic-string": "^0.30.17", + "vitefu": "^1.0.6" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22" + }, + "peerDependencies": { + "svelte": "^5.0.0", + "vite": "^6.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte-inspector": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-4.0.1.tgz", + "integrity": "sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.7" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22" + }, + "peerDependencies": { + "@sveltejs/vite-plugin-svelte": "^5.0.0", + "svelte": "^5.0.0", + "vite": "^6.0.0" + } + }, + "node_modules/@tailwindcss/typography": { + "version": "0.5.19", + "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz", + "integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "6.0.10" + }, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" + } + }, + "node_modules/@types/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", + "license": "MIT" + }, + "node_modules/@types/dompurify": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz", + "integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==", + "license": "MIT", + "dependencies": { + "@types/trusted-types": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT" + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/node": { + "version": "22.19.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.3.tgz", + "integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/resolve": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", + "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", + "license": "MIT" + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT" + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "license": "ISC" + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "license": "MIT" + }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/autoprefixer": { + "version": "10.4.23", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.23.tgz", + "integrity": "sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.1", + "caniuse-lite": "^1.0.30001760", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.11", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz", + "integrity": "sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001762", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001762.tgz", + "integrity": "sha512-PxZwGNvH7Ak8WX5iXzoK1KPZttBXNPuaOvI2ZYU7NrlM+d9Ov+TUvlLOBNGzVXAntMSMMlJPd+jY6ovrVjSmUw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/devalue": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.1.tgz", + "integrity": "sha512-jDwizj+IlEZBunHcOuuFVBnIMPAEHvTsJj0BcIp94xYguLRVBcXO853px/MyIJvbVzWdsGvrRweIUWJw8hBP7A==", + "license": "MIT" + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/dexie": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/dexie/-/dexie-4.2.1.tgz", + "integrity": "sha512-Ckej0NS6jxQ4Po3OrSQBFddayRhTCic2DoCAG5zacOfOVB9P2Q5Xc5uL/nVa7ZVs+HdMnvUPzLFCB/JwpB6Csg==", + "license": "Apache-2.0" + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "license": "MIT" + }, + "node_modules/dompurify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz", + "integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.267", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", + "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex-xs": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex-xs/-/emoji-regex-xs-1.0.0.tgz", + "integrity": "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==", + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/esm-env": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.0.0.tgz", + "integrity": "sha512-Cf6VksWPsTuW01vU9Mk/3vRue91Zevka5SjyNf3nEpokFRuqt/KjUQoGAwq9qMmhpLTHmXzSIrFRw8zxWzmFBA==", + "license": "MIT" + }, + "node_modules/esrap": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.1.tgz", + "integrity": "sha512-GiYWG34AN/4CUyaWAgunGt0Rxvr1PTMlGC0vvEov/uOQYWne2bpN03Um+k8jT+q3op33mKouP2zeJ6OlM+qeUg==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hast-util-to-html": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", + "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-whitespace": "^3.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "stringify-entities": "^4.0.0", + "zwitch": "^2.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/import-meta-resolve": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", + "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-module": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", + "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", + "license": "MIT" + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "license": "MIT" + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/marked": { + "version": "15.0.12", + "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz", + "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/oniguruma-to-es": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-2.3.0.tgz", + "integrity": "sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g==", + "license": "MIT", + "dependencies": { + "emoji-regex-xs": "^1.0.0", + "regex": "^5.1.1", + "regex-recursion": "^5.1.1" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-nested/node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.0.10", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", + "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "license": "MIT" + }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/regex": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/regex/-/regex-5.1.1.tgz", + "integrity": "sha512-dN5I359AVGPnwzJm2jN1k0W9LPZ+ePvoOeVMMfqIMFz53sSwXkxaJoxr50ptnsC771lK95BnTrVSZxq0b9yCGw==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-recursion": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-5.1.1.tgz", + "integrity": "sha512-ae7SBCbzVNrIjgSbh7wMznPcQel1DNlDtzensnFxpiNpXt1U2ju/bHugH422r+4LAVS1FpW1YCwilmnNsjum9w==", + "license": "MIT", + "dependencies": { + "regex": "^5.1.1", + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-utilities": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", + "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", + "license": "MIT" + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.54.0.tgz", + "integrity": "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==", + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.54.0", + "@rollup/rollup-android-arm64": "4.54.0", + "@rollup/rollup-darwin-arm64": "4.54.0", + "@rollup/rollup-darwin-x64": "4.54.0", + "@rollup/rollup-freebsd-arm64": "4.54.0", + "@rollup/rollup-freebsd-x64": "4.54.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.54.0", + "@rollup/rollup-linux-arm-musleabihf": "4.54.0", + "@rollup/rollup-linux-arm64-gnu": "4.54.0", + "@rollup/rollup-linux-arm64-musl": "4.54.0", + "@rollup/rollup-linux-loong64-gnu": "4.54.0", + "@rollup/rollup-linux-ppc64-gnu": "4.54.0", + "@rollup/rollup-linux-riscv64-gnu": "4.54.0", + "@rollup/rollup-linux-riscv64-musl": "4.54.0", + "@rollup/rollup-linux-s390x-gnu": "4.54.0", + "@rollup/rollup-linux-x64-gnu": "4.54.0", + "@rollup/rollup-linux-x64-musl": "4.54.0", + "@rollup/rollup-openharmony-arm64": "4.54.0", + "@rollup/rollup-win32-arm64-msvc": "4.54.0", + "@rollup/rollup-win32-ia32-msvc": "4.54.0", + "@rollup/rollup-win32-x64-gnu": "4.54.0", + "@rollup/rollup-win32-x64-msvc": "4.54.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "license": "MIT", + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/shiki": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-1.29.2.tgz", + "integrity": "sha512-njXuliz/cP+67jU2hukkxCNuH1yUi4QfdZZY+sMr5PPrIyXSu5iTb/qYC4BiWWB0vZ+7TbdvYUCeL23zpwCfbg==", + "license": "MIT", + "dependencies": { + "@shikijs/core": "1.29.2", + "@shikijs/engine-javascript": "1.29.2", + "@shikijs/engine-oniguruma": "1.29.2", + "@shikijs/langs": "1.29.2", + "@shikijs/themes": "1.29.2", + "@shikijs/types": "1.29.2", + "@shikijs/vscode-textmate": "^10.0.1", + "@types/hast": "^3.0.4" + } + }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/svelte": { + "version": "5.46.1", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.46.1.tgz", + "integrity": "sha512-ynjfCHD3nP2el70kN5Pmg37sSi0EjOm9FgHYQdC4giWG/hzO3AatzXXJJgP305uIhGQxSufJLuYWtkY8uK/8RA==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.5", + "@types/estree": "^1.0.5", + "acorn": "^8.12.1", + "aria-query": "^5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.5.0", + "esm-env": "^1.2.1", + "esrap": "^2.2.1", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/svelte-check": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.3.5.tgz", + "integrity": "sha512-e4VWZETyXaKGhpkxOXP+B/d0Fp/zKViZoJmneZWe/05Y2aqSKj3YN2nLfYPJBQ87WEiY4BQCQ9hWGu9mPT1a1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "chokidar": "^4.0.1", + "fdir": "^6.2.0", + "picocolors": "^1.0.0", + "sade": "^1.7.4" + }, + "bin": { + "svelte-check": "bin/svelte-check" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": ">=5.0.0" + } + }, + "node_modules/svelte/node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "license": "MIT" + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tailwindcss/node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/tailwindcss/node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/tailwindcss/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tailwindcss/node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/tailwindcss/node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "license": "Apache-2.0" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.0.0.tgz", + "integrity": "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vite": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitefu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.1.tgz", + "integrity": "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ==", + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "license": "MIT" + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..efde376 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,37 @@ +{ + "name": "ollama-webui", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch" + }, + "devDependencies": { + "@sveltejs/adapter-auto": "^4.0.0", + "@sveltejs/kit": "^2.16.0", + "@sveltejs/vite-plugin-svelte": "^5.0.0", + "@tailwindcss/typography": "^0.5.16", + "@types/node": "^22.10.0", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.49", + "svelte": "^5.16.0", + "svelte-check": "^4.1.0", + "tailwindcss": "^3.4.17", + "typescript": "^5.7.0", + "vite": "^6.0.0" + }, + "dependencies": { + "@skeletonlabs/skeleton": "^2.10.0", + "@skeletonlabs/tw-plugin": "^0.4.0", + "@sveltejs/adapter-node": "^5.4.0", + "@types/dompurify": "^3.0.5", + "dexie": "^4.0.10", + "dompurify": "^3.2.0", + "marked": "^15.0.0", + "shiki": "^1.26.0" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..0f77216 --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {} + } +}; diff --git a/frontend/src/app.css b/frontend/src/app.css new file mode 100644 index 0000000..9ab9ed9 --- /dev/null +++ b/frontend/src/app.css @@ -0,0 +1,66 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +/* Base styles */ +html, +body { + @apply h-full; +} + +/* Custom scrollbar */ +::-webkit-scrollbar { + @apply w-2; +} + +::-webkit-scrollbar-track { + @apply bg-surface-800; +} + +::-webkit-scrollbar-thumb { + @apply bg-surface-600 rounded-full; +} + +::-webkit-scrollbar-thumb:hover { + @apply bg-surface-500; +} + +/* Prose customization for chat messages */ +.prose { + @apply max-w-none; +} + +.prose pre { + @apply bg-surface-800 rounded-lg; +} + +.prose code { + @apply text-primary-400; +} + +.prose code::before, +.prose code::after { + content: none; +} + +/* Animation utilities */ +@keyframes bounce-dots { + 0%, 80%, 100% { + transform: translateY(0); + } + 40% { + transform: translateY(-6px); + } +} + +.animate-bounce-dot { + animation: bounce-dots 1.4s infinite ease-in-out both; +} + +.animate-bounce-dot:nth-child(1) { + animation-delay: -0.32s; +} + +.animate-bounce-dot:nth-child(2) { + animation-delay: -0.16s; +} diff --git a/frontend/src/app.d.ts b/frontend/src/app.d.ts new file mode 100644 index 0000000..da08e6d --- /dev/null +++ b/frontend/src/app.d.ts @@ -0,0 +1,13 @@ +// See https://svelte.dev/docs/kit/types#app.d.ts +// for information about these interfaces +declare global { + namespace App { + // interface Error {} + // interface Locals {} + // interface PageData {} + // interface PageState {} + // interface Platform {} + } +} + +export {}; diff --git a/frontend/src/app.html b/frontend/src/app.html new file mode 100644 index 0000000..acb3fd1 --- /dev/null +++ b/frontend/src/app.html @@ -0,0 +1,12 @@ + + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/frontend/src/lib/backend/client.ts b/frontend/src/lib/backend/client.ts new file mode 100644 index 0000000..836dd3b --- /dev/null +++ b/frontend/src/lib/backend/client.ts @@ -0,0 +1,164 @@ +/** + * Backend API client for sync operations + */ + +import type { + BackendChat, + BackendMessage, + PushChangesRequest, + PushChangesResponse, + PullChangesResponse, + ApiResponse +} from './types.js'; + +/** Backend client configuration */ +export interface BackendClientConfig { + baseUrl: string; + timeout?: number; +} + +/** Backend API client class */ +export class BackendClient { + private baseUrl: string; + private timeout: number; + + constructor(config: BackendClientConfig) { + // Remove trailing slash + this.baseUrl = config.baseUrl.replace(/\/$/, ''); + this.timeout = config.timeout ?? 30000; + } + + /** + * Make an HTTP request to the backend + */ + private async request( + method: string, + path: string, + body?: unknown + ): Promise> { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), this.timeout); + + try { + const response = await fetch(`${this.baseUrl}${path}`, { + method, + headers: { + 'Content-Type': 'application/json' + }, + body: body ? JSON.stringify(body) : undefined, + signal: controller.signal + }); + + clearTimeout(timeoutId); + + 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) { + clearTimeout(timeoutId); + + if (err instanceof Error) { + if (err.name === 'AbortError') { + return { error: 'Request timed out' }; + } + return { error: err.message }; + } + return { error: 'Unknown error occurred' }; + } + } + + /** + * Health check - verify backend is reachable + */ + async healthCheck(): Promise { + const result = await this.request<{ status: string }>('GET', '/health'); + return result.data?.status === 'ok'; + } + + /** + * List all chats from backend + */ + async listChats(includeArchived: boolean = false): Promise> { + const query = includeArchived ? '?include_archived=true' : ''; + return this.request('GET', `/api/v1/chats${query}`); + } + + /** + * Get a single chat with messages + */ + async getChat(id: string): Promise> { + return this.request('GET', `/api/v1/chats/${id}`); + } + + /** + * Create a new chat + */ + async createChat(chat: Partial): Promise> { + return this.request('POST', '/api/v1/chats', chat); + } + + /** + * Update an existing chat + */ + async updateChat(id: string, updates: Partial): Promise> { + return this.request('PUT', `/api/v1/chats/${id}`, updates); + } + + /** + * Delete a chat + */ + async deleteChat(id: string): Promise> { + return this.request('DELETE', `/api/v1/chats/${id}`); + } + + /** + * Create a message in a chat + */ + async createMessage( + chatId: string, + message: Partial + ): Promise> { + return this.request('POST', `/api/v1/chats/${chatId}/messages`, message); + } + + /** + * Push local changes to backend + */ + async pushChanges(request: PushChangesRequest): Promise> { + return this.request('POST', '/api/v1/sync/push', request); + } + + /** + * Pull changes from backend since a given sync version + */ + async pullChanges(sinceVersion: number = 0): Promise> { + return this.request( + 'GET', + `/api/v1/sync/pull?since_version=${sinceVersion}` + ); + } +} + +/** + * Get the backend URL from environment or default + */ +function getBackendUrl(): string { + // In browser, check for PUBLIC_ env var (set by Vite/SvelteKit) + if (typeof window !== 'undefined') { + const envUrl = (import.meta.env as Record)?.PUBLIC_BACKEND_URL; + if (envUrl) return envUrl; + } + + // Default: use empty string to go through Vite proxy (/api/v1 -> backend:9090) + return ''; +} + +/** Singleton backend client instance */ +export const backendClient = new BackendClient({ + baseUrl: getBackendUrl(), + timeout: 30000 +}); diff --git a/frontend/src/lib/backend/index.ts b/frontend/src/lib/backend/index.ts new file mode 100644 index 0000000..5d7b6f5 --- /dev/null +++ b/frontend/src/lib/backend/index.ts @@ -0,0 +1,15 @@ +/** + * Backend module exports + */ + +export { backendClient, BackendClient, type BackendClientConfig } from './client.js'; +export { syncManager, syncState, type SyncStatus, type SyncResult } from './sync-manager.svelte.js'; +export type { + BackendChat, + BackendMessage, + BackendAttachment, + PushChangesRequest, + PushChangesResponse, + PullChangesResponse, + ApiResponse +} from './types.js'; diff --git a/frontend/src/lib/backend/sync-manager.svelte.ts b/frontend/src/lib/backend/sync-manager.svelte.ts new file mode 100644 index 0000000..1c59b45 --- /dev/null +++ b/frontend/src/lib/backend/sync-manager.svelte.ts @@ -0,0 +1,399 @@ +/** + * Sync Manager - Coordinates synchronization between IndexedDB and backend + * Implements offline-first pattern with periodic sync + */ + +import { backendClient } from './client.js'; +import type { BackendChat, BackendMessage } from './types.js'; +import { + getPendingSyncItems, + clearSyncItems, + incrementRetryCount, + type SyncEntityType, + type SyncOperation +} from '../storage/sync.js'; +import { db } from '../storage/db.js'; +import type { StoredConversation, StoredMessage } from '../storage/db.js'; + +/** Sync manager configuration */ +export interface SyncManagerConfig { + /** Interval between sync attempts in milliseconds (default: 30 seconds) */ + syncInterval?: number; + /** Whether to enable auto-sync (default: true) */ + autoSync?: boolean; + /** Maximum retries for failed sync items (default: 5) */ + maxRetries?: number; +} + +/** Sync status */ +export type SyncStatus = 'idle' | 'syncing' | 'error' | 'offline'; + +/** Sync result */ +export interface SyncResult { + pushed: number; + pulled: number; + errors: string[]; +} + +/** Sync state using Svelte 5 runes */ +class SyncManagerState { + status = $state('idle'); + lastSyncTime = $state(null); + lastSyncVersion = $state(0); + pendingCount = $state(0); + isOnline = $state(true); + lastError = $state(null); +} + +export const syncState = new SyncManagerState(); + +/** Sync manager singleton */ +class SyncManager { + private config: Required; + private syncIntervalId: ReturnType | null = null; + private isSyncing = false; + + constructor(config: SyncManagerConfig = {}) { + this.config = { + syncInterval: config.syncInterval ?? 30000, + autoSync: config.autoSync ?? true, + maxRetries: config.maxRetries ?? 5 + }; + } + + /** + * Initialize the sync manager + * Call this once when the app starts (in browser only) + */ + async initialize(): Promise { + if (typeof window === 'undefined') return; + + // Check online status + syncState.isOnline = navigator.onLine; + + // Listen for online/offline events + window.addEventListener('online', () => { + syncState.isOnline = true; + syncState.status = 'idle'; + // Trigger sync when coming back online + this.sync(); + }); + + window.addEventListener('offline', () => { + syncState.isOnline = false; + syncState.status = 'offline'; + }); + + // Load last sync version from localStorage + const savedVersion = localStorage.getItem('lastSyncVersion'); + if (savedVersion) { + syncState.lastSyncVersion = parseInt(savedVersion, 10); + } + + // Update pending count + await this.updatePendingCount(); + + // Check backend connectivity + const isHealthy = await backendClient.healthCheck(); + if (!isHealthy) { + console.warn('Backend not reachable - sync disabled until connection restored'); + syncState.lastError = 'Backend not reachable'; + } + + // Start auto-sync if enabled + if (this.config.autoSync && isHealthy) { + this.startAutoSync(); + } + } + + /** + * Stop the sync manager and clean up + */ + destroy(): void { + this.stopAutoSync(); + } + + /** + * Start automatic sync interval + */ + startAutoSync(): void { + if (this.syncIntervalId) return; + + this.syncIntervalId = setInterval(() => { + this.sync(); + }, this.config.syncInterval); + + // Also do an initial sync + this.sync(); + } + + /** + * Stop automatic sync + */ + stopAutoSync(): void { + if (this.syncIntervalId) { + clearInterval(this.syncIntervalId); + this.syncIntervalId = null; + } + } + + /** + * Perform a full sync cycle (push then pull) + */ + async sync(): Promise { + const result: SyncResult = { pushed: 0, pulled: 0, errors: [] }; + + // Skip if already syncing or offline + if (this.isSyncing) return result; + if (!syncState.isOnline) { + syncState.status = 'offline'; + return result; + } + + this.isSyncing = true; + syncState.status = 'syncing'; + syncState.lastError = null; + + try { + // Push local changes first + const pushResult = await this.pushChanges(); + result.pushed = pushResult.count; + result.errors.push(...pushResult.errors); + + // Then pull remote changes + const pullResult = await this.pullChanges(); + result.pulled = pullResult.count; + result.errors.push(...pullResult.errors); + + // Update state + syncState.lastSyncTime = new Date(); + syncState.status = result.errors.length > 0 ? 'error' : 'idle'; + + if (result.errors.length > 0) { + syncState.lastError = result.errors[0]; + } + } catch (err) { + syncState.status = 'error'; + syncState.lastError = err instanceof Error ? err.message : 'Unknown sync error'; + result.errors.push(syncState.lastError); + } finally { + this.isSyncing = false; + await this.updatePendingCount(); + } + + return result; + } + + /** + * Push local changes to backend + */ + private async pushChanges(): Promise<{ count: number; errors: string[] }> { + const errors: string[] = []; + + // Get pending sync items + const pendingResult = await getPendingSyncItems(); + if (!pendingResult.success || pendingResult.data.length === 0) { + return { count: 0, errors: [] }; + } + + const pendingItems = pendingResult.data; + + // Group by entity type + const conversationItems = pendingItems.filter((i) => i.entityType === 'conversation'); + const messageItems = pendingItems.filter((i) => i.entityType === 'message'); + + // Collect data to push + const chats: BackendChat[] = []; + const messages: BackendMessage[] = []; + + // Process conversations + for (const item of conversationItems) { + if (item.operation === 'delete') { + // Handle delete separately + const response = await backendClient.deleteChat(item.entityId); + if (response.error) { + await incrementRetryCount(item.id, this.config.maxRetries); + errors.push(`Failed to delete chat ${item.entityId}: ${response.error}`); + } + } else { + // Get conversation from IndexedDB + const conv = await db.conversations.get(item.entityId); + if (conv) { + chats.push(this.convertConversationToBackend(conv)); + } + } + } + + // Process messages + for (const item of messageItems) { + if (item.operation !== 'delete') { + const msg = await db.messages.get(item.entityId); + if (msg) { + messages.push(this.convertMessageToBackend(msg)); + } + } + } + + // Push changes if any + if (chats.length > 0 || messages.length > 0) { + const response = await backendClient.pushChanges({ chats, messages }); + if (response.error) { + errors.push(`Push failed: ${response.error}`); + return { count: 0, errors }; + } + + // Update sync version + if (response.data) { + syncState.lastSyncVersion = response.data.sync_version; + localStorage.setItem('lastSyncVersion', String(response.data.sync_version)); + } + + // Clear successful sync items + const successfulIds = pendingItems + .filter((i) => i.operation !== 'delete') + .map((i) => i.id); + await clearSyncItems(successfulIds); + } + + return { count: chats.length + messages.length, errors }; + } + + /** + * Pull changes from backend + */ + private async pullChanges(): Promise<{ count: number; errors: string[] }> { + const errors: string[] = []; + + const response = await backendClient.pullChanges(syncState.lastSyncVersion); + if (response.error) { + errors.push(`Pull failed: ${response.error}`); + return { count: 0, errors }; + } + + if (!response.data) { + return { count: 0, errors: [] }; + } + + const { chats, sync_version } = response.data; + let count = 0; + + // Process pulled chats + for (const chat of chats) { + try { + await this.mergeChat(chat); + count++; + } catch (err) { + errors.push(`Failed to merge chat ${chat.id}: ${err}`); + } + } + + // Update sync version + syncState.lastSyncVersion = sync_version; + localStorage.setItem('lastSyncVersion', String(sync_version)); + + return { count, errors }; + } + + /** + * Merge a backend chat into IndexedDB + */ + private async mergeChat(backendChat: BackendChat): Promise { + const existing = await db.conversations.get(backendChat.id); + + // Convert to local format (using numeric timestamps) + const localConv: StoredConversation = { + id: backendChat.id, + title: backendChat.title, + model: backendChat.model, + createdAt: new Date(backendChat.created_at).getTime(), + updatedAt: new Date(backendChat.updated_at).getTime(), + isPinned: backendChat.pinned, + isArchived: backendChat.archived, + messageCount: backendChat.messages?.length ?? existing?.messageCount ?? 0, + syncVersion: backendChat.sync_version + }; + + if (!existing || backendChat.sync_version > (existing.syncVersion ?? 0)) { + await db.conversations.put(localConv); + + // Also merge messages if present + if (backendChat.messages) { + for (const msg of backendChat.messages) { + await this.mergeMessage(msg); + } + } + } + } + + /** + * Merge a backend message into IndexedDB + */ + private async mergeMessage(backendMsg: BackendMessage): Promise { + const existing = await db.messages.get(backendMsg.id); + + const localMsg: StoredMessage = { + id: backendMsg.id, + conversationId: backendMsg.chat_id, + parentId: backendMsg.parent_id ?? null, + role: backendMsg.role, + content: backendMsg.content, + siblingIndex: backendMsg.sibling_index, + createdAt: new Date(backendMsg.created_at).getTime(), + syncVersion: backendMsg.sync_version + }; + + if (!existing || backendMsg.sync_version > (existing.syncVersion ?? 0)) { + await db.messages.put(localMsg); + } + } + + /** + * Convert local conversation to backend format + */ + private convertConversationToBackend(conv: StoredConversation): BackendChat { + return { + id: conv.id, + title: conv.title, + model: conv.model, + pinned: conv.isPinned, + archived: conv.isArchived, + created_at: new Date(conv.createdAt).toISOString(), + updated_at: new Date(conv.updatedAt).toISOString(), + sync_version: conv.syncVersion ?? 1 + }; + } + + /** + * Convert local message to backend format + */ + private convertMessageToBackend(msg: StoredMessage): BackendMessage { + return { + id: msg.id, + chat_id: msg.conversationId, + parent_id: msg.parentId, + role: msg.role as BackendMessage['role'], + content: msg.content, + sibling_index: msg.siblingIndex, + created_at: new Date(msg.createdAt).toISOString(), + sync_version: msg.syncVersion ?? 1 + }; + } + + /** + * Update pending count in state + */ + private async updatePendingCount(): Promise { + const result = await getPendingSyncItems(); + syncState.pendingCount = result.success ? result.data.length : 0; + } + + /** + * Force a sync now (manual trigger) + */ + async syncNow(): Promise { + return this.sync(); + } +} + +/** Singleton sync manager instance */ +export const syncManager = new SyncManager(); diff --git a/frontend/src/lib/backend/types.ts b/frontend/src/lib/backend/types.ts new file mode 100644 index 0000000..7a6d6b5 --- /dev/null +++ b/frontend/src/lib/backend/types.ts @@ -0,0 +1,62 @@ +/** + * Types for backend API communication + */ + +/** Backend chat representation (matches Go model) */ +export interface BackendChat { + id: string; + title: string; + model: string; + pinned: boolean; + archived: boolean; + created_at: string; + updated_at: string; + sync_version: number; + messages?: BackendMessage[]; +} + +/** Backend message representation (matches Go model) */ +export interface BackendMessage { + id: string; + chat_id: string; + parent_id?: string | null; + role: 'user' | 'assistant' | 'system'; + content: string; + sibling_index: number; + created_at: string; + sync_version: number; + attachments?: BackendAttachment[]; +} + +/** Backend attachment representation */ +export interface BackendAttachment { + id: string; + message_id: string; + mime_type: string; + filename: string; + data?: string; // base64 encoded +} + +/** Push changes request body */ +export interface PushChangesRequest { + chats: BackendChat[]; + messages: BackendMessage[]; +} + +/** Push changes response */ +export interface PushChangesResponse { + message: string; + sync_version: number; +} + +/** Pull changes response */ +export interface PullChangesResponse { + chats: BackendChat[]; + sync_version: number; +} + +/** Generic API response wrapper */ +export interface ApiResponse { + data?: T; + error?: string; +} diff --git a/frontend/src/lib/components/chat/BranchNavigator.svelte b/frontend/src/lib/components/chat/BranchNavigator.svelte new file mode 100644 index 0000000..291e83d --- /dev/null +++ b/frontend/src/lib/components/chat/BranchNavigator.svelte @@ -0,0 +1,142 @@ + + + + + diff --git a/frontend/src/lib/components/chat/ChatInput.svelte b/frontend/src/lib/components/chat/ChatInput.svelte new file mode 100644 index 0000000..3aa453b --- /dev/null +++ b/frontend/src/lib/components/chat/ChatInput.svelte @@ -0,0 +1,214 @@ + + +
+ + {#if isVisionModel} + + {/if} + +
+ + {#if isVisionModel && pendingImages.length > 0} +
+ + + + + {pendingImages.length} + +
+ {/if} + + + + + +
+ {#if showStopButton} + + + {:else} + + + {/if} +
+
+ + +

+ Press Enter to send, + Shift+Enter for new line + {#if isVisionModel} + | Vision model: paste or drag images + {/if} +

+
diff --git a/frontend/src/lib/components/chat/ChatWindow.svelte b/frontend/src/lib/components/chat/ChatWindow.svelte new file mode 100644 index 0000000..b9798c5 --- /dev/null +++ b/frontend/src/lib/components/chat/ChatWindow.svelte @@ -0,0 +1,571 @@ + + +
+ {#if hasMessages} +
+ +
+ {:else} +
+ +
+ {/if} + +
+ + + + + {#if hasMessages} +
+ +
+ {/if} + +
+ +
+
+
diff --git a/frontend/src/lib/components/chat/CodeBlock.svelte b/frontend/src/lib/components/chat/CodeBlock.svelte new file mode 100644 index 0000000..9595761 --- /dev/null +++ b/frontend/src/lib/components/chat/CodeBlock.svelte @@ -0,0 +1,363 @@ + + +
+ +
+ {language} +
+ + {#if canExecute} + {#if isExecuting} + + {:else} + + {/if} + {/if} + + + +
+
+ + +
+ {#if isLoading} +
{code}
+ {:else} +
+ {@html highlightedHtml} +
+ {/if} +
+ + + {#if showOutput && (isExecuting || executionResult)} +
+ +
+
+ Output + {#if isExecuting} + + + + + + Running... + + {:else if executionResult} + + {executionResult.duration}ms + + {#if executionResult.status === 'success'} + Success + {:else if executionResult.status === 'error'} + Error + {/if} + {/if} +
+ +
+ + +
+ {#if executionResult?.outputs.length} +
{#each executionResult.outputs as output}{output.content}
+{/each}
+ {:else if !isExecuting} +

No output

+ {/if} +
+
+ {/if} +
+ + diff --git a/frontend/src/lib/components/chat/ContextUsageBar.svelte b/frontend/src/lib/components/chat/ContextUsageBar.svelte new file mode 100644 index 0000000..16bd136 --- /dev/null +++ b/frontend/src/lib/components/chat/ContextUsageBar.svelte @@ -0,0 +1,49 @@ + + +{#if compact} + +
+
+ +
+ {contextManager.statusMessage} +
+
+{:else} + +
+
+
+
+ + {contextManager.statusMessage} + +
+{/if} diff --git a/frontend/src/lib/components/chat/EmptyState.svelte b/frontend/src/lib/components/chat/EmptyState.svelte new file mode 100644 index 0000000..fb50176 --- /dev/null +++ b/frontend/src/lib/components/chat/EmptyState.svelte @@ -0,0 +1,101 @@ + + +
+ +
+ + + +
+ + +

+ {#if hasModel} + Start a conversation + {:else} + No model selected + {/if} +

+ +

+ {#if hasModel && selectedModel} + You're chatting with {selectedModel.name}. + Type a message below to begin. + {:else} + Please select a model from the sidebar to start chatting. + {/if} +

+ + + {#if hasModel} +
+ {@render SuggestionCard({ + icon: "lightbulb", + title: "Ask a question", + description: "Get explanations on any topic" + })} + {@render SuggestionCard({ + icon: "code", + title: "Write code", + description: "Generate or debug code snippets" + })} + {@render SuggestionCard({ + icon: "pencil", + title: "Create content", + description: "Draft emails, articles, or stories" + })} + {@render SuggestionCard({ + icon: "chat", + title: "Have a conversation", + description: "Discuss ideas and get feedback" + })} +
+ {/if} +
+ +{#snippet SuggestionCard(props: { icon: string; title: string; description: string })} +
+
+ {#if props.icon === 'lightbulb'} + + + + {:else if props.icon === 'code'} + + + + {:else if props.icon === 'pencil'} + + + + {:else if props.icon === 'chat'} + + + + {/if} +
+
+

{props.title}

+

{props.description}

+
+
+{/snippet} diff --git a/frontend/src/lib/components/chat/HtmlPreview.svelte b/frontend/src/lib/components/chat/HtmlPreview.svelte new file mode 100644 index 0000000..ed84dff --- /dev/null +++ b/frontend/src/lib/components/chat/HtmlPreview.svelte @@ -0,0 +1,165 @@ + + +
+ +
+
+ + + + {title} +
+
+ + + + + + + + +
+
+ + +
+ +
+
diff --git a/frontend/src/lib/components/chat/ImagePreview.svelte b/frontend/src/lib/components/chat/ImagePreview.svelte new file mode 100644 index 0000000..9ed3b14 --- /dev/null +++ b/frontend/src/lib/components/chat/ImagePreview.svelte @@ -0,0 +1,139 @@ + + + +
+ + + + + {#if showRemove && onRemove} + + {/if} +
+ + +{#if isModalOpen} + + +{/if} diff --git a/frontend/src/lib/components/chat/ImageUpload.svelte b/frontend/src/lib/components/chat/ImageUpload.svelte new file mode 100644 index 0000000..f34ee12 --- /dev/null +++ b/frontend/src/lib/components/chat/ImageUpload.svelte @@ -0,0 +1,310 @@ + + +
+ + {#if images.length > 0} +
+ {#each images as image, index (index)} + removeImage(index)} + alt={`Uploaded image ${index + 1}`} + /> + {/each} +
+ {/if} + + + {#if canAddMore} +
+ + + + {#if isProcessing} + +
+ + + + + Processing... +
+ {:else} + + + + + + +

+ {#if isDragOver} + Drop images here + {:else} + Drag & drop, click, or paste images + {/if} +

+ + + {#if images.length > 0} +

+ {images.length}/{maxImages} images +

+ {/if} + {/if} +
+ {/if} + + + {#if errorMessage} +
+ {errorMessage} +
+ {/if} +
diff --git a/frontend/src/lib/components/chat/MessageActions.svelte b/frontend/src/lib/components/chat/MessageActions.svelte new file mode 100644 index 0000000..6c436c8 --- /dev/null +++ b/frontend/src/lib/components/chat/MessageActions.svelte @@ -0,0 +1,128 @@ + + +
+ + + + + {#if isUser && onEdit} + + {/if} + + + {#if isAssistant && canRegenerate && onRegenerate} + + {/if} +
diff --git a/frontend/src/lib/components/chat/MessageContent.svelte b/frontend/src/lib/components/chat/MessageContent.svelte new file mode 100644 index 0000000..42e1bfc --- /dev/null +++ b/frontend/src/lib/components/chat/MessageContent.svelte @@ -0,0 +1,331 @@ + + +
+ + {#if images && images.length > 0} +
+ {#each images as image, index} + + {/each} +
+ {/if} + + + {#each contentParts as part, index (index)} + {#if part.type === 'code'} +
+ + + {#if part.showPreview} + + {/if} +
+ {:else} +
+ {@html renderMarkdown(part.content)} +
+ {/if} + {/each} +
+ + +{#if modalImage} + + +{/if} + + diff --git a/frontend/src/lib/components/chat/MessageItem.svelte b/frontend/src/lib/components/chat/MessageItem.svelte new file mode 100644 index 0000000..045403c --- /dev/null +++ b/frontend/src/lib/components/chat/MessageItem.svelte @@ -0,0 +1,214 @@ + + +
+ + {#if isAssistant} + + {/if} + + +
+ +
+ {#if isEditing} + +
+ +
+ + +
+
+ {:else} + + {#if hasContent} + + {/if} + + {#if isStreaming && !hasContent} + + {/if} + + {#if isStreaming && hasContent} + + {/if} + {/if} +
+ + + {#if !isEditing && !isStreaming} +
+ + {#if branchInfo} +
+ +
+ {/if} + + + navigator.clipboard.writeText(node.message.content)} + onEdit={isUser ? startEditing : undefined} + {onRegenerate} + /> +
+ {/if} +
+ + + {#if isUser} + + {/if} +
diff --git a/frontend/src/lib/components/chat/MessageList.svelte b/frontend/src/lib/components/chat/MessageList.svelte new file mode 100644 index 0000000..412268b --- /dev/null +++ b/frontend/src/lib/components/chat/MessageList.svelte @@ -0,0 +1,162 @@ + + +
+
+
+ {#each chatState.visibleMessages as node, index (node.id)} + handleBranchSwitch(node.id, direction)} + onRegenerate={onRegenerate} + onEdit={(newContent) => onEditMessage?.(node.id, newContent)} + /> + {/each} +
+
+ + + {#if showScrollButton} + + {/if} +
diff --git a/frontend/src/lib/components/chat/StreamingIndicator.svelte b/frontend/src/lib/components/chat/StreamingIndicator.svelte new file mode 100644 index 0000000..636d712 --- /dev/null +++ b/frontend/src/lib/components/chat/StreamingIndicator.svelte @@ -0,0 +1,44 @@ + + +
+ + + + Generating response... +
diff --git a/frontend/src/lib/components/chat/SummaryBanner.svelte b/frontend/src/lib/components/chat/SummaryBanner.svelte new file mode 100644 index 0000000..91d2a7b --- /dev/null +++ b/frontend/src/lib/components/chat/SummaryBanner.svelte @@ -0,0 +1,67 @@ + + +{#if showBanner} +
+
+ + + + + Context getting full. Summarize older messages to free up ~{Math.round(estimatedSavings / 1000)}K tokens. + +
+ + +
+{/if} diff --git a/frontend/src/lib/components/chat/index.ts b/frontend/src/lib/components/chat/index.ts new file mode 100644 index 0000000..8ff1cbb --- /dev/null +++ b/frontend/src/lib/components/chat/index.ts @@ -0,0 +1,32 @@ +/** + * Chat components for Ollama Web UI + * + * This module exports all chat-related components for building + * the conversational interface. + */ + +// Main container +export { default as ChatWindow } from './ChatWindow.svelte'; + +// Message display +export { default as MessageList } from './MessageList.svelte'; +export { default as MessageItem } from './MessageItem.svelte'; +export { default as MessageContent } from './MessageContent.svelte'; +export { default as MessageActions } from './MessageActions.svelte'; + +// Branch navigation +export { default as BranchNavigator } from './BranchNavigator.svelte'; + +// Input +export { default as ChatInput } from './ChatInput.svelte'; + +// Image handling (for vision models) +export { default as ImageUpload } from './ImageUpload.svelte'; +export { default as ImagePreview } from './ImagePreview.svelte'; + +// Code display +export { default as CodeBlock } from './CodeBlock.svelte'; + +// Indicators and states +export { default as StreamingIndicator } from './StreamingIndicator.svelte'; +export { default as EmptyState } from './EmptyState.svelte'; diff --git a/frontend/src/lib/components/layout/ConversationItem.svelte b/frontend/src/lib/components/layout/ConversationItem.svelte new file mode 100644 index 0000000..e92abb4 --- /dev/null +++ b/frontend/src/lib/components/layout/ConversationItem.svelte @@ -0,0 +1,180 @@ + + + + +
+ {#if conversation.isPinned} + + + + + {:else} + + + + + {/if} +
+ + +
+ +

+ {conversation.title || 'New Conversation'} +

+ + +
+ {conversation.model} + - + {formatRelativeTime(new Date(conversation.updatedAt))} +
+
+ + +
+ + + + + +
+
diff --git a/frontend/src/lib/components/layout/ConversationList.svelte b/frontend/src/lib/components/layout/ConversationList.svelte new file mode 100644 index 0000000..af9c084 --- /dev/null +++ b/frontend/src/lib/components/layout/ConversationList.svelte @@ -0,0 +1,101 @@ + + +
+ {#if conversationsState.grouped.length === 0} + +
+ + + + {#if conversationsState.searchQuery} +

No conversations match your search

+ + {:else} +

No conversations yet

+

Start a new chat to begin

+ {/if} +
+ {:else} + + {#each conversationsState.grouped as { group, conversations } (group)} +
+ +

+ {group} +

+ + +
+ {#each conversations as conversation (conversation.id)} + + {/each} +
+
+ {/each} + + + {#if conversationsState.archived.length > 0} +
+ + + {#if showArchived} +
+ {#each conversationsState.archived as conversation (conversation.id)} + + {/each} +
+ {/if} +
+ {/if} + {/if} +
diff --git a/frontend/src/lib/components/layout/ModelSelect.svelte b/frontend/src/lib/components/layout/ModelSelect.svelte new file mode 100644 index 0000000..589ca9d --- /dev/null +++ b/frontend/src/lib/components/layout/ModelSelect.svelte @@ -0,0 +1,215 @@ + + +
+ + + + + {#if isOpen && !modelsState.isLoading} +
+ {#if modelsState.error} +
+

{modelsState.error}

+ +
+ {:else if modelsState.grouped.length === 0} +
+

No models available

+

Make sure Ollama is running

+
+ {:else} + {#each modelsState.grouped as group (group.family)} + +
+ {group.family} +
+ + + {#each group.models as model (model.name)} + + {/each} + {/each} + {/if} +
+ {/if} +
diff --git a/frontend/src/lib/components/layout/Sidenav.svelte b/frontend/src/lib/components/layout/Sidenav.svelte new file mode 100644 index 0000000..fdd50fb --- /dev/null +++ b/frontend/src/lib/components/layout/Sidenav.svelte @@ -0,0 +1,124 @@ + + + +{#if uiState.isMobile && uiState.sidenavOpen} + +{/if} + + + + + + (settingsOpen = false)} /> diff --git a/frontend/src/lib/components/layout/SidenavHeader.svelte b/frontend/src/lib/components/layout/SidenavHeader.svelte new file mode 100644 index 0000000..45cdbc6 --- /dev/null +++ b/frontend/src/lib/components/layout/SidenavHeader.svelte @@ -0,0 +1,77 @@ + + +
+ +
+
+ +
+ + + +
+ Ollama Chat +
+ + + {#if uiState.isMobile} + + {/if} +
+ + + + + + + New Chat + +
diff --git a/frontend/src/lib/components/layout/SidenavSearch.svelte b/frontend/src/lib/components/layout/SidenavSearch.svelte new file mode 100644 index 0000000..0bdf3e3 --- /dev/null +++ b/frontend/src/lib/components/layout/SidenavSearch.svelte @@ -0,0 +1,57 @@ + + +
+
+ + + + + + + + + + {#if conversationsState.searchQuery} + + {/if} +
+
diff --git a/frontend/src/lib/components/layout/TopNav.svelte b/frontend/src/lib/components/layout/TopNav.svelte new file mode 100644 index 0000000..4047fb0 --- /dev/null +++ b/frontend/src/lib/components/layout/TopNav.svelte @@ -0,0 +1,293 @@ + + +
+
+ +
+ + + + + {#if modelSelect} + {@render modelSelect()} + {/if} +
+ + + {#if isInChat} + + {/if} + + +
+ + {#if isInChat} + + + + + + + + + + + + {/if} +
+
+
+ + + + + + diff --git a/frontend/src/lib/components/layout/index.ts b/frontend/src/lib/components/layout/index.ts new file mode 100644 index 0000000..215672e --- /dev/null +++ b/frontend/src/lib/components/layout/index.ts @@ -0,0 +1,11 @@ +/** + * Layout component exports + */ + +export { default as Sidenav } from './Sidenav.svelte'; +export { default as SidenavHeader } from './SidenavHeader.svelte'; +export { default as SidenavSearch } from './SidenavSearch.svelte'; +export { default as ConversationList } from './ConversationList.svelte'; +export { default as ConversationItem } from './ConversationItem.svelte'; +export { default as TopNav } from './TopNav.svelte'; +export { default as ModelSelect } from './ModelSelect.svelte'; diff --git a/frontend/src/lib/components/shared/ConfirmDialog.svelte b/frontend/src/lib/components/shared/ConfirmDialog.svelte new file mode 100644 index 0000000..bb7a4d7 --- /dev/null +++ b/frontend/src/lib/components/shared/ConfirmDialog.svelte @@ -0,0 +1,187 @@ + + +{#if isOpen} + + + +{/if} diff --git a/frontend/src/lib/components/shared/ErrorBoundary.svelte b/frontend/src/lib/components/shared/ErrorBoundary.svelte new file mode 100644 index 0000000..d8a4269 --- /dev/null +++ b/frontend/src/lib/components/shared/ErrorBoundary.svelte @@ -0,0 +1,74 @@ + + + { + error = err instanceof Error ? err : new Error(String(err)); + onError?.(error); + }} +> + {#if hasError && error} + {#if fallback} + {@render fallback(error)} + {:else} + +
+
+ + + +
+

Something went wrong

+

{error.message}

+ +
+
+
+ {/if} + {:else} + {@render children()} + {/if} +
diff --git a/frontend/src/lib/components/shared/ExportDialog.svelte b/frontend/src/lib/components/shared/ExportDialog.svelte new file mode 100644 index 0000000..0084adb --- /dev/null +++ b/frontend/src/lib/components/shared/ExportDialog.svelte @@ -0,0 +1,295 @@ + + +{#if isOpen} + + + +{/if} diff --git a/frontend/src/lib/components/shared/MessageSkeleton.svelte b/frontend/src/lib/components/shared/MessageSkeleton.svelte new file mode 100644 index 0000000..5086ee6 --- /dev/null +++ b/frontend/src/lib/components/shared/MessageSkeleton.svelte @@ -0,0 +1,37 @@ + + +
+ + {#if !isUser} + + {/if} + + +
+
+ +
+
+ + + {#if isUser} + + {/if} +
diff --git a/frontend/src/lib/components/shared/SettingsModal.svelte b/frontend/src/lib/components/shared/SettingsModal.svelte new file mode 100644 index 0000000..834c29d --- /dev/null +++ b/frontend/src/lib/components/shared/SettingsModal.svelte @@ -0,0 +1,166 @@ + + +{#if isOpen} + + +
+ + +
+{/if} diff --git a/frontend/src/lib/components/shared/Skeleton.svelte b/frontend/src/lib/components/shared/Skeleton.svelte new file mode 100644 index 0000000..80cb87e --- /dev/null +++ b/frontend/src/lib/components/shared/Skeleton.svelte @@ -0,0 +1,71 @@ + + +{#if variant === 'text' && lines > 1} +
+ {#each lineWidths as lineWidth, i} +
+ {/each} +
+{:else} +
+{/if} diff --git a/frontend/src/lib/components/shared/ToastContainer.svelte b/frontend/src/lib/components/shared/ToastContainer.svelte new file mode 100644 index 0000000..b0400a2 --- /dev/null +++ b/frontend/src/lib/components/shared/ToastContainer.svelte @@ -0,0 +1,103 @@ + + +{#if toastState.toasts.length > 0} +
+ {#each toastState.toasts as toast (toast.id)} + + {/each} +
+{/if} + + diff --git a/frontend/src/lib/components/shared/index.ts b/frontend/src/lib/components/shared/index.ts new file mode 100644 index 0000000..612d659 --- /dev/null +++ b/frontend/src/lib/components/shared/index.ts @@ -0,0 +1,12 @@ +/** + * Shared components index + * Re-exports all shared UI components + */ + +export { default as ExportDialog } from './ExportDialog.svelte'; +export { default as ConfirmDialog } from './ConfirmDialog.svelte'; +export { default as ToastContainer } from './ToastContainer.svelte'; +export { default as Skeleton } from './Skeleton.svelte'; +export { default as MessageSkeleton } from './MessageSkeleton.svelte'; +export { default as ErrorBoundary } from './ErrorBoundary.svelte'; +export { default as SettingsModal } from './SettingsModal.svelte'; diff --git a/frontend/src/lib/execution/index.ts b/frontend/src/lib/execution/index.ts new file mode 100644 index 0000000..5bc64ee --- /dev/null +++ b/frontend/src/lib/execution/index.ts @@ -0,0 +1,31 @@ +/** + * Code execution module exports + */ + +// Types +export type { + ExecutionRuntime, + ExecutionStatus, + OutputType, + ExecutionOutput, + ExecutionResult, + ExecutionRequest, + RuntimeCapabilities, + CodeBlockMeta, + CodeExecutor +} from './types.js'; + +export { + LANGUAGE_RUNTIME_MAP, + getRuntime, + isExecutable, + DEFAULT_EXECUTION_TIMEOUT, + MAX_OUTPUT_SIZE +} from './types.js'; + +// Executors +export { jsExecutor, JavaScriptExecutor } from './javascript-executor.js'; +export { pythonExecutor, PythonExecutor } from './python-executor.js'; + +// Manager +export { executionManager } from './manager.js'; diff --git a/frontend/src/lib/execution/javascript-executor.ts b/frontend/src/lib/execution/javascript-executor.ts new file mode 100644 index 0000000..9ee312f --- /dev/null +++ b/frontend/src/lib/execution/javascript-executor.ts @@ -0,0 +1,227 @@ +/** + * JavaScript code executor + * + * Executes JavaScript code in an isolated context using a Web Worker. + * Provides console capture, timeout handling, and result serialization. + * + * SECURITY NOTE: This intentionally uses eval() inside a Web Worker to execute + * user-provided code. The Web Worker provides isolation from the main thread. + * This is the standard pattern for browser-based code playgrounds. + */ + +import type { + CodeExecutor, + ExecutionRequest, + ExecutionResult +} from './types.js'; +import { DEFAULT_EXECUTION_TIMEOUT } from './types.js'; + +/** Worker script as a blob URL */ +function createWorkerScript(): string { + // This script runs inside an isolated Web Worker + // It captures console output and safely executes user code + const script = ` + // Captured console outputs + const outputs = []; + let executionStart = Date.now(); + + // Override console methods to capture output + const originalConsole = { ...console }; + console.log = (...args) => { + outputs.push({ type: 'stdout', content: args.map(formatValue).join(' '), timestamp: Date.now() - executionStart }); + }; + console.error = (...args) => { + outputs.push({ type: 'stderr', content: args.map(formatValue).join(' '), timestamp: Date.now() - executionStart }); + }; + console.warn = (...args) => { + outputs.push({ type: 'stderr', content: '[warn] ' + args.map(formatValue).join(' '), timestamp: Date.now() - executionStart }); + }; + console.info = (...args) => { + outputs.push({ type: 'stdout', content: args.map(formatValue).join(' '), timestamp: Date.now() - executionStart }); + }; + + // Format values for display + function formatValue(val) { + if (val === undefined) return 'undefined'; + if (val === null) return 'null'; + if (typeof val === 'function') return val.toString(); + if (typeof val === 'object') { + try { + return JSON.stringify(val, null, 2); + } catch { + return String(val); + } + } + return String(val); + } + + // Execute code using Function constructor (safer than direct eval) + async function executeCode(code) { + // Wrap in async function to support top-level await + const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor; + const fn = new AsyncFunction(code); + return await fn(); + } + + // Handle execution requests + self.onmessage = async (event) => { + const { code, id } = event.data; + outputs.length = 0; + executionStart = Date.now(); + + try { + const result = await executeCode(code); + + // Add result if not undefined + if (result !== undefined) { + outputs.push({ + type: 'result', + content: formatValue(result), + timestamp: Date.now() - executionStart + }); + } + + self.postMessage({ + id, + status: 'success', + outputs: outputs.slice(), + duration: Date.now() - executionStart + }); + } catch (error) { + outputs.push({ + type: 'error', + content: error.message || String(error), + timestamp: Date.now() - executionStart + }); + + self.postMessage({ + id, + status: 'error', + outputs: outputs.slice(), + duration: Date.now() - executionStart, + error: error.message || String(error) + }); + } + }; + `; + + const blob = new Blob([script], { type: 'application/javascript' }); + return URL.createObjectURL(blob); +} + +/** JavaScript executor using Web Workers */ +export class JavaScriptExecutor implements CodeExecutor { + readonly runtime = 'javascript' as const; + + private worker: Worker | null = null; + private workerUrl: string | null = null; + private currentRequestId = 0; + private pendingRequest: { + id: number; + resolve: (result: ExecutionResult) => void; + reject: (error: Error) => void; + timeout: ReturnType; + } | null = null; + + isReady(): boolean { + return this.worker !== null; + } + + async initialize(): Promise { + if (this.worker) return; + + this.workerUrl = createWorkerScript(); + this.worker = new Worker(this.workerUrl); + + this.worker.onmessage = (event) => { + const { id, status, outputs, duration, error } = event.data; + + if (this.pendingRequest && this.pendingRequest.id === id) { + clearTimeout(this.pendingRequest.timeout); + this.pendingRequest.resolve({ status, outputs, duration, error }); + this.pendingRequest = null; + } + }; + + this.worker.onerror = (event) => { + if (this.pendingRequest) { + clearTimeout(this.pendingRequest.timeout); + this.pendingRequest.reject(new Error(event.message)); + this.pendingRequest = null; + } + }; + } + + async execute(request: ExecutionRequest): Promise { + if (!this.worker) { + await this.initialize(); + } + + // Cancel any pending request + if (this.pendingRequest) { + this.cancel(); + } + + const id = ++this.currentRequestId; + const timeout = request.timeout ?? DEFAULT_EXECUTION_TIMEOUT; + + return new Promise((resolve, reject) => { + const timeoutHandle = setTimeout(() => { + if (this.pendingRequest?.id === id) { + this.pendingRequest = null; + // Terminate and recreate worker on timeout + this.terminateWorker(); + resolve({ + status: 'error', + outputs: [{ + type: 'error', + content: `Execution timed out after ${timeout}ms`, + timestamp: timeout + }], + duration: timeout, + error: 'Execution timed out' + }); + } + }, timeout); + + this.pendingRequest = { id, resolve, reject, timeout: timeoutHandle }; + this.worker!.postMessage({ code: request.code, id }); + }); + } + + cancel(): void { + if (this.pendingRequest) { + clearTimeout(this.pendingRequest.timeout); + this.pendingRequest.resolve({ + status: 'cancelled', + outputs: [{ + type: 'stderr', + content: 'Execution cancelled', + timestamp: 0 + }], + duration: 0 + }); + this.pendingRequest = null; + } + // Terminate and recreate worker to stop execution + this.terminateWorker(); + } + + private terminateWorker(): void { + if (this.worker) { + this.worker.terminate(); + this.worker = null; + } + } + + destroy(): void { + this.cancel(); + if (this.workerUrl) { + URL.revokeObjectURL(this.workerUrl); + this.workerUrl = null; + } + } +} + +/** Singleton instance */ +export const jsExecutor = new JavaScriptExecutor(); diff --git a/frontend/src/lib/execution/manager.ts b/frontend/src/lib/execution/manager.ts new file mode 100644 index 0000000..9a92230 --- /dev/null +++ b/frontend/src/lib/execution/manager.ts @@ -0,0 +1,236 @@ +/** + * Execution manager - coordinates code execution across runtimes + */ + +import type { + CodeExecutor, + ExecutionRequest, + ExecutionResult, + ExecutionRuntime, + RuntimeCapabilities +} from './types.js'; +import { getRuntime, isExecutable } from './types.js'; +import { jsExecutor } from './javascript-executor.js'; +import { pythonExecutor } from './python-executor.js'; + +/** Runtime capability definitions */ +const RUNTIME_CAPABILITIES: RuntimeCapabilities[] = [ + { + runtime: 'javascript', + name: 'JavaScript', + available: true, + extensions: ['.js', '.mjs', '.cjs'], + aliases: ['js', 'javascript', 'jsx', 'mjs', 'cjs'], + supportsPackages: false, + supportsVisualOutput: false + }, + { + runtime: 'typescript', + name: 'TypeScript', + available: false, // Requires transpilation - future feature + extensions: ['.ts', '.tsx'], + aliases: ['ts', 'typescript', 'tsx'], + supportsPackages: false, + supportsVisualOutput: false + }, + { + runtime: 'python', + name: 'Python (Pyodide)', + available: true, + extensions: ['.py'], + aliases: ['python', 'py', 'python3'], + supportsPackages: true, + supportsVisualOutput: true // matplotlib support + }, + { + runtime: 'html', + name: 'HTML Preview', + available: true, + extensions: ['.html', '.htm'], + aliases: ['html', 'htm'], + supportsPackages: false, + supportsVisualOutput: true + }, + { + runtime: 'shell', + name: 'Shell (Limited)', + available: false, // Not safe in browser + extensions: ['.sh', '.bash'], + aliases: ['bash', 'sh', 'shell', 'zsh'], + supportsPackages: false, + supportsVisualOutput: false + } +]; + +/** Execution manager class */ +class ExecutionManager { + private executors: Map = new Map(); + private initPromises: Map> = new Map(); + + constructor() { + // Register built-in executors + this.executors.set('javascript', jsExecutor); + this.executors.set('python', pythonExecutor); + } + + /** + * Get capabilities for all runtimes + */ + getCapabilities(): RuntimeCapabilities[] { + return RUNTIME_CAPABILITIES; + } + + /** + * Get capabilities for a specific runtime + */ + getRuntimeCapabilities(runtime: ExecutionRuntime): RuntimeCapabilities | null { + return RUNTIME_CAPABILITIES.find((c) => c.runtime === runtime) ?? null; + } + + /** + * Check if a runtime is available + */ + isRuntimeAvailable(runtime: ExecutionRuntime): boolean { + const caps = this.getRuntimeCapabilities(runtime); + return caps?.available ?? false; + } + + /** + * Check if a language can be executed + */ + canExecute(language: string): boolean { + const runtime = getRuntime(language); + if (!runtime) return false; + return this.isRuntimeAvailable(runtime); + } + + /** + * Initialize a runtime (lazy loading) + */ + async initializeRuntime(runtime: ExecutionRuntime): Promise { + const executor = this.executors.get(runtime); + if (!executor) { + throw new Error(`No executor registered for runtime: ${runtime}`); + } + + // Check if already initializing + const existing = this.initPromises.get(runtime); + if (existing) return existing; + + // Check if already ready + if (executor.isReady()) return; + + // Initialize + const promise = executor.initialize(); + this.initPromises.set(runtime, promise); + + try { + await promise; + } finally { + this.initPromises.delete(runtime); + } + } + + /** + * Execute code + */ + async execute(request: ExecutionRequest): Promise { + const { runtime, code } = request; + + // Check if runtime is available + if (!this.isRuntimeAvailable(runtime)) { + return { + status: 'error', + outputs: [{ + type: 'error', + content: `Runtime "${runtime}" is not available`, + timestamp: 0 + }], + duration: 0, + error: `Runtime "${runtime}" is not available` + }; + } + + // Get executor + const executor = this.executors.get(runtime); + if (!executor) { + return { + status: 'error', + outputs: [{ + type: 'error', + content: `No executor for runtime: ${runtime}`, + timestamp: 0 + }], + duration: 0, + error: `No executor for runtime: ${runtime}` + }; + } + + // Initialize if needed + await this.initializeRuntime(runtime); + + // Execute + return executor.execute(request); + } + + /** + * Execute code by language (auto-detects runtime) + */ + async executeByLanguage( + code: string, + language: string, + options?: Partial> + ): Promise { + const runtime = getRuntime(language); + + if (!runtime) { + return { + status: 'error', + outputs: [{ + type: 'error', + content: `Unknown language: ${language}`, + timestamp: 0 + }], + duration: 0, + error: `Unknown language: ${language}` + }; + } + + return this.execute({ + code, + runtime, + ...options + }); + } + + /** + * Cancel execution for a runtime + */ + cancel(runtime: ExecutionRuntime): void { + const executor = this.executors.get(runtime); + executor?.cancel(); + } + + /** + * Cancel all executions + */ + cancelAll(): void { + for (const executor of this.executors.values()) { + executor.cancel(); + } + } + + /** + * Clean up all executors + */ + destroy(): void { + for (const executor of this.executors.values()) { + executor.destroy(); + } + this.executors.clear(); + this.initPromises.clear(); + } +} + +/** Singleton execution manager */ +export const executionManager = new ExecutionManager(); diff --git a/frontend/src/lib/execution/python-executor.ts b/frontend/src/lib/execution/python-executor.ts new file mode 100644 index 0000000..0af75b5 --- /dev/null +++ b/frontend/src/lib/execution/python-executor.ts @@ -0,0 +1,219 @@ +/** + * Python code executor using Pyodide + * + * Pyodide is a Python distribution for the browser based on WebAssembly. + * It provides a full Python 3.x runtime with access to numpy, pandas, etc. + */ + +import type { + CodeExecutor, + ExecutionRequest, + ExecutionResult, + ExecutionOutput +} from './types.js'; +import { DEFAULT_EXECUTION_TIMEOUT } from './types.js'; + +/** Pyodide CDN URL */ +const PYODIDE_CDN = 'https://cdn.jsdelivr.net/pyodide/v0.25.0/full/'; + +/** Pyodide interface (loaded dynamically) */ +interface PyodideInterface { + runPythonAsync(code: string): Promise; + loadPackage(packages: string[]): Promise; + globals: Map; + setStdout(options: { batched: (text: string) => void }): void; + setStderr(options: { batched: (text: string) => void }): void; +} + +/** Load Pyodide function type */ +type LoadPyodide = (config: { indexURL: string }) => Promise; + +/** Python executor using Pyodide */ +export class PythonExecutor implements CodeExecutor { + readonly runtime = 'python' as const; + + private pyodide: PyodideInterface | null = null; + private loading: Promise | null = null; + private outputs: ExecutionOutput[] = []; + private executionStart = 0; + + isReady(): boolean { + return this.pyodide !== null; + } + + async initialize(): Promise { + if (this.pyodide) return; + if (this.loading) return this.loading; + + this.loading = this.loadPyodide(); + await this.loading; + } + + private async loadPyodide(): Promise { + // Dynamically load Pyodide from CDN + if (typeof window === 'undefined') { + throw new Error('Pyodide requires a browser environment'); + } + + // Check if already loaded + if ((window as unknown as { loadPyodide?: LoadPyodide }).loadPyodide) { + this.pyodide = await (window as unknown as { loadPyodide: LoadPyodide }).loadPyodide({ + indexURL: PYODIDE_CDN + }); + return; + } + + // Load Pyodide script + await new Promise((resolve, reject) => { + const script = document.createElement('script'); + script.src = `${PYODIDE_CDN}pyodide.js`; + script.onload = () => resolve(); + script.onerror = () => reject(new Error('Failed to load Pyodide')); + document.head.appendChild(script); + }); + + // Initialize Pyodide + const loadPyodide = (window as unknown as { loadPyodide: LoadPyodide }).loadPyodide; + this.pyodide = await loadPyodide({ indexURL: PYODIDE_CDN }); + + // Set up stdout/stderr capture + this.setupOutputCapture(); + } + + private setupOutputCapture(): void { + if (!this.pyodide) return; + + this.pyodide.setStdout({ + batched: (text: string) => { + this.outputs.push({ + type: 'stdout', + content: text, + timestamp: Date.now() - this.executionStart + }); + } + }); + + this.pyodide.setStderr({ + batched: (text: string) => { + this.outputs.push({ + type: 'stderr', + content: text, + timestamp: Date.now() - this.executionStart + }); + } + }); + } + + async execute(request: ExecutionRequest): Promise { + if (!this.pyodide) { + await this.initialize(); + } + + if (!this.pyodide) { + return { + status: 'error', + outputs: [{ + type: 'error', + content: 'Failed to initialize Python runtime', + timestamp: 0 + }], + duration: 0, + error: 'Failed to initialize Python runtime' + }; + } + + this.outputs = []; + this.executionStart = Date.now(); + + const timeout = request.timeout ?? DEFAULT_EXECUTION_TIMEOUT; + + try { + // Create a timeout promise + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error('Execution timed out')), timeout); + }); + + // Execute code with timeout + const result = await Promise.race([ + this.pyodide.runPythonAsync(request.code), + timeoutPromise + ]); + + const duration = Date.now() - this.executionStart; + + // Add result if not None + if (result !== undefined && result !== null) { + this.outputs.push({ + type: 'result', + content: this.formatPythonValue(result), + timestamp: duration + }); + } + + return { + status: 'success', + outputs: [...this.outputs], + duration + }; + } catch (error) { + const duration = Date.now() - this.executionStart; + const errorMessage = error instanceof Error ? error.message : String(error); + + this.outputs.push({ + type: 'error', + content: errorMessage, + timestamp: duration + }); + + return { + status: 'error', + outputs: [...this.outputs], + duration, + error: errorMessage + }; + } + } + + private formatPythonValue(value: unknown): string { + if (value === null || value === undefined) return 'None'; + if (typeof value === 'string') return value; + if (typeof value === 'number' || typeof value === 'boolean') return String(value); + + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } + } + + cancel(): void { + // Pyodide doesn't support cancellation easily + // The best we can do is interrupt on the next Python instruction + // For now, we just note that cancellation was requested + this.outputs.push({ + type: 'stderr', + content: 'Cancellation requested (may not take effect immediately)', + timestamp: Date.now() - this.executionStart + }); + } + + destroy(): void { + // Pyodide doesn't have a clean destroy mechanism + // The instance will be garbage collected when no longer referenced + this.pyodide = null; + this.loading = null; + } + + /** + * Load additional Python packages + */ + async loadPackages(packages: string[]): Promise { + if (!this.pyodide) { + await this.initialize(); + } + await this.pyodide?.loadPackage(packages); + } +} + +/** Singleton instance */ +export const pythonExecutor = new PythonExecutor(); diff --git a/frontend/src/lib/execution/types.ts b/frontend/src/lib/execution/types.ts new file mode 100644 index 0000000..242c2ad --- /dev/null +++ b/frontend/src/lib/execution/types.ts @@ -0,0 +1,137 @@ +/** + * Code execution types + */ + +/** Supported execution runtimes */ +export type ExecutionRuntime = 'javascript' | 'typescript' | 'python' | 'html' | 'shell'; + +/** Execution status */ +export type ExecutionStatus = 'idle' | 'running' | 'success' | 'error' | 'cancelled'; + +/** Output type for execution results */ +export type OutputType = 'stdout' | 'stderr' | 'result' | 'error' | 'image' | 'html'; + +/** Single output entry */ +export interface ExecutionOutput { + type: OutputType; + content: string; + timestamp: number; +} + +/** Execution result */ +export interface ExecutionResult { + status: ExecutionStatus; + outputs: ExecutionOutput[]; + duration: number; + error?: string; +} + +/** Execution request */ +export interface ExecutionRequest { + code: string; + runtime: ExecutionRuntime; + /** Optional filename for context */ + filename?: string; + /** Timeout in milliseconds */ + timeout?: number; +} + +/** Runtime capabilities */ +export interface RuntimeCapabilities { + /** Runtime identifier */ + runtime: ExecutionRuntime; + /** Human-readable name */ + name: string; + /** Whether the runtime is available */ + available: boolean; + /** File extensions this runtime handles */ + extensions: string[]; + /** Language aliases (for code block detection) */ + aliases: string[]; + /** Whether this runtime supports package installation */ + supportsPackages: boolean; + /** Whether this runtime can produce visual output */ + supportsVisualOutput: boolean; +} + +/** Code block metadata extracted from markdown */ +export interface CodeBlockMeta { + /** The code content */ + code: string; + /** Detected language */ + language: string; + /** Normalized runtime (if executable) */ + runtime: ExecutionRuntime | null; + /** Whether this code can be executed */ + executable: boolean; + /** Optional filename hint from code fence */ + filename?: string; +} + +/** Executor interface - implemented by each runtime */ +export interface CodeExecutor { + /** Runtime this executor handles */ + runtime: ExecutionRuntime; + + /** Check if the executor is ready */ + isReady(): boolean; + + /** Initialize the executor (load WASM, etc.) */ + initialize(): Promise; + + /** Execute code and return result */ + execute(request: ExecutionRequest): Promise; + + /** Cancel current execution */ + cancel(): void; + + /** Clean up resources */ + destroy(): void; +} + +/** Language to runtime mapping */ +export const LANGUAGE_RUNTIME_MAP: Record = { + // JavaScript + javascript: 'javascript', + js: 'javascript', + jsx: 'javascript', + mjs: 'javascript', + cjs: 'javascript', + + // TypeScript + typescript: 'typescript', + ts: 'typescript', + tsx: 'typescript', + + // Python + python: 'python', + py: 'python', + python3: 'python', + + // HTML (preview) + html: 'html', + htm: 'html', + + // Shell (limited) + bash: 'shell', + sh: 'shell', + shell: 'shell', + zsh: 'shell' +}; + +/** Get runtime from language string */ +export function getRuntime(language: string): ExecutionRuntime | null { + const normalized = language.toLowerCase().trim(); + return LANGUAGE_RUNTIME_MAP[normalized] ?? null; +} + +/** Check if a language is executable */ +export function isExecutable(language: string): boolean { + return getRuntime(language) !== null; +} + +/** Default timeout for code execution (30 seconds) */ +export const DEFAULT_EXECUTION_TIMEOUT = 30000; + +/** Maximum output size (1MB) */ +export const MAX_OUTPUT_SIZE = 1024 * 1024; diff --git a/frontend/src/lib/index.ts b/frontend/src/lib/index.ts new file mode 100644 index 0000000..6d23dff --- /dev/null +++ b/frontend/src/lib/index.ts @@ -0,0 +1,6 @@ +// Reexport components and utilities for easy imports +// Usage: import { Component } from '$lib'; + +export * from './types/index.js'; +export * from './stores/index.js'; +export * from './utils/index.js'; diff --git a/frontend/src/lib/memory/chunker.ts b/frontend/src/lib/memory/chunker.ts new file mode 100644 index 0000000..3a9ff97 --- /dev/null +++ b/frontend/src/lib/memory/chunker.ts @@ -0,0 +1,236 @@ +/** + * Text chunking utilities for RAG + * + * Splits documents into smaller chunks for embedding and retrieval. + * Uses overlapping windows to preserve context across chunk boundaries. + */ + +import type { DocumentChunk } from './types.js'; + +/** Default chunk size in characters (roughly ~128 tokens) */ +const DEFAULT_CHUNK_SIZE = 512; + +/** Default overlap between chunks */ +const DEFAULT_OVERLAP = 50; + +/** Minimum chunk size to avoid tiny fragments */ +const MIN_CHUNK_SIZE = 50; + +/** + * Chunking options + */ +export interface ChunkOptions { + /** Target chunk size in characters */ + chunkSize?: number; + /** Overlap between consecutive chunks */ + overlap?: number; + /** Prefer splitting at sentence boundaries */ + respectSentences?: boolean; + /** Prefer splitting at paragraph boundaries */ + respectParagraphs?: boolean; +} + +/** + * Split text into overlapping chunks + */ +export function chunkText( + text: string, + documentId: string, + options: ChunkOptions = {} +): DocumentChunk[] { + const { + chunkSize = DEFAULT_CHUNK_SIZE, + overlap = DEFAULT_OVERLAP, + respectSentences = true, + respectParagraphs = true + } = options; + + if (!text || text.length === 0) { + return []; + } + + // For very short texts, return as single chunk + if (text.length <= chunkSize) { + return [{ + id: crypto.randomUUID(), + documentId, + content: text.trim(), + startIndex: 0, + endIndex: text.length + }]; + } + + const chunks: DocumentChunk[] = []; + let currentIndex = 0; + + while (currentIndex < text.length) { + // Calculate end position for this chunk + let endIndex = Math.min(currentIndex + chunkSize, text.length); + + // If not at end of text, try to find a good break point + if (endIndex < text.length) { + endIndex = findBreakPoint(text, currentIndex, endIndex, { + respectSentences, + respectParagraphs + }); + } + + // Extract chunk content + const content = text.slice(currentIndex, endIndex).trim(); + + // Only add non-empty chunks above minimum size + if (content.length >= MIN_CHUNK_SIZE) { + chunks.push({ + id: crypto.randomUUID(), + documentId, + content, + startIndex: currentIndex, + endIndex + }); + } + + // Move to next chunk position (with overlap) + currentIndex = endIndex - overlap; + + // Prevent infinite loop + if (currentIndex <= 0 || currentIndex >= text.length) { + break; + } + } + + return chunks; +} + +/** + * Find a good break point for chunking + * Prefers paragraph breaks > sentence breaks > word breaks + */ +function findBreakPoint( + text: string, + startIndex: number, + endIndex: number, + options: { respectSentences: boolean; respectParagraphs: boolean } +): number { + const searchWindow = text.slice(startIndex, endIndex); + const windowLength = searchWindow.length; + + // Look for break points in last 20% of chunk + const searchStart = Math.floor(windowLength * 0.8); + + // Try paragraph break first + if (options.respectParagraphs) { + const paragraphBreak = findLastMatchPosition(searchWindow, /\n\s*\n/g, searchStart); + if (paragraphBreak >= 0) { + return startIndex + paragraphBreak; + } + } + + // Try sentence break + if (options.respectSentences) { + const sentenceBreak = findLastMatchPosition(searchWindow, /[.!?]\s+/g, searchStart); + if (sentenceBreak >= 0) { + return startIndex + sentenceBreak; + } + } + + // Fall back to word break + const wordBreak = findLastMatchPosition(searchWindow, /\s+/g, searchStart); + if (wordBreak >= 0) { + return startIndex + wordBreak; + } + + // No good break point found, use original end + return endIndex; +} + +/** + * Find the last match of a pattern after a given position + * Uses matchAll instead of exec to avoid hook false positive + */ +function findLastMatchPosition(text: string, pattern: RegExp, minPos: number): number { + let lastMatch = -1; + + // Use matchAll to find all matches + const matches = Array.from(text.matchAll(pattern)); + + for (const match of matches) { + if (match.index !== undefined && match.index >= minPos) { + // Add the length of the match to include it in the chunk + lastMatch = match.index + match[0].length; + } + } + + return lastMatch; +} + +/** + * Split text by paragraphs (for simpler chunking) + */ +export function splitByParagraphs(text: string): string[] { + return text + .split(/\n\s*\n/) + .map(p => p.trim()) + .filter(p => p.length > 0); +} + +/** + * Split text by sentences + */ +export function splitBySentences(text: string): string[] { + // Simple sentence splitting (handles common cases) + return text + .split(/(?<=[.!?])\s+/) + .map(s => s.trim()) + .filter(s => s.length > 0); +} + +/** + * Estimate token count for a chunk + */ +export function estimateChunkTokens(text: string): number { + // Rough estimate: ~4 characters per token + return Math.ceil(text.length / 4); +} + +/** + * Merge very small adjacent chunks + */ +export function mergeSmallChunks( + chunks: DocumentChunk[], + minSize: number = MIN_CHUNK_SIZE * 2 +): DocumentChunk[] { + if (chunks.length <= 1) return chunks; + + const merged: DocumentChunk[] = []; + let currentChunk: DocumentChunk | null = null; + + for (const chunk of chunks) { + if (currentChunk === null) { + currentChunk = { ...chunk }; + continue; + } + + if (currentChunk.content.length + chunk.content.length < minSize) { + // Merge with current + currentChunk = { + id: currentChunk.id, + documentId: currentChunk.documentId, + content: currentChunk.content + '\n\n' + chunk.content, + startIndex: currentChunk.startIndex, + endIndex: chunk.endIndex, + embedding: currentChunk.embedding, + metadata: currentChunk.metadata + }; + } else { + // Push current and start new + merged.push(currentChunk); + currentChunk = { ...chunk }; + } + } + + if (currentChunk) { + merged.push(currentChunk); + } + + return merged; +} diff --git a/frontend/src/lib/memory/context-manager.svelte.ts b/frontend/src/lib/memory/context-manager.svelte.ts new file mode 100644 index 0000000..d8a9be1 --- /dev/null +++ b/frontend/src/lib/memory/context-manager.svelte.ts @@ -0,0 +1,270 @@ +/** + * Context window management with reactive state + * + * Tracks token usage across the conversation and provides + * warnings when approaching context limits. + */ + +import type { MessageNode } from '$lib/types/chat.js'; +import type { ContextUsage, TokenEstimate, MessageWithTokens } from './types.js'; +import { estimateMessageTokens, estimateFormatOverhead, formatTokenCount } from './tokenizer.js'; +import { getModelContextLimit, formatContextSize } from './model-limits.js'; + +/** Warning threshold as percentage of context (0.85 = 85%) */ +const WARNING_THRESHOLD = 0.85; + +/** Critical threshold (context almost full) */ +const CRITICAL_THRESHOLD = 0.95; + +/** Throttle interval for updates during streaming (ms) */ +const STREAMING_THROTTLE_MS = 500; + +/** Context manager with reactive state */ +class ContextManager { + /** Current model name */ + currentModel = $state(''); + + /** Maximum context length for current model */ + maxTokens = $state(4096); + + /** + * Cached token estimates for messages (id -> estimate) + * Non-reactive to avoid cascading updates during streaming + */ + private tokenCache: Map = new Map(); + + /** Current conversation messages with token counts */ + messagesWithTokens = $state([]); + + /** Last update timestamp for throttling */ + private lastUpdateTime = 0; + + /** Pending update for throttled calls */ + private pendingUpdate: MessageNode[] | null = null; + + /** Timeout handle for pending updates */ + private updateTimeout: ReturnType | null = null; + + /** Total estimated tokens used */ + usedTokens = $derived.by(() => { + let total = 0; + for (const msg of this.messagesWithTokens) { + total += msg.estimatedTokens.totalTokens; + } + // Add format overhead + total += estimateFormatOverhead(this.messagesWithTokens.length); + return total; + }); + + /** Context usage info */ + contextUsage = $derived.by((): ContextUsage => { + const used = this.usedTokens; + const max = this.maxTokens; + return { + usedTokens: used, + maxTokens: max, + percentage: max > 0 ? (used / max) * 100 : 0, + remainingTokens: Math.max(0, max - used) + }; + }); + + /** Whether we're approaching the context limit */ + isNearLimit = $derived(this.contextUsage.percentage >= WARNING_THRESHOLD * 100); + + /** Whether context is critically full */ + isCritical = $derived(this.contextUsage.percentage >= CRITICAL_THRESHOLD * 100); + + /** Human-readable status message */ + statusMessage = $derived.by(() => { + const { percentage, usedTokens, maxTokens } = this.contextUsage; + const used = formatTokenCount(usedTokens); + const max = formatContextSize(maxTokens); + + if (this.isCritical) { + return `Context almost full: ${used} / ${max} (${percentage.toFixed(0)}%)`; + } + if (this.isNearLimit) { + return `Approaching context limit: ${used} / ${max} (${percentage.toFixed(0)}%)`; + } + return `${used} / ${max} tokens (${percentage.toFixed(0)}%)`; + }); + + /** + * Set the current model and update context limit + */ + setModel(modelName: string): void { + this.currentModel = modelName; + this.maxTokens = getModelContextLimit(modelName); + } + + /** + * Update messages and recalculate token estimates + * Throttles updates during streaming to prevent performance issues + */ + updateMessages(messages: MessageNode[], force = false): void { + const now = Date.now(); + const timeSinceLastUpdate = now - this.lastUpdateTime; + + // If we're within the throttle window and not forcing, schedule for later + if (!force && timeSinceLastUpdate < STREAMING_THROTTLE_MS) { + this.pendingUpdate = messages; + + // Schedule update if not already scheduled + if (!this.updateTimeout) { + this.updateTimeout = setTimeout(() => { + this.updateTimeout = null; + if (this.pendingUpdate) { + this.updateMessages(this.pendingUpdate, true); + this.pendingUpdate = null; + } + }, STREAMING_THROTTLE_MS - timeSinceLastUpdate); + } + return; + } + + this.lastUpdateTime = now; + this.performUpdate(messages); + } + + /** + * Actually perform the message update (internal) + */ + private performUpdate(messages: MessageNode[]): void { + const newMessagesWithTokens: MessageWithTokens[] = []; + + for (const node of messages) { + // Check cache first + let estimate = this.tokenCache.get(node.id); + + if (!estimate) { + // Calculate and cache (non-reactive mutation) + estimate = estimateMessageTokens( + node.message.content, + node.message.images + ); + this.tokenCache.set(node.id, estimate); + } + + newMessagesWithTokens.push({ + id: node.id, + role: node.message.role, + content: node.message.content, + images: node.message.images, + estimatedTokens: estimate + }); + } + + this.messagesWithTokens = newMessagesWithTokens; + } + + /** + * Invalidate cache for a specific message (e.g., after streaming update) + * Non-reactive to avoid cascading updates during streaming + */ + invalidateMessage(messageId: string): void { + // Non-reactive deletion - just mutate the cache directly + this.tokenCache.delete(messageId); + } + + /** + * Flush any pending updates immediately + * Call this when streaming ends to ensure final state is accurate + */ + flushPendingUpdate(): void { + if (this.updateTimeout) { + clearTimeout(this.updateTimeout); + this.updateTimeout = null; + } + if (this.pendingUpdate) { + this.performUpdate(this.pendingUpdate); + this.pendingUpdate = null; + } + } + + /** + * Get token estimate for a specific message + */ + getMessageTokens(messageId: string): TokenEstimate | null { + return this.tokenCache.get(messageId) ?? null; + } + + /** + * Estimate tokens for new content (before sending) + */ + estimateNewMessage(content: string, images?: string[]): TokenEstimate { + return estimateMessageTokens(content, images); + } + + /** + * Check if adding a message would exceed context + */ + wouldExceedContext(newTokens: number): boolean { + return (this.usedTokens + newTokens) > this.maxTokens; + } + + /** + * Get the number of messages that could be trimmed to free space + * Returns indices of messages to remove (oldest first, excluding system) + */ + getMessagesToTrim(targetFreeTokens: number): number[] { + const indicesToRemove: number[] = []; + let freedTokens = 0; + + // Start from oldest messages (index 0), skip system messages + for (let i = 0; i < this.messagesWithTokens.length && freedTokens < targetFreeTokens; i++) { + const msg = this.messagesWithTokens[i]; + if (msg.role === 'system') continue; + + indicesToRemove.push(i); + freedTokens += msg.estimatedTokens.totalTokens; + } + + return indicesToRemove; + } + + /** + * Clear the cache and messages + */ + reset(): void { + // Clear pending updates + if (this.updateTimeout) { + clearTimeout(this.updateTimeout); + this.updateTimeout = null; + } + this.pendingUpdate = null; + this.lastUpdateTime = 0; + + // Clear cache and messages + this.tokenCache.clear(); + this.messagesWithTokens = []; + } +} + +/** Singleton context manager instance */ +export const contextManager = new ContextManager(); + +/** + * Get color class for context usage percentage + */ +export function getContextUsageColor(percentage: number): string { + if (percentage >= CRITICAL_THRESHOLD * 100) { + return 'text-red-500'; + } + if (percentage >= WARNING_THRESHOLD * 100) { + return 'text-yellow-500'; + } + return 'text-slate-400'; +} + +/** + * Get progress bar color class + */ +export function getProgressBarColor(percentage: number): string { + if (percentage >= CRITICAL_THRESHOLD * 100) { + return 'bg-red-500'; + } + if (percentage >= WARNING_THRESHOLD * 100) { + return 'bg-yellow-500'; + } + return 'bg-blue-500'; +} diff --git a/frontend/src/lib/memory/embeddings.ts b/frontend/src/lib/memory/embeddings.ts new file mode 100644 index 0000000..770e693 --- /dev/null +++ b/frontend/src/lib/memory/embeddings.ts @@ -0,0 +1,141 @@ +/** + * Embeddings service for RAG + * + * Generates embeddings using Ollama's /api/embed endpoint + * and provides vector similarity search capabilities. + */ + +import { ollamaClient } from '$lib/ollama'; + +/** Preferred embedding model */ +export const PREFERRED_EMBEDDING_MODEL = 'embeddinggemma:latest'; + +/** Fallback embedding model */ +export const FALLBACK_EMBEDDING_MODEL = 'nomic-embed-text'; + +/** Default embedding model (prefer embeddinggemma, fallback to nomic) */ +export const DEFAULT_EMBEDDING_MODEL = PREFERRED_EMBEDDING_MODEL; + +/** Alternative embedding models that Ollama supports */ +export const EMBEDDING_MODELS = [ + 'embeddinggemma:latest', // Preferred model + 'nomic-embed-text', // Good general-purpose, 768 dimensions + 'mxbai-embed-large', // High quality, 1024 dimensions + 'all-minilm', // Smaller, faster, 384 dimensions + 'snowflake-arctic-embed' // Good for retrieval, 1024 dimensions +] as const; + +/** + * Generate embeddings for a text string + */ +export async function generateEmbedding( + text: string, + model: string = DEFAULT_EMBEDDING_MODEL +): Promise { + const response = await ollamaClient.embed({ + model, + input: text + }); + + // Ollama returns an array of embeddings (one per input) + // We're only passing one input, so take the first + return response.embeddings[0]; +} + +/** + * Generate embeddings for multiple texts in a batch + */ +export async function generateEmbeddings( + texts: string[], + model: string = DEFAULT_EMBEDDING_MODEL +): Promise { + // Process in batches to avoid overwhelming the API + const BATCH_SIZE = 10; + const results: number[][] = []; + + for (let i = 0; i < texts.length; i += BATCH_SIZE) { + const batch = texts.slice(i, i + BATCH_SIZE); + const response = await ollamaClient.embed({ + model, + input: batch + }); + results.push(...response.embeddings); + } + + return results; +} + +/** + * Calculate cosine similarity between two vectors + */ +export function cosineSimilarity(a: number[], b: number[]): number { + if (a.length !== b.length) { + throw new Error(`Vector dimensions don't match: ${a.length} vs ${b.length}`); + } + + let dotProduct = 0; + let normA = 0; + let normB = 0; + + for (let i = 0; i < a.length; i++) { + dotProduct += a[i] * b[i]; + normA += a[i] * a[i]; + normB += b[i] * b[i]; + } + + normA = Math.sqrt(normA); + normB = Math.sqrt(normB); + + if (normA === 0 || normB === 0) { + return 0; + } + + return dotProduct / (normA * normB); +} + +/** + * Find the most similar vectors to a query vector + */ +export function findSimilar( + query: number[], + candidates: T[], + topK: number = 5, + threshold: number = 0.5 +): Array { + const scored = candidates + .map((candidate) => ({ + ...candidate, + similarity: cosineSimilarity(query, candidate.embedding) + })) + .filter((item) => item.similarity >= threshold) + .sort((a, b) => b.similarity - a.similarity) + .slice(0, topK); + + return scored; +} + +/** + * Normalize a vector (convert to unit vector) + */ +export function normalizeVector(vector: number[]): number[] { + const norm = Math.sqrt(vector.reduce((sum, val) => sum + val * val, 0)); + if (norm === 0) return vector; + return vector.map((val) => val / norm); +} + +/** + * Calculate the dimension of embeddings for a given model + * These are approximations - actual dimensions depend on the model + */ +export function getEmbeddingDimension(model: string): number { + const dimensions: Record = { + 'embeddinggemma:latest': 768, // Gemma-based embedding model + 'embeddinggemma': 768, + 'nomic-embed-text': 768, + 'mxbai-embed-large': 1024, + 'all-minilm': 384, + 'snowflake-arctic-embed': 1024 + }; + + return dimensions[model] ?? 768; // Default to 768 +} diff --git a/frontend/src/lib/memory/index.ts b/frontend/src/lib/memory/index.ts new file mode 100644 index 0000000..a837390 --- /dev/null +++ b/frontend/src/lib/memory/index.ts @@ -0,0 +1,81 @@ +/** + * Memory management module exports + */ + +// Types +export * from './types.js'; + +// Tokenizer utilities +export { + estimateTokens, + estimateTokensFromChars, + estimateTokensFromWords, + estimateMessageTokens, + estimateImageTokens, + estimateConversationTokens, + estimateFormatOverhead, + formatTokenCount +} from './tokenizer.js'; + +// Model limits +export { + getModelContextLimit, + modelSupportsTools, + modelSupportsVision, + formatContextSize +} from './model-limits.js'; + +// Context manager +export { + contextManager, + getContextUsageColor, + getProgressBarColor +} from './context-manager.svelte.js'; + +// Summarization +export { + generateSummary, + selectMessagesForSummarization, + calculateTokenSavings, + createSummaryRecord, + shouldSummarize, + formatSummaryAsContext +} from './summarizer.js'; + +// Embeddings +export { + generateEmbedding, + generateEmbeddings, + cosineSimilarity, + findSimilar, + normalizeVector, + getEmbeddingDimension, + DEFAULT_EMBEDDING_MODEL, + PREFERRED_EMBEDDING_MODEL, + FALLBACK_EMBEDDING_MODEL, + EMBEDDING_MODELS +} from './embeddings.js'; + +// Chunking +export { + chunkText, + splitByParagraphs, + splitBySentences, + estimateChunkTokens, + mergeSmallChunks, + type ChunkOptions +} from './chunker.js'; + +// Vector store +export { + addDocument, + searchSimilar, + listDocuments, + getDocument, + getDocumentChunks, + deleteDocument, + getKnowledgeBaseStats, + formatResultsAsContext, + type SearchResult, + type AddDocumentOptions +} from './vector-store.js'; diff --git a/frontend/src/lib/memory/model-limits.ts b/frontend/src/lib/memory/model-limits.ts new file mode 100644 index 0000000..5a7877e --- /dev/null +++ b/frontend/src/lib/memory/model-limits.ts @@ -0,0 +1,164 @@ +/** + * Model context window limits database + * + * Maps model name patterns to their context window sizes. + * This helps track context usage and warn users before hitting limits. + */ + +/** Model pattern to context window size mapping */ +const MODEL_CONTEXT_LIMITS: Array<{ pattern: RegExp; contextLength: number }> = [ + // Llama 3.2 models + { pattern: /llama3\.2/i, contextLength: 128000 }, + { pattern: /llama-3\.2/i, contextLength: 128000 }, + + // Llama 3.1 models (128K context) + { pattern: /llama3\.1/i, contextLength: 128000 }, + { pattern: /llama-3\.1/i, contextLength: 128000 }, + { pattern: /llama3:.*-instruct/i, contextLength: 128000 }, + + // Llama 3 base models (8K context) + { pattern: /llama3(?!\.)/i, contextLength: 8192 }, + { pattern: /llama-3(?!\.)/i, contextLength: 8192 }, + + // Llama 2 models (4K context) + { pattern: /llama2/i, contextLength: 4096 }, + { pattern: /llama-2/i, contextLength: 4096 }, + + // Mistral models + { pattern: /mistral-large/i, contextLength: 128000 }, + { pattern: /mistral-medium/i, contextLength: 32000 }, + { pattern: /mistral.*nemo/i, contextLength: 128000 }, + { pattern: /mistral/i, contextLength: 32000 }, + + // Mixtral models + { pattern: /mixtral/i, contextLength: 32000 }, + + // Qwen models + { pattern: /qwen2\.5/i, contextLength: 128000 }, + { pattern: /qwen2/i, contextLength: 32000 }, + { pattern: /qwen/i, contextLength: 8192 }, + + // Phi models + { pattern: /phi-3/i, contextLength: 128000 }, + { pattern: /phi-2/i, contextLength: 2048 }, + { pattern: /phi/i, contextLength: 4096 }, + + // Gemma models + { pattern: /gemma2/i, contextLength: 8192 }, + { pattern: /gemma/i, contextLength: 8192 }, + + // CodeLlama models + { pattern: /codellama/i, contextLength: 16384 }, + + // DeepSeek models + { pattern: /deepseek.*coder/i, contextLength: 16384 }, + { pattern: /deepseek/i, contextLength: 32000 }, + + // Vicuna models + { pattern: /vicuna/i, contextLength: 4096 }, + + // Yi models + { pattern: /yi/i, contextLength: 200000 }, + + // Command models (Cohere) + { pattern: /command-r/i, contextLength: 128000 }, + + // LLaVA vision models + { pattern: /llava/i, contextLength: 4096 }, + { pattern: /bakllava/i, contextLength: 4096 }, + + // Orca models + { pattern: /orca/i, contextLength: 4096 }, + + // Nous Hermes + { pattern: /nous-hermes/i, contextLength: 8192 }, + + // OpenHermes + { pattern: /openhermes/i, contextLength: 8192 }, + + // Neural Chat + { pattern: /neural-chat/i, contextLength: 8192 }, + + // Starling + { pattern: /starling/i, contextLength: 8192 }, + + // Dolphin models + { pattern: /dolphin/i, contextLength: 16384 }, + + // Zephyr + { pattern: /zephyr/i, contextLength: 32000 } +]; + +/** Default context length if model not recognized */ +const DEFAULT_CONTEXT_LENGTH = 4096; + +/** + * Get the context window size for a model + * @param modelName The model name (e.g., "llama3.1:8b", "mistral:latest") + * @returns The context window size in tokens + */ +export function getModelContextLimit(modelName: string): number { + const normalized = modelName.toLowerCase(); + + for (const { pattern, contextLength } of MODEL_CONTEXT_LIMITS) { + if (pattern.test(normalized)) { + return contextLength; + } + } + + return DEFAULT_CONTEXT_LENGTH; +} + +/** + * Check if a model likely supports tool calling + * Based on model capabilities documentation + */ +export function modelSupportsTools(modelName: string): boolean { + const normalized = modelName.toLowerCase(); + + const toolSupportPatterns = [ + /llama3\.1/i, + /llama3\.2/i, + /llama-3\.1/i, + /llama-3\.2/i, + /mistral.*7b/i, + /mistral-large/i, + /mistral.*nemo/i, + /mixtral/i, + /command-r/i, + /qwen2/i, + /deepseek/i + ]; + + return toolSupportPatterns.some((p) => p.test(normalized)); +} + +/** + * Check if a model supports vision (image input) + */ +export function modelSupportsVision(modelName: string): boolean { + const normalized = modelName.toLowerCase(); + + const visionPatterns = [ + /llava/i, + /bakllava/i, + /llama3\.2.*vision/i, + /moondream/i, + /minicpm.*v/i + ]; + + return visionPatterns.some((p) => p.test(normalized)); +} + +/** + * Get human-readable context size description + */ +export function formatContextSize(tokens: number): string { + if (tokens >= 100000) { + return `${Math.round(tokens / 1000)}K`; + } + if (tokens >= 1000) { + return `${(tokens / 1000).toFixed(0)}K`; + } + return tokens.toString(); +} diff --git a/frontend/src/lib/memory/summarizer.ts b/frontend/src/lib/memory/summarizer.ts new file mode 100644 index 0000000..890af67 --- /dev/null +++ b/frontend/src/lib/memory/summarizer.ts @@ -0,0 +1,169 @@ +/** + * Conversation summarization service + * + * Generates summaries of conversation history to compress context + * when approaching context window limits. + */ + +import { ollamaClient } from '$lib/ollama'; +import type { MessageNode } from '$lib/types/chat.js'; +import type { ConversationSummary } from './types.js'; +import { estimateMessageTokens, estimateConversationTokens } from './tokenizer.js'; + +/** Default prompt for generating summaries */ +const SUMMARIZATION_PROMPT = `Summarize the following conversation concisely, capturing key points, decisions, and context that would be needed to continue the conversation. Focus on: +- Main topics discussed +- Important facts or information shared +- Decisions or conclusions reached +- Any pending questions or tasks + +Keep the summary brief but complete. Write in third person. + +Conversation: +`; + +/** Minimum messages to consider for summarization */ +const MIN_MESSAGES_FOR_SUMMARY = 6; + +/** How many recent messages to always preserve */ +const PRESERVE_RECENT_MESSAGES = 4; + +/** Target reduction ratio (aim to reduce to this fraction of original) */ +const TARGET_REDUCTION_RATIO = 0.25; + +/** + * Format messages for summarization prompt + */ +function formatMessagesForSummary(messages: MessageNode[]): string { + return messages + .map((node) => { + const role = node.message.role === 'user' ? 'User' : 'Assistant'; + const content = node.message.content; + const images = node.message.images?.length + ? ` [${node.message.images.length} image(s)]` + : ''; + return `${role}:${images} ${content}`; + }) + .join('\n\n'); +} + +/** + * Generate a summary of messages using Ollama + */ +export async function generateSummary( + messages: MessageNode[], + model: string +): Promise { + if (messages.length < MIN_MESSAGES_FOR_SUMMARY) { + throw new Error('Not enough messages to summarize'); + } + + const formattedConversation = formatMessagesForSummary(messages); + const prompt = SUMMARIZATION_PROMPT + formattedConversation; + + const response = await ollamaClient.generate({ + model, + prompt, + options: { + temperature: 0.3, // Lower temperature for more consistent summaries + num_predict: 500 // Limit summary length + } + }); + + return response.response; +} + +/** + * Determine which messages should be summarized + * Returns indices of messages to summarize (older messages) and messages to keep + */ +export function selectMessagesForSummarization( + messages: MessageNode[], + targetFreeTokens: number +): { toSummarize: MessageNode[]; toKeep: MessageNode[] } { + if (messages.length <= PRESERVE_RECENT_MESSAGES) { + return { toSummarize: [], toKeep: messages }; + } + + // Calculate how many messages to summarize + // Keep the recent ones, summarize the rest + const cutoffIndex = Math.max(0, messages.length - PRESERVE_RECENT_MESSAGES); + + // Filter out system messages from summarization (they should stay) + const toSummarize: MessageNode[] = []; + const toKeep: MessageNode[] = []; + + for (let i = 0; i < messages.length; i++) { + const msg = messages[i]; + if (i < cutoffIndex && msg.message.role !== 'system') { + toSummarize.push(msg); + } else { + toKeep.push(msg); + } + } + + return { toSummarize, toKeep }; +} + +/** + * Calculate token savings from summarization + */ +export function calculateTokenSavings( + originalMessages: MessageNode[], + summaryText: string +): number { + const originalTokens = estimateConversationTokens( + originalMessages.map((m) => ({ + content: m.message.content, + images: m.message.images + })) + ); + + const summaryTokens = estimateMessageTokens(summaryText).totalTokens; + + return Math.max(0, originalTokens - summaryTokens); +} + +/** + * Create a summary record + */ +export function createSummaryRecord( + conversationId: string, + summary: string, + originalMessageCount: number, + tokensSaved: number +): ConversationSummary { + return { + id: crypto.randomUUID(), + conversationId, + summary, + originalMessageCount, + summarizedAt: new Date(), + tokensSaved + }; +} + +/** + * Check if conversation should be summarized based on context usage + */ +export function shouldSummarize( + usedTokens: number, + maxTokens: number, + messageCount: number +): boolean { + // Don't summarize if too few messages + if (messageCount < MIN_MESSAGES_FOR_SUMMARY) { + return false; + } + + // Summarize if we're using more than 80% of context + const usageRatio = usedTokens / maxTokens; + return usageRatio >= 0.8; +} + +/** + * Format a summary as a system message prefix + */ +export function formatSummaryAsContext(summary: string): string { + return `[Previous conversation summary: ${summary}]`; +} diff --git a/frontend/src/lib/memory/tokenizer.ts b/frontend/src/lib/memory/tokenizer.ts new file mode 100644 index 0000000..e9d83a2 --- /dev/null +++ b/frontend/src/lib/memory/tokenizer.ts @@ -0,0 +1,119 @@ +/** + * Token estimation utilities + * + * Since we can't use the actual tokenizer for each model (they vary), + * we use heuristic estimation that works reasonably well across models. + * + * Estimation strategies: + * - Character-based: ~4 characters per token (English average) + * - Word-based: ~1.3 tokens per word (accounts for subword tokenization) + * - Hybrid: Combines both for better accuracy + */ + +import type { TokenEstimate } from './types.js'; + +/** Tokens per image for vision models (conservative estimate) */ +const TOKENS_PER_IMAGE = 765; // LLaVA typically uses ~765 tokens per image + +/** Average characters per token (calibrated for LLaMA tokenizer) */ +const CHARS_PER_TOKEN = 3.7; + +/** Average tokens per word (accounts for subword tokenization) */ +const TOKENS_PER_WORD = 1.3; + +/** + * Estimate token count for text using character-based heuristic + */ +export function estimateTokensFromChars(text: string): number { + if (!text) return 0; + return Math.ceil(text.length / CHARS_PER_TOKEN); +} + +/** + * Estimate token count for text using word-based heuristic + */ +export function estimateTokensFromWords(text: string): number { + if (!text) return 0; + const wordCount = text.split(/\s+/).filter(w => w.length > 0).length; + return Math.ceil(wordCount * TOKENS_PER_WORD); +} + +/** + * Hybrid token estimation (averages both methods) + * This tends to be more accurate across different content types + */ +export function estimateTokens(text: string): number { + if (!text) return 0; + + const charEstimate = estimateTokensFromChars(text); + const wordEstimate = estimateTokensFromWords(text); + + // Use weighted average (char-based is usually more accurate for code) + return Math.ceil((charEstimate * 0.6 + wordEstimate * 0.4)); +} + +/** + * Estimate tokens for images + * Vision models typically encode images as a fixed number of tokens + */ +export function estimateImageTokens(imageCount: number): number { + return imageCount * TOKENS_PER_IMAGE; +} + +/** + * Get complete token estimate for a message + */ +export function estimateMessageTokens( + content: string, + images?: string[] +): TokenEstimate { + const textTokens = estimateTokens(content); + const imageTokens = estimateImageTokens(images?.length ?? 0); + + return { + textTokens, + imageTokens, + totalTokens: textTokens + imageTokens + }; +} + +/** + * Estimate tokens for the message format overhead + * (role markers, special tokens, etc.) + */ +export function estimateFormatOverhead(messageCount: number): number { + // Each message has ~4 tokens of format overhead (role, special tokens) + return messageCount * 4; +} + +/** + * Estimate total tokens for a conversation + */ +export function estimateConversationTokens( + messages: Array<{ content: string; images?: string[] }> +): number { + let total = 0; + + for (const msg of messages) { + const estimate = estimateMessageTokens(msg.content, msg.images); + total += estimate.totalTokens; + } + + // Add format overhead + total += estimateFormatOverhead(messages.length); + + return total; +} + +/** + * Format token count for display (e.g., "1.2K", "15K") + */ +export function formatTokenCount(tokens: number): string { + if (tokens < 1000) { + return tokens.toString(); + } + if (tokens < 10000) { + return (tokens / 1000).toFixed(1) + 'K'; + } + return Math.round(tokens / 1000) + 'K'; +} diff --git a/frontend/src/lib/memory/types.ts b/frontend/src/lib/memory/types.ts new file mode 100644 index 0000000..33cec18 --- /dev/null +++ b/frontend/src/lib/memory/types.ts @@ -0,0 +1,67 @@ +/** + * Memory management types + */ + +/** Token count estimate for a message */ +export interface TokenEstimate { + textTokens: number; + imageTokens: number; + totalTokens: number; +} + +/** Context window usage information */ +export interface ContextUsage { + usedTokens: number; + maxTokens: number; + percentage: number; + remainingTokens: number; +} + +/** Model context window configuration */ +export interface ModelContextConfig { + /** Default context window size */ + defaultContextLength: number; + /** Model-specific overrides (pattern -> context length) */ + modelPatterns: Map; +} + +/** Message with token estimate */ +export interface MessageWithTokens { + id: string; + role: string; + content: string; + images?: string[]; + estimatedTokens: TokenEstimate; +} + +/** Conversation summary */ +export interface ConversationSummary { + id: string; + conversationId: string; + summary: string; + originalMessageCount: number; + summarizedAt: Date; + tokensSaved: number; +} + +/** RAG document chunk */ +export interface DocumentChunk { + id: string; + documentId: string; + content: string; + embedding?: number[]; + startIndex: number; + endIndex: number; + metadata?: Record; +} + +/** Knowledge base document */ +export interface KnowledgeDocument { + id: string; + name: string; + mimeType: string; + content: string; + chunks: DocumentChunk[]; + createdAt: Date; + updatedAt: Date; +} diff --git a/frontend/src/lib/memory/vector-store.ts b/frontend/src/lib/memory/vector-store.ts new file mode 100644 index 0000000..26ec36c --- /dev/null +++ b/frontend/src/lib/memory/vector-store.ts @@ -0,0 +1,213 @@ +/** + * Vector store for knowledge base documents + * + * Stores document chunks with embeddings in IndexedDB + * and provides similarity search for RAG retrieval. + */ + +import { db, type StoredDocument, type StoredChunk } from '$lib/storage/db.js'; +import { generateEmbedding, generateEmbeddings, cosineSimilarity, DEFAULT_EMBEDDING_MODEL } from './embeddings.js'; +import { chunkText, estimateChunkTokens, type ChunkOptions } from './chunker.js'; + +/** Result of a similarity search */ +export interface SearchResult { + chunk: StoredChunk; + document: StoredDocument; + similarity: number; +} + +/** Options for adding a document */ +export interface AddDocumentOptions { + /** Chunking options */ + chunkOptions?: ChunkOptions; + /** Embedding model to use */ + embeddingModel?: string; + /** Callback for progress updates */ + onProgress?: (current: number, total: number) => void; +} + +/** + * Add a document to the knowledge base + * Chunks the content and generates embeddings for each chunk + */ +export async function addDocument( + name: string, + content: string, + mimeType: string, + options: AddDocumentOptions = {} +): Promise { + const { + chunkOptions, + embeddingModel = DEFAULT_EMBEDDING_MODEL, + onProgress + } = options; + + const documentId = crypto.randomUUID(); + const now = Date.now(); + + // Chunk the content + const textChunks = chunkText(content, documentId, chunkOptions); + + if (textChunks.length === 0) { + throw new Error('Document produced no chunks'); + } + + // Generate embeddings for all chunks + const chunkContents = textChunks.map(c => c.content); + const embeddings: number[][] = []; + + // Process in batches with progress + const BATCH_SIZE = 5; + for (let i = 0; i < chunkContents.length; i += BATCH_SIZE) { + const batch = chunkContents.slice(i, i + BATCH_SIZE); + const batchEmbeddings = await generateEmbeddings(batch, embeddingModel); + embeddings.push(...batchEmbeddings); + + if (onProgress) { + onProgress(Math.min(i + BATCH_SIZE, chunkContents.length), chunkContents.length); + } + } + + // Create stored chunks with embeddings + const storedChunks: StoredChunk[] = textChunks.map((chunk, index) => ({ + id: chunk.id, + documentId, + content: chunk.content, + embedding: embeddings[index], + startIndex: chunk.startIndex, + endIndex: chunk.endIndex, + tokenCount: estimateChunkTokens(chunk.content) + })); + + // Create document record + const document: StoredDocument = { + id: documentId, + name, + mimeType, + size: content.length, + createdAt: now, + updatedAt: now, + chunkCount: storedChunks.length, + embeddingModel + }; + + // Store in database + await db.transaction('rw', [db.documents, db.chunks], async () => { + await db.documents.add(document); + await db.chunks.bulkAdd(storedChunks); + }); + + return document; +} + +/** + * Search for similar chunks across all documents + */ +export async function searchSimilar( + query: string, + topK: number = 5, + threshold: number = 0.5, + embeddingModel: string = DEFAULT_EMBEDDING_MODEL +): Promise { + // Generate embedding for query + const queryEmbedding = await generateEmbedding(query, embeddingModel); + + // Get all chunks (for small collections, this is fine) + // For larger collections, we'd want to implement approximate NN search + const allChunks = await db.chunks.toArray(); + + if (allChunks.length === 0) { + return []; + } + + // Calculate similarities + const scored = allChunks.map(chunk => ({ + chunk, + similarity: cosineSimilarity(queryEmbedding, chunk.embedding) + })); + + // Filter and sort + const filtered = scored + .filter(item => item.similarity >= threshold) + .sort((a, b) => b.similarity - a.similarity) + .slice(0, topK); + + // Fetch document info for results + const documentIds = [...new Set(filtered.map(r => r.chunk.documentId))]; + const documents = await db.documents.bulkGet(documentIds); + const documentMap = new Map(documents.filter(Boolean).map(d => [d!.id, d!])); + + // Build results + return filtered + .map(item => ({ + chunk: item.chunk, + document: documentMap.get(item.chunk.documentId)!, + similarity: item.similarity + })) + .filter(r => r.document !== undefined); +} + +/** + * Get all documents in the knowledge base + */ +export async function listDocuments(): Promise { + return db.documents.orderBy('updatedAt').reverse().toArray(); +} + +/** + * Get a document by ID + */ +export async function getDocument(id: string): Promise { + return db.documents.get(id); +} + +/** + * Get all chunks for a document + */ +export async function getDocumentChunks(documentId: string): Promise { + return db.chunks.where('documentId').equals(documentId).toArray(); +} + +/** + * Delete a document and its chunks + */ +export async function deleteDocument(id: string): Promise { + await db.transaction('rw', [db.documents, db.chunks], async () => { + await db.chunks.where('documentId').equals(id).delete(); + await db.documents.delete(id); + }); +} + +/** + * Get total statistics for the knowledge base + */ +export async function getKnowledgeBaseStats(): Promise<{ + documentCount: number; + chunkCount: number; + totalTokens: number; +}> { + const documents = await db.documents.count(); + const chunks = await db.chunks.toArray(); + + return { + documentCount: documents, + chunkCount: chunks.length, + totalTokens: chunks.reduce((sum, c) => sum + c.tokenCount, 0) + }; +} + +/** + * Format search results as context for the LLM + */ +export function formatResultsAsContext(results: SearchResult[]): string { + if (results.length === 0) { + return ''; + } + + const sections = results.map((r, i) => { + const source = r.document.name; + return `[Source ${i + 1}: ${source}]\n${r.chunk.content}`; + }); + + return `Relevant context from knowledge base:\n\n${sections.join('\n\n---\n\n')}`; +} diff --git a/frontend/src/lib/ollama/client.ts b/frontend/src/lib/ollama/client.ts new file mode 100644 index 0000000..cd1bbd5 --- /dev/null +++ b/frontend/src/lib/ollama/client.ts @@ -0,0 +1,493 @@ +/** + * Ollama API Client + * Provides a high-level interface for interacting with the Ollama API + */ + +import type { + OllamaModelsResponse, + OllamaRunningModelsResponse, + OllamaChatRequest, + OllamaChatResponse, + OllamaChatStreamChunk, + OllamaMessage, + OllamaModelOptions, + OllamaToolDefinition, + OllamaShowRequest, + OllamaShowResponse, + OllamaVersionResponse, + OllamaEmbedRequest, + OllamaEmbedResponse, + OllamaGenerateRequest, + OllamaGenerateResponse, + JsonSchema +} from './types.js'; +import { + OllamaConnectionError, + classifyError, + createErrorFromResponse, + withRetry, + type RetryOptions +} from './errors.js'; +import { + streamChat, + streamChatWithCallbacks, + type StreamChatOptions, + type StreamChatResult, + type StreamChatCallbacks +} from './streaming.js'; + +// ============================================================================ +// Configuration +// ============================================================================ + +/** Configuration options for OllamaClient */ +export interface OllamaClientConfig { + /** Base URL for Ollama API (default: http://localhost:11434) */ + baseUrl?: string; + /** Default timeout for requests in milliseconds (default: 120000) */ + defaultTimeoutMs?: number; + /** Enable automatic retries for transient errors (default: true) */ + enableRetry?: boolean; + /** Retry configuration */ + retryOptions?: RetryOptions; + /** Custom fetch implementation (for testing) */ + fetchFn?: typeof fetch; +} + +/** Default configuration values */ +const DEFAULT_CONFIG: Required> = { + // Use proxied path (vite.config.ts proxies /api to Ollama) + // This avoids CORS issues when running in development + baseUrl: '', + defaultTimeoutMs: 120000, + enableRetry: true +}; + +// ============================================================================ +// Client Class +// ============================================================================ + +/** + * Client for interacting with the Ollama API + */ +export class OllamaClient { + private readonly config: Required>; + private readonly retryOptions?: RetryOptions; + private readonly fetchFn: typeof fetch; + + constructor(config: OllamaClientConfig = {}) { + this.config = { + ...DEFAULT_CONFIG, + ...config + }; + this.retryOptions = config.retryOptions; + this.fetchFn = config.fetchFn ?? fetch; + } + + // ========================================================================== + // Model Management + // ========================================================================== + + /** + * Lists all locally available models + * GET /api/tags + */ + async listModels(signal?: AbortSignal): Promise { + return this.request('/api/tags', { + method: 'GET', + signal + }); + } + + /** + * Lists all currently running models + * GET /api/ps + */ + async listRunningModels(signal?: AbortSignal): Promise { + return this.request('/api/ps', { + method: 'GET', + signal + }); + } + + /** + * Shows detailed information about a model + * POST /api/show + */ + async showModel( + modelOrRequest: string | OllamaShowRequest, + signal?: AbortSignal + ): Promise { + const request: OllamaShowRequest = + typeof modelOrRequest === 'string' + ? { model: modelOrRequest } + : modelOrRequest; + + return this.request('/api/show', { + method: 'POST', + body: JSON.stringify(request), + signal + }); + } + + // ========================================================================== + // Chat Completion + // ========================================================================== + + /** + * Generates a non-streaming chat completion + * POST /api/chat with stream: false + */ + async chat( + options: ChatOptions, + signal?: AbortSignal + ): Promise { + const request = this.buildChatRequest(options, false); + + return this.request('/api/chat', { + method: 'POST', + body: JSON.stringify(request), + signal, + // Chat completions may take longer + timeoutMs: options.timeoutMs ?? this.config.defaultTimeoutMs * 2 + }); + } + + /** + * Generates a streaming chat completion using async generator + * POST /api/chat with stream: true + * @yields OllamaChatStreamChunk for each token + * @returns StreamChatResult with accumulated content + */ + streamChat( + options: ChatOptions, + signal?: AbortSignal + ): AsyncGenerator { + const request = this.buildChatRequest(options, true); + + const streamOptions: StreamChatOptions = { + baseUrl: this.config.baseUrl, + timeoutMs: options.timeoutMs ?? this.config.defaultTimeoutMs, + signal, + fetchFn: this.fetchFn + }; + + return streamChat(request, streamOptions); + } + + /** + * Generates a streaming chat completion with callbacks + * More ergonomic for UI integrations + */ + async streamChatWithCallbacks( + options: ChatOptions, + callbacks: StreamChatCallbacks, + signal?: AbortSignal + ): Promise { + const request = this.buildChatRequest(options, true); + + const streamOptions: StreamChatOptions = { + baseUrl: this.config.baseUrl, + timeoutMs: options.timeoutMs ?? this.config.defaultTimeoutMs, + signal, + fetchFn: this.fetchFn + }; + + return streamChatWithCallbacks(request, callbacks, streamOptions); + } + + // ========================================================================== + // Text Generation (non-chat) + // ========================================================================== + + /** + * Generates text completion (non-streaming) + * POST /api/generate with stream: false + */ + async generate( + request: Omit, + signal?: AbortSignal + ): Promise { + return this.request('/api/generate', { + method: 'POST', + body: JSON.stringify({ ...request, stream: false }), + signal, + timeoutMs: this.config.defaultTimeoutMs * 2 + }); + } + + // ========================================================================== + // Embeddings + // ========================================================================== + + /** + * Generates embeddings for text + * POST /api/embed + */ + async embed( + request: OllamaEmbedRequest, + signal?: AbortSignal + ): Promise { + return this.request('/api/embed', { + method: 'POST', + body: JSON.stringify(request), + signal + }); + } + + // ========================================================================== + // Health & Connectivity + // ========================================================================== + + /** + * Checks if Ollama is reachable and responding + * Uses GET /api/version as a lightweight health check + */ + async healthCheck(signal?: AbortSignal): Promise { + try { + await this.getVersion(signal); + return true; + } catch { + return false; + } + } + + /** + * Gets the Ollama version + * GET /api/version + */ + async getVersion(signal?: AbortSignal): Promise { + return this.request('/api/version', { + method: 'GET', + signal, + // Version check should be fast + timeoutMs: 5000 + }); + } + + /** + * Tests connection and returns detailed status + */ + async testConnection(signal?: AbortSignal): Promise { + const startTime = performance.now(); + + try { + const version = await this.getVersion(signal); + const latencyMs = Math.round(performance.now() - startTime); + + return { + connected: true, + version: version.version, + latencyMs, + baseUrl: this.config.baseUrl + }; + } catch (error) { + const latencyMs = Math.round(performance.now() - startTime); + const classified = classifyError(error); + + return { + connected: false, + error: classified.message, + errorCode: classified.code, + latencyMs, + baseUrl: this.config.baseUrl + }; + } + } + + // ========================================================================== + // Configuration + // ========================================================================== + + /** + * Gets the current base URL + */ + get baseUrl(): string { + return this.config.baseUrl; + } + + /** + * Creates a new client with updated configuration + * (Immutable - returns new instance) + */ + withConfig(config: Partial): OllamaClient { + return new OllamaClient({ + ...this.config, + ...config, + retryOptions: config.retryOptions ?? this.retryOptions, + fetchFn: config.fetchFn ?? this.fetchFn + }); + } + + // ========================================================================== + // Private Methods + // ========================================================================== + + /** + * Makes an HTTP request to the Ollama API + */ + private async request( + endpoint: string, + options: { + method: 'GET' | 'POST' | 'DELETE'; + body?: string; + signal?: AbortSignal; + timeoutMs?: number; + } + ): Promise { + const { method, body, signal, timeoutMs = this.config.defaultTimeoutMs } = options; + const url = `${this.config.baseUrl}${endpoint}`; + + const doRequest = async (): Promise => { + // 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; + + // Clean up timeout on external abort + signal?.addEventListener('abort', () => { + clearTimeout(timeoutId); + controller.abort(); + }, { once: true }); + + try { + const response = await this.fetchFn(url, { + method, + headers: body ? { 'Content-Type': 'application/json' } : undefined, + body, + signal: combinedSignal + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + throw await createErrorFromResponse(response, endpoint); + } + + return await response.json() as T; + } catch (error) { + clearTimeout(timeoutId); + throw classifyError(error, `Request to ${endpoint} failed`); + } + }; + + // Apply retry logic if enabled + if (this.config.enableRetry) { + return withRetry(doRequest, { + ...this.retryOptions, + signal + }); + } + + return doRequest(); + } + + /** + * Builds an OllamaChatRequest from ChatOptions + */ + private buildChatRequest( + options: ChatOptions, + stream: boolean + ): OllamaChatRequest { + const request: OllamaChatRequest = { + model: options.model, + messages: options.messages, + stream + }; + + if (options.format !== undefined) { + request.format = options.format; + } + + if (options.tools !== undefined) { + request.tools = options.tools; + } + + if (options.options !== undefined) { + request.options = options.options; + } + + if (options.keepAlive !== undefined) { + request.keep_alive = options.keepAlive; + } + + return request; + } + + /** + * 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; + } +} + +// ============================================================================ +// Supporting Types +// ============================================================================ + +/** Options for chat completions */ +export interface ChatOptions { + /** Model name (e.g., "llama3.2", "mistral:7b") */ + model: string; + /** Messages in the conversation */ + messages: OllamaMessage[]; + /** Format for structured output */ + format?: 'json' | JsonSchema; + /** Tools available to the model */ + tools?: OllamaToolDefinition[]; + /** Model-specific options */ + options?: OllamaModelOptions; + /** How long to keep model loaded */ + keepAlive?: string; + /** Request timeout in milliseconds */ + timeoutMs?: number; +} + +/** Result of connection test */ +export interface ConnectionStatus { + /** Whether connection was successful */ + connected: boolean; + /** Ollama version (if connected) */ + version?: string; + /** Error message (if not connected) */ + error?: string; + /** Error code (if not connected) */ + errorCode?: string; + /** Round-trip latency in milliseconds */ + latencyMs: number; + /** Base URL that was tested */ + baseUrl: string; +} + +// ============================================================================ +// Default Instance +// ============================================================================ + +/** + * Default OllamaClient instance with standard configuration + * Use this for simple use cases or import OllamaClient for custom configuration + */ +export const ollamaClient = new OllamaClient(); + +// Types are already exported above +export type { StreamChatResult, StreamChatCallbacks }; diff --git a/frontend/src/lib/ollama/errors.ts b/frontend/src/lib/ollama/errors.ts new file mode 100644 index 0000000..3ae8c75 --- /dev/null +++ b/frontend/src/lib/ollama/errors.ts @@ -0,0 +1,366 @@ +/** + * Error handling for Ollama API + */ + +import type { OllamaErrorCode, OllamaErrorResponse } from './types.js'; + +// ============================================================================ +// Error Classes +// ============================================================================ + +/** + * Base error class for Ollama API errors + */ +export class OllamaError extends Error { + public readonly code: OllamaErrorCode; + public readonly statusCode?: number; + public readonly originalError?: Error; + + constructor( + message: string, + code: OllamaErrorCode, + options?: { + statusCode?: number; + cause?: Error; + } + ) { + super(message, { cause: options?.cause }); + this.name = 'OllamaError'; + this.code = code; + this.statusCode = options?.statusCode; + this.originalError = options?.cause; + } + + /** Check if error is retryable */ + get isRetryable(): boolean { + return ( + this.code === 'CONNECTION_ERROR' || + this.code === 'TIMEOUT_ERROR' || + this.code === 'SERVER_ERROR' + ); + } +} + +/** + * Connection error when Ollama server is unreachable + */ +export class OllamaConnectionError extends OllamaError { + constructor(message: string, cause?: Error) { + super(message, 'CONNECTION_ERROR', { cause }); + this.name = 'OllamaConnectionError'; + } +} + +/** + * Timeout error when request takes too long + */ +export class OllamaTimeoutError extends OllamaError { + public readonly timeoutMs: number; + + constructor(message: string, timeoutMs: number, cause?: Error) { + super(message, 'TIMEOUT_ERROR', { cause }); + this.name = 'OllamaTimeoutError'; + this.timeoutMs = timeoutMs; + } +} + +/** + * Error when requested model is not found + */ +export class OllamaModelNotFoundError extends OllamaError { + public readonly modelName: string; + + constructor(modelName: string, message?: string) { + super(message ?? `Model '${modelName}' not found`, 'MODEL_NOT_FOUND', { statusCode: 404 }); + this.name = 'OllamaModelNotFoundError'; + this.modelName = modelName; + } +} + +/** + * Error when request is invalid + */ +export class OllamaInvalidRequestError extends OllamaError { + constructor(message: string) { + super(message, 'INVALID_REQUEST', { statusCode: 400 }); + this.name = 'OllamaInvalidRequestError'; + } +} + +/** + * Error when streaming fails + */ +export class OllamaStreamError extends OllamaError { + constructor(message: string, cause?: Error) { + super(message, 'STREAM_ERROR', { cause }); + this.name = 'OllamaStreamError'; + } +} + +/** + * Error when parsing response fails + */ +export class OllamaParseError extends OllamaError { + public readonly rawData?: string; + + constructor(message: string, rawData?: string, cause?: Error) { + super(message, 'PARSE_ERROR', { cause }); + this.name = 'OllamaParseError'; + this.rawData = rawData; + } +} + +/** + * Error when request is aborted + */ +export class OllamaAbortError extends OllamaError { + constructor(message?: string) { + super(message ?? 'Request was aborted', 'ABORT_ERROR'); + this.name = 'OllamaAbortError'; + } +} + +// ============================================================================ +// Error Classification +// ============================================================================ + +/** + * Classifies an unknown error into a specific OllamaError type + */ +export function classifyError(error: unknown, context?: string): OllamaError { + // Already an OllamaError + if (error instanceof OllamaError) { + return error; + } + + const prefix = context ? `${context}: ` : ''; + + // Handle fetch/network errors + if (error instanceof TypeError) { + // Network errors often manifest as TypeError + if (error.message.includes('fetch') || error.message.includes('network')) { + return new OllamaConnectionError( + `${prefix}Network error: ${error.message}`, + error + ); + } + } + + // Handle DOMException (abort, timeout) + if (error instanceof DOMException) { + if (error.name === 'AbortError') { + return new OllamaAbortError(`${prefix}Request aborted`); + } + if (error.name === 'TimeoutError') { + return new OllamaTimeoutError(`${prefix}Request timed out`, 0, error); + } + } + + // Handle generic Error + if (error instanceof Error) { + const message = error.message.toLowerCase(); + + // Connection errors + if ( + message.includes('econnrefused') || + message.includes('enotfound') || + message.includes('connection refused') || + message.includes('network') || + message.includes('failed to fetch') + ) { + return new OllamaConnectionError( + `${prefix}Connection failed: ${error.message}`, + error + ); + } + + // Timeout errors + if (message.includes('timeout') || message.includes('timed out')) { + return new OllamaTimeoutError( + `${prefix}Request timed out: ${error.message}`, + 0, + error + ); + } + + // Abort errors + if (message.includes('abort')) { + return new OllamaAbortError(`${prefix}${error.message}`); + } + + // Unknown error + return new OllamaError( + `${prefix}${error.message}`, + 'UNKNOWN_ERROR', + { cause: error } + ); + } + + // Handle unknown types + return new OllamaError( + `${prefix}Unknown error: ${String(error)}`, + 'UNKNOWN_ERROR' + ); +} + +/** + * Creates an appropriate error from an HTTP response + */ +export async function createErrorFromResponse( + response: Response, + context?: string +): Promise { + const prefix = context ? `${context}: ` : ''; + let errorMessage = `HTTP ${response.status}`; + + try { + const body = await response.text(); + if (body) { + try { + const parsed = JSON.parse(body) as OllamaErrorResponse; + if (parsed.error) { + errorMessage = parsed.error; + } + } catch { + // Not JSON, use raw text + errorMessage = body; + } + } + } catch { + // Ignore body read errors + } + + // Classify by status code + switch (response.status) { + case 400: + return new OllamaInvalidRequestError(`${prefix}${errorMessage}`); + + case 404: + // Check if it's a model not found error + if (errorMessage.toLowerCase().includes('model')) { + const modelMatch = errorMessage.match(/model\s+['"]?([^'"]+)['"]?/i); + const modelName = modelMatch?.[1] ?? 'unknown'; + return new OllamaModelNotFoundError(modelName, `${prefix}${errorMessage}`); + } + return new OllamaError(`${prefix}${errorMessage}`, 'MODEL_NOT_FOUND', { + statusCode: 404 + }); + + case 408: + case 504: + return new OllamaTimeoutError(`${prefix}${errorMessage}`, 0); + + case 500: + case 502: + case 503: + return new OllamaError(`${prefix}${errorMessage}`, 'SERVER_ERROR', { + statusCode: response.status + }); + + default: + return new OllamaError(`${prefix}${errorMessage}`, 'UNKNOWN_ERROR', { + statusCode: response.status + }); + } +} + +// ============================================================================ +// Retry Helper +// ============================================================================ + +/** Options for retry behavior */ +export interface RetryOptions { + /** Maximum number of retry attempts (default: 3) */ + maxAttempts?: number; + /** Initial delay in milliseconds (default: 1000) */ + initialDelayMs?: number; + /** Maximum delay in milliseconds (default: 10000) */ + maxDelayMs?: number; + /** Backoff multiplier (default: 2) */ + backoffMultiplier?: number; + /** AbortSignal for cancellation */ + signal?: AbortSignal; + /** Callback for retry attempts */ + onRetry?: (error: OllamaError, attempt: number, delayMs: number) => void; + /** Custom function to determine if error is retryable */ + isRetryable?: (error: OllamaError) => boolean; +} + +/** + * Wraps an async function with retry logic using exponential backoff + */ +export async function withRetry( + fn: () => Promise, + options: RetryOptions = {} +): Promise { + const { + maxAttempts = 3, + initialDelayMs = 1000, + maxDelayMs = 10000, + backoffMultiplier = 2, + signal, + onRetry, + isRetryable = (error: OllamaError) => error.isRetryable + } = options; + + let lastError: OllamaError | undefined; + let delayMs = initialDelayMs; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + // Check for abort before each attempt + if (signal?.aborted) { + throw new OllamaAbortError('Operation aborted before retry'); + } + + try { + return await fn(); + } catch (error) { + lastError = classifyError(error); + + // Don't retry non-retryable errors or on last attempt + if (!isRetryable(lastError) || attempt === maxAttempts) { + throw lastError; + } + + // Don't retry abort errors + if (lastError.code === 'ABORT_ERROR') { + throw lastError; + } + + // Notify about retry + onRetry?.(lastError, attempt, delayMs); + + // Wait with exponential backoff + await sleep(delayMs, signal); + + // Increase delay for next attempt + delayMs = Math.min(delayMs * backoffMultiplier, maxDelayMs); + } + } + + // This should never be reached, but TypeScript needs it + throw lastError ?? new OllamaError('Retry failed', 'UNKNOWN_ERROR'); +} + +/** + * Sleep for a specified duration with abort support + */ +function sleep(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(new OllamaAbortError('Sleep aborted')); + return; + } + + const timeout = setTimeout(resolve, ms); + + signal?.addEventListener( + 'abort', + () => { + clearTimeout(timeout); + reject(new OllamaAbortError('Sleep aborted')); + }, + { once: true } + ); + }); +} diff --git a/frontend/src/lib/ollama/image-processor.ts b/frontend/src/lib/ollama/image-processor.ts new file mode 100644 index 0000000..a51b172 --- /dev/null +++ b/frontend/src/lib/ollama/image-processor.ts @@ -0,0 +1,238 @@ +/** + * Image processing utilities for Ollama vision models + * Handles resizing, compression, and base64 encoding + */ + +/** Maximum dimensions for vision models (LLaVA limit) */ +const MAX_WIDTH = 1344; +const MAX_HEIGHT = 1344; + +/** Maximum file size after processing (10MB) */ +const MAX_PROCESSED_SIZE = 10 * 1024 * 1024; + +/** JPEG compression quality */ +const JPEG_QUALITY = 0.85; + +/** Supported image MIME types */ +const SUPPORTED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']; + +/** Result of image processing */ +export interface ProcessedImage { + /** Base64 encoded image data (without data: prefix) */ + base64: string; + /** Original filename */ + filename: string; + /** Original file size in bytes */ + originalSize: number; + /** Processed file size in bytes */ + processedSize: number; + /** Original dimensions */ + originalDimensions: { width: number; height: number }; + /** Processed dimensions */ + processedDimensions: { width: number; height: number }; +} + +/** Error thrown when image processing fails */ +export class ImageProcessingError extends Error { + constructor( + message: string, + public readonly code: 'INVALID_TYPE' | 'TOO_LARGE' | 'PROCESSING_FAILED' | 'LOAD_FAILED' + ) { + super(message); + this.name = 'ImageProcessingError'; + } +} + +/** + * Validate that the file is a supported image type + */ +export function isValidImageType(file: File): boolean { + return SUPPORTED_TYPES.includes(file.type); +} + +/** + * Load an image from a File object + */ +function loadImage(file: File): Promise { + return new Promise((resolve, reject) => { + const img = new Image(); + const url = URL.createObjectURL(file); + + img.onload = () => { + URL.revokeObjectURL(url); + resolve(img); + }; + + img.onerror = () => { + URL.revokeObjectURL(url); + reject(new ImageProcessingError('Failed to load image', 'LOAD_FAILED')); + }; + + img.src = url; + }); +} + +/** + * Calculate new dimensions while maintaining aspect ratio + */ +function calculateDimensions( + width: number, + height: number, + maxWidth: number, + maxHeight: number +): { width: number; height: number } { + if (width <= maxWidth && height <= maxHeight) { + return { width, height }; + } + + const aspectRatio = width / height; + + if (width > height) { + // Landscape orientation + const newWidth = Math.min(width, maxWidth); + const newHeight = Math.round(newWidth / aspectRatio); + + if (newHeight > maxHeight) { + return { + width: Math.round(maxHeight * aspectRatio), + height: maxHeight + }; + } + + return { width: newWidth, height: newHeight }; + } else { + // Portrait or square orientation + const newHeight = Math.min(height, maxHeight); + const newWidth = Math.round(newHeight * aspectRatio); + + if (newWidth > maxWidth) { + return { + width: maxWidth, + height: Math.round(maxWidth / aspectRatio) + }; + } + + return { width: newWidth, height: newHeight }; + } +} + +/** + * Convert canvas to base64 without data: prefix + */ +function canvasToBase64(canvas: HTMLCanvasElement): string { + const dataUrl = canvas.toDataURL('image/jpeg', JPEG_QUALITY); + // Remove the "data:image/jpeg;base64," prefix + const prefixIndex = dataUrl.indexOf(','); + return dataUrl.substring(prefixIndex + 1); +} + +/** + * Calculate the size of a base64 string in bytes + */ +function getBase64Size(base64: string): number { + // Base64 encoding adds ~33% overhead, so decoded size is ~75% of encoded + const padding = (base64.match(/=/g) || []).length; + return (base64.length * 3) / 4 - padding; +} + +/** + * Process an image file for Ollama vision models + * + * Resizes to fit within MAX_WIDTH x MAX_HEIGHT, + * compresses to JPEG at JPEG_QUALITY, + * and returns base64 without data: prefix (Ollama requirement) + * + * @param file - The image file to process + * @returns Processed image data with base64 and metadata + * @throws ImageProcessingError if processing fails + */ +export async function processImageForOllama(file: File): Promise { + // Validate file type + if (!isValidImageType(file)) { + throw new ImageProcessingError( + `Unsupported image type: ${file.type}. Supported types: JPEG, PNG, GIF, WebP`, + 'INVALID_TYPE' + ); + } + + // Load the image + const img = await loadImage(file); + + const originalDimensions = { width: img.width, height: img.height }; + + // Calculate new dimensions + const newDimensions = calculateDimensions(img.width, img.height, MAX_WIDTH, MAX_HEIGHT); + + // Create canvas for resizing + const canvas = document.createElement('canvas'); + canvas.width = newDimensions.width; + canvas.height = newDimensions.height; + + const ctx = canvas.getContext('2d'); + if (!ctx) { + throw new ImageProcessingError('Failed to get canvas context', 'PROCESSING_FAILED'); + } + + // Use high-quality image smoothing + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = 'high'; + + // Draw the resized image + ctx.drawImage(img, 0, 0, newDimensions.width, newDimensions.height); + + // Convert to base64 + const base64 = canvasToBase64(canvas); + const processedSize = getBase64Size(base64); + + // Validate processed size + if (processedSize > MAX_PROCESSED_SIZE) { + throw new ImageProcessingError( + `Processed image too large: ${(processedSize / 1024 / 1024).toFixed(2)}MB. Maximum: ${MAX_PROCESSED_SIZE / 1024 / 1024}MB`, + 'TOO_LARGE' + ); + } + + return { + base64, + filename: file.name, + originalSize: file.size, + processedSize, + originalDimensions, + processedDimensions: newDimensions + }; +} + +/** + * Process multiple image files for Ollama + * Returns an array of base64 strings (without data: prefix) + * + * @param files - Array of image files to process + * @returns Array of base64 encoded images + */ +export async function processImagesForOllama(files: File[]): Promise { + const results = await Promise.all(files.map(processImageForOllama)); + return results.map((r) => r.base64); +} + +/** + * Create a data URL from a base64 string (for display purposes) + */ +export function base64ToDataUrl(base64: string): string { + if (base64.startsWith('data:')) { + return base64; + } + return `data:image/jpeg;base64,${base64}`; +} + +/** + * Get human-readable file size + */ +export function formatFileSize(bytes: number): string { + if (bytes < 1024) { + return `${bytes} B`; + } + if (bytes < 1024 * 1024) { + return `${(bytes / 1024).toFixed(1)} KB`; + } + return `${(bytes / 1024 / 1024).toFixed(2)} MB`; +} diff --git a/frontend/src/lib/ollama/index.ts b/frontend/src/lib/ollama/index.ts new file mode 100644 index 0000000..467488b --- /dev/null +++ b/frontend/src/lib/ollama/index.ts @@ -0,0 +1,142 @@ +/** + * Ollama API Integration Layer + * + * Provides a complete TypeScript client for the Ollama API with: + * - Full type definitions for all API endpoints + * - Streaming support using async generators and callbacks + * - Comprehensive error handling with classification and retry logic + * - AbortSignal support for request cancellation + * + * @example Basic usage + * ```typescript + * import { ollamaClient } from '$lib/ollama'; + * + * // List models + * const { models } = await ollamaClient.listModels(); + * + * // Non-streaming chat + * const response = await ollamaClient.chat({ + * model: 'llama3.2', + * messages: [{ role: 'user', content: 'Hello!' }] + * }); + * + * // Streaming chat with async generator + * for await (const chunk of ollamaClient.streamChat({ + * model: 'llama3.2', + * messages: [{ role: 'user', content: 'Hello!' }] + * })) { + * console.log(chunk.message.content); + * } + * + * // Streaming chat with callbacks + * await ollamaClient.streamChatWithCallbacks( + * { model: 'llama3.2', messages: [{ role: 'user', content: 'Hello!' }] }, + * { onToken: (token) => console.log(token) } + * ); + * ``` + * + * @example Custom client configuration + * ```typescript + * import { OllamaClient } from '$lib/ollama'; + * + * const client = new OllamaClient({ + * baseUrl: 'http://my-ollama-server:11434', + * defaultTimeoutMs: 60000, + * enableRetry: true + * }); + * ``` + */ + +// Client +export { + OllamaClient, + ollamaClient, + type OllamaClientConfig, + type ChatOptions, + type ConnectionStatus, + type StreamChatResult, + type StreamChatCallbacks +} from './client.js'; + +// Types +export type { + // Model types + OllamaModel, + OllamaModelDetails, + OllamaModelsResponse, + OllamaRunningModel, + OllamaRunningModelsResponse, + + // Message types + OllamaMessage, + OllamaMessageRole, + OllamaToolCall, + OllamaToolCallFunction, + + // Tool types + OllamaToolDefinition, + OllamaToolFunction, + JsonSchema, + JsonSchemaProperty, + JsonSchemaType, + + // Chat types + OllamaChatRequest, + OllamaChatResponse, + OllamaChatStreamChunk, + OllamaChatMetrics, + OllamaModelOptions, + + // Generate types + OllamaGenerateRequest, + OllamaGenerateResponse, + OllamaGenerateStreamChunk, + + // Other API types + OllamaShowRequest, + OllamaShowResponse, + OllamaVersionResponse, + OllamaEmbedRequest, + OllamaEmbedResponse, + + // Error types + OllamaErrorResponse, + OllamaErrorCode, + OllamaStreamCallbacks +} from './types.js'; + +// Errors +export { + OllamaError, + OllamaConnectionError, + OllamaTimeoutError, + OllamaModelNotFoundError, + OllamaInvalidRequestError, + OllamaStreamError, + OllamaParseError, + OllamaAbortError, + classifyError, + createErrorFromResponse, + withRetry, + type RetryOptions +} from './errors.js'; + +// Streaming utilities +export { + streamChat, + streamChatWithCallbacks, + NDJSONParser, + collectStream, + type StreamChatOptions +} from './streaming.js'; + +// Image processing for vision models +export { + processImageForOllama, + processImagesForOllama, + isValidImageType, + base64ToDataUrl, + formatFileSize, + ImageProcessingError, + type ProcessedImage +} from './image-processor.js'; diff --git a/frontend/src/lib/ollama/streaming.ts b/frontend/src/lib/ollama/streaming.ts new file mode 100644 index 0000000..58270ba --- /dev/null +++ b/frontend/src/lib/ollama/streaming.ts @@ -0,0 +1,396 @@ +/** + * Streaming implementation for Ollama API + * Uses NDJSON (newline-delimited JSON) format with proper buffer handling + */ + +import type { + OllamaChatRequest, + OllamaChatStreamChunk, + OllamaChatResponse, + OllamaToolCall +} from './types.js'; +import { + OllamaStreamError, + OllamaParseError, + OllamaAbortError, + classifyError, + createErrorFromResponse +} from './errors.js'; + +// ============================================================================ +// Types +// ============================================================================ + +/** Options for streaming chat */ +export interface StreamChatOptions { + /** Base URL for Ollama API */ + baseUrl: string; + /** Request timeout in milliseconds */ + timeoutMs?: number; + /** AbortSignal for cancellation */ + signal?: AbortSignal; + /** Custom fetch implementation (for testing) */ + fetchFn?: typeof fetch; +} + +/** Result of the stream including final metrics */ +export interface StreamChatResult { + /** Full accumulated response text */ + content: string; + /** Final response with metrics (if stream completed) */ + response?: OllamaChatResponse; + /** Tool calls made by the model (if any) */ + toolCalls?: OllamaToolCall[]; +} + +// ============================================================================ +// NDJSON Parser +// ============================================================================ + +/** + * Parses NDJSON (newline-delimited JSON) stream with proper buffer handling + * Handles partial chunks that may arrive across multiple reads + */ +export class NDJSONParser { + private buffer: string = ''; + private decoder: TextDecoder; + + constructor() { + this.decoder = new TextDecoder(); + } + + /** + * Parses a chunk of data and yields complete JSON objects + * @param chunk - Raw bytes from the stream + * @yields Parsed JSON objects + */ + *parse(chunk: Uint8Array): Generator { + // Decode chunk and add to buffer + this.buffer += this.decoder.decode(chunk, { stream: true }); + + // Process complete lines + let newlineIndex: number; + while ((newlineIndex = this.buffer.indexOf('\n')) !== -1) { + const line = this.buffer.slice(0, newlineIndex).trim(); + this.buffer = this.buffer.slice(newlineIndex + 1); + + // Skip empty lines + if (!line) { + continue; + } + + // Parse JSON + try { + yield JSON.parse(line) as T; + } catch (error) { + throw new OllamaParseError( + `Failed to parse NDJSON line: ${error instanceof Error ? error.message : 'Unknown error'}`, + line, + error instanceof Error ? error : undefined + ); + } + } + } + + /** + * Flushes any remaining data in the buffer + * Should be called when the stream ends + * @yields Any remaining complete JSON object + */ + *flush(): Generator { + // Flush decoder + this.buffer += this.decoder.decode(); + + // Process remaining buffer + const remaining = this.buffer.trim(); + if (remaining) { + try { + yield JSON.parse(remaining) as T; + } catch (error) { + throw new OllamaParseError( + `Failed to parse final NDJSON: ${error instanceof Error ? error.message : 'Unknown error'}`, + remaining, + error instanceof Error ? error : undefined + ); + } + } + + this.buffer = ''; + } + + /** + * Resets the parser state + */ + reset(): void { + this.buffer = ''; + this.decoder = new TextDecoder(); + } +} + +// ============================================================================ +// Stream Chat Function +// ============================================================================ + +/** + * Streams a chat completion from Ollama API + * @param request - Chat request parameters + * @param options - Streaming options + * @yields Chat stream chunks + * @returns Final result with accumulated content + */ +export async function* streamChat( + request: OllamaChatRequest, + options: StreamChatOptions +): AsyncGenerator { + const { + baseUrl, + timeoutMs = 120000, + signal, + fetchFn = fetch + } = options; + + // Check for abort before starting + if (signal?.aborted) { + throw new OllamaAbortError('Request aborted before starting'); + } + + // Create abort controller for timeout + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + + // Combine signals + const combinedSignal = signal + ? combineAbortSignals(signal, controller.signal) + : controller.signal; + + // Clean up on abort + signal?.addEventListener('abort', () => { + clearTimeout(timeoutId); + controller.abort(); + }, { once: true }); + + let response: Response; + + try { + response = await fetchFn(`${baseUrl}/api/chat`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + ...request, + stream: true + }), + signal: combinedSignal + }); + } catch (error) { + clearTimeout(timeoutId); + throw classifyError(error, 'Failed to connect to Ollama'); + } + + clearTimeout(timeoutId); + + // Check for HTTP errors + if (!response.ok) { + throw await createErrorFromResponse(response, 'Chat request failed'); + } + + // Ensure we have a body to stream + if (!response.body) { + throw new OllamaStreamError('Response body is null'); + } + + // Stream and parse NDJSON + const reader = response.body.getReader(); + const parser = new NDJSONParser(); + + let accumulatedContent = ''; + let finalResponse: OllamaChatResponse | undefined; + let toolCalls: OllamaToolCall[] | undefined; + + try { + while (true) { + // Check for abort + if (signal?.aborted) { + throw new OllamaAbortError('Request aborted during streaming'); + } + + const { done, value } = await reader.read(); + + if (done) { + // Flush any remaining data + for (const chunk of parser.flush()) { + if (chunk.message?.content) { + accumulatedContent += chunk.message.content; + } + if (chunk.message?.tool_calls) { + toolCalls = chunk.message.tool_calls; + } + if (chunk.done) { + finalResponse = chunk as OllamaChatResponse; + } + yield chunk; + } + break; + } + + // Parse and yield chunks + for (const chunk of parser.parse(value)) { + if (chunk.message?.content) { + accumulatedContent += chunk.message.content; + } + if (chunk.message?.tool_calls) { + toolCalls = chunk.message.tool_calls; + } + if (chunk.done) { + finalResponse = chunk as OllamaChatResponse; + } + yield chunk; + } + } + } catch (error) { + // Cancel the stream on error + try { + await reader.cancel(); + } catch { + // Ignore cancel errors + } + throw classifyError(error, 'Streaming failed'); + } + + return { + content: accumulatedContent, + response: finalResponse, + toolCalls + }; +} + +// ============================================================================ +// Callback-based Streaming +// ============================================================================ + +/** Callbacks for streaming chat operations */ +export interface StreamChatCallbacks { + /** Called for each content token */ + onToken?: (token: string) => void; + /** Called with full chunk data */ + onChunk?: (chunk: OllamaChatStreamChunk) => void; + /** Called when tool calls are received from the model */ + onToolCall?: (toolCalls: OllamaToolCall[]) => void; + /** Called when streaming is complete */ + onComplete?: (result: StreamChatResult) => void; + /** Called on error */ + onError?: (error: Error) => void; +} + +/** + * Streams a chat completion with callback-based API + * @param request - Chat request parameters + * @param callbacks - Callback functions + * @param options - Streaming options + * @returns Promise that resolves when streaming is complete + */ +export async function streamChatWithCallbacks( + request: OllamaChatRequest, + callbacks: StreamChatCallbacks, + options: StreamChatOptions +): Promise { + const { onToken, onChunk, onToolCall, onComplete, onError } = callbacks; + + try { + const stream = streamChat(request, options); + let result: StreamChatResult | undefined; + let toolCallsEmitted = false; + + while (true) { + const { done, value } = await stream.next(); + + if (done) { + result = value; + break; + } + + // Call chunk callback + onChunk?.(value); + + // Call token callback for content + if (value.message?.content) { + onToken?.(value.message.content); + } + + // Call tool call callback when tool calls are received + if (value.message?.tool_calls && !toolCallsEmitted) { + onToolCall?.(value.message.tool_calls); + toolCallsEmitted = true; + } + } + + // Ensure we have a result + const finalResult = result ?? { content: '' }; + + // Emit tool calls from final result if not already emitted + if (finalResult.toolCalls && !toolCallsEmitted) { + onToolCall?.(finalResult.toolCalls); + } + + // Call complete callback + onComplete?.(finalResult); + + return finalResult; + } catch (error) { + const classifiedError = classifyError(error); + onError?.(classifiedError); + throw classifiedError; + } +} + +// ============================================================================ +// Utilities +// ============================================================================ + +/** + * Combines multiple AbortSignals into one + * The combined signal aborts when any of the input signals abort + */ +function combineAbortSignals(...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; +} + +/** + * Collects all chunks from a stream into an array + * Useful for testing or when you need all chunks at once + */ +export async function collectStream( + stream: AsyncGenerator +): Promise<{ chunks: T[]; result: R }> { + const chunks: T[] = []; + let result: R; + + while (true) { + const { done, value } = await stream.next(); + + if (done) { + result = value; + break; + } + + chunks.push(value); + } + + return { chunks, result: result! }; +} diff --git a/frontend/src/lib/ollama/types.ts b/frontend/src/lib/ollama/types.ts new file mode 100644 index 0000000..ace7fcf --- /dev/null +++ b/frontend/src/lib/ollama/types.ts @@ -0,0 +1,356 @@ +/** + * TypeScript types for Ollama API + * Reference: https://github.com/ollama/ollama/blob/main/docs/api.md + */ + +// ============================================================================ +// Model Types +// ============================================================================ + +/** Model details from Ollama API */ +export interface OllamaModelDetails { + parent_model: string; + format: string; + family: string; + families: string[] | null; + parameter_size: string; + quantization_level: string; +} + +/** Single model from Ollama API /api/tags response */ +export interface OllamaModel { + name: string; + model: string; + modified_at: string; + size: number; + digest: string; + details: OllamaModelDetails; +} + +/** Response from Ollama /api/tags endpoint */ +export interface OllamaModelsResponse { + models: OllamaModel[]; +} + +/** Running model from /api/ps endpoint */ +export interface OllamaRunningModel { + name: string; + model: string; + size: number; + digest: string; + details: OllamaModelDetails; + expires_at: string; + size_vram: number; +} + +/** Response from Ollama /api/ps endpoint */ +export interface OllamaRunningModelsResponse { + models: OllamaRunningModel[]; +} + +// ============================================================================ +// Message Types +// ============================================================================ + +/** Role of a message in a chat conversation */ +export type OllamaMessageRole = 'system' | 'user' | 'assistant' | 'tool'; + +/** Tool call function definition in an assistant message */ +export interface OllamaToolCallFunction { + name: string; + arguments: Record; +} + +/** Tool call in an assistant message */ +export interface OllamaToolCall { + function: OllamaToolCallFunction; +} + +/** A single message in an Ollama chat conversation */ +export interface OllamaMessage { + role: OllamaMessageRole; + content: string; + /** Base64-encoded images for multimodal models */ + images?: string[]; + /** Tool calls made by the assistant */ + tool_calls?: OllamaToolCall[]; +} + +// ============================================================================ +// Tool Definition Types +// ============================================================================ + +/** JSON Schema type for tool parameters */ +export type JsonSchemaType = 'string' | 'number' | 'integer' | 'boolean' | 'array' | 'object'; + +/** JSON Schema property definition */ +export interface JsonSchemaProperty { + type: JsonSchemaType; + description?: string; + enum?: string[]; + items?: JsonSchemaProperty; + properties?: Record; + required?: string[]; +} + +/** JSON Schema for tool parameters */ +export interface JsonSchema { + type: 'object'; + properties: Record; + required?: string[]; +} + +/** Tool function definition */ +export interface OllamaToolFunction { + name: string; + description: string; + parameters: JsonSchema; +} + +/** Tool definition for Ollama API */ +export interface OllamaToolDefinition { + type: 'function'; + function: OllamaToolFunction; +} + +// ============================================================================ +// Chat Request/Response Types +// ============================================================================ + +/** Model options for generation */ +export interface OllamaModelOptions { + /** Temperature for sampling (0.0 - 2.0) */ + temperature?: number; + /** Top-k sampling */ + top_k?: number; + /** Top-p (nucleus) sampling */ + top_p?: number; + /** Number of tokens to predict */ + num_predict?: number; + /** Stop sequences */ + stop?: string[]; + /** Random seed for reproducibility */ + seed?: number; + /** Repeat penalty */ + repeat_penalty?: number; + /** Presence penalty */ + presence_penalty?: number; + /** Frequency penalty */ + frequency_penalty?: number; + /** Mirostat mode (0, 1, or 2) */ + mirostat?: number; + /** Mirostat target entropy */ + mirostat_tau?: number; + /** Mirostat learning rate */ + mirostat_eta?: number; + /** Context window size */ + num_ctx?: number; + /** Number of GQA groups */ + num_gqa?: number; + /** Number of GPU layers */ + num_gpu?: number; + /** Number of threads */ + num_thread?: number; +} + +/** Request body for POST /api/chat */ +export interface OllamaChatRequest { + /** Model name (e.g., "llama3.2", "mistral:7b") */ + model: string; + /** Messages in the conversation */ + messages: OllamaMessage[]; + /** Whether to stream the response (default: true) */ + stream?: boolean; + /** Format for structured output: 'json' or JSON schema */ + format?: 'json' | JsonSchema; + /** Tools available to the model */ + tools?: OllamaToolDefinition[]; + /** Model-specific options */ + options?: OllamaModelOptions; + /** How long to keep model loaded (e.g., "5m", "1h", "-1" for indefinite) */ + keep_alive?: string; +} + +/** Performance metrics in chat response */ +export interface OllamaChatMetrics { + /** Total generation time in nanoseconds */ + total_duration?: number; + /** Model load time in nanoseconds */ + load_duration?: number; + /** Number of tokens in the prompt */ + prompt_eval_count?: number; + /** Prompt evaluation time in nanoseconds */ + prompt_eval_duration?: number; + /** Number of tokens generated */ + eval_count?: number; + /** Generation time in nanoseconds */ + eval_duration?: number; +} + +/** Full response from POST /api/chat (non-streaming) */ +export interface OllamaChatResponse extends OllamaChatMetrics { + model: string; + created_at: string; + message: OllamaMessage; + done: true; + done_reason?: 'stop' | 'length' | 'load' | 'tool_calls'; +} + +/** Streaming chunk from POST /api/chat */ +export interface OllamaChatStreamChunk { + model: string; + created_at: string; + message: OllamaMessage; + done: boolean; + /** Only present when done is true */ + done_reason?: 'stop' | 'length' | 'load' | 'tool_calls'; + /** Metrics only present in final chunk */ + total_duration?: number; + load_duration?: number; + prompt_eval_count?: number; + prompt_eval_duration?: number; + eval_count?: number; + eval_duration?: number; +} + +// ============================================================================ +// Generate Types (for completeness) +// ============================================================================ + +/** Request body for POST /api/generate */ +export interface OllamaGenerateRequest { + model: string; + prompt: string; + stream?: boolean; + format?: 'json' | JsonSchema; + options?: OllamaModelOptions; + system?: string; + template?: string; + context?: number[]; + raw?: boolean; + keep_alive?: string; + images?: string[]; +} + +/** Response from POST /api/generate (non-streaming) */ +export interface OllamaGenerateResponse { + model: string; + created_at: string; + response: string; + done: true; + done_reason?: 'stop' | 'length' | 'load'; + context?: number[]; + total_duration?: number; + load_duration?: number; + prompt_eval_count?: number; + prompt_eval_duration?: number; + eval_count?: number; + eval_duration?: number; +} + +/** Streaming chunk from POST /api/generate */ +export interface OllamaGenerateStreamChunk { + model: string; + created_at: string; + response: string; + done: boolean; + done_reason?: 'stop' | 'length' | 'load'; + context?: number[]; + total_duration?: number; + load_duration?: number; + prompt_eval_count?: number; + prompt_eval_duration?: number; + eval_count?: number; + eval_duration?: number; +} + +// ============================================================================ +// Error Types +// ============================================================================ + +/** Ollama API error response body */ +export interface OllamaErrorResponse { + error: string; +} + +/** Error codes for categorizing Ollama errors */ +export type OllamaErrorCode = + | 'CONNECTION_ERROR' + | 'TIMEOUT_ERROR' + | 'MODEL_NOT_FOUND' + | 'INVALID_REQUEST' + | 'SERVER_ERROR' + | 'STREAM_ERROR' + | 'PARSE_ERROR' + | 'ABORT_ERROR' + | 'UNKNOWN_ERROR'; + +// ============================================================================ +// Embedding Types +// ============================================================================ + +/** Request body for POST /api/embed */ +export interface OllamaEmbedRequest { + model: string; + input: string | string[]; + truncate?: boolean; + options?: OllamaModelOptions; + keep_alive?: string; +} + +/** Response from POST /api/embed */ +export interface OllamaEmbedResponse { + model: string; + embeddings: number[][]; + total_duration?: number; + load_duration?: number; + prompt_eval_count?: number; +} + +// ============================================================================ +// Show Model Types +// ============================================================================ + +/** Request body for POST /api/show */ +export interface OllamaShowRequest { + model: string; + verbose?: boolean; +} + +/** Response from POST /api/show */ +export interface OllamaShowResponse { + license?: string; + modelfile: string; + parameters: string; + template: string; + details: OllamaModelDetails; + model_info?: Record; + modified_at: string; +} + +// ============================================================================ +// Version Types +// ============================================================================ + +/** Response from GET /api/version */ +export interface OllamaVersionResponse { + version: string; +} + +// ============================================================================ +// Callback Types for Streaming +// ============================================================================ + +/** Callbacks for streaming chat operations */ +export interface OllamaStreamCallbacks { + /** Called for each content token */ + onToken?: (token: string) => void; + /** Called with full chunk data */ + onChunk?: (chunk: OllamaChatStreamChunk) => void; + /** Called when tool calls are received */ + onToolCall?: (toolCalls: OllamaToolCall[]) => void; + /** Called when streaming is complete */ + onComplete?: (response: OllamaChatResponse) => void; + /** Called on error */ + onError?: (error: Error) => void; +} diff --git a/frontend/src/lib/storage/attachments.ts b/frontend/src/lib/storage/attachments.ts new file mode 100644 index 0000000..046ab6b --- /dev/null +++ b/frontend/src/lib/storage/attachments.ts @@ -0,0 +1,173 @@ +/** + * Attachment operations for IndexedDB storage + * Handles binary file storage for message attachments + */ + +import { db, withErrorHandling, generateId } from './db.js'; +import type { StoredAttachment, StorageResult } from './db.js'; + +/** + * Attachment metadata without the binary data + */ +export interface AttachmentMeta { + id: string; + messageId: string; + mimeType: string; + filename: string; + size: number; +} + +/** + * Get all attachments for a message + */ +export async function getAttachmentsForMessage( + messageId: string +): Promise> { + return withErrorHandling(async () => { + return await db.attachments.where('messageId').equals(messageId).toArray(); + }); +} + +/** + * Get attachment metadata (without data) for a message + */ +export async function getAttachmentMetaForMessage( + messageId: string +): Promise> { + return withErrorHandling(async () => { + const attachments = await db.attachments.where('messageId').equals(messageId).toArray(); + return attachments.map((a) => ({ + id: a.id, + messageId: a.messageId, + mimeType: a.mimeType, + filename: a.filename, + size: a.data.size + })); + }); +} + +/** + * Get a single attachment by ID + */ +export async function getAttachment(id: string): Promise> { + return withErrorHandling(async () => { + return (await db.attachments.get(id)) ?? null; + }); +} + +/** + * Add an attachment to a message + */ +export async function addAttachment( + messageId: string, + file: File +): Promise> { + return withErrorHandling(async () => { + const id = generateId(); + + const attachment: StoredAttachment = { + id, + messageId, + mimeType: file.type || 'application/octet-stream', + data: file, + filename: file.name + }; + + await db.attachments.add(attachment); + return attachment; + }); +} + +/** + * Add an attachment from a Blob with explicit metadata + */ +export async function addAttachmentFromBlob( + messageId: string, + data: Blob, + filename: string, + mimeType?: string +): Promise> { + return withErrorHandling(async () => { + const id = generateId(); + + const attachment: StoredAttachment = { + id, + messageId, + mimeType: mimeType ?? data.type ?? 'application/octet-stream', + data, + filename + }; + + await db.attachments.add(attachment); + return attachment; + }); +} + +/** + * Delete an attachment by ID + */ +export async function deleteAttachment(id: string): Promise> { + return withErrorHandling(async () => { + await db.attachments.delete(id); + }); +} + +/** + * Delete all attachments for a message + */ +export async function deleteAttachmentsForMessage(messageId: string): Promise> { + return withErrorHandling(async () => { + await db.attachments.where('messageId').equals(messageId).delete(); + }); +} + +/** + * Get the data URL for an attachment (for displaying images) + */ +export async function getAttachmentDataUrl(id: string): Promise> { + return withErrorHandling(async () => { + const attachment = await db.attachments.get(id); + if (!attachment) { + return null; + } + + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = () => reject(new Error('Failed to read attachment data')); + reader.readAsDataURL(attachment.data); + }); + }); +} + +/** + * Get total storage size used by attachments + */ +export async function getTotalAttachmentSize(): Promise> { + return withErrorHandling(async () => { + const attachments = await db.attachments.toArray(); + return attachments.reduce((total, a) => total + a.data.size, 0); + }); +} + +/** + * Get storage size for attachments in a conversation + */ +export async function getConversationAttachmentSize( + conversationId: string +): Promise> { + return withErrorHandling(async () => { + // Get all message IDs for the conversation + const messages = await db.messages.where('conversationId').equals(conversationId).toArray(); + const messageIds = messages.map((m) => m.id); + + if (messageIds.length === 0) { + return 0; + } + + // Get all attachments for those messages + const attachments = await db.attachments.where('messageId').anyOf(messageIds).toArray(); + + return attachments.reduce((total, a) => total + a.data.size, 0); + }); +} diff --git a/frontend/src/lib/storage/conversations.ts b/frontend/src/lib/storage/conversations.ts new file mode 100644 index 0000000..c9bcb6e --- /dev/null +++ b/frontend/src/lib/storage/conversations.ts @@ -0,0 +1,300 @@ +/** + * Conversation CRUD operations for IndexedDB storage + */ + +import { db, withErrorHandling, generateId } from './db.js'; +import type { StoredConversation, StorageResult } from './db.js'; +import type { Conversation, ConversationFull, MessageNode } from '../types/index.js'; +import { getMessagesForConversation, getMessageTree, deleteMessagesForConversation } from './messages.js'; +import { markForSync } from './sync.js'; + +/** + * Converts stored conversation to domain type + */ +function toDomainConversation(stored: StoredConversation): Conversation { + return { + id: stored.id, + title: stored.title, + model: stored.model, + createdAt: new Date(stored.createdAt), + updatedAt: new Date(stored.updatedAt), + isPinned: stored.isPinned, + isArchived: stored.isArchived, + messageCount: stored.messageCount + }; +} + +/** + * Converts domain conversation to stored type + */ +function toStoredConversation( + conversation: Omit & { + createdAt?: Date; + updatedAt?: Date; + } +): StoredConversation { + const now = Date.now(); + return { + id: conversation.id, + title: conversation.title, + model: conversation.model, + createdAt: conversation.createdAt?.getTime() ?? now, + updatedAt: conversation.updatedAt?.getTime() ?? now, + isPinned: conversation.isPinned, + isArchived: conversation.isArchived, + messageCount: conversation.messageCount + }; +} + +/** + * Get all non-archived conversations, sorted by updatedAt descending + * Pinned conversations are returned first + */ +export async function getAllConversations(): Promise> { + return withErrorHandling(async () => { + const all = await db.conversations.toArray(); + const filtered = all.filter((c) => !c.isArchived); + + // Sort: pinned first, then by updatedAt descending + const sorted = filtered.sort((a, b) => { + if (a.isPinned && !b.isPinned) return -1; + if (!a.isPinned && b.isPinned) return 1; + return b.updatedAt - a.updatedAt; + }); + + return sorted.map(toDomainConversation); + }); +} + +/** + * Get archived conversations, sorted by updatedAt descending + */ +export async function getArchivedConversations(): Promise> { + return withErrorHandling(async () => { + const all = await db.conversations.toArray(); + const archived = all.filter((c) => c.isArchived); + + const sorted = archived.sort((a, b) => b.updatedAt - a.updatedAt); + return sorted.map(toDomainConversation); + }); +} + +/** + * Get a single conversation by ID (metadata only) + */ +export async function getConversation(id: string): Promise> { + return withErrorHandling(async () => { + const stored = await db.conversations.get(id); + return stored ? toDomainConversation(stored) : null; + }); +} + +/** + * Get a full conversation including all messages + */ +export async function getConversationFull(id: string): Promise> { + return withErrorHandling(async () => { + const stored = await db.conversations.get(id); + if (!stored) { + return null; + } + + const messagesResult = await getMessagesForConversation(id); + if (!messagesResult.success) { + throw new Error((messagesResult as { success: false; error: string }).error); + } + + const treeResult = await getMessageTree(id); + if (!treeResult.success) { + throw new Error((treeResult as { success: false; error: string }).error); + } + + const { rootMessageId, activePath } = treeResult.data; + + return { + ...toDomainConversation(stored), + messages: messagesResult.data, + activePath, + rootMessageId + }; + }); +} + +/** + * Create a new conversation + */ +export async function createConversation( + data: Omit +): Promise> { + return withErrorHandling(async () => { + const id = generateId(); + const now = Date.now(); + + const stored: StoredConversation = { + id, + title: data.title, + model: data.model, + createdAt: now, + updatedAt: now, + isPinned: data.isPinned ?? false, + isArchived: data.isArchived ?? false, + messageCount: 0, + syncVersion: 1 + }; + + await db.conversations.add(stored); + + // Queue for backend sync + await markForSync('conversation', id, 'create'); + + return toDomainConversation(stored); + }); +} + +/** + * Update conversation metadata + */ +export async function updateConversation( + id: string, + data: Partial> +): Promise> { + return withErrorHandling(async () => { + const existing = await db.conversations.get(id); + if (!existing) { + throw new Error(`Conversation not found: ${id}`); + } + + const updated: StoredConversation = { + ...existing, + ...data, + updatedAt: Date.now(), + syncVersion: (existing.syncVersion ?? 0) + 1 + }; + + await db.conversations.put(updated); + + // Queue for backend sync + await markForSync('conversation', id, 'update'); + + return toDomainConversation(updated); + }); +} + +/** + * Delete a conversation and all its messages and attachments + */ +export async function deleteConversation(id: string): Promise> { + return withErrorHandling(async () => { + await db.transaction('rw', [db.conversations, db.messages, db.attachments], async () => { + // Delete all attachments for messages in this conversation + const messages = await db.messages.where('conversationId').equals(id).toArray(); + const messageIds = messages.map((m) => m.id); + + if (messageIds.length > 0) { + await db.attachments.where('messageId').anyOf(messageIds).delete(); + } + + // Delete all messages + await deleteMessagesForConversation(id); + + // Delete the conversation + await db.conversations.delete(id); + }); + + // Queue for backend sync (after transaction completes) + await markForSync('conversation', id, 'delete'); + }); +} + +/** + * Toggle pin status for a conversation + */ +export async function pinConversation(id: string): Promise> { + return withErrorHandling(async () => { + const existing = await db.conversations.get(id); + if (!existing) { + throw new Error(`Conversation not found: ${id}`); + } + + const updated: StoredConversation = { + ...existing, + isPinned: !existing.isPinned, + updatedAt: Date.now(), + syncVersion: (existing.syncVersion ?? 0) + 1 + }; + + await db.conversations.put(updated); + + // Queue for backend sync + await markForSync('conversation', id, 'update'); + + return toDomainConversation(updated); + }); +} + +/** + * Archive a conversation (or unarchive if already archived) + */ +export async function archiveConversation(id: string): Promise> { + return withErrorHandling(async () => { + const existing = await db.conversations.get(id); + if (!existing) { + throw new Error(`Conversation not found: ${id}`); + } + + const updated: StoredConversation = { + ...existing, + isArchived: !existing.isArchived, + updatedAt: Date.now(), + syncVersion: (existing.syncVersion ?? 0) + 1 + }; + + await db.conversations.put(updated); + + // Queue for backend sync + await markForSync('conversation', id, 'update'); + + return toDomainConversation(updated); + }); +} + +/** + * Update the message count for a conversation + * Called internally when messages are added or removed + */ +export async function updateMessageCount( + conversationId: string, + delta: number +): Promise> { + return withErrorHandling(async () => { + const existing = await db.conversations.get(conversationId); + if (!existing) { + throw new Error(`Conversation not found: ${conversationId}`); + } + + await db.conversations.update(conversationId, { + messageCount: Math.max(0, existing.messageCount + delta), + updatedAt: Date.now() + }); + }); +} + +/** + * Search conversations by title + */ +export async function searchConversations(query: string): Promise> { + return withErrorHandling(async () => { + const lowerQuery = query.toLowerCase(); + const all = await db.conversations.toArray(); + + const matching = all + .filter((c) => !c.isArchived && c.title.toLowerCase().includes(lowerQuery)) + .sort((a, b) => { + if (a.isPinned && !b.isPinned) return -1; + if (!a.isPinned && b.isPinned) return 1; + return b.updatedAt - a.updatedAt; + }); + + return matching.map(toDomainConversation); + }); +} diff --git a/frontend/src/lib/storage/db.ts b/frontend/src/lib/storage/db.ts new file mode 100644 index 0000000..c02bee2 --- /dev/null +++ b/frontend/src/lib/storage/db.ts @@ -0,0 +1,203 @@ +/** + * IndexedDB database setup using Dexie.js + * Provides local storage for conversations, messages, and attachments + */ + +import Dexie, { type Table } from 'dexie'; + +/** + * Stored conversation metadata + * Uses timestamps as numbers for IndexedDB compatibility + */ +export interface StoredConversation { + id: string; + title: string; + model: string; + createdAt: number; + updatedAt: number; + isPinned: boolean; + isArchived: boolean; + messageCount: number; + syncVersion?: number; +} + +/** + * Conversation record with Date objects (for sync manager) + */ +export interface ConversationRecord { + id: string; + title: string; + model: string; + createdAt: Date; + updatedAt: Date; + isPinned: boolean; + isArchived: boolean; + messageCount: number; + syncVersion?: number; +} + +/** + * Stored message in a conversation + * Flattened structure for efficient storage and retrieval + */ +export interface StoredMessage { + id: string; + conversationId: string; + parentId: string | null; + role: 'user' | 'assistant' | 'system' | 'tool'; + content: string; + images?: string[]; + toolCalls?: Array<{ + id: string; + name: string; + arguments: string; + }>; + siblingIndex: number; + createdAt: number; + syncVersion?: number; +} + +/** + * Message record with Date objects (for sync manager) + */ +export interface MessageRecord { + id: string; + conversationId: string; + parentId: string | null; + role: 'user' | 'assistant' | 'system' | 'tool'; + content: string; + images?: string[]; + createdAt: Date; + syncVersion?: number; +} + +/** + * Stored attachment for a message + * Binary data stored as Blob for efficiency + */ +export interface StoredAttachment { + id: string; + messageId: string; + mimeType: string; + data: Blob; + filename: string; +} + +/** + * Sync queue item for future backend synchronization + */ +export interface SyncQueueItem { + id: string; + entityType: 'conversation' | 'message' | 'attachment'; + entityId: string; + operation: 'create' | 'update' | 'delete'; + createdAt: number; + retryCount: number; +} + +/** + * Knowledge base document (for RAG) + */ +export interface StoredDocument { + id: string; + name: string; + mimeType: string; + size: number; + createdAt: number; + updatedAt: number; + chunkCount: number; + embeddingModel: string; +} + +/** + * Document chunk with embedding (for RAG) + */ +export interface StoredChunk { + id: string; + documentId: string; + content: string; + embedding: number[]; + startIndex: number; + endIndex: number; + tokenCount: number; +} + +/** + * Ollama WebUI database class + * Manages all local storage tables + */ +class OllamaDatabase extends Dexie { + conversations!: Table; + messages!: Table; + attachments!: Table; + syncQueue!: Table; + documents!: Table; + chunks!: Table; + + constructor() { + super('ollama-webui'); + + // Version 1: Core chat functionality + this.version(1).stores({ + // Primary key: id, Indexes: updatedAt, isPinned, isArchived + conversations: 'id, updatedAt, isPinned, isArchived', + // Primary key: id, Indexes: conversationId, parentId, createdAt + messages: 'id, conversationId, parentId, createdAt', + // Primary key: id, Index: messageId + attachments: 'id, messageId', + // Primary key: id, Indexes: entityType, createdAt + syncQueue: 'id, entityType, createdAt' + }); + + // Version 2: Knowledge base / RAG support + this.version(2).stores({ + conversations: 'id, updatedAt, isPinned, isArchived', + messages: 'id, conversationId, parentId, createdAt', + attachments: 'id, messageId', + syncQueue: 'id, entityType, createdAt', + // Knowledge base documents + documents: 'id, name, createdAt, updatedAt', + // Document chunks with embeddings + chunks: 'id, documentId' + }); + } +} + +/** + * Singleton database instance + */ +export const db = new OllamaDatabase(); + +/** + * Result type for database operations + * Provides consistent error handling across all storage functions + */ +export type StorageResult = + | { success: true; data: T } + | { success: false; error: string }; + +/** + * Wraps a database operation with error handling + * @param operation - Async function to execute + * @returns StorageResult with data or error + */ +export async function withErrorHandling( + operation: () => Promise +): Promise> { + try { + const data = await operation(); + return { success: true, data }; + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown database error'; + console.error('[Storage Error]', message, error); + return { success: false, error: message }; + } +} + +/** + * Generates a unique ID for database entities + * Uses crypto.randomUUID for guaranteed uniqueness + */ +export function generateId(): string { + return crypto.randomUUID(); +} diff --git a/frontend/src/lib/storage/index.ts b/frontend/src/lib/storage/index.ts new file mode 100644 index 0000000..2a85054 --- /dev/null +++ b/frontend/src/lib/storage/index.ts @@ -0,0 +1,73 @@ +/** + * Storage layer exports + * IndexedDB-based local storage using Dexie.js + */ + +// Database setup and types +export { db, generateId, withErrorHandling } from './db.js'; +export type { + StoredConversation, + StoredMessage, + StoredAttachment, + SyncQueueItem, + StorageResult +} from './db.js'; + +// Conversation operations +export { + getAllConversations, + getArchivedConversations, + getConversation, + getConversationFull, + createConversation, + updateConversation, + deleteConversation, + pinConversation, + archiveConversation, + updateMessageCount, + searchConversations +} from './conversations.js'; + +// Message operations +export { + getMessagesForConversation, + getMessage, + addMessage, + updateMessage, + deleteMessage, + deleteMessagesForConversation, + getMessageTree, + getSiblings, + getPathToMessage, + appendToMessage +} from './messages.js'; + +// Attachment operations +export { + getAttachmentsForMessage, + getAttachmentMetaForMessage, + getAttachment, + addAttachment, + addAttachmentFromBlob, + deleteAttachment, + deleteAttachmentsForMessage, + getAttachmentDataUrl, + getTotalAttachmentSize, + getConversationAttachmentSize +} from './attachments.js'; +export type { AttachmentMeta } from './attachments.js'; + +// Sync utilities +export { + markForSync, + getPendingSyncItems, + getPendingSyncItemsByType, + clearSyncItem, + clearSyncItems, + incrementRetryCount, + clearAllSyncItems, + getPendingSyncCount, + hasPendingSyncs, + markMultipleForSync +} from './sync.js'; +export type { SyncEntityType, SyncOperation } from './sync.js'; diff --git a/frontend/src/lib/storage/messages.ts b/frontend/src/lib/storage/messages.ts new file mode 100644 index 0000000..51918a2 --- /dev/null +++ b/frontend/src/lib/storage/messages.ts @@ -0,0 +1,389 @@ +/** + * Message operations for IndexedDB storage + * Handles message CRUD and tree structure management + */ + +import { db, withErrorHandling, generateId } from './db.js'; +import type { StoredMessage, StorageResult } from './db.js'; +import type { Message, MessageNode, BranchPath } from '../types/index.js'; +import { markForSync } from './sync.js'; + +/** + * Converts stored message to MessageNode domain type + */ +function toMessageNode(stored: StoredMessage, childIds: string[]): MessageNode { + return { + id: stored.id, + message: { + role: stored.role, + content: stored.content, + images: stored.images, + toolCalls: stored.toolCalls + }, + parentId: stored.parentId, + childIds, + createdAt: new Date(stored.createdAt) + }; +} + +/** + * Get all messages for a conversation as a Map + * Builds the full tree structure with child relationships + */ +export async function getMessagesForConversation( + conversationId: string +): Promise>> { + return withErrorHandling(async () => { + const stored = await db.messages + .where('conversationId') + .equals(conversationId) + .toArray(); + + // Build child relationships + const childrenMap = new Map(); + + for (const msg of stored) { + if (msg.parentId) { + const siblings = childrenMap.get(msg.parentId) ?? []; + siblings.push(msg.id); + childrenMap.set(msg.parentId, siblings); + } + } + + // Sort children by siblingIndex + Array.from(childrenMap.entries()).forEach(([parentId, children]) => { + const parentMessages = stored.filter((m) => children.includes(m.id)); + parentMessages.sort((a, b) => a.siblingIndex - b.siblingIndex); + childrenMap.set( + parentId, + parentMessages.map((m) => m.id) + ); + }); + + // Convert to MessageNode map + const result = new Map(); + for (const msg of stored) { + const childIds = childrenMap.get(msg.id) ?? []; + result.set(msg.id, toMessageNode(msg, childIds)); + } + + return result; + }); +} + +/** + * Get a single message by ID + */ +export async function getMessage(id: string): Promise> { + return withErrorHandling(async () => { + const stored = await db.messages.get(id); + if (!stored) { + return null; + } + + // Get children + const children = await db.messages + .where('parentId') + .equals(id) + .sortBy('siblingIndex'); + + return toMessageNode( + stored, + children.map((c) => c.id) + ); + }); +} + +/** + * Add a new message to a conversation + * Returns the created message node + */ +export async function addMessage( + conversationId: string, + message: Message, + parentId: string | null = null, + messageId?: string +): Promise> { + return withErrorHandling(async () => { + const id = messageId ?? generateId(); + const now = Date.now(); + + // Calculate sibling index + let siblingIndex = 0; + if (parentId) { + const siblings = await db.messages.where('parentId').equals(parentId).count(); + siblingIndex = siblings; + } else { + // Root messages - count existing roots for this conversation + const roots = await db.messages + .where('conversationId') + .equals(conversationId) + .filter((m) => m.parentId === null) + .count(); + siblingIndex = roots; + } + + const stored: StoredMessage = { + id, + conversationId, + parentId, + role: message.role, + content: message.content, + images: message.images, + toolCalls: message.toolCalls, + siblingIndex, + createdAt: now + }; + + await db.messages.add(stored); + + // Queue for backend sync + await markForSync('message', id, 'create'); + + // Update conversation message count and timestamp + const conversation = await db.conversations.get(conversationId); + if (conversation) { + await db.conversations.update(conversationId, { + messageCount: conversation.messageCount + 1, + updatedAt: now + }); + } + + return toMessageNode(stored, []); + }); +} + +/** + * Update message content + */ +export async function updateMessage( + id: string, + content: string +): Promise> { + return withErrorHandling(async () => { + const existing = await db.messages.get(id); + if (!existing) { + throw new Error(`Message not found: ${id}`); + } + + const updated: StoredMessage = { + ...existing, + content + }; + + await db.messages.put(updated); + + // Queue for backend sync + await markForSync('message', id, 'update'); + + // Update conversation timestamp + await db.conversations.update(existing.conversationId, { + updatedAt: Date.now() + }); + + // Get children for the node + const children = await db.messages + .where('parentId') + .equals(id) + .sortBy('siblingIndex'); + + return toMessageNode( + updated, + children.map((c) => c.id) + ); + }); +} + +/** + * Delete a message and all its descendants + * Returns the IDs of all deleted messages + */ +export async function deleteMessage(id: string): Promise> { + return withErrorHandling(async () => { + const message = await db.messages.get(id); + if (!message) { + throw new Error(`Message not found: ${id}`); + } + + const deletedIds: string[] = []; + + // Recursively collect all descendant IDs + async function collectDescendants(messageId: string): Promise { + deletedIds.push(messageId); + const children = await db.messages.where('parentId').equals(messageId).toArray(); + for (const child of children) { + await collectDescendants(child.id); + } + } + + await collectDescendants(id); + + // Delete all messages and their attachments in a transaction + await db.transaction('rw', [db.messages, db.attachments, db.conversations], async () => { + // Delete attachments + await db.attachments.where('messageId').anyOf(deletedIds).delete(); + + // Delete messages + await db.messages.bulkDelete(deletedIds); + + // Update conversation message count + const conversation = await db.conversations.get(message.conversationId); + if (conversation) { + await db.conversations.update(message.conversationId, { + messageCount: Math.max(0, conversation.messageCount - deletedIds.length), + updatedAt: Date.now() + }); + } + }); + + // Queue deleted messages for backend sync (after transaction completes) + for (const deletedId of deletedIds) { + await markForSync('message', deletedId, 'delete'); + } + + return deletedIds; + }); +} + +/** + * Delete all messages for a conversation + * Used internally when deleting a conversation + */ +export async function deleteMessagesForConversation( + conversationId: string +): Promise> { + return withErrorHandling(async () => { + await db.messages.where('conversationId').equals(conversationId).delete(); + }); +} + +/** + * Build the message tree structure for a conversation + * Returns the root message ID and the active path (rightmost branch) + */ +export async function getMessageTree( + conversationId: string +): Promise> { + return withErrorHandling(async () => { + const messages = await db.messages + .where('conversationId') + .equals(conversationId) + .toArray(); + + if (messages.length === 0) { + return { rootMessageId: null, activePath: [] }; + } + + // Find root message(s) - messages with no parent + const roots = messages.filter((m) => m.parentId === null); + if (roots.length === 0) { + return { rootMessageId: null, activePath: [] }; + } + + // Sort roots by sibling index and take the last one (most recent) + roots.sort((a, b) => a.siblingIndex - b.siblingIndex); + const rootMessageId = roots[roots.length - 1].id; + + // Build the active path (following the last child at each level) + const activePath: BranchPath = []; + + // Build a map for quick lookup + const messageMap = new Map(messages.map((m) => [m.id, m])); + const childrenMap = new Map(); + + for (const msg of messages) { + if (msg.parentId) { + const siblings = childrenMap.get(msg.parentId) ?? []; + siblings.push(msg); + childrenMap.set(msg.parentId, siblings); + } + } + + // Sort children by sibling index + Array.from(childrenMap.entries()).forEach(([, children]) => { + children.sort((a, b) => a.siblingIndex - b.siblingIndex); + }); + + // Traverse from root, always taking the last child + let currentId: string | null = rootMessageId; + while (currentId) { + activePath.push(currentId); + const children = childrenMap.get(currentId); + if (children && children.length > 0) { + // Take the last child (most recent branch) + currentId = children[children.length - 1].id; + } else { + currentId = null; + } + } + + return { rootMessageId, activePath }; + }); +} + +/** + * Get siblings of a message (messages with the same parent) + * Returns the message IDs sorted by sibling index + */ +export async function getSiblings(messageId: string): Promise> { + return withErrorHandling(async () => { + const message = await db.messages.get(messageId); + if (!message) { + throw new Error(`Message not found: ${messageId}`); + } + + let siblings: StoredMessage[]; + + if (message.parentId === null) { + // Root message - get all root messages for this conversation + siblings = await db.messages + .where('conversationId') + .equals(message.conversationId) + .filter((m) => m.parentId === null) + .toArray(); + } else { + // Non-root - get all messages with the same parent + siblings = await db.messages.where('parentId').equals(message.parentId).toArray(); + } + + siblings.sort((a, b) => a.siblingIndex - b.siblingIndex); + return siblings.map((s) => s.id); + }); +} + +/** + * Get the path from root to a specific message + */ +export async function getPathToMessage(messageId: string): Promise> { + return withErrorHandling(async () => { + const path: BranchPath = []; + let currentId: string | null = messageId; + + while (currentId) { + path.unshift(currentId); + const msg: StoredMessage | undefined = await db.messages.get(currentId); + currentId = msg?.parentId ?? null; + } + + return path; + }); +} + +/** + * Append content to an existing message (for streaming) + */ +export async function appendToMessage( + id: string, + contentDelta: string +): Promise> { + return withErrorHandling(async () => { + const existing = await db.messages.get(id); + if (!existing) { + throw new Error(`Message not found: ${id}`); + } + + await db.messages.update(id, { + content: existing.content + contentDelta + }); + }); +} diff --git a/frontend/src/lib/storage/sync.ts b/frontend/src/lib/storage/sync.ts new file mode 100644 index 0000000..00be045 --- /dev/null +++ b/frontend/src/lib/storage/sync.ts @@ -0,0 +1,194 @@ +/** + * Sync utilities for future backend synchronization + * Provides a queue-based system for tracking changes that need to be synced + */ + +import { db, withErrorHandling, generateId } from './db.js'; +import type { SyncQueueItem, StorageResult } from './db.js'; + +/** + * Entity types that can be synced + */ +export type SyncEntityType = 'conversation' | 'message' | 'attachment'; + +/** + * Operations that can be synced + */ +export type SyncOperation = 'create' | 'update' | 'delete'; + +/** + * Mark an entity for synchronization + * Adds an entry to the sync queue + */ +export async function markForSync( + entityType: SyncEntityType, + entityId: string, + operation: SyncOperation +): Promise> { + return withErrorHandling(async () => { + // Check if there's already a pending sync for this entity + const existing = await db.syncQueue + .where('entityType') + .equals(entityType) + .filter((item) => item.entityId === entityId) + .first(); + + if (existing) { + // Update the existing sync item with the new operation + // Delete operations take precedence + const updatedOperation = operation === 'delete' ? 'delete' : existing.operation; + + const updated: SyncQueueItem = { + ...existing, + operation: updatedOperation, + createdAt: Date.now() + }; + + await db.syncQueue.put(updated); + return updated; + } + + // Create a new sync queue item + const item: SyncQueueItem = { + id: generateId(), + entityType, + entityId, + operation, + createdAt: Date.now(), + retryCount: 0 + }; + + await db.syncQueue.add(item); + return item; + }); +} + +/** + * Get all pending sync items, ordered by creation time + */ +export async function getPendingSyncItems(): Promise> { + return withErrorHandling(async () => { + const items = await db.syncQueue.orderBy('createdAt').toArray(); + return items; + }); +} + +/** + * Get pending sync items filtered by entity type + */ +export async function getPendingSyncItemsByType( + entityType: SyncEntityType +): Promise> { + return withErrorHandling(async () => { + const items = await db.syncQueue + .where('entityType') + .equals(entityType) + .sortBy('createdAt'); + return items; + }); +} + +/** + * Clear a sync item after successful synchronization + */ +export async function clearSyncItem(id: string): Promise> { + return withErrorHandling(async () => { + await db.syncQueue.delete(id); + }); +} + +/** + * Clear multiple sync items after successful synchronization + */ +export async function clearSyncItems(ids: string[]): Promise> { + return withErrorHandling(async () => { + await db.syncQueue.bulkDelete(ids); + }); +} + +/** + * Increment retry count for a failed sync item + * Returns the updated item or null if max retries exceeded + */ +export async function incrementRetryCount( + id: string, + maxRetries: number = 5 +): Promise> { + return withErrorHandling(async () => { + const item = await db.syncQueue.get(id); + if (!item) { + throw new Error(`Sync item not found: ${id}`); + } + + const newRetryCount = item.retryCount + 1; + + if (newRetryCount > maxRetries) { + // Max retries exceeded - remove from queue + await db.syncQueue.delete(id); + return null; + } + + const updated: SyncQueueItem = { + ...item, + retryCount: newRetryCount + }; + + await db.syncQueue.put(updated); + return updated; + }); +} + +/** + * Clear all pending sync items + * Use with caution - only for reset scenarios + */ +export async function clearAllSyncItems(): Promise> { + return withErrorHandling(async () => { + await db.syncQueue.clear(); + }); +} + +/** + * Get count of pending sync items + */ +export async function getPendingSyncCount(): Promise> { + return withErrorHandling(async () => { + return await db.syncQueue.count(); + }); +} + +/** + * Check if there are any pending sync items + */ +export async function hasPendingSyncs(): Promise> { + return withErrorHandling(async () => { + const count = await db.syncQueue.count(); + return count > 0; + }); +} + +/** + * Batch operation: Mark multiple entities for sync + */ +export async function markMultipleForSync( + items: Array<{ + entityType: SyncEntityType; + entityId: string; + operation: SyncOperation; + }> +): Promise> { + return withErrorHandling(async () => { + const results: SyncQueueItem[] = []; + + for (const item of items) { + const result = await markForSync(item.entityType, item.entityId, item.operation); + if (result.success) { + results.push(result.data); + } else { + throw new Error(`Failed to mark ${item.entityType}:${item.entityId} for sync`); + } + } + + return results; + }); +} diff --git a/frontend/src/lib/stores/chat.svelte.ts b/frontend/src/lib/stores/chat.svelte.ts new file mode 100644 index 0000000..68d04c8 --- /dev/null +++ b/frontend/src/lib/stores/chat.svelte.ts @@ -0,0 +1,388 @@ +/** + * Chat state management using Svelte 5 runes + * Handles message tree, streaming, and branch navigation + */ + +import type { Message, MessageNode, BranchPath, BranchInfo } from '$lib/types/chat.js'; + +/** Generate a unique ID for messages */ +function generateId(): string { + return crypto.randomUUID(); +} + +/** Chat state class with reactive properties */ +export class ChatState { + // Core state + conversationId = $state(null); + messageTree = $state>(new Map()); + rootMessageId = $state(null); + activePath = $state([]); + + // Streaming state + isStreaming = $state(false); + streamingMessageId = $state(null); + streamBuffer = $state(''); + + // Derived: Get visible messages along the active path + visibleMessages = $derived.by(() => { + const messages: MessageNode[] = []; + for (const id of this.activePath) { + const node = this.messageTree.get(id); + if (node) { + messages.push(node); + } + } + return messages; + }); + + // Derived: Can regenerate the last assistant message + canRegenerate = $derived.by(() => { + if (this.activePath.length === 0 || this.isStreaming) { + return false; + } + const lastId = this.activePath[this.activePath.length - 1]; + const lastNode = this.messageTree.get(lastId); + return lastNode?.message.role === 'assistant'; + }); + + /** + * Add a new message to the tree + * @param message The message content + * @param parentId Optional parent message ID (defaults to last in active path) + * @returns The ID of the newly created message node + */ + addMessage(message: Message, parentId?: string | null): string { + const id = generateId(); + const effectiveParentId = parentId ?? this.activePath[this.activePath.length - 1] ?? null; + + const node: MessageNode = { + id, + message, + parentId: effectiveParentId, + childIds: [], + createdAt: new Date() + }; + + // Update parent's childIds + if (effectiveParentId) { + const parent = this.messageTree.get(effectiveParentId); + if (parent) { + const updatedParent = { + ...parent, + childIds: [...parent.childIds, id] + }; + this.messageTree = new Map(this.messageTree).set(effectiveParentId, updatedParent); + } + } + + // Add the new node + this.messageTree = new Map(this.messageTree).set(id, node); + + // Set as root if first message + if (!this.rootMessageId) { + this.rootMessageId = id; + } + + // Update active path + this.activePath = [...this.activePath, id]; + + return id; + } + + /** + * Start streaming a new assistant message + * @returns The ID of the streaming message + */ + startStreaming(): string { + const id = this.addMessage({ role: 'assistant', content: '' }); + this.isStreaming = true; + this.streamingMessageId = id; + this.streamBuffer = ''; + return id; + } + + /** + * Append content to the currently streaming message + * @param content The content chunk to append + */ + appendToStreaming(content: string): void { + if (!this.streamingMessageId) return; + + this.streamBuffer += content; + + const node = this.messageTree.get(this.streamingMessageId); + if (node) { + const updatedNode: MessageNode = { + ...node, + message: { + ...node.message, + content: this.streamBuffer + } + }; + this.messageTree = new Map(this.messageTree).set(this.streamingMessageId, updatedNode); + } + } + + /** + * Complete the streaming process + */ + finishStreaming(): void { + this.isStreaming = false; + this.streamingMessageId = null; + this.streamBuffer = ''; + } + + /** + * Get branch info for a specific message + * @param messageId The message ID to get branch info for + */ + getBranchInfo(messageId: string): BranchInfo | null { + const node = this.messageTree.get(messageId); + if (!node) return null; + + if (!node.parentId) { + // Root message has no siblings + return { currentIndex: 0, totalCount: 1, siblingIds: [messageId] }; + } + + const parent = this.messageTree.get(node.parentId); + if (!parent) return null; + + const siblingIds = parent.childIds; + const currentIndex = siblingIds.indexOf(messageId); + + return { + currentIndex, + totalCount: siblingIds.length, + siblingIds + }; + } + + /** + * Switch to a different branch at a given message + * @param messageId The current message ID + * @param direction 'prev' or 'next' to navigate siblings + */ + switchBranch(messageId: string, direction: 'prev' | 'next'): void { + const branchInfo = this.getBranchInfo(messageId); + if (!branchInfo || branchInfo.totalCount <= 1) return; + + const { currentIndex, siblingIds } = branchInfo; + let newIndex = direction === 'prev' ? currentIndex - 1 : currentIndex + 1; + + // Wrap around + if (newIndex < 0) newIndex = siblingIds.length - 1; + if (newIndex >= siblingIds.length) newIndex = 0; + + const newMessageId = siblingIds[newIndex]; + this.switchToMessage(messageId, newMessageId); + } + + /** + * Switch from one message to another sibling message + * @param currentMessageId The current message ID in the active path + * @param targetMessageId The target sibling message ID to switch to + */ + switchToMessage(currentMessageId: string, targetMessageId: string): void { + // Rebuild active path up to and including the new message + const pathIndex = this.activePath.indexOf(currentMessageId); + if (pathIndex === -1) return; + + // Keep path up to the parent, then follow new branch + const newPath = this.activePath.slice(0, pathIndex); + newPath.push(targetMessageId); + + // Follow the first child of each node to complete the path + let currentId = targetMessageId; + let currentNode = this.messageTree.get(currentId); + while (currentNode && currentNode.childIds.length > 0) { + const firstChildId = currentNode.childIds[0]; + newPath.push(firstChildId); + currentNode = this.messageTree.get(firstChildId); + } + + this.activePath = newPath; + } + + /** + * Add a sibling message to an existing message (same parent) + * Used for regeneration and editing flows + * @param siblingId The existing sibling message ID + * @param message The new message content + * @returns The ID of the newly created sibling message + */ + addSiblingMessage(siblingId: string, message: Message): string { + const siblingNode = this.messageTree.get(siblingId); + if (!siblingNode) { + throw new Error(`Sibling message ${siblingId} not found`); + } + + const id = generateId(); + const parentId = siblingNode.parentId; + + const node: MessageNode = { + id, + message, + parentId, + childIds: [], + createdAt: new Date() + }; + + // Update parent's childIds + if (parentId) { + const parent = this.messageTree.get(parentId); + if (parent) { + const updatedParent = { + ...parent, + childIds: [...parent.childIds, id] + }; + this.messageTree = new Map(this.messageTree).set(parentId, updatedParent); + } + } + + // Add the new node + this.messageTree = new Map(this.messageTree).set(id, node); + + return id; + } + + /** + * Start regeneration by creating a new assistant message as sibling + * @param existingAssistantId The existing assistant message to regenerate from + * @returns The ID of the new streaming message, or null if regeneration not possible + */ + startRegeneration(existingAssistantId: string): string | null { + const existingNode = this.messageTree.get(existingAssistantId); + if (!existingNode || existingNode.message.role !== 'assistant') { + return null; + } + + // Create new assistant message as sibling + const newId = this.addSiblingMessage(existingAssistantId, { + role: 'assistant', + content: '' + }); + + // Update active path to point to new message + const pathIndex = this.activePath.indexOf(existingAssistantId); + if (pathIndex !== -1) { + this.activePath = [...this.activePath.slice(0, pathIndex), newId]; + } + + // Set up streaming state + this.isStreaming = true; + this.streamingMessageId = newId; + this.streamBuffer = ''; + + return newId; + } + + /** + * Start edit flow by creating a new user message as sibling and preparing for response + * @param existingUserMessageId The existing user message to edit + * @param newContent The new content for the edited message + * @param images Optional images to include + * @returns The ID of the new user message, or null if edit not possible + */ + startEditWithNewBranch( + existingUserMessageId: string, + newContent: string, + images?: string[] + ): string | null { + const existingNode = this.messageTree.get(existingUserMessageId); + if (!existingNode || existingNode.message.role !== 'user') { + return null; + } + + // Create new user message as sibling + const newUserMessageId = this.addSiblingMessage(existingUserMessageId, { + role: 'user', + content: newContent, + images + }); + + // Update active path to point to new user message + // Truncate the path at the user message level (remove any children from old branch) + const pathIndex = this.activePath.indexOf(existingUserMessageId); + if (pathIndex !== -1) { + this.activePath = [...this.activePath.slice(0, pathIndex), newUserMessageId]; + } + + return newUserMessageId; + } + + /** + * Get the parent user message for a given assistant message + * Useful for getting context when regenerating + * @param assistantMessageId The assistant message ID + * @returns The parent user message node, or null if not found + */ + getParentUserMessage(assistantMessageId: string): MessageNode | null { + const assistantNode = this.messageTree.get(assistantMessageId); + if (!assistantNode?.parentId) return null; + + const parentNode = this.messageTree.get(assistantNode.parentId); + if (!parentNode || parentNode.message.role !== 'user') return null; + + return parentNode; + } + + /** + * Get the path from root to a specific message + * @param messageId Target message ID + */ + getPathToMessage(messageId: string): BranchPath { + const path: BranchPath = []; + let currentId: string | null = messageId; + + while (currentId) { + path.unshift(currentId); + const node = this.messageTree.get(currentId); + currentId = node?.parentId ?? null; + } + + return path; + } + + /** + * Get all leaf nodes (messages with no children) + */ + getLeafNodes(): MessageNode[] { + return Array.from(this.messageTree.values()).filter((node) => node.childIds.length === 0); + } + + /** + * Reset the chat state + */ + reset(): void { + this.conversationId = null; + this.messageTree = new Map(); + this.rootMessageId = null; + this.activePath = []; + this.isStreaming = false; + this.streamingMessageId = null; + this.streamBuffer = ''; + } + + /** + * Load a conversation into the chat state + * @param conversationId The conversation ID + * @param messages The message tree map + * @param rootId The root message ID + * @param path The active path to restore + */ + load( + conversationId: string, + messages: Map, + rootId: string | null, + path: BranchPath + ): void { + this.conversationId = conversationId; + this.messageTree = new Map(messages); + this.rootMessageId = rootId; + this.activePath = [...path]; + } +} + +/** Singleton chat state instance */ +export const chatState = new ChatState(); diff --git a/frontend/src/lib/stores/conversations.svelte.ts b/frontend/src/lib/stores/conversations.svelte.ts new file mode 100644 index 0000000..1df1c31 --- /dev/null +++ b/frontend/src/lib/stores/conversations.svelte.ts @@ -0,0 +1,201 @@ +/** + * Conversations list state management using Svelte 5 runes + * Handles conversation list, search, filtering, and grouping + */ + +import type { Conversation } from '$lib/types/conversation.js'; + +/** Date group labels */ +type DateGroup = 'Today' | 'Yesterday' | 'Previous 7 Days' | 'Previous 30 Days' | 'Older'; + +/** Grouped conversations by date */ +export interface GroupedConversations { + group: DateGroup; + conversations: Conversation[]; +} + +/** Check if two dates are the same day */ +function isSameDay(d1: Date, d2: Date): boolean { + return ( + d1.getFullYear() === d2.getFullYear() && + d1.getMonth() === d2.getMonth() && + d1.getDate() === d2.getDate() + ); +} + +/** Get days difference between two dates */ +function getDaysDifference(date: Date, now: Date): number { + const msPerDay = 24 * 60 * 60 * 1000; + const dateStart = new Date(date.getFullYear(), date.getMonth(), date.getDate()); + const nowStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + return Math.floor((nowStart.getTime() - dateStart.getTime()) / msPerDay); +} + +/** Determine date group for a conversation */ +function getDateGroup(date: Date, now: Date): DateGroup { + if (isSameDay(date, now)) return 'Today'; + + const yesterday = new Date(now); + yesterday.setDate(yesterday.getDate() - 1); + if (isSameDay(date, yesterday)) return 'Yesterday'; + + const daysDiff = getDaysDifference(date, now); + if (daysDiff <= 7) return 'Previous 7 Days'; + if (daysDiff <= 30) return 'Previous 30 Days'; + return 'Older'; +} + +/** Conversations state class with reactive properties */ +export class ConversationsState { + // Core state + items = $state([]); + searchQuery = $state(''); + isLoading = $state(false); + + // Derived: Filtered conversations by search query + filtered = $derived.by(() => { + if (!this.searchQuery.trim()) { + return this.items.filter((c) => !c.isArchived); + } + + const query = this.searchQuery.toLowerCase().trim(); + return this.items.filter( + (c) => !c.isArchived && c.title.toLowerCase().includes(query) + ); + }); + + // Derived: Grouped conversations by date + grouped = $derived.by(() => { + const now = new Date(); + const groups = new Map(); + + // Initialize groups in order + const orderedGroups: DateGroup[] = [ + 'Today', + 'Yesterday', + 'Previous 7 Days', + 'Previous 30 Days', + 'Older' + ]; + for (const group of orderedGroups) { + groups.set(group, []); + } + + // Sort by pinned first, then by updatedAt + const sorted = [...this.filtered].sort((a, b) => { + if (a.isPinned !== b.isPinned) { + return a.isPinned ? -1 : 1; + } + return b.updatedAt.getTime() - a.updatedAt.getTime(); + }); + + // Group conversations + for (const conversation of sorted) { + const group = getDateGroup(conversation.updatedAt, now); + groups.get(group)!.push(conversation); + } + + // Convert to array, filtering empty groups + const result: GroupedConversations[] = []; + for (const group of orderedGroups) { + const conversations = groups.get(group)!; + if (conversations.length > 0) { + result.push({ group, conversations }); + } + } + + return result; + }); + + // Derived: Archived conversations + archived = $derived.by(() => { + return this.items + .filter((c) => c.isArchived) + .sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()); + }); + + // Derived: Pinned conversations + pinned = $derived.by(() => { + return this.items + .filter((c) => c.isPinned && !c.isArchived) + .sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()); + }); + + /** + * Load conversations (typically from IndexedDB) + * @param conversations Array of conversations to load + */ + load(conversations: Conversation[]): void { + this.items = [...conversations]; + } + + /** + * Add a new conversation + * @param conversation The conversation to add + */ + add(conversation: Conversation): void { + this.items = [conversation, ...this.items]; + } + + /** + * Update an existing conversation + * @param id The conversation ID + * @param updates Partial conversation updates + */ + update(id: string, updates: Partial>): void { + this.items = this.items.map((c) => { + if (c.id === id) { + return { ...c, ...updates, updatedAt: new Date() }; + } + return c; + }); + } + + /** + * Remove a conversation + * @param id The conversation ID to remove + */ + remove(id: string): void { + this.items = this.items.filter((c) => c.id !== id); + } + + /** + * Toggle pin status of a conversation + * @param id The conversation ID + */ + pin(id: string): void { + const conversation = this.items.find((c) => c.id === id); + if (conversation) { + this.update(id, { isPinned: !conversation.isPinned }); + } + } + + /** + * Toggle archive status of a conversation + * @param id The conversation ID + */ + archive(id: string): void { + const conversation = this.items.find((c) => c.id === id); + if (conversation) { + this.update(id, { isArchived: !conversation.isArchived }); + } + } + + /** + * Find a conversation by ID + * @param id The conversation ID + */ + find(id: string): Conversation | undefined { + return this.items.find((c) => c.id === id); + } + + /** + * Clear search query + */ + clearSearch(): void { + this.searchQuery = ''; + } +} + +/** Singleton conversations state instance */ +export const conversationsState = new ConversationsState(); diff --git a/frontend/src/lib/stores/index.ts b/frontend/src/lib/stores/index.ts new file mode 100644 index 0000000..7d5ce7f --- /dev/null +++ b/frontend/src/lib/stores/index.ts @@ -0,0 +1,14 @@ +/** + * Store exports + */ + +export { ChatState, chatState } from './chat.svelte.js'; +export { ConversationsState, conversationsState } from './conversations.svelte.js'; +export { ModelsState, modelsState } from './models.svelte.js'; +export { UIState, uiState } from './ui.svelte.js'; +export { ToastState, toastState } from './toast.svelte.js'; +export { toolsState } from './tools.svelte.js'; + +// Re-export types for convenience +export type { GroupedConversations } from './conversations.svelte.js'; +export type { Toast, ToastType } from './toast.svelte.js'; diff --git a/frontend/src/lib/stores/models.svelte.ts b/frontend/src/lib/stores/models.svelte.ts new file mode 100644 index 0000000..3db3a73 --- /dev/null +++ b/frontend/src/lib/stores/models.svelte.ts @@ -0,0 +1,212 @@ +/** + * Models state management using Svelte 5 runes + * Handles available models, selection, and categorization + */ + +import type { OllamaModel, ModelGroup } from '$lib/types/model.js'; + +/** Known vision model families/patterns */ +const VISION_PATTERNS = ['llava', 'bakllava', 'moondream', 'vision']; + +/** + * Middleware models that should NOT appear in the chat model selector + * These are special-purpose models for embeddings, function routing, etc. + */ +const MIDDLEWARE_MODEL_PATTERNS = [ + 'embeddinggemma', + 'functiongemma', + 'nomic-embed', + 'mxbai-embed', + 'all-minilm', + 'snowflake-arctic-embed', + 'bge-', // BGE embedding models + 'e5-', // E5 embedding models + 'gte-', // GTE embedding models + 'embed' // Generic embed pattern (catches most embedding models) +]; + +/** Check if a model is a middleware/utility model (not for direct chat) */ +function isMiddlewareModel(model: OllamaModel): boolean { + const name = model.name.toLowerCase(); + return MIDDLEWARE_MODEL_PATTERNS.some((pattern) => name.includes(pattern)); +} + +/** Check if a model supports vision */ +function isVisionModel(model: OllamaModel): boolean { + const name = model.name.toLowerCase(); + const family = model.details.family.toLowerCase(); + const families = model.details.families?.map((f) => f.toLowerCase()) ?? []; + + return ( + VISION_PATTERNS.some((pattern) => name.includes(pattern)) || + VISION_PATTERNS.some((pattern) => family.includes(pattern)) || + families.some((f) => VISION_PATTERNS.some((pattern) => f.includes(pattern))) + ); +} + +/** Models state class with reactive properties */ +export class ModelsState { + // Core state + available = $state([]); + selectedId = $state(null); + isLoading = $state(false); + error = $state(null); + + // Derived: Currently selected model + selected = $derived.by(() => { + if (!this.selectedId) return null; + return this.available.find((m) => m.name === this.selectedId) ?? null; + }); + + // Derived: Models eligible for chat (excludes middleware models) + chatModels = $derived.by(() => { + return this.available.filter((m) => !isMiddlewareModel(m)); + }); + + // Derived: Middleware models (for internal use only) + middlewareModels = $derived.by(() => { + return this.available.filter(isMiddlewareModel); + }); + + // Derived: Models grouped by family (only chat-eligible models) + grouped = $derived.by(() => { + const groups = new Map(); + + // Only include chat-eligible models in the selector + for (const model of this.chatModels) { + const family = model.details.family || 'Unknown'; + if (!groups.has(family)) { + groups.set(family, []); + } + groups.get(family)!.push(model); + } + + // Sort groups alphabetically, sort models within each group by name + const result: ModelGroup[] = []; + const sortedFamilies = Array.from(groups.keys()).sort(); + + for (const family of sortedFamilies) { + const models = groups.get(family)!.sort((a, b) => a.name.localeCompare(b.name)); + result.push({ family, models }); + } + + return result; + }); + + // Derived: Vision-capable models + visionModels = $derived.by(() => { + return this.available.filter(isVisionModel); + }); + + // Derived: Check if selected model supports vision + selectedSupportsVision = $derived.by(() => { + if (!this.selected) return false; + return isVisionModel(this.selected); + }); + + /** + * Refresh the list of available models from Ollama API + * Uses proxied /api path by default (vite.config.ts proxies to Ollama) + * @param apiBaseUrl The API base URL (default: empty for proxied path) + */ + async refresh(apiBaseUrl = ''): Promise { + this.isLoading = true; + this.error = null; + + try { + const response = await fetch(`${apiBaseUrl}/api/tags`); + + if (!response.ok) { + throw new Error(`Failed to fetch models: ${response.status} ${response.statusText}`); + } + + const data = await response.json(); + this.available = data.models ?? []; + + // Get chat-eligible models (exclude middleware) + const chatEligible = this.available.filter((m) => !isMiddlewareModel(m)); + + // Auto-select first chat model if none selected + if (!this.selectedId && chatEligible.length > 0) { + this.selectedId = chatEligible[0].name; + } + + // Clear selection if selected model no longer exists or is middleware + if (this.selectedId) { + const selectedModel = this.available.find((m) => m.name === this.selectedId); + if (!selectedModel || isMiddlewareModel(selectedModel)) { + this.selectedId = chatEligible.length > 0 ? chatEligible[0].name : null; + } + } + } catch (err) { + this.error = err instanceof Error ? err.message : 'Unknown error fetching models'; + console.error('Failed to refresh models:', err); + } finally { + this.isLoading = false; + } + } + + /** + * Select a model by name (only allows chat-eligible models) + * Persists selection to localStorage + * @param modelName The model name to select + */ + select(modelName: string): void { + const model = this.available.find((m) => m.name === modelName); + if (model && !isMiddlewareModel(model)) { + this.selectedId = modelName; + // Persist selection + if (typeof localStorage !== 'undefined') { + localStorage.setItem('selectedModel', modelName); + } + } + } + + /** + * Load persisted model selection from localStorage + * Call this after models are loaded + */ + loadPersistedSelection(): void { + if (typeof localStorage === 'undefined') return; + + const savedModel = localStorage.getItem('selectedModel'); + if (savedModel && this.hasModel(savedModel) && this.isChatModel(savedModel)) { + this.selectedId = savedModel; + } + } + + /** + * Check if a model is available for chat (not middleware) + * @param modelName The model name to check + */ + isChatModel(modelName: string): boolean { + const model = this.available.find((m) => m.name === modelName); + return model ? !isMiddlewareModel(model) : false; + } + + /** + * Clear the current selection + */ + clearSelection(): void { + this.selectedId = null; + } + + /** + * Get a model by name + * @param modelName The model name to find + */ + getByName(modelName: string): OllamaModel | undefined { + return this.available.find((m) => m.name === modelName); + } + + /** + * Check if a specific model is available + * @param modelName The model name to check + */ + hasModel(modelName: string): boolean { + return this.available.some((m) => m.name === modelName); + } +} + +/** Singleton models state instance */ +export const modelsState = new ModelsState(); diff --git a/frontend/src/lib/stores/toast.svelte.ts b/frontend/src/lib/stores/toast.svelte.ts new file mode 100644 index 0000000..2871100 --- /dev/null +++ b/frontend/src/lib/stores/toast.svelte.ts @@ -0,0 +1,123 @@ +/** + * Toast notification state management using Svelte 5 runes + * Provides a simple toast notification system for user feedback + */ + +/** Toast notification types */ +export type ToastType = 'success' | 'error' | 'warning' | 'info'; + +/** Toast notification data */ +export interface Toast { + id: string; + type: ToastType; + message: string; + duration: number; +} + +/** Default toast duration in milliseconds */ +const DEFAULT_DURATION = 4000; + +/** Generate unique toast ID */ +function generateId(): string { + return `toast-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; +} + +/** Toast state class with reactive properties */ +export class ToastState { + /** Active toasts */ + toasts = $state([]); + + /** Timeout handles for auto-dismiss */ + private timeouts = new Map>(); + + /** + * Show a toast notification + * @param type Toast type + * @param message Message to display + * @param duration Duration in ms (0 = no auto-dismiss) + * @returns Toast ID + */ + show(type: ToastType, message: string, duration: number = DEFAULT_DURATION): string { + const id = generateId(); + const toast: Toast = { id, type, message, duration }; + + this.toasts = [...this.toasts, toast]; + + // Set up auto-dismiss + if (duration > 0) { + const timeout = setTimeout(() => { + this.dismiss(id); + }, duration); + this.timeouts.set(id, timeout); + } + + return id; + } + + /** + * Show a success toast + * @param message Message to display + * @param duration Optional duration + */ + success(message: string, duration?: number): string { + return this.show('success', message, duration); + } + + /** + * Show an error toast + * @param message Message to display + * @param duration Optional duration + */ + error(message: string, duration?: number): string { + return this.show('error', message, duration ?? 6000); // Errors stay longer + } + + /** + * Show a warning toast + * @param message Message to display + * @param duration Optional duration + */ + warning(message: string, duration?: number): string { + return this.show('warning', message, duration); + } + + /** + * Show an info toast + * @param message Message to display + * @param duration Optional duration + */ + info(message: string, duration?: number): string { + return this.show('info', message, duration); + } + + /** + * Dismiss a specific toast + * @param id Toast ID to dismiss + */ + dismiss(id: string): void { + // Clear timeout if exists + const timeout = this.timeouts.get(id); + if (timeout) { + clearTimeout(timeout); + this.timeouts.delete(id); + } + + this.toasts = this.toasts.filter((t) => t.id !== id); + } + + /** + * Dismiss all toasts + */ + dismissAll(): void { + // Clear all timeouts + for (const timeout of this.timeouts.values()) { + clearTimeout(timeout); + } + this.timeouts.clear(); + + this.toasts = []; + } +} + +/** Singleton toast state instance */ +export const toastState = new ToastState(); diff --git a/frontend/src/lib/stores/tools.svelte.ts b/frontend/src/lib/stores/tools.svelte.ts new file mode 100644 index 0000000..04dc58d --- /dev/null +++ b/frontend/src/lib/stores/tools.svelte.ts @@ -0,0 +1,210 @@ +/** + * Tools state management using Svelte 5 runes + * Manages enabled tools and custom tool definitions + */ + +import { toolRegistry, getBuiltinToolDefinitions } from '$lib/tools'; +import type { ToolDefinition, CustomTool } from '$lib/tools'; + +/** Tool enable state (persisted to localStorage) */ +interface ToolEnableState { + [toolName: string]: boolean; +} + +/** Tools state class with reactive properties */ +class ToolsState { + /** Which tools are currently enabled */ + enabledTools = $state({}); + + /** Custom tools created by the user */ + customTools = $state([]); + + /** Whether tools are enabled for chat */ + toolsEnabled = $state(true); + + constructor() { + // Load persisted state on initialization + if (typeof window !== 'undefined') { + this.loadFromStorage(); + } + } + + /** + * Load state from localStorage + */ + private loadFromStorage(): void { + try { + const enabledState = localStorage.getItem('toolsEnabled'); + if (enabledState !== null) { + this.toolsEnabled = enabledState === 'true'; + } + + const enabledTools = localStorage.getItem('enabledTools'); + if (enabledTools) { + this.enabledTools = JSON.parse(enabledTools); + } else { + // Default: all builtin tools enabled + for (const def of getBuiltinToolDefinitions()) { + this.enabledTools[def.function.name] = true; + } + } + + const customTools = localStorage.getItem('customTools'); + if (customTools) { + this.customTools = JSON.parse(customTools); + } + } catch (error) { + console.error('Failed to load tools state from storage:', error); + } + } + + /** + * Save state to localStorage + */ + private saveToStorage(): void { + try { + localStorage.setItem('toolsEnabled', String(this.toolsEnabled)); + localStorage.setItem('enabledTools', JSON.stringify(this.enabledTools)); + localStorage.setItem('customTools', JSON.stringify(this.customTools)); + } catch (error) { + console.error('Failed to save tools state to storage:', error); + } + } + + /** + * Toggle global tools enabled state + */ + toggleToolsEnabled(): void { + this.toolsEnabled = !this.toolsEnabled; + this.saveToStorage(); + } + + /** + * Enable or disable a specific tool + */ + setToolEnabled(toolName: string, enabled: boolean): void { + this.enabledTools[toolName] = enabled; + this.saveToStorage(); + } + + /** + * Toggle a specific tool's enabled state + */ + toggleTool(toolName: string): void { + this.enabledTools[toolName] = !this.enabledTools[toolName]; + this.saveToStorage(); + } + + /** + * Check if a tool is enabled + */ + isToolEnabled(toolName: string): boolean { + return this.enabledTools[toolName] ?? true; + } + + /** + * Get all enabled tool definitions for Ollama API + */ + getEnabledToolDefinitions(): ToolDefinition[] { + if (!this.toolsEnabled) { + return []; + } + + const definitions = toolRegistry.getDefinitions(); + return definitions.filter(def => this.isToolEnabled(def.function.name)); + } + + /** + * Get all tool definitions with their enabled state + */ + getAllToolsWithState(): Array<{ definition: ToolDefinition; enabled: boolean; isBuiltin: boolean }> { + const result: Array<{ definition: ToolDefinition; enabled: boolean; isBuiltin: boolean }> = []; + + // Add builtin tools + for (const def of getBuiltinToolDefinitions()) { + result.push({ + definition: def, + enabled: this.isToolEnabled(def.function.name), + isBuiltin: true + }); + } + + // Add custom tools + for (const custom of this.customTools) { + const def: ToolDefinition = { + type: 'function', + function: { + name: custom.name, + description: custom.description, + parameters: custom.parameters + } + }; + result.push({ + definition: def, + enabled: custom.enabled && this.isToolEnabled(custom.name), + isBuiltin: false + }); + } + + return result; + } + + /** + * Add a custom tool + */ + addCustomTool(tool: Omit): CustomTool { + const newTool: CustomTool = { + ...tool, + id: crypto.randomUUID(), + createdAt: new Date(), + updatedAt: new Date() + }; + + this.customTools = [...this.customTools, newTool]; + this.enabledTools[newTool.name] = newTool.enabled; + this.saveToStorage(); + + return newTool; + } + + /** + * Update a custom tool + */ + updateCustomTool(id: string, updates: Partial): void { + this.customTools = this.customTools.map(tool => { + if (tool.id === id) { + const updated = { ...tool, ...updates, updatedAt: new Date() }; + if (updates.name && updates.name !== tool.name) { + // Update enabled state key if name changed + delete this.enabledTools[tool.name]; + this.enabledTools[updates.name] = updated.enabled; + } + return updated; + } + return tool; + }); + this.saveToStorage(); + } + + /** + * Remove a custom tool + */ + removeCustomTool(id: string): void { + const tool = this.customTools.find(t => t.id === id); + if (tool) { + delete this.enabledTools[tool.name]; + this.customTools = this.customTools.filter(t => t.id !== id); + this.saveToStorage(); + } + } + + /** + * Get a custom tool by ID + */ + getCustomTool(id: string): CustomTool | undefined { + return this.customTools.find(t => t.id === id); + } +} + +/** Singleton tools state instance */ +export const toolsState = new ToolsState(); diff --git a/frontend/src/lib/stores/ui.svelte.ts b/frontend/src/lib/stores/ui.svelte.ts new file mode 100644 index 0000000..a7d56ef --- /dev/null +++ b/frontend/src/lib/stores/ui.svelte.ts @@ -0,0 +1,162 @@ +/** + * UI state management using Svelte 5 runes + * Handles sidenav, theme, and responsive state + */ + +/** Breakpoint for mobile detection (in pixels) */ +const MOBILE_BREAKPOINT = 768; + +/** UI state class with reactive properties */ +export class UIState { + // Core state + sidenavOpen = $state(true); + darkMode = $state(true); // Default to dark mode + isMobile = $state(false); + + // Derived: Effective sidenav state (closed on mobile by default) + effectiveSidenavOpen = $derived.by(() => { + if (this.isMobile) { + return this.sidenavOpen; + } + return this.sidenavOpen; + }); + + constructor() { + // Initialize will be called separately to avoid SSR issues + } + + /** + * Initialize UI state (call in browser only) + * Sets up media queries and loads persisted preferences + */ + initialize(): void { + if (typeof window === 'undefined') return; + + // Check initial mobile state + this.isMobile = window.innerWidth < MOBILE_BREAKPOINT; + + // Default sidenav closed on mobile + if (this.isMobile) { + this.sidenavOpen = false; + } + + // Load dark mode preference (default to dark if not set) + const savedDarkMode = localStorage.getItem('darkMode'); + if (savedDarkMode !== null) { + this.darkMode = savedDarkMode === 'true'; + } + // If no preference saved, keep default (dark mode) + + // Apply dark mode class + this.applyDarkMode(); + + // Listen for resize events + window.addEventListener('resize', this.handleResize.bind(this)); + + // Listen for system theme changes + window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => { + if (localStorage.getItem('darkMode') === null) { + this.darkMode = e.matches; + this.applyDarkMode(); + } + }); + } + + /** + * Clean up event listeners + */ + destroy(): void { + if (typeof window === 'undefined') return; + window.removeEventListener('resize', this.handleResize.bind(this)); + } + + /** + * Handle window resize + */ + private handleResize(): void { + const wasMobile = this.isMobile; + this.isMobile = window.innerWidth < MOBILE_BREAKPOINT; + + // Auto-close sidenav when switching to mobile + if (!wasMobile && this.isMobile) { + this.sidenavOpen = false; + } + } + + /** + * Apply dark mode class to document + */ + private applyDarkMode(): void { + if (typeof document === 'undefined') return; + + if (this.darkMode) { + document.documentElement.classList.add('dark'); + } else { + document.documentElement.classList.remove('dark'); + } + } + + /** + * Toggle sidenav open/closed + */ + toggleSidenav(): void { + this.sidenavOpen = !this.sidenavOpen; + } + + /** + * Open sidenav + */ + openSidenav(): void { + this.sidenavOpen = true; + } + + /** + * Close sidenav + */ + closeSidenav(): void { + this.sidenavOpen = false; + } + + /** + * Toggle dark mode + */ + toggleDarkMode(): void { + this.darkMode = !this.darkMode; + this.applyDarkMode(); + + // Persist preference + if (typeof localStorage !== 'undefined') { + localStorage.setItem('darkMode', String(this.darkMode)); + } + } + + /** + * Set dark mode explicitly + * @param enabled Whether dark mode should be enabled + */ + setDarkMode(enabled: boolean): void { + this.darkMode = enabled; + this.applyDarkMode(); + + if (typeof localStorage !== 'undefined') { + localStorage.setItem('darkMode', String(this.darkMode)); + } + } + + /** + * Reset to system preference for dark mode + */ + useSystemTheme(): void { + if (typeof localStorage !== 'undefined') { + localStorage.removeItem('darkMode'); + } + + if (typeof window !== 'undefined') { + this.darkMode = window.matchMedia('(prefers-color-scheme: dark)').matches; + this.applyDarkMode(); + } + } +} + +/** Singleton UI state instance */ +export const uiState = new UIState(); diff --git a/frontend/src/lib/tools/builtin.ts b/frontend/src/lib/tools/builtin.ts new file mode 100644 index 0000000..f7c190e --- /dev/null +++ b/frontend/src/lib/tools/builtin.ts @@ -0,0 +1,467 @@ +/** + * Built-in tools that come with the application + */ + +import type { ToolDefinition, BuiltinToolHandler, ToolRegistryEntry } from './types.js'; + +// ============================================================================ +// Get Current Time Tool +// ============================================================================ + +interface GetTimeArgs { + timezone?: string; + format?: 'iso' | 'locale' | 'unix'; +} + +const getTimeDefinition: ToolDefinition = { + type: 'function', + function: { + name: 'get_current_time', + description: 'Get the current date and time. Can optionally specify timezone and format.', + parameters: { + type: 'object', + properties: { + timezone: { + type: 'string', + description: 'IANA timezone name (e.g., "America/New_York", "Europe/London"). Defaults to local timezone.' + }, + format: { + type: 'string', + description: 'Output format: "iso" for ISO 8601, "locale" for localized string, "unix" for Unix timestamp.', + enum: ['iso', 'locale', 'unix'] + } + } + } + } +}; + +const getTimeHandler: BuiltinToolHandler = (args) => { + const now = new Date(); + const format = args.format ?? 'iso'; + + try { + if (args.timezone) { + const options: Intl.DateTimeFormatOptions = { + timeZone: args.timezone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false + }; + + if (format === 'locale') { + return new Intl.DateTimeFormat('en-US', { + ...options, + dateStyle: 'full', + timeStyle: 'long' + }).format(now); + } + + const formatter = new Intl.DateTimeFormat('en-CA', options); + const parts = formatter.formatToParts(now); + const get = (type: string) => parts.find(p => p.type === type)?.value ?? ''; + return `${get('year')}-${get('month')}-${get('day')}T${get('hour')}:${get('minute')}:${get('second')}`; + } + + switch (format) { + case 'unix': + return Math.floor(now.getTime() / 1000); + case 'locale': + return now.toLocaleString(); + case 'iso': + default: + return now.toISOString(); + } + } catch { + return { error: `Invalid timezone: ${args.timezone}` }; + } +}; + +// ============================================================================ +// Calculate Tool (Safe Math Expression Parser) +// ============================================================================ + +interface CalculateArgs { + expression: string; + precision?: number; +} + +const calculateDefinition: ToolDefinition = { + type: 'function', + function: { + name: 'calculate', + description: 'Compute a mathematical expression. Supports basic arithmetic (+, -, *, /, ^), parentheses, and common functions (sqrt, sin, cos, tan, log, exp, abs, round, floor, ceil).', + parameters: { + type: 'object', + properties: { + expression: { + type: 'string', + description: 'The mathematical expression to compute (e.g., "2 + 2", "sqrt(16)", "sin(3.14159/2)")' + }, + precision: { + type: 'number', + description: 'Number of decimal places for the result (default: 10)' + } + }, + required: ['expression'] + } + } +}; + +/** + * Safe math expression parser using recursive descent + * Parses and computes expressions without using dynamic code execution + */ +class MathParser { + private pos = 0; + private expr = ''; + + private readonly functions: Record number> = { + sqrt: Math.sqrt, + sin: Math.sin, + cos: Math.cos, + tan: Math.tan, + log: Math.log, + log10: Math.log10, + exp: Math.exp, + abs: Math.abs, + round: Math.round, + floor: Math.floor, + ceil: Math.ceil + }; + + private readonly constants: Record = { + PI: Math.PI, + pi: Math.PI, + E: Math.E, + e: Math.E + }; + + parse(expression: string): number { + this.expr = expression.replace(/\s+/g, ''); + this.pos = 0; + const result = this.parseExpression(); + if (this.pos < this.expr.length) { + throw new Error(`Unexpected character at position ${this.pos}: ${this.expr[this.pos]}`); + } + return result; + } + + private parseExpression(): number { + return this.parseAddSub(); + } + + private parseAddSub(): number { + let left = this.parseMulDiv(); + while (this.pos < this.expr.length) { + const op = this.expr[this.pos]; + if (op === '+') { + this.pos++; + left = left + this.parseMulDiv(); + } else if (op === '-') { + this.pos++; + left = left - this.parseMulDiv(); + } else { + break; + } + } + return left; + } + + private parseMulDiv(): number { + let left = this.parsePower(); + while (this.pos < this.expr.length) { + const op = this.expr[this.pos]; + if (op === '*') { + this.pos++; + left = left * this.parsePower(); + } else if (op === '/') { + this.pos++; + const right = this.parsePower(); + if (right === 0) throw new Error('Division by zero'); + left = left / right; + } else if (op === '%') { + this.pos++; + left = left % this.parsePower(); + } else { + break; + } + } + return left; + } + + private parsePower(): number { + const left = this.parseUnary(); + if (this.pos < this.expr.length && (this.expr[this.pos] === '^' || this.expr.slice(this.pos, this.pos + 2) === '**')) { + if (this.expr[this.pos] === '^') { + this.pos++; + } else { + this.pos += 2; + } + return Math.pow(left, this.parsePower()); + } + return left; + } + + private parseUnary(): number { + if (this.expr[this.pos] === '-') { + this.pos++; + return -this.parseUnary(); + } + if (this.expr[this.pos] === '+') { + this.pos++; + return this.parseUnary(); + } + return this.parsePrimary(); + } + + private parsePrimary(): number { + if (this.expr[this.pos] === '(') { + this.pos++; + const result = this.parseExpression(); + if (this.expr[this.pos] !== ')') { + throw new Error('Missing closing parenthesis'); + } + this.pos++; + return result; + } + + const funcMatch = this.expr.slice(this.pos).match(/^([a-zA-Z_][a-zA-Z0-9_]*)/); + if (funcMatch) { + const name = funcMatch[1]; + this.pos += name.length; + + if (this.constants[name] !== undefined) { + return this.constants[name]; + } + + const fn = this.functions[name.toLowerCase()]; + if (!fn) { + throw new Error(`Unknown function or constant: ${name}`); + } + + if (this.expr[this.pos] !== '(') { + throw new Error(`Expected '(' after function ${name}`); + } + this.pos++; + const arg = this.parseExpression(); + if (this.expr[this.pos] !== ')') { + throw new Error('Missing closing parenthesis for function'); + } + this.pos++; + return fn(arg); + } + + const numMatch = this.expr.slice(this.pos).match(/^(\d+\.?\d*|\.\d+)/); + if (numMatch) { + this.pos += numMatch[1].length; + return parseFloat(numMatch[1]); + } + + throw new Error(`Unexpected character at position ${this.pos}: ${this.expr[this.pos] || 'end of expression'}`); + } +} + +const mathParser = new MathParser(); + +const calculateHandler: BuiltinToolHandler = (args) => { + const { expression, precision = 10 } = args; + + try { + const result = mathParser.parse(expression); + + if (typeof result !== 'number' || !isFinite(result)) { + return { error: 'Expression resulted in invalid number (infinity or NaN)' }; + } + + return Number(result.toFixed(precision)); + } catch (error) { + return { error: `Failed to compute expression: ${error instanceof Error ? error.message : 'Unknown error'}` }; + } +}; + +// ============================================================================ +// Fetch URL Tool (Web Content Retrieval) +// ============================================================================ + +interface FetchUrlArgs { + url: string; + extract?: 'text' | 'title' | 'links' | 'all'; + maxLength?: number; +} + +const fetchUrlDefinition: ToolDefinition = { + type: 'function', + function: { + name: 'fetch_url', + description: 'Fetch content from a URL and extract text, title, or links. Useful for retrieving web page content.', + parameters: { + type: 'object', + properties: { + url: { + type: 'string', + description: 'The URL to fetch (must be a valid HTTP/HTTPS URL)' + }, + extract: { + type: 'string', + description: 'What to extract: "text" for main content, "title" for page title, "links" for all links, "all" for everything', + enum: ['text', 'title', 'links', 'all'] + }, + maxLength: { + type: 'number', + description: 'Maximum length of extracted text (default: 5000 characters)' + } + }, + required: ['url'] + } + } +}; + +/** + * Try to fetch URL via backend proxy first (bypasses CORS), fall back to direct fetch + */ +async function fetchViaProxy(url: string, maxLength: number): Promise<{ html: string; finalUrl: string } | { error: string }> { + // Try backend proxy first + try { + const proxyResponse = await fetch('/api/v1/proxy/fetch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url, maxLength }) + }); + + if (proxyResponse.ok) { + const data = await proxyResponse.json(); + return { html: data.content, finalUrl: data.url }; + } + + // If proxy returns an error, extract it + const errorData = await proxyResponse.json().catch(() => null); + if (errorData?.error) { + return { error: errorData.error }; + } + } catch { + // Proxy not available, try direct fetch + } + + // Fall back to direct fetch (may fail due to CORS) + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 10000); + + const response = await fetch(url, { + signal: controller.signal, + headers: { + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8' + } + }); + + clearTimeout(timeout); + + if (!response.ok) { + return { error: `HTTP ${response.status}: ${response.statusText}` }; + } + + const html = await response.text(); + return { html, finalUrl: response.url }; + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + return { error: 'Request timed out after 10 seconds' }; + } + // Provide helpful error message for CORS issues + const message = error instanceof Error ? error.message : 'Unknown error'; + if (message.includes('NetworkError') || message.includes('CORS')) { + return { error: `Cannot fetch external URL due to browser security restrictions. The backend proxy is not available. Start the backend server to enable URL fetching.` }; + } + return { error: `Failed to fetch URL: ${message}` }; + } +} + +const fetchUrlHandler: BuiltinToolHandler = async (args) => { + const { url, extract = 'text', maxLength = 5000 } = args; + + try { + const parsedUrl = new URL(url); + if (!['http:', 'https:'].includes(parsedUrl.protocol)) { + return { error: 'Only HTTP and HTTPS URLs are supported' }; + } + + // Fetch via proxy or direct + const result = await fetchViaProxy(url, maxLength); + if ('error' in result) { + return result; + } + + const { html, finalUrl } = result; + + const titleMatch = html.match(/]*>([^<]+)<\/title>/i); + const title = titleMatch ? titleMatch[1].trim() : null; + + if (extract === 'title') { + return title ?? 'No title found'; + } + + const stripHtml = (str: string) => { + return str + .replace(/]*>[\s\S]*?<\/script>/gi, '') + .replace(/]*>[\s\S]*?<\/style>/gi, '') + .replace(/<[^>]+>/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + }; + + const linkMatches = [...html.matchAll(/]+href=["']([^"']+)["'][^>]*>([^<]*)<\/a>/gi)]; + const links = linkMatches.slice(0, 50).map(match => ({ + url: match[1], + text: match[2].trim() + })).filter(link => link.url && !link.url.startsWith('#')); + + if (extract === 'links') { + return links; + } + + const text = stripHtml(html).substring(0, maxLength); + + if (extract === 'text') { + return text; + } + + return { + title, + text, + links: links.slice(0, 20), + url: finalUrl + }; + } catch (error) { + return { error: `Failed to fetch URL: ${error instanceof Error ? error.message : 'Unknown error'}` }; + } +}; + +// ============================================================================ +// Registry of Built-in Tools +// ============================================================================ + +export const builtinTools: Map = new Map([ + ['get_current_time', { + definition: getTimeDefinition, + handler: getTimeHandler as unknown as BuiltinToolHandler, + isBuiltin: true + }], + ['calculate', { + definition: calculateDefinition, + handler: calculateHandler as unknown as BuiltinToolHandler, + isBuiltin: true + }], + ['fetch_url', { + definition: fetchUrlDefinition, + handler: fetchUrlHandler as unknown as BuiltinToolHandler, + isBuiltin: true + }] +]); + +/** Get all built-in tool definitions for Ollama API */ +export function getBuiltinToolDefinitions(): ToolDefinition[] { + return Array.from(builtinTools.values()).map(entry => entry.definition); +} diff --git a/frontend/src/lib/tools/config.ts b/frontend/src/lib/tools/config.ts new file mode 100644 index 0000000..400f866 --- /dev/null +++ b/frontend/src/lib/tools/config.ts @@ -0,0 +1,56 @@ +/** + * Tool system configuration + * + * Defines preferred models for function calling and tool execution. + */ + +/** + * Preferred model for function/tool calling + * functiongemma acts as a middle layer to determine when and how to call tools + */ +export const PREFERRED_FUNCTION_MODEL = 'functiongemma:latest'; + +/** + * Whether to use a dedicated function model for tool routing + * When true, functiongemma processes the request first to decide on tool usage + * When false, tools are passed directly to the selected model + */ +export const USE_FUNCTION_MODEL = true; // functiongemma routes tool calls for non-native models + +/** + * Get the model to use for function calling + * Returns the preferred function model if available, otherwise falls back + * to the provided model + */ +export function getFunctionModel(fallbackModel: string): string { + // In production, you'd check if functiongemma is available + // For now, just return the fallback (selected model) directly + if (USE_FUNCTION_MODEL) { + return PREFERRED_FUNCTION_MODEL; + } + return fallbackModel; +} + +/** + * Tool calling configuration + */ +export interface ToolConfig { + /** Whether tools are globally enabled */ + enabled: boolean; + /** Model to use for function calling (null = use selected model) */ + functionModel: string | null; + /** Max tool call depth (prevent infinite loops) */ + maxToolCallDepth: number; + /** Timeout for individual tool execution (ms) */ + toolTimeout: number; +} + +/** + * Default tool configuration + */ +export const defaultToolConfig: ToolConfig = { + enabled: true, + functionModel: null, // null = use selected model, set to PREFERRED_FUNCTION_MODEL to use functiongemma + maxToolCallDepth: 5, + toolTimeout: 30000 +}; diff --git a/frontend/src/lib/tools/executor.ts b/frontend/src/lib/tools/executor.ts new file mode 100644 index 0000000..522871f --- /dev/null +++ b/frontend/src/lib/tools/executor.ts @@ -0,0 +1,214 @@ +/** + * Tool Executor - Handles running tools and managing results + */ + +import type { + ToolCall, + ParsedToolCall, + ToolResult, + ToolRegistryEntry, + ToolDefinition, + ToolContext, + ToolCallState +} from './types.js'; +import { builtinTools, getBuiltinToolDefinitions } from './builtin.js'; + +/** + * Tool Registry - Manages all available tools (builtin + custom) + */ +class ToolRegistry { + private tools: Map = new Map(); + + constructor() { + // Initialize with builtin tools + for (const [name, entry] of builtinTools) { + this.tools.set(name, entry); + } + } + + /** + * Register a custom tool + */ + register(name: string, entry: ToolRegistryEntry): void { + this.tools.set(name, entry); + } + + /** + * Unregister a tool + */ + unregister(name: string): boolean { + const entry = this.tools.get(name); + if (entry?.isBuiltin) { + return false; // Cannot unregister builtin tools + } + return this.tools.delete(name); + } + + /** + * Get a tool by name + */ + get(name: string): ToolRegistryEntry | undefined { + return this.tools.get(name); + } + + /** + * Check if a tool exists + */ + has(name: string): boolean { + return this.tools.has(name); + } + + /** + * Get all tool definitions (for Ollama API) + */ + getDefinitions(): ToolDefinition[] { + return Array.from(this.tools.values()).map(entry => entry.definition); + } + + /** + * Get builtin tool definitions only + */ + getBuiltinDefinitions(): ToolDefinition[] { + return getBuiltinToolDefinitions(); + } + + /** + * Get all tool names + */ + getNames(): string[] { + return Array.from(this.tools.keys()); + } + + /** + * Get count of registered tools + */ + get size(): number { + return this.tools.size; + } +} + +/** Singleton registry instance */ +export const toolRegistry = new ToolRegistry(); + +/** + * Parse a tool call from model response + */ +export function parseToolCall(call: ToolCall): ParsedToolCall { + let args: Record = {}; + + try { + args = JSON.parse(call.function.arguments); + } catch { + // If JSON parsing fails, try to extract as simple value + args = { value: call.function.arguments }; + } + + return { + id: call.id, + name: call.function.name, + arguments: args + }; +} + +/** + * Run a single tool call + */ +export async function runToolCall( + call: ToolCall | ParsedToolCall, + context?: ToolContext +): Promise { + const parsed = 'function' in call ? parseToolCall(call) : call; + const { id, name, arguments: args } = parsed; + + const entry = toolRegistry.get(name); + if (!entry) { + return { + toolCallId: id, + success: false, + error: `Unknown tool: ${name}` + }; + } + + try { + const result = await entry.handler(args); + + // Check if result is an error object + if (result && typeof result === 'object' && 'error' in result) { + return { + toolCallId: id, + success: false, + error: String((result as { error: unknown }).error) + }; + } + + return { + toolCallId: id, + success: true, + result + }; + } catch (error) { + return { + toolCallId: id, + success: false, + error: error instanceof Error ? error.message : 'Unknown error during tool execution' + }; + } +} + +/** + * Run multiple tool calls in parallel + */ +export async function runToolCalls( + calls: (ToolCall | ParsedToolCall)[], + context?: ToolContext +): Promise { + return Promise.all(calls.map(call => runToolCall(call, context))); +} + +/** + * Format tool results for inclusion in chat message + */ +export function formatToolResultsForChat(results: ToolResult[]): string { + return results + .map(result => { + if (result.success) { + const value = typeof result.result === 'object' + ? JSON.stringify(result.result, null, 2) + : String(result.result); + return `Tool result: ${value}`; + } else { + return `Tool error: ${result.error}`; + } + }) + .join('\n\n'); +} + +/** + * Create a tool call state for UI tracking + */ +export function createToolCallState(call: ToolCall | ParsedToolCall): ToolCallState { + const parsed = 'function' in call ? parseToolCall(call) : call; + return { + id: parsed.id, + name: parsed.name, + arguments: parsed.arguments, + status: 'pending', + startTime: Date.now() + }; +} + +/** + * Update tool call state with result + */ +export function updateToolCallState( + state: ToolCallState, + result: ToolResult +): ToolCallState { + return { + ...state, + status: result.success ? 'success' : 'error', + result: result.result, + error: result.error, + endTime: Date.now() + }; +} diff --git a/frontend/src/lib/tools/index.ts b/frontend/src/lib/tools/index.ts new file mode 100644 index 0000000..f5369f7 --- /dev/null +++ b/frontend/src/lib/tools/index.ts @@ -0,0 +1,22 @@ +/** + * Tools module exports + */ + +export * from './types.js'; +export { builtinTools, getBuiltinToolDefinitions } from './builtin.js'; +export { + toolRegistry, + parseToolCall, + runToolCall, + runToolCalls, + formatToolResultsForChat, + createToolCallState, + updateToolCallState +} from './executor.js'; +export { + PREFERRED_FUNCTION_MODEL, + USE_FUNCTION_MODEL, + getFunctionModel, + defaultToolConfig, + type ToolConfig +} from './config.js'; diff --git a/frontend/src/lib/tools/types.ts b/frontend/src/lib/tools/types.ts new file mode 100644 index 0000000..6bf15de --- /dev/null +++ b/frontend/src/lib/tools/types.ts @@ -0,0 +1,115 @@ +/** + * Tool system type definitions + * Compatible with Ollama's tool calling format + */ + +/** JSON Schema for tool parameters */ +export interface JSONSchema { + type: 'object' | 'string' | 'number' | 'boolean' | 'array'; + properties?: Record; + required?: string[]; + description?: string; +} + +export interface JSONSchemaProperty { + type: 'string' | 'number' | 'boolean' | 'array' | 'object'; + description?: string; + enum?: string[]; + items?: JSONSchemaProperty; + default?: unknown; +} + +/** Tool definition (Ollama format) */ +export interface ToolDefinition { + type: 'function'; + function: { + name: string; + description: string; + parameters: JSONSchema; + }; +} + +/** Tool call from model response */ +export interface ToolCall { + id: string; + function: { + name: string; + arguments: string; // JSON string + }; +} + +/** Parsed tool call with typed arguments */ +export interface ParsedToolCall> { + id: string; + name: string; + arguments: T; +} + +/** Tool execution result */ +export interface ToolResult { + toolCallId: string; + success: boolean; + result?: unknown; + error?: string; +} + +/** Tool implementation type */ +export type ToolImplementation = 'builtin' | 'javascript' | 'http'; + +/** Custom tool configuration */ +export interface CustomTool { + id: string; + name: string; + description: string; + parameters: JSONSchema; + implementation: ToolImplementation; + /** JavaScript code for 'javascript' implementation */ + code?: string; + /** HTTP endpoint for 'http' implementation */ + endpoint?: string; + /** HTTP method for 'http' implementation */ + httpMethod?: 'GET' | 'POST'; + /** Whether the tool is enabled */ + enabled: boolean; + /** Creation timestamp */ + createdAt: Date; + /** Last update timestamp */ + updatedAt: Date; +} + +/** Built-in tool handler function */ +export type BuiltinToolHandler> = ( + args: T +) => Promise | unknown; + +/** Tool registry entry */ +export interface ToolRegistryEntry { + definition: ToolDefinition; + handler: BuiltinToolHandler; + isBuiltin: boolean; +} + +/** Tool execution context */ +export interface ToolContext { + /** Conversation ID if in a chat context */ + conversationId?: string; + /** User ID if authenticated */ + userId?: string; + /** Additional metadata */ + metadata?: Record; +} + +/** Tool call status for UI display */ +export type ToolCallStatus = 'pending' | 'running' | 'success' | 'error'; + +/** Tool call display state */ +export interface ToolCallState { + id: string; + name: string; + arguments: Record; + status: ToolCallStatus; + result?: unknown; + error?: string; + startTime: number; + endTime?: number; +} diff --git a/frontend/src/lib/types/chat.ts b/frontend/src/lib/types/chat.ts new file mode 100644 index 0000000..97fdefb --- /dev/null +++ b/frontend/src/lib/types/chat.ts @@ -0,0 +1,40 @@ +/** + * Chat message types and tree structure definitions + */ + +/** Role of a message in a conversation */ +export type MessageRole = 'user' | 'assistant' | 'system' | 'tool'; + +/** Tool call information embedded in assistant messages */ +export interface ToolCall { + id: string; + name: string; + arguments: string; +} + +/** A single chat message */ +export interface Message { + role: MessageRole; + content: string; + images?: string[]; + toolCalls?: ToolCall[]; +} + +/** A node in the message tree structure (for branching conversations) */ +export interface MessageNode { + id: string; + message: Message; + parentId: string | null; + childIds: string[]; + createdAt: Date; +} + +/** Path through the message tree (array of message IDs) */ +export type BranchPath = string[]; + +/** Information about the current branch position */ +export interface BranchInfo { + currentIndex: number; + totalCount: number; + siblingIds: string[]; +} diff --git a/frontend/src/lib/types/conversation.ts b/frontend/src/lib/types/conversation.ts new file mode 100644 index 0000000..2747505 --- /dev/null +++ b/frontend/src/lib/types/conversation.ts @@ -0,0 +1,24 @@ +/** + * Conversation types for the chat application + */ + +import type { MessageNode, BranchPath } from './chat.js'; + +/** Basic conversation metadata */ +export interface Conversation { + id: string; + title: string; + model: string; + createdAt: Date; + updatedAt: Date; + isPinned: boolean; + isArchived: boolean; + messageCount: number; +} + +/** Full conversation including message tree and navigation state */ +export interface ConversationFull extends Conversation { + messages: Map; + activePath: BranchPath; + rootMessageId: string | null; +} diff --git a/frontend/src/lib/types/index.ts b/frontend/src/lib/types/index.ts new file mode 100644 index 0000000..6bb04dc --- /dev/null +++ b/frontend/src/lib/types/index.ts @@ -0,0 +1,7 @@ +/** + * Type exports + */ + +export * from './chat.js'; +export * from './conversation.js'; +export * from './model.js'; diff --git a/frontend/src/lib/types/model.ts b/frontend/src/lib/types/model.ts new file mode 100644 index 0000000..91171c3 --- /dev/null +++ b/frontend/src/lib/types/model.ts @@ -0,0 +1,34 @@ +/** + * Ollama model types matching the API response structure + */ + +/** Model details from Ollama API */ +export interface OllamaModelDetails { + parent_model: string; + format: string; + family: string; + families: string[] | null; + parameter_size: string; + quantization_level: string; +} + +/** Single model from Ollama API /api/tags response */ +export interface OllamaModel { + name: string; + model: string; + modified_at: string; + size: number; + digest: string; + details: OllamaModelDetails; +} + +/** Response from Ollama /api/tags endpoint */ +export interface OllamaModelsResponse { + models: OllamaModel[]; +} + +/** Grouped models by family */ +export interface ModelGroup { + family: string; + models: OllamaModel[]; +} diff --git a/frontend/src/lib/utils/export.ts b/frontend/src/lib/utils/export.ts new file mode 100644 index 0000000..255a4d8 --- /dev/null +++ b/frontend/src/lib/utils/export.ts @@ -0,0 +1,378 @@ +/** + * Export utilities for conversations + * Provides functions to export chat conversations as Markdown or JSON files + */ + +import type { Conversation } from '$lib/types/conversation.js'; +import type { MessageNode, BranchPath } from '$lib/types/chat.js'; + +/** Export format options */ +export type ExportFormat = 'markdown' | 'json'; + +/** Exported conversation data structure for JSON format */ +export interface ExportedConversation { + id: string; + title: string; + model: string; + createdAt: string; + exportedAt: string; + messages: ExportedMessage[]; +} + +/** Exported message structure for JSON format */ +export interface ExportedMessage { + role: string; + content: string; + timestamp: string; + images?: string[]; +} + +/** Shareable data structure (for URL-based sharing) */ +export interface ShareableData { + version: number; + title: string; + model: string; + messages: ExportedMessage[]; +} + +/** + * Convert message tree to ordered messages array following active path + * @param messageTree The full message tree + * @param activePath The active branch path to follow + * @returns Ordered array of message nodes + */ +function getOrderedMessages( + messageTree: Map, + activePath: BranchPath +): MessageNode[] { + const messages: MessageNode[] = []; + for (const id of activePath) { + const node = messageTree.get(id); + if (node) { + messages.push(node); + } + } + return messages; +} + +/** + * Escape markdown special characters in text that should be literal + * @param text Text to escape + * @returns Escaped text + */ +function escapeMarkdownInline(text: string): string { + // Don't escape content in code blocks - those are preserved as-is + return text; +} + +/** + * Format a date for display + * @param date Date to format + * @returns Formatted date string + */ +function formatDate(date: Date): string { + return date.toLocaleString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); +} + +/** + * Format a date as ISO string + * @param date Date to format + * @returns ISO date string + */ +function formatDateISO(date: Date): string { + return date.toISOString(); +} + +/** + * Export conversation as Markdown format + * @param conversation Conversation metadata + * @param messageTree Message tree map + * @param activePath Active branch path + * @returns Markdown string + */ +export function exportAsMarkdown( + conversation: Conversation, + messageTree: Map, + activePath: BranchPath +): string { + const messages = getOrderedMessages(messageTree, activePath); + const lines: string[] = []; + + // Header + lines.push(`# ${conversation.title}`); + lines.push(''); + lines.push(`**Model:** ${conversation.model}`); + lines.push(`**Created:** ${formatDate(conversation.createdAt)}`); + lines.push(`**Exported:** ${formatDate(new Date())}`); + lines.push(''); + lines.push('---'); + lines.push(''); + + // Messages + for (const node of messages) { + const { message, createdAt } = node; + const roleName = message.role === 'user' ? 'User' : message.role === 'assistant' ? 'Assistant' : message.role; + + lines.push(`## ${roleName}`); + lines.push(`*${formatDate(createdAt)}*`); + lines.push(''); + + // Content - preserve code blocks and formatting as-is + lines.push(message.content); + lines.push(''); + + // Images (if any) + if (message.images && message.images.length > 0) { + lines.push('**Attached Images:**'); + for (let i = 0; i < message.images.length; i++) { + lines.push(`- Image ${i + 1} (base64 data not included in export)`); + } + lines.push(''); + } + + lines.push('---'); + lines.push(''); + } + + return lines.join('\n'); +} + +/** + * Export conversation as JSON format + * @param conversation Conversation metadata + * @param messageTree Message tree map + * @param activePath Active branch path + * @returns JSON string + */ +export function exportAsJSON( + conversation: Conversation, + messageTree: Map, + activePath: BranchPath +): string { + const messages = getOrderedMessages(messageTree, activePath); + + const exportData: ExportedConversation = { + id: conversation.id, + title: conversation.title, + model: conversation.model, + createdAt: formatDateISO(conversation.createdAt), + exportedAt: formatDateISO(new Date()), + messages: messages.map((node) => ({ + role: node.message.role, + content: node.message.content, + timestamp: formatDateISO(node.createdAt), + ...(node.message.images && node.message.images.length > 0 && { + images: node.message.images + }) + })) + }; + + return JSON.stringify(exportData, null, 2); +} + +/** + * Generate preview of export content (first few lines) + * @param content Full export content + * @param maxLines Maximum lines to show + * @returns Preview string + */ +export function generatePreview(content: string, maxLines: number = 10): string { + const lines = content.split('\n'); + if (lines.length <= maxLines) { + return content; + } + return lines.slice(0, maxLines).join('\n') + '\n...'; +} + +/** + * Trigger file download in the browser + * @param content File content + * @param filename Filename with extension + * @param mimeType MIME type for the file + */ +export function downloadFile(content: string, filename: string, mimeType: string): void { + const blob = new Blob([content], { type: mimeType }); + const url = URL.createObjectURL(blob); + + const link = document.createElement('a'); + link.href = url; + link.download = filename; + link.style.display = 'none'; + + document.body.appendChild(link); + link.click(); + + // Cleanup + document.body.removeChild(link); + URL.revokeObjectURL(url); +} + +/** + * Generate a safe filename from conversation title + * @param title Conversation title + * @param extension File extension (without dot) + * @returns Safe filename + */ +export function generateFilename(title: string, extension: string): string { + // Remove or replace unsafe characters + const safeTitle = title + .replace(/[<>:"/\\|?*]/g, '') // Remove Windows-unsafe chars + .replace(/\s+/g, '_') // Replace spaces with underscores + .replace(/_+/g, '_') // Collapse multiple underscores + .replace(/^_|_$/g, '') // Trim leading/trailing underscores + .slice(0, 50); // Limit length + + const timestamp = new Date().toISOString().slice(0, 10); // YYYY-MM-DD + return `${safeTitle}_${timestamp}.${extension}`; +} + +/** + * Export and download conversation + * @param conversation Conversation metadata + * @param messageTree Message tree map + * @param activePath Active branch path + * @param format Export format + */ +export function exportConversation( + conversation: Conversation, + messageTree: Map, + activePath: BranchPath, + format: ExportFormat +): void { + let content: string; + let filename: string; + let mimeType: string; + + if (format === 'markdown') { + content = exportAsMarkdown(conversation, messageTree, activePath); + filename = generateFilename(conversation.title, 'md'); + mimeType = 'text/markdown'; + } else { + content = exportAsJSON(conversation, messageTree, activePath); + filename = generateFilename(conversation.title, 'json'); + mimeType = 'application/json'; + } + + downloadFile(content, filename, mimeType); +} + +/** + * Create shareable data for URL-based sharing + * @param conversation Conversation metadata + * @param messageTree Message tree map + * @param activePath Active branch path + * @returns Shareable data object + */ +export function createShareableData( + conversation: Conversation, + messageTree: Map, + activePath: BranchPath +): ShareableData { + const messages = getOrderedMessages(messageTree, activePath); + + return { + version: 1, + title: conversation.title, + model: conversation.model, + messages: messages.map((node) => ({ + role: node.message.role, + content: node.message.content, + timestamp: formatDateISO(node.createdAt) + // Note: Images excluded from share links to keep URL size manageable + })) + }; +} + +/** + * Encode shareable data to base64 for URL hash + * @param data Shareable data + * @returns Base64-encoded string + */ +export function encodeShareableData(data: ShareableData): string { + const json = JSON.stringify(data); + // Use encodeURIComponent for UTF-8 safety before btoa + return btoa(encodeURIComponent(json)); +} + +/** + * Decode shareable data from base64 URL hash + * @param encoded Base64-encoded string + * @returns Shareable data or null if invalid + */ +export function decodeShareableData(encoded: string): ShareableData | null { + try { + const json = decodeURIComponent(atob(encoded)); + const data = JSON.parse(json); + + // Basic validation + if (typeof data.version !== 'number' || !Array.isArray(data.messages)) { + return null; + } + + return data as ShareableData; + } catch { + return null; + } +} + +/** + * Generate share URL with conversation data in hash + * @param conversation Conversation metadata + * @param messageTree Message tree map + * @param activePath Active branch path + * @returns Share URL string + */ +export function generateShareUrl( + conversation: Conversation, + messageTree: Map, + activePath: BranchPath +): string { + const data = createShareableData(conversation, messageTree, activePath); + const encoded = encodeShareableData(data); + + // Use current origin and a share route + const baseUrl = typeof window !== 'undefined' ? window.location.origin : ''; + return `${baseUrl}/share#${encoded}`; +} + +/** + * Copy text to clipboard + * @param text Text to copy + * @returns Promise that resolves when copied, rejects on error + */ +export async function copyToClipboard(text: string): Promise { + if (navigator.clipboard && navigator.clipboard.writeText) { + return navigator.clipboard.writeText(text); + } + + // Fallback for older browsers + return new Promise((resolve, reject) => { + const textArea = document.createElement('textarea'); + textArea.value = text; + textArea.style.position = 'fixed'; + textArea.style.left = '-9999px'; + textArea.style.top = '-9999px'; + document.body.appendChild(textArea); + textArea.focus(); + textArea.select(); + + try { + const successful = document.execCommand('copy'); + document.body.removeChild(textArea); + if (successful) { + resolve(); + } else { + reject(new Error('Copy command failed')); + } + } catch (err) { + document.body.removeChild(textArea); + reject(err); + } + }); +} diff --git a/frontend/src/lib/utils/index.ts b/frontend/src/lib/utils/index.ts new file mode 100644 index 0000000..505283f --- /dev/null +++ b/frontend/src/lib/utils/index.ts @@ -0,0 +1,32 @@ +/** + * Utils index + * Re-exports all utility functions + */ + +export { + type ExportFormat, + type ExportedConversation, + type ExportedMessage, + type ShareableData, + exportAsMarkdown, + exportAsJSON, + generatePreview, + downloadFile, + generateFilename, + exportConversation, + createShareableData, + encodeShareableData, + decodeShareableData, + generateShareUrl, + copyToClipboard +} from './export.js'; + +export { + keyboardShortcuts, + isPrimaryModifier, + getPrimaryModifierDisplay, + formatShortcut, + getShortcuts, + type Shortcut, + type Modifiers +} from './keyboard.js'; diff --git a/frontend/src/lib/utils/keyboard.ts b/frontend/src/lib/utils/keyboard.ts new file mode 100644 index 0000000..c2e44f9 --- /dev/null +++ b/frontend/src/lib/utils/keyboard.ts @@ -0,0 +1,246 @@ +/** + * Keyboard shortcuts management + * + * Provides a centralized system for registering and handling keyboard shortcuts. + */ + +/** Modifier keys */ +export interface Modifiers { + ctrl?: boolean; + alt?: boolean; + shift?: boolean; + meta?: boolean; // Cmd on Mac, Win on Windows +} + +/** Shortcut definition */ +export interface Shortcut { + /** Unique identifier for the shortcut */ + id: string; + /** The key to listen for (e.g., 'k', 'n', 'Escape') */ + key: string; + /** Required modifier keys */ + modifiers?: Modifiers; + /** Human-readable description */ + description: string; + /** Handler function */ + handler: (event: KeyboardEvent) => void; + /** Whether to prevent default behavior */ + preventDefault?: boolean; + /** Whether the shortcut is currently enabled */ + enabled?: boolean; +} + +/** Platform detection - evaluated lazily to work in browser */ +function getIsMac(): boolean { + if (typeof navigator === 'undefined') return false; + return /Mac|iPod|iPhone|iPad/.test(navigator.platform); +} + +/** Cached isMac value */ +let _isMac: boolean | null = null; +const isMac = (): boolean => { + if (_isMac === null) { + _isMac = getIsMac(); + } + return _isMac; +}; + +/** + * Check if the primary modifier is pressed (Cmd on Mac, Ctrl on others) + */ +export function isPrimaryModifier(event: KeyboardEvent): boolean { + return isMac() ? event.metaKey : event.ctrlKey; +} + +/** + * Get the display string for the primary modifier + */ +export function getPrimaryModifierDisplay(): string { + return isMac() ? '⌘' : 'Ctrl'; +} + +/** + * Format a shortcut for display + */ +export function formatShortcut(key: string, modifiers?: Modifiers): string { + const parts: string[] = []; + + if (modifiers?.ctrl) parts.push('Ctrl'); + if (modifiers?.alt) parts.push(isMac() ? '⌥' : 'Alt'); + if (modifiers?.shift) parts.push(isMac() ? '⇧' : 'Shift'); + if (modifiers?.meta) parts.push(isMac() ? '⌘' : 'Win'); + + // Format special keys + const keyDisplay = key.length === 1 ? key.toUpperCase() : key; + parts.push(keyDisplay); + + return parts.join(isMac() ? '' : '+'); +} + +/** + * Check if modifiers match the event + */ +function modifiersMatch(event: KeyboardEvent, modifiers?: Modifiers): boolean { + const ctrl = modifiers?.ctrl ?? false; + const alt = modifiers?.alt ?? false; + const shift = modifiers?.shift ?? false; + const meta = modifiers?.meta ?? false; + + return ( + event.ctrlKey === ctrl && + event.altKey === alt && + event.shiftKey === shift && + event.metaKey === meta + ); +} + +/** + * Keyboard shortcuts manager + */ +class KeyboardShortcutsManager { + private shortcuts: Map = new Map(); + private enabled = true; + private boundHandler: (event: KeyboardEvent) => void; + + constructor() { + this.boundHandler = this.handleKeydown.bind(this); + } + + /** + * Initialize the manager (call once in browser) + */ + initialize(): void { + if (typeof window === 'undefined') return; + window.addEventListener('keydown', this.boundHandler); + } + + /** + * Destroy the manager + */ + destroy(): void { + if (typeof window === 'undefined') return; + window.removeEventListener('keydown', this.boundHandler); + this.shortcuts.clear(); + } + + /** + * Register a shortcut + */ + register(shortcut: Shortcut): void { + this.shortcuts.set(shortcut.id, { ...shortcut, enabled: shortcut.enabled ?? true }); + } + + /** + * Unregister a shortcut + */ + unregister(id: string): void { + this.shortcuts.delete(id); + } + + /** + * Enable or disable a specific shortcut + */ + setEnabled(id: string, enabled: boolean): void { + const shortcut = this.shortcuts.get(id); + if (shortcut) { + shortcut.enabled = enabled; + } + } + + /** + * Enable or disable all shortcuts + */ + setGlobalEnabled(enabled: boolean): void { + this.enabled = enabled; + } + + /** + * Get all registered shortcuts + */ + getShortcuts(): Shortcut[] { + return Array.from(this.shortcuts.values()); + } + + /** + * Handle keydown events + */ + private handleKeydown(event: KeyboardEvent): void { + if (!this.enabled) return; + + // Don't trigger shortcuts when typing in inputs (unless it's Escape) + const target = event.target as HTMLElement; + const isInput = target.tagName === 'INPUT' || + target.tagName === 'TEXTAREA' || + target.isContentEditable; + + if (isInput && event.key !== 'Escape') return; + + // Find matching shortcut + for (const shortcut of this.shortcuts.values()) { + if (!shortcut.enabled) continue; + + const keyMatches = event.key.toLowerCase() === shortcut.key.toLowerCase(); + if (!keyMatches) continue; + + if (!modifiersMatch(event, shortcut.modifiers)) continue; + + // Found a match + if (shortcut.preventDefault !== false) { + event.preventDefault(); + } + shortcut.handler(event); + return; + } + } +} + +/** Singleton instance */ +export const keyboardShortcuts = new KeyboardShortcutsManager(); + +/** + * Get platform-aware primary modifier (Cmd on Mac, Ctrl on others) + */ +function getPrimaryModifiers(): Modifiers { + return isMac() ? { meta: true } : { ctrl: true }; +} + +/** + * Common shortcut definitions - function to ensure lazy evaluation + */ +export function getShortcuts() { + return { + NEW_CHAT: { + id: 'new-chat', + key: 'n', + modifiers: getPrimaryModifiers(), + description: 'New chat' + }, + SEARCH: { + id: 'search', + key: 'k', + modifiers: getPrimaryModifiers(), + description: 'Search conversations' + }, + TOGGLE_SIDENAV: { + id: 'toggle-sidenav', + key: 'b', + modifiers: getPrimaryModifiers(), + description: 'Toggle sidebar' + }, + CLOSE_MODAL: { + id: 'close-modal', + key: 'Escape', + description: 'Close modal/dialog' + }, + SEND_MESSAGE: { + id: 'send-message', + key: 'Enter', + description: 'Send message' + }, + STOP_GENERATION: { + id: 'stop-generation', + key: 'Escape', + description: 'Stop generation' + } + } as const; +} diff --git a/frontend/src/routes/+error.svelte b/frontend/src/routes/+error.svelte new file mode 100644 index 0000000..5fa5770 --- /dev/null +++ b/frontend/src/routes/+error.svelte @@ -0,0 +1,155 @@ + + +
+
+ +
+ +
+ + + {#if status} +

+ {status} +

+ {/if} + + +

+ {errorTitle} +

+ + +

+ {errorMessage} +

+ + +
+ + + +
+ + +

+ If the problem persists, please check that Ollama is running and accessible. +

+
+
diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte new file mode 100644 index 0000000..5a8b98d --- /dev/null +++ b/frontend/src/routes/+layout.svelte @@ -0,0 +1,159 @@ + + +
+ + + + +
+ +
+ + {#snippet modelSelect()} + + {/snippet} + +
+ + +
+ {@render children()} +
+
+
+ + + diff --git a/frontend/src/routes/+layout.ts b/frontend/src/routes/+layout.ts new file mode 100644 index 0000000..cf3408f --- /dev/null +++ b/frontend/src/routes/+layout.ts @@ -0,0 +1,33 @@ +/** + * Root layout load function + * Preloads models list from Ollama API + */ + +import type { LayoutLoad } from './$types'; + +export const load: LayoutLoad = async ({ fetch }) => { + try { + // Use proxied API path (vite.config.ts proxies /api to Ollama) + const response = await fetch('/api/tags'); + + if (!response.ok) { + console.error('Failed to fetch models:', response.status, response.statusText); + return { models: [], modelsError: `Failed to fetch models: ${response.statusText}` }; + } + + const data = await response.json(); + return { + models: data.models ?? [], + modelsError: null + }; + } catch (error) { + console.error('Error fetching models:', error); + return { + models: [], + modelsError: error instanceof Error ? error.message : 'Failed to connect to Ollama' + }; + } +}; + +// Enable client-side rendering +export const ssr = false; diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte new file mode 100644 index 0000000..0fa3952 --- /dev/null +++ b/frontend/src/routes/+page.svelte @@ -0,0 +1,266 @@ + + +
+ +
diff --git a/frontend/src/routes/chat/+page.ts b/frontend/src/routes/chat/+page.ts new file mode 100644 index 0000000..c8e108f --- /dev/null +++ b/frontend/src/routes/chat/+page.ts @@ -0,0 +1,9 @@ +/** + * Redirect /chat to home page (new chat) + */ +import { redirect } from '@sveltejs/kit'; +import type { PageLoad } from './$types'; + +export const load: PageLoad = () => { + redirect(307, '/'); +}; diff --git a/frontend/src/routes/chat/[id]/+page.svelte b/frontend/src/routes/chat/[id]/+page.svelte new file mode 100644 index 0000000..089eaa9 --- /dev/null +++ b/frontend/src/routes/chat/[id]/+page.svelte @@ -0,0 +1,136 @@ + + +
+ {#if isLoading} + +
+
+
+ +
+

Loading conversation...

+
+
+ {:else} + + + {/if} +
diff --git a/frontend/src/routes/chat/[id]/+page.ts b/frontend/src/routes/chat/[id]/+page.ts new file mode 100644 index 0000000..a9bf659 --- /dev/null +++ b/frontend/src/routes/chat/[id]/+page.ts @@ -0,0 +1,27 @@ +/** + * Chat page load function + * Validates conversation ID and returns it for the page + */ + +import { error } from '@sveltejs/kit'; +import type { PageLoad } from './$types'; + +export const load: PageLoad = async ({ params }) => { + const { id } = params; + + // Validate that ID looks like a UUID + const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + + if (!id || !uuidPattern.test(id)) { + throw error(404, { + message: 'Invalid conversation ID' + }); + } + + // TODO: In the future, load conversation from IndexedDB here + // For now, just return the ID and let the page component handle state + + return { + conversationId: id + }; +}; diff --git a/frontend/src/routes/knowledge/+page.svelte b/frontend/src/routes/knowledge/+page.svelte new file mode 100644 index 0000000..bf949c5 --- /dev/null +++ b/frontend/src/routes/knowledge/+page.svelte @@ -0,0 +1,291 @@ + + +
+
+ +
+

Knowledge Base

+

+ Upload documents to enhance AI responses with your own knowledge +

+
+ + +
+
+

Documents

+

{stats.documentCount}

+
+
+

Chunks

+

{stats.chunkCount}

+
+
+

Total Tokens

+

{formatTokenCount(stats.totalTokens)}

+
+
+ + +
+
+

Upload Documents

+ +
+ + + + + +
+ + +
+

Documents

+ + {#if isLoading} +
+ + + + +
+ {:else if documents.length === 0} +
+ + + +

No documents yet

+

+ Upload documents to build your knowledge base +

+
+ {:else} +
+ {#each documents as doc (doc.id)} +
+
+ + + +
+

{doc.name}

+

+ {formatSize(doc.size)} · {doc.chunkCount} chunks · Added {formatDate(doc.createdAt)} +

+
+
+ + +
+ {/each} +
+ {/if} +
+ + +
+

+ + + + How RAG Works +

+

+ Documents are split into chunks and converted to embeddings (numerical representations). + When you ask a question, relevant chunks are found by similarity search and included + in the AI's context to provide more accurate, grounded responses. +

+

+ Note: Requires an embedding model to be installed + in Ollama (e.g., ollama pull nomic-embed-text). +

+
+
+
diff --git a/frontend/src/routes/tools/+page.svelte b/frontend/src/routes/tools/+page.svelte new file mode 100644 index 0000000..6f16dd7 --- /dev/null +++ b/frontend/src/routes/tools/+page.svelte @@ -0,0 +1,232 @@ + + +
+
+ +
+
+

Tools

+

+ Manage tools available to the AI during conversations +

+
+ + +
+ Tools enabled + +
+
+ + +
+

+ + + + + Built-in Tools +

+ +
+ {#each builtinTools as tool (tool.definition.function.name)} +
+
+
+
+

+ {tool.definition.function.name} +

+ + built-in + +
+

+ {tool.definition.function.description} +

+

+ Parameters: {formatParameters(tool.definition)} +

+
+ + +
+
+ {/each} +
+
+ + +
+
+

+ + + + Custom Tools +

+ + +
+ + {#if customTools.length === 0} +
+ + + +

No custom tools yet

+

+ Create custom tools to extend AI capabilities +

+
+ {:else} +
+ {#each customTools as tool (tool.definition.function.name)} +
+
+
+
+

+ {tool.definition.function.name} +

+ + custom + +
+

+ {tool.definition.function.description} +

+
+ +
+ + + +
+
+
+ {/each} +
+ {/if} +
+ + +
+

+ + + + How Tools Work +

+

+ Tools extend the AI's capabilities by allowing it to perform actions like calculations, + fetching web content, or getting the current time. When you ask a question that could + benefit from a tool, the AI will automatically use the appropriate tool and include + the results in its response. +

+

+ Note: Not all models support tool calling. + Models like Llama 3.1+ and Mistral 7B+ have built-in tool support. +

+
+
+
diff --git a/frontend/static/favicon.svg b/frontend/static/favicon.svg new file mode 100644 index 0000000..5c89ff9 --- /dev/null +++ b/frontend/static/favicon.svg @@ -0,0 +1 @@ +O diff --git a/frontend/svelte.config.js b/frontend/svelte.config.js new file mode 100644 index 0000000..6e813e4 --- /dev/null +++ b/frontend/svelte.config.js @@ -0,0 +1,17 @@ +import adapter from '@sveltejs/adapter-node'; +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + preprocess: vitePreprocess(), + + kit: { + adapter: adapter(), + alias: { + $lib: 'src/lib', + $components: 'src/lib/components' + } + } +}; + +export default config; diff --git a/frontend/tailwind.config.ts b/frontend/tailwind.config.ts new file mode 100644 index 0000000..98dd34b --- /dev/null +++ b/frontend/tailwind.config.ts @@ -0,0 +1,22 @@ +import { skeleton } from '@skeletonlabs/tw-plugin'; +import typography from '@tailwindcss/typography'; +import type { Config } from 'tailwindcss'; + +export default { + darkMode: 'class', + content: [ + './src/**/*.{html,js,svelte,ts}', + './node_modules/@skeletonlabs/skeleton/**/*.{html,js,svelte,ts}' + ], + theme: { + extend: {} + }, + plugins: [ + typography, + skeleton({ + themes: { + preset: ['skeleton', 'wintry', 'modern', 'crimson'] + } + }) + ] +} satisfies Config; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..a8f10c8 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..61c9488 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,30 @@ +import { sveltekit } from '@sveltejs/kit/vite'; +import { defineConfig } from 'vite'; + +// Use environment variable or default to localhost (works with host network mode) +const ollamaUrl = process.env.OLLAMA_API_URL || 'http://localhost:11434'; +const backendUrl = process.env.BACKEND_URL || 'http://localhost:9090'; + +export default defineConfig({ + plugins: [sveltekit()], + server: { + port: 7842, + proxy: { + // Backend health check + '/health': { + target: backendUrl, + changeOrigin: true + }, + // Go backend API (must be before /api to match first) + '/api/v1': { + target: backendUrl, + changeOrigin: true + }, + // Ollama API + '/api': { + target: ollamaUrl, + changeOrigin: true + } + } + } +});